authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-08-14 21:12:39+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 00:42:39+05:30
logc9063f03ea1c16e0239e1996a9b6ef611679995d
treec8b2eb5ba5f8b223a3c5f89c8fa7897e79682534
parent1add47b322ab1dfb5010bb363613650176b88088
signature Signed by PGP key B5690EEEBB952194

perf(desktop): speed up Windows update installation (#6169)


16 files changed, 1822 insertions(+), 188 deletions(-)

apps/desktop/src/app/DesktopEnvironment.test.ts+19
......@@ -65,6 +65,7 @@ describe("DesktopEnvironment", () => {
6565 assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts");
6666 assert.equal(environment.rootDir, "/repo");
6767 assert.equal(environment.appRoot, "/repo");
68 assert.equal(environment.serverRoot, "/repo");
6869 assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs");
6970 assert.equal(environment.backendCwd, "/repo");
7071 assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev");
......@@ -98,6 +99,24 @@ describe("DesktopEnvironment", () => {
9899 }),
99100 );
100101
102 it.effect("uses the packaged Windows server sidecar as the backend root", () =>
103 Effect.gen(function* () {
104 const environment = yield* makeEnvironment({
105 platform: "win32",
106 isPackaged: true,
107 appPath: "/install/resources/app.asar",
108 resourcesPath: "/install/resources",
109 });
110
111 assert.equal(environment.appRoot, "/install/resources/app.asar");
112 assert.equal(environment.serverRoot, "/install/resources/server.asar");
113 assert.equal(
114 environment.backendEntryPath,
115 "/install/resources/server.asar/apps/server/dist/bin.mjs",
116 );
117 }),
118 );
119
101120 it.effect("keeps implicit development state separate from production state", () =>
102121 Effect.gen(function* () {
103122 const development = yield* makeEnvironment(
apps/desktop/src/app/DesktopEnvironment.ts+13-1
......@@ -52,6 +52,13 @@ export class DesktopEnvironment extends Context.Service<
5252 readonly browserArtifactsDir: string;
5353 readonly rootDir: string;
5454 readonly appRoot: string;
55 // Root of the tree containing apps/server/dist and node_modules for the
56 // backend. Equals appRoot everywhere except packaged Windows, where the
57 // server tree ships as the resources/server.asar sidecar (see
58 // scripts/build-desktop-artifact.ts) that the asar-aware
59 // ELECTRON_RUN_AS_NODE primary reads in place and the WSL backend
60 // extracts on demand (see DesktopWslServerTree).
61 readonly serverRoot: string;
5562 readonly backendEntryPath: string;
5663 readonly backendCwd: string;
5764 readonly preloadPath: string;
......@@ -157,6 +164,10 @@ const make = Effect.fn("desktop.environment.make")(function* (
157164 });
158165 const rootDir = path.resolve(input.dirname, "../../..");
159166 const appRoot = input.isPackaged ? input.appPath : rootDir;
167 const serverRoot =
168 input.isPackaged && input.platform === "win32"
169 ? path.join(input.resourcesPath, "server.asar")
170 : appRoot;
160171 const branding = resolveDesktopAppBranding({
161172 isDevelopment,
162173 appVersion: input.appVersion,
......@@ -198,7 +209,8 @@ const make = Effect.fn("desktop.environment.make")(function* (
198209 browserArtifactsDir: path.join(stateDir, "browser-artifacts"),
199210 rootDir,
200211 appRoot,
201 backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"),
212 serverRoot,
213 backendEntryPath: path.join(serverRoot, "apps/server/dist/bin.mjs"),
202214 backendCwd: input.isPackaged ? homeDirectory : appRoot,
203215 preloadPath: path.join(input.dirname, "preload.cjs"),
204216 appUpdateYmlPath: input.isPackaged
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+102-2
......@@ -17,6 +17,7 @@ import * as DesktopConfig from "../app/DesktopConfig.ts";
1717import * as DesktopServerExposure from "./DesktopServerExposure.ts";
1818import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
1919import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
20import * as DesktopWslServerTree from "../wsl/DesktopWslServerTree.ts";
2021
2122const PersistedServerObservabilitySettingsDocument = Schema.Struct({
2223 observability: Schema.Struct({
......@@ -115,6 +116,7 @@ const withHarness = <A, E, R>(
115116 Layer.provideMerge(serverExposureLayer),
116117 Layer.provideMerge(DesktopAppSettings.layerTest()),
117118 Layer.provideMerge(DesktopWslEnvironment.layerTest()),
119 Layer.provideMerge(DesktopWslServerTree.layerTest()),
118120 Layer.provideMerge(makeEnvironmentLayer(baseDir)),
119121 ),
120122 ),
......@@ -153,6 +155,47 @@ describe("DesktopBackendConfiguration", () => {
153155 ),
154156 );
155157
158 it.effect("resolvePrimary starts from server.asar without materializing the WSL tree", () =>
159 Effect.gen(function* () {
160 const fileSystem = yield* FileSystem.FileSystem;
161 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
162 prefix: "t3-desktop-backend-config-test-",
163 });
164 const resourcesPath = `${baseDir}/resources`;
165
166 const config = yield* Effect.gen(function* () {
167 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
168 return yield* configuration.resolvePrimary;
169 }).pipe(
170 Effect.provide(
171 DesktopBackendConfiguration.layer.pipe(
172 Layer.provideMerge(serverExposureLayer),
173 Layer.provideMerge(DesktopAppSettings.layerTest()),
174 Layer.provideMerge(DesktopWslEnvironment.layerTest()),
175 Layer.provideMerge(
176 Layer.succeed(
177 DesktopWslServerTree.DesktopWslServerTree,
178 DesktopWslServerTree.DesktopWslServerTree.of({
179 ensure: Effect.die("Windows primary must not extract the WSL server tree"),
180 }),
181 ),
182 ),
183 Layer.provideMerge(
184 makeEnvironmentLayer(baseDir, {
185 appPath: `${resourcesPath}/app.asar`,
186 platform: "win32",
187 resourcesPath,
188 }),
189 ),
190 ),
191 ),
192 );
193
194 assert.equal(config.entryPath, `${resourcesPath}/server.asar/apps/server/dist/bin.mjs`);
195 assert.equal(config.env.ELECTRON_RUN_AS_NODE, "1");
196 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
197 );
198
156199 it.effect("resolveWsl reuses the primary's bootstrap token", () =>
157200 withHarness(
158201 Effect.gen(function* () {
......@@ -173,7 +216,7 @@ describe("DesktopBackendConfiguration", () => {
173216 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
174217 prefix: "t3-desktop-backend-config-test-",
175218 });
176 const entryPath = path.join(baseDir, "app.asar.unpacked/apps/server/dist/bin.mjs");
219 const entryPath = path.join(baseDir, "apps/server/dist/bin.mjs");
177220 yield* fileSystem.makeDirectory(path.dirname(entryPath), { recursive: true });
178221 yield* fileSystem.writeFileString(entryPath, "");
179222
......@@ -186,6 +229,7 @@ describe("DesktopBackendConfiguration", () => {
186229 DesktopBackendConfiguration.layer.pipe(
187230 Layer.provideMerge(serverExposureLayer),
188231 Layer.provideMerge(DesktopAppSettings.layerTest()),
232 Layer.provideMerge(DesktopWslServerTree.layerTest()),
189233 Layer.provideMerge(
190234 DesktopWslEnvironment.layerTest({
191235 isAvailable: true,
......@@ -234,7 +278,7 @@ describe("DesktopBackendConfiguration", () => {
234278 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
235279 prefix: "t3-desktop-backend-config-test-",
236280 });
237 const entryPath = path.join(baseDir, "app.asar.unpacked/apps/server/dist/bin.mjs");
281 const entryPath = path.join(baseDir, "apps/server/dist/bin.mjs");
238282 yield* fileSystem.makeDirectory(path.dirname(entryPath), { recursive: true });
239283 yield* fileSystem.writeFileString(entryPath, "");
240284
......@@ -250,6 +294,7 @@ describe("DesktopBackendConfiguration", () => {
250294 DesktopBackendConfiguration.layer.pipe(
251295 Layer.provideMerge(serverExposureLayer),
252296 Layer.provideMerge(DesktopAppSettings.layerTest()),
297 Layer.provideMerge(DesktopWslServerTree.layerTest()),
253298 Layer.provideMerge(
254299 DesktopWslEnvironment.layerTest({
255300 isAvailable: true,
......@@ -386,6 +431,7 @@ describe("DesktopBackendConfiguration", () => {
386431 DesktopBackendConfiguration.layer.pipe(
387432 Layer.provideMerge(serverExposureLayer),
388433 Layer.provideMerge(DesktopAppSettings.layerTest()),
434 Layer.provideMerge(DesktopWslServerTree.layerTest()),
389435 Layer.provideMerge(DesktopWslEnvironment.layerTest()),
390436 Layer.provideMerge(makeEnvironmentLayer(baseDir)),
391437 Layer.provideMerge(failingFileSystemLayer),
......@@ -427,6 +473,7 @@ describe("DesktopBackendConfiguration", () => {
427473 DesktopBackendConfiguration.layer.pipe(
428474 Layer.provideMerge(serverExposureLayer),
429475 Layer.provideMerge(DesktopAppSettings.layerTest()),
476 Layer.provideMerge(DesktopWslServerTree.layerTest()),
430477 Layer.provideMerge(DesktopWslEnvironment.layerTest()),
431478 Layer.provideMerge(
432479 makeEnvironmentLayer(baseDir, {
......@@ -486,6 +533,7 @@ describe("DesktopBackendConfiguration", () => {
486533 DesktopBackendConfiguration.layer.pipe(
487534 Layer.provideMerge(serverExposureLayer),
488535 Layer.provideMerge(DesktopAppSettings.layerTest()),
536 Layer.provideMerge(DesktopWslServerTree.layerTest()),
489537 Layer.provideMerge(
490538 DesktopWslEnvironment.layerTest({
491539 isAvailable: true,
......@@ -536,6 +584,7 @@ describe("DesktopBackendConfiguration", () => {
536584 wslOnly: true,
537585 }),
538586 ),
587 Layer.provideMerge(DesktopWslServerTree.layerTest()),
539588 Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: false })),
540589 Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })),
541590 ),
......@@ -573,6 +622,7 @@ describe("DesktopBackendConfiguration", () => {
573622 wslDistro: "Removed-Distro",
574623 }),
575624 ),
625 Layer.provideMerge(DesktopWslServerTree.layerTest()),
576626 Layer.provideMerge(
577627 DesktopWslEnvironment.layerTest({
578628 isAvailable: true,
......@@ -606,6 +656,7 @@ describe("DesktopBackendConfiguration", () => {
606656 DesktopBackendConfiguration.layer.pipe(
607657 Layer.provideMerge(serverExposureLayer),
608658 Layer.provideMerge(DesktopAppSettings.layerTest()),
659 Layer.provideMerge(DesktopWslServerTree.layerTest()),
609660 Layer.provideMerge(
610661 DesktopWslEnvironment.layerTest({
611662 isAvailable: true,
......@@ -640,6 +691,49 @@ describe("DesktopBackendConfiguration", () => {
640691 DesktopBackendConfiguration.layer.pipe(
641692 Layer.provideMerge(serverExposureLayer),
642693 Layer.provideMerge(DesktopAppSettings.layerTest()),
694 Layer.provideMerge(DesktopWslServerTree.layerTest()),
695 Layer.provideMerge(
696 DesktopWslEnvironment.layerTest({
697 isAvailable: true,
698 distros: [{ name: "Ubuntu", isDefault: true, version: 2 }],
699 }),
700 ),
701 Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })),
702 ),
703 ),
704 );
705 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
706 );
707
708 it.effect("resolveWsl surfaces sidecar extraction failures through typed preflight", () =>
709 Effect.gen(function* () {
710 const fileSystem = yield* FileSystem.FileSystem;
711 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
712 prefix: "t3-desktop-backend-config-test-",
713 });
714
715 yield* Effect.gen(function* () {
716 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
717 const config = yield* configuration.resolveWsl({ port: 5050, distro: "Ubuntu" });
718 const failure = Option.getOrThrow(config.preflightFailure);
719
720 assert.isFalse(failure.fatal);
721 assert.equal(failure.retryLimit, 12);
722 assert.include(failure.reason, "could not be extracted");
723 }).pipe(
724 Effect.provide(
725 DesktopBackendConfiguration.layer.pipe(
726 Layer.provideMerge(serverExposureLayer),
727 Layer.provideMerge(DesktopAppSettings.layerTest()),
728 Layer.provideMerge(
729 DesktopWslServerTree.layerTest({
730 result: {
731 ok: false,
732 reason: "WSL server files could not be extracted",
733 fatal: false,
734 },
735 }),
736 ),
643737 Layer.provideMerge(
644738 DesktopWslEnvironment.layerTest({
645739 isAvailable: true,
......@@ -672,6 +766,7 @@ describe("DesktopBackendConfiguration", () => {
672766 DesktopBackendConfiguration.layer.pipe(
673767 Layer.provideMerge(serverExposureLayer),
674768 Layer.provideMerge(DesktopAppSettings.layerTest()),
769 Layer.provideMerge(DesktopWslServerTree.layerTest()),
675770 Layer.provideMerge(
676771 DesktopWslEnvironment.layerTest({
677772 isAvailable: true,
......@@ -708,6 +803,7 @@ describe("DesktopBackendConfiguration", () => {
708803 wslDistro: "Ubuntu",
709804 }),
710805 ),
806 Layer.provideMerge(DesktopWslServerTree.layerTest()),
711807 Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: true })),
712808 Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })),
713809 ),
......@@ -748,6 +844,7 @@ describe("DesktopBackendConfiguration", () => {
748844 DesktopBackendConfiguration.layer.pipe(
749845 Layer.provideMerge(serverExposureLayer),
750846 Layer.provideMerge(DesktopAppSettings.layerTest()),
847 Layer.provideMerge(DesktopWslServerTree.layerTest()),
751848 Layer.provideMerge(DesktopWslEnvironment.layerTest()),
752849 Layer.provideMerge(
753850 makeEnvironmentLayer(baseDir, {
......@@ -793,6 +890,7 @@ describe("DesktopBackendConfiguration", () => {
793890 DesktopBackendConfiguration.layer.pipe(
794891 Layer.provideMerge(serverExposureLayer),
795892 Layer.provideMerge(DesktopAppSettings.layerTest()),
893 Layer.provideMerge(DesktopWslServerTree.layerTest()),
796894 Layer.provideMerge(DesktopWslEnvironment.layerTest()),
797895 Layer.provideMerge(
798896 makeEnvironmentLayer(baseDir, {
......@@ -843,6 +941,7 @@ describe("DesktopBackendConfiguration", () => {
843941 wslDistro: "Ubuntu",
844942 }),
845943 ),
944 Layer.provideMerge(DesktopWslServerTree.layerTest()),
846945 Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: false })),
847946 Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })),
848947 ),
......@@ -864,6 +963,7 @@ describe("DesktopBackendConfiguration", () => {
864963 DesktopBackendConfiguration.layer.pipe(
865964 Layer.provideMerge(serverExposureLayer),
866965 Layer.provideMerge(DesktopAppSettings.layerTest()),
966 Layer.provideMerge(DesktopWslServerTree.layerTest()),
867967 Layer.provideMerge(DesktopWslEnvironment.layer),
868968 // isAvailable on win32 only touches the filesystem, never the spawner,
869969 // so a die-stub is enough to satisfy the layer's deps.
apps/desktop/src/backend/DesktopBackendConfiguration.ts+29-23
......@@ -19,6 +19,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1919import * as DesktopServerExposure from "./DesktopServerExposure.ts";
2020import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
2121import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
22import * as DesktopWslServerTree from "../wsl/DesktopWslServerTree.ts";
2223
2324export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedErrorClass<DesktopBackendObservabilitySettingsReadError>()(
2425 "DesktopBackendObservabilitySettingsReadError",
......@@ -424,10 +425,12 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
424425 never,
425426 | DesktopEnvironment.DesktopEnvironment
426427 | DesktopWslEnvironment.DesktopWslEnvironment
428 | DesktopWslServerTree.DesktopWslServerTree
427429 | FileSystem.FileSystem
428430> {
429431 const environment = yield* DesktopEnvironment.DesktopEnvironment;
430432 const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
433 const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree;
431434
432435 // Bind to 0.0.0.0 inside WSL so the backend is reachable both via
433436 // WSL2's automatic localhost forwarding (wslhost: Windows 127.0.0.1
......@@ -464,31 +467,31 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
464467 ...buildObservabilityFragment(input.observabilitySettings),
465468 };
466469
467 // In packaged builds environment.appRoot is .../resources/app.asar — an
468 // archive FILE. The Windows primary reads its entry through
469 // ELECTRON_RUN_AS_NODE (asar-aware), but the WSL backend launches plain
470 // `wsl.exe -- node`, which can't read inside an asar. electron-builder unpacks
471 // the server bundle + node-pty (see asarUnpack in build-desktop-artifact.ts)
472 // to the app.asar.unpacked sibling, so point WSL there. In dev appRoot is
473 // already a real directory, so this is a no-op.
474 const wslAppRoot = environment.isPackaged
475 ? environment.path.join(environment.resourcesPath, "app.asar.unpacked")
476 : environment.appRoot;
470 // In packaged builds the server tree ships inside resources/server.asar —
471 // an archive FILE the Windows primary reads through ELECTRON_RUN_AS_NODE
472 // (asar-aware). The WSL backend launches plain `wsl.exe -- node`, which
473 // can't read an asar, so materialize (or reuse) the extracted copy of the
474 // sidecar before preflighting. In dev the server tree is the real checkout
475 // directory and ensure returns it unchanged.
476 const serverTree = yield* wslServerTree.ensure;
477 const wslAppRoot = serverTree.ok ? serverTree.root : environment.serverRoot;
477478 const wslEntryPath = environment.path.join(wslAppRoot, "apps/server/dist/bin.mjs");
478479
479 const preflight = yield* runWslPreflight({
480 distro: input.distro,
481 windowsEntryPath: wslEntryPath,
482 windowsRepoRoot: wslAppRoot,
483 // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and
484 // attached to the Windows artifact — see build-desktop-artifact.ts), so the
485 // WSL backend never needs a compiler, node-gyp, or network on first launch.
486 // Compiling from source is a dev-only convenience: a checkout has no shipped
487 // prebuilt, and developers have the toolchain. In packaged builds we instead
488 // surface a clear diagnostic if the prebuilt can't load (unsupported
489 // arch/distro), rather than silently dropping into a fragile runtime build.
490 allowBuild: !environment.isPackaged,
491 });
480 const preflight = serverTree.ok
481 ? yield* runWslPreflight({
482 distro: input.distro,
483 windowsEntryPath: wslEntryPath,
484 windowsRepoRoot: wslAppRoot,
485 // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and
486 // attached to the Windows artifact — see build-desktop-artifact.ts), so the
487 // WSL backend never needs a compiler, node-gyp, or network on first launch.
488 // Compiling from source is a dev-only convenience: a checkout has no shipped
489 // prebuilt, and developers have the toolchain. In packaged builds we instead
490 // surface a clear diagnostic if the prebuilt can't load (unsupported
491 // arch/distro), rather than silently dropping into a fragile runtime build.
492 allowBuild: !environment.isPackaged,
493 })
494 : ({ _tag: "Failed", reason: serverTree.reason, fatal: serverTree.fatal } as const);
492495
493496 // Every operation after preflight uses the same concrete distro. In
494497 // default-tracking mode this closes the race where the system default
......@@ -610,6 +613,7 @@ export const make = Effect.gen(function* () {
610613 const fileSystem = yield* FileSystem.FileSystem;
611614 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
612615 const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
616 const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree;
613617 const settings = yield* DesktopAppSettings.DesktopAppSettings;
614618 const crypto = yield* Crypto.Crypto;
615619 // SynchronizedRef (not a plain Ref) so the read-generate-write is atomic.
......@@ -665,6 +669,7 @@ export const make = Effect.gen(function* () {
665669 }).pipe(
666670 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
667671 Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment),
672 Effect.provideService(DesktopWslServerTree.DesktopWslServerTree, wslServerTree),
668673 Effect.provideService(FileSystem.FileSystem, fileSystem),
669674 );
670675 });
......@@ -727,6 +732,7 @@ export const make = Effect.gen(function* () {
727732 return yield* resolveWslStartConfig({ ...shared, ...input }).pipe(
728733 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
729734 Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment),
735 Effect.provideService(DesktopWslServerTree.DesktopWslServerTree, wslServerTree),
730736 Effect.provideService(FileSystem.FileSystem, fileSystem),
731737 );
732738 }).pipe(
apps/desktop/src/main.ts+2
......@@ -62,6 +62,7 @@ import * as PreviewManager from "./preview/Manager.ts";
6262import * as DesktopWindow from "./window/DesktopWindow.ts";
6363import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts";
6464import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts";
65import * as DesktopWslServerTree from "./wsl/DesktopWslServerTree.ts";
6566
6667const desktopEnvironmentLayer = Layer.unwrap(
6768 Effect.gen(function* () {
......@@ -165,6 +166,7 @@ const desktopBackendLayer = DesktopBackendPool.layer.pipe(
165166 Layer.provideMerge(DesktopAppIdentity.layer),
166167 Layer.provideMerge(DesktopBackendConfiguration.layer),
167168 Layer.provideMerge(DesktopWslEnvironment.layer),
169 Layer.provideMerge(DesktopWslServerTree.layer),
168170 Layer.provideMerge(DesktopTelemetryPublisher.layer),
169171 Layer.provideMerge(desktopWindowLayer),
170172);
apps/desktop/src/wsl/DesktopWslServerTree.test.ts created+323
......@@ -0,0 +1,323 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Path from "effect/Path";
7import * as PlatformError from "effect/PlatformError";
8import * as Ref from "effect/Ref";
9import * as Scope from "effect/Scope";
10
11import * as DesktopConfig from "../app/DesktopConfig.ts";
12import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
13import * as DesktopWslServerTree from "./DesktopWslServerTree.ts";
14
15// The service reads packaged Windows roots through the (asar-aware, in
16// Electron) fs, so a plain directory named server.asar exercises the full
17// extraction path under plain Node.
18
19const environmentLayer = (input: {
20 readonly baseDir: string;
21 readonly resourcesPath: string;
22 readonly appVersion?: string;
23 readonly isPackaged?: boolean;
24}) =>
25 DesktopEnvironment.layer({
26 dirname: "/repo/apps/desktop/src",
27 homeDirectory: input.baseDir,
28 platform: "win32",
29 processArch: "x64",
30 appVersion: input.appVersion ?? "1.2.3",
31 appPath: "/repo",
32 isPackaged: input.isPackaged ?? true,
33 resourcesPath: input.resourcesPath,
34 runningUnderArm64Translation: false,
35 }).pipe(
36 Layer.provide(
37 Layer.mergeAll(
38 NodeServices.layer,
39 DesktopConfig.layerTest({
40 T3CODE_HOME: input.baseDir,
41 T3CODE_MODE: "desktop",
42 }),
43 ),
44 ),
45 );
46
47const withTempDir = <A, E, R>(
48 run: (tempDir: string) => Effect.Effect<A, E, R>,
49): Effect.Effect<
50 A,
51 E | PlatformError.PlatformError,
52 FileSystem.FileSystem | Exclude<R, Scope.Scope>
53> =>
54 Effect.gen(function* () {
55 const fileSystem = yield* FileSystem.FileSystem;
56 const tempDir = yield* fileSystem.makeTempDirectoryScoped({
57 prefix: "t3-wsl-server-tree-test-",
58 });
59 return yield* run(tempDir);
60 }).pipe(Effect.scoped);
61
62const ensureWith = (input: {
63 readonly baseDir: string;
64 readonly resourcesPath: string;
65 readonly appVersion?: string;
66 readonly isPackaged?: boolean;
67}) =>
68 Effect.gen(function* () {
69 const tree = yield* DesktopWslServerTree.DesktopWslServerTree;
70 return yield* tree.ensure;
71 }).pipe(
72 Effect.provide(DesktopWslServerTree.layer.pipe(Layer.provideMerge(environmentLayer(input)))),
73 );
74
75describe("DesktopWslServerTree", () => {
76 it.effect("bounds entry work across an eight-way nested tree", () =>
77 Effect.gen(function* () {
78 const active = yield* Ref.make(0);
79 const maxActive = yield* Ref.make(0);
80 const visited = yield* Ref.make(0);
81
82 yield* DesktopWslServerTree.forEachBoundedTree([{ depth: 0, id: "root" }], (node) =>
83 Effect.acquireUseRelease(
84 Effect.gen(function* () {
85 const current = yield* Ref.updateAndGet(active, (count) => count + 1);
86 yield* Ref.update(maxActive, (maximum) => Math.max(maximum, current));
87 yield* Ref.update(visited, (count) => count + 1);
88 }),
89 () =>
90 Effect.gen(function* () {
91 // Give every task in the current batch a chance to overlap.
92 yield* Effect.yieldNow;
93 if (node.depth === 4) return [];
94 return Array.from({ length: 8 }, (_, index) => ({
95 depth: node.depth + 1,
96 id: `${node.id}.${String(index)}`,
97 }));
98 }),
99 () => Ref.update(active, (count) => count - 1),
100 ),
101 );
102
103 assert.equal(yield* Ref.get(active), 0);
104 assert.equal(yield* Ref.get(maxActive), 8);
105 assert.equal(yield* Ref.get(visited), 4_681);
106 }),
107 );
108
109 it.effect("returns the server root unchanged when it is a plain directory (dev)", () =>
110 withTempDir((tempDir) =>
111 Effect.gen(function* () {
112 const result = yield* ensureWith({
113 baseDir: tempDir,
114 resourcesPath: tempDir,
115 isPackaged: false,
116 });
117 assert.isTrue(result.ok);
118 assert.isFalse(result.ok && result.root.endsWith(".asar"));
119 }),
120 ).pipe(Effect.provide(NodeServices.layer)),
121 );
122
123 it.effect("extracts an archive root into a version-keyed state directory", () =>
124 withTempDir((tempDir) =>
125 Effect.gen(function* () {
126 const fileSystem = yield* FileSystem.FileSystem;
127 const path = yield* Path.Path;
128 const serverRoot = path.join(tempDir, "resources", "server.asar");
129 yield* fileSystem.makeDirectory(path.join(serverRoot, "apps/server/dist"), {
130 recursive: true,
131 });
132 yield* fileSystem.writeFileString(
133 path.join(serverRoot, "apps/server/dist/bin.mjs"),
134 "server-entry",
135 );
136 yield* fileSystem.makeDirectory(path.join(serverRoot, "node_modules/effect"), {
137 recursive: true,
138 });
139 yield* fileSystem.writeFileString(
140 path.join(serverRoot, "node_modules/effect/package.json"),
141 "{}",
142 );
143
144 const result = yield* ensureWith({
145 baseDir: tempDir,
146 resourcesPath: path.join(tempDir, "resources"),
147 });
148
149 assert.isTrue(result.ok);
150 const root = result.ok ? result.root : "";
151 assert.include(root, path.join("wsl-server-tree", "1.2.3"));
152 const entry = yield* fileSystem.readFileString(path.join(root, "apps/server/dist/bin.mjs"));
153 assert.equal(entry, "server-entry");
154 const dep = yield* fileSystem.exists(path.join(root, "node_modules/effect/package.json"));
155 assert.isTrue(dep);
156 const marker = yield* fileSystem.readFileString(
157 path.join(root, "t3code-wsl-server-tree.json"),
158 );
159 assert.include(marker, '"version":"1.2.3"');
160 }),
161 ).pipe(Effect.provide(NodeServices.layer)),
162 );
163
164 it.effect("serializes concurrent extraction callers and publishes one complete tree", () =>
165 withTempDir((tempDir) =>
166 Effect.gen(function* () {
167 const fileSystem = yield* FileSystem.FileSystem;
168 const path = yield* Path.Path;
169 const resourcesPath = path.join(tempDir, "resources");
170 const serverRoot = path.join(resourcesPath, "server.asar");
171 yield* fileSystem.makeDirectory(path.join(serverRoot, "apps/server/dist"), {
172 recursive: true,
173 });
174 yield* fileSystem.writeFileString(
175 path.join(serverRoot, "apps/server/dist/bin.mjs"),
176 "server-entry",
177 );
178
179 const results = yield* Effect.gen(function* () {
180 const tree = yield* DesktopWslServerTree.DesktopWslServerTree;
181 return yield* Effect.all([tree.ensure, tree.ensure], { concurrency: "unbounded" });
182 }).pipe(
183 Effect.provide(
184 DesktopWslServerTree.layer.pipe(
185 Layer.provideMerge(environmentLayer({ baseDir: tempDir, resourcesPath })),
186 ),
187 ),
188 );
189
190 assert.isTrue(results.every((result) => result.ok));
191 const roots = results.flatMap((result) => (result.ok ? [result.root] : []));
192 assert.lengthOf(new Set(roots), 1);
193 assert.equal(
194 yield* fileSystem.readFileString(path.join(roots[0] ?? "", "apps/server/dist/bin.mjs")),
195 "server-entry",
196 );
197 }),
198 ).pipe(Effect.provide(NodeServices.layer)),
199 );
200
201 it.effect("reuses a completed extraction instead of copying again", () =>
202 withTempDir((tempDir) =>
203 Effect.gen(function* () {
204 const fileSystem = yield* FileSystem.FileSystem;
205 const path = yield* Path.Path;
206 const serverRoot = path.join(tempDir, "resources", "server.asar");
207 yield* fileSystem.makeDirectory(path.join(serverRoot, "apps/server/dist"), {
208 recursive: true,
209 });
210 yield* fileSystem.writeFileString(path.join(serverRoot, "apps/server/dist/bin.mjs"), "v1");
211
212 const first = yield* ensureWith({
213 baseDir: tempDir,
214 resourcesPath: path.join(tempDir, "resources"),
215 });
216 assert.isTrue(first.ok);
217
218 // Mutate the source; a reused tree must keep the first copy.
219 yield* fileSystem.writeFileString(
220 path.join(serverRoot, "apps/server/dist/bin.mjs"),
221 "v2-should-not-appear",
222 );
223 const second = yield* ensureWith({
224 baseDir: tempDir,
225 resourcesPath: path.join(tempDir, "resources"),
226 });
227 assert.isTrue(second.ok);
228 const root = second.ok ? second.root : "";
229 const entry = yield* fileSystem.readFileString(path.join(root, "apps/server/dist/bin.mjs"));
230 assert.equal(entry, "v1");
231 }),
232 ).pipe(Effect.provide(NodeServices.layer)),
233 );
234
235 it.effect("sweeps stale version directories and leftover partials after extraction", () =>
236 withTempDir((tempDir) =>
237 Effect.gen(function* () {
238 const fileSystem = yield* FileSystem.FileSystem;
239 const path = yield* Path.Path;
240 const serverRoot = path.join(tempDir, "resources", "server.asar");
241 yield* fileSystem.makeDirectory(path.join(serverRoot, "apps/server/dist"), {
242 recursive: true,
243 });
244 yield* fileSystem.writeFileString(path.join(serverRoot, "apps/server/dist/bin.mjs"), "x");
245
246 // T3CODE_HOME is set to tempDir, so the desktop state dir resolves to
247 // <tempDir>/userdata (no .t3 segment).
248 const treeRoot = path.join(tempDir, "userdata", "wsl-server-tree");
249 yield* fileSystem.makeDirectory(path.join(treeRoot, "1.0.0"), { recursive: true });
250 yield* fileSystem.makeDirectory(path.join(treeRoot, "1.2.3.partial"), { recursive: true });
251
252 const result = yield* ensureWith({
253 baseDir: tempDir,
254 resourcesPath: path.join(tempDir, "resources"),
255 });
256 assert.isTrue(result.ok);
257 assert.isFalse(yield* fileSystem.exists(path.join(treeRoot, "1.0.0")));
258 assert.isFalse(yield* fileSystem.exists(path.join(treeRoot, "1.2.3.partial")));
259 assert.isTrue(yield* fileSystem.exists(path.join(treeRoot, "1.2.3")));
260 }),
261 ).pipe(Effect.provide(NodeServices.layer)),
262 );
263
264 it.effect("re-extracts when the app version changes", () =>
265 withTempDir((tempDir) =>
266 Effect.gen(function* () {
267 const fileSystem = yield* FileSystem.FileSystem;
268 const path = yield* Path.Path;
269 const serverRoot = path.join(tempDir, "resources", "server.asar");
270 yield* fileSystem.makeDirectory(path.join(serverRoot, "apps/server/dist"), {
271 recursive: true,
272 });
273 yield* fileSystem.writeFileString(path.join(serverRoot, "apps/server/dist/bin.mjs"), "old");
274
275 const first = yield* ensureWith({
276 baseDir: tempDir,
277 resourcesPath: path.join(tempDir, "resources"),
278 appVersion: "1.2.3",
279 });
280 assert.isTrue(first.ok);
281
282 yield* fileSystem.writeFileString(path.join(serverRoot, "apps/server/dist/bin.mjs"), "new");
283 const second = yield* ensureWith({
284 baseDir: tempDir,
285 resourcesPath: path.join(tempDir, "resources"),
286 appVersion: "1.2.4",
287 });
288 assert.isTrue(second.ok);
289 const root = second.ok ? second.root : "";
290 assert.include(root, "1.2.4");
291 const entry = yield* fileSystem.readFileString(path.join(root, "apps/server/dist/bin.mjs"));
292 assert.equal(entry, "new");
293 // The previous version's tree is gone.
294 const treeRoot = path.dirname(root);
295 assert.isFalse(yield* fileSystem.exists(path.join(treeRoot, "1.2.3")));
296 }),
297 ).pipe(Effect.provide(NodeServices.layer)),
298 );
299
300 it.effect("reports a retryable failure when the archive cannot be read", () =>
301 withTempDir((tempDir) =>
302 Effect.gen(function* () {
303 const fileSystem = yield* FileSystem.FileSystem;
304 const path = yield* Path.Path;
305 const result = yield* ensureWith({
306 baseDir: tempDir,
307 // resources dir exists but server.asar does not
308 resourcesPath: path.join(tempDir, "resources"),
309 });
310 assert.isFalse(result.ok);
311 if (!result.ok) {
312 assert.include(result.reason, "could not be extracted");
313 assert.isFalse(result.fatal);
314 }
315 const treeRoot = path.join(tempDir, "userdata", "wsl-server-tree");
316 const leftovers = yield* fileSystem
317 .readDirectory(treeRoot)
318 .pipe(Effect.orElseSucceed(() => []));
319 assert.deepStrictEqual(leftovers, []);
320 }),
321 ).pipe(Effect.provide(NodeServices.layer)),
322 );
323});
apps/desktop/src/wsl/DesktopWslServerTree.ts created+226
......@@ -0,0 +1,226 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";
5import * as PlatformError from "effect/PlatformError";
6import * as Schema from "effect/Schema";
7import * as Semaphore from "effect/Semaphore";
8
9import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
10
11// Packaged Windows builds ship the server tree inside resources/server.asar
12// (see scripts/build-desktop-artifact.ts). The Windows primary reads it in
13// place through the asar-aware ELECTRON_RUN_AS_NODE runtime, but the WSL
14// backend launches plain `wsl.exe -- node`, which cannot read an asar
15// archive. This service materializes the archive into a real, version-keyed
16// directory the first time the WSL backend starts, and reuses it afterwards —
17// so only users who enable WSL ever pay for a loose copy of the server tree.
18//
19// Reading through Electron's patched fs also transparently returns the
20// contents of files that electron-builder/asar left in the server.asar.unpacked
21// sibling (native binaries), so a single walk of the archive yields the
22// complete tree.
23
24export type WslServerTreeResult =
25 | { readonly ok: true; readonly root: string }
26 | { readonly ok: false; readonly reason: string; readonly fatal: boolean };
27
28const MARKER_FILE_NAME = "t3code-wsl-server-tree.json";
29const COPY_CONCURRENCY = 8;
30
31const Marker = Schema.Struct({ version: Schema.String });
32const decodeMarker = Schema.decodeUnknownEffect(Schema.fromJsonString(Marker));
33const encodeMarker = Schema.encodeEffect(Schema.fromJsonString(Marker));
34
35export class DesktopWslServerTreeExtractError extends Schema.TaggedErrorClass<DesktopWslServerTreeExtractError>()(
36 "DesktopWslServerTreeExtractError",
37 {
38 targetDir: Schema.String,
39 cause: Schema.Defect(),
40 },
41) {
42 override get message(): string {
43 return `Failed to extract the WSL server tree to ${this.targetDir}.`;
44 }
45}
46
47export class DesktopWslServerTree extends Context.Service<
48 DesktopWslServerTree,
49 {
50 // Resolves the directory the WSL backend should treat as the app root
51 // (the directory containing apps/server/dist and node_modules). In dev
52 // the checkout already is that directory; packaged Windows builds extract
53 // server.asar on first use.
54 readonly ensure: Effect.Effect<WslServerTreeResult>;
55 }
56>()("@t3tools/desktop/wsl/DesktopWslServerTree") {}
57
58// Child scheduling stays here instead of inside `visit`, so nested directories
59// cannot create independent concurrency pools. The LIFO work list also keeps
60// traversal memory proportional to the remaining frontier rather than the
61// number of active fibers.
62export const forEachBoundedTree = <Node, E, R>(
63 roots: ReadonlyArray<Node>,
64 visit: (node: Node) => Effect.Effect<ReadonlyArray<Node>, E, R>,
65): Effect.Effect<void, E, R> =>
66 Effect.gen(function* () {
67 const pending = [...roots];
68 while (pending.length > 0) {
69 const batch = pending.splice(-COPY_CONCURRENCY);
70 const children = yield* Effect.forEach(batch, visit, {
71 concurrency: COPY_CONCURRENCY,
72 });
73 for (const entries of children) {
74 pending.push(...entries);
75 }
76 }
77 });
78
79interface CopyTreeEntry {
80 readonly sourcePath: string;
81 readonly targetPath: string;
82}
83
84// Copy using only operations supported by Electron's asar-patched fs. Symlinks
85// are not expected because the sidecar is installed with a hoisted, physical
86// layout; anything that is neither a file nor a directory is skipped.
87const copyTree = (
88 fs: FileSystem.FileSystem,
89 join: (first: string, ...rest: string[]) => string,
90 from: string,
91 to: string,
92): Effect.Effect<void, PlatformError.PlatformError> =>
93 forEachBoundedTree<CopyTreeEntry, PlatformError.PlatformError, never>(
94 [{ sourcePath: from, targetPath: to }],
95 ({ sourcePath, targetPath }) =>
96 Effect.gen(function* () {
97 const info = yield* fs.stat(sourcePath);
98 if (info.type === "Directory") {
99 yield* fs.makeDirectory(targetPath, { recursive: true });
100 const entries = yield* fs.readDirectory(sourcePath);
101 return entries.map((entry) => ({
102 sourcePath: join(sourcePath, entry),
103 targetPath: join(targetPath, entry),
104 }));
105 }
106 if (info.type === "File") {
107 // Read and write stay in the same bounded task, so at most eight file
108 // buffers can be retained while their writes complete.
109 const bytes = yield* fs.readFile(sourcePath);
110 yield* fs.writeFile(targetPath, bytes);
111 }
112 return [];
113 }),
114 );
115
116export const make = Effect.gen(function* () {
117 const environment = yield* DesktopEnvironment.DesktopEnvironment;
118 const fs = yield* FileSystem.FileSystem;
119 const join = environment.path.join;
120
121 const serverRoot = environment.serverRoot;
122 const needsExtraction = environment.isPackaged && environment.platform === "win32";
123 const treeRoot = join(environment.stateDir, "wsl-server-tree");
124 const version = environment.appVersion;
125 const versionDir = join(treeRoot, version);
126
127 // Remove sibling trees left behind by previous app versions (and aborted
128 // extractions). Best-effort: a locked file must not block the backend.
129 const sweepStale = Effect.gen(function* () {
130 const entries = yield* fs.readDirectory(treeRoot).pipe(Effect.orElseSucceed(() => []));
131 yield* Effect.forEach(
132 entries.filter((entry) => entry !== version),
133 (entry) => fs.remove(join(treeRoot, entry), { recursive: true }).pipe(Effect.ignore),
134 { discard: true },
135 );
136 });
137
138 const markerMatches = Effect.gen(function* () {
139 const raw = yield* fs.readFileString(join(versionDir, MARKER_FILE_NAME));
140 const marker = yield* decodeMarker(raw);
141 return marker.version === version;
142 }).pipe(Effect.orElseSucceed(() => false));
143
144 const extract = Effect.gen(function* () {
145 yield* Effect.log(`[wsl-server-tree] Extracting ${serverRoot} to ${versionDir}...`);
146 yield* fs.makeDirectory(treeRoot, { recursive: true });
147 // Keep the temporary tree beside the target so rename is atomic. Cleanup
148 // is owned explicitly because a scoped temp-directory finalizer treats the
149 // successful rename (and therefore missing original path) as an error.
150 const partialDir = yield* fs.makeTempDirectory({
151 directory: treeRoot,
152 prefix: `.${version}.extract-`,
153 });
154 yield* Effect.gen(function* () {
155 yield* copyTree(fs, join, serverRoot, partialDir);
156 const markerJson = yield* encodeMarker({ version });
157 yield* fs.writeFileString(join(partialDir, MARKER_FILE_NAME), `${markerJson}\n`);
158 // The marker is written before the rename, so a directory named after
159 // the version is complete by construction.
160 yield* fs.remove(versionDir, { recursive: true }).pipe(Effect.ignore);
161 yield* fs.rename(partialDir, versionDir);
162 }).pipe(
163 Effect.ensuring(fs.remove(partialDir, { recursive: true, force: true }).pipe(Effect.ignore)),
164 );
165 yield* Effect.log(`[wsl-server-tree] Extraction complete at ${versionDir}.`);
166 }).pipe(
167 Effect.mapError(
168 (cause) => new DesktopWslServerTreeExtractError({ targetDir: versionDir, cause }),
169 ),
170 );
171
172 // Serialize concurrent ensure calls (backend restarts can overlap): the
173 // first caller extracts, later callers see the marker and reuse the tree.
174 const gate = yield* Semaphore.make(1);
175
176 const ensure: Effect.Effect<WslServerTreeResult> = gate
177 .withPermits(1)(
178 Effect.gen(function* () {
179 if (!needsExtraction) {
180 return { ok: true, root: serverRoot } as const;
181 }
182 if (yield* markerMatches) {
183 yield* sweepStale;
184 return { ok: true, root: versionDir } as const;
185 }
186 const result = yield* extract.pipe(
187 Effect.map(() => ({ ok: true, root: versionDir }) as const),
188 // Retryable: transient antivirus locks and slow disks are the common
189 // causes, and the backend manager already bounds preflight retries.
190 Effect.catch((error) =>
191 Effect.succeed({
192 ok: false,
193 reason: `WSL server files could not be extracted to ${versionDir}: ${
194 error.cause instanceof Error ? error.cause.message : String(error.cause)
195 }`,
196 fatal: false,
197 } as const),
198 ),
199 );
200 if (result.ok) {
201 yield* sweepStale;
202 }
203 return result;
204 }),
205 )
206 .pipe(Effect.withSpan("desktop.wslServerTree.ensure"));
207
208 return DesktopWslServerTree.of({ ensure });
209});
210
211export const layer = Layer.effect(DesktopWslServerTree, make);
212
213export interface DesktopWslServerTreeTestStub {
214 readonly result?: WslServerTreeResult;
215}
216
217export const layerTest = (stub: DesktopWslServerTreeTestStub = {}) =>
218 Layer.effect(
219 DesktopWslServerTree,
220 Effect.gen(function* () {
221 const environment = yield* DesktopEnvironment.DesktopEnvironment;
222 return DesktopWslServerTree.of({
223 ensure: Effect.succeed(stub.result ?? { ok: true, root: environment.appRoot }),
224 });
225 }),
226 );
apps/server/package.json+1
......@@ -31,6 +31,7 @@
3131 "@opencode-ai/sdk": "^1.3.15",
3232 "@pierre/diffs": "catalog:",
3333 "effect": "catalog:",
34 "msgpackr-extract": "3.0.4",
3435 "node-pty": "^1.1.0",
3536 "yaml": "catalog:"
3637 },
docs/operations/release.md+31
......@@ -214,6 +214,37 @@ desktop-managed guidance when those environments are available.
214214 - `electron-updater` reads `latest-mac.yml` on stable and `nightly-mac.yml` on nightly, for both Intel and Apple Silicon.
215215 - The workflow merges the per-arch mac manifests into one channel-specific mac manifest before publishing the GitHub Release.
216216
217### Windows payload topology and update validation
218
219Windows packages the bundled server and only its runtime-external/native
220dependency closure in `resources/server.asar`. Native modules and helper
221executables declared as unpacked by that archive must be present at the matching
222paths below `resources/server.asar.unpacked`. The Windows-native backend reads
223the archive in place through Electron. WSL cannot read ASAR files, so enabling
224the WSL backend extracts the server tree once into the desktop state directory
225under `wsl-server-tree/<version>` and reuses the completed version until the app
226is updated.
227
228The artifact builder rejects a Windows package when any of these invariants
229break:
230
231- `resources/server.asar` is absent or does not contain the server entry.
232- Any file marked unpacked in the ASAR header is absent from
233 `resources/server.asar.unpacked`.
234- On same-architecture Windows builds, the packaged primary cannot load the fff
235 native library from inside `server.asar` through its `.unpacked` sibling.
236- The isolated, extracted sidecar cannot load the server entry with plain Node.
237- The external Windows resource monitor is absent.
238- The unpacked Windows application contains more than 80 files.
239
240Cross-architecture Windows builds retain every structural and extracted-sidecar
241check, but skip executing the target Electron binary. A same-architecture build
242for each release target must exercise the primary native-load probe.
243
244NSIS differential packaging remains enabled. A sidecar layout transition can
245produce a larger one-time download; subsequent small releases retain their
246blockmaps, with a 60 MB maximum for a representative sidecar-to-sidecar update.
247
217248## 0) npm OIDC trusted publishing setup (CLI)
218249
219250The workflow invokes `node apps/server/scripts/cli.ts publish` after aligning package versions. That
patches/@ff-labs__fff-node@0.9.4.patch+6-4
......@@ -11,16 +11,18 @@ index ee181aef5007e4bf34a49479c089ca30f73a320b..327e2c55c83cc4c50d396a3109190ef1
1111 import { fileURLToPath } from "node:url";
1212 import { getLibFilename, getNpmPackageName } from "./platform.js";
1313 /**
14@@ -46,6 +46,14 @@ function getPackageDir() {
14@@ -46,6 +46,16 @@ function getPackageDir() {
1515 // Fallback: assume we're one level deep in src/
1616 return dirname(currentDir);
1717 }
1818+function resolveUnpackedAsarPath(binaryPath) {
19+ const asarSegment = `${sep}app.asar${sep}`;
20+ if (!binaryPath.includes(asarSegment)) {
19+ const pathSegments = binaryPath.split(sep);
20+ const asarIndex = pathSegments.findLastIndex((segment) => segment.endsWith(".asar"));
21+ if (asarIndex === -1) {
2122+ return binaryPath;
2223+ }
23+ const unpackedPath = binaryPath.replace(asarSegment, `${sep}app.asar.unpacked${sep}`);
24+ pathSegments[asarIndex] = `${pathSegments[asarIndex]}.unpacked`;
25+ const unpackedPath = pathSegments.join(sep);
2426+ return existsSync(unpackedPath) ? unpackedPath : binaryPath;
2527+}
2628 /**
pnpm-lock.yaml+9-5
......@@ -75,7 +75,7 @@ patchedDependencies:
7575 '@clerk/expo@4.2.0': 72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1
7676 '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b
7777 '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46
78 '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8
78 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368
7979 '@legendapp/list@3.3.5': 6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5
8080 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa
8181 '@react-native-menu/menu@2.0.0': c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045
......@@ -463,7 +463,7 @@ importers:
463463 version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))
464464 '@ff-labs/fff-node':
465465 specifier: 0.9.4
466 version: 0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8)
466 version: 0.9.4(patch_hash=ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368)
467467 '@opencode-ai/sdk':
468468 specifier: ^1.3.15
469469 version: 1.15.13
......@@ -473,6 +473,9 @@ importers:
473473 effect:
474474 specifier: 4.0.0-beta.103
475475 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)
476 msgpackr-extract:
477 specifier: 3.0.4
478 version: 3.0.4
476479 node-pty:
477480 specifier: ^1.1.0
478481 version: 1.1.0
......@@ -913,6 +916,9 @@ importers:
913916 '@effect/platform-node':
914917 specifier: 4.0.0-beta.103
915918 version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6)
919 '@electron/asar':
920 specifier: ^3.4.1
921 version: 3.4.1
916922 '@t3tools/contracts':
917923 specifier: workspace:*
918924 version: link:../packages/contracts
......@@ -12789,7 +12795,7 @@ snapshots:
1278912795 '@ff-labs/fff-bin-win32-x64@0.9.4':
1279012796 optional: true
1279112797
12792 '@ff-labs/fff-node@0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8)':
12798 '@ff-labs/fff-node@0.9.4(patch_hash=ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368)':
1279312799 dependencies:
1279412800 ffi-rs: 1.3.2
1279512801 optionalDependencies:
......@@ -19143,7 +19149,6 @@ snapshots:
1914319149 '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4
1914419150 '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4
1914519151 '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4
19146 optional: true
1914719152
1914819153 msgpackr@2.0.4:
1914919154 optionalDependencies:
......@@ -19237,7 +19242,6 @@ snapshots:
1923719242 node-gyp-build-optional-packages@5.2.2:
1923819243 dependencies:
1923919244 detect-libc: 2.1.2
19240 optional: true
1924119245
1924219246 node-gyp-build@4.8.4:
1924319247 optional: true
scripts/build-desktop-artifact.test.ts+393-14
......@@ -2,15 +2,16 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
22import { assert, it } from "@effect/vitest";
33import * as ConfigProvider from "effect/ConfigProvider";
44import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
65import * as Effect from "effect/Effect";
76import * as Layer from "effect/Layer";
87import * as Option from "effect/Option";
8import * as Path from "effect/Path";
99import * as Sink from "effect/Sink";
1010import * as Stream from "effect/Stream";
1111import { ChildProcessSpawner } from "effect/unstable/process";
1212
1313import {
14 BundleNotSelfContainedError,
1415 BuildCommandFailedError,
1516 createStageWorkspaceConfig,
1617 createStagePatchedDependencies,
......@@ -26,6 +27,7 @@ import {
2627 LinuxIconResizeError,
2728 MacPasskeySigningConfigurationResolutionError,
2829 MissingMacPasskeyProvisioningProfileError,
30 packWindowsServerAsar,
2931 renderMacPasskeyEntitlements,
3032 resolveClerkPasskeyNativeArtifacts,
3133 resolveMacPasskeySigningConfiguration,
......@@ -44,9 +46,17 @@ import {
4446 resolvePackageManagerUserAgent,
4547 stageLinuxIconSize,
4648 STAGE_INSTALL_ARGS,
47 WINDOWS_ASAR_UNPACK,
4849 ancestorNodeModulesPaths,
4950 copyDirectoryPreservingSymlinks,
51 validateWindowsPackagedPayload,
52 WindowsPrimaryNativeProbeError,
53 WindowsPackagedPayloadValidationError,
54 WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT,
55 WINDOWS_SERVER_ASAR_IGNORE_GLOBS,
56 WINDOWS_SERVER_EXTRA_RESOURCES,
57 WINDOWS_SERVER_ASAR_RESOURCE,
58 WINDOWS_SERVER_ASAR_UNPACK_GLOB,
59 WINDOWS_SERVER_RESOURCE_SOURCE_DIR,
5060} from "./build-desktop-artifact.ts";
5161import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";
5262import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
......@@ -88,6 +98,54 @@ function iconResizeSpawnerLayer(
8898 );
8999}
90100
101const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(function* (input: {
102 readonly copyUnpackedNatives: boolean;
103 readonly serverEntrySource?: string;
104}) {
105 const fs = yield* FileSystem.FileSystem;
106 const path = yield* Path.Path;
107 const tempDir = yield* fs.makeTempDirectoryScoped({
108 prefix: "t3-windows-payload-test-",
109 });
110 const sourceDir = path.join(tempDir, "server-source");
111 const serverEntryPath = path.join(sourceDir, "apps/server/dist/bin.mjs");
112 const nativePath = path.join(sourceDir, "node_modules/native/addon.node");
113 yield* fs.makeDirectory(path.dirname(serverEntryPath), { recursive: true });
114 yield* fs.makeDirectory(path.dirname(nativePath), { recursive: true });
115 yield* fs.writeFileString(serverEntryPath, input.serverEntrySource ?? "console.log('server');\n");
116 yield* fs.writeFileString(nativePath, "native-binary");
117
118 const generatedAsarPath = path.join(tempDir, WINDOWS_SERVER_ASAR_RESOURCE);
119 yield* packWindowsServerAsar({ sourceDir, asarPath: generatedAsarPath });
120
121 const stageDistDir = path.join(tempDir, "dist");
122 const packagedAppDir = path.join(stageDistDir, "win-unpacked");
123 const resourcesDir = path.join(packagedAppDir, "resources");
124 yield* fs.makeDirectory(path.join(resourcesDir, "resource-monitor"), { recursive: true });
125 yield* fs.copyFile(generatedAsarPath, path.join(resourcesDir, WINDOWS_SERVER_ASAR_RESOURCE));
126 if (input.copyUnpackedNatives) {
127 yield* fs.copy(
128 `${generatedAsarPath}.unpacked`,
129 path.join(resourcesDir, `${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked`),
130 );
131 }
132 yield* fs.writeFileString(
133 path.join(resourcesDir, "resource-monitor/t3-resource-monitor.exe"),
134 "monitor",
135 );
136 const appExecutableName = "t3code.exe";
137 yield* fs.writeFileString(path.join(packagedAppDir, appExecutableName), "electron");
138 yield* fs.writeFileString(path.join(packagedAppDir, "chrome_crashpad_handler.exe"), "crashpad");
139
140 return {
141 stageDistDir,
142 packagedAppDir,
143 sourceDir,
144 generatedAsarPath,
145 appExecutableName,
146 } as const;
147});
148
91149it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
92150 it("resolves the dedicated nightly updater channel from nightly versions", () => {
93151 assert.equal(resolveDesktopUpdateChannel("0.0.17-nightly.20260413.42"), "nightly");
......@@ -232,22 +290,40 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
232290 libc: ["glibc"],
233291 },
234292 });
235 // Windows artifacts also bundle the same-architecture WSL (Linux, glibc) backend, so the
236 // staged install must fetch its native optional deps (e.g. ffi-rs) too.
293 // The Windows app stage only serves the desktop main process; the server
294 // sidecar stage is the one that needs Linux natives (below).
237295 assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "x64" }), {
238296 supportedArchitectures: {
239 os: ["win32", "linux"],
297 os: ["win32"],
240298 cpu: ["x64"],
241 libc: ["glibc"],
242299 },
243300 });
244 assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "arm64" }), {
245 supportedArchitectures: {
246 os: ["win32", "linux"],
247 cpu: ["arm64"],
248 libc: ["glibc"],
301 // The server sidecar stage bundles the same-architecture WSL (Linux,
302 // glibc) backend, so its install must fetch Linux native optional deps
303 // (e.g. ffi-rs) too — and must be hoisted so the tree survives asar
304 // packing and runtime extraction without symlinks.
305 assert.deepStrictEqual(
306 createStageWorkspaceConfig({ platform: "win", arch: "x64", linuxServerBackend: true }),
307 {
308 supportedArchitectures: {
309 os: ["win32", "linux"],
310 cpu: ["x64"],
311 libc: ["glibc"],
312 },
313 nodeLinker: "hoisted",
249314 },
250 });
315 );
316 assert.deepStrictEqual(
317 createStageWorkspaceConfig({ platform: "win", arch: "arm64", linuxServerBackend: true }),
318 {
319 supportedArchitectures: {
320 os: ["win32", "linux"],
321 cpu: ["arm64"],
322 libc: ["glibc"],
323 },
324 nodeLinker: "hoisted",
325 },
326 );
251327 assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "mac", arch: "universal" }), {
252328 supportedArchitectures: {
253329 os: ["darwin"],
......@@ -317,6 +393,16 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
317393 assert.deepStrictEqual(DESKTOP_ELECTRON_LANGUAGES, ["en-US"]);
318394 assert.deepStrictEqual(DESKTOP_FILE_EXCLUSIONS, [
319395 "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*",
396 "!apps/desktop/prod-resources/windows-server",
397 "!apps/desktop/prod-resources/windows-server/**/*",
398 ]);
399 assert.equal(WINDOWS_SERVER_RESOURCE_SOURCE_DIR, "apps/desktop/prod-resources/windows-server");
400 assert.deepStrictEqual(WINDOWS_SERVER_EXTRA_RESOURCES, [
401 {
402 from: "apps/desktop/prod-resources/windows-server",
403 to: ".",
404 filter: ["server.asar", "server.asar.unpacked/**/*"],
405 },
320406 ]);
321407 });
322408
......@@ -350,9 +436,33 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
350436 undefined,
351437 );
352438
439 // All platforms keep app.asar fully packed; Windows ships the server
440 // tree as the hand-packed server.asar sidecar in extraResources instead
441 // of unpacking thousands of loose files at install time.
353442 assert.notProperty(mac, "asarUnpack");
354443 assert.notProperty(linux, "asarUnpack");
355 assert.deepStrictEqual(win.asarUnpack, WINDOWS_ASAR_UNPACK);
444 assert.notProperty(win, "asarUnpack");
445 assert.deepStrictEqual(win.extraResources, [
446 {
447 from: "apps/desktop/prod-resources/resource-monitor",
448 to: "resource-monitor",
449 },
450 ...WINDOWS_SERVER_EXTRA_RESOURCES,
451 ]);
452 assert.deepStrictEqual(win.nsis, { differentialPackage: true });
453 // Native binaries and helper executables cannot load from inside an
454 // asar; everything else stays packed. The Claude SDK platform packages
455 // and .bin shims never ship.
456 assert.equal(
457 WINDOWS_SERVER_ASAR_UNPACK_GLOB,
458 "{**/*.node,**/*.dll,**/*.exe,**/*.so,**/*.so.*,**/*.dylib}",
459 );
460 assert.deepStrictEqual(WINDOWS_SERVER_ASAR_IGNORE_GLOBS, [
461 "**/node_modules/@anthropic-ai/claude-agent-sdk-*",
462 "**/node_modules/@anthropic-ai/claude-agent-sdk-*/**",
463 "**/node_modules/.bin",
464 "**/node_modules/.bin/**",
465 ]);
356466 // Linux must register the renderer schemes so the generated .desktop
357467 // entry advertises MimeType=x-scheme-handler/t3code; for OAuth deep links.
358468 assert.deepStrictEqual((linux.linux as Record<string, unknown>).protocols, [
......@@ -365,6 +475,275 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
365475 }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))),
366476 );
367477
478 it.effect("validates every ASAR-unpacked native in the packaged Windows payload", () =>
479 Effect.scoped(
480 Effect.gen(function* () {
481 const fs = yield* FileSystem.FileSystem;
482 const path = yield* Path.Path;
483 const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true });
484 const result = yield* validateWindowsPackagedPayload({
485 stageDistDir: fixture.stageDistDir,
486 appExecutableName: fixture.appExecutableName,
487 targetArch: "x64",
488 });
489
490 const secondAsarPath = path.join(path.dirname(fixture.generatedAsarPath), "second.asar");
491 yield* packWindowsServerAsar({
492 sourceDir: fixture.sourceDir,
493 asarPath: secondAsarPath,
494 });
495 const [firstAsar, secondAsar] = yield* Effect.all([
496 fs.readFile(fixture.generatedAsarPath),
497 fs.readFile(secondAsarPath),
498 ]);
499
500 assert.equal(result.packagedAppDir, fixture.packagedAppDir);
501 assert.deepStrictEqual(result.unpackedFiles, ["node_modules/native/addon.node"]);
502 assert.isBelow(result.fileCount, WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT);
503 assert.deepStrictEqual(secondAsar, firstAsar);
504 }),
505 ),
506 );
507
508 it.effect("probes fff through the packaged Windows primary instead of helper executables", () => {
509 const commands: Array<{
510 readonly command: string;
511 readonly args: ReadonlyArray<string>;
512 readonly options: {
513 readonly cwd?: string;
514 readonly env?: Readonly<Record<string, string | undefined>>;
515 };
516 }> = [];
517 const spawnerLayer = Layer.succeed(
518 ChildProcessSpawner.ChildProcessSpawner,
519 ChildProcessSpawner.make((command) => {
520 commands.push(command as unknown as (typeof commands)[number]);
521 return Effect.succeed(mockProcess(0));
522 }),
523 );
524
525 return Effect.scoped(
526 Effect.gen(function* () {
527 const path = yield* Path.Path;
528 const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true });
529 yield* validateWindowsPackagedPayload({
530 stageDistDir: fixture.stageDistDir,
531 appExecutableName: fixture.appExecutableName,
532 targetArch: "x64",
533 });
534
535 const primaryProbe = commands.find(
536 (command) => command.options.env?.ELECTRON_RUN_AS_NODE === "1",
537 );
538 if (primaryProbe === undefined) return assert.fail("Windows primary probe was not spawned");
539
540 assert.equal(
541 primaryProbe.command,
542 path.join(fixture.packagedAppDir, fixture.appExecutableName),
543 );
544 assert.deepStrictEqual(primaryProbe.args.slice(0, 3), [
545 "--no-global-search-paths",
546 "--input-type=module",
547 "--eval",
548 ]);
549 assert.include(primaryProbe.args[3], "FileFinder.create");
550 assert.equal(
551 primaryProbe.args[4],
552 path.join(
553 fixture.packagedAppDir,
554 "resources/server.asar/node_modules/@ff-labs/fff-node/dist/src/index.js",
555 ),
556 );
557 assert.equal(primaryProbe.options.cwd, fixture.packagedAppDir);
558 assert.equal(primaryProbe.options.env?.NODE_PATH, "");
559 }),
560 ).pipe(
561 Effect.provide(
562 Layer.mergeAll(
563 spawnerLayer,
564 Layer.succeed(HostProcessPlatform, "win32"),
565 Layer.succeed(HostProcessArchitecture, "x64"),
566 ),
567 ),
568 );
569 });
570
571 it.effect("skips the primary native probe for cross-architecture Windows payloads", () => {
572 const commands: Array<{
573 readonly command: string;
574 readonly options: {
575 readonly env?: Readonly<Record<string, string | undefined>>;
576 };
577 }> = [];
578 const spawnerLayer = Layer.succeed(
579 ChildProcessSpawner.ChildProcessSpawner,
580 ChildProcessSpawner.make((command) => {
581 commands.push(command as unknown as (typeof commands)[number]);
582 return Effect.succeed(mockProcess(0));
583 }),
584 );
585
586 return Effect.scoped(
587 Effect.gen(function* () {
588 const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true });
589 yield* validateWindowsPackagedPayload({
590 stageDistDir: fixture.stageDistDir,
591 appExecutableName: fixture.appExecutableName,
592 targetArch: "arm64",
593 });
594
595 assert.isFalse(
596 commands.some((command) => command.options.env?.ELECTRON_RUN_AS_NODE === "1"),
597 );
598 assert.isTrue(
599 commands.some(
600 (command) =>
601 command.command === process.execPath && command.options.env?.NODE_PATH === "",
602 ),
603 );
604 }),
605 ).pipe(
606 Effect.provide(
607 Layer.mergeAll(
608 spawnerLayer,
609 Layer.succeed(HostProcessPlatform, "win32"),
610 Layer.succeed(HostProcessArchitecture, "x64"),
611 ),
612 ),
613 );
614 });
615
616 it.effect("rejects a cross-architecture Windows payload without its primary executable", () =>
617 Effect.scoped(
618 Effect.gen(function* () {
619 const fs = yield* FileSystem.FileSystem;
620 const path = yield* Path.Path;
621 const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true });
622 const executablePath = path.join(fixture.packagedAppDir, fixture.appExecutableName);
623 yield* fs.remove(executablePath);
624
625 const error = yield* validateWindowsPackagedPayload({
626 stageDistDir: fixture.stageDistDir,
627 appExecutableName: fixture.appExecutableName,
628 targetArch: "arm64",
629 }).pipe(Effect.flip);
630
631 assert.instanceOf(error, WindowsPrimaryNativeProbeError);
632 assert.equal(error.executablePath, executablePath);
633 }),
634 ).pipe(
635 Effect.provide(
636 Layer.mergeAll(
637 Layer.succeed(HostProcessPlatform, "win32"),
638 Layer.succeed(HostProcessArchitecture, "x64"),
639 ),
640 ),
641 ),
642 );
643
644 it.effect("rejects a packaged sidecar whose ASAR-unpacked native is missing", () =>
645 Effect.scoped(
646 Effect.gen(function* () {
647 const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: false });
648 const error = yield* validateWindowsPackagedPayload({
649 stageDistDir: fixture.stageDistDir,
650 appExecutableName: fixture.appExecutableName,
651 targetArch: "x64",
652 }).pipe(Effect.flip);
653
654 assert.instanceOf(error, WindowsPackagedPayloadValidationError);
655 assert.equal(error.reason, "unpacked-native-missing");
656 assert.deepStrictEqual(error.missingFiles, [
657 "server.asar.unpacked/node_modules/native/addon.node",
658 ]);
659 }),
660 ),
661 );
662
663 it.effect("rejects directories in place of packaged executable files", () =>
664 Effect.scoped(
665 Effect.gen(function* () {
666 const fs = yield* FileSystem.FileSystem;
667 const path = yield* Path.Path;
668 const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true });
669 const nativePath = path.join(
670 fixture.packagedAppDir,
671 "resources/server.asar.unpacked/node_modules/native/addon.node",
672 );
673 yield* fs.remove(nativePath);
674 yield* fs.makeDirectory(nativePath);
675
676 const nativeError = yield* validateWindowsPackagedPayload({
677 stageDistDir: fixture.stageDistDir,
678 appExecutableName: fixture.appExecutableName,
679 targetArch: "x64",
680 }).pipe(Effect.flip);
681 assert.instanceOf(nativeError, WindowsPackagedPayloadValidationError);
682 assert.equal(nativeError.reason, "unpacked-native-missing");
683 assert.deepStrictEqual(nativeError.missingFiles, [
684 "server.asar.unpacked/node_modules/native/addon.node",
685 ]);
686
687 yield* fs.remove(nativePath, { recursive: true });
688 yield* fs.writeFileString(nativePath, "native-binary");
689 const resourceMonitorPath = path.join(
690 fixture.packagedAppDir,
691 "resources/resource-monitor/t3-resource-monitor.exe",
692 );
693 yield* fs.remove(resourceMonitorPath);
694 yield* fs.makeDirectory(resourceMonitorPath);
695
696 const resourceMonitorError = yield* validateWindowsPackagedPayload({
697 stageDistDir: fixture.stageDistDir,
698 appExecutableName: fixture.appExecutableName,
699 targetArch: "x64",
700 }).pipe(Effect.flip);
701 assert.instanceOf(resourceMonitorError, WindowsPackagedPayloadValidationError);
702 assert.equal(resourceMonitorError.reason, "resource-monitor-missing");
703 assert.deepStrictEqual(resourceMonitorError.missingFiles, [
704 "resource-monitor/t3-resource-monitor.exe",
705 ]);
706 }),
707 ),
708 );
709
710 it.effect("rejects a Windows payload that regresses above the file-count budget", () =>
711 Effect.scoped(
712 Effect.gen(function* () {
713 const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true });
714 const error = yield* validateWindowsPackagedPayload({
715 stageDistDir: fixture.stageDistDir,
716 appExecutableName: fixture.appExecutableName,
717 targetArch: "x64",
718 fileLimit: 2,
719 }).pipe(Effect.flip);
720
721 assert.instanceOf(error, WindowsPackagedPayloadValidationError);
722 assert.equal(error.reason, "file-limit-exceeded");
723 assert.isAbove(error.fileCount ?? 0, 2);
724 }),
725 ),
726 );
727
728 it.effect("rejects a sidecar whose extracted server bundle cannot resolve", () =>
729 Effect.scoped(
730 Effect.gen(function* () {
731 const fixture = yield* makeWindowsPayloadFixture({
732 copyUnpackedNatives: true,
733 serverEntrySource: 'import "t3code-deliberately-missing-package";\n',
734 });
735 const error = yield* validateWindowsPackagedPayload({
736 stageDistDir: fixture.stageDistDir,
737 appExecutableName: fixture.appExecutableName,
738 targetArch: "x64",
739 }).pipe(Effect.flip);
740
741 assert.instanceOf(error, BundleNotSelfContainedError);
742 assert.include(error.output, "t3code-deliberately-missing-package");
743 }),
744 ),
745 );
746
368747 it.effect("preserves both Linux icon resize failures with structural context", () => {
369748 const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> = [];
370749
......@@ -773,7 +1152,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
7731152});
7741153
7751154// The self-containment check runs the packaged tree in a scratch directory. Its
776// own node_modules holds the unpacked externals and must be ignored, but any
1155// own node_modules holds the sidecar externals and must be ignored, but any
7771156// node_modules *above* it would let Node's parent walk satisfy an import that is
7781157// missing from the package, so the probe refuses to run in that case.
7791158it("lists ancestor node_modules, nearest first, excluding the start directory", () => {
scripts/build-desktop-artifact.ts+618-89
......@@ -4,8 +4,16 @@
44import * as NodeFSP from "node:fs/promises";
55import * as NodeModule from "node:module";
66
7import {
8 createPackageWithOptions,
9 extractAll,
10 getRawHeader,
11 statFile,
12 type DirectoryRecord,
13} from "@electron/asar";
14
715import { fromYaml } from "@t3tools/shared/schemaYaml";
8import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
16import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
917import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth";
1018import { resolveSpawnCommand } from "@t3tools/shared/shell";
1119import rootPackageJson from "../package.json" with { type: "json" };
......@@ -20,8 +28,8 @@ import {
2028} from "./lib/brand-assets.ts";
2129import { getDefaultBuildArch } from "./lib/build-target-arch.ts";
2230import {
23 CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS,
2431 findInlinedExternalPackages,
32 selectCliRuntimeExternalDependencies,
2533} from "./lib/cli-external-packages.ts";
2634import { loadRepoEnv } from "./lib/public-config.ts";
2735import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts";
......@@ -69,6 +77,7 @@ const StageWorkspaceConfig = Schema.Struct({
6977 allowBuilds: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
7078 patchedDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
7179 overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)),
80 nodeLinker: Schema.optional(Schema.Literals(["hoisted"])),
7281});
7382type StageWorkspaceConfig = typeof StageWorkspaceConfig.Type;
7483
......@@ -386,18 +395,36 @@ const desktopBuildInputArtifactNames = {
386395/**
387396 * Imported by every server module, so it is inlined in any correctly bundled
388397 * build. Its absence means the bundle went back to externalizing its
389 * dependencies, which the unpack globs do not cover.
398 * dependencies, which the sidecar's selected runtime closure does not cover.
390399 */
391400const BUNDLE_SELF_CONTAINED_SENTINEL = "effect";
392401
393402const BUNDLE_SELF_CHECK_TIMEOUT = Duration.seconds(120);
403const WINDOWS_PRIMARY_NATIVE_PROBE_TIMEOUT = Duration.seconds(30);
404
405const WINDOWS_PRIMARY_FFF_PROBE_SOURCE = `
406const { join } = await import("node:path");
407const { pathToFileURL } = await import("node:url");
408const { FileFinder } = await import(pathToFileURL(process.argv[1]).href);
409const probeRoot = process.argv[2];
410const result = FileFinder.create({
411 basePath: probeRoot,
412 frecencyDbPath: join(probeRoot, "frecency.mdb"),
413 historyDbPath: join(probeRoot, "history.mdb"),
414 disableWatch: true,
415 disableMmapCache: true,
416 disableContentIndexing: true,
417});
418if (!result.ok) throw new Error(result.error);
419result.value.destroy();
420`;
394421
395422export class ExternalizedBundleError extends Schema.TaggedErrorClass<ExternalizedBundleError>()(
396423 "ExternalizedBundleError",
397424 { sentinel: Schema.String, inlinedPackageCount: Schema.Number },
398425) {
399426 override get message(): string {
400 return `The server bundle did not inline "${this.sentinel}" (${this.inlinedPackageCount} packages inlined). The bundle is meant to be self-contained apart from the native externals; if its dependencies are external again they will not be unpacked, and the WSL backend will fail with ERR_MODULE_NOT_FOUND. Check the deps.alwaysBundle wiring in apps/server/vite.config.ts.`;
427 return `The server bundle did not inline "${this.sentinel}" (${this.inlinedPackageCount} packages inlined). The bundle is meant to be self-contained apart from the runtime externals; if its dependencies are external again they will be absent from the sidecar, and the backend will fail with ERR_MODULE_NOT_FOUND. Check the deps.alwaysBundle wiring in apps/server/vite.config.ts.`;
401428 }
402429}
403430
......@@ -406,7 +433,7 @@ export class BundleNotSelfContainedError extends Schema.TaggedErrorClass<BundleN
406433 { exitCode: Schema.Number, output: Schema.String },
407434) {
408435 override get message(): string {
409 return `The packaged server bundle could not load with only its unpacked dependencies present (exit ${this.exitCode}). Anything it imports that is neither a Node built-in nor an unpacked external is unreachable to the WSL backend, which runs plain node and cannot read app.asar. Output:
436 return `The packaged server bundle could not load from the isolated, extracted sidecar (exit ${this.exitCode}). Anything it imports that is neither a Node built-in nor in the selected runtime-external closure is unavailable to both backends. Output:
410437${this.output}`;
411438 }
412439}
......@@ -416,7 +443,7 @@ export class InlinedNativePackageError extends Schema.TaggedErrorClass<InlinedNa
416443 { packages: Schema.Array(Schema.String) },
417444) {
418445 override get message(): string {
419 return `The server bundle inlined packages that load native binaries: ${this.packages.join(", ")}. A node-gyp-build style loader resolves prebuilds relative to its own file, so inlined into a chunk it finds none and the importer quietly falls back to a slower JS path. Add them to CLI_RUNTIME_EXTERNAL_PREFIXES in scripts/lib/cli-external-packages.ts so they stay external and get unpacked.`;
446 return `The server bundle inlined packages that load native binaries: ${this.packages.join(", ")}. A node-gyp-build style loader resolves prebuilds relative to its own file, so inlined into a chunk it finds none and the importer quietly falls back to a slower JS path. Add them to CLI_RUNTIME_EXTERNAL_PREFIXES in scripts/lib/cli-external-packages.ts so they stay external and are staged in the sidecar.`;
420447 }
421448}
422449
......@@ -490,6 +517,71 @@ export class WslNodePtyPrebuildMissingError extends Schema.TaggedErrorClass<WslN
490517 }
491518}
492519
520export class WindowsServerSidecarPackError extends Schema.TaggedErrorClass<WindowsServerSidecarPackError>()(
521 "WindowsServerSidecarPackError",
522 {
523 asarPath: Schema.String,
524 cause: Schema.optionalKey(Schema.Defect()),
525 },
526) {
527 override get message(): string {
528 return `Failed to pack the Windows server sidecar at ${this.asarPath}.`;
529 }
530}
531
532export class WindowsPrimaryNativeProbeError extends Schema.TaggedErrorClass<WindowsPrimaryNativeProbeError>()(
533 "WindowsPrimaryNativeProbeError",
534 {
535 executablePath: Schema.String,
536 exitCode: Schema.Number,
537 output: Schema.String,
538 },
539) {
540 override get message(): string {
541 return `The packaged Windows primary could not load fff from server.asar (exit ${this.exitCode}). Output:\n${this.output}`;
542 }
543}
544
545const WindowsPackagedPayloadValidationReason = Schema.Literals([
546 "packaged-app-missing",
547 "sidecar-missing",
548 "sidecar-invalid",
549 "unpacked-native-missing",
550 "resource-monitor-missing",
551 "file-limit-exceeded",
552]);
553
554export class WindowsPackagedPayloadValidationError extends Schema.TaggedErrorClass<WindowsPackagedPayloadValidationError>()(
555 "WindowsPackagedPayloadValidationError",
556 {
557 reason: WindowsPackagedPayloadValidationReason,
558 packagedAppDir: Schema.String,
559 missingFiles: Schema.optionalKey(Schema.Array(Schema.String)),
560 fileCount: Schema.optionalKey(Schema.Int),
561 fileLimit: Schema.optionalKey(Schema.Int),
562 cause: Schema.optionalKey(Schema.Defect()),
563 },
564) {
565 override get message(): string {
566 if (this.reason === "file-limit-exceeded") {
567 return `Windows packaged payload contains ${String(this.fileCount)} files; expected at most ${String(this.fileLimit)}.`;
568 }
569 if (this.reason === "unpacked-native-missing") {
570 return `Windows server sidecar is missing ${String(this.missingFiles?.length ?? 0)} unpacked native files.`;
571 }
572 if (this.reason === "resource-monitor-missing") {
573 return "Windows packaged payload is missing the resource monitor executable.";
574 }
575 if (this.reason === "sidecar-invalid") {
576 return "Windows packaged payload contains an invalid server.asar sidecar.";
577 }
578 if (this.reason === "sidecar-missing") {
579 return "Windows packaged payload is missing resources/server.asar.";
580 }
581 return `Windows packaged application directory was not found at ${this.packagedAppDir}.`;
582 }
583}
584
493585export class WslNodePtyManifestReadError extends Schema.TaggedErrorClass<WslNodePtyManifestReadError>()(
494586 "WslNodePtyManifestReadError",
495587 {
......@@ -686,21 +778,47 @@ export const DESKTOP_FILE_EXCLUSIONS = [
686778 // so the SDK's optional platform packages (each a ~200MB bundled executable)
687779 // are dead weight. The trailing dash keeps the SDK's own JS package.
688780 "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*",
781 // Windows stages the server sidecar below prod-resources so electron-builder
782 // can copy it using project-relative extraResources matchers. Keep those
783 // staging inputs out of app.asar; they are emitted once at resources/.
784 "!apps/desktop/prod-resources/windows-server",
785 "!apps/desktop/prod-resources/windows-server/**/*",
689786] as const;
690// The WSL backend launches the server with plain `wsl.exe -- node`, which cannot
691// read inside an asar archive, so everything it loads must be on the real
692// filesystem. This used to unpack `**\/node_modules\/**` wholesale, because the
693// server bundle externalized its runtime deps and the Linux Node would fail with
694// ERR_MODULE_NOT_FOUND ("Cannot find package 'effect'") before it even reached
695// node-pty.
696//
697// The CLI bundle now inlines its JS dependencies, so the only things that still
698// have to be loose are the server bundle itself and the packages the bundle
699// leaves external — derived from the same list the bundler uses, so the two
700// cannot drift apart.
701export const WINDOWS_ASAR_UNPACK = [
702 "apps/server/dist/**",
703 ...CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS,
787// Windows ships the server tree (bundle + node_modules) as a separate
788// resources/server.asar sidecar instead of loose files: the NSIS installer
789// then extracts a handful of large archives instead of thousands of small
790// files, which dominates install (and update) time. The Windows primary runs
791// the server from inside server.asar via the asar-aware ELECTRON_RUN_AS_NODE
792// runtime; the WSL backend cannot read asar archives, so enabling WSL lazily
793// extracts the sidecar to a version-keyed directory (see DesktopWslServerTree).
794export const WINDOWS_SERVER_ASAR_RESOURCE = "server.asar";
795// dlopen/spawn need real files, so native modules, shared libraries, and
796// helper executables live in the server.asar.unpacked sibling (the standard
797// asar redirect convention). Everything else stays packed.
798export const WINDOWS_SERVER_ASAR_UNPACK_GLOB =
799 "{**/*.node,**/*.dll,**/*.exe,**/*.so,**/*.so.*,**/*.dylib}";
800// Mirrors DESKTOP_FILE_EXCLUSIONS for the hand-packed sidecar: the Claude SDK
801// platform packages are dead weight (see above), and node_modules/.bin shims
802// are never spawned at runtime (and are symlinks on POSIX build hosts, which
803// the asar extraction path deliberately does not support).
804export const WINDOWS_SERVER_ASAR_IGNORE_GLOBS = [
805 "**/node_modules/@anthropic-ai/claude-agent-sdk-*",
806 "**/node_modules/@anthropic-ai/claude-agent-sdk-*/**",
807 "**/node_modules/.bin",
808 "**/node_modules/.bin/**",
809] as const;
810export const WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT = 80;
811export const WINDOWS_SERVER_RESOURCE_SOURCE_DIR = "apps/desktop/prod-resources/windows-server";
812export const WINDOWS_SERVER_EXTRA_RESOURCES = [
813 {
814 // Copy the archive and its .unpacked sibling from one parent directory.
815 // Mapping the .unpacked directory as an independent FileSet silently
816 // omitted it from Windows packages even though electron-builder copied
817 // the adjacent archive.
818 from: WINDOWS_SERVER_RESOURCE_SOURCE_DIR,
819 to: ".",
820 filter: [WINDOWS_SERVER_ASAR_RESOURCE, `${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked/**/*`],
821 },
704822] as const;
705823export const DESKTOP_EXTRA_RESOURCES = [
706824 {
......@@ -1019,14 +1137,20 @@ export function createStageWorkspaceConfig(input: {
10191137 readonly allowBuilds?: Record<string, boolean>;
10201138 readonly patchedDependencies?: Record<string, string>;
10211139 readonly overrides?: Record<string, string>;
1140 // The Windows server sidecar stage runs both the Windows primary and the
1141 // WSL Linux backend from one dependency tree, so it needs win32 + linux
1142 // natives (e.g. @yuuang/ffi-rs-linux-x64-gnu) — and a hoisted (physical,
1143 // symlink-free) node_modules: the tree gets packed into server.asar and
1144 // later extracted for WSL, and neither step can rely on pnpm's
1145 // symlink/junction layout surviving the trip.
1146 readonly linuxServerBackend?: boolean;
10221147}): StageWorkspaceConfig {
1023 const { platform, arch, allowBuilds, patchedDependencies, overrides } = input;
1148 const { platform, arch, allowBuilds, patchedDependencies, overrides, linuxServerBackend } = input;
10241149 const hostOs = platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux";
10251150 const hostCpu = arch === "universal" ? ["arm64", "x64"] : [arch];
1026 // Linux AppImages and Windows WSL backends both execute a Linux/glibc Node
1027 // process that loads Linux-native optional deps at runtime (e.g.
1028 // @yuuang/ffi-rs-linux-x64-gnu). Keep libc explicit so pnpm includes those
1029 // optional packages in the staged production install.
1151 // Linux AppImages execute a Linux/glibc Node process that loads
1152 // Linux-native optional deps at runtime. Keep libc explicit so pnpm
1153 // includes those optional packages in the staged production install.
10301154 const supportedArchitectures =
10311155 platform === "linux"
10321156 ? {
......@@ -1034,7 +1158,7 @@ export function createStageWorkspaceConfig(input: {
10341158 cpu: hostCpu,
10351159 libc: ["glibc"],
10361160 }
1037 : platform === "win"
1161 : linuxServerBackend
10381162 ? {
10391163 os: Array.from(new Set([hostOs, "linux"])),
10401164 cpu: hostCpu,
......@@ -1052,6 +1176,7 @@ export function createStageWorkspaceConfig(input: {
10521176 ? { patchedDependencies }
10531177 : {}),
10541178 ...(overrides && Object.keys(overrides).length > 0 ? { overrides } : {}),
1179 ...(linuxServerBackend ? { nodeLinker: "hoisted" as const } : {}),
10551180 };
10561181}
10571182
......@@ -1411,36 +1536,27 @@ export const copyDirectoryPreservingSymlinks = Effect.fn("copyDirectoryPreservin
14111536);
14121537
14131538const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSelfContained")(
1414 function* (input: { readonly stageDistDir: string; readonly verbose: boolean }) {
1539 function* (input: { readonly asarPath: string; readonly verbose: boolean }) {
14151540 const fs = yield* FileSystem.FileSystem;
14161541 const path = yield* Path.Path;
14171542
1418 // electron-builder names this win-unpacked, win-arm64-unpacked, and so on.
1419 const distEntries = yield* fs
1420 .readDirectory(input.stageDistDir)
1421 .pipe(Effect.orElseSucceed(() => [] as Array<string>));
1422 let unpackedRoot: string | null = null;
1423 for (const entry of distEntries) {
1424 const candidate = path.join(input.stageDistDir, entry, "resources/app.asar.unpacked");
1425 if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) {
1426 unpackedRoot = candidate;
1427 break;
1428 }
1429 }
1430 // Nothing to verify rather than silently passing: a packaging layout change
1431 // should surface here instead of turning the check into a no-op.
1432 if (unpackedRoot === null) {
1433 return yield* new BundleNotSelfContainedError({
1434 exitCode: -1,
1435 output: `No */resources/app.asar.unpacked directory under ${input.stageDistDir}; the bundle self-containment check found nothing to verify.`,
1436 });
1437 }
1438
14391543 const probeRoot = yield* fs.makeTempDirectoryScoped({
14401544 prefix: "t3code-bundle-selfcheck-",
14411545 });
1546 const extractedApp = path.join(probeRoot, "extracted");
14421547 const probeApp = path.join(probeRoot, "app");
1443 yield* copyDirectoryPreservingSymlinks(unpackedRoot, probeApp);
1548 yield* Effect.try({
1549 try: () => extractAll(input.asarPath, extractedApp),
1550 catch: (cause) =>
1551 new BundleNotSelfContainedError({
1552 exitCode: -1,
1553 output: `Could not extract ${input.asarPath} for the bundle self-containment check: ${String(cause)}`,
1554 }),
1555 });
1556 // Keep the existing symlink isolation guard even though the sidecar stage
1557 // is hoisted and should be physical. A future package-manager layout change
1558 // must not let the probe resolve through the build tree.
1559 yield* copyDirectoryPreservingSymlinks(extractedApp, probeApp);
14441560
14451561 // Guard the guard: if anything above the probe provides a node_modules, a
14461562 // missing dependency would resolve there and the check would pass while the
......@@ -1466,8 +1582,8 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel
14661582 // missing dependency shows up, without starting a server or touching disk
14671583 // state. It does not cover lazily imported externals: node-pty is checked
14681584 // by the WSL preflight probe at runtime, while ffi-rs, @ff-labs/fff-node
1469 // and the bun adapters are only covered by the unpack globs and the
1470 // inlined-native check below.
1585 // and the bun adapters are covered by the shared runtime-external closure
1586 // and emitted-bundle checks.
14711587 yield* runCommand(
14721588 ChildProcess.make(
14731589 process.execPath,
......@@ -1486,7 +1602,10 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel
14861602 env: { ...process.env, NODE_PATH: "" },
14871603 },
14881604 ),
1489 { label: "bundle self-containment check (node bin.mjs --version)", verbose: input.verbose },
1605 {
1606 label: "server sidecar self-containment check (node bin.mjs --version)",
1607 verbose: input.verbose,
1608 },
14901609 ).pipe(
14911610 // Printing a version should be immediate. A regression that blocks (on
14921611 // stdin, a port, a lock) would otherwise hang release CI until the job
......@@ -1878,11 +1997,14 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
18781997 directories: {
18791998 buildResources: "apps/desktop/resources",
18801999 },
1881 // Only the Windows WSL backend needs files outside the asar (see
1882 // WINDOWS_ASAR_UNPACK); macOS and Linux stay packed — smart unpack
1883 // extracts native libraries, which fff-node finds in app.asar.unpacked.
1884 ...(platform === "win" ? { asarUnpack: [...WINDOWS_ASAR_UNPACK] } : {}),
1885 extraResources: DESKTOP_EXTRA_RESOURCES,
2000 // All platforms keep app.asar fully packed; electron-builder's default
2001 // smart unpack extracts native libraries, which loaders find in
2002 // app.asar.unpacked. Windows additionally ships the server tree as the
2003 // hand-packed server.asar sidecar (see WINDOWS_SERVER_ASAR_RESOURCE).
2004 extraResources: [
2005 ...DESKTOP_EXTRA_RESOURCES,
2006 ...(platform === "win" ? WINDOWS_SERVER_EXTRA_RESOURCES : []),
2007 ],
18862008 };
18872009 const updateChannel = resolveDesktopUpdateChannel(version);
18882010 const publishConfig = yield* resolveGitHubPublishConfig(updateChannel);
......@@ -1942,6 +2064,10 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
19422064
19432065 if (platform === "win") {
19442066 buildConfig.npmRebuild = false;
2067 // Keep blockmap-based differential downloads enabled while changing the
2068 // installed file topology. The optimization is in the payload shape, not
2069 // in trading update bandwidth for install speed.
2070 buildConfig.nsis = { differentialPackage: true };
19452071 const winConfig: Record<string, unknown> = {
19462072 target: [target],
19472073 icon: "icon.ico",
......@@ -2050,6 +2176,381 @@ const stageWslNodePtyPrebuild = Effect.fn("stageWslNodePtyPrebuild")(function* (
20502176 );
20512177});
20522178
2179// Stage and pack the Windows server sidecar: the bundled server plus a hoisted
2180// install of only its runtime-external/native dependency closure for win32 and
2181// WSL Linux. The Windows primary runs from the archive through the asar-aware
2182// ELECTRON_RUN_AS_NODE runtime; enabling WSL extracts it to a real directory.
2183// Shipping one packed archive instead of thousands of loose files is what
2184// makes the NSIS install/update fast.
2185export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function* (input: {
2186 readonly sourceDir: string;
2187 readonly asarPath: string;
2188}) {
2189 const fs = yield* FileSystem.FileSystem;
2190 yield* Effect.tryPromise({
2191 try: () =>
2192 createPackageWithOptions(input.sourceDir, input.asarPath, {
2193 dot: true,
2194 unpack: WINDOWS_SERVER_ASAR_UNPACK_GLOB,
2195 globOptions: { ignore: [...WINDOWS_SERVER_ASAR_IGNORE_GLOBS] },
2196 }),
2197 catch: (cause) => new WindowsServerSidecarPackError({ asarPath: input.asarPath, cause }),
2198 });
2199 const unpackedDirPath = `${input.asarPath}.unpacked`;
2200 if (!(yield* fs.exists(unpackedDirPath))) {
2201 return yield* new WindowsServerSidecarPackError({
2202 asarPath: input.asarPath,
2203 cause: new Error(`expected native binaries at ${unpackedDirPath}, but none were unpacked`),
2204 });
2205 }
2206});
2207
2208export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")(function* (input: {
2209 readonly stageRoot: string;
2210 readonly repoRoot: string;
2211 readonly serverDistDir: string;
2212 readonly arch: typeof BuildArch.Type;
2213 readonly appVersion: string;
2214 readonly runtimeExternalDependencies: Record<string, string>;
2215 readonly fffNodeVersion: string;
2216 readonly allowBuilds: Record<string, boolean>;
2217 readonly patchedDependencies: Record<string, string>;
2218 readonly overrides: Record<string, string>;
2219 readonly wslPrebuildPath: string | undefined;
2220 readonly asarPath: string;
2221 readonly verbose: boolean;
2222}) {
2223 const fs = yield* FileSystem.FileSystem;
2224 const path = yield* Path.Path;
2225
2226 const serverStageDir = path.join(input.stageRoot, "server");
2227 yield* fs.makeDirectory(path.join(serverStageDir, "apps/server"), { recursive: true });
2228 yield* fs.copy(input.serverDistDir, path.join(serverStageDir, "apps/server/dist"));
2229
2230 const sidecarDependencies = {
2231 ...input.runtimeExternalDependencies,
2232 // The sidecar serves two processes: the Windows primary loads win32
2233 // natives, and the WSL backend loads the matching Linux natives (fff via
2234 // ffi-rs) from the extracted copy of this same tree.
2235 ...resolveFffNativeDependencies("win", input.arch, input.fffNodeVersion),
2236 ...resolveFffNativeDependencies("linux", input.arch, input.fffNodeVersion),
2237 };
2238 const sidecarPatchedDependencies = createStagePatchedDependencies(
2239 input.patchedDependencies,
2240 sidecarDependencies,
2241 );
2242 const sidecarPackageJson = {
2243 name: "t3code-server",
2244 version: input.appVersion,
2245 private: true,
2246 packageManager: rootPackageJson.packageManager,
2247 dependencies: sidecarDependencies,
2248 };
2249 const sidecarPackageJsonString = yield* encodeJsonString(sidecarPackageJson);
2250 yield* fs.writeFileString(
2251 path.join(serverStageDir, "package.json"),
2252 `${sidecarPackageJsonString}\n`,
2253 );
2254 const sidecarWorkspaceConfig = createStageWorkspaceConfig({
2255 platform: "win",
2256 arch: input.arch,
2257 allowBuilds: input.allowBuilds,
2258 patchedDependencies: sidecarPatchedDependencies,
2259 overrides: input.overrides,
2260 linuxServerBackend: true,
2261 });
2262 const sidecarWorkspaceConfigString = yield* encodeStageWorkspaceConfig(sidecarWorkspaceConfig);
2263 yield* fs.writeFileString(
2264 path.join(serverStageDir, "pnpm-workspace.yaml"),
2265 sidecarWorkspaceConfigString,
2266 );
2267 if (Object.keys(sidecarPatchedDependencies).length > 0) {
2268 yield* fs.copy(path.join(input.repoRoot, "patches"), path.join(serverStageDir, "patches"));
2269 }
2270
2271 yield* Effect.log("[desktop-artifact] Installing server sidecar runtime externals...");
2272 const installCommand = yield* resolveSpawnCommand("vp", [...STAGE_INSTALL_ARGS]);
2273 yield* runCommand(
2274 ChildProcess.make(installCommand.command, installCommand.args, {
2275 cwd: serverStageDir,
2276 shell: installCommand.shell,
2277 }),
2278 { label: "vp install --prod (server sidecar)", verbose: input.verbose },
2279 );
2280
2281 yield* stageWslNodePtyPrebuild({
2282 stageAppDir: serverStageDir,
2283 arch: input.arch,
2284 prebuildPath: input.wslPrebuildPath,
2285 });
2286
2287 yield* Effect.log("[desktop-artifact] Packing server.asar...");
2288 yield* fs.makeDirectory(path.dirname(input.asarPath), { recursive: true });
2289 yield* packWindowsServerAsar({ sourceDir: serverStageDir, asarPath: input.asarPath });
2290 const packedStat = yield* fs.stat(input.asarPath);
2291 yield* Effect.log(
2292 `[desktop-artifact] Packed server.asar (${String(packedStat.size)} bytes) + unpacked natives.`,
2293 );
2294});
2295
2296function collectUnpackedAsarFiles(
2297 directory: DirectoryRecord,
2298 parentPath = "",
2299 output: string[] = [],
2300): readonly string[] {
2301 for (const [name, entry] of Object.entries(directory.files)) {
2302 const entryPath = parentPath.length === 0 ? name : `${parentPath}/${name}`;
2303 if ("files" in entry) {
2304 collectUnpackedAsarFiles(entry, entryPath, output);
2305 } else if (entry.unpacked) {
2306 output.push(entryPath);
2307 }
2308 }
2309 return output;
2310}
2311
2312const countPayloadFiles = Effect.fn("desktopArtifact.countPayloadFiles")(function* (root: string) {
2313 const fs = yield* FileSystem.FileSystem;
2314 const path = yield* Path.Path;
2315 const pendingDirectories = [root];
2316 let count = 0;
2317
2318 while (pendingDirectories.length > 0) {
2319 const directory = pendingDirectories.pop();
2320 if (directory === undefined) break;
2321 const entries = yield* fs.readDirectory(directory);
2322 for (const entry of entries) {
2323 const entryPath = path.join(directory, entry);
2324 const stat = yield* fs.stat(entryPath);
2325 if (stat.type === "Directory") {
2326 pendingDirectories.push(entryPath);
2327 } else if (stat.type === "File") {
2328 count += 1;
2329 }
2330 }
2331 }
2332
2333 return count;
2334});
2335
2336export const verifyWindowsPrimaryFffNativeLoad = Effect.fn(
2337 "desktopArtifact.verifyWindowsPrimaryFffNativeLoad",
2338)(function* (input: {
2339 readonly packagedAppDir: string;
2340 readonly asarPath: string;
2341 readonly appExecutableName: string;
2342 readonly targetArch: typeof BuildArch.Type;
2343 readonly verbose: boolean;
2344}) {
2345 const hostPlatform = yield* HostProcessPlatform;
2346 const hostArchitecture = yield* HostProcessArchitecture;
2347 const fs = yield* FileSystem.FileSystem;
2348 const path = yield* Path.Path;
2349 const executablePath = path.join(input.packagedAppDir, input.appExecutableName);
2350 const executableStat = yield* fs.stat(executablePath).pipe(Effect.orElseSucceed(() => null));
2351 if (executableStat?.type !== "File") {
2352 return yield* new WindowsPrimaryNativeProbeError({
2353 executablePath,
2354 exitCode: -1,
2355 output: "The unpacked application does not contain its expected primary executable.",
2356 });
2357 }
2358 if (hostPlatform !== "win32" || hostArchitecture !== input.targetArch) return;
2359
2360 const probeRoot = yield* fs.makeTempDirectoryScoped({
2361 prefix: "t3code-windows-primary-native-probe-",
2362 });
2363 const fffEntryPath = path.join(
2364 input.asarPath,
2365 "node_modules/@ff-labs/fff-node/dist/src/index.js",
2366 );
2367 const probeEnv = { ...process.env };
2368 delete probeEnv.ELECTRON_NO_ASAR;
2369 delete probeEnv.NODE_OPTIONS;
2370
2371 yield* runCommand(
2372 ChildProcess.make(
2373 executablePath,
2374 [
2375 "--no-global-search-paths",
2376 "--input-type=module",
2377 "--eval",
2378 WINDOWS_PRIMARY_FFF_PROBE_SOURCE,
2379 fffEntryPath,
2380 probeRoot,
2381 ],
2382 {
2383 cwd: input.packagedAppDir,
2384 stdout: "pipe",
2385 stderr: "pipe",
2386 env: {
2387 ...probeEnv,
2388 ELECTRON_RUN_AS_NODE: "1",
2389 NODE_PATH: "",
2390 },
2391 },
2392 ),
2393 {
2394 label: "Windows primary fff native-load probe",
2395 verbose: input.verbose,
2396 },
2397 ).pipe(
2398 Effect.timeout(WINDOWS_PRIMARY_NATIVE_PROBE_TIMEOUT),
2399 Effect.catchTags({
2400 TimeoutError: () =>
2401 Effect.fail(
2402 new WindowsPrimaryNativeProbeError({
2403 executablePath,
2404 exitCode: -1,
2405 output: `The native-load probe did not finish within ${Duration.toSeconds(WINDOWS_PRIMARY_NATIVE_PROBE_TIMEOUT)}s.`,
2406 }),
2407 ),
2408 BuildCommandFailedError: (error) =>
2409 Effect.fail(
2410 new WindowsPrimaryNativeProbeError({
2411 executablePath,
2412 exitCode: error.exitCode,
2413 output: `${error.stderrTail ?? ""}${error.stdoutTail ?? ""}`.trim(),
2414 }),
2415 ),
2416 }),
2417 );
2418});
2419
2420export const validateWindowsPackagedPayload = Effect.fn(
2421 "desktopArtifact.validateWindowsPackagedPayload",
2422)(function* (input: {
2423 readonly stageDistDir: string;
2424 readonly appExecutableName: string;
2425 readonly targetArch: typeof BuildArch.Type;
2426 readonly fileLimit?: number;
2427 readonly verbose?: boolean;
2428}) {
2429 const fs = yield* FileSystem.FileSystem;
2430 const path = yield* Path.Path;
2431 const fileLimit = input.fileLimit ?? WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT;
2432 const isFile = (filePath: string) =>
2433 fs.stat(filePath).pipe(
2434 Effect.map((stat) => stat.type === "File"),
2435 Effect.orElseSucceed(() => false),
2436 );
2437 const stageEntries = yield* fs.readDirectory(input.stageDistDir);
2438 let packagedAppDir: string | undefined;
2439
2440 for (const entry of stageEntries) {
2441 if (!entry.endsWith("-unpacked")) continue;
2442 const candidate = path.join(input.stageDistDir, entry);
2443 const stat = yield* fs.stat(candidate).pipe(Effect.orElseSucceed(() => null));
2444 if (stat?.type === "Directory") {
2445 packagedAppDir = candidate;
2446 break;
2447 }
2448 }
2449
2450 if (packagedAppDir === undefined) {
2451 return yield* new WindowsPackagedPayloadValidationError({
2452 reason: "packaged-app-missing",
2453 packagedAppDir: path.join(input.stageDistDir, "win-unpacked"),
2454 });
2455 }
2456
2457 const resourcesDir = path.join(packagedAppDir, "resources");
2458 const asarPath = path.join(resourcesDir, WINDOWS_SERVER_ASAR_RESOURCE);
2459 if (!(yield* fs.exists(asarPath).pipe(Effect.orElseSucceed(() => false)))) {
2460 return yield* new WindowsPackagedPayloadValidationError({
2461 reason: "sidecar-missing",
2462 packagedAppDir,
2463 missingFiles: [WINDOWS_SERVER_ASAR_RESOURCE],
2464 });
2465 }
2466
2467 const unpackedFiles = yield* Effect.try({
2468 try: () => {
2469 // The entry lookup proves the archive contains the server executable,
2470 // while the single header walk identifies every file ASAR redirects to
2471 // the unpacked sibling at runtime.
2472 // @electron/asar resolves entry names using the host path separator.
2473 // POSIX separators work on Linux/macOS but fail on Windows even when the
2474 // entry is present in the archive.
2475 statFile(asarPath, path.join("apps", "server", "dist", "bin.mjs"));
2476 return [...collectUnpackedAsarFiles(getRawHeader(asarPath).header)].sort();
2477 },
2478 catch: (cause) =>
2479 new WindowsPackagedPayloadValidationError({
2480 reason: "sidecar-invalid",
2481 packagedAppDir,
2482 cause,
2483 }),
2484 });
2485 if (unpackedFiles.length === 0) {
2486 return yield* new WindowsPackagedPayloadValidationError({
2487 reason: "sidecar-invalid",
2488 packagedAppDir,
2489 cause: new Error("server.asar does not declare any unpacked native files"),
2490 });
2491 }
2492
2493 const missingFiles: string[] = [];
2494 for (const unpackedFile of unpackedFiles) {
2495 const unpackedPath = path.join(
2496 resourcesDir,
2497 `${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked`,
2498 ...unpackedFile.split("/"),
2499 );
2500 if (!(yield* isFile(unpackedPath))) {
2501 missingFiles.push(`${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked/${unpackedFile}`);
2502 }
2503 }
2504 if (missingFiles.length > 0) {
2505 return yield* new WindowsPackagedPayloadValidationError({
2506 reason: "unpacked-native-missing",
2507 packagedAppDir,
2508 missingFiles,
2509 });
2510 }
2511
2512 const resourceMonitorPath = path.join(
2513 resourcesDir,
2514 "resource-monitor",
2515 resourceMonitorExecutableName("win"),
2516 );
2517 if (!(yield* isFile(resourceMonitorPath))) {
2518 return yield* new WindowsPackagedPayloadValidationError({
2519 reason: "resource-monitor-missing",
2520 packagedAppDir,
2521 missingFiles: ["resource-monitor/t3-resource-monitor.exe"],
2522 });
2523 }
2524
2525 const fileCount = yield* countPayloadFiles(packagedAppDir);
2526 if (fileCount > fileLimit) {
2527 return yield* new WindowsPackagedPayloadValidationError({
2528 reason: "file-limit-exceeded",
2529 packagedAppDir,
2530 fileCount,
2531 fileLimit,
2532 });
2533 }
2534
2535 yield* verifyWindowsPrimaryFffNativeLoad({
2536 packagedAppDir,
2537 asarPath,
2538 appExecutableName: input.appExecutableName,
2539 targetArch: input.targetArch,
2540 verbose: input.verbose ?? false,
2541 });
2542
2543 yield* verifyPackagedBundleIsSelfContained({
2544 asarPath,
2545 verbose: input.verbose ?? false,
2546 });
2547
2548 yield* Effect.log(
2549 `[desktop-artifact] Validated Windows payload (${String(fileCount)} files, ${String(unpackedFiles.length)} sidecar natives).`,
2550 );
2551 return { packagedAppDir, fileCount, unpackedFiles } as const;
2552});
2553
20532554const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
20542555 options: ResolvedBuildOptions,
20552556) {
......@@ -2098,6 +2599,9 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
20982599 cause,
20992600 }),
21002601 });
2602 const resolvedServerRuntimeExternalDependencies = selectCliRuntimeExternalDependencies(
2603 resolvedServerDependencies,
2604 );
21012605 const resolvedDesktopRuntimeDependencies = yield* Effect.try({
21022606 try: () => resolveDesktopRuntimeDependencies(desktopPackageJson.dependencies, workspaceCatalog),
21032607 catch: (cause) =>
......@@ -2188,7 +2692,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
21882692 // inlined. A regression to externalizing everything would also pass it,
21892693 // since source-file regions still exist -- and that is the failure this
21902694 // whole change exists to prevent, because those packages are not in the
2191 // unpack globs and the WSL backend would die on ERR_MODULE_NOT_FOUND.
2695 // selected sidecar closure and both backends would die on ERR_MODULE_NOT_FOUND.
21922696 // `effect` is imported by every server module, so it is inlined in any
21932697 // correctly bundled build.
21942698 // The list-based check above only sees packages someone already thought to
......@@ -2227,12 +2731,18 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
22272731 yield* validateBundledClientAssets(path.dirname(bundledClientEntry));
22282732
22292733 yield* fs.makeDirectory(path.join(stageAppDir, "apps/desktop"), { recursive: true });
2230 yield* fs.makeDirectory(path.join(stageAppDir, "apps/server"), { recursive: true });
2734 if (options.platform !== "win") {
2735 yield* fs.makeDirectory(path.join(stageAppDir, "apps/server"), { recursive: true });
2736 }
22312737
22322738 yield* Effect.log("[desktop-artifact] Staging release app...");
22332739 yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"));
22342740 yield* fs.copy(distDirs.desktopResources, stageResourcesDir);
2235 yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"));
2741 // On Windows the server tree ships in the server.asar sidecar instead of
2742 // app.asar (see stageWindowsServerSidecar), so the app stage omits it.
2743 if (options.platform !== "win") {
2744 yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"));
2745 }
22362746 yield* stageResourceMonitor({
22372747 repoRoot,
22382748 stageResourcesDir,
......@@ -2253,7 +2763,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
22532763 );
22542764
22552765 // electron-builder is filtering out stageResourcesDir directory in the AppImage for production
2256 yield* fs.copy(stageResourcesDir, path.join(stageAppDir, "apps/desktop/prod-resources"));
2766 const stageProdResourcesDir = path.join(stageAppDir, "apps/desktop/prod-resources");
2767 yield* fs.copy(stageResourcesDir, stageProdResourcesDir);
22572768
22582769 const configuredMacPasskeySigning =
22592770 options.platform === "mac" && options.signed
......@@ -2283,30 +2794,31 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
22832794 yield* fs.writeFileString(macEntitlementsPath, renderMacPasskeyEntitlements(macPasskeySigning));
22842795 }
22852796
2286 const stageDependencies = {
2287 ...resolvedServerDependencies,
2288 ...resolvedDesktopRuntimeDependencies,
2289 ...resolveFffNativeDependencies(
2290 options.platform,
2291 options.arch,
2292 serverPackageJson.dependencies["@ff-labs/fff-node"],
2293 ),
2294 // Windows artifacts also bundle the same-architecture WSL Linux backend, which loads the
2295 // fff native binary through ffi-rs. The platform fff binary above is the
2296 // host's (win32), so promote the matching Linux fff binaries too; without
2297 // them file-finding in WSL fails to load its Linux native package.
2298 ...(options.platform === "win"
2299 ? resolveFffNativeDependencies(
2300 "linux",
2301 options.arch,
2302 serverPackageJson.dependencies["@ff-labs/fff-node"],
2303 )
2304 : {}),
2305 };
2797 // Windows splits dependencies per process: app.asar carries only the
2798 // desktop main-process runtime deps, while the server bundle's deps live in
2799 // the server.asar sidecar (see stageWindowsServerSidecar). macOS and Linux
2800 // keep the single merged tree — their primary resolves everything from
2801 // app.asar and there is no second consumer.
2802 const stageDependencies =
2803 options.platform === "win"
2804 ? { ...resolvedDesktopRuntimeDependencies }
2805 : {
2806 ...resolvedServerDependencies,
2807 ...resolvedDesktopRuntimeDependencies,
2808 ...resolveFffNativeDependencies(
2809 options.platform,
2810 options.arch,
2811 serverPackageJson.dependencies["@ff-labs/fff-node"],
2812 ),
2813 };
23062814 const stagePatchedDependencies = createStagePatchedDependencies(
23072815 workspacePatchedDependencies,
23082816 stageDependencies,
23092817 );
2818 const windowsServerAsarPath =
2819 options.platform === "win"
2820 ? path.join(stageAppDir, WINDOWS_SERVER_RESOURCE_SOURCE_DIR, WINDOWS_SERVER_ASAR_RESOURCE)
2821 : undefined;
23102822 const stagePackageJson: StagePackageJson = {
23112823 name: "t3code",
23122824 version: appVersion,
......@@ -2367,13 +2879,24 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
23672879 );
23682880 yield* stageClerkPasskeyNativeBinaries(stageAppDir, options.platform, options.arch);
23692881
2370 // WSL is Windows-only, so only the Windows artifact carries the Linux backend
2371 // binary; other platforms ignore the prebuild input.
2372 if (options.platform === "win") {
2373 yield* stageWslNodePtyPrebuild({
2374 stageAppDir,
2882 // WSL is Windows-only, so only the Windows artifact carries the server
2883 // sidecar (which embeds the Linux node-pty prebuild); other platforms
2884 // ignore the prebuild input.
2885 if (options.platform === "win" && windowsServerAsarPath) {
2886 yield* stageWindowsServerSidecar({
2887 stageRoot,
2888 repoRoot,
2889 serverDistDir: distDirs.serverDist,
23752890 arch: options.arch,
2376 prebuildPath: options.wslPrebuild,
2891 appVersion,
2892 runtimeExternalDependencies: resolvedServerRuntimeExternalDependencies,
2893 fffNodeVersion: serverPackageJson.dependencies["@ff-labs/fff-node"],
2894 allowBuilds: workspaceAllowBuilds,
2895 patchedDependencies: workspacePatchedDependencies,
2896 overrides: resolvedOverrides,
2897 wslPrebuildPath: options.wslPrebuild,
2898 asarPath: windowsServerAsarPath,
2899 verbose: options.verbose,
23772900 });
23782901 }
23792902
......@@ -2462,9 +2985,15 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
24622985 // resolver has no such ambiguity: it either finds every import or it does not.
24632986 //
24642987 // Only Windows unpacks anything; macOS and Linux keep the whole tree inside
2465 // the asar, where this check has nothing to look at.
2988 // the app asar. Windows validates and executes the separately packed server
2989 // sidecar after electron-builder copies it into the final payload.
24662990 if (options.platform === "win") {
2467 yield* verifyPackagedBundleIsSelfContained({ stageDistDir, verbose: options.verbose });
2991 yield* validateWindowsPackagedPayload({
2992 stageDistDir,
2993 appExecutableName: `${resolveDesktopProductName(appVersion)}.exe`,
2994 targetArch: options.arch,
2995 verbose: options.verbose,
2996 });
24682997 }
24692998
24702999 const stageEntries = yield* fs.readDirectory(stageDistDir);
scripts/lib/cli-external-packages.test.ts+28-25
......@@ -7,11 +7,12 @@ import * as FileSystem from "effect/FileSystem";
77import * as Path from "effect/Path";
88import * as Schema from "effect/Schema";
99
10import serverPackageJson from "../../apps/server/package.json" with { type: "json" };
11
1012import {
11 CLI_EXTERNAL_PACKAGE_PREFIXES,
12 CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS,
1313 CLI_RUNTIME_EXTERNAL_PREFIXES,
1414 findInlinedExternalPackages,
15 selectCliRuntimeExternalDependencies,
1516 shouldBundleCliDependency,
1617} from "./cli-external-packages.ts";
1718
......@@ -60,39 +61,41 @@ describe("shouldBundleCliDependency", () => {
6061 });
6162
6263 // The real package is `node-gyp-build-optional-packages`, reached by prefix.
63 // Matching it as external while failing to unpack it is invisible on the
64 // Windows primary (which reads app.asar) and breaks only under WSL.
64 // It is transitive to a selected dependency root, so the runtime closure test
65 // below ensures it follows that root into the sidecar.
6566 it("treats prefix-matched siblings as external", () => {
6667 assert.strictEqual(shouldBundleCliDependency("node-gyp-build-optional-packages"), false);
6768 });
6869});
6970
70describe("CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS", () => {
71 it("unpacks every external prefix from both the top level and the pnpm store", () => {
72 for (const prefix of CLI_EXTERNAL_PACKAGE_PREFIXES) {
73 assert.include(CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, `node_modules/${prefix}*/**/*`, prefix);
74 assert.include(
75 CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS,
76 `node_modules/.pnpm/**/node_modules/${prefix}*/**/*`,
77 prefix,
78 );
79 }
71describe("selectCliRuntimeExternalDependencies", () => {
72 it("keeps only runtime-external dependency roots for the Windows sidecar", () => {
73 assert.deepStrictEqual(
74 selectCliRuntimeExternalDependencies({
75 "@effect/platform-bun": "1.0.0",
76 "@ff-labs/fff-node": "2.0.0",
77 effect: "3.0.0",
78 "node-pty": "4.0.0",
79 }),
80 {
81 "@ff-labs/fff-node": "2.0.0",
82 "node-pty": "4.0.0",
83 },
84 );
8085 });
8186
82 // Without the trailing `*` the globs stop covering prefix-matched siblings,
83 // which is exactly how a package ends up external but not unpacked.
84 it("keeps the trailing wildcard that matches prefix siblings", () => {
85 assert.include(CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, "node_modules/node-gyp-build*/**/*");
87 it("selects every external root declared by the server", () => {
88 assert.deepStrictEqual(
89 Object.keys(selectCliRuntimeExternalDependencies(serverPackageJson.dependencies)).sort(),
90 ["@ff-labs/fff-node", "msgpackr-extract", "node-pty"],
91 );
8692 });
8793});
8894
89// The failure this guards is invisible on Windows and fatal under WSL.
90//
9195// An external package is loaded from the real filesystem, so its own `require`
9296// also resolves from the real filesystem. If one of its dependencies was
93// bundled away instead of left external, that dependency exists only inside
94// app.asar — which the Windows primary reads transparently under
95// ELECTRON_RUN_AS_NODE, and plain `node` under WSL cannot.
97// bundled away instead of left external, that dependency does not follow the
98// selected root into the sidecar.
9699//
97100// Found the hard way: node-gyp-build-optional-packages requires detect-libc,
98101// which was bundled. Windows was fine; WSL got MODULE_NOT_FOUND.
......@@ -103,8 +106,8 @@ it.layer(NodeServices.layer)("external package dependency closure", (it) => {
103106 // by name from this file at all, and an `exports` map can refuse the
104107 // `/package.json` subpath outright (@ff-labs/fff-node). Both surface as "not
105108 // installed", which would let this test skip everything and pass while
106 // checking nothing. The store is also what asarUnpack globs target, so this
107 // reads the same tree the build packages.
109 // checking nothing. The store contains the dependency graph the sidecar's
110 // minimal production install resolves.
108111 const readInstalledPackages = Effect.gen(function* () {
109112 const fileSystem = yield* FileSystem.FileSystem;
110113 const path = yield* Path.Path;
scripts/lib/cli-external-packages.ts+21-25
......@@ -4,14 +4,12 @@
44 * Two consumers derive from this list, and they must never disagree:
55 *
66 * - apps/server/vite.config.ts decides what stays external to the bundle.
7 * - scripts/build-desktop-artifact.ts decides what gets unpacked out of the asar.
7 * - scripts/build-desktop-artifact.ts selects the runtime dependency roots for
8 * the Windows server sidecar.
89 *
9 * A package that is external but not unpacked still resolves on the Windows
10 * primary, which runs under ELECTRON_RUN_AS_NODE and reads app.asar
11 * transparently. It fails only under WSL, where the backend is launched as plain
12 * `wsl.exe -- node` and cannot read inside an archive. That asymmetry makes the
13 * drift invisible on the platform you are most likely to test on, which is why
14 * both consumers derive from one list instead of maintaining their own.
10 * A runtime package that is external but absent from the sidecar fails as soon
11 * as Node resolves it from the emitted bundle. Keeping both consumers on one
12 * list prevents packaging from drifting away from the bundle boundary.
1513 *
1614 * Entries are matched as prefixes (`id.startsWith(prefix)`), so they also cover
1715 * a package's platform-specific siblings — `node-gyp-build` covers
......@@ -24,8 +22,8 @@
2422 * critically — the ordinary JS packages those wrappers require. An external
2523 * package is loaded from the real filesystem, so its own `require` also
2624 * resolves from the real filesystem; a dependency that was bundled away exists
27 * only inside app.asar and is unreachable there. This closure is enforced by a
28 * test, not by inspection.
25 * only inside the emitted bundle and is unreachable there. This closure is
26 * enforced by a test, not by inspection.
2927 */
3028export const CLI_RUNTIME_EXTERNAL_PREFIXES = [
3129 "node-pty",
......@@ -70,6 +68,10 @@ export const CLI_EXTERNAL_PACKAGE_PREFIXES = [
7068 ...CLI_BUILD_ONLY_EXTERNAL_PREFIXES,
7169] as const;
7270
71export function isRuntimeExternalCliDependency(id: string): boolean {
72 return CLI_RUNTIME_EXTERNAL_PREFIXES.some((prefix) => id.startsWith(prefix));
73}
74
7375/**
7476 * True when `id` must stay out of the bundle.
7577 *
......@@ -90,20 +92,14 @@ export function shouldBundleCliDependency(id: string): boolean {
9092 return !isExternalCliDependency(id);
9193}
9294
93/**
94 * asar-unpack globs covering every external package.
95 *
96 * The trailing `*` is what keeps these aligned with the prefix matching above:
97 * without it, `node-gyp-build` would be left external by the bundler and then
98 * not unpacked, because the real package is `node-gyp-build-optional-packages`.
99 *
100 * pnpm stores real files under `.pnpm` and symlinks the top-level names, so both
101 * paths are unpacked for the link target to exist on disk.
102 */
103export const CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS = CLI_EXTERNAL_PACKAGE_PREFIXES.flatMap(
104 (prefix) =>
105 [`node_modules/${prefix}*/**/*`, `node_modules/.pnpm/**/node_modules/${prefix}*/**/*`] as const,
106);
95/** Select direct dependency roots whose runtime closure belongs in the sidecar. */
96export function selectCliRuntimeExternalDependencies(
97 dependencies: Readonly<Record<string, string>>,
98): Record<string, string> {
99 return Object.fromEntries(
100 Object.entries(dependencies).filter(([name]) => isRuntimeExternalCliDependency(name)),
101 );
102}
107103
108104/**
109105 * Scan an emitted bundle chunk for runtime-external packages that were inlined.
......@@ -122,8 +118,8 @@ export const CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS = CLI_EXTERNAL_PACKAGE_PREFIXES.f
122118 * check the opposite direction too. Verifying only that externals are absent
123119 * would still pass if the bundler reverted to leaving everything external: the
124120 * scan would see source-file regions, report nothing inlined, and the packaged
125 * WSL backend would then fail with ERR_MODULE_NOT_FOUND because those packages
126 * are not in the unpack globs either.
121 * backends would then fail with ERR_MODULE_NOT_FOUND because those packages
122 * are not in the selected sidecar closure either.
127123 */
128124export function findInlinedExternalPackages(source: string): {
129125 readonly regionCount: number;
scripts/package.json+1
......@@ -8,6 +8,7 @@
88 },
99 "dependencies": {
1010 "@effect/platform-node": "catalog:",
11 "@electron/asar": "^3.4.1",
1112 "@t3tools/contracts": "workspace:*",
1213 "@t3tools/shared": "workspace:*",
1314 "@t3tools/tailscale": "workspace:*",