authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-13 21:24:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 21:24:20-07:00
log683aa87096899aa191e150429415ecc78966f538
tree7c9414892116ee106b0f6c44e025590d3dc9afca
parentd81278aa6b272bb3d3314266c6d17f22504d19d6
signature Signed by PGP key B5690EEEBB952194

build(desktop): bundle the main process and stage only its native externals (#11410)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

4 files changed, 142 insertions(+), 45 deletions(-)

apps/desktop/vite.config.ts+12-1
......@@ -1,9 +1,18 @@
11import "vite-plus/test/config";
22import { defineConfig } from "vite-plus";
33
4import { isDesktopRuntimeExternalDependency } from "../../scripts/lib/desktop-external-packages.ts";
45import { loadRepoEnv } from "../../scripts/lib/public-config.ts";
56
67const repoEnv = loadRepoEnv();
8
9// The main process is bundled the same way the server CLI is: every JS
10// dependency is inlined and only packages Node must load from disk stay
11// external. The packaged app then installs just those externals, instead of a
12// full production install of apps/desktop's dependency tree next to a server
13// bundle that already carries its own copy of the same libraries.
14const isMainProcessExternal = (id: string) =>
15 id === "electron" || id.startsWith("electron/") || isDesktopRuntimeExternalDependency(id);
716const shouldLaunchElectronAfterPack = process.env.T3CODE_DESKTOP_DEV === "1";
817const publicConfigDefine = {
918 __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: JSON.stringify(
......@@ -55,7 +64,9 @@ export default defineConfig({
5564 ],
5665 clean: true,
5766 deps: {
58 alwaysBundle: (id) => id.startsWith("@t3tools/"),
67 alwaysBundle: (id) => !id.startsWith("node:") && !isMainProcessExternal(id),
68 neverBundle: isMainProcessExternal,
69 onlyBundle: false,
5970 },
6071 ...(shouldLaunchElectronAfterPack ? { onSuccess: "node scripts/dev-electron.mjs" } : {}),
6172 },
scripts/build-desktop-artifact.test.ts+51-14
......@@ -28,6 +28,7 @@ import {
2828 DESKTOP_EXTRA_RESOURCES,
2929 LINUX_CAPTURE_EXTRA_RESOURCES,
3030 LINUX_BROWSER_SECRET_EXTRA_RESOURCES,
31 LINUX_FILE_EXCLUSIONS,
3132 MAC_FILE_EXCLUSIONS,
3233 InvalidMacPasskeyRpDomainError,
3334 InvalidMacPasskeyPublishableKeyError,
......@@ -47,7 +48,7 @@ import {
4748 resolveClerkPasskeyNativeArtifacts,
4849 resolveMacPasskeySigningConfiguration,
4950 resolveDesktopRuntimeDependencies,
50 resolveMacStageDependencies,
51 resolveMergedStageDependencies,
5152 resolveFffNativeDependencies,
5253 resolveBuildOptions,
5354 resolveDesktopBuildIconAssets,
......@@ -367,26 +368,37 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
367368 ),
368369 );
369370
370 it("omits bundled workspace packages from staged desktop dependencies", () => {
371 it("stages only the desktop main-process externals", () => {
371372 assert.deepStrictEqual(
372373 resolveDesktopRuntimeDependencies(
373374 {
375 "@clerk/electron": "catalog:",
376 "@clerk/electron-passkeys": "catalog:",
377 "@crowecawcaw/xa11y": "0.13.0",
374378 "@effect/platform-node": "catalog:",
379 "@napi-rs/keyring": "^1.3.0",
375380 "@t3tools/contracts": "workspace:*",
376381 "@t3tools/shared": "workspace:*",
377 "@t3tools/ssh": "workspace:*",
378 "@t3tools/tailscale": "workspace:*",
382 "dbus-next": "0.10.2",
379383 effect: "catalog:",
380384 electron: "41.5.0",
385 "electron-updater": "^6.6.2",
386 "ffi-rs": "1.3.2",
387 "playwright-core": "1.60.0",
381388 },
382389 {
390 "@clerk/electron": "0.0.37",
391 "@clerk/electron-passkeys": "0.0.3",
383392 "@effect/platform-node": "4.0.0-beta.59",
384393 effect: "4.0.0-beta.59",
385394 },
386395 ),
387396 {
388 "@effect/platform-node": "4.0.0-beta.59",
389 effect: "4.0.0-beta.59",
397 "@clerk/electron-passkeys": "0.0.3",
398 "@crowecawcaw/xa11y": "0.13.0",
399 "@napi-rs/keyring": "^1.3.0",
400 "ffi-rs": "1.3.2",
401 "playwright-core": "1.60.0",
390402 },
391403 );
392404 });
......@@ -559,6 +571,8 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
559571
560572 assert.deepStrictEqual(DESKTOP_FILE_EXCLUSIONS, [
561573 "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*",
574 "!**/*.map",
575 "!**/*.d.cts",
562576 "!apps/desktop/resources/browser-secret",
563577 "!apps/desktop/resources/browser-secret/**/*",
564578 "!apps/desktop/prod-resources/browser-secret",
......@@ -661,6 +675,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
661675 "**/node_modules/@anthropic-ai/claude-agent-sdk-*/**",
662676 "**/node_modules/.bin",
663677 "**/node_modules/.bin/**",
678 "**/*.map",
664679 ]);
665680 assert.deepStrictEqual(mac.dmg, {
666681 title: "T3 Code (Alpha) 1.2.3 Installer",
......@@ -679,7 +694,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
679694 { name: "T3 Code", schemes: ["t3code", "t3code-dev"] },
680695 ]);
681696 assert.deepStrictEqual(mac.files, [...DESKTOP_FILE_EXCLUSIONS, ...MAC_FILE_EXCLUSIONS]);
682 assert.deepStrictEqual(linux.files, DESKTOP_FILE_EXCLUSIONS);
697 assert.deepStrictEqual(linux.files, [...DESKTOP_FILE_EXCLUSIONS, ...LINUX_FILE_EXCLUSIONS]);
683698 assert.deepStrictEqual(win.files, DESKTOP_FILE_EXCLUSIONS);
684699 assert.deepStrictEqual(winWithoutWslPrebuild.files, win.files);
685700 assert.notProperty(mac.mac as Record<string, unknown>, "sign");
......@@ -690,11 +705,15 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
690705 }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))),
691706 );
692707
693 it("excludes Windows terminal binaries only from macOS packages", () => {
708 it("excludes foreign node-pty prebuilds from macOS and Linux packages", () => {
694709 assert.deepStrictEqual(MAC_FILE_EXCLUSIONS, [
695710 "!**/node_modules/node-pty/prebuilds/win32-*/**/*",
696711 "!**/node_modules/node-pty/third_party/conpty/**/*",
697712 ]);
713 assert.deepStrictEqual(LINUX_FILE_EXCLUSIONS, [
714 ...MAC_FILE_EXCLUSIONS,
715 "!**/node_modules/node-pty/prebuilds/darwin-*/**/*",
716 ]);
698717 });
699718
700719 it("unpacks native binaries while keeping their JavaScript and metadata archived", () => {
......@@ -725,9 +744,10 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
725744 }
726745 });
727746
728 it("stages only server runtime externals in macOS packages", () => {
747 it("stages only the externals of both bundles in merged packages", () => {
729748 assert.deepStrictEqual(
730 resolveMacStageDependencies({
749 resolveMergedStageDependencies({
750 platform: "mac",
731751 serverDependencies: {
732752 "@anthropic-ai/claude-agent-sdk": "^0.3.170",
733753 "@ff-labs/fff-node": "0.9.4",
......@@ -737,8 +757,8 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
737757 "node-pty": "1.1.0",
738758 },
739759 desktopDependencies: {
740 "@clerk/electron": "0.0.34",
741 effect: "4.0.0-beta.103",
760 "@napi-rs/keyring": "1.3.0",
761 "playwright-core": "1.60.0",
742762 },
743763 arch: "arm64",
744764 fffNodeVersion: "0.9.4",
......@@ -747,11 +767,28 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
747767 "@ff-labs/fff-node": "0.9.4",
748768 "msgpackr-extract": "3.0.4",
749769 "node-pty": "1.1.0",
750 "@clerk/electron": "0.0.34",
751 effect: "4.0.0-beta.103",
770 "@napi-rs/keyring": "1.3.0",
771 "playwright-core": "1.60.0",
752772 "@ff-labs/fff-bin-darwin-arm64": "0.9.4",
753773 },
754774 );
775
776 assert.deepStrictEqual(
777 resolveMergedStageDependencies({
778 platform: "linux",
779 serverDependencies: { "@ff-labs/fff-node": "0.9.4", "node-pty": "1.1.0", effect: "4.0.0" },
780 desktopDependencies: { "@crowecawcaw/xa11y": "0.13.0" },
781 arch: "x64",
782 fffNodeVersion: "0.9.4",
783 }),
784 {
785 "@ff-labs/fff-node": "0.9.4",
786 "node-pty": "1.1.0",
787 "@crowecawcaw/xa11y": "0.13.0",
788 "@ff-labs/fff-bin-linux-x64-gnu": "0.9.4",
789 "@ff-labs/fff-bin-linux-x64-musl": "0.9.4",
790 },
791 );
755792 });
756793
757794 it("excludes node-pty binaries for the other Windows architecture", () => {
scripts/build-desktop-artifact.ts+41-30
......@@ -34,6 +34,7 @@ import {
3434 selectCliRuntimeExternalDependencies,
3535} from "./lib/cli-external-packages.ts";
3636import { loadRepoEnv } from "./lib/public-config.ts";
37import { selectDesktopRuntimeExternalDependencies } from "./lib/desktop-external-packages.ts";
3738import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts";
3839
3940import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
......@@ -959,6 +960,10 @@ export const DESKTOP_FILE_EXCLUSIONS = [
959960 // so the SDK's optional platform packages (each a ~200MB bundled executable)
960961 // are dead weight. The trailing dash keeps the SDK's own JS package.
961962 "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*",
963 // Nothing in the packaged app enables source maps or serves them: the web
964 // client's maps alone were 50 MB of app.asar that no request ever read.
965 "!**/*.map",
966 "!**/*.d.cts",
962967 "!apps/desktop/resources/browser-secret",
963968 "!apps/desktop/resources/browser-secret/**/*",
964969 "!apps/desktop/prod-resources/browser-secret",
......@@ -978,6 +983,12 @@ export const MAC_FILE_EXCLUSIONS = [
978983 "!**/node_modules/node-pty/prebuilds/win32-*/**/*",
979984 "!**/node_modules/node-pty/third_party/conpty/**/*",
980985] as const;
986// Linux builds node-pty from source, so every prebuild in the package is for
987// another platform (58 MB of it Windows debug symbols).
988export const LINUX_FILE_EXCLUSIONS = [
989 ...MAC_FILE_EXCLUSIONS,
990 "!**/node_modules/node-pty/prebuilds/darwin-*/**/*",
991] as const;
981992
982993// node-pty publishes both Darwin prebuilds in one package. Single-architecture
983994// apps only need the native target; universal apps need both. An omitted arch
......@@ -1013,6 +1024,7 @@ export const WINDOWS_SERVER_ASAR_IGNORE_GLOBS = [
10131024 "**/node_modules/@anthropic-ai/claude-agent-sdk-*/**",
10141025 "**/node_modules/.bin",
10151026 "**/node_modules/.bin/**",
1027 "**/*.map",
10161028] as const;
10171029
10181030export function resolveWindowsServerAsarIgnoreGlobs(arch: typeof BuildArch.Type) {
......@@ -1357,7 +1369,10 @@ export function resolveFffNativeDependencies(
13571369 );
13581370}
13591371
1360export function resolveMacStageDependencies(input: {
1372// macOS and Linux run both processes from one app.asar, so the stage installs
1373// the union of what each bundle leaves external and nothing else.
1374export function resolveMergedStageDependencies(input: {
1375 readonly platform: "mac" | "linux";
13611376 readonly serverDependencies: Record<string, string>;
13621377 readonly desktopDependencies: Record<string, string>;
13631378 readonly arch: typeof BuildArch.Type;
......@@ -1366,7 +1381,7 @@ export function resolveMacStageDependencies(input: {
13661381 return {
13671382 ...selectCliRuntimeExternalDependencies(input.serverDependencies),
13681383 ...input.desktopDependencies,
1369 ...resolveFffNativeDependencies("mac", input.arch, input.fffNodeVersion),
1384 ...resolveFffNativeDependencies(input.platform, input.arch, input.fffNodeVersion),
13701385 };
13711386}
13721387
......@@ -2551,6 +2566,11 @@ function validateBundledClientAssets(clientDir: string) {
25512566 });
25522567}
25532568
2569// The main-process bundle inlines every JS dependency (see
2570// apps/desktop/vite.config.ts), so the packaged app only installs the packages
2571// that bundle leaves external: native addons and playwright-core. Everything
2572// else already lives inside dist-electron and would only duplicate what the
2573// server bundle carries too.
25542574export function resolveDesktopRuntimeDependencies(
25552575 dependencies: Record<string, string> | undefined,
25562576 catalog: Record<string, string>,
......@@ -2559,14 +2579,11 @@ export function resolveDesktopRuntimeDependencies(
25592579 return {};
25602580 }
25612581
2562 const runtimeDependencies = Object.fromEntries(
2563 Object.entries(dependencies).filter(
2564 ([dependencyName, dependencySpec]) =>
2565 dependencyName !== "electron" && !dependencySpec.startsWith("workspace:"),
2566 ),
2582 return resolveCatalogDependencies(
2583 selectDesktopRuntimeExternalDependencies(dependencies),
2584 catalog,
2585 "apps/desktop",
25672586 );
2568
2569 return resolveCatalogDependencies(runtimeDependencies, catalog, "apps/desktop");
25702587}
25712588
25722589export const resolveGitHubPublishConfig = Effect.fn("resolveGitHubPublishConfig")(function* (
......@@ -2672,7 +2689,11 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
26722689 electronLanguages: [...DESKTOP_ELECTRON_LANGUAGES],
26732690 files: [
26742691 ...DESKTOP_FILE_EXCLUSIONS,
2675 ...(platform === "mac" ? resolveMacFileExclusions(arch) : []),
2692 ...(platform === "mac"
2693 ? resolveMacFileExclusions(arch)
2694 : platform === "linux"
2695 ? LINUX_FILE_EXCLUSIONS
2696 : []),
26762697 ],
26772698 directories: {
26782699 buildResources: "apps/desktop/resources",
......@@ -3735,29 +3756,19 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
37353756 }
37363757
37373758 // Windows splits dependencies per process: app.asar carries only the
3738 // desktop main-process runtime deps, while the server bundle's deps live in
3739 // the server.asar sidecar (see stageWindowsServerSidecar). macOS adds only
3740 // server packages that remain external to its merged app.asar. Linux retains
3741 // its existing full dependency tree.
3759 // desktop main-process externals, while the server bundle's externals live
3760 // in the server.asar sidecar (see stageWindowsServerSidecar). macOS and
3761 // Linux merge both sets into one app.asar.
37423762 const stageDependencies =
37433763 options.platform === "win"
37443764 ? { ...resolvedDesktopRuntimeDependencies }
3745 : options.platform === "mac"
3746 ? resolveMacStageDependencies({
3747 serverDependencies: resolvedServerDependencies,
3748 desktopDependencies: resolvedDesktopRuntimeDependencies,
3749 arch: options.arch,
3750 fffNodeVersion: serverPackageJson.dependencies["@ff-labs/fff-node"],
3751 })
3752 : {
3753 ...resolvedServerDependencies,
3754 ...resolvedDesktopRuntimeDependencies,
3755 ...resolveFffNativeDependencies(
3756 options.platform,
3757 options.arch,
3758 serverPackageJson.dependencies["@ff-labs/fff-node"],
3759 ),
3760 };
3765 : resolveMergedStageDependencies({
3766 platform: options.platform,
3767 serverDependencies: resolvedServerDependencies,
3768 desktopDependencies: resolvedDesktopRuntimeDependencies,
3769 arch: options.arch,
3770 fffNodeVersion: serverPackageJson.dependencies["@ff-labs/fff-node"],
3771 });
37613772 const stagePatchedDependencies = createStagePatchedDependencies(
37623773 workspacePatchedDependencies,
37633774 stageDependencies,
scripts/lib/desktop-external-packages.ts created+38
......@@ -0,0 +1,38 @@
1/**
2 * Packages the desktop main-process bundle must NOT inline.
3 *
4 * The desktop bundle follows the same policy as the server CLI bundle (see
5 * cli-external-packages.ts): everything is inlined except what Node has to
6 * load from the real filesystem. Both `apps/desktop/vite.config.ts` and the
7 * artifact stage in scripts/build-desktop-artifact.ts derive from this list,
8 * so a package that is external is also the only kind of package the staged
9 * production install carries. Anything not listed here ships inside
10 * `dist-electron/*.cjs` and has no `node_modules` presence at all.
11 *
12 * Entries are matched as prefixes so platform-specific siblings are covered.
13 */
14export const DESKTOP_RUNTIME_EXTERNAL_PREFIXES = [
15 // Native addons and the wrappers that dlopen them by real path.
16 "@napi-rs/keyring",
17 "@crowecawcaw/xa11y",
18 "@clerk/electron-passkeys",
19 "ffi-rs",
20 "@yuuang/",
21 // Reads its own bundle from disk by resolving `playwright-core/package.json`
22 // at runtime and ships the browser driver alongside; there is nothing to
23 // gain from inlining a 10 MB file the code re-reads as text.
24 "playwright-core",
25] as const;
26
27export function isDesktopRuntimeExternalDependency(id: string): boolean {
28 return DESKTOP_RUNTIME_EXTERNAL_PREFIXES.some((prefix) => id.startsWith(prefix));
29}
30
31/** Select the desktop dependency roots whose runtime closure the stage must install. */
32export function selectDesktopRuntimeExternalDependencies(
33 dependencies: Readonly<Record<string, string>>,
34): Record<string, string> {
35 return Object.fromEntries(
36 Object.entries(dependencies).filter(([name]) => isDesktopRuntimeExternalDependency(name)),
37 );
38}