diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 89c11fe6e18c..f3ec31ed34d9 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -1,9 +1,18 @@ import "vite-plus/test/config"; import { defineConfig } from "vite-plus"; +import { isDesktopRuntimeExternalDependency } from "../../scripts/lib/desktop-external-packages.ts"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; const repoEnv = loadRepoEnv(); + +// The main process is bundled the same way the server CLI is: every JS +// dependency is inlined and only packages Node must load from disk stay +// external. The packaged app then installs just those externals, instead of a +// full production install of apps/desktop's dependency tree next to a server +// bundle that already carries its own copy of the same libraries. +const isMainProcessExternal = (id: string) => + id === "electron" || id.startsWith("electron/") || isDesktopRuntimeExternalDependency(id); const shouldLaunchElectronAfterPack = process.env.T3CODE_DESKTOP_DEV === "1"; const publicConfigDefine = { __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: JSON.stringify( @@ -55,7 +64,9 @@ export default defineConfig({ ], clean: true, deps: { - alwaysBundle: (id) => id.startsWith("@t3tools/"), + alwaysBundle: (id) => !id.startsWith("node:") && !isMainProcessExternal(id), + neverBundle: isMainProcessExternal, + onlyBundle: false, }, ...(shouldLaunchElectronAfterPack ? { onSuccess: "node scripts/dev-electron.mjs" } : {}), }, diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index b601d3997fd7..04be06755750 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -28,6 +28,7 @@ import { DESKTOP_EXTRA_RESOURCES, LINUX_CAPTURE_EXTRA_RESOURCES, LINUX_BROWSER_SECRET_EXTRA_RESOURCES, + LINUX_FILE_EXCLUSIONS, MAC_FILE_EXCLUSIONS, InvalidMacPasskeyRpDomainError, InvalidMacPasskeyPublishableKeyError, @@ -47,7 +48,7 @@ import { resolveClerkPasskeyNativeArtifacts, resolveMacPasskeySigningConfiguration, resolveDesktopRuntimeDependencies, - resolveMacStageDependencies, + resolveMergedStageDependencies, resolveFffNativeDependencies, resolveBuildOptions, resolveDesktopBuildIconAssets, @@ -367,26 +368,37 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ), ); - it("omits bundled workspace packages from staged desktop dependencies", () => { + it("stages only the desktop main-process externals", () => { assert.deepStrictEqual( resolveDesktopRuntimeDependencies( { + "@clerk/electron": "catalog:", + "@clerk/electron-passkeys": "catalog:", + "@crowecawcaw/xa11y": "0.13.0", "@effect/platform-node": "catalog:", + "@napi-rs/keyring": "^1.3.0", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", - "@t3tools/ssh": "workspace:*", - "@t3tools/tailscale": "workspace:*", + "dbus-next": "0.10.2", effect: "catalog:", electron: "41.5.0", + "electron-updater": "^6.6.2", + "ffi-rs": "1.3.2", + "playwright-core": "1.60.0", }, { + "@clerk/electron": "0.0.37", + "@clerk/electron-passkeys": "0.0.3", "@effect/platform-node": "4.0.0-beta.59", effect: "4.0.0-beta.59", }, ), { - "@effect/platform-node": "4.0.0-beta.59", - effect: "4.0.0-beta.59", + "@clerk/electron-passkeys": "0.0.3", + "@crowecawcaw/xa11y": "0.13.0", + "@napi-rs/keyring": "^1.3.0", + "ffi-rs": "1.3.2", + "playwright-core": "1.60.0", }, ); }); @@ -559,6 +571,8 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.deepStrictEqual(DESKTOP_FILE_EXCLUSIONS, [ "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*", + "!**/*.map", + "!**/*.d.cts", "!apps/desktop/resources/browser-secret", "!apps/desktop/resources/browser-secret/**/*", "!apps/desktop/prod-resources/browser-secret", @@ -661,6 +675,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { "**/node_modules/@anthropic-ai/claude-agent-sdk-*/**", "**/node_modules/.bin", "**/node_modules/.bin/**", + "**/*.map", ]); assert.deepStrictEqual(mac.dmg, { title: "T3 Code (Alpha) 1.2.3 Installer", @@ -679,7 +694,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, ]); assert.deepStrictEqual(mac.files, [...DESKTOP_FILE_EXCLUSIONS, ...MAC_FILE_EXCLUSIONS]); - assert.deepStrictEqual(linux.files, DESKTOP_FILE_EXCLUSIONS); + assert.deepStrictEqual(linux.files, [...DESKTOP_FILE_EXCLUSIONS, ...LINUX_FILE_EXCLUSIONS]); assert.deepStrictEqual(win.files, DESKTOP_FILE_EXCLUSIONS); assert.deepStrictEqual(winWithoutWslPrebuild.files, win.files); assert.notProperty(mac.mac as Record, "sign"); @@ -690,11 +705,15 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), ); - it("excludes Windows terminal binaries only from macOS packages", () => { + it("excludes foreign node-pty prebuilds from macOS and Linux packages", () => { assert.deepStrictEqual(MAC_FILE_EXCLUSIONS, [ "!**/node_modules/node-pty/prebuilds/win32-*/**/*", "!**/node_modules/node-pty/third_party/conpty/**/*", ]); + assert.deepStrictEqual(LINUX_FILE_EXCLUSIONS, [ + ...MAC_FILE_EXCLUSIONS, + "!**/node_modules/node-pty/prebuilds/darwin-*/**/*", + ]); }); it("unpacks native binaries while keeping their JavaScript and metadata archived", () => { @@ -725,9 +744,10 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { } }); - it("stages only server runtime externals in macOS packages", () => { + it("stages only the externals of both bundles in merged packages", () => { assert.deepStrictEqual( - resolveMacStageDependencies({ + resolveMergedStageDependencies({ + platform: "mac", serverDependencies: { "@anthropic-ai/claude-agent-sdk": "^0.3.170", "@ff-labs/fff-node": "0.9.4", @@ -737,8 +757,8 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { "node-pty": "1.1.0", }, desktopDependencies: { - "@clerk/electron": "0.0.34", - effect: "4.0.0-beta.103", + "@napi-rs/keyring": "1.3.0", + "playwright-core": "1.60.0", }, arch: "arm64", fffNodeVersion: "0.9.4", @@ -747,11 +767,28 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { "@ff-labs/fff-node": "0.9.4", "msgpackr-extract": "3.0.4", "node-pty": "1.1.0", - "@clerk/electron": "0.0.34", - effect: "4.0.0-beta.103", + "@napi-rs/keyring": "1.3.0", + "playwright-core": "1.60.0", "@ff-labs/fff-bin-darwin-arm64": "0.9.4", }, ); + + assert.deepStrictEqual( + resolveMergedStageDependencies({ + platform: "linux", + serverDependencies: { "@ff-labs/fff-node": "0.9.4", "node-pty": "1.1.0", effect: "4.0.0" }, + desktopDependencies: { "@crowecawcaw/xa11y": "0.13.0" }, + arch: "x64", + fffNodeVersion: "0.9.4", + }), + { + "@ff-labs/fff-node": "0.9.4", + "node-pty": "1.1.0", + "@crowecawcaw/xa11y": "0.13.0", + "@ff-labs/fff-bin-linux-x64-gnu": "0.9.4", + "@ff-labs/fff-bin-linux-x64-musl": "0.9.4", + }, + ); }); it("excludes node-pty binaries for the other Windows architecture", () => { diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 44d5f0ab12f4..2069b0c836ad 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -34,6 +34,7 @@ import { selectCliRuntimeExternalDependencies, } from "./lib/cli-external-packages.ts"; import { loadRepoEnv } from "./lib/public-config.ts"; +import { selectDesktopRuntimeExternalDependencies } from "./lib/desktop-external-packages.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; @@ -959,6 +960,10 @@ export const DESKTOP_FILE_EXCLUSIONS = [ // so the SDK's optional platform packages (each a ~200MB bundled executable) // are dead weight. The trailing dash keeps the SDK's own JS package. "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*", + // Nothing in the packaged app enables source maps or serves them: the web + // client's maps alone were 50 MB of app.asar that no request ever read. + "!**/*.map", + "!**/*.d.cts", "!apps/desktop/resources/browser-secret", "!apps/desktop/resources/browser-secret/**/*", "!apps/desktop/prod-resources/browser-secret", @@ -978,6 +983,12 @@ export const MAC_FILE_EXCLUSIONS = [ "!**/node_modules/node-pty/prebuilds/win32-*/**/*", "!**/node_modules/node-pty/third_party/conpty/**/*", ] as const; +// Linux builds node-pty from source, so every prebuild in the package is for +// another platform (58 MB of it Windows debug symbols). +export const LINUX_FILE_EXCLUSIONS = [ + ...MAC_FILE_EXCLUSIONS, + "!**/node_modules/node-pty/prebuilds/darwin-*/**/*", +] as const; // node-pty publishes both Darwin prebuilds in one package. Single-architecture // apps only need the native target; universal apps need both. An omitted arch @@ -1013,6 +1024,7 @@ export const WINDOWS_SERVER_ASAR_IGNORE_GLOBS = [ "**/node_modules/@anthropic-ai/claude-agent-sdk-*/**", "**/node_modules/.bin", "**/node_modules/.bin/**", + "**/*.map", ] as const; export function resolveWindowsServerAsarIgnoreGlobs(arch: typeof BuildArch.Type) { @@ -1357,7 +1369,10 @@ export function resolveFffNativeDependencies( ); } -export function resolveMacStageDependencies(input: { +// macOS and Linux run both processes from one app.asar, so the stage installs +// the union of what each bundle leaves external and nothing else. +export function resolveMergedStageDependencies(input: { + readonly platform: "mac" | "linux"; readonly serverDependencies: Record; readonly desktopDependencies: Record; readonly arch: typeof BuildArch.Type; @@ -1366,7 +1381,7 @@ export function resolveMacStageDependencies(input: { return { ...selectCliRuntimeExternalDependencies(input.serverDependencies), ...input.desktopDependencies, - ...resolveFffNativeDependencies("mac", input.arch, input.fffNodeVersion), + ...resolveFffNativeDependencies(input.platform, input.arch, input.fffNodeVersion), }; } @@ -2551,6 +2566,11 @@ function validateBundledClientAssets(clientDir: string) { }); } +// The main-process bundle inlines every JS dependency (see +// apps/desktop/vite.config.ts), so the packaged app only installs the packages +// that bundle leaves external: native addons and playwright-core. Everything +// else already lives inside dist-electron and would only duplicate what the +// server bundle carries too. export function resolveDesktopRuntimeDependencies( dependencies: Record | undefined, catalog: Record, @@ -2559,14 +2579,11 @@ export function resolveDesktopRuntimeDependencies( return {}; } - const runtimeDependencies = Object.fromEntries( - Object.entries(dependencies).filter( - ([dependencyName, dependencySpec]) => - dependencyName !== "electron" && !dependencySpec.startsWith("workspace:"), - ), + return resolveCatalogDependencies( + selectDesktopRuntimeExternalDependencies(dependencies), + catalog, + "apps/desktop", ); - - return resolveCatalogDependencies(runtimeDependencies, catalog, "apps/desktop"); } export const resolveGitHubPublishConfig = Effect.fn("resolveGitHubPublishConfig")(function* ( @@ -2672,7 +2689,11 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( electronLanguages: [...DESKTOP_ELECTRON_LANGUAGES], files: [ ...DESKTOP_FILE_EXCLUSIONS, - ...(platform === "mac" ? resolveMacFileExclusions(arch) : []), + ...(platform === "mac" + ? resolveMacFileExclusions(arch) + : platform === "linux" + ? LINUX_FILE_EXCLUSIONS + : []), ], directories: { buildResources: "apps/desktop/resources", @@ -3735,29 +3756,19 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( } // Windows splits dependencies per process: app.asar carries only the - // desktop main-process runtime deps, while the server bundle's deps live in - // the server.asar sidecar (see stageWindowsServerSidecar). macOS adds only - // server packages that remain external to its merged app.asar. Linux retains - // its existing full dependency tree. + // desktop main-process externals, while the server bundle's externals live + // in the server.asar sidecar (see stageWindowsServerSidecar). macOS and + // Linux merge both sets into one app.asar. const stageDependencies = options.platform === "win" ? { ...resolvedDesktopRuntimeDependencies } - : options.platform === "mac" - ? resolveMacStageDependencies({ - serverDependencies: resolvedServerDependencies, - desktopDependencies: resolvedDesktopRuntimeDependencies, - arch: options.arch, - fffNodeVersion: serverPackageJson.dependencies["@ff-labs/fff-node"], - }) - : { - ...resolvedServerDependencies, - ...resolvedDesktopRuntimeDependencies, - ...resolveFffNativeDependencies( - options.platform, - options.arch, - serverPackageJson.dependencies["@ff-labs/fff-node"], - ), - }; + : resolveMergedStageDependencies({ + platform: options.platform, + serverDependencies: resolvedServerDependencies, + desktopDependencies: resolvedDesktopRuntimeDependencies, + arch: options.arch, + fffNodeVersion: serverPackageJson.dependencies["@ff-labs/fff-node"], + }); const stagePatchedDependencies = createStagePatchedDependencies( workspacePatchedDependencies, stageDependencies, diff --git a/scripts/lib/desktop-external-packages.ts b/scripts/lib/desktop-external-packages.ts new file mode 100644 index 000000000000..919397a684d1 --- /dev/null +++ b/scripts/lib/desktop-external-packages.ts @@ -0,0 +1,38 @@ +/** + * Packages the desktop main-process bundle must NOT inline. + * + * The desktop bundle follows the same policy as the server CLI bundle (see + * cli-external-packages.ts): everything is inlined except what Node has to + * load from the real filesystem. Both `apps/desktop/vite.config.ts` and the + * artifact stage in scripts/build-desktop-artifact.ts derive from this list, + * so a package that is external is also the only kind of package the staged + * production install carries. Anything not listed here ships inside + * `dist-electron/*.cjs` and has no `node_modules` presence at all. + * + * Entries are matched as prefixes so platform-specific siblings are covered. + */ +export const DESKTOP_RUNTIME_EXTERNAL_PREFIXES = [ + // Native addons and the wrappers that dlopen them by real path. + "@napi-rs/keyring", + "@crowecawcaw/xa11y", + "@clerk/electron-passkeys", + "ffi-rs", + "@yuuang/", + // Reads its own bundle from disk by resolving `playwright-core/package.json` + // at runtime and ships the browser driver alongside; there is nothing to + // gain from inlining a 10 MB file the code re-reads as text. + "playwright-core", +] as const; + +export function isDesktopRuntimeExternalDependency(id: string): boolean { + return DESKTOP_RUNTIME_EXTERNAL_PREFIXES.some((prefix) => id.startsWith(prefix)); +} + +/** Select the desktop dependency roots whose runtime closure the stage must install. */ +export function selectDesktopRuntimeExternalDependencies( + dependencies: Readonly>, +): Record { + return Object.fromEntries( + Object.entries(dependencies).filter(([name]) => isDesktopRuntimeExternalDependency(name)), + ); +}