- Add an electron-builder afterPack hook to restore the Effect runtime closure - Add focused tests for hook behavior and build configuration
Unified · Split
| @@ -14,6 +14,7 @@ import { · 13 unmodified lines | |||
| 14 | createStagePatchedDependencies, | 14 | createStagePatchedDependencies, |
| 15 | createBuildConfig, | 15 | createBuildConfig, |
| 16 | DESKTOP_ASAR_UNPACK, | 16 | DESKTOP_ASAR_UNPACK, |
| 17 | DESKTOP_AFTER_PACK_HOOK_FILE, | ||
| 17 | InvalidMacPasskeyRpDomainError, | 18 | InvalidMacPasskeyRpDomainError, |
| 18 | InvalidMacPasskeyPublishableKeyError, | 19 | InvalidMacPasskeyPublishableKeyError, |
| 19 | InvalidMockUpdateServerPortError, | 20 | InvalidMockUpdateServerPortError, |
| @@ -305,6 +306,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { · 285 unmodified lines | |||
| 305 | 306 | ||
| 306 | it("unpacks the fff shared library for filesystem and FFI access", () => { | 307 | it("unpacks the fff shared library for filesystem and FFI access", () => { |
| 307 | assert.deepStrictEqual(DESKTOP_ASAR_UNPACK, ["node_modules/@ff-labs/fff-bin-*/**/*"]); | 308 | assert.deepStrictEqual(DESKTOP_ASAR_UNPACK, ["node_modules/@ff-labs/fff-bin-*/**/*"]); |
| 309 | assert.equal(DESKTOP_AFTER_PACK_HOOK_FILE, "desktop-after-pack.ts"); | ||
| 308 | }); | 310 | }); |
| 309 | 311 | ||
| 310 | it.effect("preserves both Linux icon resize failures with structural context", () => { | 312 | it.effect("preserves both Linux icon resize failures with structural context", () => { |
| @@ -489,9 +491,11 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { · 178 unmodified lines | |||
| 489 | false, | 491 | false, |
| 490 | undefined, | 492 | undefined, |
| 491 | undefined, | 493 | undefined, |
| 494 | "C:\\stage\\desktop-after-pack.ts", | ||
| 492 | ); | 495 | ); |
| 493 | 496 | ||
| 494 | const win = config.win as Record<string, unknown>; | 497 | const win = config.win as Record<string, unknown>; |
| 498 | assert.equal(config.afterPack, "C:\\stage\\desktop-after-pack.ts"); | ||
| 495 | assert.equal(win.icon, "icon.ico"); | 499 | assert.equal(win.icon, "icon.ico"); |
| 496 | assert.equal(win.signAndEditExecutable, true); | 500 | assert.equal(win.signAndEditExecutable, true); |
| 497 | assert.notProperty(win, "azureSignOptions"); | 501 | assert.notProperty(win, "azureSignOptions"); |
| @@ -580,6 +580,7 @@ interface StagePackageJson { · 579 unmodified lines | |||
| 580 | 580 | ||
| 581 | export const STAGE_INSTALL_ARGS = ["install", "--prod"] as const; | 581 | export const STAGE_INSTALL_ARGS = ["install", "--prod"] as const; |
| 582 | export const DESKTOP_ASAR_UNPACK = ["node_modules/@ff-labs/fff-bin-*/**/*"] as const; | 582 | export const DESKTOP_ASAR_UNPACK = ["node_modules/@ff-labs/fff-bin-*/**/*"] as const; |
| 583 | export const DESKTOP_AFTER_PACK_HOOK_FILE = "desktop-after-pack.ts"; | ||
| 583 | 584 | ||
| 584 | export interface MacPasskeySigningConfiguration { | 585 | export interface MacPasskeySigningConfiguration { |
| 585 | readonly appId: string; | 586 | readonly appId: string; |
| @@ -1385,6 +1386,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( · 799 unmodified lines | |||
| 1385 | readonly provisioningProfilePath: string; | 1386 | readonly provisioningProfilePath: string; |
| 1386 | } | 1387 | } |
| 1387 | | undefined, | 1388 | | undefined, |
| 1389 | afterPackHookPath?: string, | ||
| 1388 | ) { | 1390 | ) { |
| 1389 | const buildConfig: Record<string, unknown> = { | 1391 | const buildConfig: Record<string, unknown> = { |
| 1390 | appId: DESKTOP_APP_ID, | 1392 | appId: DESKTOP_APP_ID, |
| @@ -1407,6 +1409,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( · 16 unmodified lines | |||
| 1407 | // files through the asar (transparently redirected to the unpacked copy), so | 1409 | // files through the asar (transparently redirected to the unpacked copy), so |
| 1408 | // there's no duplication. | 1410 | // there's no duplication. |
| 1409 | asarUnpack: [...DESKTOP_ASAR_UNPACK, "apps/server/dist/**", "**/node_modules/**"], | 1411 | asarUnpack: [...DESKTOP_ASAR_UNPACK, "apps/server/dist/**", "**/node_modules/**"], |
| 1412 | ...(afterPackHookPath ? { afterPack: afterPackHookPath } : {}), | ||
| 1410 | }; | 1413 | }; |
| 1411 | const updateChannel = resolveDesktopUpdateChannel(version); | 1414 | const updateChannel = resolveDesktopUpdateChannel(version); |
| 1412 | const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); | 1415 | const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); |
| @@ -1776,6 +1779,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( · 363 unmodified lines | |||
| 1776 | provisioningProfilePath: macPasskeySigning.provisioningProfilePath, | 1779 | provisioningProfilePath: macPasskeySigning.provisioningProfilePath, |
| 1777 | } | 1780 | } |
| 1778 | : undefined, | 1781 | : undefined, |
| 1782 | path.join(stageAppDir, DESKTOP_AFTER_PACK_HOOK_FILE), | ||
| 1779 | ), | 1783 | ), |
| 1780 | dependencies: stageDependencies, | 1784 | dependencies: stageDependencies, |
| 1781 | devDependencies: { | 1785 | devDependencies: { |
| @@ -1784,6 +1788,10 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( · 2 unmodified lines | |||
| 1784 | }; | 1788 | }; |
| 1785 | 1789 | ||
| 1786 | const stagePackageJsonString = yield* encodeJsonString(stagePackageJson); | 1790 | const stagePackageJsonString = yield* encodeJsonString(stagePackageJson); |
| 1791 | yield* fs.copyFile( | ||
| 1792 | path.join(repoRoot, "scripts", DESKTOP_AFTER_PACK_HOOK_FILE), | ||
| 1793 | path.join(stageAppDir, DESKTOP_AFTER_PACK_HOOK_FILE), | ||
| 1794 | ); | ||
| 1787 | yield* fs.writeFileString(path.join(stageAppDir, "package.json"), `${stagePackageJsonString}\n`); | 1795 | yield* fs.writeFileString(path.join(stageAppDir, "package.json"), `${stagePackageJsonString}\n`); |
| 1788 | const stageWorkspaceConfig = createStageWorkspaceConfig({ | 1796 | const stageWorkspaceConfig = createStageWorkspaceConfig({ |
| 1789 | platform: options.platform, | 1797 | platform: options.platform, |
| @@ -0,0 +1,96 @@ | |||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - This fixture verifies the filesystem repair boundary. | ||
| 2 | import * as NodeFSP from "node:fs/promises"; | ||
| 3 | import * as NodeOS from "node:os"; | ||
| 4 | import * as NodePath from "node:path"; | ||
| 5 | |||
| 6 | import { assert } from "@effect/vitest"; | ||
| 7 | import { afterEach, describe, it } from "vite-plus/test"; | ||
| 8 | |||
| 9 | import { | ||
| 10 | repairUnpackedEffectModules, | ||
| 11 | REQUIRED_UNPACKED_EFFECT_MODULES, | ||
| 12 | } from "./desktop-after-pack.ts"; | ||
| 13 | |||
| 14 | const temporaryDirectories: string[] = []; | ||
| 15 | |||
| 16 | async function writePackage( | ||
| 17 | nodeModulesDir: string, | ||
| 18 | packageName: string, | ||
| 19 | marker: string, | ||
| 20 | ): Promise<string> { | ||
| 21 | const packageDir = NodePath.join(nodeModulesDir, ...packageName.split("/")); | ||
| 22 | await NodeFSP.mkdir(packageDir, { recursive: true }); | ||
| 23 | await NodeFSP.writeFile( | ||
| 24 | NodePath.join(packageDir, "package.json"), | ||
| 25 | `${JSON.stringify({ name: packageName, main: "index.js" })}\n`, | ||
| 26 | ); | ||
| 27 | await NodeFSP.writeFile(NodePath.join(packageDir, "index.js"), `export default ${marker};\n`); | ||
| 28 | return packageDir; | ||
| 29 | } | ||
| 30 | |||
| 31 | afterEach(async () => { | ||
| 32 | await Promise.all( | ||
| 33 | temporaryDirectories | ||
| 34 | .splice(0) | ||
| 35 | .map((directory) => NodeFSP.rm(directory, { recursive: true, force: true })), | ||
| 36 | ); | ||
| 37 | }); | ||
| 38 | |||
| 39 | describe("desktop afterPack", () => { | ||
| 40 | it("restores the Effect runtime closure when electron-builder omits unpacked payloads", async () => { | ||
| 41 | const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3code-after-pack-")); | ||
| 42 | temporaryDirectories.push(root); | ||
| 43 | const stageAppDir = NodePath.join(root, "stage"); | ||
| 44 | const appOutDir = NodePath.join(root, "win-unpacked"); | ||
| 45 | const stageNodeModules = NodePath.join(stageAppDir, "node_modules"); | ||
| 46 | |||
| 47 | await NodeFSP.mkdir(stageAppDir, { recursive: true }); | ||
| 48 | await NodeFSP.writeFile(NodePath.join(stageAppDir, "package.json"), '{"name":"t3code"}\n'); | ||
| 49 | await writePackage(stageNodeModules, "effect", '"effect"'); | ||
| 50 | const platformNodeDir = await writePackage( | ||
| 51 | stageNodeModules, | ||
| 52 | "@effect/platform-node", | ||
| 53 | '"platform-node"', | ||
| 54 | ); | ||
| 55 | const platformNodeModules = NodePath.join(platformNodeDir, "node_modules"); | ||
| 56 | await writePackage(platformNodeModules, "@effect/platform-node-shared", '"shared"'); | ||
| 57 | await writePackage(platformNodeModules, "mime", '"mime"'); | ||
| 58 | await writePackage(platformNodeModules, "undici", '"undici"'); | ||
| 59 | |||
| 60 | const staleEffectDir = NodePath.join( | ||
| 61 | appOutDir, | ||
| 62 | "resources/app.asar.unpacked/node_modules/effect", | ||
| 63 | ); | ||
| 64 | await NodeFSP.mkdir(staleEffectDir, { recursive: true }); | ||
| 65 | await NodeFSP.writeFile(NodePath.join(staleEffectDir, "stale.js"), "stale\n"); | ||
| 66 | |||
| 67 | await repairUnpackedEffectModules({ stageAppDir, appOutDir }); | ||
| 68 | |||
| 69 | const expectedMarkers: Record<(typeof REQUIRED_UNPACKED_EFFECT_MODULES)[number], string> = { | ||
| 70 | effect: "effect", | ||
| 71 | "@effect/platform-node": "platform-node", | ||
| 72 | "@effect/platform-node-shared": "shared", | ||
| 73 | mime: "mime", | ||
| 74 | undici: "undici", | ||
| 75 | }; | ||
| 76 | for (const packageName of REQUIRED_UNPACKED_EFFECT_MODULES) { | ||
| 77 | const packageDir = NodePath.join( | ||
| 78 | appOutDir, | ||
| 79 | "resources/app.asar.unpacked/node_modules", | ||
| 80 | ...packageName.split("/"), | ||
| 81 | ); | ||
| 82 | const manifest = JSON.parse( | ||
| 83 | await NodeFSP.readFile(NodePath.join(packageDir, "package.json"), "utf8"), | ||
| 84 | ) as { readonly name: string }; | ||
| 85 | assert.equal(manifest.name, packageName); | ||
| 86 | assert.equal( | ||
| 87 | await NodeFSP.readFile(NodePath.join(packageDir, "index.js"), "utf8"), | ||
| 88 | `export default "${expectedMarkers[packageName]}";\n`, | ||
| 89 | ); | ||
| 90 | } | ||
| 91 | await NodeFSP.access(NodePath.join(staleEffectDir, "stale.js")).then( | ||
| 92 | () => assert.fail("The stale unpacked payload should have been replaced."), | ||
| 93 | (error: NodeJS.ErrnoException) => assert.equal(error.code, "ENOENT"), | ||
| 94 | ); | ||
| 95 | }); | ||
| 96 | }); | ||
| @@ -0,0 +1,97 @@ | |||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - electron-builder hooks run outside an Effect runtime. | ||
| 2 | import * as NodeFSP from "node:fs/promises"; | ||
| 3 | import * as NodeModule from "node:module"; | ||
| 4 | import * as NodePath from "node:path"; | ||
| 5 | import * as NodeURL from "node:url"; | ||
| 6 | |||
| 7 | export const REQUIRED_UNPACKED_EFFECT_MODULES = [ | ||
| 8 | "effect", | ||
| 9 | "@effect/platform-node", | ||
| 10 | "@effect/platform-node-shared", | ||
| 11 | "mime", | ||
| 12 | "undici", | ||
| 13 | ] as const; | ||
| 14 | |||
| 15 | interface DesktopAfterPackContext { | ||
| 16 | readonly appOutDir: string; | ||
| 17 | } | ||
| 18 | |||
| 19 | async function findPackageRoot(resolvedEntry: string, packageName: string): Promise<string> { | ||
| 20 | let current = NodePath.dirname(resolvedEntry); | ||
| 21 | const root = NodePath.parse(current).root; | ||
| 22 | |||
| 23 | while (current !== root) { | ||
| 24 | try { | ||
| 25 | const manifest = JSON.parse( | ||
| 26 | await NodeFSP.readFile(NodePath.join(current, "package.json"), "utf8"), | ||
| 27 | ) as { readonly name?: unknown }; | ||
| 28 | if (manifest.name === packageName) { | ||
| 29 | return current; | ||
| 30 | } | ||
| 31 | } catch (error) { | ||
| 32 | const code = (error as NodeJS.ErrnoException).code; | ||
| 33 | if (code !== "ENOENT" && !(error instanceof SyntaxError)) { | ||
| 34 | throw error; | ||
| 35 | } | ||
| 36 | } | ||
| 37 | current = NodePath.dirname(current); | ||
| 38 | } | ||
| 39 | |||
| 40 | throw new Error(`Could not locate the package root for ${packageName} from ${resolvedEntry}.`); | ||
| 41 | } | ||
| 42 | |||
| 43 | async function resolvePackageRoot( | ||
| 44 | resolver: ReturnType<typeof NodeModule.createRequire>, | ||
| 45 | packageName: string, | ||
| 46 | ): Promise<string> { | ||
| 47 | return findPackageRoot(resolver.resolve(packageName), packageName); | ||
| 48 | } | ||
| 49 | |||
| 50 | export async function repairUnpackedEffectModules(input: { | ||
| 51 | readonly stageAppDir: string; | ||
| 52 | readonly appOutDir: string; | ||
| 53 | }): Promise<void> { | ||
| 54 | const stageResolver = NodeModule.createRequire(NodePath.join(input.stageAppDir, "package.json")); | ||
| 55 | const platformNodeRoot = await resolvePackageRoot(stageResolver, "@effect/platform-node"); | ||
| 56 | const platformNodeResolver = NodeModule.createRequire( | ||
| 57 | NodePath.join(platformNodeRoot, "package.json"), | ||
| 58 | ); | ||
| 59 | const unpackedNodeModules = NodePath.join( | ||
| 60 | input.appOutDir, | ||
| 61 | "resources", | ||
| 62 | "app.asar.unpacked", | ||
| 63 | "node_modules", | ||
| 64 | ); | ||
| 65 | |||
| 66 | for (const packageName of REQUIRED_UNPACKED_EFFECT_MODULES) { | ||
| 67 | const resolver = | ||
| 68 | packageName === "effect" || packageName === "@effect/platform-node" | ||
| 69 | ? stageResolver | ||
| 70 | : platformNodeResolver; | ||
| 71 | const source = await resolvePackageRoot(resolver, packageName); | ||
| 72 | const destination = NodePath.join(unpackedNodeModules, ...packageName.split("/")); | ||
| 73 | |||
| 74 | // electron-builder can mark a module as unpacked in the ASAR header but omit | ||
| 75 | // some or all of its payload on Windows. Replace this small runtime closure | ||
| 76 | // from the staged pnpm install after ASAR creation and before signing/NSIS. | ||
| 77 | await NodeFSP.rm(destination, { recursive: true, force: true }); | ||
| 78 | await NodeFSP.cp(source, destination, { | ||
| 79 | recursive: true, | ||
| 80 | dereference: true, | ||
| 81 | force: true, | ||
| 82 | preserveTimestamps: true, | ||
| 83 | }); | ||
| 84 | |||
| 85 | const copiedManifest = JSON.parse( | ||
| 86 | await NodeFSP.readFile(NodePath.join(destination, "package.json"), "utf8"), | ||
| 87 | ) as { readonly name?: unknown }; | ||
| 88 | if (copiedManifest.name !== packageName) { | ||
| 89 | throw new Error(`Packaged runtime module verification failed for ${packageName}.`); | ||
| 90 | } | ||
| 91 | } | ||
| 92 | } | ||
| 93 | |||
| 94 | export async function afterPack(context: DesktopAfterPackContext): Promise<void> { | ||
| 95 | const stageAppDir = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); | ||
| 96 | await repairUnpackedEffectModules({ stageAppDir, appOutDir: context.appOutDir }); | ||
| 97 | } | ||