- Add an electron-builder afterPack hook to restore the Effect runtime closure - Add focused tests for hook behavior and build configuration
Showing the changes to scripts (4 of 4 files). Show all
Unified · Split
| @@ -14,6 +14,7 @@ import { · 13 unmodified lines | ||
| 14 | 14 | createStagePatchedDependencies, |
| 15 | 15 | createBuildConfig, |
| 16 | 16 | DESKTOP_ASAR_UNPACK, |
| 17 | + DESKTOP_AFTER_PACK_HOOK_FILE, | |
| 17 | 18 | InvalidMacPasskeyRpDomainError, |
| 18 | 19 | InvalidMacPasskeyPublishableKeyError, |
| 19 | 20 | InvalidMockUpdateServerPortError, |
| @@ -305,6 +306,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { · 285 unmodified lines | ||
| 305 | 306 | |
| 306 | 307 | it("unpacks the fff shared library for filesystem and FFI access", () => { |
| 307 | 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 | 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 | 491 | false, |
| 490 | 492 | undefined, |
| 491 | 493 | undefined, |
| 494 | + "C:\\stage\\desktop-after-pack.ts", | |
| 492 | 495 | ); |
| 493 | 496 | |
| 494 | 497 | const win = config.win as Record<string, unknown>; |
| 498 | + assert.equal(config.afterPack, "C:\\stage\\desktop-after-pack.ts"); | |
| 495 | 499 | assert.equal(win.icon, "icon.ico"); |
| 496 | 500 | assert.equal(win.signAndEditExecutable, true); |
| 497 | 501 | assert.notProperty(win, "azureSignOptions"); |
| @@ -580,6 +580,7 @@ interface StagePackageJson { · 579 unmodified lines | ||
| 580 | 580 | |
| 581 | 581 | export const STAGE_INSTALL_ARGS = ["install", "--prod"] as const; |
| 582 | 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 | 585 | export interface MacPasskeySigningConfiguration { |
| 585 | 586 | readonly appId: string; |
| @@ -1385,6 +1386,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( · 799 unmodified lines | ||
| 1385 | 1386 | readonly provisioningProfilePath: string; |
| 1386 | 1387 | } |
| 1387 | 1388 | | undefined, |
| 1389 | + afterPackHookPath?: string, | |
| 1388 | 1390 | ) { |
| 1389 | 1391 | const buildConfig: Record<string, unknown> = { |
| 1390 | 1392 | appId: DESKTOP_APP_ID, |
| @@ -1407,6 +1409,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( · 16 unmodified lines | ||
| 1407 | 1409 | // files through the asar (transparently redirected to the unpacked copy), so |
| 1408 | 1410 | // there's no duplication. |
| 1409 | 1411 | asarUnpack: [...DESKTOP_ASAR_UNPACK, "apps/server/dist/**", "**/node_modules/**"], |
| 1412 | + ...(afterPackHookPath ? { afterPack: afterPackHookPath } : {}), | |
| 1410 | 1413 | }; |
| 1411 | 1414 | const updateChannel = resolveDesktopUpdateChannel(version); |
| 1412 | 1415 | const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); |
| @@ -1776,6 +1779,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( · 363 unmodified lines | ||
| 1776 | 1779 | provisioningProfilePath: macPasskeySigning.provisioningProfilePath, |
| 1777 | 1780 | } |
| 1778 | 1781 | : undefined, |
| 1782 | + path.join(stageAppDir, DESKTOP_AFTER_PACK_HOOK_FILE), | |
| 1779 | 1783 | ), |
| 1780 | 1784 | dependencies: stageDependencies, |
| 1781 | 1785 | devDependencies: { |
| @@ -1784,6 +1788,10 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( · 2 unmodified lines | ||
| 1784 | 1788 | }; |
| 1785 | 1789 | |
| 1786 | 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 | 1795 | yield* fs.writeFileString(path.join(stageAppDir, "package.json"), `${stagePackageJsonString}\n`); |
| 1788 | 1796 | const stageWorkspaceConfig = createStageWorkspaceConfig({ |
| 1789 | 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 | +} | |