Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
129 changes: 124 additions & 5 deletions apps/desktop/scripts/config-bundle.mts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,23 @@
// 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.
import { parseBrandIdentityArtifact } from '../../../packages/foundation/common/src/config/brand-identity';
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;
readonly bundleText: string;
}
Expand All @@ -21,13 +29,67 @@ 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<Partial<Record<string, string>>>,
): GeneratedConfigBundle | null {
const bundlePath = resolve(desktopDir, 'generated/config-build-bundle.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 ' +
Expand Down Expand Up @@ -62,6 +124,54 @@ export function loadGeneratedConfigBundle(
'LINKCODE_REQUIRE_CONFIG_BUNDLE=1 requires an emergency endpoint and emergency public key',
);
}
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`);
}
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`',
);
}
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.
const bootstrap = {
Expand All @@ -75,7 +185,11 @@ export function loadGeneratedConfigBundle(
publicKeys: bundle.keyrings.normal,
telemetryEndpoint: bundle.endpoints.telemetry,
};
return { bootstrapJson: JSON.stringify(bootstrap), bundleText };
const generatedBase = {
bootstrapJson: JSON.stringify(bootstrap),
bundleText,
};
return brand === undefined ? generatedBase : { ...generatedBase, ...brand };
}

/**
Expand All @@ -92,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);
}
40 changes: 34 additions & 6 deletions apps/desktop/scripts/package-app.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<NodeJS.Platform, BuilderPlatform>> = {
Expand Down Expand Up @@ -159,10 +160,35 @@ 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';
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.
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',
);
}
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) {
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, 'out', 'config', 'brand-assets', 'icon.png');
const feeds = new Map<string, string>();
for (const arch of stagedArches()) {
const target = materializeStaging(arch);
Expand All @@ -180,15 +206,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,
],
Expand Down
75 changes: 75 additions & 0 deletions apps/desktop/scripts/package-config.mts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading