Skip to content
Merged
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
11 changes: 3 additions & 8 deletions .github/workflows/build-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,9 @@ jobs:
- name: Package with electron-builder
working-directory: ${{ env.DESKTOP_DIR }}
shell: bash # unify quoting across runners for the conditional signing args below
# package-app.mts materializes a single-importer staging dir and points electron-builder's
# --projectDir at it (see the script header): fixes the Windows native-rebuild miss and the
# module-collector drops without the app-builder-lib patch. azureSignOptions is forwarded via
# -c only on signed Windows builds so unsigned builds skip signing instead of failing.
# package-app.mts materializes one CPU-scoped staging dir per target arch and points
# electron-builder's --projectDir at it (see the script header). azureSignOptions is
# forwarded via -c only on signed Windows builds so unsigned builds skip signing.
run: |
# electron-builder treats a *set but empty* CSC_LINK as a certificate path and dies on
# "<projectDir> not a file". Actions cannot conditionally unset an env key, so the cert
Expand All @@ -160,13 +159,9 @@ jobs:
# Electron 43 needs Clang 15; the arm64 rebuild also needs an explicit cross target.
CC=clang-15 CXX=clang++-15 \
node scripts/package-app.mts linux --x64 --publish never
mv release/linux-unpacked "$RUNNER_TEMP/linkcode-linux-x64-unpacked"
CC='clang-15 --target=aarch64-linux-gnu' \
CXX='clang++-15 --target=aarch64-linux-gnu' \
node scripts/package-app.mts linux --arm64 --publish never
# Single-arch Linux builds both use linux-unpacked; preserve the verifier's arch layout.
mv release/linux-unpacked release/linux-arm64-unpacked
mv "$RUNNER_TEMP/linkcode-linux-x64-unpacked" release/linux-unpacked
else
node scripts/package-app.mts ${{ matrix.platform }} --publish never \
${{ (runner.os == 'Windows' && inputs.sign) && format('-c.win.azureSignOptions.publisherName="{0}" -c.win.azureSignOptions.endpoint="{1}" -c.win.azureSignOptions.codeSigningAccountName="{2}" -c.win.azureSignOptions.certificateProfileName="{3}"', env.AZURE_PUBLISHER_NAME, env.AZURE_SIGN_ENDPOINT, env.AZURE_CODE_SIGNING_ACCOUNT, env.AZURE_CERTIFICATE_PROFILE) || '' }}
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ The rules below are desktop-only — the **system plane**.
## Packaging & asar invariants

