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

Showing the changes to scripts (4 of 4 files). Show all

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
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");
scripts/build-desktop-artifact.ts+8−0
@@ -580,6 +580,7 @@ interface StagePackageJson { · 579 unmodified lines
580 580
581export const STAGE_INSTALL_ARGS = ["install", "--prod"] as const;581export const STAGE_INSTALL_ARGS = ["install", "--prod"] as const;
582export const DESKTOP_ASAR_UNPACK = ["node_modules/@ff-labs/fff-bin-*/**/*"] as const;582export const DESKTOP_ASAR_UNPACK = ["node_modules/@ff-labs/fff-bin-*/**/*"] as const;
583export const DESKTOP_AFTER_PACK_HOOK_FILE = "desktop-after-pack.ts";
583 584
584export interface MacPasskeySigningConfiguration {585export 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), so1409 // 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,
scripts/desktop-after-pack.test.ts+96−0
@@ -0,0 +1,96 @@
1// @effect-diagnostics nodeBuiltinImport:off - This fixture verifies the filesystem repair boundary.
2import * as NodeFSP from "node:fs/promises";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5
6import { assert } from "@effect/vitest";
7import { afterEach, describe, it } from "vite-plus/test";
8
9import {
10 repairUnpackedEffectModules,
11 REQUIRED_UNPACKED_EFFECT_MODULES,
12} from "./desktop-after-pack.ts";
13
14const temporaryDirectories: string[] = [];
15
16async 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
31afterEach(async () => {
32 await Promise.all(
33 temporaryDirectories
34 .splice(0)
35 .map((directory) => NodeFSP.rm(directory, { recursive: true, force: true })),
36 );
37});
38
39describe("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.
2import * as NodeFSP from "node:fs/promises";
3import * as NodeModule from "node:module";
4import * as NodePath from "node:path";
5import * as NodeURL from "node:url";
6
7export const REQUIRED_UNPACKED_EFFECT_MODULES = [
8 "effect",
9 "@effect/platform-node",
10 "@effect/platform-node-shared",
11 "mime",
12 "undici",
13] as const;
14
15interface DesktopAfterPackContext {
16 readonly appOutDir: string;
17}
18
19async 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
43async function resolvePackageRoot(
44 resolver: ReturnType<typeof NodeModule.createRequire>,
45 packageName: string,
46): Promise<string> {
47 return findPackageRoot(resolver.resolve(packageName), packageName);
48}
49
50export 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
94export 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}