History

Repair missing unpacked Effect modules after desktop packaging

- Add an electron-builder afterPack hook to restore the Effect runtime closure - Add focused tests for hook behavior and build configuration

Julius Marminge committed 3 months ago

Commit 28926991 parent 5d34f9f4 files · +205 −0Browse files

Unified · Split

4 files
  1. scripts/build-desktop-artifact.test.ts +4 −0
  2. scripts/build-desktop-artifact.ts +8 −0
  3. scripts/desktop-after-pack.test.ts +96 −0
  4. scripts/desktop-after-pack.ts +97 −0
scripts/build-desktop-artifact.test.ts+4−0
@@ -14,6 +14,7 @@ import { · 13 unmodified lines
1414 createStagePatchedDependencies,
1515 createBuildConfig,
1616 DESKTOP_ASAR_UNPACK,
17+ DESKTOP_AFTER_PACK_HOOK_FILE,
1718 InvalidMacPasskeyRpDomainError,
1819 InvalidMacPasskeyPublishableKeyError,
1920 InvalidMockUpdateServerPortError,
@@ -305,6 +306,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { · 285 unmodified lines
305306
306307 it("unpacks the fff shared library for filesystem and FFI access", () => {
307308 assert.deepStrictEqual(DESKTOP_ASAR_UNPACK, ["node_modules/@ff-labs/fff-bin-*/**/*"]);
309+ assert.equal(DESKTOP_AFTER_PACK_HOOK_FILE, "desktop-after-pack.ts");
308310 });
309311
310312 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
489491 false,
490492 undefined,
491493 undefined,
494+ "C:\\stage\\desktop-after-pack.ts",
492495 );
493496
494497 const win = config.win as Record<string, unknown>;
498+ assert.equal(config.afterPack, "C:\\stage\\desktop-after-pack.ts");
495499 assert.equal(win.icon, "icon.ico");
496500 assert.equal(win.signAndEditExecutable, true);
497501 assert.notProperty(win, "azureSignOptions");
scripts/build-desktop-artifact.ts+8−0
@@ -580,6 +580,7 @@ interface StagePackageJson { · 579 unmodified lines
580580
581581 export const STAGE_INSTALL_ARGS = ["install", "--prod"] as const;
582582 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";
583584
584585 export interface MacPasskeySigningConfiguration {
585586 readonly appId: string;
@@ -1385,6 +1386,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( · 799 unmodified lines
13851386 readonly provisioningProfilePath: string;
13861387 }
13871388 | undefined,
1389+ afterPackHookPath?: string,
13881390 ) {
13891391 const buildConfig: Record<string, unknown> = {
13901392 appId: DESKTOP_APP_ID,
@@ -1407,6 +1409,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( · 16 unmodified lines
14071409 // files through the asar (transparently redirected to the unpacked copy), so
14081410 // there's no duplication.
14091411 asarUnpack: [...DESKTOP_ASAR_UNPACK, "apps/server/dist/**", "**/node_modules/**"],
1412+ ...(afterPackHookPath ? { afterPack: afterPackHookPath } : {}),
14101413 };
14111414 const updateChannel = resolveDesktopUpdateChannel(version);
14121415 const publishConfig = yield* resolveGitHubPublishConfig(updateChannel);
@@ -1776,6 +1779,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( · 363 unmodified lines
17761779 provisioningProfilePath: macPasskeySigning.provisioningProfilePath,
17771780 }
17781781 : undefined,
1782+ path.join(stageAppDir, DESKTOP_AFTER_PACK_HOOK_FILE),
17791783 ),
17801784 dependencies: stageDependencies,
17811785 devDependencies: {
@@ -1784,6 +1788,10 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( · 2 unmodified lines
17841788 };
17851789
17861790 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+ );
17871795 yield* fs.writeFileString(path.join(stageAppDir, "package.json"), `${stagePackageJsonString}\n`);
17881796 const stageWorkspaceConfig = createStageWorkspaceConfig({
17891797 platform: options.platform,
scripts/desktop-after-pack.test.ts+96−0
@@ -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+});
scripts/desktop-after-pack.ts+97−0
@@ -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+}