From efb44fd179fe17d74712bd92ea8666297867f696 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 3 Aug 2026 20:10:44 +0000 Subject: [PATCH 1/7] feat(common): parse the immutable brand identity artifact v1 --- .../brand-identity-v1-android.json | 15 ++ .../__fixtures__/brand-identity-v1-ios.json | 15 ++ .../brand-identity-v1-zenith-canary.json | 15 ++ .../__fixtures__/brand-identity-v1.json | 15 ++ .../config/__tests__/brand-identity.test.ts | 254 ++++++++++++++++++ .../common/src/config/brand-identity.ts | 186 +++++++++++++ .../foundation/common/src/config/index.ts | 7 + 7 files changed, 507 insertions(+) create mode 100644 packages/foundation/common/src/config/__fixtures__/brand-identity-v1-android.json create mode 100644 packages/foundation/common/src/config/__fixtures__/brand-identity-v1-ios.json create mode 100644 packages/foundation/common/src/config/__fixtures__/brand-identity-v1-zenith-canary.json create mode 100644 packages/foundation/common/src/config/__fixtures__/brand-identity-v1.json create mode 100644 packages/foundation/common/src/config/__tests__/brand-identity.test.ts create mode 100644 packages/foundation/common/src/config/brand-identity.ts diff --git a/packages/foundation/common/src/config/__fixtures__/brand-identity-v1-android.json b/packages/foundation/common/src/config/__fixtures__/brand-identity-v1-android.json new file mode 100644 index 00000000..2e8b86aa --- /dev/null +++ b/packages/foundation/common/src/config/__fixtures__/brand-identity-v1-android.json @@ -0,0 +1,15 @@ +{ + "applicationId": "dev.arcbox.acme", + "assetsPath": "brands/acme", + "brandId": "acme", + "brandIdentityVersion": 1, + "channel": "stable", + "displayName": "Acme Studio", + "platform": "android", + "provenance": { + "manifestSchemaVersion": 1, + "sourceGitSha": "0123456789abcdef0123456789abcdef01234567" + }, + "storageNamespace": "Acme Studio", + "urlScheme": "acme" +} diff --git a/packages/foundation/common/src/config/__fixtures__/brand-identity-v1-ios.json b/packages/foundation/common/src/config/__fixtures__/brand-identity-v1-ios.json new file mode 100644 index 00000000..e1c837c8 --- /dev/null +++ b/packages/foundation/common/src/config/__fixtures__/brand-identity-v1-ios.json @@ -0,0 +1,15 @@ +{ + "applicationId": "dev.arcbox.acme", + "assetsPath": "brands/acme", + "brandId": "acme", + "brandIdentityVersion": 1, + "channel": "stable", + "displayName": "Acme Studio", + "platform": "ios", + "provenance": { + "manifestSchemaVersion": 1, + "sourceGitSha": "0123456789abcdef0123456789abcdef01234567" + }, + "storageNamespace": "Acme Studio", + "urlScheme": "acme" +} diff --git a/packages/foundation/common/src/config/__fixtures__/brand-identity-v1-zenith-canary.json b/packages/foundation/common/src/config/__fixtures__/brand-identity-v1-zenith-canary.json new file mode 100644 index 00000000..d3278a9c --- /dev/null +++ b/packages/foundation/common/src/config/__fixtures__/brand-identity-v1-zenith-canary.json @@ -0,0 +1,15 @@ +{ + "applicationId": "dev.arcbox.zenith.desktop.canary", + "assetsPath": "brands/zenith", + "brandId": "zenith", + "brandIdentityVersion": 1, + "channel": "canary", + "displayName": "Zenith Workspace Canary", + "platform": "desktop", + "provenance": { + "manifestSchemaVersion": 1, + "sourceGitSha": "0123456789abcdef0123456789abcdef01234567" + }, + "storageNamespace": "Zenith Workspace Canary", + "urlScheme": "zenith-canary" +} diff --git a/packages/foundation/common/src/config/__fixtures__/brand-identity-v1.json b/packages/foundation/common/src/config/__fixtures__/brand-identity-v1.json new file mode 100644 index 00000000..b3382a21 --- /dev/null +++ b/packages/foundation/common/src/config/__fixtures__/brand-identity-v1.json @@ -0,0 +1,15 @@ +{ + "applicationId": "dev.arcbox.acme.desktop", + "assetsPath": "brands/acme", + "brandId": "acme", + "brandIdentityVersion": 1, + "channel": "stable", + "displayName": "Acme Studio", + "platform": "desktop", + "provenance": { + "manifestSchemaVersion": 1, + "sourceGitSha": "0123456789abcdef0123456789abcdef01234567" + }, + "storageNamespace": "Acme Studio", + "urlScheme": "acme" +} diff --git a/packages/foundation/common/src/config/__tests__/brand-identity.test.ts b/packages/foundation/common/src/config/__tests__/brand-identity.test.ts new file mode 100644 index 00000000..c471fa57 --- /dev/null +++ b/packages/foundation/common/src/config/__tests__/brand-identity.test.ts @@ -0,0 +1,254 @@ +import { readFile } from 'node:fs/promises'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { describe, expect, it } from 'vitest'; +import fixture from '../__fixtures__/brand-identity-v1.json'; +import fixtureAndroid from '../__fixtures__/brand-identity-v1-android.json'; +import fixtureIos from '../__fixtures__/brand-identity-v1-ios.json'; +import fixtureZenithCanary from '../__fixtures__/brand-identity-v1-zenith-canary.json'; +import bundleFixture from '../__fixtures__/build-bundle-v1.json'; +import { assertBrandIdentityMatchesBundle, parseBrandIdentityArtifact } from '../brand-identity'; +import { parseConfigBuildBundle } from '../build-bundle'; + +// Frozen publisher fixture bytes (config-publisher fixtures/brand-identity-v1*.json). Never edit +// the vendored copies; re-vendor from the publisher and update the digests together. +const FIXTURE_SHA256 = { + android: '5a3339557869a5ba44c4b43f6c1b401522fe78203431f803b1db2f84a90f0f2d', + desktop: 'a5894d069644597b470551e4c6e1cd53c5b9c19316226b1c3ea1de22607d9dad', + ios: '1e0190690315416b16d5976d3f54847f20efa487e3efd938d9c8d7cc6598cd05', + 'zenith-canary': 'a1990f62f8848f303664859d3b9fd52958854db2aad60be982980dd4a8a64090', +} as const; + +function toHex(bytes: Uint8Array): string { + let hex = ''; + for (const byte of bytes) hex += byte.toString(16).padStart(2, '0'); + return hex; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- tampering requires loose writes +function mutate(change: (artifact: Record) => void): unknown { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- tampering requires loose writes + const clone = structuredClone(fixture) as Record; + change(clone); + return clone; +} + +describe('brand identity v1 vendored fixtures', () => { + it('matches the frozen publisher bytes exactly', async () => { + for (const [name, digest] of Object.entries(FIXTURE_SHA256)) { + const suffix = name === 'desktop' ? '' : `-${name}`; + // eslint-disable-next-line no-await-in-loop -- four small reads + const bytes = await readFile( + new URL(`../__fixtures__/brand-identity-v1${suffix}.json`, import.meta.url), + ); + expect(toHex(sha256(bytes)), name).toBe(digest); + } + }); + + it('validates every fixture and preserves its target', () => { + for (const [raw, brandId, platform, channel] of [ + [fixture, 'acme', 'desktop', 'stable'], + [fixtureIos, 'acme', 'ios', 'stable'], + [fixtureAndroid, 'acme', 'android', 'stable'], + [fixtureZenithCanary, 'zenith', 'desktop', 'canary'], + ] as const) { + const identity = parseBrandIdentityArtifact(structuredClone(raw)); + expect(identity.brandId).toBe(brandId); + expect(identity.platform).toBe(platform); + expect(identity.channel).toBe(channel); + } + }); + + it('keeps the two brands disjoint on every OS-visible identifier', () => { + const acme = parseBrandIdentityArtifact(structuredClone(fixture)); + const zenith = parseBrandIdentityArtifact(structuredClone(fixtureZenithCanary)); + expect(acme.applicationId).not.toBe(zenith.applicationId); + expect(acme.urlScheme).not.toBe(zenith.urlScheme); + expect(acme.storageNamespace).not.toBe(zenith.storageNamespace); + expect(acme.assetsPath).not.toBe(zenith.assetsPath); + expect(acme.displayName).not.toBe(zenith.displayName); + }); +}); + +describe('parseBrandIdentityArtifact', () => { + it('rejects structural tampering', () => { + expect(() => parseBrandIdentityArtifact(null)).toThrow('must be an object'); + expect(() => + parseBrandIdentityArtifact( + mutate((artifact) => { + artifact.extra = true; + }), + ), + ).toThrow('unsupported field extra'); + expect(() => + parseBrandIdentityArtifact( + mutate((artifact) => { + delete artifact.storageNamespace; + }), + ), + ).toThrow('missing field storageNamespace'); + expect(() => + parseBrandIdentityArtifact( + mutate((artifact) => { + artifact.brandIdentityVersion = 2; + }), + ), + ).toThrow('unsupported'); + expect(() => + parseBrandIdentityArtifact( + mutate((artifact) => { + artifact.provenance.extra = true; + }), + ), + ).toThrow('unsupported field extra'); + }); + + it('rejects malformed identifiers, names, paths, and provenance', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- tampering requires loose writes + const cases: Array<[(artifact: Record) => void, string]> = [ + [ + (artifact) => { + artifact.brandId = 'Acme'; + }, + 'brandId is invalid', + ], + [ + (artifact) => { + artifact.platform = 'windows'; + }, + 'platform is invalid', + ], + [ + (artifact) => { + artifact.channel = 'beta'; + }, + 'channel is invalid', + ], + [ + (artifact) => { + artifact.applicationId = 'singlesegment'; + }, + 'at least two segments', + ], + [ + (artifact) => { + artifact.applicationId = 'dev..acme'; + }, + 'segment', + ], + [ + (artifact) => { + artifact.displayName = ' Acme'; + }, + 'whitespace', + ], + [ + (artifact) => { + artifact.displayName = 'Acme\u{7}Studio'; + }, + 'control characters', + ], + [ + (artifact) => { + artifact.storageNamespace = 'Acme/Studio'; + }, + 'Windows-reserved', + ], + [ + (artifact) => { + artifact.storageNamespace = 'Acme Studio.'; + }, + 'end with a dot', + ], + [ + (artifact) => { + artifact.urlScheme = '1acme'; + }, + 'urlScheme is invalid', + ], + [ + (artifact) => { + artifact.assetsPath = '/brands/acme'; + }, + 'forward-slash relative path', + ], + [ + (artifact) => { + artifact.assetsPath = 'brands/../acme'; + }, + 'parent segments', + ], + [ + (artifact) => { + artifact.assetsPath = String.raw`brands\acme`; + }, + 'forward-slash relative path', + ], + [ + (artifact) => { + artifact.provenance.sourceGitSha = 'not-a-sha'; + }, + 'sourceGitSha', + ], + [ + (artifact) => { + artifact.provenance.manifestSchemaVersion = 0; + }, + 'manifestSchemaVersion', + ], + ]; + for (const [change, message] of cases) { + expect(() => parseBrandIdentityArtifact(mutate(change))).toThrow(message); + } + }); + + it('enforces platform-specific application id rules', () => { + // Dashes are legal in Apple/desktop ids but never in Android application ids. + const android = structuredClone(fixtureAndroid) as Record; + android.applicationId = 'dev.arc-box.acme'; + expect(() => parseBrandIdentityArtifact(android)).toThrow('invalid for android'); + expect(() => + parseBrandIdentityArtifact( + mutate((artifact) => { + artifact.applicationId = 'dev.arc-box.acme'; + }), + ), + ).not.toThrow(); + }); +}); + +describe('assertBrandIdentityMatchesBundle', () => { + const bundle = parseConfigBuildBundle(structuredClone(bundleFixture)); + + it('accepts an identity for the same target and source commit', () => { + const identity = parseBrandIdentityArtifact( + mutate((artifact) => { + artifact.provenance.sourceGitSha = bundle.provenance.sourceGitSha; + }), + ); + expect(() => assertBrandIdentityMatchesBundle(identity, bundle)).not.toThrow(); + }); + + it('rejects a cross-target identity', () => { + const identity = parseBrandIdentityArtifact( + mutate((artifact) => { + artifact.brandId = 'zenith'; + artifact.provenance.sourceGitSha = bundle.provenance.sourceGitSha; + }), + ); + expect(() => assertBrandIdentityMatchesBundle(identity, bundle)).toThrow( + 'identity targets zenith/desktop/stable', + ); + }); + + it('rejects source commit drift between the two artifacts', () => { + const identity = parseBrandIdentityArtifact( + mutate((artifact) => { + artifact.provenance.sourceGitSha = 'feedfacefeedfacefeedfacefeedfacefeedface'; + }), + ); + expect(identity.provenance.sourceGitSha).not.toBe(bundle.provenance.sourceGitSha); + expect(() => assertBrandIdentityMatchesBundle(identity, bundle)).toThrow( + 'regenerate both from the same pinned commit', + ); + }); +}); diff --git a/packages/foundation/common/src/config/brand-identity.ts b/packages/foundation/common/src/config/brand-identity.ts new file mode 100644 index 00000000..c73ecc59 --- /dev/null +++ b/packages/foundation/common/src/config/brand-identity.ts @@ -0,0 +1,186 @@ +// Client half of the frozen brand identity artifact v1 (publisher CONTRACT.md "Brand identity +// artifact v1"). Validation only — derivation stays in the publisher; never reimplement it here. +import type { ConfigBuildBundle } from './build-bundle'; +import { isRecord } from './contract'; +import type { ConfigChannel, ConfigPlatform } from './types'; +import { CONFIG_CHANNELS, CONFIG_PLATFORMS } from './types'; + +export const BRAND_IDENTITY_VERSION = 1; + +export interface BrandIdentityProvenance { + readonly manifestSchemaVersion: number; + readonly sourceGitSha: string; +} + +/** Resolved build identity for exactly one brand/platform/channel target. Every field is final: + * build tooling consumes it verbatim and never re-derives identity from the brand manifest. */ +export interface BrandIdentityArtifact { + readonly applicationId: string; + readonly assetsPath: string; + readonly brandId: string; + readonly brandIdentityVersion: 1; + readonly channel: ConfigChannel; + readonly displayName: string; + readonly platform: ConfigPlatform; + readonly provenance: BrandIdentityProvenance; + readonly storageNamespace: string; + readonly urlScheme: string; +} + +const ARTIFACT_KEYS = new Set([ + 'applicationId', + 'assetsPath', + 'brandId', + 'brandIdentityVersion', + 'channel', + 'displayName', + 'platform', + 'provenance', + 'storageNamespace', + 'urlScheme', +]); +const PROVENANCE_KEYS = new Set(['manifestSchemaVersion', 'sourceGitSha']); + +const RE_BRAND_ID = /^[a-z][a-z0-9-]{0,62}$/; +const RE_SOURCE_GIT_SHA = /^[0-9a-f]{40}$/; +const RE_URL_SCHEME = /^[a-z][a-z0-9+.-]*$/; +// Android application ids reject dashes and uppercase; Apple/desktop ids allow dashes. +const RE_ANDROID_ID_SEGMENT = /^[a-z][a-z0-9_]*$/; +const RE_APPLE_ID_SEGMENT = /^[a-z][a-z0-9-]*$/i; +// eslint-disable-next-line no-control-regex -- rejecting control characters is the point +const RE_CONTROL_CHARS = /[\u0000-\u001F\u007F]/; +const RE_STORAGE_FORBIDDEN = /[<>:"/\\|?*]/; + +const CONFIG_PLATFORM_SET = new Set(CONFIG_PLATFORMS); +const CONFIG_CHANNEL_SET = new Set(CONFIG_CHANNELS); + +const MAX_DISPLAY_NAME_LENGTH = 80; +const MAX_APPLICATION_ID_LENGTH = 155; + +function fail(message: string): never { + throw new TypeError(message); +} + +function requireExactKeys( + value: Record, + allowed: ReadonlySet, + label: string, +): void { + for (const key of Object.keys(value)) { + if (!allowed.has(key)) fail(`${label} contains unsupported field ${key}`); + } + for (const key of allowed) { + if (!(key in value)) fail(`${label} is missing field ${key}`); + } +} + +function assertApplicationId(value: string, platform: ConfigPlatform, label: string): void { + if (value.length > MAX_APPLICATION_ID_LENGTH) fail(`${label} is too long`); + const segments = value.split('.'); + if (segments.length < 2) fail(`${label} must contain at least two segments`); + const segmentRule = platform === 'android' ? RE_ANDROID_ID_SEGMENT : RE_APPLE_ID_SEGMENT; + for (const segment of segments) { + if (!segmentRule.test(segment)) { + fail(`${label} segment ${segment || '(empty)'} is invalid for ${platform}`); + } + } +} + +function assertDisplayName(value: string, label: string): void { + if (value.length === 0 || value.length > MAX_DISPLAY_NAME_LENGTH) { + fail(`${label} must be 1..${MAX_DISPLAY_NAME_LENGTH} characters`); + } + if (RE_CONTROL_CHARS.test(value)) fail(`${label} must not contain control characters`); + if (value !== value.trim()) fail(`${label} must not have leading or trailing whitespace`); +} + +function assertStorageNamespace(value: string, label: string): void { + assertDisplayName(value, label); + if (RE_STORAGE_FORBIDDEN.test(value)) { + fail(`${label} must not contain path or Windows-reserved characters`); + } + if (value.endsWith('.')) fail(`${label} must not end with a dot`); + if (value === '.' || value === '..') fail(`${label} must not be a relative path segment`); +} + +function assertAssetsPath(value: string, label: string): void { + if (value.length === 0) fail(`${label} must not be empty`); + if (value[0] === '/' || value.includes('\\')) { + fail(`${label} must be a forward-slash relative path`); + } + for (const segment of value.split('/')) { + if (segment === '' || segment === '.' || segment === '..') { + fail(`${label} must not contain empty, dot, or parent segments`); + } + } +} + +export function assertBrandIdentityArtifact( + value: unknown, +): asserts value is BrandIdentityArtifact { + if (!isRecord(value)) fail('artifact must be an object'); + requireExactKeys(value, ARTIFACT_KEYS, 'artifact'); + if (value.brandIdentityVersion !== BRAND_IDENTITY_VERSION) { + fail('artifact.brandIdentityVersion is unsupported'); + } + if (typeof value.brandId !== 'string' || !RE_BRAND_ID.test(value.brandId)) { + fail('artifact.brandId is invalid'); + } + if (typeof value.platform !== 'string' || !CONFIG_PLATFORM_SET.has(value.platform)) { + fail('artifact.platform is invalid'); + } + if (typeof value.channel !== 'string' || !CONFIG_CHANNEL_SET.has(value.channel)) { + fail('artifact.channel is invalid'); + } + if (typeof value.applicationId !== 'string') fail('artifact.applicationId must be a string'); + assertApplicationId( + value.applicationId, + value.platform as ConfigPlatform, + 'artifact.applicationId', + ); + if (typeof value.displayName !== 'string') fail('artifact.displayName must be a string'); + assertDisplayName(value.displayName, 'artifact.displayName'); + if (typeof value.storageNamespace !== 'string') { + fail('artifact.storageNamespace must be a string'); + } + assertStorageNamespace(value.storageNamespace, 'artifact.storageNamespace'); + if (typeof value.urlScheme !== 'string' || !RE_URL_SCHEME.test(value.urlScheme)) { + fail('artifact.urlScheme is invalid'); + } + if (typeof value.assetsPath !== 'string') fail('artifact.assetsPath must be a string'); + assertAssetsPath(value.assetsPath, 'artifact.assetsPath'); + if (!isRecord(value.provenance)) fail('artifact.provenance must be an object'); + requireExactKeys(value.provenance, PROVENANCE_KEYS, 'artifact.provenance'); + const { manifestSchemaVersion, sourceGitSha } = value.provenance; + if (!Number.isSafeInteger(manifestSchemaVersion) || (manifestSchemaVersion as number) < 1) { + fail('artifact.provenance.manifestSchemaVersion is invalid'); + } + if (typeof sourceGitSha !== 'string' || !RE_SOURCE_GIT_SHA.test(sourceGitSha)) { + fail('artifact.provenance.sourceGitSha must be a lowercase 40-hex commit'); + } +} + +export function parseBrandIdentityArtifact(value: unknown): BrandIdentityArtifact { + assertBrandIdentityArtifact(value); + return value; +} + +/** A build embeds exactly one identity and one build bundle; both must answer the same target + * from the same manifest commit, or one of them is stale and the build must stop. */ +export function assertBrandIdentityMatchesBundle( + identity: BrandIdentityArtifact, + bundle: ConfigBuildBundle, +): void { + const identityTarget = `${identity.brandId}/${identity.platform}/${identity.channel}`; + const bundleTarget = `${bundle.brandId}/${bundle.platform}/${bundle.channel}`; + if (identityTarget !== bundleTarget) { + fail(`brand identity targets ${identityTarget}, but the build bundle targets ${bundleTarget}`); + } + if (identity.provenance.sourceGitSha !== bundle.provenance.sourceGitSha) { + fail( + `brand identity was rendered from source commit ${identity.provenance.sourceGitSha}, ` + + `but the build bundle came from ${bundle.provenance.sourceGitSha}; ` + + 'regenerate both from the same pinned commit', + ); + } +} diff --git a/packages/foundation/common/src/config/index.ts b/packages/foundation/common/src/config/index.ts index 85ababdf..86558a54 100644 --- a/packages/foundation/common/src/config/index.ts +++ b/packages/foundation/common/src/config/index.ts @@ -1,3 +1,10 @@ +export type { BrandIdentityArtifact, BrandIdentityProvenance } from './brand-identity'; +export { + assertBrandIdentityArtifact, + assertBrandIdentityMatchesBundle, + BRAND_IDENTITY_VERSION, + parseBrandIdentityArtifact, +} from './brand-identity'; export type { ConfigBuildBundle, ConfigBuildBundleEndpoints, From 2f32ac1dce7c689c18fce962857a46928d9bb7e1 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 3 Aug 2026 20:11:26 +0000 Subject: [PATCH 2/7] feat(common): render brand identity and stage assets through the pinned publisher --- .../src/node/__tests__/brand-assets.test.ts | 161 +++++++++++++ .../__tests__/config-brand-render.test.ts | 213 ++++++++++++++++++ .../common/src/node/brand-assets.ts | 82 +++++++ .../common/src/node/config-brand-render.ts | 129 +++++++++++ .../common/src/node/config-build-render.ts | 84 ++----- packages/foundation/common/src/node/index.ts | 2 + .../common/src/node/render-checkout.ts | 76 +++++++ 7 files changed, 679 insertions(+), 68 deletions(-) create mode 100644 packages/foundation/common/src/node/__tests__/brand-assets.test.ts create mode 100644 packages/foundation/common/src/node/__tests__/config-brand-render.test.ts create mode 100644 packages/foundation/common/src/node/brand-assets.ts create mode 100644 packages/foundation/common/src/node/config-brand-render.ts create mode 100644 packages/foundation/common/src/node/render-checkout.ts diff --git a/packages/foundation/common/src/node/__tests__/brand-assets.test.ts b/packages/foundation/common/src/node/__tests__/brand-assets.test.ts new file mode 100644 index 00000000..6387cd20 --- /dev/null +++ b/packages/foundation/common/src/node/__tests__/brand-assets.test.ts @@ -0,0 +1,161 @@ +import { + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { BRAND_ASSET_ICON, stageBrandAssets } from '../brand-assets'; + +const PNG_MAGIC = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); +const RE_ABSENT = /does not exist/; +const RE_ESCAPES_CHECKOUT = /escapes the structural checkout/; +const RE_SYMLINK = /must not be a symlink/; +const RE_NOT_REGULAR_FILE = /regular file/; +const RE_MISSING_ICON = /missing icon\.png/; +const RE_NOT_PNG = /not a PNG file/; +const RE_INVALID_SIZE = /invalid size/; + +let workDir: string; +let outDir: string; + +beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), 'brand-assets-')); + outDir = join(workDir, 'out'); +}); + +afterEach(() => { + rmSync(workDir, { force: true, recursive: true }); +}); + +function makeAssets(brandId: string, iconBody = brandId): string { + const dir = join(workDir, 'structural', 'brands', brandId); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, BRAND_ASSET_ICON), Buffer.concat([PNG_MAGIC, Buffer.from(iconBody)])); + return dir; +} + +function structuralDir(): string { + return join(workDir, 'structural'); +} + +describe('stageBrandAssets', () => { + it('stages exactly the selected brand and reports content digests', () => { + makeAssets('acme'); + makeAssets('zenith'); + const staged = stageBrandAssets({ + assetsPath: 'brands/acme', + outDir, + structuralDir: structuralDir(), + }); + expect(staged.map(({ name }) => name)).toEqual([BRAND_ASSET_ICON]); + expect(readdirSync(outDir)).toEqual([BRAND_ASSET_ICON]); + // The staged bytes are the acme bytes, not the zenith bytes. + expect(readFileSync(join(outDir, BRAND_ASSET_ICON)).toString('latin1')).toContain('acme'); + }); + + it('is deterministic: re-staging yields identical bytes and digests', () => { + makeAssets('acme'); + const first = stageBrandAssets({ + assetsPath: 'brands/acme', + outDir, + structuralDir: structuralDir(), + }); + const firstBytes = readFileSync(join(outDir, BRAND_ASSET_ICON)); + const second = stageBrandAssets({ + assetsPath: 'brands/acme', + outDir, + structuralDir: structuralDir(), + }); + expect(second).toEqual(first); + expect(readFileSync(join(outDir, BRAND_ASSET_ICON)).equals(firstBytes)).toBe(true); + }); + + it("replaces a previous brand's output wholesale — no cross-brand leftovers", () => { + makeAssets('acme'); + const zenithDir = makeAssets('zenith'); + writeFileSync( + join(zenithDir, 'extra.png'), + Buffer.concat([PNG_MAGIC, Buffer.from('zenith-extra')]), + ); + stageBrandAssets({ assetsPath: 'brands/zenith', outDir, structuralDir: structuralDir() }); + expect(readdirSync(outDir).sort()).toEqual(['extra.png', BRAND_ASSET_ICON].sort()); + stageBrandAssets({ assetsPath: 'brands/acme', outDir, structuralDir: structuralDir() }); + expect(readdirSync(outDir)).toEqual([BRAND_ASSET_ICON]); + expect(readFileSync(join(outDir, BRAND_ASSET_ICON)).toString('latin1')).toContain('acme'); + }); + + it('rejects a missing assets directory', () => { + expect(() => + stageBrandAssets({ assetsPath: 'brands/ghost', outDir, structuralDir: structuralDir() }), + ).toThrow(RE_ABSENT); + }); + + it('rejects a path that escapes the structural checkout', () => { + makeAssets('acme'); + expect(() => + stageBrandAssets({ + assetsPath: '../escape', + outDir, + structuralDir: join(workDir, 'structural', 'brands'), + }), + ).toThrow(RE_ESCAPES_CHECKOUT); + }); + + it('rejects a symlinked assets directory', () => { + const real = makeAssets('acme'); + symlinkSync(real, join(workDir, 'structural', 'brands', 'evil')); + expect(() => + stageBrandAssets({ assetsPath: 'brands/evil', outDir, structuralDir: structuralDir() }), + ).toThrow(RE_SYMLINK); + }); + + it('rejects a symlinked asset file pointing at another brand', () => { + const acme = makeAssets('acme'); + const zenith = makeAssets('zenith'); + symlinkSync(join(zenith, BRAND_ASSET_ICON), join(acme, 'stolen.png')); + expect(() => + stageBrandAssets({ assetsPath: 'brands/acme', outDir, structuralDir: structuralDir() }), + ).toThrow(RE_SYMLINK); + }); + + it('rejects nested directories (asset set v1 is flat)', () => { + const dir = makeAssets('acme'); + mkdirSync(join(dir, 'nested')); + expect(() => + stageBrandAssets({ assetsPath: 'brands/acme', outDir, structuralDir: structuralDir() }), + ).toThrow(RE_NOT_REGULAR_FILE); + }); + + it('rejects a directory without the required icon', () => { + const dir = makeAssets('acme'); + rmSync(join(dir, BRAND_ASSET_ICON)); + writeFileSync(join(dir, 'other.png'), Buffer.concat([PNG_MAGIC, Buffer.from('x')])); + expect(() => + stageBrandAssets({ assetsPath: 'brands/acme', outDir, structuralDir: structuralDir() }), + ).toThrow(RE_MISSING_ICON); + }); + + it('rejects an icon without PNG magic bytes', () => { + const dir = makeAssets('acme'); + writeFileSync(join(dir, BRAND_ASSET_ICON), Buffer.from('not a png at all')); + expect(() => + stageBrandAssets({ assetsPath: 'brands/acme', outDir, structuralDir: structuralDir() }), + ).toThrow(RE_NOT_PNG); + }); + + it('rejects an empty asset file and leaves no partial output behind', () => { + const dir = makeAssets('acme'); + writeFileSync(join(dir, 'empty.txt'), ''); + expect(() => + stageBrandAssets({ assetsPath: 'brands/acme', outDir, structuralDir: structuralDir() }), + ).toThrow(RE_INVALID_SIZE); + // Validation happens before the output directory is touched. + expect(() => readdirSync(outDir)).toThrow(); + }); +}); diff --git a/packages/foundation/common/src/node/__tests__/config-brand-render.test.ts b/packages/foundation/common/src/node/__tests__/config-brand-render.test.ts new file mode 100644 index 00000000..2ae799ca --- /dev/null +++ b/packages/foundation/common/src/node/__tests__/config-brand-render.test.ts @@ -0,0 +1,213 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fixture from '../../config/__fixtures__/brand-identity-v1.json'; +import { parseBrandIdentityArtifact } from '../../config/brand-identity'; +import type { ConfigBrandRenderRequest } from '../config-brand-render'; +import { + assertRenderedIdentityMatches, + configBrandRenderArgs, + renderBrandIdentityWithPublisher, +} from '../config-brand-render'; +import type { RenderCommandRunner } from '../render-checkout'; + +const PUBLISHER_SHA = 'a'.repeat(40); +const fixtureIdentity = parseBrandIdentityArtifact(structuredClone(fixture)); +const SOURCE_SHA = fixtureIdentity.provenance.sourceGitSha; + +const RE_CHECKOUT_ABSENT = /Config publisher checkout not found/; +const RE_WRONG_COMMIT = /is at commit b{40}, but this build pins a{40}/; +const RE_DIRTY_CHECKOUT = /has local modifications/; +const RE_NO_OUTPUT = /did not produce/; +const RE_TARGET_MISMATCH = + /targets acme\/desktop\/stable, but this build requires other-brand\/desktop\/stable/; +const RE_SOURCE_DRIFT = /regenerate from the pinned commit/; +const RE_FIXTURE_TARGET = /targets acme\/desktop\/stable/; + +function headForDir(dir: string): string { + return dir.endsWith('structural') ? SOURCE_SHA : PUBLISHER_SHA; +} + +let workDir: string; + +beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), 'config-brand-render-')); +}); + +afterEach(async () => { + await rm(workDir, { force: true, recursive: true }); +}); + +interface RecordedCall { + readonly args: readonly string[]; + readonly command: string; + readonly cwd: string; +} + +function fakeRunner(options: { + head?: (dir: string) => string; + onRender?: () => Promise; + status?: string; +}): { calls: RecordedCall[]; run: RenderCommandRunner } { + const calls: RecordedCall[] = []; + const run: RenderCommandRunner = async (command, args, { cwd }) => { + calls.push({ args, command, cwd }); + if (command === 'git' && args[2] === 'rev-parse') { + const dir = args[1]; + return { stdout: `${options.head?.(dir) ?? headForDir(dir)}\n` }; + } + if (command === 'git' && args[2] === 'status') { + return { stdout: options.status ?? '' }; + } + if (command === 'pnpm') { + await options.onRender?.(); + return { stdout: '' }; + } + throw new Error(`unexpected command ${command}`); + }; + return { calls, run }; +} + +async function makePublisherCheckout(): Promise { + const publisherDir = join(workDir, 'publisher'); + await mkdir(join(publisherDir, 'packages/config-publisher/scripts'), { recursive: true }); + await writeFile(join(publisherDir, 'packages/config-publisher/scripts/brand-render.mts'), ''); + return publisherDir; +} + +function makeRequest(publisherDir: string): ConfigBrandRenderRequest { + return { + brandId: fixtureIdentity.brandId, + channel: fixtureIdentity.channel, + outPath: join(workDir, 'identity.json'), + platform: fixtureIdentity.platform, + publisherDir, + publisherGitSha: PUBLISHER_SHA, + sourceGitSha: SOURCE_SHA, + structuralDir: join(workDir, 'structural'), + }; +} + +describe('renderBrandIdentityWithPublisher', () => { + it('renders through the pinned publisher CLI and validates the output', async () => { + const publisherDir = await makePublisherCheckout(); + const request = makeRequest(publisherDir); + const { calls, run } = fakeRunner({ + onRender: () => writeFile(request.outPath, JSON.stringify(fixture)), + }); + + const identity = await renderBrandIdentityWithPublisher(request, run); + expect(identity.brandId).toBe('acme'); + expect(identity.applicationId).toBe('dev.arcbox.acme.desktop'); + expect(identity.provenance.sourceGitSha).toBe(SOURCE_SHA); + + const render = calls.find((call) => call.command === 'pnpm'); + expect(render).toBeDefined(); + expect(render?.cwd).toBe(publisherDir); + expect(render?.args).toEqual(configBrandRenderArgs(request)); + // Both checkouts were pin-verified and checked for local modifications before rendering. + const gitCalls = calls.filter((call) => call.command === 'git'); + expect(gitCalls).toHaveLength(4); + }); + + it('fails with an actionable error when the publisher checkout is absent', async () => { + const request = makeRequest(join(workDir, 'missing')); + const { calls, run } = fakeRunner({}); + await expect(renderBrandIdentityWithPublisher(request, run)).rejects.toThrow( + RE_CHECKOUT_ABSENT, + ); + expect(calls).toHaveLength(0); + }); + + it('rejects malformed commit pins before touching any checkout', async () => { + const publisherDir = await makePublisherCheckout(); + const { calls, run } = fakeRunner({}); + await expect( + renderBrandIdentityWithPublisher( + { ...makeRequest(publisherDir), publisherGitSha: 'HEAD' }, + run, + ), + ).rejects.toThrow('publisherGitSha must be an exact lowercase 40-hex commit'); + await expect( + renderBrandIdentityWithPublisher( + { ...makeRequest(publisherDir), sourceGitSha: SOURCE_SHA.toUpperCase() }, + run, + ), + ).rejects.toThrow('sourceGitSha must be an exact lowercase 40-hex commit'); + expect(calls).toHaveLength(0); + }); + + it('refuses a publisher checkout at the wrong commit', async () => { + const publisherDir = await makePublisherCheckout(); + const { run } = fakeRunner({ head: () => 'b'.repeat(40) }); + await expect(renderBrandIdentityWithPublisher(makeRequest(publisherDir), run)).rejects.toThrow( + RE_WRONG_COMMIT, + ); + }); + + it('refuses a checkout with local modifications', async () => { + const publisherDir = await makePublisherCheckout(); + const { run } = fakeRunner({ status: ' M packages/config-publisher/src/render.ts\n' }); + await expect(renderBrandIdentityWithPublisher(makeRequest(publisherDir), run)).rejects.toThrow( + RE_DIRTY_CHECKOUT, + ); + }); + + it('fails when the publisher CLI does not produce the output file', async () => { + const publisherDir = await makePublisherCheckout(); + const { run } = fakeRunner({}); + await expect(renderBrandIdentityWithPublisher(makeRequest(publisherDir), run)).rejects.toThrow( + RE_NO_OUTPUT, + ); + }); + + it('fails closed when the rendered output is malformed', async () => { + const publisherDir = await makePublisherCheckout(); + const request = makeRequest(publisherDir); + const tampered = structuredClone(fixture) as { urlScheme: string }; + tampered.urlScheme = 'Not A Scheme'; + const { run } = fakeRunner({ + onRender: () => writeFile(request.outPath, JSON.stringify(tampered)), + }); + await expect(renderBrandIdentityWithPublisher(request, run)).rejects.toThrow( + 'artifact.urlScheme is invalid', + ); + }); + + it('rejects rendered output answering a different request', async () => { + const publisherDir = await makePublisherCheckout(); + const request = { ...makeRequest(publisherDir), brandId: 'other-brand' }; + const { run } = fakeRunner({ + onRender: () => writeFile(request.outPath, JSON.stringify(fixture)), + }); + await expect(renderBrandIdentityWithPublisher(request, run)).rejects.toThrow( + RE_TARGET_MISMATCH, + ); + }); +}); + +describe('assertRenderedIdentityMatches', () => { + const matching = { + brandId: fixtureIdentity.brandId, + channel: fixtureIdentity.channel, + platform: fixtureIdentity.platform, + sourceGitSha: SOURCE_SHA, + } as const; + + it('accepts an identity that answers the request exactly', () => { + expect(() => assertRenderedIdentityMatches(fixtureIdentity, matching)).not.toThrow(); + }); + + it('rejects source commit drift', () => { + expect(() => + assertRenderedIdentityMatches(fixtureIdentity, { ...matching, sourceGitSha: 'c'.repeat(40) }), + ).toThrow(RE_SOURCE_DRIFT); + }); + + it('rejects a channel mismatch', () => { + expect(() => + assertRenderedIdentityMatches(fixtureIdentity, { ...matching, channel: 'canary' }), + ).toThrow(RE_FIXTURE_TARGET); + }); +}); diff --git a/packages/foundation/common/src/node/brand-assets.ts b/packages/foundation/common/src/node/brand-assets.ts new file mode 100644 index 00000000..f4312ef2 --- /dev/null +++ b/packages/foundation/common/src/node/brand-assets.ts @@ -0,0 +1,82 @@ +/// +import { createHash } from 'node:crypto'; +import { copyFileSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { join, resolve, sep } from 'node:path'; + +/** Brand asset set v1 (publisher CONTRACT.md): a flat directory of regular files that must + * contain icon.png. Mirrors the publisher-side checks — both ends fail closed independently. */ +export const BRAND_ASSET_ICON = 'icon.png'; + +const PNG_MAGIC = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); +const MAX_ASSET_BYTES = 4 * 1024 * 1024; + +export interface StagedBrandAsset { + readonly name: string; + readonly sha256: string; +} + +function fail(message: string): never { + throw new Error(message); +} + +function validatedAssetDir(structuralDir: string, assetsPath: string): string { + const root = resolve(structuralDir); + const dir = resolve(root, assetsPath); + if (dir !== root && !dir.startsWith(root + sep)) { + fail(`brand assets path ${assetsPath} escapes the structural checkout`); + } + let dirStats; + try { + dirStats = lstatSync(dir); + } catch { + fail(`brand assets directory ${assetsPath} does not exist`); + } + if (dirStats.isSymbolicLink()) fail(`brand assets directory ${assetsPath} must not be a symlink`); + if (!dirStats.isDirectory()) fail(`brand assets path ${assetsPath} is not a directory`); + return dir; +} + +/** + * Copies exactly the selected brand's asset set out of the structural checkout into an isolated + * output directory, failing closed on anything a build could mis-ship: symlinks, nested + * directories, empty/oversized files, a missing or non-PNG icon, or a path escaping the + * checkout. The output directory is replaced wholesale so a previous brand's files can never + * survive a re-render. Returns the staged files with content digests, sorted by name. + */ +export function stageBrandAssets(options: { + readonly assetsPath: string; + readonly outDir: string; + readonly structuralDir: string; +}): readonly StagedBrandAsset[] { + const dir = validatedAssetDir(options.structuralDir, options.assetsPath); + const entries = readdirSync(dir).sort(); + const label = options.assetsPath; + for (const entry of entries) { + const stats = lstatSync(join(dir, entry)); + if (stats.isSymbolicLink()) fail(`brand asset ${label}/${entry} must not be a symlink`); + if (!stats.isFile()) { + fail(`brand asset ${label}/${entry} must be a regular file (asset set v1 is flat)`); + } + if (stats.size === 0 || stats.size > MAX_ASSET_BYTES) { + fail(`brand asset ${label}/${entry} has an invalid size`); + } + } + if (!entries.includes(BRAND_ASSET_ICON)) { + fail(`brand assets directory ${label} is missing ${BRAND_ASSET_ICON}`); + } + const icon = readFileSync(join(dir, BRAND_ASSET_ICON)); + if (icon.length < PNG_MAGIC.length || !icon.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC)) { + fail(`brand asset ${label}/${BRAND_ASSET_ICON} is not a PNG file`); + } + + const outDir = resolve(options.outDir); + rmSync(outDir, { force: true, recursive: true }); + mkdirSync(outDir, { recursive: true }); + return entries.map((name) => { + copyFileSync(join(dir, name), join(outDir, name)); + const sha256 = createHash('sha256') + .update(readFileSync(join(outDir, name))) + .digest('hex'); + return { name, sha256 }; + }); +} diff --git a/packages/foundation/common/src/node/config-brand-render.ts b/packages/foundation/common/src/node/config-brand-render.ts new file mode 100644 index 00000000..ea9a4dd9 --- /dev/null +++ b/packages/foundation/common/src/node/config-brand-render.ts @@ -0,0 +1,129 @@ +/// +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { extractErrorMessage } from 'foxts/extract-error-message'; +import type { BrandIdentityArtifact } from '../config/brand-identity'; +import { parseBrandIdentityArtifact } from '../config/brand-identity'; +import type { ConfigChannel, ConfigPlatform } from '../config/types'; +import type { RenderCommandRunner } from './render-checkout'; +import { + assertPublisherCheckout, + defaultRenderCommandRunner, + RE_GIT_SHA, + verifyPinnedCheckout, +} from './render-checkout'; + +// Layout of the publisher checkout; the render CLI ships inside the publisher package itself. +const PUBLISHER_PACKAGE_PATH = 'packages/config-publisher'; +const PUBLISHER_SCRIPT_PATH = 'packages/config-publisher/scripts/brand-render.mts'; + +/** Explicit, fully pinned inputs for one brand identity render. Nothing is defaulted or fetched: + * both checkouts must already exist at the exact pinned commits or the render fails closed. */ +export interface ConfigBrandRenderRequest { + readonly brandId: string; + readonly channel: ConfigChannel; + readonly outPath: string; + readonly platform: ConfigPlatform; + /** Root of the config publisher checkout (contains the render CLI). */ + readonly publisherDir: string; + /** Exact commit the publisher checkout must be at. */ + readonly publisherGitSha: string; + /** Exact commit the structural source checkout must be at. */ + readonly sourceGitSha: string; + /** Structural configuration source directory (contains brands.manifest.yaml). */ + readonly structuralDir: string; +} + +/** pnpm arguments that invoke the publisher's own brand-render CLI inside its checkout. */ +export function configBrandRenderArgs(request: ConfigBrandRenderRequest): readonly string[] { + return [ + '--dir', + join(request.publisherDir, PUBLISHER_PACKAGE_PATH), + 'run', + 'brand-render', + '--structural', + request.structuralDir, + '--source-git-sha', + request.sourceGitSha, + '--brand', + request.brandId, + '--platform', + request.platform, + '--channel', + request.channel, + '--out', + request.outPath, + ]; +} + +/** Rejects rendered identity that does not answer this exact request — stale or mismatched + * generated artifacts must stop the build instead of shipping another brand's identity. */ +export function assertRenderedIdentityMatches( + identity: BrandIdentityArtifact, + request: Pick, +): void { + const target = `${identity.brandId}/${identity.platform}/${identity.channel}`; + const expected = `${request.brandId}/${request.platform}/${request.channel}`; + if (target !== expected) { + throw new Error(`Rendered identity targets ${target}, but this build requires ${expected}`); + } + if (identity.provenance.sourceGitSha !== request.sourceGitSha) { + throw new Error( + `Rendered identity was produced from source commit ${identity.provenance.sourceGitSha}, ` + + `but this build pins ${request.sourceGitSha}; regenerate from the pinned commit`, + ); + } +} + +/** Renders one brand identity by invoking the publisher CLI from a pinned checkout, then + * validates the output with the frozen v1 contract. Derivation lives in the publisher only. */ +export async function renderBrandIdentityWithPublisher( + request: ConfigBrandRenderRequest, + run: RenderCommandRunner = defaultRenderCommandRunner, +): Promise { + if (!RE_GIT_SHA.test(request.publisherGitSha)) { + throw new Error('publisherGitSha must be an exact lowercase 40-hex commit'); + } + if (!RE_GIT_SHA.test(request.sourceGitSha)) { + throw new Error('sourceGitSha must be an exact lowercase 40-hex commit'); + } + await assertPublisherCheckout( + request.publisherDir, + request.publisherGitSha, + PUBLISHER_SCRIPT_PATH, + ); + await verifyPinnedCheckout( + request.publisherDir, + request.publisherGitSha, + 'Config publisher', + run, + ); + await verifyPinnedCheckout(request.structuralDir, request.sourceGitSha, 'Config source', run); + + try { + await run('pnpm', configBrandRenderArgs(request), { cwd: request.publisherDir }); + } catch (error) { + const stderr = + typeof error === 'object' && error !== null && 'stderr' in error ? String(error.stderr) : ''; + throw new Error( + `Config publisher brand render failed: ${extractErrorMessage(error)}${stderr ? `\n${stderr}` : ''}`, + { cause: error }, + ); + } + + let text: string; + try { + text = await readFile(request.outPath, 'utf8'); + } catch { + throw new Error(`Config publisher brand render did not produce ${request.outPath}`); + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new Error(`Rendered identity at ${request.outPath} is not valid JSON`); + } + const identity = parseBrandIdentityArtifact(parsed); + assertRenderedIdentityMatches(identity, request); + return identity; +} diff --git a/packages/foundation/common/src/node/config-build-render.ts b/packages/foundation/common/src/node/config-build-render.ts index f6034e1b..7afd3437 100644 --- a/packages/foundation/common/src/node/config-build-render.ts +++ b/packages/foundation/common/src/node/config-build-render.ts @@ -1,21 +1,25 @@ /// -import { execFile } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { readFile, rm, stat } from 'node:fs/promises'; +import { readFile, rm } from 'node:fs/promises'; import { join } from 'node:path'; -import { promisify } from 'node:util'; import { extractErrorMessage } from 'foxts/extract-error-message'; import type { ConfigBuildBundle } from '../config/build-bundle'; import { parseConfigBuildBundle } from '../config/build-bundle'; import type { ConfigChannel, ConfigPlatform } from '../config/types'; +import type { RenderCommandResult, RenderCommandRunner } from './render-checkout'; +import { + assertPublisherCheckout, + defaultRenderCommandRunner, + RE_GIT_SHA, + verifyPinnedCheckout, +} from './render-checkout'; -const execFileAsync = promisify(execFile); - -const RE_GIT_SHA = /^[0-9a-f]{40}$/; // Layout of the publisher checkout; the render CLI ships inside the publisher package itself. const PUBLISHER_PACKAGE_PATH = 'packages/config-publisher'; const PUBLISHER_SCRIPT_PATH = 'packages/config-publisher/scripts/build-render.mts'; +export type { RenderCommandResult, RenderCommandRunner }; + /** Explicit, fully pinned inputs for one build-bundle render. Nothing is defaulted or fetched: * both checkouts must already exist at the exact pinned commits or the render fails closed. */ export interface ConfigBuildRenderRequest { @@ -39,24 +43,6 @@ export interface ConfigBuildRenderRequest { readonly telemetryEndpoint: string; } -export interface RenderCommandResult { - readonly stdout: string; -} - -export type RenderCommandRunner = ( - command: string, - args: readonly string[], - options: { readonly cwd: string }, -) => Promise; - -const defaultRunner: RenderCommandRunner = async (command, args, options) => { - const { stdout } = await execFileAsync(command, [...args], { - cwd: options.cwd, - windowsHide: true, - }); - return { stdout }; -}; - /** pnpm arguments that invoke the publisher's own build-render CLI inside its checkout. */ export function configBuildRenderArgs(request: ConfigBuildRenderRequest): readonly string[] { return [ @@ -231,53 +217,11 @@ export function assertRenderedBundleMatches( } } -async function assertPublisherCheckout(dir: string, pinnedSha: string): Promise { - try { - const stats = await stat(join(dir, PUBLISHER_SCRIPT_PATH)); - if (!stats.isFile()) throw new Error('not a file'); - } catch { - throw new Error( - `Config publisher checkout not found at ${dir} (expected ${PUBLISHER_SCRIPT_PATH} inside). ` + - `Check out the config publisher at commit ${pinnedSha} and pass its root explicitly; ` + - 'builds never fall back to a stale or global publisher install', - ); - } -} - -async function verifyPinnedCheckout( - dir: string, - pinnedSha: string, - label: string, - run: RenderCommandRunner, -): Promise { - let head: string; - try { - head = (await run('git', ['-C', dir, 'rev-parse', 'HEAD'], { cwd: dir })).stdout.trim(); - } catch { - throw new Error(`${label} checkout at ${dir} is not a git checkout`); - } - if (head !== pinnedSha) { - throw new Error( - `${label} checkout at ${dir} is at commit ${head}, but this build pins ${pinnedSha}; ` + - 'check out the pinned commit and retry', - ); - } - const status = ( - await run('git', ['-C', dir, 'status', '--porcelain'], { cwd: dir }) - ).stdout.trim(); - if (status.length > 0) { - throw new Error( - `${label} checkout at ${dir} has local modifications; ` + - 'rendered output must come from the pinned commit only', - ); - } -} - /** Renders one build bundle by invoking the publisher CLI from a pinned checkout, then validates * the output with the frozen v1 contract. Rendering semantics live in the publisher only. */ export async function renderConfigBundleWithPublisher( request: ConfigBuildRenderRequest, - run: RenderCommandRunner = defaultRunner, + run: RenderCommandRunner = defaultRenderCommandRunner, ): Promise { if (!RE_GIT_SHA.test(request.publisherGitSha)) { throw new Error('publisherGitSha must be an exact lowercase 40-hex commit'); @@ -285,7 +229,11 @@ export async function renderConfigBundleWithPublisher( if (!RE_GIT_SHA.test(request.sourceGitSha)) { throw new Error('sourceGitSha must be an exact lowercase 40-hex commit'); } - await assertPublisherCheckout(request.publisherDir, request.publisherGitSha); + await assertPublisherCheckout( + request.publisherDir, + request.publisherGitSha, + PUBLISHER_SCRIPT_PATH, + ); await verifyPinnedCheckout( request.publisherDir, request.publisherGitSha, diff --git a/packages/foundation/common/src/node/index.ts b/packages/foundation/common/src/node/index.ts index 4d903418..6c5526f0 100644 --- a/packages/foundation/common/src/node/index.ts +++ b/packages/foundation/common/src/node/index.ts @@ -11,6 +11,8 @@ import { daemonRuntimeFileSegments } from '@linkcode/schema/daemon-runtime'; * bundles. The tsconfig base sets `types: []` — the reference above opts in the Node globals. */ +export * from './brand-assets'; +export * from './config-brand-render'; export * from './config-build-render'; export { executableSearchLocations } from './executable-locations'; export * from './windows-path'; diff --git a/packages/foundation/common/src/node/render-checkout.ts b/packages/foundation/common/src/node/render-checkout.ts new file mode 100644 index 00000000..4fb3204d --- /dev/null +++ b/packages/foundation/common/src/node/render-checkout.ts @@ -0,0 +1,76 @@ +/// +import { execFile } from 'node:child_process'; +import { stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +export const RE_GIT_SHA = /^[0-9a-f]{40}$/; + +export interface RenderCommandResult { + readonly stdout: string; +} + +export type RenderCommandRunner = ( + command: string, + args: readonly string[], + options: { readonly cwd: string }, +) => Promise; + +export const defaultRenderCommandRunner: RenderCommandRunner = async (command, args, options) => { + const { stdout } = await execFileAsync(command, [...args], { + cwd: options.cwd, + windowsHide: true, + }); + return { stdout }; +}; + +/** The publisher CLI must exist inside the checkout — builds never fall back to a stale or + * global publisher install. */ +export async function assertPublisherCheckout( + dir: string, + pinnedSha: string, + scriptPath: string, +): Promise { + try { + const stats = await stat(join(dir, scriptPath)); + if (!stats.isFile()) throw new Error('not a file'); + } catch { + throw new Error( + `Config publisher checkout not found at ${dir} (expected ${scriptPath} inside). ` + + `Check out the config publisher at commit ${pinnedSha} and pass its root explicitly; ` + + 'builds never fall back to a stale or global publisher install', + ); + } +} + +/** Rendered output must come from the pinned commit only: exact HEAD, no local modifications. */ +export async function verifyPinnedCheckout( + dir: string, + pinnedSha: string, + label: string, + run: RenderCommandRunner, +): Promise { + let head: string; + try { + head = (await run('git', ['-C', dir, 'rev-parse', 'HEAD'], { cwd: dir })).stdout.trim(); + } catch { + throw new Error(`${label} checkout at ${dir} is not a git checkout`); + } + if (head !== pinnedSha) { + throw new Error( + `${label} checkout at ${dir} is at commit ${head}, but this build pins ${pinnedSha}; ` + + 'check out the pinned commit and retry', + ); + } + const status = ( + await run('git', ['-C', dir, 'status', '--porcelain'], { cwd: dir }) + ).stdout.trim(); + if (status.length > 0) { + throw new Error( + `${label} checkout at ${dir} has local modifications; ` + + 'rendered output must come from the pinned commit only', + ); + } +} From 635dfa24f52f056c86fb4c6abf76c294f0867e17 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 3 Aug 2026 20:11:55 +0000 Subject: [PATCH 3/7] feat(desktop): derive app identity and isolated storage from the brand artifact --- apps/desktop/scripts/config-bundle.mts | 43 ++++++- apps/desktop/src/env.d.ts | 4 + apps/desktop/src/main/__tests__/brand.test.ts | 106 ++++++++++++++++++ apps/desktop/src/main/brand.ts | 48 ++++++++ apps/desktop/src/main/cloud-auth/client.ts | 12 +- apps/desktop/src/main/constants.ts | 33 +++++- apps/desktop/src/main/identity.ts | 6 +- apps/desktop/vite.main.config.mts | 3 + 8 files changed, 242 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/brand.test.ts create mode 100644 apps/desktop/src/main/brand.ts diff --git a/apps/desktop/scripts/config-bundle.mts b/apps/desktop/scripts/config-bundle.mts index 2d6e63eb..e8fe3c42 100644 --- a/apps/desktop/scripts/config-bundle.mts +++ b/apps/desktop/scripts/config-bundle.mts @@ -6,6 +6,9 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { isObjectEmpty } from 'foxts/is-object-empty'; +// Relative on purpose: this module is inlined into the bundled Vite config, which runs under +// plain Node — Node cannot resolve the package's extensionless TS source exports. +import { parseBrandIdentityArtifact } from '../../../packages/foundation/common/src/config/brand-identity'; import { configBuildBundleDefaults, parseConfigBuildBundle, @@ -13,6 +16,9 @@ import { export interface GeneratedConfigBundle { readonly bootstrapJson: string; + /** Present only on white-label renders (config:render --brand-artifacts); the default product + * never has one and keeps its built-in identity. */ + readonly brandIdentityJson?: string; readonly bundleText: string; } @@ -27,6 +33,7 @@ export function loadGeneratedConfigBundle( env: Readonly>>, ): GeneratedConfigBundle | null { const bundlePath = resolve(desktopDir, 'generated/config-build-bundle.json'); + const brandIdentityPath = resolve(desktopDir, 'generated/brand-identity.json'); if (!existsSync(bundlePath)) { if (env.LINKCODE_REQUIRE_CONFIG_BUNDLE === '1') { throw new Error( @@ -34,6 +41,12 @@ export function loadGeneratedConfigBundle( '`pnpm -F @linkcode/desktop config:render` with pinned inputs before building', ); } + if (existsSync(brandIdentityPath)) { + throw new Error( + 'apps/desktop/generated has a brand identity but no config bundle — re-run ' + + '`pnpm -F @linkcode/desktop config:render --brand-artifacts` with pinned inputs', + ); + } return null; } if (env.MAIN_VITE_CONFIG_BOOTSTRAP) { @@ -62,6 +75,34 @@ export function loadGeneratedConfigBundle( 'LINKCODE_REQUIRE_CONFIG_BUNDLE=1 requires an emergency endpoint and emergency public key', ); } + // White-label renders also write the immutable identity artifact; when present it must be the + // same brand/channel/source as the bundle and no ambient override may exist. Deep validation + // (parseBrandIdentityArtifact) throws on any malformed or tampered artifact. + let brandIdentityJson: string | undefined; + if (existsSync(brandIdentityPath)) { + if (env.MAIN_VITE_BRAND_IDENTITY) { + throw new Error( + 'MAIN_VITE_BRAND_IDENTITY must not be set when a generated brand identity exists; ' + + 'the generated brand identity is immutable', + ); + } + brandIdentityJson = readFileSync(brandIdentityPath, 'utf8'); + const identity = parseBrandIdentityArtifact(JSON.parse(brandIdentityJson)); + if (identity.platform !== 'desktop') { + throw new Error(`generated brand identity targets ${identity.platform}, expected desktop`); + } + if ( + identity.brandId !== bundle.brandId || + identity.channel !== bundle.channel || + identity.provenance.sourceGitSha !== bundle.provenance.sourceGitSha + ) { + throw new Error( + `generated brand identity (${identity.brandId}/${identity.channel}) does not match the ` + + `config bundle (${bundle.brandId}/${bundle.channel}) — re-run ` + + '`pnpm -F @linkcode/desktop config:render --brand-artifacts`', + ); + } + } // Same shape as DesktopConfigBootstrap (src/main/config.ts); parseBootstrap revalidates it at // runtime after Vite inlines it into the main bundle. const bootstrap = { @@ -75,7 +116,7 @@ export function loadGeneratedConfigBundle( publicKeys: bundle.keyrings.normal, telemetryEndpoint: bundle.endpoints.telemetry, }; - return { bootstrapJson: JSON.stringify(bootstrap), bundleText }; + return { bootstrapJson: JSON.stringify(bootstrap), brandIdentityJson, bundleText }; } /** diff --git a/apps/desktop/src/env.d.ts b/apps/desktop/src/env.d.ts index 9e6c85ba..9469585f 100644 --- a/apps/desktop/src/env.d.ts +++ b/apps/desktop/src/env.d.ts @@ -7,6 +7,10 @@ interface ImportMetaEnv { * apps/desktop/generated holds a rendered build bundle (config:render), vite.main.config.mts * inlines the derived bootstrap and rejects any ambient env value. */ readonly MAIN_VITE_CONFIG_BOOTSTRAP?: string; + /** Build-time immutable brand identity artifact (config:render, CODE-558); unset builds are + * the default LinkCode identity. Inlined only from generated output — never from ambient env, + * which vite.main.config.ts rejects outright. */ + readonly MAIN_VITE_BRAND_IDENTITY?: string; /** Public PostHog project configuration; both values are required or analytics no-ops. */ readonly RENDERER_VITE_POSTHOG_PROJECT_TOKEN?: string; readonly RENDERER_VITE_POSTHOG_HOST?: string; diff --git a/apps/desktop/src/main/__tests__/brand.test.ts b/apps/desktop/src/main/__tests__/brand.test.ts new file mode 100644 index 00000000..aab50c1a --- /dev/null +++ b/apps/desktop/src/main/__tests__/brand.test.ts @@ -0,0 +1,106 @@ +import { nullthrow } from 'foxts/guard'; +import { describe, expect, it } from 'vitest'; +import { deriveDesktopBrandBase, parseDesktopBrandIdentity } from '../brand'; + +const SOURCE_SHA = '0123456789abcdef0123456789abcdef01234567'; +const RE_DESKTOP = /desktop/; +const RE_DEFAULT_PRODUCT = /linkcode/i; + +function rawIdentity(overrides: Partial> = {}) { + return JSON.stringify({ + applicationId: 'dev.arcbox.acme.desktop', + assetsPath: 'brands/acme', + brandId: 'acme', + brandIdentityVersion: 1, + channel: 'stable', + displayName: 'Acme Studio', + platform: 'desktop', + provenance: { manifestSchemaVersion: 1, sourceGitSha: SOURCE_SHA }, + storageNamespace: 'Acme Studio', + urlScheme: 'acme', + ...overrides, + }); +} + +describe('parseDesktopBrandIdentity', () => { + it('returns null when no identity is inlined', () => { + expect(parseDesktopBrandIdentity(undefined)).toBeNull(); + expect(parseDesktopBrandIdentity('')).toBeNull(); + }); + + it('parses a desktop identity', () => { + const identity = parseDesktopBrandIdentity(rawIdentity()); + expect(identity?.brandId).toBe('acme'); + expect(identity?.applicationId).toBe('dev.arcbox.acme.desktop'); + }); + + it('fails closed on malformed JSON instead of falling back to the default brand', () => { + expect(() => parseDesktopBrandIdentity('{not json')).toThrow(); + }); + + it('fails closed on a non-desktop identity', () => { + expect(() => + parseDesktopBrandIdentity(rawIdentity({ applicationId: 'dev.arcbox.acme', platform: 'ios' })), + ).toThrow(RE_DESKTOP); + }); + + it('fails closed on malformed identity fields', () => { + expect(() => parseDesktopBrandIdentity(rawIdentity({ urlScheme: 'Not A Scheme' }))).toThrow(); + expect(() => + parseDesktopBrandIdentity(rawIdentity({ applicationId: 'no spaces allowed' })), + ).toThrow(); + }); +}); + +describe('deriveDesktopBrandBase', () => { + it('uses the publisher identity verbatim on the release channel', () => { + const identity = nullthrow(parseDesktopBrandIdentity(rawIdentity()), 'expected identity'); + expect(deriveDesktopBrandBase(identity, 'release')).toStrictEqual({ + appId: 'dev.arcbox.acme.desktop', + appName: 'Acme Studio', + authScheme: 'acme', + storageDirName: 'Acme Studio', + }); + }); + + it('decorates the development channel without touching release identity', () => { + const identity = nullthrow(parseDesktopBrandIdentity(rawIdentity()), 'expected identity'); + expect(deriveDesktopBrandBase(identity, 'development')).toStrictEqual({ + appId: 'dev.arcbox.acme.desktop.development', + appName: 'Acme Studio Development', + authScheme: 'acme-dev', + storageDirName: 'Acme Studio Development', + }); + }); + + it('keeps two brands fully isolated on the same channel', () => { + const acme = parseDesktopBrandIdentity(rawIdentity()); + const zenith = parseDesktopBrandIdentity( + rawIdentity({ + applicationId: 'dev.arcbox.zenith.desktop', + assetsPath: 'brands/zenith', + brandId: 'zenith', + displayName: 'Zenith Workspace', + storageNamespace: 'Zenith Workspace', + urlScheme: 'zenith', + }), + ); + if (acme === null || zenith === null) throw new Error('expected identities'); + for (const channel of ['release', 'development'] as const) { + const acmeBase = deriveDesktopBrandBase(acme, channel); + const zenithBase = deriveDesktopBrandBase(zenith, channel); + expect(zenithBase.storageDirName).not.toBe(acmeBase.storageDirName); + expect(zenithBase.appId).not.toBe(acmeBase.appId); + expect(zenithBase.authScheme).not.toBe(acmeBase.authScheme); + } + }); + + it('never derives the legacy unbranded storage location', () => { + const identity = nullthrow(parseDesktopBrandIdentity(rawIdentity()), 'expected identity'); + for (const channel of ['release', 'development'] as const) { + const base = deriveDesktopBrandBase(identity, channel); + expect(base.storageDirName).not.toMatch(RE_DEFAULT_PRODUCT); + expect(base.appId).not.toMatch(RE_DEFAULT_PRODUCT); + } + }); +}); diff --git a/apps/desktop/src/main/brand.ts b/apps/desktop/src/main/brand.ts new file mode 100644 index 00000000..ff84a7ff --- /dev/null +++ b/apps/desktop/src/main/brand.ts @@ -0,0 +1,48 @@ +import type { BrandIdentityArtifact } from '@linkcode/common/config'; +import { parseBrandIdentityArtifact } from '@linkcode/common/config'; +import type { ProductChannel } from '@linkcode/schema/daemon-runtime'; + +/** + * The build-time brand identity (CODE-558): rendered by the pinned config publisher, inlined by + * vite.main.config.ts as MAIN_VITE_BRAND_IDENTITY next to the config bootstrap. No identity means + * the default LinkCode build; a present-but-invalid identity aborts boot instead of falling back, + * so a tampered or stale artifact can never ship under the wrong brand. + */ +export function parseDesktopBrandIdentity(raw: string | undefined): BrandIdentityArtifact | null { + if (raw === undefined || raw === '') return null; + const identity = parseBrandIdentityArtifact(JSON.parse(raw)); + if (identity.platform !== 'desktop') { + throw new Error(`brand identity targets ${identity.platform}, not desktop`); + } + return identity; +} + +/** OS-facing base identity before profile suffixing (see constants.ts). */ +export interface DesktopBrandBase { + readonly appId: string; + readonly appName: string; + readonly authScheme: string; + readonly storageDirName: string; +} + +/** + * Applies the client-side development-channel decoration on top of the publisher-rendered + * identity. The publisher already decorated the product channel (canary/stable); the development + * axis is purely local (dev shells, unpackaged runs) and must keep the same isolation rationale + * as the default brand: a development build never clobbers the installed release's settings, + * instance lock, or OS-global URL scheme. + */ +export function deriveDesktopBrandBase( + identity: BrandIdentityArtifact, + channel: ProductChannel, +): DesktopBrandBase { + const development = channel === 'development'; + return { + appId: development ? `${identity.applicationId}.development` : identity.applicationId, + appName: development ? `${identity.displayName} Development` : identity.displayName, + authScheme: development ? `${identity.urlScheme}-dev` : identity.urlScheme, + storageDirName: development + ? `${identity.storageNamespace} Development` + : identity.storageNamespace, + }; +} diff --git a/apps/desktop/src/main/cloud-auth/client.ts b/apps/desktop/src/main/cloud-auth/client.ts index 45e955ce..e8893307 100644 --- a/apps/desktop/src/main/cloud-auth/client.ts +++ b/apps/desktop/src/main/cloud-auth/client.ts @@ -3,7 +3,7 @@ import { electronClient } from '@better-auth/electron/client'; import { createAuthClient } from 'better-auth/client'; import { app, BrowserWindow, ipcMain } from 'electron'; import { CLOUD_CLAIM_DEEP_LINK_CHANNEL } from '../../shared/cloud'; -import { CHANNEL } from '../constants'; +import { CLOUD_AUTH_SCHEME } from '../constants'; import { createSafeStorage } from './storage'; /** @@ -13,12 +13,10 @@ import { createSafeStorage } from './storage'; */ export const CLOUD_API_URL = process.env.LINKCODE_CLOUD_API_URL ?? 'https://api.linkcode.ai'; -/** - * OAuth deep-link protocol, trusted by linkcodehq; split per channel (see constants.ts) so a - * `development` build never fights the installed `release` app over the OS-global scheme — the - * last registrant would win and silently route the callback to the wrong app. - */ -export const CLOUD_AUTH_SCHEME = CHANNEL === 'development' ? 'linkcode-dev' : 'linkcode'; +// The OAuth deep-link scheme is brand identity (CLOUD_AUTH_SCHEME in constants.ts): brand-owned +// on branded builds, channel-split so a development build never fights the release app over the +// OS-global scheme — the last registrant would win and silently route the callback wrong. +export { CLOUD_AUTH_SCHEME } from '../constants'; // Tell the HQ sign-in page which scheme to deep-link back on; the better-auth client appends its // own params with `URL.searchParams.set`, preserving this one. diff --git a/apps/desktop/src/main/constants.ts b/apps/desktop/src/main/constants.ts index 746a7f7f..ecee9192 100644 --- a/apps/desktop/src/main/constants.ts +++ b/apps/desktop/src/main/constants.ts @@ -3,6 +3,7 @@ import { parseProfileName } from '@linkcode/schema/daemon-runtime'; import { workspacesDirName } from '@linkcode/schema/product'; import { app, dialog } from 'electron'; import { extractErrorMessage } from 'foxts/extract-error-message'; +import { deriveDesktopBrandBase, parseDesktopBrandIdentity } from './brand'; /** * The desktop identity is two orthogonal axes; every OS-facing surface (app name, `userData`, @@ -39,7 +40,13 @@ function resolveProfile(): string | undefined { /** The requested profile; `undefined` is the default universe (every pre-profile install). */ export const PROFILE = resolveProfile(); -const BASE_NAME = CHANNEL === 'development' ? 'LinkCode Development' : 'LinkCode'; +/** Build-time brand identity (CODE-558): null on default LinkCode builds. A malformed inlined + * artifact throws here and aborts boot — a branded build must never fall back to LinkCode. */ +const BRAND = parseDesktopBrandIdentity(import.meta.env.MAIN_VITE_BRAND_IDENTITY); +const BRAND_BASE = BRAND === null ? null : deriveDesktopBrandBase(BRAND, CHANNEL); + +const BASE_NAME = + BRAND_BASE?.appName ?? (CHANNEL === 'development' ? 'LinkCode Development' : 'LinkCode'); export const APP_NAME = PROFILE === undefined ? BASE_NAME : `${BASE_NAME} (${PROFILE})`; @@ -49,12 +56,32 @@ export const APP_NAME = PROFILE === undefined ? BASE_NAME : `${BASE_NAME} (${PRO * profile get distinct ids for the same isolation rationale as `APP_NAME`. */ const BASE_ID = - CHANNEL === 'development' + BRAND_BASE?.appId ?? + (CHANNEL === 'development' ? 'com.arcboxlabs.linkcode.desktop.development' - : 'com.arcboxlabs.linkcode.desktop'; + : 'com.arcboxlabs.linkcode.desktop'); export const APP_ID = PROFILE === undefined ? BASE_ID : `${BASE_ID}.${PROFILE}`; +/** + * The brand's on-disk storage universe (`userData` directory name, see identity.ts). Without a + * brand this is exactly APP_NAME — the pre-CODE-558 value, so existing installs keep their data. + * With a brand it is the publisher's storageNamespace (channel/profile-forked like APP_NAME); + * each brand only ever resolves its own namespace and never migrates or reads another's. + */ +const STORAGE_BASE = BRAND_BASE?.storageDirName ?? BASE_NAME; + +export const STORAGE_DIR_NAME = + PROFILE === undefined ? STORAGE_BASE : `${STORAGE_BASE} (${PROFILE})`; + +/** + * OAuth deep-link protocol (see cloud-auth/client.ts): brand-owned when a brand identity is + * embedded, split per channel so a development build never fights the installed release over + * the OS-global scheme. + */ +export const CLOUD_AUTH_SCHEME = + BRAND_BASE?.authScheme ?? (CHANNEL === 'development' ? 'linkcode-dev' : 'linkcode'); + /** The channel's workspace directory (`~/LinkCode`, `~/LinkCode Development`) — shared across * that channel's profiles on purpose, but never across channels (CODE-460). Must agree with the * daemon's `chatWorkspaceRoot()`, which derives the same name from its own resolved channel. */ diff --git a/apps/desktop/src/main/identity.ts b/apps/desktop/src/main/identity.ts index 725f809b..0df8d359 100644 --- a/apps/desktop/src/main/identity.ts +++ b/apps/desktop/src/main/identity.ts @@ -1,7 +1,7 @@ import { join } from 'node:path'; import { app } from 'electron'; import log from 'electron-log'; -import { APP_ID, APP_NAME } from './constants'; +import { APP_ID, APP_NAME, STORAGE_DIR_NAME } from './constants'; /** * Applies the channel × profile identity (see constants.ts) as an import side effect. Must stay @@ -14,7 +14,9 @@ app.setName(APP_NAME); // setName alone is not enough: Electron pins userData from the asar's productName (electron-builder // bakes the release "LinkCode" in even for dev-shell packages), so without this a packaged dev shell // shares the release app's settings and single-instance lock — the second one to start exits silently. -app.setPath('userData', join(app.getPath('appData'), APP_NAME)); +// STORAGE_DIR_NAME is the brand's own storage universe (equal to APP_NAME on default builds); +// resolving it here, before any other module captures a path, is what keeps brands isolated. +app.setPath('userData', join(app.getPath('appData'), STORAGE_DIR_NAME)); // Windows keys the taskbar icon, pinning, and notification identity off the AppUserModelID; without // this the taskbar shows a blank/default icon. No-op on macOS/Linux. diff --git a/apps/desktop/vite.main.config.mts b/apps/desktop/vite.main.config.mts index fe69c43f..47cbf1d0 100644 --- a/apps/desktop/vite.main.config.mts +++ b/apps/desktop/vite.main.config.mts @@ -15,6 +15,9 @@ export default defineConfig({ ...(generatedConfig && { 'import.meta.env.MAIN_VITE_CONFIG_BOOTSTRAP': JSON.stringify(generatedConfig.bootstrapJson), }), + ...(generatedConfig?.brandIdentityJson !== undefined && { + 'import.meta.env.MAIN_VITE_BRAND_IDENTITY': JSON.stringify(generatedConfig.brandIdentityJson), + }), }, envPrefix: ['MAIN_VITE_', 'VITE_'], resolve: { From 09dd504b2366201967e596960a41b91282f50c0d Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 3 Aug 2026 20:12:13 +0000 Subject: [PATCH 4/7] feat(desktop): generate the electron-builder overlay from the rendered brand --- .gitignore | 4 + apps/desktop/scripts/package-app.mts | 42 ++++- apps/desktop/scripts/render-config-bundle.mts | 174 ++++++++++++++---- .../__tests__/electron-builder-brand.test.ts | 79 ++++++++ .../src/build/electron-builder-brand.ts | 65 +++++++ 5 files changed, 326 insertions(+), 38 deletions(-) create mode 100644 apps/desktop/src/build/__tests__/electron-builder-brand.test.ts create mode 100644 apps/desktop/src/build/electron-builder-brand.ts diff --git a/.gitignore b/.gitignore index 0a64f211..ca71308d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ node_modules/ dist/ out/ build/ +# build-time source modules, not build output (the bare build/ pattern above would eat them) +!apps/desktop/src/build/ +!apps/mobile/src/build/ .vite/ expo-export/ *.tsbuildinfo @@ -32,6 +35,7 @@ apps/desktop/sidecar/ # generated immutable config bootstrap (CODE-552; rendered by the pinned config publisher) apps/desktop/generated/ +apps/mobile/generated/ apps/mobile/src/runtime/config/bundled.generated.ios.ts apps/mobile/src/runtime/config/bundled.generated.android.ts diff --git a/apps/desktop/scripts/package-app.mts b/apps/desktop/scripts/package-app.mts index 59a6b022..568ca5a5 100644 --- a/apps/desktop/scripts/package-app.mts +++ b/apps/desktop/scripts/package-app.mts @@ -159,10 +159,38 @@ function updateFeedName(arch: BuilderArch): string { return arch === 'arm64' ? 'latest-linux-arm64.yml' : 'latest-linux.yml'; } +/** Identity-owned builder fields; a passthrough `-c.` override of these on a branded build would + * silently re-brand the artifact, so they are refused outright. */ +const IDENTITY_OVERRIDE_RE = /^-c\.(?:appId|productName|protocols)\b/; + function build(): void { // Both extend the shared electron-builder.yml base; each adds its own deep-link scheme (release // `linkcode://`, dev shell `linkcode-dev://`). The base is never passed directly — it has none. - const config = devshell ? 'electron-builder.devshell.yml' : 'electron-builder.release.yml'; + // A branded build (config:render wrote generated/electron-builder.brand.json) uses the generated + // overlay instead: appId, productName, protocol scheme, and icons all come from the rendered + // brand identity, never from hand-edited YAML. + const brandConfig = join(desktopDir, 'generated', 'electron-builder.brand.json'); + const branded = existsSync(brandConfig); + if (branded && devshell) { + // out/ already embeds the branded bootstrap+identity; packing it as a dev shell would mix + // the LinkCode Development shell identity with another brand's runtime identity. + throw new Error( + 'apps/desktop/generated holds a rendered brand config; delete it (or package without ' + + '--devshell) — a dev shell must not embed another brand', + ); + } + if (branded) { + const rejected = passthrough.find((arg) => IDENTITY_OVERRIDE_RE.test(arg)); + if (rejected !== undefined) { + throw new Error(`branded builds refuse identity overrides: ${rejected}`); + } + } + const config = devshell + ? 'electron-builder.devshell.yml' + : branded + ? brandConfig + : 'electron-builder.release.yml'; + const brandIcon = join(desktopDir, 'generated', 'brand-assets', 'icon.png'); const feeds = new Map(); for (const arch of stagedArches()) { const target = materializeStaging(arch); @@ -180,15 +208,17 @@ function build(): void { '--projectDir', target, '--config', - join(desktopDir, config), + branded ? config : join(desktopDir, config), // projectDir is the staging dir, so config-relative paths would resolve under it; redirect - // output back to where CI/verify-artifacts expect it and icons to the shared repo-root assets. + // output back to where CI/verify-artifacts expect it and icons to the shared repo-root + // assets — or, on branded builds, to the staged brand assets only. `-c.directories.output=${releaseDir}`, - `-c.mac.icon=${join(assetsDir, 'linkcode.icon')}`, - `-c.win.icon=${join(assetsDir, 'icon.png')}`, + `-c.mac.icon=${branded ? brandIcon : join(assetsDir, 'linkcode.icon')}`, + `-c.win.icon=${branded ? brandIcon : join(assetsDir, 'icon.png')}`, // A directory of per-size PNGs — app-builder-lib 26+ won't expand a single PNG into a size // set, so a lone raster installs only hicolor/1024x1024 (unindexed → GNOME fallback icon). - `-c.linux.icon=${join(assetsDir, 'linux-icons')}`, + // Branded builds ship the single brand raster for now (launcher may fall back on GNOME). + `-c.linux.icon=${branded ? brandIcon : join(assetsDir, 'linux-icons')}`, ...(devshell ? ['--dir'] : []), ...passthrough, ], diff --git a/apps/desktop/scripts/render-config-bundle.mts b/apps/desktop/scripts/render-config-bundle.mts index fd534854..a1bbc346 100644 --- a/apps/desktop/scripts/render-config-bundle.mts +++ b/apps/desktop/scripts/render-config-bundle.mts @@ -1,12 +1,29 @@ // Renders the desktop build bundle through the pinned config publisher checkout. The raw bundle -// is the only generated artifact: vite.main.config.mts validates it and derives the inlined -// bootstrap from it in-process, so there is no second generated file to drift. +// is the only generated config source: vite.main.config.mts validates it and derives the inlined +// bootstrap from it in-process, so there is no second generated file to drift. With +// `--brand-artifacts` (white-label builds) it also renders the immutable brand identity, stages +// the brand's assets, and writes the electron-builder brand overlay from the same pinned source. // Run via `pnpm -F @linkcode/desktop config:render --publisher …` (no `--` separator). // Every input is an explicit pin; there is no default checkout, no fetch, and no stale fallback. -import { mkdir } from 'node:fs/promises'; -import { resolve } from 'node:path'; +// `--check` re-renders into a temp dir and fails on any byte drift against apps/desktop/generated +// instead of silently regenerating. +import { createHash } from 'node:crypto'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; import { parseArgs } from 'node:util'; -import { renderConfigBundleWithPublisher } from '@linkcode/common/node'; +import { assertBrandIdentityMatchesBundle } from '@linkcode/common/config'; +import { + renderBrandIdentityWithPublisher, + renderConfigBundleWithPublisher, + stageBrandAssets, +} from '@linkcode/common/node'; +import { appendArrayInPlace } from 'foxts/append-array-in-place'; +import { + electronBuilderBrandConfig, + serializeElectronBuilderBrandConfig, +} from '../src/build/electron-builder-brand'; const USAGE = String.raw`Usage: config:render \ --publisher config publisher checkout root \ @@ -17,18 +34,125 @@ const USAGE = String.raw`Usage: config:render \ --keyrings public keyrings JSON \ --brand --channel \ --telemetry-endpoint authenticated telemetry endpoint for this target \ - --release-manifest optional manifest digest-binding inputs and published snapshot`; + --release-manifest optional manifest digest-binding inputs and published snapshot \ + --brand-artifacts also render the brand identity, staged assets, and builder overlay \ + --check verify apps/desktop/generated is byte-identical; never rewrite`; function bail(message: string): never { console.error(`config:render: ${message}\n\n${USAGE}`); process.exit(1); } +async function renderInto( + outDir: string, + values: Record, +): Promise { + const channel = values.channel ?? bail('--channel is required'); + if (channel !== 'canary' && channel !== 'stable') { + bail('--channel must be canary or stable'); + } + // Render mode is explicit regeneration: clear the target so a previous render (e.g. a branded + // one) can never leave stale artifacts next to a fresh render of a different target. + await rm(outDir, { force: true, recursive: true }); + await mkdir(outDir, { recursive: true }); + const structuralDir = String(values.structural ?? bail('--structural is required')); + const shared = { + brandId: String(values.brand ?? bail('--brand is required')), + channel, + platform: 'desktop', + publisherDir: String(values.publisher ?? bail('--publisher is required')), + publisherGitSha: String(values['publisher-git-sha'] ?? bail('--publisher-git-sha is required')), + sourceGitSha: String(values['source-git-sha'] ?? bail('--source-git-sha is required')), + structuralDir, + } as const; + + const releaseManifest = values['release-manifest']; + const bundle = await renderConfigBundleWithPublisher({ + ...shared, + keyringsPath: String(values.keyrings ?? bail('--keyrings is required')), + outPath: resolve(outDir, 'config-build-bundle.json'), + ...(typeof releaseManifest === 'string' && { releaseManifestPath: releaseManifest }), + revisionPath: String(values.revision ?? bail('--revision is required')), + telemetryEndpoint: String( + values['telemetry-endpoint'] ?? bail('--telemetry-endpoint is required'), + ), + }); + + if (values['brand-artifacts'] === true) { + const identity = await renderBrandIdentityWithPublisher({ + ...shared, + outPath: resolve(outDir, 'brand-identity.json'), + }); + // Same target, same manifest commit — or one of the two artifacts is stale. + assertBrandIdentityMatchesBundle(identity, bundle); + + stageBrandAssets({ + assetsPath: identity.assetsPath, + outDir: resolve(outDir, 'brand-assets'), + structuralDir, + }); + + await writeFile( + resolve(outDir, 'electron-builder.brand.json'), + serializeElectronBuilderBrandConfig(electronBuilderBrandConfig(identity)), + ); + } + + console.log( + `Rendered ${bundle.brandId}/desktop/${bundle.channel} from source ${bundle.provenance.sourceGitSha} ` + + `(revision ${bundle.provenance.configRevisionId}) into ${outDir}`, + ); +} + +function listFiles(dir: string, prefix = ''): string[] { + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => + a.name.localeCompare(b.name), + )) { + const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`; + if (entry.isDirectory()) appendArrayInPlace(files, listFiles(join(dir, entry.name), rel)); + else files.push(rel); + } + return files; +} + +function digest(path: string): string { + return createHash('sha256').update(readFileSync(path)).digest('hex'); +} + +/** Byte-compares a fresh render against the checked-in generated dir; drift is a hard failure so + * a stale artifact can never ride into a build unnoticed. */ +function assertNoDrift(freshDir: string, generatedDir: string): void { + if (!existsSync(generatedDir)) { + bail(`--check: ${generatedDir} does not exist; run config:render without --check first`); + } + const fresh = listFiles(freshDir); + const generated = listFiles(generatedDir); + const drifted = new Set(); + for (const file of fresh) { + if (!generated.includes(file)) drifted.add(`${file} (missing from generated)`); + else if (digest(join(freshDir, file)) !== digest(join(generatedDir, file))) { + drifted.add(`${file} (content drift)`); + } + } + for (const file of generated) { + if (!fresh.includes(file)) drifted.add(`${file} (stale extra file)`); + } + if (drifted.size > 0) { + bail( + `--check: apps/desktop/generated has drifted from the pinned source:\n ${[...drifted].join('\n ')}\n` + + 'Re-run config:render with the pinned inputs and commit nothing — generated output is never checked in', + ); + } +} + async function main(): Promise { const { values } = parseArgs({ options: { brand: { type: 'string' }, + 'brand-artifacts': { type: 'boolean' }, channel: { type: 'string' }, + check: { type: 'boolean' }, keyrings: { type: 'string' }, publisher: { type: 'string' }, 'publisher-git-sha': { type: 'string' }, @@ -41,33 +165,19 @@ async function main(): Promise { strict: true, }); - const channel = values.channel ?? bail('--channel is required'); - if (channel !== 'canary' && channel !== 'stable') { - bail('--channel must be canary or stable'); - } const generatedDir = resolve(import.meta.dirname, '../generated'); - await mkdir(generatedDir, { recursive: true }); - const outPath = resolve(generatedDir, 'config-build-bundle.json'); - - const bundle = await renderConfigBundleWithPublisher({ - brandId: values.brand ?? bail('--brand is required'), - channel, - keyringsPath: values.keyrings ?? bail('--keyrings is required'), - outPath, - platform: 'desktop', - publisherDir: values.publisher ?? bail('--publisher is required'), - publisherGitSha: values['publisher-git-sha'] ?? bail('--publisher-git-sha is required'), - releaseManifestPath: values['release-manifest'], - revisionPath: values.revision ?? bail('--revision is required'), - sourceGitSha: values['source-git-sha'] ?? bail('--source-git-sha is required'), - structuralDir: values.structural ?? bail('--structural is required'), - telemetryEndpoint: values['telemetry-endpoint'] ?? bail('--telemetry-endpoint is required'), - }); - - console.log( - `Rendered ${bundle.brandId}/desktop/${bundle.channel} from source ${bundle.provenance.sourceGitSha} ` + - `(revision ${bundle.provenance.configRevisionId}) into apps/desktop/generated`, - ); + if (values.check === true) { + const freshDir = await mkdtemp(join(tmpdir(), 'linkcode-config-check-')); + try { + await renderInto(freshDir, values); + assertNoDrift(freshDir, generatedDir); + console.log('config:render --check: generated output matches the pinned source'); + } finally { + await rm(freshDir, { force: true, recursive: true }); + } + return; + } + await renderInto(generatedDir, values); } void main(); diff --git a/apps/desktop/src/build/__tests__/electron-builder-brand.test.ts b/apps/desktop/src/build/__tests__/electron-builder-brand.test.ts new file mode 100644 index 00000000..1956f538 --- /dev/null +++ b/apps/desktop/src/build/__tests__/electron-builder-brand.test.ts @@ -0,0 +1,79 @@ +import type { BrandIdentityArtifact } from '@linkcode/common/config'; +import { parseBrandIdentityArtifact } from '@linkcode/common/config'; +import { describe, expect, it } from 'vitest'; +import { + electronBuilderBrandConfig, + serializeElectronBuilderBrandConfig, +} from '../electron-builder-brand'; + +const SOURCE_SHA = '0123456789abcdef0123456789abcdef01234567'; + +function identity(overrides: Partial> = {}): BrandIdentityArtifact { + return parseBrandIdentityArtifact({ + applicationId: 'dev.arcbox.acme.desktop', + assetsPath: 'brands/acme', + brandId: 'acme', + brandIdentityVersion: 1, + channel: 'stable', + displayName: 'Acme Studio', + platform: 'desktop', + provenance: { manifestSchemaVersion: 1, sourceGitSha: SOURCE_SHA }, + storageNamespace: 'Acme Studio', + urlScheme: 'acme', + ...overrides, + }); +} + +const ZENITH_CANARY = identity({ + applicationId: 'dev.arcbox.zenith.desktop.canary', + assetsPath: 'brands/zenith', + brandId: 'zenith', + channel: 'canary', + displayName: 'Zenith Workspace Canary', + storageNamespace: 'Zenith Workspace Canary', + urlScheme: 'zenith-canary', +}); + +describe('electronBuilderBrandConfig', () => { + it('derives every identity-owned builder field from the artifact (acme stable)', () => { + expect(serializeElectronBuilderBrandConfig(electronBuilderBrandConfig(identity()))).toBe( + `${JSON.stringify( + { + appId: 'dev.arcbox.acme.desktop', + extends: './electron-builder.yml', + linux: { executableName: 'acme', icon: 'generated/brand-assets/icon.png' }, + mac: { icon: 'generated/brand-assets/icon.png' }, + productName: 'Acme Studio', + protocols: [{ name: 'Acme Studio', schemes: ['acme'] }], + publish: null, + win: { icon: 'generated/brand-assets/icon.png' }, + }, + null, + 2, + )}\n`, + ); + }); + + it('keeps a second brand fully isolated (zenith canary)', () => { + const serialized = serializeElectronBuilderBrandConfig( + electronBuilderBrandConfig(ZENITH_CANARY), + ); + expect(serialized).toContain('"appId": "dev.arcbox.zenith.desktop.canary"'); + expect(serialized).toContain('"productName": "Zenith Workspace Canary"'); + expect(serialized).toContain('"zenith-canary"'); + // Nothing of the other brand, the default product, or internal names leaks in. + expect(serialized).not.toMatch(/acme/i); + expect(serialized.replaceAll('./electron-builder.yml', '')).not.toMatch(/linkcode/i); + }); + + it('serializes deterministically', () => { + const first = serializeElectronBuilderBrandConfig(electronBuilderBrandConfig(ZENITH_CANARY)); + const second = serializeElectronBuilderBrandConfig(electronBuilderBrandConfig(ZENITH_CANARY)); + expect(second).toBe(first); + }); + + it('refuses a non-desktop identity', () => { + const ios = identity({ applicationId: 'dev.arcbox.acme', platform: 'ios' }); + expect(() => electronBuilderBrandConfig(ios)).toThrow(/requires a desktop identity/); + }); +}); diff --git a/apps/desktop/src/build/electron-builder-brand.ts b/apps/desktop/src/build/electron-builder-brand.ts new file mode 100644 index 00000000..4e7339b3 --- /dev/null +++ b/apps/desktop/src/build/electron-builder-brand.ts @@ -0,0 +1,65 @@ +import type { BrandIdentityArtifact } from '@linkcode/common/config'; + +/** + * Derives the electron-builder overlay for one rendered brand identity. Identity-owned fields + * only — everything else (files, publish feed, targets) stays in the shared electron-builder.yml + * base this overlay extends. Never write these fields by hand for a branded build. + */ +export interface ElectronBuilderBrandConfig { + readonly appId: string; + readonly extends: string; + readonly linux: { readonly executableName: string; readonly icon: string }; + readonly mac: { readonly icon: string }; + readonly productName: string; + readonly protocols: readonly [{ readonly name: string; readonly schemes: readonly [string] }]; + /** Always null: the base config's update feed belongs to the default product. A branded + * artifact must never auto-update from another brand's feed; per-brand feeds are release + * orchestration (CODE-559), not identity. */ + readonly publish: null; + readonly win: { readonly icon: string }; +} + +/** Staged brand icon, relative to apps/desktop (the generated config lives in generated/). */ +export const BRAND_ICON_PATH = 'generated/brand-assets/icon.png'; + +export function electronBuilderBrandConfig( + identity: BrandIdentityArtifact, +): ElectronBuilderBrandConfig { + if (identity.platform !== 'desktop') { + throw new Error(`electron-builder config requires a desktop identity, got ${identity.platform}`); + } + return { + appId: identity.applicationId, + // app-builder-lib resolves `extends` against the PROJECT dir, not the config file: that is + // apps/desktop when packing in place and the staging root under package-app.mts (the deploy + // copies electron-builder.yml there) — both hold the base config at ./electron-builder.yml. + extends: './electron-builder.yml', + // The base names the executable after the default product; the brand id is the stable, + // filesystem-safe brand analog (process name and .desktop wmclass are user-visible). + linux: { executableName: identity.brandId, icon: BRAND_ICON_PATH }, + mac: { icon: BRAND_ICON_PATH }, + productName: identity.displayName, + protocols: [{ name: identity.displayName, schemes: [identity.urlScheme] }], + publish: null, + win: { icon: BRAND_ICON_PATH }, + }; +} + +/** Deterministic bytes: two-space indent, lexicographically sorted keys, trailing newline — + * the same serialization contract as the publisher's identity artifact. */ +export function serializeElectronBuilderBrandConfig(config: ElectronBuilderBrandConfig): string { + return `${JSON.stringify(sortedJson(config), null, 2)}\n`; +} + +function sortedJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map((entry) => sortedJson(entry)); + if (typeof value === 'object' && value !== null) { + const record = value as Record; + return Object.fromEntries( + Object.keys(record) + .sort() + .map((key) => [key, sortedJson(record[key])]), + ); + } + return value; +} From 97acb9c2e5dd2f49d094eabe629c86631344f794 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 3 Aug 2026 20:12:31 +0000 Subject: [PATCH 5/7] feat(mobile): apply the rendered brand to Expo config before prebuild --- apps/mobile/app.config.ts | 62 +++++ apps/mobile/scripts/render-config-bundle.mts | 215 ++++++++++++++---- .../src/build/__tests__/expo-brand.test.ts | 180 +++++++++++++++ apps/mobile/src/build/expo-brand.ts | 213 +++++++++++++++++ apps/mobile/tsconfig.json | 3 +- 5 files changed, 627 insertions(+), 46 deletions(-) create mode 100644 apps/mobile/app.config.ts create mode 100644 apps/mobile/src/build/__tests__/expo-brand.test.ts create mode 100644 apps/mobile/src/build/expo-brand.ts diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts new file mode 100644 index 00000000..ceecf0c3 --- /dev/null +++ b/apps/mobile/app.config.ts @@ -0,0 +1,62 @@ +// Dynamic Expo config (CODE-558): app.json stays the default-product base; when +// scripts/render-config-bundle.mts has rendered a brand into generated/, every identity-owned +// field is replaced from that immutable overlay before prebuild — there is no runtime mutation +// of native identity and no partial application: an incomplete or inconsistent generated set +// aborts config evaluation instead of falling back to the default brand. +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { BrandIdentityArtifact } from '@linkcode/common/config'; +import type { ConfigContext, ExpoConfig } from 'expo/config'; +// The explicit .ts extension is load-bearing: Expo's config evaluator transpiles only this +// entry file, so the import must resolve through Node's own require — which loads .ts (type +// stripping, Node >= 24) only when the extension is spelled out. +import { + applyBrandExpoConfig, + deriveExpoBrandOverlay, + parseExpoBrandOverlay, + serializeExpoBrandOverlay, +} from './src/build/expo-brand.ts'; + +const PNG_MAGIC = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + +function loadGeneratedBrand(): ReturnType | null { + const generatedDir = join(__dirname, 'generated'); + const overlayPath = join(generatedDir, 'expo-brand.json'); + const iosPath = join(generatedDir, 'brand-identity.ios.json'); + const androidPath = join(generatedDir, 'brand-identity.android.json'); + const iconPath = join(generatedDir, 'brand-assets', 'icon.png'); + const present = [overlayPath, iosPath, androidPath, iconPath].filter((path) => existsSync(path)); + if (present.length === 0) return null; + if (present.length !== 4) { + throw new Error( + 'apps/mobile/generated is incomplete — re-run `pnpm -F @linkcode/mobile config:render`', + ); + } + const overlay = parseExpoBrandOverlay(JSON.parse(readFileSync(overlayPath, 'utf8'))); + // The overlay must still be the one derived from the identity artifacts next to it; a + // hand-edited overlay (or a stale one after re-rendering another brand) fails here. Deep + // field validation ran at render time (the renderer only writes publisher-validated + // artifacts); Expo CLI's config evaluator cannot load @linkcode/common at runtime, so this + // re-check is structural: platform split, cross-platform consistency, and overlay equality. + const rederived = deriveExpoBrandOverlay( + JSON.parse(readFileSync(iosPath, 'utf8')) as BrandIdentityArtifact, + JSON.parse(readFileSync(androidPath, 'utf8')) as BrandIdentityArtifact, + ); + if (serializeExpoBrandOverlay(rederived) !== serializeExpoBrandOverlay(overlay)) { + throw new Error( + 'apps/mobile/generated/expo-brand.json does not match the rendered identity artifacts — ' + + 're-run `pnpm -F @linkcode/mobile config:render`', + ); + } + const icon = readFileSync(iconPath); + if (icon.length < PNG_MAGIC.length || !icon.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC)) { + throw new Error('apps/mobile/generated/brand-assets/icon.png is not a PNG'); + } + return overlay; +} + +export default ({ config }: ConfigContext): ExpoConfig => { + const base = config as ExpoConfig; + const overlay = loadGeneratedBrand(); + return overlay === null ? base : applyBrandExpoConfig(base, overlay); +}; diff --git a/apps/mobile/scripts/render-config-bundle.mts b/apps/mobile/scripts/render-config-bundle.mts index 68322f56..c203128f 100644 --- a/apps/mobile/scripts/render-config-bundle.mts +++ b/apps/mobile/scripts/render-config-bundle.mts @@ -1,12 +1,25 @@ -// Renders the iOS and Android build bundles through the pinned config publisher checkout into -// bundled.generated..ts, which Metro resolves over the committed { bundle: null } -// sentinel. Run via `pnpm -F @linkcode/mobile config:render --publisher …` (no `--` separator). -// Every input is an explicit pin; there is no default checkout, no fetch, and no stale fallback. -import { copyFile, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +// Renders the iOS and Android build bundles AND brand identities through the pinned config +// publisher checkout: the bundles land in bundled.generated..ts (which Metro resolves +// over the committed { bundle: null } sentinel) and the identity artifacts, brand assets, and +// Expo overlay land in apps/mobile/generated/ for app.config.ts. Run via +// `pnpm -F @linkcode/mobile config:render --publisher …` (no `--` separator). Every input is an +// explicit pin; there is no default checkout, no fetch, and no stale fallback. `--check` +// re-renders into a temp dir and fails on any byte drift instead of silently regenerating. +import { createHash } from 'node:crypto'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { parseArgs } from 'node:util'; -import { renderConfigBundleWithPublisher } from '@linkcode/common/node'; +import { assertBrandIdentityMatchesBundle } from '@linkcode/common/config'; +import { + renderBrandIdentityWithPublisher, + renderConfigBundleWithPublisher, + stageBrandAssets, +} from '@linkcode/common/node'; +import { appendArrayInPlace } from 'foxts/append-array-in-place'; +import { trueFn } from 'foxts/noop'; +import { deriveExpoBrandOverlay, serializeExpoBrandOverlay } from '../src/build/expo-brand'; const USAGE = String.raw`Usage: config:render \ --publisher config publisher checkout root \ @@ -18,33 +31,28 @@ const USAGE = String.raw`Usage: config:render \ --brand --channel \ --telemetry-endpoint authenticated telemetry endpoint for this target \ --release-manifest-ios optional per-target manifest digest-binding inputs/snapshot \ - --release-manifest-android required together with --release-manifest-ios`; + --release-manifest-android required together with --release-manifest-ios \ + --check verify all generated outputs are byte-identical; never rewrite`; const MOBILE_PLATFORMS = ['ios', 'android'] as const; +type MobilePlatform = (typeof MOBILE_PLATFORMS)[number]; function bail(message: string): never { console.error(`config:render: ${message}\n\n${USAGE}`); process.exit(1); } -async function main(): Promise { - const { values } = parseArgs({ - options: { - brand: { type: 'string' }, - channel: { type: 'string' }, - keyrings: { type: 'string' }, - publisher: { type: 'string' }, - 'publisher-git-sha': { type: 'string' }, - 'release-manifest-android': { type: 'string' }, - 'release-manifest-ios': { type: 'string' }, - revision: { type: 'string' }, - 'source-git-sha': { type: 'string' }, - structural: { type: 'string' }, - 'telemetry-endpoint': { type: 'string' }, - }, - strict: true, - }); +interface RenderRoots { + /** Receives bundled.generated..ts (real: src/runtime/config). */ + readonly configDir: string; + /** Receives brand-identity.*.json, brand-assets/, expo-brand.json (real: generated/). */ + readonly generatedDir: string; +} +async function renderInto( + roots: RenderRoots, + values: Record, +): Promise { const channel = values.channel ?? bail('--channel is required'); if (channel !== 'canary' && channel !== 'stable') { bail('--channel must be canary or stable'); @@ -56,35 +64,56 @@ async function main(): Promise { if ((releaseManifests.ios === undefined) !== (releaseManifests.android === undefined)) { bail('--release-manifest-ios and --release-manifest-android must be passed together'); } - const configDir = resolve(import.meta.dirname, '../src/runtime/config'); - const workDir = await mkdtemp(join(tmpdir(), 'linkcode-config-render-')); + const structuralDir = String(values.structural ?? bail('--structural is required')); + const shared = { + brandId: String(values.brand ?? bail('--brand is required')), + channel, + publisherDir: String(values.publisher ?? bail('--publisher is required')), + publisherGitSha: String(values['publisher-git-sha'] ?? bail('--publisher-git-sha is required')), + sourceGitSha: String(values['source-git-sha'] ?? bail('--source-git-sha is required')), + structuralDir, + } as const; + await mkdir(roots.configDir, { recursive: true }); + await mkdir(roots.generatedDir, { recursive: true }); + const workDir = await mkdtemp(join(tmpdir(), 'linkcode-config-render-')); + const identities = {} as Record< + MobilePlatform, + Awaited> + >; try { // Both platforms render in one invocation so they can never drift apart in source or inputs. for (const platform of MOBILE_PLATFORMS) { const outPath = join(workDir, `${platform}.json`); + const releaseManifest = releaseManifests[platform]; // eslint-disable-next-line no-await-in-loop -- renders share pinned checkouts sequentially const bundle = await renderConfigBundleWithPublisher({ - brandId: values.brand ?? bail('--brand is required'), - channel, - keyringsPath: values.keyrings ?? bail('--keyrings is required'), + ...shared, + keyringsPath: String(values.keyrings ?? bail('--keyrings is required')), outPath, platform, - publisherDir: values.publisher ?? bail('--publisher is required'), - publisherGitSha: values['publisher-git-sha'] ?? bail('--publisher-git-sha is required'), - releaseManifestPath: releaseManifests[platform], - revisionPath: values.revision ?? bail('--revision is required'), - sourceGitSha: values['source-git-sha'] ?? bail('--source-git-sha is required'), - structuralDir: values.structural ?? bail('--structural is required'), - telemetryEndpoint: values['telemetry-endpoint'] ?? bail('--telemetry-endpoint is required'), + ...(typeof releaseManifest === 'string' && { releaseManifestPath: releaseManifest }), + revisionPath: String(values.revision ?? bail('--revision is required')), + telemetryEndpoint: String( + values['telemetry-endpoint'] ?? bail('--telemetry-endpoint is required'), + ), + }); + // eslint-disable-next-line no-await-in-loop -- one identity render per platform + const identity = await renderBrandIdentityWithPublisher({ + ...shared, + outPath: resolve(roots.generatedDir, `brand-identity.${platform}.json`), + platform, }); + // Same target, same manifest commit — or one of the two artifacts is stale. + assertBrandIdentityMatchesBundle(identity, bundle); + identities[platform] = identity; // eslint-disable-next-line no-await-in-loop -- read back the file the render just wrote const rendered = await readFile(outPath, 'utf8'); // The generated module shape is load-bearing: smoke-native-entry-export.cjs parses the // object literal back out of it to verify the compiled Hermes export. // eslint-disable-next-line no-await-in-loop -- one small write per platform await writeFile( - join(workDir, `bundled.generated.${platform}.ts`), + join(roots.configDir, `bundled.generated.${platform}.ts`), '// Generated by scripts/render-config-bundle.mts — do not edit, do not commit.\n' + `const generatedConfigModule: unknown = { bundle: ${rendered.trim()} };\n` + 'export default generatedConfigModule;\n', @@ -94,17 +123,113 @@ async function main(): Promise { `${bundle.provenance.sourceGitSha} (revision ${bundle.provenance.configRevisionId})`, ); } - await Promise.all( - MOBILE_PLATFORMS.map((platform) => - copyFile( - join(workDir, `bundled.generated.${platform}.ts`), - join(configDir, `bundled.generated.${platform}.ts`), - ), - ), - ); } finally { await rm(workDir, { force: true, recursive: true }); } + + const overlay = deriveExpoBrandOverlay(identities.ios, identities.android); + stageBrandAssets({ + assetsPath: identities.ios.assetsPath, + outDir: resolve(roots.generatedDir, 'brand-assets'), + structuralDir, + }); + await writeFile( + resolve(roots.generatedDir, 'expo-brand.json'), + serializeExpoBrandOverlay(overlay), + ); + console.log(`Wrote Expo brand overlay for ${overlay.brandId}/${overlay.channel}`); +} + +function listFiles(dir: string, prefix = ''): string[] { + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => + a.name.localeCompare(b.name), + )) { + const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`; + if (entry.isDirectory()) appendArrayInPlace(files, listFiles(join(dir, entry.name), rel)); + else files.push(rel); + } + return files; +} + +function digest(path: string): string { + return createHash('sha256').update(readFileSync(path)).digest('hex'); +} + +/** Byte-compares a fresh render against the on-disk outputs; drift is a hard failure so a stale + * artifact can never ride into a prebuild unnoticed. `relevant` scopes the comparison to the + * files this renderer owns — src/runtime/config also holds committed, non-generated modules. */ +function assertNoDrift( + freshDir: string, + realDir: string, + label: string, + relevant: (file: string) => boolean, +): void { + if (!existsSync(realDir)) { + bail(`--check: ${realDir} does not exist; run config:render without --check first`); + } + const fresh = listFiles(freshDir).filter(relevant); + const real = listFiles(realDir).filter(relevant); + const drifted = new Set(); + for (const file of fresh) { + if (!real.includes(file)) drifted.add(`${file} (missing from ${label})`); + else if (digest(join(freshDir, file)) !== digest(join(realDir, file))) { + drifted.add(`${file} (content drift)`); + } + } + for (const file of real) { + if (!fresh.includes(file)) drifted.add(`${file} (stale extra file)`); + } + if (drifted.size > 0) { + bail( + `--check: ${label} has drifted from the pinned source:\n ${[...drifted].join('\n ')}\n` + + 'Re-run config:render with the pinned inputs and commit nothing — generated output is never checked in', + ); + } +} + +async function main(): Promise { + const { values } = parseArgs({ + options: { + brand: { type: 'string' }, + channel: { type: 'string' }, + check: { type: 'boolean' }, + keyrings: { type: 'string' }, + publisher: { type: 'string' }, + 'publisher-git-sha': { type: 'string' }, + 'release-manifest-android': { type: 'string' }, + 'release-manifest-ios': { type: 'string' }, + revision: { type: 'string' }, + 'source-git-sha': { type: 'string' }, + structural: { type: 'string' }, + 'telemetry-endpoint': { type: 'string' }, + }, + strict: true, + }); + + const roots: RenderRoots = { + configDir: resolve(import.meta.dirname, '../src/runtime/config'), + generatedDir: resolve(import.meta.dirname, '../generated'), + }; + if (values.check === true) { + const freshRoot = await mkdtemp(join(tmpdir(), 'linkcode-config-check-')); + const fresh: RenderRoots = { + configDir: join(freshRoot, 'runtime-config'), + generatedDir: join(freshRoot, 'generated'), + }; + try { + await renderInto(fresh, values); + assertNoDrift(fresh.configDir, roots.configDir, 'src/runtime/config', (file) => + MOBILE_PLATFORMS.some((p) => file === `bundled.generated.${p}.ts`), + ); + assertNoDrift(fresh.generatedDir, roots.generatedDir, 'generated', trueFn); + console.log('config:render --check: generated output matches the pinned source'); + } finally { + await rm(freshRoot, { force: true, recursive: true }); + } + return; + } + await renderInto(roots, values); } void main(); diff --git a/apps/mobile/src/build/__tests__/expo-brand.test.ts b/apps/mobile/src/build/__tests__/expo-brand.test.ts new file mode 100644 index 00000000..c8c45dbb --- /dev/null +++ b/apps/mobile/src/build/__tests__/expo-brand.test.ts @@ -0,0 +1,180 @@ +import type { BrandIdentityArtifact } from '@linkcode/common/config'; +import { describe, expect, it } from 'vitest'; +import baseAppJson from '../../../app.json'; +import type { ExpoBrandableConfig } from '../expo-brand'; +import { + applyBrandExpoConfig, + deriveExpoBrandOverlay, + parseExpoBrandOverlay, + serializeExpoBrandOverlay, +} from '../expo-brand'; + +const SOURCE_SHA = '0123456789abcdef0123456789abcdef01234567'; + +function identity( + platform: 'ios' | 'android', + overrides: Partial = {}, +): BrandIdentityArtifact { + return { + applicationId: 'dev.arcbox.acme', + assetsPath: 'brands/acme', + brandId: 'acme', + brandIdentityVersion: 1, + channel: 'stable', + displayName: 'Acme Studio', + platform, + provenance: { manifestSchemaVersion: 1, sourceGitSha: SOURCE_SHA }, + storageNamespace: 'Acme Studio', + urlScheme: 'acme', + ...overrides, + }; +} + +const ACME = deriveExpoBrandOverlay(identity('ios'), identity('android')); + +const ZENITH = deriveExpoBrandOverlay( + identity('ios', { + applicationId: 'dev.arcbox.zenith.canary', + assetsPath: 'brands/zenith', + brandId: 'zenith', + channel: 'canary', + displayName: 'Zenith Workspace Canary', + storageNamespace: 'Zenith Workspace Canary', + urlScheme: 'zenith-canary', + }), + identity('android', { + applicationId: 'dev.arcbox.zenith.canary', + assetsPath: 'brands/zenith', + brandId: 'zenith', + channel: 'canary', + displayName: 'Zenith Workspace Canary', + storageNamespace: 'Zenith Workspace Canary', + urlScheme: 'zenith-canary', + }), +); + +const BASE = baseAppJson.expo as unknown as ExpoBrandableConfig; + +describe('deriveExpoBrandOverlay', () => { + it('collapses matching platform identities into one overlay', () => { + expect(ACME).toStrictEqual({ + androidPackage: 'dev.arcbox.acme', + brandId: 'acme', + channel: 'stable', + displayName: 'Acme Studio', + iosBundleIdentifier: 'dev.arcbox.acme', + sourceGitSha: SOURCE_SHA, + urlScheme: 'acme', + }); + }); + + it('fails closed on swapped platforms', () => { + expect(() => deriveExpoBrandOverlay(identity('android'), identity('android'))).toThrow( + /expected an ios identity/, + ); + expect(() => deriveExpoBrandOverlay(identity('ios'), identity('ios'))).toThrow( + /expected an android identity/, + ); + }); + + it('fails closed when the two artifacts disagree', () => { + expect(() => + deriveExpoBrandOverlay(identity('ios'), identity('android', { displayName: 'Other' })), + ).toThrow(/displayName differs/); + expect(() => + deriveExpoBrandOverlay(identity('ios'), identity('android', { brandId: 'zenith' })), + ).toThrow(/brandId differs/); + expect(() => + deriveExpoBrandOverlay( + identity('ios'), + identity('android', { + provenance: { + manifestSchemaVersion: 1, + sourceGitSha: 'feedfacefeedfacefeedfacefeedfacefeedface', + }, + }), + ), + ).toThrow(/sourceGitSha differs/); + }); +}); + +describe('overlay serialization', () => { + it('is deterministic and round-trips through the structural parser', () => { + const first = serializeExpoBrandOverlay(ACME); + expect(serializeExpoBrandOverlay(ACME)).toBe(first); + expect(parseExpoBrandOverlay(JSON.parse(first))).toStrictEqual(ACME); + }); + + it('rejects missing, extra, and empty fields', () => { + const valid = JSON.parse(serializeExpoBrandOverlay(ACME)) as Record; + const { urlScheme: _dropped, ...missing } = valid; + expect(() => parseExpoBrandOverlay(missing)).toThrow(/exactly/); + expect(() => parseExpoBrandOverlay({ ...valid, extraField: 'x' })).toThrow(/exactly/); + expect(() => parseExpoBrandOverlay({ ...valid, displayName: '' })).toThrow(/non-empty/); + expect(() => parseExpoBrandOverlay(null)).toThrow(/object/); + }); +}); + +describe('applyBrandExpoConfig', () => { + const branded = applyBrandExpoConfig(BASE, ACME); + + it('replaces every identity-owned field from the overlay', () => { + expect(branded.name).toBe('Acme Studio'); + expect(branded.slug).toBe('acme'); + expect(branded.scheme).toBe('acme'); + expect(branded.icon).toBe('./generated/brand-assets/icon.png'); + expect(branded.ios?.bundleIdentifier).toBe('dev.arcbox.acme'); + expect(branded.ios?.icon).toBe('./generated/brand-assets/icon.png'); + expect(branded.android?.package).toBe('dev.arcbox.acme'); + expect(branded.android?.adaptiveIcon).toStrictEqual({ + backgroundColor: '#FFFFFF', + foregroundImage: './generated/brand-assets/icon.png', + }); + expect(branded.splash?.image).toBe('./generated/brand-assets/icon.png'); + expect(branded.web?.favicon).toBe('./generated/brand-assets/icon.png'); + }); + + it('strips the default product update/EAS wiring instead of inheriting it', () => { + expect(branded.updates).toBeUndefined(); + expect(branded.extra).toStrictEqual({}); + expect(JSON.stringify(branded)).not.toContain('u.expo.dev'); + }); + + it('rebrands user-visible permission prompts and the share app group', () => { + const text = JSON.stringify(branded); + expect(text).toContain('"appGroupId":"group.dev.arcbox.acme"'); + expect(text).toContain('Acme Studio needs camera access'); + expect(text).toContain('Acme Studio needs microphone access'); + expect(text).toContain('Acme Studio uses Face ID'); + }); + + it('leaks nothing of the default product identity', () => { + const text = JSON.stringify(branded); + expect(text).not.toContain('LinkCode'); + expect(text).not.toContain('com.arcboxlabs.linkcode'); + // The daemon discovery service type is shared-core runtime behavior, not brand identity. + expect(text).toContain('_linkcode._tcp'); + }); + + it('keeps non-identity configuration untouched', () => { + expect(branded.orientation).toBe(BASE.orientation); + expect(branded.runtimeVersion).toStrictEqual(BASE.runtimeVersion); + expect(branded.android?.permissions).toStrictEqual(BASE.android?.permissions); + expect(branded.ios?.appleTeamId).toBe(BASE.ios?.appleTeamId); + expect(branded.plugins?.length).toBe(BASE.plugins?.length); + }); + + it('keeps a second brand fully isolated (zenith canary)', () => { + const zenith = applyBrandExpoConfig(BASE, ZENITH); + const text = JSON.stringify(zenith); + expect(zenith.ios?.bundleIdentifier).toBe('dev.arcbox.zenith.canary'); + expect(zenith.android?.package).toBe('dev.arcbox.zenith.canary'); + expect(zenith.scheme).toBe('zenith-canary'); + expect(text).not.toContain('LinkCode'); + expect(text).not.toMatch(/acme/i); + }); + + it('is deterministic', () => { + expect(applyBrandExpoConfig(BASE, ACME)).toStrictEqual(branded); + }); +}); diff --git a/apps/mobile/src/build/expo-brand.ts b/apps/mobile/src/build/expo-brand.ts new file mode 100644 index 00000000..df192d0c --- /dev/null +++ b/apps/mobile/src/build/expo-brand.ts @@ -0,0 +1,213 @@ +// Build-time only (config:render + app.config.ts): derives the Expo brand overlay from the two +// rendered mobile identity artifacts and applies it to the static app.json base. Never imported +// by runtime code — native identity is fixed at prebuild and must not be re-derived on device. +import type { BrandIdentityArtifact } from '@linkcode/common/config'; +import type { ExpoConfig } from 'expo/config'; + +/** One overlay for both mobile platforms: the shared fields must agree across the ios and + * android artifacts (same manifest render), while each platform keeps its own application id. */ +export interface ExpoBrandOverlay { + readonly androidPackage: string; + readonly brandId: string; + readonly channel: string; + readonly displayName: string; + readonly iosBundleIdentifier: string; + readonly sourceGitSha: string; + readonly urlScheme: string; +} + +/** Staged brand icon, relative to apps/mobile (where app.config.ts resolves asset paths). */ +export const MOBILE_BRAND_ICON_PATH = './generated/brand-assets/icon.png'; + +function fail(message: string): never { + throw new Error(message); +} + +function requireEqual(field: string, ios: unknown, android: unknown): void { + if (ios !== android) { + fail( + `brand identity ${field} differs between ios (${String(ios)}) and android ` + + `(${String(android)}); both artifacts must come from one manifest render`, + ); + } +} + +/** Collapses the two platform identities into one overlay, failing closed on any disagreement — + * mixed-render artifacts must never produce a build that is one brand on iOS and another on + * Android. */ +export function deriveExpoBrandOverlay( + ios: BrandIdentityArtifact, + android: BrandIdentityArtifact, +): ExpoBrandOverlay { + if (ios.platform !== 'ios') fail(`expected an ios identity, got ${ios.platform}`); + if (android.platform !== 'android') { + fail(`expected an android identity, got ${android.platform}`); + } + requireEqual('brandId', ios.brandId, android.brandId); + requireEqual('channel', ios.channel, android.channel); + requireEqual('displayName', ios.displayName, android.displayName); + requireEqual('urlScheme', ios.urlScheme, android.urlScheme); + requireEqual('assetsPath', ios.assetsPath, android.assetsPath); + requireEqual( + 'provenance.sourceGitSha', + ios.provenance.sourceGitSha, + android.provenance.sourceGitSha, + ); + return { + androidPackage: android.applicationId, + brandId: ios.brandId, + channel: ios.channel, + displayName: ios.displayName, + iosBundleIdentifier: ios.applicationId, + sourceGitSha: ios.provenance.sourceGitSha, + urlScheme: ios.urlScheme, + }; +} + +/** Deterministic bytes: two-space indent, lexicographically sorted keys, trailing newline — + * the same serialization contract as the publisher's identity artifact. */ +export function serializeExpoBrandOverlay(overlay: ExpoBrandOverlay): string { + return `${JSON.stringify( + Object.fromEntries( + Object.entries(overlay).sort(([a], [b]) => (a < b ? -1 : 1)), + ), + null, + 2, + )}\n`; +} + +const OVERLAY_KEYS = [ + 'androidPackage', + 'brandId', + 'channel', + 'displayName', + 'iosBundleIdentifier', + 'sourceGitSha', + 'urlScheme', +] as const; + +/** Structural check for the overlay JSON read back by app.config.ts. Deep identity validation + * already happened at render time; this rejects truncated or hand-edited files. */ +export function parseExpoBrandOverlay(value: unknown): ExpoBrandOverlay { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + fail('expo brand overlay must be a JSON object'); + } + const record = value as Record; + const keys = Object.keys(record).sort(); + if (keys.length !== OVERLAY_KEYS.length || keys.some((key, i) => key !== OVERLAY_KEYS[i])) { + fail(`expo brand overlay must contain exactly: ${OVERLAY_KEYS.join(', ')}`); + } + for (const key of OVERLAY_KEYS) { + if (typeof record[key] !== 'string' || record[key] === '') { + fail(`expo brand overlay field ${key} must be a non-empty string`); + } + } + return record as unknown as ExpoBrandOverlay; +} + +/** The default product name as it appears in user-facing template strings of the base config + * (permission prompts). Only exact-case occurrences are rebranded; lowercase protocol/service + * identifiers (`_linkcode._tcp`) are shared-core runtime contracts and stay untouched. */ +const DEFAULT_PRODUCT_NAME = /LinkCode/g; + +function rebrandStrings(value: unknown, displayName: string): unknown { + if (typeof value === 'string') return value.replace(DEFAULT_PRODUCT_NAME, displayName); + if (Array.isArray(value)) return value.map((entry) => rebrandStrings(entry, displayName)); + if (typeof value === 'object' && value !== null) { + return Object.fromEntries( + Object.entries(value as Record).map(([key, entry]) => [ + key, + rebrandStrings(entry, displayName), + ]), + ); + } + return value; +} + +/** SDK 57's ExpoConfig type dropped the root `splash` key, but app.json still carries it and + * prebuild still honors it — keep it typed so the brand image replacement is checked. */ +export type ExpoBrandableConfig = ExpoConfig & { + readonly splash?: Record; +}; + +type PluginEntry = NonNullable[number]; + +/** Permission-prompt strings owned by these plugins are user-visible and carry the product + * name; everything else in the plugin list (sentry project, gradle tweaks) is not identity. */ +const REBRANDED_PLUGINS = new Set(['expo-audio', 'expo-local-authentication']); + +function rebrandPlugin( + entry: PluginEntry, + overlay: ExpoBrandOverlay, +): PluginEntry { + if (!Array.isArray(entry)) return entry; + const [name, props] = entry; + if (name === 'expo-sharing' && typeof props === 'object' && props !== null) { + const shared = props as Record; + const ios = shared.ios; + return [ + name, + { + ...shared, + ...(typeof ios === 'object' && ios !== null + ? { + ios: { + ...(ios as Record), + // The app group namespaces the share-extension container; it must follow the + // brand's bundle id or two brands would share (and fight over) one container. + appGroupId: `group.${overlay.iosBundleIdentifier}`, + }, + } + : {}), + }, + ] as PluginEntry; + } + if (typeof name === 'string' && REBRANDED_PLUGINS.has(name)) { + return [name, rebrandStrings(props, overlay.displayName)] as PluginEntry; + } + return entry; +} + +/** + * Applies one rendered brand overlay to the static base config. Everything identity-owned is + * replaced wholesale — name, slug, scheme, application ids, icons/splash, permission prompts, + * share app group — and the default product's update/EAS wiring is stripped: a branded build + * must never publish to or update from the default project's channels (per-brand release + * wiring is CODE-559, not identity). + */ +export function applyBrandExpoConfig( + config: ExpoBrandableConfig, + overlay: ExpoBrandOverlay, + iconPath: string = MOBILE_BRAND_ICON_PATH, +): ExpoBrandableConfig { + const { updates: _updates, description: _description, ...base } = config; + const { eas: _eas, ...extra } = base.extra ?? {}; + return { + ...base, + android: { + ...base.android, + adaptiveIcon: { + backgroundColor: base.android?.adaptiveIcon?.backgroundColor ?? '#FFFFFF', + foregroundImage: iconPath, + }, + package: overlay.androidPackage, + }, + extra, + icon: iconPath, + ios: { + ...base.ios, + bundleIdentifier: overlay.iosBundleIdentifier, + icon: iconPath, + infoPlist: rebrandStrings(base.ios?.infoPlist, overlay.displayName) as Record< + string, + unknown + >, + }, + name: overlay.displayName, + plugins: base.plugins?.map((entry) => rebrandPlugin(entry, overlay)), + scheme: overlay.urlScheme, + slug: overlay.brandId, + splash: { ...base.splash, image: iconPath }, + web: { ...base.web, favicon: iconPath }, + }; +} diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index c174d273..fd5e0c7d 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -6,7 +6,8 @@ "paths": { "@mobile/*": ["./src/*"] }, + "allowImportingTsExtensions": true, "strict": true }, - "include": ["src", "modules", "scripts", "vitest.config.ts"] + "include": ["src", "modules", "scripts", "app.config.ts", "vitest.config.ts"] } From cce8705674db07320faf9fa629039514df5554bf Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 4 Aug 2026 07:23:09 +0000 Subject: [PATCH 6/7] fix(mobile): render startup mark from brand assets Amp-Thread-ID: https://ampcode.com/threads/T-019fcb86-b39a-718c-8565-6dd3a5941d91 --- apps/mobile/metro.config.js | 32 +++++++++++++++++-- .../src/components/shell/brand-mark-icon.ts | 1 + .../src/components/shell/brand-mark.tsx | 2 +- 3 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/src/components/shell/brand-mark-icon.ts diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index cea21198..20d789d3 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -1,8 +1,9 @@ // Expo monorepo Metro config + Uniwind + Sentry. // getSentryExpoConfig wraps Expo's default config with Sentry's source-map serializer. +const { existsSync } = require('node:fs'); +const path = require('node:path'); const { getSentryExpoConfig } = require('@sentry/react-native/metro'); const { withUniwindConfig } = require('uniwind/metro'); -const path = require('node:path'); const projectRoot = __dirname; const workspaceRoot = path.resolve(projectRoot, '../..'); @@ -18,11 +19,36 @@ config.resolver.nodeModulesPaths = [ path.resolve(workspaceRoot, 'node_modules'), ]; -// 3) Bundle the terminal's self-hosted font in native and web exports. +// 3) Native identity and the in-app mark must select one complete generated asset set or none. +const brandMarkModule = '@mobile/components/shell/brand-mark-icon'; +const generatedBrandIcon = path.resolve(projectRoot, 'generated/brand-assets/icon.png'); +const generatedBrandFiles = [ + path.resolve(projectRoot, 'generated/expo-brand.json'), + path.resolve(projectRoot, 'generated/brand-identity.ios.json'), + path.resolve(projectRoot, 'generated/brand-identity.android.json'), + generatedBrandIcon, +]; +config.resolver.resolveRequest = (context, moduleName, platform) => { + let request = moduleName; + if (moduleName === brandMarkModule) { + const present = generatedBrandFiles.filter((file) => existsSync(file)); + if (present.length !== 0 && present.length !== generatedBrandFiles.length) { + throw new Error( + 'apps/mobile/generated is incomplete — re-run `pnpm -F @linkcode/mobile config:render`', + ); + } + if (present.length === generatedBrandFiles.length) { + request = generatedBrandIcon; + } + } + return context.resolveRequest(context, request, platform); +}; + +// 4) Bundle the terminal's self-hosted font in native and web exports. // expo-sqlite's web worker imports WASM, while the DOM terminal bundles local WOFF2 fonts. config.resolver.assetExts.push('wasm', 'woff2'); -// 4) Apply Uniwind, compiling ./src/global.css and generating className typings. +// 5) Apply Uniwind, compiling ./src/global.css and generating className typings. module.exports = withUniwindConfig(config, { cssEntryFile: './src/global.css', dtsFile: './src/uniwind-types.d.ts', diff --git a/apps/mobile/src/components/shell/brand-mark-icon.ts b/apps/mobile/src/components/shell/brand-mark-icon.ts new file mode 100644 index 00000000..76da0963 --- /dev/null +++ b/apps/mobile/src/components/shell/brand-mark-icon.ts @@ -0,0 +1 @@ +export { default } from '../../../assets/splash-icon.png'; diff --git a/apps/mobile/src/components/shell/brand-mark.tsx b/apps/mobile/src/components/shell/brand-mark.tsx index 98a35d49..a10c140d 100644 --- a/apps/mobile/src/components/shell/brand-mark.tsx +++ b/apps/mobile/src/components/shell/brand-mark.tsx @@ -1,5 +1,5 @@ +import icon from '@mobile/components/shell/brand-mark-icon'; import { Image, StyleSheet, View } from 'react-native'; -import icon from '../../../assets/splash-icon.png'; /** * The app icon as an in-app brand mark: the transparent splash glyph on a From 77f3fb89d4268ffa93a9cc70d5281b5496b7318e Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 4 Aug 2026 07:51:36 +0000 Subject: [PATCH 7/7] fix(desktop): bind packaged identity to rendered brand Amp-Thread-ID: https://ampcode.com/threads/T-019fcb86-b39a-718c-8565-6dd3a5941d91 --- apps/desktop/scripts/config-bundle.mts | 132 ++++++++++++++---- apps/desktop/scripts/package-app.mts | 10 +- apps/desktop/scripts/package-config.mts | 75 ++++++++++ .../src/__tests__/config-bundle.test.ts | 77 +++++++++- apps/desktop/turbo.json | 3 +- 5 files changed, 260 insertions(+), 37 deletions(-) create mode 100644 apps/desktop/scripts/package-config.mts diff --git a/apps/desktop/scripts/config-bundle.mts b/apps/desktop/scripts/config-bundle.mts index e8fe3c42..4bdf5488 100644 --- a/apps/desktop/scripts/config-bundle.mts +++ b/apps/desktop/scripts/config-bundle.mts @@ -3,8 +3,9 @@ // validates it with the frozen v1 loader, derives the inlined bootstrap from the validated object // in-process, and stages the exact bytes it parsed. Any ambient MAIN_VITE_CONFIG_BOOTSTRAP is a // hard error — generated output cannot be overridden. -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { appendArrayInPlace } from 'foxts/append-array-in-place'; import { isObjectEmpty } from 'foxts/is-object-empty'; // Relative on purpose: this module is inlined into the bundled Vite config, which runs under // plain Node — Node cannot resolve the package's extensionless TS source exports. @@ -13,12 +14,13 @@ import { configBuildBundleDefaults, parseConfigBuildBundle, } from '../../../packages/foundation/common/src/config/build-bundle'; // eslint-disable-line import-x/no-relative-packages -- Vite must inline this source dependency. +import { + electronBuilderBrandConfig, + serializeElectronBuilderBrandConfig, +} from '../src/build/electron-builder-brand'; -export interface GeneratedConfigBundle { +interface GeneratedConfigBundleBase { readonly bootstrapJson: string; - /** Present only on white-label renders (config:render --brand-artifacts); the default product - * never has one and keeps its built-in identity. */ - readonly brandIdentityJson?: string; readonly bundleText: string; } @@ -27,26 +29,73 @@ const CONFORMANCE_FIXTURE_PUBLIC_KEYS = new Set([ 'PUAXw-hDiVqStwqnTRt-vJyYLM8uxJaMwM1V8Sr0Zgw', '_FHNjmIYoaONpH7QAjDwWAgW7RO6MwOsXeuRFUiQgCU', ]); +interface DefaultGeneratedConfigBundle extends GeneratedConfigBundleBase { + readonly brandBuilderConfigText?: undefined; + readonly brandIconBytes?: undefined; + readonly brandIdentityJson?: undefined; +} + +interface BrandedGeneratedConfigBundle extends GeneratedConfigBundleBase { + readonly brandBuilderConfigText: string; + readonly brandIconBytes: Uint8Array; + readonly brandIdentityJson: string; +} + +export type GeneratedConfigBundle = BrandedGeneratedConfigBundle | DefaultGeneratedConfigBundle; + +const DEFAULT_BRAND_ID = 'linkcode'; +const BUNDLE_FILE = 'config-build-bundle.json'; +const BRAND_IDENTITY_FILE = 'brand-identity.json'; +const BRAND_BUILDER_FILE = 'electron-builder.brand.json'; +const BRAND_ICON_FILE = 'brand-assets/icon.png'; + +function listFiles(dir: string, prefix = ''): string[] { + if (!existsSync(dir)) return []; + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => + a.name.localeCompare(b.name), + )) { + const relative = prefix === '' ? entry.name : `${prefix}/${entry.name}`; + if (entry.isDirectory()) appendArrayInPlace(files, listFiles(join(dir, entry.name), relative)); + else files.push(relative); + } + return files; +} + +function assertExactFiles(dir: string, expected: readonly string[], label: string): void { + const actual = listFiles(dir); + if ( + actual.length === expected.length && + actual.every((file, index) => file === expected[index]) + ) { + return; + } + throw new Error( + `${label} must contain exactly ${expected.length === 0 ? 'no files' : expected.join(', ')}; ` + + 're-run the matching config render/build before packaging', + ); +} export function loadGeneratedConfigBundle( desktopDir: string, env: Readonly>>, ): GeneratedConfigBundle | null { - const bundlePath = resolve(desktopDir, 'generated/config-build-bundle.json'); - const brandIdentityPath = resolve(desktopDir, 'generated/brand-identity.json'); + if (env.MAIN_VITE_BRAND_IDENTITY) { + throw new Error( + 'MAIN_VITE_BRAND_IDENTITY must not be set; desktop identity comes only from generated ' + + 'brand artifacts or the built-in default', + ); + } + const generatedDir = resolve(desktopDir, 'generated'); + const bundlePath = resolve(generatedDir, BUNDLE_FILE); if (!existsSync(bundlePath)) { + assertExactFiles(generatedDir, [], 'apps/desktop/generated without a config bundle'); if (env.LINKCODE_REQUIRE_CONFIG_BUNDLE === '1') { throw new Error( 'LINKCODE_REQUIRE_CONFIG_BUNDLE=1 but apps/desktop/generated has no bundle — run ' + '`pnpm -F @linkcode/desktop config:render` with pinned inputs before building', ); } - if (existsSync(brandIdentityPath)) { - throw new Error( - 'apps/desktop/generated has a brand identity but no config bundle — re-run ' + - '`pnpm -F @linkcode/desktop config:render --brand-artifacts` with pinned inputs', - ); - } return null; } if (env.MAIN_VITE_CONFIG_BOOTSTRAP) { @@ -75,18 +124,23 @@ export function loadGeneratedConfigBundle( 'LINKCODE_REQUIRE_CONFIG_BUNDLE=1 requires an emergency endpoint and emergency public key', ); } - // White-label renders also write the immutable identity artifact; when present it must be the - // same brand/channel/source as the bundle and no ambient override may exist. Deep validation - // (parseBrandIdentityArtifact) throws on any malformed or tampered artifact. - let brandIdentityJson: string | undefined; - if (existsSync(brandIdentityPath)) { - if (env.MAIN_VITE_BRAND_IDENTITY) { - throw new Error( - 'MAIN_VITE_BRAND_IDENTITY must not be set when a generated brand identity exists; ' + - 'the generated brand identity is immutable', - ); - } - brandIdentityJson = readFileSync(brandIdentityPath, 'utf8'); + const branded = bundle.brandId !== DEFAULT_BRAND_ID; + assertExactFiles( + generatedDir, + branded + ? [BRAND_ICON_FILE, BRAND_IDENTITY_FILE, BUNDLE_FILE, BRAND_BUILDER_FILE] + : [BUNDLE_FILE], + 'apps/desktop/generated', + ); + + let brand: + | Pick< + BrandedGeneratedConfigBundle, + 'brandBuilderConfigText' | 'brandIconBytes' | 'brandIdentityJson' + > + | undefined; + if (branded) { + const brandIdentityJson = readFileSync(resolve(generatedDir, BRAND_IDENTITY_FILE), 'utf8'); const identity = parseBrandIdentityArtifact(JSON.parse(brandIdentityJson)); if (identity.platform !== 'desktop') { throw new Error(`generated brand identity targets ${identity.platform}, expected desktop`); @@ -102,6 +156,21 @@ export function loadGeneratedConfigBundle( '`pnpm -F @linkcode/desktop config:render --brand-artifacts`', ); } + const brandBuilderConfigText = readFileSync(resolve(generatedDir, BRAND_BUILDER_FILE), 'utf8'); + const expectedBuilderConfig = serializeElectronBuilderBrandConfig( + electronBuilderBrandConfig(identity), + ); + if (brandBuilderConfigText !== expectedBuilderConfig) { + throw new Error( + 'generated electron-builder brand config does not match brand-identity.json — re-run ' + + '`pnpm -F @linkcode/desktop config:render --brand-artifacts`', + ); + } + brand = { + brandBuilderConfigText, + brandIconBytes: readFileSync(resolve(generatedDir, BRAND_ICON_FILE)), + brandIdentityJson, + }; } // Same shape as DesktopConfigBootstrap (src/main/config.ts); parseBootstrap revalidates it at // runtime after Vite inlines it into the main bundle. @@ -116,7 +185,11 @@ export function loadGeneratedConfigBundle( publicKeys: bundle.keyrings.normal, telemetryEndpoint: bundle.endpoints.telemetry, }; - return { bootstrapJson: JSON.stringify(bootstrap), brandIdentityJson, bundleText }; + const generatedBase = { + bootstrapJson: JSON.stringify(bootstrap), + bundleText, + }; + return brand === undefined ? generatedBase : { ...generatedBase, ...brand }; } /** @@ -133,4 +206,9 @@ export function stageConfigBundle( if (!generated) return; mkdirSync(outConfig, { recursive: true }); writeFileSync(resolve(outConfig, 'build-bundle.json'), generated.bundleText); + if (generated.brandIdentityJson === undefined) return; + writeFileSync(resolve(outConfig, BRAND_IDENTITY_FILE), generated.brandIdentityJson); + writeFileSync(resolve(outConfig, BRAND_BUILDER_FILE), generated.brandBuilderConfigText); + mkdirSync(resolve(outConfig, 'brand-assets'), { recursive: true }); + writeFileSync(resolve(outConfig, BRAND_ICON_FILE), generated.brandIconBytes); } diff --git a/apps/desktop/scripts/package-app.mts b/apps/desktop/scripts/package-app.mts index 568ca5a5..23658340 100644 --- a/apps/desktop/scripts/package-app.mts +++ b/apps/desktop/scripts/package-app.mts @@ -28,6 +28,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import process from 'node:process'; import crossSpawn from 'cross-spawn'; +import { assertStagedConfigMatchesGenerated } from './package-config.mts'; import { mergeUpdateFeeds } from './update-feed.mts'; const HOST_PLATFORM: Partial> = { @@ -166,11 +167,7 @@ const IDENTITY_OVERRIDE_RE = /^-c\.(?:appId|productName|protocols)\b/; function build(): void { // Both extend the shared electron-builder.yml base; each adds its own deep-link scheme (release // `linkcode://`, dev shell `linkcode-dev://`). The base is never passed directly — it has none. - // A branded build (config:render wrote generated/electron-builder.brand.json) uses the generated - // overlay instead: appId, productName, protocol scheme, and icons all come from the rendered - // brand identity, never from hand-edited YAML. - const brandConfig = join(desktopDir, 'generated', 'electron-builder.brand.json'); - const branded = existsSync(brandConfig); + const branded = assertStagedConfigMatchesGenerated(desktopDir); if (branded && devshell) { // out/ already embeds the branded bootstrap+identity; packing it as a dev shell would mix // the LinkCode Development shell identity with another brand's runtime identity. @@ -179,6 +176,7 @@ function build(): void { '--devshell) — a dev shell must not embed another brand', ); } + const brandConfig = join(desktopDir, 'out', 'config', 'electron-builder.brand.json'); if (branded) { const rejected = passthrough.find((arg) => IDENTITY_OVERRIDE_RE.test(arg)); if (rejected !== undefined) { @@ -190,7 +188,7 @@ function build(): void { : branded ? brandConfig : 'electron-builder.release.yml'; - const brandIcon = join(desktopDir, 'generated', 'brand-assets', 'icon.png'); + const brandIcon = join(desktopDir, 'out', 'config', 'brand-assets', 'icon.png'); const feeds = new Map(); for (const arch of stagedArches()) { const target = materializeStaging(arch); diff --git a/apps/desktop/scripts/package-config.mts b/apps/desktop/scripts/package-config.mts new file mode 100644 index 00000000..f79cc829 --- /dev/null +++ b/apps/desktop/scripts/package-config.mts @@ -0,0 +1,75 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { appendArrayInPlace } from 'foxts/append-array-in-place'; + +const CONFIG_FILE_PAIRS = [ + ['config-build-bundle.json', 'build-bundle.json'], + ['brand-identity.json', 'brand-identity.json'], + ['electron-builder.brand.json', 'electron-builder.brand.json'], + ['brand-assets/icon.png', 'brand-assets/icon.png'], +] as const; + +function listFiles(dir: string, prefix = ''): string[] { + if (!existsSync(dir)) return []; + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => + a.name.localeCompare(b.name), + )) { + const relative = prefix === '' ? entry.name : `${prefix}/${entry.name}`; + if (entry.isDirectory()) appendArrayInPlace(files, listFiles(join(dir, entry.name), relative)); + else files.push(relative); + } + return files; +} + +function assertFiles(actual: readonly string[], expected: readonly string[], label: string): void { + if ( + actual.length === expected.length && + actual.every((file, index) => file === expected[index]) + ) { + return; + } + throw new Error( + `${label} does not match a complete desktop config build — rebuild before packaging`, + ); +} + +/** Refuses packaging when generated inputs no longer match the files staged by the Vite build. */ +export function assertStagedConfigMatchesGenerated(desktopDir: string): boolean { + const generatedDir = join(desktopDir, 'generated'); + const outConfig = join(desktopDir, 'out', 'config'); + const generatedFiles = listFiles(generatedDir); + const branded = generatedFiles.includes('brand-identity.json'); + if (generatedFiles.includes('config-build-bundle.json')) { + const bundle = JSON.parse( + readFileSync(join(generatedDir, 'config-build-bundle.json'), 'utf8'), + ) as { brandId?: unknown }; + if (typeof bundle.brandId !== 'string' || (bundle.brandId !== 'linkcode') !== branded) { + throw new Error( + 'apps/desktop/generated brand artifacts do not match the config bundle — rebuild before packaging', + ); + } + } + const pairs = + generatedFiles.length === 0 ? [] : branded ? CONFIG_FILE_PAIRS : CONFIG_FILE_PAIRS.slice(0, 1); + assertFiles( + generatedFiles, + pairs.map(([generated]) => generated).sort(), + 'apps/desktop/generated', + ); + assertFiles( + listFiles(outConfig), + pairs.map(([, staged]) => staged).sort(), + 'apps/desktop/out/config', + ); + for (const [generated, staged] of pairs) { + if ( + !readFileSync(join(generatedDir, generated)).equals(readFileSync(join(outConfig, staged))) + ) { + throw new Error( + `apps/desktop/out/config/${staged} does not match generated/${generated} — rebuild before packaging`, + ); + } + } + return branded; +} diff --git a/apps/desktop/src/__tests__/config-bundle.test.ts b/apps/desktop/src/__tests__/config-bundle.test.ts index f5cef245..8b879973 100644 --- a/apps/desktop/src/__tests__/config-bundle.test.ts +++ b/apps/desktop/src/__tests__/config-bundle.test.ts @@ -2,13 +2,24 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { parseBrandIdentityArtifact } from '@linkcode/common/config'; import { keysLength } from 'foxts/property-count'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { loadGeneratedConfigBundle, stageConfigBundle } from '../../scripts/config-bundle.mts'; +import { assertStagedConfigMatchesGenerated } from '../../scripts/package-config.mts'; +import { + electronBuilderBrandConfig, + serializeElectronBuilderBrandConfig, +} from '../build/electron-builder-brand'; const RE_REQUIRED_ABSENT = /LINKCODE_REQUIRE_CONFIG_BUNDLE=1 but apps\/desktop\/generated has no bundle/; +const RE_AMBIENT_IDENTITY = /MAIN_VITE_BRAND_IDENTITY must not be set/; +const RE_BRAND_ARTIFACT_MISMATCH = /brand artifacts do not match the config bundle/; +const RE_BUILDER_MISMATCH = /electron-builder brand config does not match/; +const RE_INCOMPLETE = /must contain exactly/; const RE_IMMUTABLE = /immutable/; +const RE_REBUILD = /rebuild before packaging/; const RE_WRONG_PLATFORM = /targets ios, expected desktop/; const RE_FIXTURE_KEY = /conformance fixture key/; const RE_EMERGENCY_BOOTSTRAP = /requires an emergency endpoint and emergency public key/; @@ -37,16 +48,39 @@ const FIXTURES = join( ); const desktopFixture = readFileSync(join(FIXTURES, 'build-bundle-v1.json'), 'utf8'); const iosFixture = readFileSync(join(FIXTURES, 'build-bundle-v1-ios.json'), 'utf8'); +const brandIdentityFixture = readFileSync(join(FIXTURES, 'brand-identity-v1.json'), 'utf8'); +const brandBuilderFixture = serializeElectronBuilderBrandConfig( + electronBuilderBrandConfig(parseBrandIdentityArtifact(JSON.parse(brandIdentityFixture))), +); +const brandIconFixture = Buffer.from('test brand icon'); const temporaryDirectories: string[] = []; -async function makeDesktopDir(bundleText?: string): Promise { +function isBrandedBundle(bundleText: string | undefined): boolean { + if (bundleText === undefined) return false; + try { + return (JSON.parse(bundleText) as { brandId?: unknown }).brandId !== 'linkcode'; + } catch { + return false; + } +} + +async function makeDesktopDir( + bundleText?: string, + brandArtifacts = isBrandedBundle(bundleText), +): Promise { const dir = await mkdtemp(join(tmpdir(), 'config-bundle-')); temporaryDirectories.push(dir); if (bundleText !== undefined) { mkdirSync(join(dir, 'generated'), { recursive: true }); writeFileSync(join(dir, 'generated/config-build-bundle.json'), bundleText); } + if (brandArtifacts) { + mkdirSync(join(dir, 'generated/brand-assets'), { recursive: true }); + writeFileSync(join(dir, 'generated/brand-identity.json'), brandIdentityFixture); + writeFileSync(join(dir, 'generated/electron-builder.brand.json'), brandBuilderFixture); + writeFileSync(join(dir, 'generated/brand-assets/icon.png'), brandIconFixture); + } return dir; } @@ -80,6 +114,13 @@ describe('loadGeneratedConfigBundle', () => { expect(loadGeneratedConfigBundle(dir, {})).toBeNull(); }); + it('rejects ambient brand identity even without generated output', async () => { + const dir = await makeDesktopDir(); + expect(() => + loadGeneratedConfigBundle(dir, { MAIN_VITE_BRAND_IDENTITY: '{"brandId":"fake"}' }), + ).toThrow(RE_AMBIENT_IDENTITY); + }); + it('fails when LINKCODE_REQUIRE_CONFIG_BUNDLE=1 and no bundle exists', async () => { const dir = await makeDesktopDir(); expect(() => loadGeneratedConfigBundle(dir, { LINKCODE_REQUIRE_CONFIG_BUNDLE: '1' })).toThrow( @@ -166,6 +207,18 @@ describe('loadGeneratedConfigBundle', () => { }, ); + it('rejects a non-default bundle without the complete brand artifact set', async () => { + const dir = await makeDesktopDir(validDesktopFixture, false); + expect(() => loadGeneratedConfigBundle(dir, {})).toThrow(RE_INCOMPLETE); + expect(() => assertStagedConfigMatchesGenerated(dir)).toThrow(RE_BRAND_ARTIFACT_MISMATCH); + }); + + it('rejects a builder overlay that does not match the rendered identity', async () => { + const dir = await makeDesktopDir(validDesktopFixture); + writeFileSync(join(dir, 'generated/electron-builder.brand.json'), '{}\n'); + expect(() => loadGeneratedConfigBundle(dir, {})).toThrow(RE_BUILDER_MISMATCH); + }); + it('derives a bootstrap that the runtime parser accepts, with exact source bytes', async () => { const dir = await makeDesktopDir(validDesktopFixture); const generated = loadGeneratedConfigBundle(dir, {}); @@ -203,8 +256,25 @@ describe('stageConfigBundle', () => { const dir = await makeDesktopDir(validDesktopFixture); const generated = loadGeneratedConfigBundle(dir, {}); stageConfigBundle(dir, generated); - const staged = readFileSync(join(dir, 'out/config/build-bundle.json'), 'utf8'); - expect(staged).toBe(validDesktopFixture); + expect(readFileSync(join(dir, 'out/config/build-bundle.json'), 'utf8')).toBe( + validDesktopFixture, + ); + expect(readFileSync(join(dir, 'out/config/brand-identity.json'), 'utf8')).toBe( + brandIdentityFixture, + ); + expect(readFileSync(join(dir, 'out/config/electron-builder.brand.json'), 'utf8')).toBe( + brandBuilderFixture, + ); + expect(readFileSync(join(dir, 'out/config/brand-assets/icon.png'))).toEqual(brandIconFixture); + expect(assertStagedConfigMatchesGenerated(dir)).toBe(true); + }); + + it('rejects packaging after generated artifacts change without a rebuild', async () => { + const dir = await makeDesktopDir(validDesktopFixture); + const generated = loadGeneratedConfigBundle(dir, {}); + stageConfigBundle(dir, generated); + writeFileSync(join(dir, 'generated/brand-identity.json'), brandIdentityFixture.trim()); + expect(() => assertStagedConfigMatchesGenerated(dir)).toThrow(RE_REBUILD); }); it('removes a stale staged copy when no bundle is rendered', async () => { @@ -213,5 +283,6 @@ describe('stageConfigBundle', () => { writeFileSync(join(dir, 'out/config/build-bundle.json'), 'stale'); stageConfigBundle(dir, null); expect(existsSync(join(dir, 'out/config'))).toBe(false); + expect(assertStagedConfigMatchesGenerated(dir)).toBe(false); }); }); diff --git a/apps/desktop/turbo.json b/apps/desktop/turbo.json index 9f198bb7..5b8f72f8 100644 --- a/apps/desktop/turbo.json +++ b/apps/desktop/turbo.json @@ -5,12 +5,13 @@ "build": { "env": [ "LINKCODE_REQUIRE_CONFIG_BUNDLE", + "MAIN_VITE_BRAND_IDENTITY", "MAIN_VITE_CONFIG_BOOTSTRAP", "MAIN_VITE_SENTRY_DSN", "RENDERER_VITE_POSTHOG_PROJECT_TOKEN", "RENDERER_VITE_POSTHOG_HOST" ], - "inputs": ["$TURBO_DEFAULT$", "generated/config-build-bundle.json"] + "inputs": ["$TURBO_DEFAULT$", "generated/**"] } } }