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", () => {...@@ -65,6 +65,7 @@ describe("DesktopEnvironment", () => {
65 assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts");65 assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts");
66 assert.equal(environment.rootDir, "/repo");66 assert.equal(environment.rootDir, "/repo");
67 assert.equal(environment.appRoot, "/repo");67 assert.equal(environment.appRoot, "/repo");
68 assert.equal(environment.serverRoot, "/repo");
68 assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs");69 assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs");
69 assert.equal(environment.backendCwd, "/repo");70 assert.equal(environment.backendCwd, "/repo");
70 assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev");71 assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev");
...@@ -98,6 +99,24 @@ describe("DesktopEnvironment", () => {...@@ -98,6 +99,24 @@ describe("DesktopEnvironment", () => {
98 }),99 }),
99 );100 );
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
101 it.effect("keeps implicit development state separate from production state", () =>120 it.effect("keeps implicit development state separate from production state", () =>
102 Effect.gen(function* () {121 Effect.gen(function* () {
103 const development = yield* makeEnvironment(122 const development = yield* makeEnvironment(
apps/desktop/src/app/DesktopEnvironment.ts+13-1
...@@ -52,6 +52,13 @@ export class DesktopEnvironment extends Context.Service<...@@ -52,6 +52,13 @@ export class DesktopEnvironment extends Context.Service<
52 readonly browserArtifactsDir: string;52 readonly browserArtifactsDir: string;
53 readonly rootDir: string;53 readonly rootDir: string;
54 readonly appRoot: string;54 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;
55 readonly backendEntryPath: string;62 readonly backendEntryPath: string;
56 readonly backendCwd: string;63 readonly backendCwd: string;
57 readonly preloadPath: string;64 readonly preloadPath: string;
...@@ -157,6 +164,10 @@ const make = Effect.fn("desktop.environment.make")(function* (...@@ -157,6 +164,10 @@ const make = Effect.fn("desktop.environment.make")(function* (
157 });164 });
158 const rootDir = path.resolve(input.dirname, "../../..");165 const rootDir = path.resolve(input.dirname, "../../..");
159 const appRoot = input.isPackaged ? input.appPath : rootDir;166 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;
160 const branding = resolveDesktopAppBranding({171 const branding = resolveDesktopAppBranding({
161 isDevelopment,172 isDevelopment,
162 appVersion: input.appVersion,173 appVersion: input.appVersion,
...@@ -198,7 +209,8 @@ const make = Effect.fn("desktop.environment.make")(function* (...@@ -198,7 +209,8 @@ const make = Effect.fn("desktop.environment.make")(function* (
198 browserArtifactsDir: path.join(stateDir, "browser-artifacts"),209 browserArtifactsDir: path.join(stateDir, "browser-artifacts"),
199 rootDir,210 rootDir,
200 appRoot,211 appRoot,
201 backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"),212 serverRoot,
213 backendEntryPath: path.join(serverRoot, "apps/server/dist/bin.mjs"),
202 backendCwd: input.isPackaged ? homeDirectory : appRoot,214 backendCwd: input.isPackaged ? homeDirectory : appRoot,
203 preloadPath: path.join(input.dirname, "preload.cjs"),215 preloadPath: path.join(input.dirname, "preload.cjs"),
204 appUpdateYmlPath: input.isPackaged216 appUpdateYmlPath: input.isPackaged
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+102-2
...@@ -17,6 +17,7 @@ import * as DesktopConfig from "../app/DesktopConfig.ts";...@@ -17,6 +17,7 @@ import * as DesktopConfig from "../app/DesktopConfig.ts";
17import * as DesktopServerExposure from "./DesktopServerExposure.ts";17import * as DesktopServerExposure from "./DesktopServerExposure.ts";
18import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";18import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
19import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";19import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
20import * as DesktopWslServerTree from "../wsl/DesktopWslServerTree.ts";
2021
21const PersistedServerObservabilitySettingsDocument = Schema.Struct({22const PersistedServerObservabilitySettingsDocument = Schema.Struct({
22 observability: Schema.Struct({23 observability: Schema.Struct({
...@@ -115,6 +116,7 @@ const withHarness = <A, E, R>(...@@ -115,6 +116,7 @@ const withHarness = <A, E, R>(
115 Layer.provideMerge(serverExposureLayer),116 Layer.provideMerge(serverExposureLayer),
116 Layer.provideMerge(DesktopAppSettings.layerTest()),117 Layer.provideMerge(DesktopAppSettings.layerTest()),
117 Layer.provideMerge(DesktopWslEnvironment.layerTest()),118 Layer.provideMerge(DesktopWslEnvironment.layerTest()),
119 Layer.provideMerge(DesktopWslServerTree.layerTest()),
118 Layer.provideMerge(makeEnvironmentLayer(baseDir)),120 Layer.provideMerge(makeEnvironmentLayer(baseDir)),
119 ),121 ),
120 ),122 ),
...@@ -153,6 +155,47 @@ describe("DesktopBackendConfiguration", () => {...@@ -153,6 +155,47 @@ describe("DesktopBackendConfiguration", () => {
153 ),155 ),
154 );156 );
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
156 it.effect("resolveWsl reuses the primary's bootstrap token", () =>199 it.effect("resolveWsl reuses the primary's bootstrap token", () =>
157 withHarness(200 withHarness(
158 Effect.gen(function* () {201 Effect.gen(function* () {
...@@ -173,7 +216,7 @@ describe("DesktopBackendConfiguration", () => {...@@ -173,7 +216,7 @@ describe("DesktopBackendConfiguration", () => {
173 const baseDir = yield* fileSystem.makeTempDirectoryScoped({216 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
174 prefix: "t3-desktop-backend-config-test-",217 prefix: "t3-desktop-backend-config-test-",
175 });218 });
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");
177 yield* fileSystem.makeDirectory(path.dirname(entryPath), { recursive: true });220 yield* fileSystem.makeDirectory(path.dirname(entryPath), { recursive: true });
178 yield* fileSystem.writeFileString(entryPath, "");221 yield* fileSystem.writeFileString(entryPath, "");
179222
...@@ -186,6 +229,7 @@ describe("DesktopBackendConfiguration", () => {...@@ -186,6 +229,7 @@ describe("DesktopBackendConfiguration", () => {
186 DesktopBackendConfiguration.layer.pipe(229 DesktopBackendConfiguration.layer.pipe(
187 Layer.provideMerge(serverExposureLayer),230 Layer.provideMerge(serverExposureLayer),
188 Layer.provideMerge(DesktopAppSettings.layerTest()),231 Layer.provideMerge(DesktopAppSettings.layerTest()),
232 Layer.provideMerge(DesktopWslServerTree.layerTest()),
189 Layer.provideMerge(233 Layer.provideMerge(
190 DesktopWslEnvironment.layerTest({234 DesktopWslEnvironment.layerTest({
191 isAvailable: true,235 isAvailable: true,
...@@ -234,7 +278,7 @@ describe("DesktopBackendConfiguration", () => {...@@ -234,7 +278,7 @@ describe("DesktopBackendConfiguration", () => {
234 const baseDir = yield* fileSystem.makeTempDirectoryScoped({278 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
235 prefix: "t3-desktop-backend-config-test-",279 prefix: "t3-desktop-backend-config-test-",
236 });280 });
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");
238 yield* fileSystem.makeDirectory(path.dirname(entryPath), { recursive: true });282 yield* fileSystem.makeDirectory(path.dirname(entryPath), { recursive: true });
239 yield* fileSystem.writeFileString(entryPath, "");283 yield* fileSystem.writeFileString(entryPath, "");
240284
...@@ -250,6 +294,7 @@ describe("DesktopBackendConfiguration", () => {...@@ -250,6 +294,7 @@ describe("DesktopBackendConfiguration", () => {
250 DesktopBackendConfiguration.layer.pipe(294 DesktopBackendConfiguration.layer.pipe(
251 Layer.provideMerge(serverExposureLayer),295 Layer.provideMerge(serverExposureLayer),
252 Layer.provideMerge(DesktopAppSettings.layerTest()),296 Layer.provideMerge(DesktopAppSettings.layerTest()),
297 Layer.provideMerge(DesktopWslServerTree.layerTest()),
253 Layer.provideMerge(298 Layer.provideMerge(
254 DesktopWslEnvironment.layerTest({299 DesktopWslEnvironment.layerTest({
255 isAvailable: true,300 isAvailable: true,
...@@ -386,6 +431,7 @@ describe("DesktopBackendConfiguration", () => {...@@ -386,6 +431,7 @@ describe("DesktopBackendConfiguration", () => {
386 DesktopBackendConfiguration.layer.pipe(431 DesktopBackendConfiguration.layer.pipe(
387 Layer.provideMerge(serverExposureLayer),432 Layer.provideMerge(serverExposureLayer),
388 Layer.provideMerge(DesktopAppSettings.layerTest()),433 Layer.provideMerge(DesktopAppSettings.layerTest()),
434 Layer.provideMerge(DesktopWslServerTree.layerTest()),
389 Layer.provideMerge(DesktopWslEnvironment.layerTest()),435 Layer.provideMerge(DesktopWslEnvironment.layerTest()),
390 Layer.provideMerge(makeEnvironmentLayer(baseDir)),436 Layer.provideMerge(makeEnvironmentLayer(baseDir)),
391 Layer.provideMerge(failingFileSystemLayer),437 Layer.provideMerge(failingFileSystemLayer),
...@@ -427,6 +473,7 @@ describe("DesktopBackendConfiguration", () => {...@@ -427,6 +473,7 @@ describe("DesktopBackendConfiguration", () => {
427 DesktopBackendConfiguration.layer.pipe(473 DesktopBackendConfiguration.layer.pipe(
428 Layer.provideMerge(serverExposureLayer),474 Layer.provideMerge(serverExposureLayer),
429 Layer.provideMerge(DesktopAppSettings.layerTest()),475 Layer.provideMerge(DesktopAppSettings.layerTest()),
476 Layer.provideMerge(DesktopWslServerTree.layerTest()),
430 Layer.provideMerge(DesktopWslEnvironment.layerTest()),477 Layer.provideMerge(DesktopWslEnvironment.layerTest()),
431 Layer.provideMerge(478 Layer.provideMerge(
432 makeEnvironmentLayer(baseDir, {479 makeEnvironmentLayer(baseDir, {
...@@ -486,6 +533,7 @@ describe("DesktopBackendConfiguration", () => {...@@ -486,6 +533,7 @@ describe("DesktopBackendConfiguration", () => {
486 DesktopBackendConfiguration.layer.pipe(533 DesktopBackendConfiguration.layer.pipe(
487 Layer.provideMerge(serverExposureLayer),534 Layer.provideMerge(serverExposureLayer),
488 Layer.provideMerge(DesktopAppSettings.layerTest()),535 Layer.provideMerge(DesktopAppSettings.layerTest()),
536 Layer.provideMerge(DesktopWslServerTree.layerTest()),
489 Layer.provideMerge(537 Layer.provideMerge(
490 DesktopWslEnvironment.layerTest({538 DesktopWslEnvironment.layerTest({
491 isAvailable: true,539 isAvailable: true,
...@@ -536,6 +584,7 @@ describe("DesktopBackendConfiguration", () => {...@@ -536,6 +584,7 @@ describe("DesktopBackendConfiguration", () => {
536 wslOnly: true,584 wslOnly: true,
537 }),585 }),
538 ),586 ),
587 Layer.provideMerge(DesktopWslServerTree.layerTest()),
539 Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: false })),588 Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: false })),
540 Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })),589 Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })),
541 ),590 ),
...@@ -573,6 +622,7 @@ describe("DesktopBackendConfiguration", () => {...@@ -573,6 +622,7 @@ describe("DesktopBackendConfiguration", () => {
573 wslDistro: "Removed-Distro",622 wslDistro: "Removed-Distro",
574 }),623 }),
575 ),624 ),
625 Layer.provideMerge(DesktopWslServerTree.layerTest()),
576 Layer.provideMerge(626 Layer.provideMerge(
577 DesktopWslEnvironment.layerTest({627 DesktopWslEnvironment.layerTest({
578 isAvailable: true,628 isAvailable: true,
...@@ -606,6 +656,7 @@ describe("DesktopBackendConfiguration", () => {...@@ -606,6 +656,7 @@ describe("DesktopBackendConfiguration", () => {
606 DesktopBackendConfiguration.layer.pipe(656 DesktopBackendConfiguration.layer.pipe(
607 Layer.provideMerge(serverExposureLayer),657 Layer.provideMerge(serverExposureLayer),
608 Layer.provideMerge(DesktopAppSettings.layerTest()),658 Layer.provideMerge(DesktopAppSettings.layerTest()),
659 Layer.provideMerge(DesktopWslServerTree.layerTest()),
609 Layer.provideMerge(660 Layer.provideMerge(
610 DesktopWslEnvironment.layerTest({661 DesktopWslEnvironment.layerTest({
611 isAvailable: true,662 isAvailable: true,
...@@ -640,6 +691,49 @@ describe("DesktopBackendConfiguration", () => {...@@ -640,6 +691,49 @@ describe("DesktopBackendConfiguration", () => {
640 DesktopBackendConfiguration.layer.pipe(691 DesktopBackendConfiguration.layer.pipe(
641 Layer.provideMerge(serverExposureLayer),692 Layer.provideMerge(serverExposureLayer),
642 Layer.provideMerge(DesktopAppSettings.layerTest()),693 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 ),
643 Layer.provideMerge(737 Layer.provideMerge(
644 DesktopWslEnvironment.layerTest({738 DesktopWslEnvironment.layerTest({
645 isAvailable: true,739 isAvailable: true,
...@@ -672,6 +766,7 @@ describe("DesktopBackendConfiguration", () => {...@@ -672,6 +766,7 @@ describe("DesktopBackendConfiguration", () => {
672 DesktopBackendConfiguration.layer.pipe(766 DesktopBackendConfiguration.layer.pipe(
673 Layer.provideMerge(serverExposureLayer),767 Layer.provideMerge(serverExposureLayer),
674 Layer.provideMerge(DesktopAppSettings.layerTest()),768 Layer.provideMerge(DesktopAppSettings.layerTest()),
769 Layer.provideMerge(DesktopWslServerTree.layerTest()),
675 Layer.provideMerge(770 Layer.provideMerge(
676 DesktopWslEnvironment.layerTest({771 DesktopWslEnvironment.layerTest({
677 isAvailable: true,772 isAvailable: true,
...@@ -708,6 +803,7 @@ describe("DesktopBackendConfiguration", () => {...@@ -708,6 +803,7 @@ describe("DesktopBackendConfiguration", () => {
708 wslDistro: "Ubuntu",803 wslDistro: "Ubuntu",
709 }),804 }),
710 ),805 ),
806 Layer.provideMerge(DesktopWslServerTree.layerTest()),
711 Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: true })),807 Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: true })),
712 Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })),808 Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })),
713 ),809 ),
...@@ -748,6 +844,7 @@ describe("DesktopBackendConfiguration", () => {...@@ -748,6 +844,7 @@ describe("DesktopBackendConfiguration", () => {
748 DesktopBackendConfiguration.layer.pipe(844 DesktopBackendConfiguration.layer.pipe(
749 Layer.provideMerge(serverExposureLayer),845 Layer.provideMerge(serverExposureLayer),
750 Layer.provideMerge(DesktopAppSettings.layerTest()),846 Layer.provideMerge(DesktopAppSettings.layerTest()),
847 Layer.provideMerge(DesktopWslServerTree.layerTest()),
751 Layer.provideMerge(DesktopWslEnvironment.layerTest()),848 Layer.provideMerge(DesktopWslEnvironment.layerTest()),
752 Layer.provideMerge(849 Layer.provideMerge(
753 makeEnvironmentLayer(baseDir, {850 makeEnvironmentLayer(baseDir, {
...@@ -793,6 +890,7 @@ describe("DesktopBackendConfiguration", () => {...@@ -793,6 +890,7 @@ describe("DesktopBackendConfiguration", () => {
793 DesktopBackendConfiguration.layer.pipe(890 DesktopBackendConfiguration.layer.pipe(
794 Layer.provideMerge(serverExposureLayer),891 Layer.provideMerge(serverExposureLayer),
795 Layer.provideMerge(DesktopAppSettings.layerTest()),892 Layer.provideMerge(DesktopAppSettings.layerTest()),
893 Layer.provideMerge(DesktopWslServerTree.layerTest()),
796 Layer.provideMerge(DesktopWslEnvironment.layerTest()),894 Layer.provideMerge(DesktopWslEnvironment.layerTest()),
797 Layer.provideMerge(895 Layer.provideMerge(
798 makeEnvironmentLayer(baseDir, {896 makeEnvironmentLayer(baseDir, {
...@@ -843,6 +941,7 @@ describe("DesktopBackendConfiguration", () => {...@@ -843,6 +941,7 @@ describe("DesktopBackendConfiguration", () => {
843 wslDistro: "Ubuntu",941 wslDistro: "Ubuntu",
844 }),942 }),
845 ),943 ),
944 Layer.provideMerge(DesktopWslServerTree.layerTest()),
846 Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: false })),945 Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: false })),
847 Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })),946 Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })),
848 ),947 ),
...@@ -864,6 +963,7 @@ describe("DesktopBackendConfiguration", () => {...@@ -864,6 +963,7 @@ describe("DesktopBackendConfiguration", () => {
864 DesktopBackendConfiguration.layer.pipe(963 DesktopBackendConfiguration.layer.pipe(
865 Layer.provideMerge(serverExposureLayer),964 Layer.provideMerge(serverExposureLayer),
866 Layer.provideMerge(DesktopAppSettings.layerTest()),965 Layer.provideMerge(DesktopAppSettings.layerTest()),
966 Layer.provideMerge(DesktopWslServerTree.layerTest()),
867 Layer.provideMerge(DesktopWslEnvironment.layer),967 Layer.provideMerge(DesktopWslEnvironment.layer),
868 // isAvailable on win32 only touches the filesystem, never the spawner,968 // isAvailable on win32 only touches the filesystem, never the spawner,
869 // so a die-stub is enough to satisfy the layer's deps.969 // 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";...@@ -19,6 +19,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
19import * as DesktopServerExposure from "./DesktopServerExposure.ts";19import * as DesktopServerExposure from "./DesktopServerExposure.ts";
20import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";20import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
21import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";21import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
22import * as DesktopWslServerTree from "../wsl/DesktopWslServerTree.ts";
2223
23export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedErrorClass<DesktopBackendObservabilitySettingsReadError>()(24export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedErrorClass<DesktopBackendObservabilitySettingsReadError>()(
24 "DesktopBackendObservabilitySettingsReadError",25 "DesktopBackendObservabilitySettingsReadError",
...@@ -424,10 +425,12 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl...@@ -424,10 +425,12 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
424 never,425 never,
425 | DesktopEnvironment.DesktopEnvironment426 | DesktopEnvironment.DesktopEnvironment
426 | DesktopWslEnvironment.DesktopWslEnvironment427 | DesktopWslEnvironment.DesktopWslEnvironment
428 | DesktopWslServerTree.DesktopWslServerTree
427 | FileSystem.FileSystem429 | FileSystem.FileSystem
428> {430> {
429 const environment = yield* DesktopEnvironment.DesktopEnvironment;431 const environment = yield* DesktopEnvironment.DesktopEnvironment;
430 const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;432 const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
433 const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree;
431434
432 // Bind to 0.0.0.0 inside WSL so the backend is reachable both via435 // Bind to 0.0.0.0 inside WSL so the backend is reachable both via
433 // WSL2's automatic localhost forwarding (wslhost: Windows 127.0.0.1436 // WSL2's automatic localhost forwarding (wslhost: Windows 127.0.0.1
...@@ -464,31 +467,31 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl...@@ -464,31 +467,31 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
464 ...buildObservabilityFragment(input.observabilitySettings),467 ...buildObservabilityFragment(input.observabilitySettings),
465 };468 };
466469
467 // In packaged builds environment.appRoot is .../resources/app.asar — an470 // In packaged builds the server tree ships inside resources/server.asar —
468 // archive FILE. The Windows primary reads its entry through471 // an archive FILE the Windows primary reads through ELECTRON_RUN_AS_NODE
469 // ELECTRON_RUN_AS_NODE (asar-aware), but the WSL backend launches plain472 // (asar-aware). The WSL backend launches plain `wsl.exe -- node`, which
470 // `wsl.exe -- node`, which can't read inside an asar. electron-builder unpacks473 // can't read an asar, so materialize (or reuse) the extracted copy of the
471 // the server bundle + node-pty (see asarUnpack in build-desktop-artifact.ts)474 // sidecar before preflighting. In dev the server tree is the real checkout
472 // to the app.asar.unpacked sibling, so point WSL there. In dev appRoot is475 // directory and ensure returns it unchanged.
473 // already a real directory, so this is a no-op.476 const serverTree = yield* wslServerTree.ensure;
474 const wslAppRoot = environment.isPackaged477 const wslAppRoot = serverTree.ok ? serverTree.root : environment.serverRoot;
475 ? environment.path.join(environment.resourcesPath, "app.asar.unpacked")
476 : environment.appRoot;
477 const wslEntryPath = environment.path.join(wslAppRoot, "apps/server/dist/bin.mjs");478 const wslEntryPath = environment.path.join(wslAppRoot, "apps/server/dist/bin.mjs");
478479
479 const preflight = yield* runWslPreflight({480 const preflight = serverTree.ok
480 distro: input.distro,481 ? yield* runWslPreflight({
481 windowsEntryPath: wslEntryPath,482 distro: input.distro,
482 windowsRepoRoot: wslAppRoot,483 windowsEntryPath: wslEntryPath,
483 // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and484 windowsRepoRoot: wslAppRoot,
484 // attached to the Windows artifact — see build-desktop-artifact.ts), so the485 // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and
485 // WSL backend never needs a compiler, node-gyp, or network on first launch.486 // attached to the Windows artifact — see build-desktop-artifact.ts), so the
486 // Compiling from source is a dev-only convenience: a checkout has no shipped487 // WSL backend never needs a compiler, node-gyp, or network on first launch.
487 // prebuilt, and developers have the toolchain. In packaged builds we instead488 // Compiling from source is a dev-only convenience: a checkout has no shipped
488 // surface a clear diagnostic if the prebuilt can't load (unsupported489 // prebuilt, and developers have the toolchain. In packaged builds we instead
489 // arch/distro), rather than silently dropping into a fragile runtime build.490 // surface a clear diagnostic if the prebuilt can't load (unsupported
490 allowBuild: !environment.isPackaged,491 // arch/distro), rather than silently dropping into a fragile runtime build.
491 });492 allowBuild: !environment.isPackaged,
493 })
494 : ({ _tag: "Failed", reason: serverTree.reason, fatal: serverTree.fatal } as const);
492495
493 // Every operation after preflight uses the same concrete distro. In496 // Every operation after preflight uses the same concrete distro. In
494 // default-tracking mode this closes the race where the system default497 // default-tracking mode this closes the race where the system default
...@@ -610,6 +613,7 @@ export const make = Effect.gen(function* () {...@@ -610,6 +613,7 @@ export const make = Effect.gen(function* () {
610 const fileSystem = yield* FileSystem.FileSystem;613 const fileSystem = yield* FileSystem.FileSystem;
611 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;614 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
612 const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;615 const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
616 const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree;
613 const settings = yield* DesktopAppSettings.DesktopAppSettings;617 const settings = yield* DesktopAppSettings.DesktopAppSettings;
614 const crypto = yield* Crypto.Crypto;618 const crypto = yield* Crypto.Crypto;
615 // SynchronizedRef (not a plain Ref) so the read-generate-write is atomic.619 // SynchronizedRef (not a plain Ref) so the read-generate-write is atomic.
...@@ -665,6 +669,7 @@ export const make = Effect.gen(function* () {...@@ -665,6 +669,7 @@ export const make = Effect.gen(function* () {
665 }).pipe(669 }).pipe(
666 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),670 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
667 Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment),671 Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment),
672 Effect.provideService(DesktopWslServerTree.DesktopWslServerTree, wslServerTree),
668 Effect.provideService(FileSystem.FileSystem, fileSystem),673 Effect.provideService(FileSystem.FileSystem, fileSystem),
669 );674 );
670 });675 });
...@@ -727,6 +732,7 @@ export const make = Effect.gen(function* () {...@@ -727,6 +732,7 @@ export const make = Effect.gen(function* () {
727 return yield* resolveWslStartConfig({ ...shared, ...input }).pipe(732 return yield* resolveWslStartConfig({ ...shared, ...input }).pipe(
728 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),733 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
729 Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment),734 Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment),
735 Effect.provideService(DesktopWslServerTree.DesktopWslServerTree, wslServerTree),
730 Effect.provideService(FileSystem.FileSystem, fileSystem),736 Effect.provideService(FileSystem.FileSystem, fileSystem),
731 );737 );
732 }).pipe(738 }).pipe(
apps/desktop/src/main.ts+2
...@@ -62,6 +62,7 @@ import * as PreviewManager from "./preview/Manager.ts";...@@ -62,6 +62,7 @@ import * as PreviewManager from "./preview/Manager.ts";
62import * as DesktopWindow from "./window/DesktopWindow.ts";62import * as DesktopWindow from "./window/DesktopWindow.ts";
63import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts";63import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts";
64import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts";64import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts";
65import * as DesktopWslServerTree from "./wsl/DesktopWslServerTree.ts";
6566
66const desktopEnvironmentLayer = Layer.unwrap(67const desktopEnvironmentLayer = Layer.unwrap(
67 Effect.gen(function* () {68 Effect.gen(function* () {
...@@ -165,6 +166,7 @@ const desktopBackendLayer = DesktopBackendPool.layer.pipe(...@@ -165,6 +166,7 @@ const desktopBackendLayer = DesktopBackendPool.layer.pipe(
165 Layer.provideMerge(DesktopAppIdentity.layer),166 Layer.provideMerge(DesktopAppIdentity.layer),
166 Layer.provideMerge(DesktopBackendConfiguration.layer),167 Layer.provideMerge(DesktopBackendConfiguration.layer),
167 Layer.provideMerge(DesktopWslEnvironment.layer),168 Layer.provideMerge(DesktopWslEnvironment.layer),
169 Layer.provideMerge(DesktopWslServerTree.layer),
168 Layer.provideMerge(DesktopTelemetryPublisher.layer),170 Layer.provideMerge(DesktopTelemetryPublisher.layer),
169 Layer.provideMerge(desktopWindowLayer),171 Layer.provideMerge(desktopWindowLayer),
170);172);
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 @@...@@ -31,6 +31,7 @@
31 "@opencode-ai/sdk": "^1.3.15",31 "@opencode-ai/sdk": "^1.3.15",
32 "@pierre/diffs": "catalog:",32 "@pierre/diffs": "catalog:",
33 "effect": "catalog:",33 "effect": "catalog:",
34 "msgpackr-extract": "3.0.4",
34 "node-pty": "^1.1.0",35 "node-pty": "^1.1.0",
35 "yaml": "catalog:"36 "yaml": "catalog:"
36 },37 },
docs/operations/release.md+31
...@@ -214,6 +214,37 @@ desktop-managed guidance when those environments are available....@@ -214,6 +214,37 @@ desktop-managed guidance when those environments are available.
214 - `electron-updater` reads `latest-mac.yml` on stable and `nightly-mac.yml` on nightly, for both Intel and Apple Silicon.214 - `electron-updater` reads `latest-mac.yml` on stable and `nightly-mac.yml` on nightly, for both Intel and Apple Silicon.
215 - The workflow merges the per-arch mac manifests into one channel-specific mac manifest before publishing the GitHub Release.215 - 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
217## 0) npm OIDC trusted publishing setup (CLI)248## 0) npm OIDC trusted publishing setup (CLI)
218249
219The workflow invokes `node apps/server/scripts/cli.ts publish` after aligning package versions. That250The 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...@@ -11,16 +11,18 @@ index ee181aef5007e4bf34a49479c089ca30f73a320b..327e2c55c83cc4c50d396a3109190ef1
11 import { fileURLToPath } from "node:url";11 import { fileURLToPath } from "node:url";
12 import { getLibFilename, getNpmPackageName } from "./platform.js";12 import { getLibFilename, getNpmPackageName } from "./platform.js";
13 /**13 /**
14@@ -46,6 +46,14 @@ function getPackageDir() {14@@ -46,6 +46,16 @@ function getPackageDir() {
15 // Fallback: assume we're one level deep in src/15 // Fallback: assume we're one level deep in src/
16 return dirname(currentDir);16 return dirname(currentDir);
17 }17 }
18+function resolveUnpackedAsarPath(binaryPath) {18+function resolveUnpackedAsarPath(binaryPath) {
19+ const asarSegment = `${sep}app.asar${sep}`;19+ const pathSegments = binaryPath.split(sep);
20+ if (!binaryPath.includes(asarSegment)) {20+ const asarIndex = pathSegments.findLastIndex((segment) => segment.endsWith(".asar"));
21+ if (asarIndex === -1) {
21+ return binaryPath;22+ return binaryPath;
22+ }23+ }
23+ const unpackedPath = binaryPath.replace(asarSegment, `${sep}app.asar.unpacked${sep}`);24+ pathSegments[asarIndex] = `${pathSegments[asarIndex]}.unpacked`;
25+ const unpackedPath = pathSegments.join(sep);
24+ return existsSync(unpackedPath) ? unpackedPath : binaryPath;26+ return existsSync(unpackedPath) ? unpackedPath : binaryPath;
25+}27+}
26 /**28 /**
pnpm-lock.yaml+9-5
...@@ -75,7 +75,7 @@ patchedDependencies:...@@ -75,7 +75,7 @@ patchedDependencies:
75 '@clerk/expo@4.2.0': 72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c175 '@clerk/expo@4.2.0': 72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1
76 '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b76 '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b
77 '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff4677 '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46
78 '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c878 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368
79 '@legendapp/list@3.3.5': 6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab579 '@legendapp/list@3.3.5': 6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5
80 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa80 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa
81 '@react-native-menu/menu@2.0.0': c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a004581 '@react-native-menu/menu@2.0.0': c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045
...@@ -463,7 +463,7 @@ importers:...@@ -463,7 +463,7 @@ importers:
463 version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))463 version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))
464 '@ff-labs/fff-node':464 '@ff-labs/fff-node':
465 specifier: 0.9.4465 specifier: 0.9.4
466 version: 0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8)466 version: 0.9.4(patch_hash=ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368)
467 '@opencode-ai/sdk':467 '@opencode-ai/sdk':
468 specifier: ^1.3.15468 specifier: ^1.3.15
469 version: 1.15.13469 version: 1.15.13
...@@ -473,6 +473,9 @@ importers:...@@ -473,6 +473,9 @@ importers:
473 effect:473 effect:
474 specifier: 4.0.0-beta.103474 specifier: 4.0.0-beta.103
475 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)475 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)
476 msgpackr-extract:
477 specifier: 3.0.4
478 version: 3.0.4
476 node-pty:479 node-pty:
477 specifier: ^1.1.0480 specifier: ^1.1.0
478 version: 1.1.0481 version: 1.1.0
...@@ -913,6 +916,9 @@ importers:...@@ -913,6 +916,9 @@ importers:
913 '@effect/platform-node':916 '@effect/platform-node':
914 specifier: 4.0.0-beta.103917 specifier: 4.0.0-beta.103
915 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)918 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
916 '@t3tools/contracts':922 '@t3tools/contracts':
917 specifier: workspace:*923 specifier: workspace:*
918 version: link:../packages/contracts924 version: link:../packages/contracts
...@@ -12789,7 +12795,7 @@ snapshots:...@@ -12789,7 +12795,7 @@ snapshots:
12789 '@ff-labs/fff-bin-win32-x64@0.9.4':12795 '@ff-labs/fff-bin-win32-x64@0.9.4':
12790 optional: true12796 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)':
12793 dependencies:12799 dependencies:
12794 ffi-rs: 1.3.212800 ffi-rs: 1.3.2
12795 optionalDependencies:12801 optionalDependencies:
...@@ -19143,7 +19149,6 @@ snapshots:...@@ -19143,7 +19149,6 @@ snapshots:
19143 '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.419149 '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4
19144 '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.419150 '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4
19145 '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.419151 '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4
19146 optional: true
1914719152
19148 msgpackr@2.0.4:19153 msgpackr@2.0.4:
19149 optionalDependencies:19154 optionalDependencies:
...@@ -19237,7 +19242,6 @@ snapshots:...@@ -19237,7 +19242,6 @@ snapshots:
19237 node-gyp-build-optional-packages@5.2.2:19242 node-gyp-build-optional-packages@5.2.2:
19238 dependencies:19243 dependencies:
19239 detect-libc: 2.1.219244 detect-libc: 2.1.2
19240 optional: true
1924119245
19242 node-gyp-build@4.8.4:19246 node-gyp-build@4.8.4:
19243 optional: true19247 optional: true
scripts/build-desktop-artifact.test.ts+393-14
...@@ -2,15 +2,16 @@ import * as NodeServices from "@effect/platform-node/NodeServices";...@@ -2,15 +2,16 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";2import { assert, it } from "@effect/vitest";
3import * as ConfigProvider from "effect/ConfigProvider";3import * as ConfigProvider from "effect/ConfigProvider";
4import * as FileSystem from "effect/FileSystem";4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";7import * as Option from "effect/Option";
8import * as Path from "effect/Path";
9import * as Sink from "effect/Sink";9import * as Sink from "effect/Sink";
10import * as Stream from "effect/Stream";10import * as Stream from "effect/Stream";
11import { ChildProcessSpawner } from "effect/unstable/process";11import { ChildProcessSpawner } from "effect/unstable/process";
1212
13import {13import {
14 BundleNotSelfContainedError,
14 BuildCommandFailedError,15 BuildCommandFailedError,
15 createStageWorkspaceConfig,16 createStageWorkspaceConfig,
16 createStagePatchedDependencies,17 createStagePatchedDependencies,
...@@ -26,6 +27,7 @@ import {...@@ -26,6 +27,7 @@ import {
26 LinuxIconResizeError,27 LinuxIconResizeError,
27 MacPasskeySigningConfigurationResolutionError,28 MacPasskeySigningConfigurationResolutionError,
28 MissingMacPasskeyProvisioningProfileError,29 MissingMacPasskeyProvisioningProfileError,
30 packWindowsServerAsar,
29 renderMacPasskeyEntitlements,31 renderMacPasskeyEntitlements,
30 resolveClerkPasskeyNativeArtifacts,32 resolveClerkPasskeyNativeArtifacts,
31 resolveMacPasskeySigningConfiguration,33 resolveMacPasskeySigningConfiguration,
...@@ -44,9 +46,17 @@ import {...@@ -44,9 +46,17 @@ import {
44 resolvePackageManagerUserAgent,46 resolvePackageManagerUserAgent,
45 stageLinuxIconSize,47 stageLinuxIconSize,
46 STAGE_INSTALL_ARGS,48 STAGE_INSTALL_ARGS,
47 WINDOWS_ASAR_UNPACK,
48 ancestorNodeModulesPaths,49 ancestorNodeModulesPaths,
49 copyDirectoryPreservingSymlinks,50 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,
50} from "./build-desktop-artifact.ts";60} from "./build-desktop-artifact.ts";
51import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";61import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";
52import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";62import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
...@@ -88,6 +98,54 @@ function iconResizeSpawnerLayer(...@@ -88,6 +98,54 @@ function iconResizeSpawnerLayer(
88 );98 );
89}99}
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
91it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {149it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
92 it("resolves the dedicated nightly updater channel from nightly versions", () => {150 it("resolves the dedicated nightly updater channel from nightly versions", () => {
93 assert.equal(resolveDesktopUpdateChannel("0.0.17-nightly.20260413.42"), "nightly");151 assert.equal(resolveDesktopUpdateChannel("0.0.17-nightly.20260413.42"), "nightly");
...@@ -232,22 +290,40 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -232,22 +290,40 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
232 libc: ["glibc"],290 libc: ["glibc"],
233 },291 },
234 });292 });
235 // Windows artifacts also bundle the same-architecture WSL (Linux, glibc) backend, so the293 // The Windows app stage only serves the desktop main process; the server
236 // staged install must fetch its native optional deps (e.g. ffi-rs) too.294 // sidecar stage is the one that needs Linux natives (below).
237 assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "x64" }), {295 assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "x64" }), {
238 supportedArchitectures: {296 supportedArchitectures: {
239 os: ["win32", "linux"],297 os: ["win32"],
240 cpu: ["x64"],298 cpu: ["x64"],
241 libc: ["glibc"],
242 },299 },
243 });300 });
244 assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "arm64" }), {301 // The server sidecar stage bundles the same-architecture WSL (Linux,
245 supportedArchitectures: {302 // glibc) backend, so its install must fetch Linux native optional deps
246 os: ["win32", "linux"],303 // (e.g. ffi-rs) too — and must be hoisted so the tree survives asar
247 cpu: ["arm64"],304 // packing and runtime extraction without symlinks.
248 libc: ["glibc"],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",
249 },314 },
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 );
251 assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "mac", arch: "universal" }), {327 assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "mac", arch: "universal" }), {
252 supportedArchitectures: {328 supportedArchitectures: {
253 os: ["darwin"],329 os: ["darwin"],
...@@ -317,6 +393,16 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -317,6 +393,16 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
317 assert.deepStrictEqual(DESKTOP_ELECTRON_LANGUAGES, ["en-US"]);393 assert.deepStrictEqual(DESKTOP_ELECTRON_LANGUAGES, ["en-US"]);
318 assert.deepStrictEqual(DESKTOP_FILE_EXCLUSIONS, [394 assert.deepStrictEqual(DESKTOP_FILE_EXCLUSIONS, [
319 "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*",395 "!**/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 },
320 ]);406 ]);
321 });407 });
322408
...@@ -350,9 +436,33 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -350,9 +436,33 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
350 undefined,436 undefined,
351 );437 );
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.
353 assert.notProperty(mac, "asarUnpack");442 assert.notProperty(mac, "asarUnpack");
354 assert.notProperty(linux, "asarUnpack");443 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 ]);
356 // Linux must register the renderer schemes so the generated .desktop466 // Linux must register the renderer schemes so the generated .desktop
357 // entry advertises MimeType=x-scheme-handler/t3code; for OAuth deep links.467 // entry advertises MimeType=x-scheme-handler/t3code; for OAuth deep links.
358 assert.deepStrictEqual((linux.linux as Record<string, unknown>).protocols, [468 assert.deepStrictEqual((linux.linux as Record<string, unknown>).protocols, [
...@@ -365,6 +475,275 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -365,6 +475,275 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
365 }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))),475 }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))),
366 );476 );
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
368 it.effect("preserves both Linux icon resize failures with structural context", () => {747 it.effect("preserves both Linux icon resize failures with structural context", () => {
369 const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> = [];748 const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> = [];
370749
...@@ -773,7 +1152,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -773,7 +1152,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
773});1152});
7741153
775// The self-containment check runs the packaged tree in a scratch directory. Its1154// 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 any1155// own node_modules holds the sidecar externals and must be ignored, but any
777// node_modules *above* it would let Node's parent walk satisfy an import that is1156// node_modules *above* it would let Node's parent walk satisfy an import that is
778// missing from the package, so the probe refuses to run in that case.1157// missing from the package, so the probe refuses to run in that case.
779it("lists ancestor node_modules, nearest first, excluding the start directory", () => {1158it("lists ancestor node_modules, nearest first, excluding the start directory", () => {
scripts/build-desktop-artifact.ts+618-89
...@@ -4,8 +4,16 @@...@@ -4,8 +4,16 @@
4import * as NodeFSP from "node:fs/promises";4import * as NodeFSP from "node:fs/promises";
5import * as NodeModule from "node:module";5import * as NodeModule from "node:module";
66
7import {
8 createPackageWithOptions,
9 extractAll,
10 getRawHeader,
11 statFile,
12 type DirectoryRecord,
13} from "@electron/asar";
14
7import { fromYaml } from "@t3tools/shared/schemaYaml";15import { fromYaml } from "@t3tools/shared/schemaYaml";
8import { HostProcessPlatform } from "@t3tools/shared/hostProcess";16import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
9import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth";17import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth";
10import { resolveSpawnCommand } from "@t3tools/shared/shell";18import { resolveSpawnCommand } from "@t3tools/shared/shell";
11import rootPackageJson from "../package.json" with { type: "json" };19import rootPackageJson from "../package.json" with { type: "json" };
...@@ -20,8 +28,8 @@ import {...@@ -20,8 +28,8 @@ import {
20} from "./lib/brand-assets.ts";28} from "./lib/brand-assets.ts";
21import { getDefaultBuildArch } from "./lib/build-target-arch.ts";29import { getDefaultBuildArch } from "./lib/build-target-arch.ts";
22import {30import {
23 CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS,
24 findInlinedExternalPackages,31 findInlinedExternalPackages,
32 selectCliRuntimeExternalDependencies,
25} from "./lib/cli-external-packages.ts";33} from "./lib/cli-external-packages.ts";
26import { loadRepoEnv } from "./lib/public-config.ts";34import { loadRepoEnv } from "./lib/public-config.ts";
27import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts";35import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts";
...@@ -69,6 +77,7 @@ const StageWorkspaceConfig = Schema.Struct({...@@ -69,6 +77,7 @@ const StageWorkspaceConfig = Schema.Struct({
69 allowBuilds: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),77 allowBuilds: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
70 patchedDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),78 patchedDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
71 overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)),79 overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)),
80 nodeLinker: Schema.optional(Schema.Literals(["hoisted"])),
72});81});
73type StageWorkspaceConfig = typeof StageWorkspaceConfig.Type;82type StageWorkspaceConfig = typeof StageWorkspaceConfig.Type;
7483
...@@ -386,18 +395,36 @@ const desktopBuildInputArtifactNames = {...@@ -386,18 +395,36 @@ const desktopBuildInputArtifactNames = {
386/**395/**
387 * Imported by every server module, so it is inlined in any correctly bundled396 * Imported by every server module, so it is inlined in any correctly bundled
388 * build. Its absence means the bundle went back to externalizing its397 * 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.
390 */399 */
391const BUNDLE_SELF_CONTAINED_SENTINEL = "effect";400const BUNDLE_SELF_CONTAINED_SENTINEL = "effect";
392401
393const BUNDLE_SELF_CHECK_TIMEOUT = Duration.seconds(120);402const 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
395export class ExternalizedBundleError extends Schema.TaggedErrorClass<ExternalizedBundleError>()(422export class ExternalizedBundleError extends Schema.TaggedErrorClass<ExternalizedBundleError>()(
396 "ExternalizedBundleError",423 "ExternalizedBundleError",
397 { sentinel: Schema.String, inlinedPackageCount: Schema.Number },424 { sentinel: Schema.String, inlinedPackageCount: Schema.Number },
398) {425) {
399 override get message(): string {426 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.`;
401 }428 }
402}429}
403430
...@@ -406,7 +433,7 @@ export class BundleNotSelfContainedError extends Schema.TaggedErrorClass<BundleN...@@ -406,7 +433,7 @@ export class BundleNotSelfContainedError extends Schema.TaggedErrorClass<BundleN
406 { exitCode: Schema.Number, output: Schema.String },433 { exitCode: Schema.Number, output: Schema.String },
407) {434) {
408 override get message(): string {435 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:
410${this.output}`;437${this.output}`;
411 }438 }
412}439}
...@@ -416,7 +443,7 @@ export class InlinedNativePackageError extends Schema.TaggedErrorClass<InlinedNa...@@ -416,7 +443,7 @@ export class InlinedNativePackageError extends Schema.TaggedErrorClass<InlinedNa
416 { packages: Schema.Array(Schema.String) },443 { packages: Schema.Array(Schema.String) },
417) {444) {
418 override get message(): string {445 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.`;
420 }447 }
421}448}
422449
...@@ -490,6 +517,71 @@ export class WslNodePtyPrebuildMissingError extends Schema.TaggedErrorClass<WslN...@@ -490,6 +517,71 @@ export class WslNodePtyPrebuildMissingError extends Schema.TaggedErrorClass<WslN
490 }517 }
491}518}
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
493export class WslNodePtyManifestReadError extends Schema.TaggedErrorClass<WslNodePtyManifestReadError>()(585export class WslNodePtyManifestReadError extends Schema.TaggedErrorClass<WslNodePtyManifestReadError>()(
494 "WslNodePtyManifestReadError",586 "WslNodePtyManifestReadError",
495 {587 {
...@@ -686,21 +778,47 @@ export const DESKTOP_FILE_EXCLUSIONS = [...@@ -686,21 +778,47 @@ export const DESKTOP_FILE_EXCLUSIONS = [
686 // so the SDK's optional platform packages (each a ~200MB bundled executable)778 // so the SDK's optional platform packages (each a ~200MB bundled executable)
687 // are dead weight. The trailing dash keeps the SDK's own JS package.779 // are dead weight. The trailing dash keeps the SDK's own JS package.
688 "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*",780 "!**/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/**/*",
689] as const;786] as const;
690// The WSL backend launches the server with plain `wsl.exe -- node`, which cannot787// Windows ships the server tree (bundle + node_modules) as a separate
691// read inside an asar archive, so everything it loads must be on the real788// resources/server.asar sidecar instead of loose files: the NSIS installer
692// filesystem. This used to unpack `**\/node_modules\/**` wholesale, because the789// then extracts a handful of large archives instead of thousands of small
693// server bundle externalized its runtime deps and the Linux Node would fail with790// files, which dominates install (and update) time. The Windows primary runs
694// ERR_MODULE_NOT_FOUND ("Cannot find package 'effect'") before it even reached791// the server from inside server.asar via the asar-aware ELECTRON_RUN_AS_NODE
695// node-pty.792// runtime; the WSL backend cannot read asar archives, so enabling WSL lazily
696//793// extracts the sidecar to a version-keyed directory (see DesktopWslServerTree).
697// The CLI bundle now inlines its JS dependencies, so the only things that still794export const WINDOWS_SERVER_ASAR_RESOURCE = "server.asar";
698// have to be loose are the server bundle itself and the packages the bundle795// dlopen/spawn need real files, so native modules, shared libraries, and
699// leaves external — derived from the same list the bundler uses, so the two796// helper executables live in the server.asar.unpacked sibling (the standard
700// cannot drift apart.797// asar redirect convention). Everything else stays packed.
701export const WINDOWS_ASAR_UNPACK = [798export const WINDOWS_SERVER_ASAR_UNPACK_GLOB =
702 "apps/server/dist/**",799 "{**/*.node,**/*.dll,**/*.exe,**/*.so,**/*.so.*,**/*.dylib}";
703 ...CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS,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 },
704] as const;822] as const;
705export const DESKTOP_EXTRA_RESOURCES = [823export const DESKTOP_EXTRA_RESOURCES = [
706 {824 {
...@@ -1019,14 +1137,20 @@ export function createStageWorkspaceConfig(input: {...@@ -1019,14 +1137,20 @@ export function createStageWorkspaceConfig(input: {
1019 readonly allowBuilds?: Record<string, boolean>;1137 readonly allowBuilds?: Record<string, boolean>;
1020 readonly patchedDependencies?: Record<string, string>;1138 readonly patchedDependencies?: Record<string, string>;
1021 readonly overrides?: Record<string, string>;1139 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;
1022}): StageWorkspaceConfig {1147}): StageWorkspaceConfig {
1023 const { platform, arch, allowBuilds, patchedDependencies, overrides } = input;1148 const { platform, arch, allowBuilds, patchedDependencies, overrides, linuxServerBackend } = input;
1024 const hostOs = platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux";1149 const hostOs = platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux";
1025 const hostCpu = arch === "universal" ? ["arm64", "x64"] : [arch];1150 const hostCpu = arch === "universal" ? ["arm64", "x64"] : [arch];
1026 // Linux AppImages and Windows WSL backends both execute a Linux/glibc Node1151 // Linux AppImages execute a Linux/glibc Node process that loads
1027 // process that loads Linux-native optional deps at runtime (e.g.1152 // Linux-native optional deps at runtime. Keep libc explicit so pnpm
1028 // @yuuang/ffi-rs-linux-x64-gnu). Keep libc explicit so pnpm includes those1153 // includes those optional packages in the staged production install.
1029 // optional packages in the staged production install.
1030 const supportedArchitectures =1154 const supportedArchitectures =
1031 platform === "linux"1155 platform === "linux"
1032 ? {1156 ? {
...@@ -1034,7 +1158,7 @@ export function createStageWorkspaceConfig(input: {...@@ -1034,7 +1158,7 @@ export function createStageWorkspaceConfig(input: {
1034 cpu: hostCpu,1158 cpu: hostCpu,
1035 libc: ["glibc"],1159 libc: ["glibc"],
1036 }1160 }
1037 : platform === "win"1161 : linuxServerBackend
1038 ? {1162 ? {
1039 os: Array.from(new Set([hostOs, "linux"])),1163 os: Array.from(new Set([hostOs, "linux"])),
1040 cpu: hostCpu,1164 cpu: hostCpu,
...@@ -1052,6 +1176,7 @@ export function createStageWorkspaceConfig(input: {...@@ -1052,6 +1176,7 @@ export function createStageWorkspaceConfig(input: {
1052 ? { patchedDependencies }1176 ? { patchedDependencies }
1053 : {}),1177 : {}),
1054 ...(overrides && Object.keys(overrides).length > 0 ? { overrides } : {}),1178 ...(overrides && Object.keys(overrides).length > 0 ? { overrides } : {}),
1179 ...(linuxServerBackend ? { nodeLinker: "hoisted" as const } : {}),
1055 };1180 };
1056}1181}
10571182
...@@ -1411,36 +1536,27 @@ export const copyDirectoryPreservingSymlinks = Effect.fn("copyDirectoryPreservin...@@ -1411,36 +1536,27 @@ export const copyDirectoryPreservingSymlinks = Effect.fn("copyDirectoryPreservin
1411);1536);
14121537
1413const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSelfContained")(1538const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSelfContained")(
1414 function* (input: { readonly stageDistDir: string; readonly verbose: boolean }) {1539 function* (input: { readonly asarPath: string; readonly verbose: boolean }) {
1415 const fs = yield* FileSystem.FileSystem;1540 const fs = yield* FileSystem.FileSystem;
1416 const path = yield* Path.Path;1541 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
1439 const probeRoot = yield* fs.makeTempDirectoryScoped({1543 const probeRoot = yield* fs.makeTempDirectoryScoped({
1440 prefix: "t3code-bundle-selfcheck-",1544 prefix: "t3code-bundle-selfcheck-",
1441 });1545 });
1546 const extractedApp = path.join(probeRoot, "extracted");
1442 const probeApp = path.join(probeRoot, "app");1547 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
1445 // Guard the guard: if anything above the probe provides a node_modules, a1561 // Guard the guard: if anything above the probe provides a node_modules, a
1446 // missing dependency would resolve there and the check would pass while the1562 // missing dependency would resolve there and the check would pass while the
...@@ -1466,8 +1582,8 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel...@@ -1466,8 +1582,8 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel
1466 // missing dependency shows up, without starting a server or touching disk1582 // missing dependency shows up, without starting a server or touching disk
1467 // state. It does not cover lazily imported externals: node-pty is checked1583 // state. It does not cover lazily imported externals: node-pty is checked
1468 // by the WSL preflight probe at runtime, while ffi-rs, @ff-labs/fff-node1584 // 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 the1585 // and the bun adapters are covered by the shared runtime-external closure
1470 // inlined-native check below.1586 // and emitted-bundle checks.
1471 yield* runCommand(1587 yield* runCommand(
1472 ChildProcess.make(1588 ChildProcess.make(
1473 process.execPath,1589 process.execPath,
...@@ -1486,7 +1602,10 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel...@@ -1486,7 +1602,10 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel
1486 env: { ...process.env, NODE_PATH: "" },1602 env: { ...process.env, NODE_PATH: "" },
1487 },1603 },
1488 ),1604 ),
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 },
1490 ).pipe(1609 ).pipe(
1491 // Printing a version should be immediate. A regression that blocks (on1610 // Printing a version should be immediate. A regression that blocks (on
1492 // stdin, a port, a lock) would otherwise hang release CI until the job1611 // stdin, a port, a lock) would otherwise hang release CI until the job
...@@ -1878,11 +1997,14 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (...@@ -1878,11 +1997,14 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
1878 directories: {1997 directories: {
1879 buildResources: "apps/desktop/resources",1998 buildResources: "apps/desktop/resources",
1880 },1999 },
1881 // Only the Windows WSL backend needs files outside the asar (see2000 // All platforms keep app.asar fully packed; electron-builder's default
1882 // WINDOWS_ASAR_UNPACK); macOS and Linux stay packed — smart unpack2001 // smart unpack extracts native libraries, which loaders find in
1883 // extracts native libraries, which fff-node finds in app.asar.unpacked.2002 // app.asar.unpacked. Windows additionally ships the server tree as the
1884 ...(platform === "win" ? { asarUnpack: [...WINDOWS_ASAR_UNPACK] } : {}),2003 // hand-packed server.asar sidecar (see WINDOWS_SERVER_ASAR_RESOURCE).
1885 extraResources: DESKTOP_EXTRA_RESOURCES,2004 extraResources: [
2005 ...DESKTOP_EXTRA_RESOURCES,
2006 ...(platform === "win" ? WINDOWS_SERVER_EXTRA_RESOURCES : []),
2007 ],
1886 };2008 };
1887 const updateChannel = resolveDesktopUpdateChannel(version);2009 const updateChannel = resolveDesktopUpdateChannel(version);
1888 const publishConfig = yield* resolveGitHubPublishConfig(updateChannel);2010 const publishConfig = yield* resolveGitHubPublishConfig(updateChannel);
...@@ -1942,6 +2064,10 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (...@@ -1942,6 +2064,10 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
19422064
1943 if (platform === "win") {2065 if (platform === "win") {
1944 buildConfig.npmRebuild = false;2066 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 };
1945 const winConfig: Record<string, unknown> = {2071 const winConfig: Record<string, unknown> = {
1946 target: [target],2072 target: [target],
1947 icon: "icon.ico",2073 icon: "icon.ico",
...@@ -2050,6 +2176,381 @@ const stageWslNodePtyPrebuild = Effect.fn("stageWslNodePtyPrebuild")(function* (...@@ -2050,6 +2176,381 @@ const stageWslNodePtyPrebuild = Effect.fn("stageWslNodePtyPrebuild")(function* (
2050 );2176 );
2051});2177});
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
2053const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (2554const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
2054 options: ResolvedBuildOptions,2555 options: ResolvedBuildOptions,
2055) {2556) {
...@@ -2098,6 +2599,9 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -2098,6 +2599,9 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
2098 cause,2599 cause,
2099 }),2600 }),
2100 });2601 });
2602 const resolvedServerRuntimeExternalDependencies = selectCliRuntimeExternalDependencies(
2603 resolvedServerDependencies,
2604 );
2101 const resolvedDesktopRuntimeDependencies = yield* Effect.try({2605 const resolvedDesktopRuntimeDependencies = yield* Effect.try({
2102 try: () => resolveDesktopRuntimeDependencies(desktopPackageJson.dependencies, workspaceCatalog),2606 try: () => resolveDesktopRuntimeDependencies(desktopPackageJson.dependencies, workspaceCatalog),
2103 catch: (cause) =>2607 catch: (cause) =>
...@@ -2188,7 +2692,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -2188,7 +2692,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
2188 // inlined. A regression to externalizing everything would also pass it,2692 // inlined. A regression to externalizing everything would also pass it,
2189 // since source-file regions still exist -- and that is the failure this2693 // since source-file regions still exist -- and that is the failure this
2190 // whole change exists to prevent, because those packages are not in the2694 // 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.
2192 // `effect` is imported by every server module, so it is inlined in any2696 // `effect` is imported by every server module, so it is inlined in any
2193 // correctly bundled build.2697 // correctly bundled build.
2194 // The list-based check above only sees packages someone already thought to2698 // The list-based check above only sees packages someone already thought to
...@@ -2227,12 +2731,18 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -2227,12 +2731,18 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
2227 yield* validateBundledClientAssets(path.dirname(bundledClientEntry));2731 yield* validateBundledClientAssets(path.dirname(bundledClientEntry));
22282732
2229 yield* fs.makeDirectory(path.join(stageAppDir, "apps/desktop"), { recursive: true });2733 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
2232 yield* Effect.log("[desktop-artifact] Staging release app...");2738 yield* Effect.log("[desktop-artifact] Staging release app...");
2233 yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"));2739 yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"));
2234 yield* fs.copy(distDirs.desktopResources, stageResourcesDir);2740 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 }
2236 yield* stageResourceMonitor({2746 yield* stageResourceMonitor({
2237 repoRoot,2747 repoRoot,
2238 stageResourcesDir,2748 stageResourcesDir,
...@@ -2253,7 +2763,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -2253,7 +2763,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
2253 );2763 );
22542764
2255 // electron-builder is filtering out stageResourcesDir directory in the AppImage for production2765 // 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
2258 const configuredMacPasskeySigning =2769 const configuredMacPasskeySigning =
2259 options.platform === "mac" && options.signed2770 options.platform === "mac" && options.signed
...@@ -2283,30 +2794,31 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -2283,30 +2794,31 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
2283 yield* fs.writeFileString(macEntitlementsPath, renderMacPasskeyEntitlements(macPasskeySigning));2794 yield* fs.writeFileString(macEntitlementsPath, renderMacPasskeyEntitlements(macPasskeySigning));
2284 }2795 }
22852796
2286 const stageDependencies = {2797 // Windows splits dependencies per process: app.asar carries only the
2287 ...resolvedServerDependencies,2798 // desktop main-process runtime deps, while the server bundle's deps live in
2288 ...resolvedDesktopRuntimeDependencies,2799 // the server.asar sidecar (see stageWindowsServerSidecar). macOS and Linux
2289 ...resolveFffNativeDependencies(2800 // keep the single merged tree — their primary resolves everything from
2290 options.platform,2801 // app.asar and there is no second consumer.
2291 options.arch,2802 const stageDependencies =
2292 serverPackageJson.dependencies["@ff-labs/fff-node"],2803 options.platform === "win"
2293 ),2804 ? { ...resolvedDesktopRuntimeDependencies }
2294 // Windows artifacts also bundle the same-architecture WSL Linux backend, which loads the2805 : {
2295 // fff native binary through ffi-rs. The platform fff binary above is the2806 ...resolvedServerDependencies,
2296 // host's (win32), so promote the matching Linux fff binaries too; without2807 ...resolvedDesktopRuntimeDependencies,
2297 // them file-finding in WSL fails to load its Linux native package.2808 ...resolveFffNativeDependencies(
2298 ...(options.platform === "win"2809 options.platform,
2299 ? resolveFffNativeDependencies(2810 options.arch,
2300 "linux",2811 serverPackageJson.dependencies["@ff-labs/fff-node"],
2301 options.arch,2812 ),
2302 serverPackageJson.dependencies["@ff-labs/fff-node"],2813 };
2303 )
2304 : {}),
2305 };
2306 const stagePatchedDependencies = createStagePatchedDependencies(2814 const stagePatchedDependencies = createStagePatchedDependencies(
2307 workspacePatchedDependencies,2815 workspacePatchedDependencies,
2308 stageDependencies,2816 stageDependencies,
2309 );2817 );
2818 const windowsServerAsarPath =
2819 options.platform === "win"
2820 ? path.join(stageAppDir, WINDOWS_SERVER_RESOURCE_SOURCE_DIR, WINDOWS_SERVER_ASAR_RESOURCE)
2821 : undefined;
2310 const stagePackageJson: StagePackageJson = {2822 const stagePackageJson: StagePackageJson = {
2311 name: "t3code",2823 name: "t3code",
2312 version: appVersion,2824 version: appVersion,
...@@ -2367,13 +2879,24 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -2367,13 +2879,24 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
2367 );2879 );
2368 yield* stageClerkPasskeyNativeBinaries(stageAppDir, options.platform, options.arch);2880 yield* stageClerkPasskeyNativeBinaries(stageAppDir, options.platform, options.arch);
23692881
2370 // WSL is Windows-only, so only the Windows artifact carries the Linux backend2882 // WSL is Windows-only, so only the Windows artifact carries the server
2371 // binary; other platforms ignore the prebuild input.2883 // sidecar (which embeds the Linux node-pty prebuild); other platforms
2372 if (options.platform === "win") {2884 // ignore the prebuild input.
2373 yield* stageWslNodePtyPrebuild({2885 if (options.platform === "win" && windowsServerAsarPath) {
2374 stageAppDir,2886 yield* stageWindowsServerSidecar({
2887 stageRoot,
2888 repoRoot,
2889 serverDistDir: distDirs.serverDist,
2375 arch: options.arch,2890 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,
2377 });2900 });
2378 }2901 }
23792902
...@@ -2462,9 +2985,15 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -2462,9 +2985,15 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
2462 // resolver has no such ambiguity: it either finds every import or it does not.2985 // resolver has no such ambiguity: it either finds every import or it does not.
2463 //2986 //
2464 // Only Windows unpacks anything; macOS and Linux keep the whole tree inside2987 // 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.
2466 if (options.platform === "win") {2990 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 });
2468 }2997 }
24692998
2470 const stageEntries = yield* fs.readDirectory(stageDistDir);2999 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";...@@ -7,11 +7,12 @@ import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";7import * as Path from "effect/Path";
8import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
99
10import serverPackageJson from "../../apps/server/package.json" with { type: "json" };
11
10import {12import {
11 CLI_EXTERNAL_PACKAGE_PREFIXES,
12 CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS,
13 CLI_RUNTIME_EXTERNAL_PREFIXES,13 CLI_RUNTIME_EXTERNAL_PREFIXES,
14 findInlinedExternalPackages,14 findInlinedExternalPackages,
15 selectCliRuntimeExternalDependencies,
15 shouldBundleCliDependency,16 shouldBundleCliDependency,
16} from "./cli-external-packages.ts";17} from "./cli-external-packages.ts";
1718
...@@ -60,39 +61,41 @@ describe("shouldBundleCliDependency", () => {...@@ -60,39 +61,41 @@ describe("shouldBundleCliDependency", () => {
60 });61 });
6162
62 // The real package is `node-gyp-build-optional-packages`, reached by prefix.63 // 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 the64 // It is transitive to a selected dependency root, so the runtime closure test
64 // Windows primary (which reads app.asar) and breaks only under WSL.65 // below ensures it follows that root into the sidecar.
65 it("treats prefix-matched siblings as external", () => {66 it("treats prefix-matched siblings as external", () => {
66 assert.strictEqual(shouldBundleCliDependency("node-gyp-build-optional-packages"), false);67 assert.strictEqual(shouldBundleCliDependency("node-gyp-build-optional-packages"), false);
67 });68 });
68});69});
6970
70describe("CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS", () => {71describe("selectCliRuntimeExternalDependencies", () => {
71 it("unpacks every external prefix from both the top level and the pnpm store", () => {72 it("keeps only runtime-external dependency roots for the Windows sidecar", () => {
72 for (const prefix of CLI_EXTERNAL_PACKAGE_PREFIXES) {73 assert.deepStrictEqual(
73 assert.include(CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, `node_modules/${prefix}*/**/*`, prefix);74 selectCliRuntimeExternalDependencies({
74 assert.include(75 "@effect/platform-bun": "1.0.0",
75 CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS,76 "@ff-labs/fff-node": "2.0.0",
76 `node_modules/.pnpm/**/node_modules/${prefix}*/**/*`,77 effect: "3.0.0",
77 prefix,78 "node-pty": "4.0.0",
78 );79 }),
79 }80 {
81 "@ff-labs/fff-node": "2.0.0",
82 "node-pty": "4.0.0",
83 },
84 );
80 });85 });
8186
82 // Without the trailing `*` the globs stop covering prefix-matched siblings,87 it("selects every external root declared by the server", () => {
83 // which is exactly how a package ends up external but not unpacked.88 assert.deepStrictEqual(
84 it("keeps the trailing wildcard that matches prefix siblings", () => {89 Object.keys(selectCliRuntimeExternalDependencies(serverPackageJson.dependencies)).sort(),
85 assert.include(CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, "node_modules/node-gyp-build*/**/*");90 ["@ff-labs/fff-node", "msgpackr-extract", "node-pty"],
91 );
86 });92 });
87});93});
8894
89// The failure this guards is invisible on Windows and fatal under WSL.
90//
91// An external package is loaded from the real filesystem, so its own `require`95// An external package is loaded from the real filesystem, so its own `require`
92// also resolves from the real filesystem. If one of its dependencies was96// also resolves from the real filesystem. If one of its dependencies was
93// bundled away instead of left external, that dependency exists only inside97// bundled away instead of left external, that dependency does not follow the
94// app.asar — which the Windows primary reads transparently under98// selected root into the sidecar.
95// ELECTRON_RUN_AS_NODE, and plain `node` under WSL cannot.
96//99//
97// Found the hard way: node-gyp-build-optional-packages requires detect-libc,100// Found the hard way: node-gyp-build-optional-packages requires detect-libc,
98// which was bundled. Windows was fine; WSL got MODULE_NOT_FOUND.101// which was bundled. Windows was fine; WSL got MODULE_NOT_FOUND.
...@@ -103,8 +106,8 @@ it.layer(NodeServices.layer)("external package dependency closure", (it) => {...@@ -103,8 +106,8 @@ it.layer(NodeServices.layer)("external package dependency closure", (it) => {
103 // by name from this file at all, and an `exports` map can refuse the106 // by name from this file at all, and an `exports` map can refuse the
104 // `/package.json` subpath outright (@ff-labs/fff-node). Both surface as "not107 // `/package.json` subpath outright (@ff-labs/fff-node). Both surface as "not
105 // installed", which would let this test skip everything and pass while108 // installed", which would let this test skip everything and pass while
106 // checking nothing. The store is also what asarUnpack globs target, so this109 // checking nothing. The store contains the dependency graph the sidecar's
107 // reads the same tree the build packages.110 // minimal production install resolves.
108 const readInstalledPackages = Effect.gen(function* () {111 const readInstalledPackages = Effect.gen(function* () {
109 const fileSystem = yield* FileSystem.FileSystem;112 const fileSystem = yield* FileSystem.FileSystem;
110 const path = yield* Path.Path;113 const path = yield* Path.Path;
scripts/lib/cli-external-packages.ts+21-25
...@@ -4,14 +4,12 @@...@@ -4,14 +4,12 @@
4 * Two consumers derive from this list, and they must never disagree:4 * Two consumers derive from this list, and they must never disagree:
5 *5 *
6 * - apps/server/vite.config.ts decides what stays external to the bundle.6 * - 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.
8 *9 *
9 * A package that is external but not unpacked still resolves on the Windows10 * A runtime package that is external but absent from the sidecar fails as soon
10 * primary, which runs under ELECTRON_RUN_AS_NODE and reads app.asar11 * as Node resolves it from the emitted bundle. Keeping both consumers on one
11 * transparently. It fails only under WSL, where the backend is launched as plain12 * list prevents packaging from drifting away from the bundle boundary.
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.
15 *13 *
16 * Entries are matched as prefixes (`id.startsWith(prefix)`), so they also cover14 * Entries are matched as prefixes (`id.startsWith(prefix)`), so they also cover
17 * a package's platform-specific siblings — `node-gyp-build` covers15 * a package's platform-specific siblings — `node-gyp-build` covers
...@@ -24,8 +22,8 @@...@@ -24,8 +22,8 @@
24 * critically — the ordinary JS packages those wrappers require. An external22 * critically — the ordinary JS packages those wrappers require. An external
25 * package is loaded from the real filesystem, so its own `require` also23 * package is loaded from the real filesystem, so its own `require` also
26 * resolves from the real filesystem; a dependency that was bundled away exists24 * 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 a25 * only inside the emitted bundle and is unreachable there. This closure is
28 * test, not by inspection.26 * enforced by a test, not by inspection.
29 */27 */
30export const CLI_RUNTIME_EXTERNAL_PREFIXES = [28export const CLI_RUNTIME_EXTERNAL_PREFIXES = [
31 "node-pty",29 "node-pty",
...@@ -70,6 +68,10 @@ export const CLI_EXTERNAL_PACKAGE_PREFIXES = [...@@ -70,6 +68,10 @@ export const CLI_EXTERNAL_PACKAGE_PREFIXES = [
70 ...CLI_BUILD_ONLY_EXTERNAL_PREFIXES,68 ...CLI_BUILD_ONLY_EXTERNAL_PREFIXES,
71] as const;69] as const;
7270
71export function isRuntimeExternalCliDependency(id: string): boolean {
72 return CLI_RUNTIME_EXTERNAL_PREFIXES.some((prefix) => id.startsWith(prefix));
73}
74
73/**75/**
74 * True when `id` must stay out of the bundle.76 * True when `id` must stay out of the bundle.
75 *77 *
...@@ -90,20 +92,14 @@ export function shouldBundleCliDependency(id: string): boolean {...@@ -90,20 +92,14 @@ export function shouldBundleCliDependency(id: string): boolean {
90 return !isExternalCliDependency(id);92 return !isExternalCliDependency(id);
91}93}
9294
93/**95/** Select direct dependency roots whose runtime closure belongs in the sidecar. */
94 * asar-unpack globs covering every external package.96export function selectCliRuntimeExternalDependencies(
95 *97 dependencies: Readonly<Record<string, string>>,
96 * The trailing `*` is what keeps these aligned with the prefix matching above:98): Record<string, string> {
97 * without it, `node-gyp-build` would be left external by the bundler and then99 return Object.fromEntries(
98 * not unpacked, because the real package is `node-gyp-build-optional-packages`.100 Object.entries(dependencies).filter(([name]) => isRuntimeExternalCliDependency(name)),
99 *101 );
100 * pnpm stores real files under `.pnpm` and symlinks the top-level names, so both102}
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);
107103
108/**104/**
109 * Scan an emitted bundle chunk for runtime-external packages that were inlined.105 * 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...@@ -122,8 +118,8 @@ export const CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS = CLI_EXTERNAL_PACKAGE_PREFIXES.f
122 * check the opposite direction too. Verifying only that externals are absent118 * check the opposite direction too. Verifying only that externals are absent
123 * would still pass if the bundler reverted to leaving everything external: the119 * would still pass if the bundler reverted to leaving everything external: the
124 * scan would see source-file regions, report nothing inlined, and the packaged120 * 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 packages121 * backends would then fail with ERR_MODULE_NOT_FOUND because those packages
126 * are not in the unpack globs either.122 * are not in the selected sidecar closure either.
127 */123 */
128export function findInlinedExternalPackages(source: string): {124export function findInlinedExternalPackages(source: string): {
129 readonly regionCount: number;125 readonly regionCount: number;
scripts/package.json+1
...@@ -8,6 +8,7 @@...@@ -8,6 +8,7 @@
8 },8 },
9 "dependencies": {9 "dependencies": {
10 "@effect/platform-node": "catalog:",10 "@effect/platform-node": "catalog:",
11 "@electron/asar": "^3.4.1",
11 "@t3tools/contracts": "workspace:*",12 "@t3tools/contracts": "workspace:*",
12 "@t3tools/shared": "workspace:*",13 "@t3tools/shared": "workspace:*",
13 "@t3tools/tailscale": "workspace:*",14 "@t3tools/tailscale": "workspace:*",