| author | |
| committer | |
| log | c9063f03ea1c16e0239e1996a9b6ef611679995d |
| tree | c8b2eb5ba5f8b223a3c5f89c8fa7897e79682534 |
| parent | 1add47b322ab1dfb5010bb363613650176b88088 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | ); |
| 100 | 101 | ||
| 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.isPackaged | 216 | 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"; |
| 17 | import * as DesktopServerExposure from "./DesktopServerExposure.ts"; | 17 | import * as DesktopServerExposure from "./DesktopServerExposure.ts"; |
| 18 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | 18 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 19 | import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; | 19 | import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; |
| 20 | import * as DesktopWslServerTree from "../wsl/DesktopWslServerTree.ts"; | ||
| 20 | 21 | ||
| 21 | const PersistedServerObservabilitySettingsDocument = Schema.Struct({ | 22 | const 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 | ); |
| 155 | 157 | ||
| 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, ""); |
| 179 | 222 | ||
| ... | @@ -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, ""); |
| 240 | 284 | ||
| ... | @@ -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"; |
| 19 | import * as DesktopServerExposure from "./DesktopServerExposure.ts"; | 19 | import * as DesktopServerExposure from "./DesktopServerExposure.ts"; |
| 20 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | 20 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 21 | import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; | 21 | import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; |
| 22 | import * as DesktopWslServerTree from "../wsl/DesktopWslServerTree.ts"; | ||
| 22 | 23 | ||
| 23 | export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedErrorClass<DesktopBackendObservabilitySettingsReadError>()( | 24 | export 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.DesktopEnvironment | 426 | | DesktopEnvironment.DesktopEnvironment |
| 426 | | DesktopWslEnvironment.DesktopWslEnvironment | 427 | | DesktopWslEnvironment.DesktopWslEnvironment |
| 428 | | DesktopWslServerTree.DesktopWslServerTree | ||
| 427 | | FileSystem.FileSystem | 429 | | 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; | ||
| 431 | 434 | ||
| 432 | // Bind to 0.0.0.0 inside WSL so the backend is reachable both via | 435 | // 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.1 | 436 | // 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 | }; |
| 466 | 469 | ||
| 467 | // In packaged builds environment.appRoot is .../resources/app.asar — an | 470 | // In packaged builds the server tree ships inside resources/server.asar — |
| 468 | // archive FILE. The Windows primary reads its entry through | 471 | // an archive FILE the Windows primary reads through ELECTRON_RUN_AS_NODE |
| 469 | // ELECTRON_RUN_AS_NODE (asar-aware), but the WSL backend launches plain | 472 | // (asar-aware). The WSL backend launches plain `wsl.exe -- node`, which |
| 470 | // `wsl.exe -- node`, which can't read inside an asar. electron-builder unpacks | 473 | // 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 is | 475 | // 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.isPackaged | 477 | 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"); |
| 478 | 479 | ||
| 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 and | 484 | windowsRepoRoot: wslAppRoot, |
| 484 | // attached to the Windows artifact — see build-desktop-artifact.ts), so the | 485 | // 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 shipped | 487 | // WSL backend never needs a compiler, node-gyp, or network on first launch. |
| 487 | // prebuilt, and developers have the toolchain. In packaged builds we instead | 488 | // Compiling from source is a dev-only convenience: a checkout has no shipped |
| 488 | // surface a clear diagnostic if the prebuilt can't load (unsupported | 489 | // 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); | ||
| 492 | 495 | ||
| 493 | // Every operation after preflight uses the same concrete distro. In | 496 | // Every operation after preflight uses the same concrete distro. In |
| 494 | // default-tracking mode this closes the race where the system default | 497 | // 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"; |
| 62 | import * as DesktopWindow from "./window/DesktopWindow.ts"; | 62 | import * as DesktopWindow from "./window/DesktopWindow.ts"; |
| 63 | import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts"; | 63 | import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts"; |
| 64 | import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts"; | 64 | import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts"; |
| 65 | import * as DesktopWslServerTree from "./wsl/DesktopWslServerTree.ts"; | ||
| 65 | 66 | ||
| 66 | const desktopEnvironmentLayer = Layer.unwrap( | 67 | const 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 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, describe, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Path from "effect/Path"; | ||
| 7 | import * as PlatformError from "effect/PlatformError"; | ||
| 8 | import * as Ref from "effect/Ref"; | ||
| 9 | import * as Scope from "effect/Scope"; | ||
| 10 | |||
| 11 | import * as DesktopConfig from "../app/DesktopConfig.ts"; | ||
| 12 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | ||
| 13 | import * 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 | |||
| 19 | const 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 | |||
| 47 | const 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 | |||
| 62 | const 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 | |||
| 75 | describe("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 @@ | ||
| 1 | import * as Context from "effect/Context"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as FileSystem from "effect/FileSystem"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as PlatformError from "effect/PlatformError"; | ||
| 6 | import * as Schema from "effect/Schema"; | ||
| 7 | import * as Semaphore from "effect/Semaphore"; | ||
| 8 | |||
| 9 | import * 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 | |||
| 24 | export type WslServerTreeResult = | ||
| 25 | | { readonly ok: true; readonly root: string } | ||
| 26 | | { readonly ok: false; readonly reason: string; readonly fatal: boolean }; | ||
| 27 | |||
| 28 | const MARKER_FILE_NAME = "t3code-wsl-server-tree.json"; | ||
| 29 | const COPY_CONCURRENCY = 8; | ||
| 30 | |||
| 31 | const Marker = Schema.Struct({ version: Schema.String }); | ||
| 32 | const decodeMarker = Schema.decodeUnknownEffect(Schema.fromJsonString(Marker)); | ||
| 33 | const encodeMarker = Schema.encodeEffect(Schema.fromJsonString(Marker)); | ||
| 34 | |||
| 35 | export 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 | |||
| 47 | export 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. | ||
| 62 | export 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 | |||
| 79 | interface 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. | ||
| 87 | const 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 | |||
| 116 | export 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 | |||
| 211 | export const layer = Layer.effect(DesktopWslServerTree, make); | ||
| 212 | |||
| 213 | export interface DesktopWslServerTreeTestStub { | ||
| 214 | readonly result?: WslServerTreeResult; | ||
| 215 | } | ||
| 216 | |||
| 217 | export 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. |
| 216 | 216 | ||
| 217 | ### Windows payload topology and update validation | ||
| 218 | |||
| 219 | Windows packages the bundled server and only its runtime-external/native | ||
| 220 | dependency closure in `resources/server.asar`. Native modules and helper | ||
| 221 | executables declared as unpacked by that archive must be present at the matching | ||
| 222 | paths below `resources/server.asar.unpacked`. The Windows-native backend reads | ||
| 223 | the archive in place through Electron. WSL cannot read ASAR files, so enabling | ||
| 224 | the WSL backend extracts the server tree once into the desktop state directory | ||
| 225 | under `wsl-server-tree/<version>` and reuses the completed version until the app | ||
| 226 | is updated. | ||
| 227 | |||
| 228 | The artifact builder rejects a Windows package when any of these invariants | ||
| 229 | break: | ||
| 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 | |||
| 240 | Cross-architecture Windows builds retain every structural and extracted-sidecar | ||
| 241 | check, but skip executing the target Electron binary. A same-architecture build | ||
| 242 | for each release target must exercise the primary native-load probe. | ||
| 243 | |||
| 244 | NSIS differential packaging remains enabled. A sidecar layout transition can | ||
| 245 | produce a larger one-time download; subsequent small releases retain their | ||
| 246 | blockmaps, 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) |
| 218 | 249 | ||
| 219 | The workflow invokes `node apps/server/scripts/cli.ts publish` after aligning package versions. That | 250 | The 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': 72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1 | 75 | '@clerk/expo@4.2.0': 72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1 |
| 76 | '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b | 76 | '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b |
| 77 | '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 | 77 | '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 |
| 78 | '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 | 78 | '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368 |
| 79 | '@legendapp/list@3.3.5': 6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5 | 79 | '@legendapp/list@3.3.5': 6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5 |
| 80 | '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa | 80 | '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa |
| 81 | '@react-native-menu/menu@2.0.0': c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045 | 81 | '@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.4 | 465 | 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.15 | 468 | specifier: ^1.3.15 |
| 469 | version: 1.15.13 | 469 | version: 1.15.13 |
| ... | @@ -473,6 +473,9 @@ importers: | ... | @@ -473,6 +473,9 @@ importers: |
| 473 | effect: | 473 | effect: |
| 474 | specifier: 4.0.0-beta.103 | 474 | 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.0 | 480 | specifier: ^1.1.0 |
| 478 | version: 1.1.0 | 481 | 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.103 | 917 | 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/contracts | 924 | 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: true | 12796 | optional: true |
| 12791 | 12797 | ||
| 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.2 | 12800 | 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.4 | 19149 | '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 |
| 19144 | '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 | 19150 | '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 |
| 19145 | '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 | 19151 | '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 |
| 19146 | optional: true | ||
| 19147 | 19152 | ||
| 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.2 | 19244 | detect-libc: 2.1.2 |
| 19240 | optional: true | ||
| 19241 | 19245 | ||
| 19242 | node-gyp-build@4.8.4: | 19246 | node-gyp-build@4.8.4: |
| 19243 | optional: true | 19247 | 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"; |
| 2 | import { assert, it } from "@effect/vitest"; | 2 | import { assert, it } from "@effect/vitest"; |
| 3 | import * as ConfigProvider from "effect/ConfigProvider"; | 3 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 4 | import * as FileSystem from "effect/FileSystem"; | 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Path from "effect/Path"; | ||
| 6 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 7 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| 8 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| 8 | import * as Path from "effect/Path"; | ||
| 9 | import * as Sink from "effect/Sink"; | 9 | import * as Sink from "effect/Sink"; |
| 10 | import * as Stream from "effect/Stream"; | 10 | import * as Stream from "effect/Stream"; |
| 11 | import { ChildProcessSpawner } from "effect/unstable/process"; | 11 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 12 | 12 | ||
| 13 | import { | 13 | import { |
| 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"; |
| 51 | import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; | 61 | import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; |
| 52 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 62 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| ... | @@ -88,6 +98,54 @@ function iconResizeSpawnerLayer( | ... | @@ -88,6 +98,54 @@ function iconResizeSpawnerLayer( |
| 88 | ); | 98 | ); |
| 89 | } | 99 | } |
| 90 | 100 | ||
| 101 | const 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 | |||
| 91 | it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | 149 | it.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 the | 293 | // 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 | }); |
| 322 | 408 | ||
| ... | @@ -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 | ); |
| 352 | 438 | ||
| 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 .desktop | 466 | // 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 | ); |
| 367 | 477 | ||
| 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> }> = []; |
| 370 | 749 | ||
| ... | @@ -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 | }); |
| 774 | 1153 | ||
| 775 | // The self-containment check runs the packaged tree in a scratch directory. Its | 1154 | // The self-containment check runs the packaged tree in a scratch directory. Its |
| 776 | // own node_modules holds the unpacked externals and must be ignored, but any | 1155 | // own node_modules holds the sidecar externals and must be ignored, but any |
| 777 | // node_modules *above* it would let Node's parent walk satisfy an import that is | 1156 | // 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. |
| 779 | it("lists ancestor node_modules, nearest first, excluding the start directory", () => { | 1158 | it("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 @@ |
| 4 | import * as NodeFSP from "node:fs/promises"; | 4 | import * as NodeFSP from "node:fs/promises"; |
| 5 | import * as NodeModule from "node:module"; | 5 | import * as NodeModule from "node:module"; |
| 6 | 6 | ||
| 7 | import { | ||
| 8 | createPackageWithOptions, | ||
| 9 | extractAll, | ||
| 10 | getRawHeader, | ||
| 11 | statFile, | ||
| 12 | type DirectoryRecord, | ||
| 13 | } from "@electron/asar"; | ||
| 14 | |||
| 7 | import { fromYaml } from "@t3tools/shared/schemaYaml"; | 15 | import { fromYaml } from "@t3tools/shared/schemaYaml"; |
| 8 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 16 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 9 | import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; | 17 | import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; |
| 10 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | 18 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 11 | import rootPackageJson from "../package.json" with { type: "json" }; | 19 | import 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"; |
| 21 | import { getDefaultBuildArch } from "./lib/build-target-arch.ts"; | 29 | import { getDefaultBuildArch } from "./lib/build-target-arch.ts"; |
| 22 | import { | 30 | import { |
| 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"; |
| 26 | import { loadRepoEnv } from "./lib/public-config.ts"; | 34 | import { loadRepoEnv } from "./lib/public-config.ts"; |
| 27 | import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; | 35 | import { 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 | }); |
| 73 | type StageWorkspaceConfig = typeof StageWorkspaceConfig.Type; | 82 | type StageWorkspaceConfig = typeof StageWorkspaceConfig.Type; |
| 74 | 83 | ||
| ... | @@ -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 bundled | 396 | * Imported by every server module, so it is inlined in any correctly bundled |
| 388 | * build. Its absence means the bundle went back to externalizing its | 397 | * 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 | */ |
| 391 | const BUNDLE_SELF_CONTAINED_SENTINEL = "effect"; | 400 | const BUNDLE_SELF_CONTAINED_SENTINEL = "effect"; |
| 392 | 401 | ||
| 393 | const BUNDLE_SELF_CHECK_TIMEOUT = Duration.seconds(120); | 402 | const BUNDLE_SELF_CHECK_TIMEOUT = Duration.seconds(120); |
| 403 | const WINDOWS_PRIMARY_NATIVE_PROBE_TIMEOUT = Duration.seconds(30); | ||
| 404 | |||
| 405 | const WINDOWS_PRIMARY_FFF_PROBE_SOURCE = ` | ||
| 406 | const { join } = await import("node:path"); | ||
| 407 | const { pathToFileURL } = await import("node:url"); | ||
| 408 | const { FileFinder } = await import(pathToFileURL(process.argv[1]).href); | ||
| 409 | const probeRoot = process.argv[2]; | ||
| 410 | const 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 | }); | ||
| 418 | if (!result.ok) throw new Error(result.error); | ||
| 419 | result.value.destroy(); | ||
| 420 | `; | ||
| 394 | 421 | ||
| 395 | export class ExternalizedBundleError extends Schema.TaggedErrorClass<ExternalizedBundleError>()( | 422 | export 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 | } |
| 403 | 430 | ||
| ... | @@ -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 | } |
| 422 | 449 | ||
| ... | @@ -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 | } |
| 492 | 519 | ||
| 520 | export 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 | |||
| 532 | export 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 | |||
| 545 | const 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 | |||
| 554 | export 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 | |||
| 493 | export class WslNodePtyManifestReadError extends Schema.TaggedErrorClass<WslNodePtyManifestReadError>()( | 585 | export 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 cannot | 787 | // 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 real | 788 | // resources/server.asar sidecar instead of loose files: the NSIS installer |
| 692 | // filesystem. This used to unpack `**\/node_modules\/**` wholesale, because the | 789 | // 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 with | 790 | // files, which dominates install (and update) time. The Windows primary runs |
| 694 | // ERR_MODULE_NOT_FOUND ("Cannot find package 'effect'") before it even reached | 791 | // 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 still | 794 | export const WINDOWS_SERVER_ASAR_RESOURCE = "server.asar"; |
| 698 | // have to be loose are the server bundle itself and the packages the bundle | 795 | // dlopen/spawn need real files, so native modules, shared libraries, and |
| 699 | // leaves external — derived from the same list the bundler uses, so the two | 796 | // helper executables live in the server.asar.unpacked sibling (the standard |
| 700 | // cannot drift apart. | 797 | // asar redirect convention). Everything else stays packed. |
| 701 | export const WINDOWS_ASAR_UNPACK = [ | 798 | export 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). | ||
| 804 | export 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; | ||
| 810 | export const WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT = 80; | ||
| 811 | export const WINDOWS_SERVER_RESOURCE_SOURCE_DIR = "apps/desktop/prod-resources/windows-server"; | ||
| 812 | export 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; |
| 705 | export const DESKTOP_EXTRA_RESOURCES = [ | 823 | export 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 Node | 1151 | // 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 those | 1153 | // 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 | } |
| 1057 | 1182 | ||
| ... | @@ -1411,36 +1536,27 @@ export const copyDirectoryPreservingSymlinks = Effect.fn("copyDirectoryPreservin | ... | @@ -1411,36 +1536,27 @@ export const copyDirectoryPreservingSymlinks = Effect.fn("copyDirectoryPreservin |
| 1411 | ); | 1536 | ); |
| 1412 | 1537 | ||
| 1413 | const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSelfContained")( | 1538 | const 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; |
| 1417 | 1542 | ||
| 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); | ||
| 1444 | 1560 | ||
| 1445 | // Guard the guard: if anything above the probe provides a node_modules, a | 1561 | // 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 the | 1562 | // 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 disk | 1582 | // missing dependency shows up, without starting a server or touching disk |
| 1467 | // state. It does not cover lazily imported externals: node-pty is checked | 1583 | // 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-node | 1584 | // by the WSL preflight probe at runtime, while ffi-rs, @ff-labs/fff-node |
| 1469 | // and the bun adapters are only covered by the unpack globs and the | 1585 | // 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 (on | 1610 | // Printing a version should be immediate. A regression that blocks (on |
| 1492 | // stdin, a port, a lock) would otherwise hang release CI until the job | 1611 | // 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 (see | 2000 | // All platforms keep app.asar fully packed; electron-builder's default |
| 1882 | // WINDOWS_ASAR_UNPACK); macOS and Linux stay packed — smart unpack | 2001 | // 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* ( |
| 1942 | 2064 | ||
| 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 | }); |
| 2052 | 2178 | ||
| 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. | ||
| 2185 | export 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 | |||
| 2208 | export 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 | |||
| 2296 | function 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 | |||
| 2312 | const 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 | |||
| 2336 | export 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 | |||
| 2420 | export 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 | |||
| 2053 | const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( | 2554 | const 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 this | 2693 | // since source-file regions still exist -- and that is the failure this |
| 2190 | // whole change exists to prevent, because those packages are not in the | 2694 | // 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 any | 2696 | // `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 to | 2698 | // 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)); |
| 2228 | 2732 | ||
| 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 | } | ||
| 2231 | 2737 | ||
| 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 | ); |
| 2254 | 2764 | ||
| 2255 | // electron-builder is filtering out stageResourcesDir directory in the AppImage for production | 2765 | // 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); | ||
| 2257 | 2768 | ||
| 2258 | const configuredMacPasskeySigning = | 2769 | const configuredMacPasskeySigning = |
| 2259 | options.platform === "mac" && options.signed | 2770 | 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 | } |
| 2285 | 2796 | ||
| 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 the | 2805 | : { |
| 2295 | // fff native binary through ffi-rs. The platform fff binary above is the | 2806 | ...resolvedServerDependencies, |
| 2296 | // host's (win32), so promote the matching Linux fff binaries too; without | 2807 | ...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); |
| 2369 | 2881 | ||
| 2370 | // WSL is Windows-only, so only the Windows artifact carries the Linux backend | 2882 | // 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 | } |
| 2379 | 2902 | ||
| ... | @@ -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 inside | 2987 | // 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 | } |
| 2469 | 2998 | ||
| 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"; |
| 7 | import * as Path from "effect/Path"; | 7 | import * as Path from "effect/Path"; |
| 8 | import * as Schema from "effect/Schema"; | 8 | import * as Schema from "effect/Schema"; |
| 9 | 9 | ||
| 10 | import serverPackageJson from "../../apps/server/package.json" with { type: "json" }; | ||
| 11 | |||
| 10 | import { | 12 | import { |
| 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"; |
| 17 | 18 | ||
| ... | @@ -60,39 +61,41 @@ describe("shouldBundleCliDependency", () => { | ... | @@ -60,39 +61,41 @@ describe("shouldBundleCliDependency", () => { |
| 60 | }); | 61 | }); |
| 61 | 62 | ||
| 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 the | 64 | // 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 | }); |
| 69 | 70 | ||
| 70 | describe("CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS", () => { | 71 | describe("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 | }); |
| 81 | 86 | ||
| 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 | }); |
| 88 | 94 | ||
| 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 was | 96 | // also resolves from the real filesystem. If one of its dependencies was |
| 93 | // bundled away instead of left external, that dependency exists only inside | 97 | // bundled away instead of left external, that dependency does not follow the |
| 94 | // app.asar — which the Windows primary reads transparently under | 98 | // 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 the | 106 | // 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 "not | 107 | // `/package.json` subpath outright (@ff-labs/fff-node). Both surface as "not |
| 105 | // installed", which would let this test skip everything and pass while | 108 | // installed", which would let this test skip everything and pass while |
| 106 | // checking nothing. The store is also what asarUnpack globs target, so this | 109 | // 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 Windows | 10 | * 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.asar | 11 | * 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 plain | 12 | * 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 cover | 14 | * Entries are matched as prefixes (`id.startsWith(prefix)`), so they also cover |
| 17 | * a package's platform-specific siblings — `node-gyp-build` covers | 15 | * 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 external | 22 | * critically — the ordinary JS packages those wrappers require. An external |
| 25 | * package is loaded from the real filesystem, so its own `require` also | 23 | * package is loaded from the real filesystem, so its own `require` also |
| 26 | * resolves from the real filesystem; a dependency that was bundled away exists | 24 | * resolves from the real filesystem; a dependency that was bundled away exists |
| 27 | * only inside app.asar and is unreachable there. This closure is enforced by a | 25 | * 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 | */ |
| 30 | export const CLI_RUNTIME_EXTERNAL_PREFIXES = [ | 28 | export 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; |
| 72 | 70 | ||
| 71 | export 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 | } |
| 92 | 94 | ||
| 93 | /** | 95 | /** Select direct dependency roots whose runtime closure belongs in the sidecar. */ |
| 94 | * asar-unpack globs covering every external package. | 96 | export 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 then | 99 | 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 both | 102 | } |
| 101 | * paths are unpacked for the link target to exist on disk. | ||
| 102 | */ | ||
| 103 | export 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 | ); | ||
| 107 | 103 | ||
| 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 absent | 118 | * check the opposite direction too. Verifying only that externals are absent |
| 123 | * would still pass if the bundler reverted to leaving everything external: the | 119 | * would still pass if the bundler reverted to leaving everything external: the |
| 124 | * scan would see source-file regions, report nothing inlined, and the packaged | 120 | * scan would see source-file regions, report nothing inlined, and the packaged |
| 125 | * WSL backend would then fail with ERR_MODULE_NOT_FOUND because those packages | 121 | * 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 | */ |
| 128 | export function findInlinedExternalPackages(source: string): { | 124 | export 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:*", |