- **`electron-builder.yml` is the packaging config** (YAML, not a `package.json` build key). `node scripts/build.mts` (three plain-Vite builds — `vite.{main,preload,renderer}.config.ts`, in that order; `scripts/dev.mts` is the dev counterpart) compiles into `out/`; electron-builder only wraps `out/**` + `package.json`. `directories.output=release` must equal `OUTPUT_DIR` in `build-desktop.yml`; `buildResources=build-resources` (NOT `build/`, which is a gitignored turbo output). `electronVersion` is hardcoded (electron-builder can't read pnpm's `catalog:` protocol) — the pinned number and its sync rule live in [`docs/RELEASE.md`](../../docs/RELEASE.md).
- **Packaging always goes through `scripts/package-app.mts` (CODE-107), never a bare `electron-builder`.** It `pnpm --prod deploy`s the app's production closure into a self-contained staging dir **outside** the workspace, then runs electron-builder with `--projectDir` pointed there so `appDir === projectDir === workspaceRoot`. This is what makes `@electron/rebuild` find better-sqlite3 (pnpm hoists it to the repo-root `node_modules`, which electron-builder's Windows workspace-root probe can't locate — the pre-CODE-107 daemon shipped an un-rebuilt binding and died at boot on Windows) and makes the asar module collector see one importer instead of the whole monorepo. `package:devshell` uses `electron-builder.devshell.yml` + `--dir`; `package` is the production variant; both run through the script, which redirects `directories.output` back to `release/` and the icons to the shared `assets/`. There is no `dist` script (release packaging is CI-only).
- **Packaging always goes through `scripts/package-app.mts` (CODE-107), never a bare `electron-builder`.** It runs `pnpm --prod deploy --cpu=<arch>` into one staging dir per target architecture **outside** the workspace, then runs electron-builder once per dir with `--projectDir` pointed there. The CPU-scoped closure carries only that arch's native optional bindings; `appDir === projectDir === workspaceRoot` makes `@electron/rebuild` find better-sqlite3 on Windows and makes the asar collector see one importer instead of the whole monorepo. macOS/Windows update manifests are merged after the single-arch builds; Linux feed names are already arch-specific. `package:devshell` uses `electron-builder.devshell.yml` + `--dir`; `package` is the production variant; both redirect `directories.output` back to `release/` and icons to shared `assets/`. There is no `dist` script (release packaging is CI-only).
- **`extraResources` ship real executables OUTSIDE the asar — the OS cannot exec an asar member:** `sidecar/${arch}` (the `linkcode-pty` PTY sidecar everywhere, plus the `linkcode-sim` iOS Simulator sidecar on macOS — the crate list lives in `scripts/stage-sidecar.mts`). `sidecar/` and `release/` are gitignored, so a fresh clone has none until staged; `pnpm -F @linkcode/desktop stage:host-runtime` builds the daemon + stages the sidecars. **Agent CLI binaries do not ship** (CODE-114): `files` globs exclude the SDK platform packages from the asar (each is ~220 MB, host-arch only — broken cross-arch anyway), and the daemon spawns a detected user install or a managed download instead (resolution in [`packages/host/agent-adapter/AGENTS.md`](../../packages/host/agent-adapter/AGENTS.md)); The pi npm closure is likewise absent (CODE-219): its SDK is a devDependency of agent-adapter, so the `--prod deploy` staging never contains it — the daemon downloads it into the asset store on first use. `verify-artifacts.mts` fails the build if a platform package, a pi-closure package, or `Resources/agent-bin` sneaks back in, or any artifact exceeds 200 MB.
- **The bundled daemon comes from the `bundle-daemon-artifact` plugin** in `vite.main.config.ts`: it copies `apps/daemon/dist/index.js` → `out/daemon/index.mjs` (and `instrument.js` → `instrument.mjs` for Sentry preload; both renamed `.mjs` because they leave the daemon's `type=module` scope) and `apps/daemon/drizzle` → `out/drizzle`. It throws `` apps/daemon/dist is missing — run `pnpm -F @linkcode/daemon build` first `` if the daemon wasn't built; turbo `^build` guarantees ordering.
- **`linkcode://` deep-link registration differs by build path.** Packaged builds register it via the `electron-builder.yml` `protocols` block (macOS Info.plist + Windows installer); dev shells register it at runtime via `setAsDefaultProtocolClient` (`cloud-auth/client.ts`). OAuth callback routing therefore depends on how the app was shipped.
Expand Down
16 changes: 5 additions & 11 deletions apps/desktop/electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,8 @@ mac:
entitlementsInherit: build-resources/entitlements.mac.inherit.plist
notarize: true # notarytool via App Store Connect API key (APPLE_API_KEY / _KEY_ID / _ISSUER)
target:
- target: dmg
arch: [x64, arm64]
- target: zip # REQUIRED for macOS auto-update (electron-updater pulls the .zip, not the .dmg)
arch: [x64, arm64]
- dmg
- zip # REQUIRED for macOS auto-update (electron-updater pulls the .zip, not the .dmg)

# UDBZ (bzip2) instead of the default ULFO (lzfse): smaller first-download dmg for slower mount.
# ULMO (lzma) would be smaller still but electron-builder's schema doesn't accept it. Only the
Expand All @@ -131,9 +129,7 @@ dmg:

win:
icon: ../../assets/icon.png # shared repo-root raster (no Liquid Glass on Windows)
target:
- target: nsis
arch: [x64, arm64]
target: nsis

nsis:
oneClick: false
Expand All @@ -155,10 +151,8 @@ linux:
# executable explicitly.
executableName: linkcode
target:
- target: AppImage # the only Linux format electron-updater can self-update
arch: [x64, arm64]
- target: deb
arch: [x64, arm64]
- AppImage # the only Linux format electron-updater can self-update
- deb
# rpm/snap need extra host tooling (rpm/fakeroot, snapcraft); add install steps to
# build-desktop.yml's Linux job before enabling them here.

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"typescript": "catalog:",
"unplugin-icons": "catalog:",
"vite": "catalog:",
"yaml": "^2.9.0",
"zustand": "catalog:"
}
}
108 changes: 71 additions & 37 deletions apps/desktop/scripts/package-app.mts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* Package the desktop app from a materialized, single-importer staging directory (CODE-107):
* Package the desktop app from one materialized, single-importer staging directory per arch:
* `node scripts/package-app.mts [mac|win|linux] [--x64|--arm64] [--devshell] [builder args]`.
*
* Packing apps/desktop in place fails silently twice under pnpm's hoisted layout: on Windows the
Expand All @@ -15,11 +15,20 @@
* better-sqlite3 and the collector sees exactly one importer. The .pnpmfile.cjs
* drizzle-orm↔expo-sqlite sever stays — it keeps the expo tree out of this deploy closure.
*/
import { cpSync, readdirSync, rmSync, statSync } from 'node:fs';
import {
cpSync,
existsSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import process from 'node:process';
import crossSpawn from 'cross-spawn';
import { mergeUpdateFeeds } from './update-feed.mts';

const HOST_PLATFORM: Partial<Record<NodeJS.Platform, BuilderPlatform>> = {
darwin: 'mac',
Expand All @@ -29,6 +38,7 @@ const HOST_PLATFORM: Partial<Record<NodeJS.Platform, BuilderPlatform>> = {
const BUILDER_PLATFORMS = ['mac', 'win', 'linux'] as const;
type BuilderPlatform = (typeof BUILDER_PLATFORMS)[number];
const BUILDER_ARCHES = ['x64', 'arm64'] as const;
type BuilderArch = (typeof BUILDER_ARCHES)[number];

const desktopDir = join(import.meta.dirname, '..');
const repoRoot = join(desktopDir, '..', '..');
Expand All @@ -39,7 +49,9 @@ const releaseDir = join(desktopDir, 'release');
* OUTSIDE the workspace on purpose: a staging dir under the repo would be rediscovered as a
* workspace member, reintroducing the multi-importer collection this flow exists to avoid.
*/
const stagingDir = join(tmpdir(), 'linkcode-desktop-staging');
function stagingDir(arch: BuilderArch): string {
return join(tmpdir(), `linkcode-desktop-staging-${arch}`);
}

/**
* Run a command, inheriting stdio, throwing on failure. cross-spawn is required on Windows: a bare
Expand Down Expand Up @@ -69,22 +81,24 @@ const passthrough = args.filter(
!BUILDER_ARCHES.some((arch) => arg === `--${arch}`),
);

/** Deploy the production closure into a fresh staging dir, then sync the build outputs into it. */
function materializeStaging(): void {
rmSync(stagingDir, { recursive: true, force: true });
/** Deploy one arch's production closure, then sync the build outputs into it. */
function materializeStaging(arch: BuilderArch): string {
const target = stagingDir(arch);
rmSync(target, { recursive: true, force: true });
// --legacy: deploy without pnpm's inject-workspace-packages requirement (v10+ default refusal).
run(
'pnpm',
['--filter', '@linkcode/desktop', '--prod', 'deploy', '--legacy', stagingDir],
['--filter', '@linkcode/desktop', '--prod', 'deploy', '--legacy', `--cpu=${arch}`, target],
repoRoot,
);
// deploy's file selection skips .gitignore'd paths inconsistently across pnpm versions; sync the
// build outputs in explicitly so `files: out/**` and `extraResources: sidecar/${arch}` resolve.
for (const dir of ['out', 'sidecar']) {
const dest = join(stagingDir, dir);
const dest = join(target, dir);
rmSync(dest, { recursive: true, force: true });
cpSync(join(desktopDir, dir), dest, { recursive: true });
}
return target;
}

/** Doc files that must survive the prune: license/attribution texts we redistribute. */
Expand All @@ -100,10 +114,10 @@ const MARKDOWN_RE = /\.(?:md|markdown)$/i;
* `files` globs in electron-builder.yml instead; notably better-sqlite3/deps must stay HERE in
* staging because @electron/rebuild compiles from it before collection.
*/
function pruneStaging(): void {
function pruneStaging(target: string): void {
let files = 0;
let bytes = 0;
for (const entry of readdirSync(join(stagingDir, 'node_modules'), {
for (const entry of readdirSync(join(target, 'node_modules'), {
recursive: true,
withFileTypes: true,
})) {
Expand All @@ -128,10 +142,9 @@ function pruneStaging(): void {
* resolve an arch that wasn't (CI stages both via `stage-sidecar --all`; a local
* `stage:host-runtime` stages just the host).
*/
function stagedArches(): string[] {
const staged = readdirSync(join(desktopDir, 'sidecar')).filter((name) =>
BUILDER_ARCHES.some((arch) => arch === name),
);
function stagedArches(): BuilderArch[] {
const stagedNames = new Set(readdirSync(join(desktopDir, 'sidecar')));
const staged = BUILDER_ARCHES.filter((arch) => stagedNames.has(arch));
if (staged.length === 0) throw new Error('no staged sidecar arch; run stage:host-runtime first');
const arches = requestedArches.length === 0 ? staged : requestedArches;
for (const arch of arches) {
Expand All @@ -140,33 +153,54 @@ function stagedArches(): string[] {
return arches;
}

function updateFeedName(arch: BuilderArch): string {
if (platform === 'mac') return 'latest-mac.yml';
if (platform === 'win') return 'latest.yml';
return arch === 'arm64' ? 'latest-linux-arm64.yml' : 'latest-linux.yml';
}

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 builderArgs = [
'exec',
'electron-builder',
`--${platform}`,
...stagedArches().map((arch) => `--${arch}`),
'--projectDir',
stagingDir,
'--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.
`-c.directories.output=${releaseDir}`,
`-c.mac.icon=${join(assetsDir, 'linkcode.icon')}`,
`-c.win.icon=${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')}`,
...(devshell ? ['--dir'] : []),
...passthrough,
];
run('pnpm', builderArgs, desktopDir);
const feeds = new Map<string, string>();
for (const arch of stagedArches()) {
const target = materializeStaging(arch);
pruneStaging(target);
const feedName = updateFeedName(arch);
const feedPath = join(releaseDir, feedName);
rmSync(feedPath, { force: true });
run(
'pnpm',
[
'exec',
'electron-builder',
`--${platform}`,
`--${arch}`,
'--projectDir',
target,
'--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.
`-c.directories.output=${releaseDir}`,
`-c.mac.icon=${join(assetsDir, 'linkcode.icon')}`,
`-c.win.icon=${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')}`,
...(devshell ? ['--dir'] : []),
...passthrough,
],
desktopDir,
);
if (existsSync(feedPath)) {
const text = readFileSync(feedPath, 'utf8');
const existing = feeds.get(feedName);
feeds.set(feedName, existing === undefined ? text : mergeUpdateFeeds(existing, text));
}
}
for (const [name, text] of feeds) writeFileSync(join(releaseDir, name), text);
}

materializeStaging();
pruneStaging();
build();
21 changes: 21 additions & 0 deletions apps/desktop/scripts/update-feed.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { appendArrayInPlace } from 'foxts/append-array-in-place';
import { parse, stringify } from 'yaml';

interface UpdateFeed {
files: unknown[];
[key: string]: unknown;
}

function parseUpdateFeed(text: string): UpdateFeed {
const value: unknown = parse(text);
if (typeof value !== 'object' || value === null || !Array.isArray((value as UpdateFeed).files)) {
throw new Error('electron-builder update feed has no files array');
}
return value as UpdateFeed;
}

export function mergeUpdateFeeds(first: string, second: string): string {
const merged = parseUpdateFeed(first);
appendArrayInPlace(merged.files, parseUpdateFeed(second).files);
return stringify(merged);
}
32 changes: 32 additions & 0 deletions apps/desktop/src/__tests__/update-feed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { parse } from 'yaml';
import { mergeUpdateFeeds } from '../../scripts/update-feed.mts';

describe('mergeUpdateFeeds', () => {
it('keeps the first arch as the legacy path and lists both arch artifacts', () => {
const first = `version: 0.13.1
files:
- url: LinkCode-0.13.1-x64.zip
sha512: x64
path: LinkCode-0.13.1-x64.zip
sha512: x64
`;
const second = `version: 0.13.1
files:
- url: LinkCode-0.13.1-arm64.zip
sha512: arm64
path: LinkCode-0.13.1-arm64.zip
sha512: arm64
`;

expect(parse(mergeUpdateFeeds(first, second))).toEqual({
version: '0.13.1',
files: [
{ url: 'LinkCode-0.13.1-x64.zip', sha512: 'x64' },
{ url: 'LinkCode-0.13.1-arm64.zip', sha512: 'arm64' },
],
path: 'LinkCode-0.13.1-x64.zip',
sha512: 'x64',
});
});
});
Loading
Loading