From 2892699bfbe74c19e460876c097ef3b2baed69dc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 19 Jul 2026 19:24:07 +0200 Subject: [PATCH 1/3] 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 --- scripts/build-desktop-artifact.test.ts | 4 ++ scripts/build-desktop-artifact.ts | 8 +++ scripts/desktop-after-pack.test.ts | 96 +++++++++++++++++++++++++ scripts/desktop-after-pack.ts | 97 ++++++++++++++++++++++++++ 4 files changed, 205 insertions(+) create mode 100644 scripts/desktop-after-pack.test.ts create mode 100644 scripts/desktop-after-pack.ts diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index da45376954f..8f8590745f7 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -14,6 +14,7 @@ import { createStagePatchedDependencies, createBuildConfig, DESKTOP_ASAR_UNPACK, + DESKTOP_AFTER_PACK_HOOK_FILE, InvalidMacPasskeyRpDomainError, InvalidMacPasskeyPublishableKeyError, InvalidMockUpdateServerPortError, @@ -305,6 +306,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { it("unpacks the fff shared library for filesystem and FFI access", () => { assert.deepStrictEqual(DESKTOP_ASAR_UNPACK, ["node_modules/@ff-labs/fff-bin-*/**/*"]); + assert.equal(DESKTOP_AFTER_PACK_HOOK_FILE, "desktop-after-pack.ts"); }); it.effect("preserves both Linux icon resize failures with structural context", () => { @@ -489,9 +491,11 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { false, undefined, undefined, + "C:\\stage\\desktop-after-pack.ts", ); const win = config.win as Record; + assert.equal(config.afterPack, "C:\\stage\\desktop-after-pack.ts"); assert.equal(win.icon, "icon.ico"); assert.equal(win.signAndEditExecutable, true); assert.notProperty(win, "azureSignOptions"); diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 8a1099a9ed0..2445f41552f 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -580,6 +580,7 @@ interface StagePackageJson { export const STAGE_INSTALL_ARGS = ["install", "--prod"] as const; export const DESKTOP_ASAR_UNPACK = ["node_modules/@ff-labs/fff-bin-*/**/*"] as const; +export const DESKTOP_AFTER_PACK_HOOK_FILE = "desktop-after-pack.ts"; export interface MacPasskeySigningConfiguration { readonly appId: string; @@ -1385,6 +1386,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( readonly provisioningProfilePath: string; } | undefined, + afterPackHookPath?: string, ) { const buildConfig: Record = { appId: DESKTOP_APP_ID, @@ -1407,6 +1409,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( // files through the asar (transparently redirected to the unpacked copy), so // there's no duplication. asarUnpack: [...DESKTOP_ASAR_UNPACK, "apps/server/dist/**", "**/node_modules/**"], + ...(afterPackHookPath ? { afterPack: afterPackHookPath } : {}), }; const updateChannel = resolveDesktopUpdateChannel(version); const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); @@ -1776,6 +1779,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( provisioningProfilePath: macPasskeySigning.provisioningProfilePath, } : undefined, + path.join(stageAppDir, DESKTOP_AFTER_PACK_HOOK_FILE), ), dependencies: stageDependencies, devDependencies: { @@ -1784,6 +1788,10 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( }; const stagePackageJsonString = yield* encodeJsonString(stagePackageJson); + yield* fs.copyFile( + path.join(repoRoot, "scripts", DESKTOP_AFTER_PACK_HOOK_FILE), + path.join(stageAppDir, DESKTOP_AFTER_PACK_HOOK_FILE), + ); yield* fs.writeFileString(path.join(stageAppDir, "package.json"), `${stagePackageJsonString}\n`); const stageWorkspaceConfig = createStageWorkspaceConfig({ platform: options.platform, diff --git a/scripts/desktop-after-pack.test.ts b/scripts/desktop-after-pack.test.ts new file mode 100644 index 00000000000..77594cd98f3 --- /dev/null +++ b/scripts/desktop-after-pack.test.ts @@ -0,0 +1,96 @@ +// @effect-diagnostics nodeBuiltinImport:off - This fixture verifies the filesystem repair boundary. +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { assert } from "@effect/vitest"; +import { afterEach, describe, it } from "vite-plus/test"; + +import { + repairUnpackedEffectModules, + REQUIRED_UNPACKED_EFFECT_MODULES, +} from "./desktop-after-pack.ts"; + +const temporaryDirectories: string[] = []; + +async function writePackage( + nodeModulesDir: string, + packageName: string, + marker: string, +): Promise { + const packageDir = NodePath.join(nodeModulesDir, ...packageName.split("/")); + await NodeFSP.mkdir(packageDir, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(packageDir, "package.json"), + `${JSON.stringify({ name: packageName, main: "index.js" })}\n`, + ); + await NodeFSP.writeFile(NodePath.join(packageDir, "index.js"), `export default ${marker};\n`); + return packageDir; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => NodeFSP.rm(directory, { recursive: true, force: true })), + ); +}); + +describe("desktop afterPack", () => { + it("restores the Effect runtime closure when electron-builder omits unpacked payloads", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3code-after-pack-")); + temporaryDirectories.push(root); + const stageAppDir = NodePath.join(root, "stage"); + const appOutDir = NodePath.join(root, "win-unpacked"); + const stageNodeModules = NodePath.join(stageAppDir, "node_modules"); + + await NodeFSP.mkdir(stageAppDir, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(stageAppDir, "package.json"), '{"name":"t3code"}\n'); + await writePackage(stageNodeModules, "effect", '"effect"'); + const platformNodeDir = await writePackage( + stageNodeModules, + "@effect/platform-node", + '"platform-node"', + ); + const platformNodeModules = NodePath.join(platformNodeDir, "node_modules"); + await writePackage(platformNodeModules, "@effect/platform-node-shared", '"shared"'); + await writePackage(platformNodeModules, "mime", '"mime"'); + await writePackage(platformNodeModules, "undici", '"undici"'); + + const staleEffectDir = NodePath.join( + appOutDir, + "resources/app.asar.unpacked/node_modules/effect", + ); + await NodeFSP.mkdir(staleEffectDir, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(staleEffectDir, "stale.js"), "stale\n"); + + await repairUnpackedEffectModules({ stageAppDir, appOutDir }); + + const expectedMarkers: Record<(typeof REQUIRED_UNPACKED_EFFECT_MODULES)[number], string> = { + effect: "effect", + "@effect/platform-node": "platform-node", + "@effect/platform-node-shared": "shared", + mime: "mime", + undici: "undici", + }; + for (const packageName of REQUIRED_UNPACKED_EFFECT_MODULES) { + const packageDir = NodePath.join( + appOutDir, + "resources/app.asar.unpacked/node_modules", + ...packageName.split("/"), + ); + const manifest = JSON.parse( + await NodeFSP.readFile(NodePath.join(packageDir, "package.json"), "utf8"), + ) as { readonly name: string }; + assert.equal(manifest.name, packageName); + assert.equal( + await NodeFSP.readFile(NodePath.join(packageDir, "index.js"), "utf8"), + `export default "${expectedMarkers[packageName]}";\n`, + ); + } + await NodeFSP.access(NodePath.join(staleEffectDir, "stale.js")).then( + () => assert.fail("The stale unpacked payload should have been replaced."), + (error: NodeJS.ErrnoException) => assert.equal(error.code, "ENOENT"), + ); + }); +}); diff --git a/scripts/desktop-after-pack.ts b/scripts/desktop-after-pack.ts new file mode 100644 index 00000000000..2ccbb3c7223 --- /dev/null +++ b/scripts/desktop-after-pack.ts @@ -0,0 +1,97 @@ +// @effect-diagnostics nodeBuiltinImport:off - electron-builder hooks run outside an Effect runtime. +import * as NodeFSP from "node:fs/promises"; +import * as NodeModule from "node:module"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +export const REQUIRED_UNPACKED_EFFECT_MODULES = [ + "effect", + "@effect/platform-node", + "@effect/platform-node-shared", + "mime", + "undici", +] as const; + +interface DesktopAfterPackContext { + readonly appOutDir: string; +} + +async function findPackageRoot(resolvedEntry: string, packageName: string): Promise { + let current = NodePath.dirname(resolvedEntry); + const root = NodePath.parse(current).root; + + while (current !== root) { + try { + const manifest = JSON.parse( + await NodeFSP.readFile(NodePath.join(current, "package.json"), "utf8"), + ) as { readonly name?: unknown }; + if (manifest.name === packageName) { + return current; + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT" && !(error instanceof SyntaxError)) { + throw error; + } + } + current = NodePath.dirname(current); + } + + throw new Error(`Could not locate the package root for ${packageName} from ${resolvedEntry}.`); +} + +async function resolvePackageRoot( + resolver: ReturnType, + packageName: string, +): Promise { + return findPackageRoot(resolver.resolve(packageName), packageName); +} + +export async function repairUnpackedEffectModules(input: { + readonly stageAppDir: string; + readonly appOutDir: string; +}): Promise { + const stageResolver = NodeModule.createRequire(NodePath.join(input.stageAppDir, "package.json")); + const platformNodeRoot = await resolvePackageRoot(stageResolver, "@effect/platform-node"); + const platformNodeResolver = NodeModule.createRequire( + NodePath.join(platformNodeRoot, "package.json"), + ); + const unpackedNodeModules = NodePath.join( + input.appOutDir, + "resources", + "app.asar.unpacked", + "node_modules", + ); + + for (const packageName of REQUIRED_UNPACKED_EFFECT_MODULES) { + const resolver = + packageName === "effect" || packageName === "@effect/platform-node" + ? stageResolver + : platformNodeResolver; + const source = await resolvePackageRoot(resolver, packageName); + const destination = NodePath.join(unpackedNodeModules, ...packageName.split("/")); + + // electron-builder can mark a module as unpacked in the ASAR header but omit + // some or all of its payload on Windows. Replace this small runtime closure + // from the staged pnpm install after ASAR creation and before signing/NSIS. + await NodeFSP.rm(destination, { recursive: true, force: true }); + await NodeFSP.cp(source, destination, { + recursive: true, + dereference: true, + force: true, + preserveTimestamps: true, + }); + + const copiedManifest = JSON.parse( + await NodeFSP.readFile(NodePath.join(destination, "package.json"), "utf8"), + ) as { readonly name?: unknown }; + if (copiedManifest.name !== packageName) { + throw new Error(`Packaged runtime module verification failed for ${packageName}.`); + } + } +} + +export async function afterPack(context: DesktopAfterPackContext): Promise { + const stageAppDir = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); + await repairUnpackedEffectModules({ stageAppDir, appOutDir: context.appOutDir }); +} From 54b3b8198ac827bb3f1dd8c9ea1c237814437b65 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 19 Jul 2026 19:39:21 +0200 Subject: [PATCH 2/3] Verify unpacked runtime files after desktop packaging - Replace module repair with explicit payload verification - Add coverage for complete and missing unpacked runtime files --- scripts/desktop-after-pack.test.ts | 103 ++++++++++------------------- scripts/desktop-after-pack.ts | 99 +++++++-------------------- 2 files changed, 60 insertions(+), 142 deletions(-) diff --git a/scripts/desktop-after-pack.test.ts b/scripts/desktop-after-pack.test.ts index 77594cd98f3..a8741bdecfa 100644 --- a/scripts/desktop-after-pack.test.ts +++ b/scripts/desktop-after-pack.test.ts @@ -1,31 +1,39 @@ -// @effect-diagnostics nodeBuiltinImport:off - This fixture verifies the filesystem repair boundary. +// @effect-diagnostics nodeBuiltinImport:off - This fixture verifies the packaging filesystem boundary. import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; -import { assert } from "@effect/vitest"; -import { afterEach, describe, it } from "vite-plus/test"; +import { afterEach, describe, expect, it } from "vite-plus/test"; import { - repairUnpackedEffectModules, - REQUIRED_UNPACKED_EFFECT_MODULES, + REQUIRED_UNPACKED_RUNTIME_FILES, + verifyUnpackedRuntimeFiles, } from "./desktop-after-pack.ts"; const temporaryDirectories: string[] = []; -async function writePackage( - nodeModulesDir: string, - packageName: string, - marker: string, -): Promise { - const packageDir = NodePath.join(nodeModulesDir, ...packageName.split("/")); - await NodeFSP.mkdir(packageDir, { recursive: true }); - await NodeFSP.writeFile( - NodePath.join(packageDir, "package.json"), - `${JSON.stringify({ name: packageName, main: "index.js" })}\n`, +async function createPackagedRuntimeFixture(): Promise<{ + readonly root: string; + readonly appOutDir: string; + readonly unpackedNodeModules: string; +}> { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3code-after-pack-")); + temporaryDirectories.push(root); + const appOutDir = NodePath.join(root, "win-unpacked"); + const unpackedNodeModules = NodePath.join( + appOutDir, + "resources", + "app.asar.unpacked", + "node_modules", ); - await NodeFSP.writeFile(NodePath.join(packageDir, "index.js"), `export default ${marker};\n`); - return packageDir; + + for (const relativeFile of REQUIRED_UNPACKED_RUNTIME_FILES) { + const filePath = NodePath.join(unpackedNodeModules, ...relativeFile.split("/")); + await NodeFSP.mkdir(NodePath.dirname(filePath), { recursive: true }); + await NodeFSP.writeFile(filePath, `${relativeFile}\n`); + } + + return { root, appOutDir, unpackedNodeModules }; } afterEach(async () => { @@ -37,60 +45,19 @@ afterEach(async () => { }); describe("desktop afterPack", () => { - it("restores the Effect runtime closure when electron-builder omits unpacked payloads", async () => { - const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3code-after-pack-")); - temporaryDirectories.push(root); - const stageAppDir = NodePath.join(root, "stage"); - const appOutDir = NodePath.join(root, "win-unpacked"); - const stageNodeModules = NodePath.join(stageAppDir, "node_modules"); - - await NodeFSP.mkdir(stageAppDir, { recursive: true }); - await NodeFSP.writeFile(NodePath.join(stageAppDir, "package.json"), '{"name":"t3code"}\n'); - await writePackage(stageNodeModules, "effect", '"effect"'); - const platformNodeDir = await writePackage( - stageNodeModules, - "@effect/platform-node", - '"platform-node"', - ); - const platformNodeModules = NodePath.join(platformNodeDir, "node_modules"); - await writePackage(platformNodeModules, "@effect/platform-node-shared", '"shared"'); - await writePackage(platformNodeModules, "mime", '"mime"'); - await writePackage(platformNodeModules, "undici", '"undici"'); + it("accepts a complete unpacked runtime closure", async () => { + const fixture = await createPackagedRuntimeFixture(); - const staleEffectDir = NodePath.join( - appOutDir, - "resources/app.asar.unpacked/node_modules/effect", - ); - await NodeFSP.mkdir(staleEffectDir, { recursive: true }); - await NodeFSP.writeFile(NodePath.join(staleEffectDir, "stale.js"), "stale\n"); + await verifyUnpackedRuntimeFiles(fixture.appOutDir); + }); - await repairUnpackedEffectModules({ stageAppDir, appOutDir }); + it("fails the build when unpacked runtime payloads are missing", async () => { + const fixture = await createPackagedRuntimeFixture(); + await NodeFSP.rm(NodePath.join(fixture.unpackedNodeModules, "effect/dist/Context.js")); + await NodeFSP.rm(NodePath.join(fixture.unpackedNodeModules, "mime/package.json")); - const expectedMarkers: Record<(typeof REQUIRED_UNPACKED_EFFECT_MODULES)[number], string> = { - effect: "effect", - "@effect/platform-node": "platform-node", - "@effect/platform-node-shared": "shared", - mime: "mime", - undici: "undici", - }; - for (const packageName of REQUIRED_UNPACKED_EFFECT_MODULES) { - const packageDir = NodePath.join( - appOutDir, - "resources/app.asar.unpacked/node_modules", - ...packageName.split("/"), - ); - const manifest = JSON.parse( - await NodeFSP.readFile(NodePath.join(packageDir, "package.json"), "utf8"), - ) as { readonly name: string }; - assert.equal(manifest.name, packageName); - assert.equal( - await NodeFSP.readFile(NodePath.join(packageDir, "index.js"), "utf8"), - `export default "${expectedMarkers[packageName]}";\n`, - ); - } - await NodeFSP.access(NodePath.join(staleEffectDir, "stale.js")).then( - () => assert.fail("The stale unpacked payload should have been replaced."), - (error: NodeJS.ErrnoException) => assert.equal(error.code, "ENOENT"), + await expect(verifyUnpackedRuntimeFiles(fixture.appOutDir)).rejects.toThrow( + /effect\/dist\/Context\.js, mime\/package\.json/u, ); }); }); diff --git a/scripts/desktop-after-pack.ts b/scripts/desktop-after-pack.ts index 2ccbb3c7223..898f86d6cc3 100644 --- a/scripts/desktop-after-pack.ts +++ b/scripts/desktop-after-pack.ts @@ -1,97 +1,48 @@ // @effect-diagnostics nodeBuiltinImport:off - electron-builder hooks run outside an Effect runtime. import * as NodeFSP from "node:fs/promises"; -import * as NodeModule from "node:module"; import * as NodePath from "node:path"; -import * as NodeURL from "node:url"; -export const REQUIRED_UNPACKED_EFFECT_MODULES = [ - "effect", - "@effect/platform-node", - "@effect/platform-node-shared", - "mime", - "undici", +export const REQUIRED_UNPACKED_RUNTIME_FILES = [ + "effect/package.json", + "effect/dist/Context.js", + "@effect/platform-node/package.json", + "@effect/platform-node/dist/NodeHttpClient.js", + "@effect/platform-node-shared/package.json", + "mime/package.json", + "undici/package.json", ] as const; interface DesktopAfterPackContext { readonly appOutDir: string; } -async function findPackageRoot(resolvedEntry: string, packageName: string): Promise { - let current = NodePath.dirname(resolvedEntry); - const root = NodePath.parse(current).root; +export async function verifyUnpackedRuntimeFiles(appOutDir: string): Promise { + const unpackedNodeModules = NodePath.join( + appOutDir, + "resources", + "app.asar.unpacked", + "node_modules", + ); + const missingFiles: string[] = []; - while (current !== root) { + for (const relativeFile of REQUIRED_UNPACKED_RUNTIME_FILES) { try { - const manifest = JSON.parse( - await NodeFSP.readFile(NodePath.join(current, "package.json"), "utf8"), - ) as { readonly name?: unknown }; - if (manifest.name === packageName) { - return current; - } + await NodeFSP.access(NodePath.join(unpackedNodeModules, ...relativeFile.split("/"))); } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT" && !(error instanceof SyntaxError)) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { throw error; } + missingFiles.push(relativeFile); } - current = NodePath.dirname(current); } - throw new Error(`Could not locate the package root for ${packageName} from ${resolvedEntry}.`); -} - -async function resolvePackageRoot( - resolver: ReturnType, - packageName: string, -): Promise { - return findPackageRoot(resolver.resolve(packageName), packageName); -} - -export async function repairUnpackedEffectModules(input: { - readonly stageAppDir: string; - readonly appOutDir: string; -}): Promise { - const stageResolver = NodeModule.createRequire(NodePath.join(input.stageAppDir, "package.json")); - const platformNodeRoot = await resolvePackageRoot(stageResolver, "@effect/platform-node"); - const platformNodeResolver = NodeModule.createRequire( - NodePath.join(platformNodeRoot, "package.json"), - ); - const unpackedNodeModules = NodePath.join( - input.appOutDir, - "resources", - "app.asar.unpacked", - "node_modules", - ); - - for (const packageName of REQUIRED_UNPACKED_EFFECT_MODULES) { - const resolver = - packageName === "effect" || packageName === "@effect/platform-node" - ? stageResolver - : platformNodeResolver; - const source = await resolvePackageRoot(resolver, packageName); - const destination = NodePath.join(unpackedNodeModules, ...packageName.split("/")); - - // electron-builder can mark a module as unpacked in the ASAR header but omit - // some or all of its payload on Windows. Replace this small runtime closure - // from the staged pnpm install after ASAR creation and before signing/NSIS. - await NodeFSP.rm(destination, { recursive: true, force: true }); - await NodeFSP.cp(source, destination, { - recursive: true, - dereference: true, - force: true, - preserveTimestamps: true, - }); - - const copiedManifest = JSON.parse( - await NodeFSP.readFile(NodePath.join(destination, "package.json"), "utf8"), - ) as { readonly name?: unknown }; - if (copiedManifest.name !== packageName) { - throw new Error(`Packaged runtime module verification failed for ${packageName}.`); - } + if (missingFiles.length > 0) { + throw new Error( + `Packaged runtime verification failed: app.asar references unpacked runtime files whose payloads are missing: ${missingFiles.join(", ")}`, + ); } } export async function afterPack(context: DesktopAfterPackContext): Promise { - const stageAppDir = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); - await repairUnpackedEffectModules({ stageAppDir, appOutDir: context.appOutDir }); + await verifyUnpackedRuntimeFiles(context.appOutDir); } From b8b14378cedf0536baf134c5599ef4a8325456cb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 20 Jul 2026 17:46:29 +0200 Subject: [PATCH 3/3] Fix macOS afterPack runtime path (Issue #4154) Resolve app.asar.unpacked under the product app bundle on darwin while retaining the resources directory layout used by Windows and Linux. Co-authored-by: codex --- scripts/desktop-after-pack.test.ts | 35 ++++++++++++++++++++---------- scripts/desktop-after-pack.ts | 31 +++++++++++++++++++------- 2 files changed, 47 insertions(+), 19 deletions(-) diff --git a/scripts/desktop-after-pack.test.ts b/scripts/desktop-after-pack.test.ts index a8741bdecfa..14fbb86d587 100644 --- a/scripts/desktop-after-pack.test.ts +++ b/scripts/desktop-after-pack.test.ts @@ -7,22 +7,31 @@ import { afterEach, describe, expect, it } from "vite-plus/test"; import { REQUIRED_UNPACKED_RUNTIME_FILES, + resolveUnpackedNodeModules, verifyUnpackedRuntimeFiles, } from "./desktop-after-pack.ts"; const temporaryDirectories: string[] = []; -async function createPackagedRuntimeFixture(): Promise<{ +async function createPackagedRuntimeFixture(platform: "darwin" | "linux" | "win32"): Promise<{ readonly root: string; - readonly appOutDir: string; + readonly context: Parameters[0]; readonly unpackedNodeModules: string; }> { const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3code-after-pack-")); temporaryDirectories.push(root); - const appOutDir = NodePath.join(root, "win-unpacked"); + const appOutDir = NodePath.join(root, `${platform}-unpacked`); + const productFilename = "T3 Code"; + const context = { + appOutDir, + electronPlatformName: platform, + packager: { appInfo: { productFilename } }, + }; const unpackedNodeModules = NodePath.join( appOutDir, - "resources", + ...(platform === "darwin" + ? [`${productFilename}.app`, "Contents", "Resources"] + : ["resources"]), "app.asar.unpacked", "node_modules", ); @@ -33,7 +42,7 @@ async function createPackagedRuntimeFixture(): Promise<{ await NodeFSP.writeFile(filePath, `${relativeFile}\n`); } - return { root, appOutDir, unpackedNodeModules }; + return { root, context, unpackedNodeModules }; } afterEach(async () => { @@ -45,18 +54,22 @@ afterEach(async () => { }); describe("desktop afterPack", () => { - it("accepts a complete unpacked runtime closure", async () => { - const fixture = await createPackagedRuntimeFixture(); + it.each(["win32", "linux", "darwin"] as const)( + "accepts a complete %s unpacked runtime closure", + async (platform) => { + const fixture = await createPackagedRuntimeFixture(platform); - await verifyUnpackedRuntimeFiles(fixture.appOutDir); - }); + expect(resolveUnpackedNodeModules(fixture.context)).toBe(fixture.unpackedNodeModules); + await verifyUnpackedRuntimeFiles(fixture.context); + }, + ); it("fails the build when unpacked runtime payloads are missing", async () => { - const fixture = await createPackagedRuntimeFixture(); + const fixture = await createPackagedRuntimeFixture("win32"); await NodeFSP.rm(NodePath.join(fixture.unpackedNodeModules, "effect/dist/Context.js")); await NodeFSP.rm(NodePath.join(fixture.unpackedNodeModules, "mime/package.json")); - await expect(verifyUnpackedRuntimeFiles(fixture.appOutDir)).rejects.toThrow( + await expect(verifyUnpackedRuntimeFiles(fixture.context)).rejects.toThrow( /effect\/dist\/Context\.js, mime\/package\.json/u, ); }); diff --git a/scripts/desktop-after-pack.ts b/scripts/desktop-after-pack.ts index 898f86d6cc3..eaa86c81096 100644 --- a/scripts/desktop-after-pack.ts +++ b/scripts/desktop-after-pack.ts @@ -14,15 +14,30 @@ export const REQUIRED_UNPACKED_RUNTIME_FILES = [ interface DesktopAfterPackContext { readonly appOutDir: string; + readonly electronPlatformName: string; + readonly packager: { + readonly appInfo: { + readonly productFilename: string; + }; + }; } -export async function verifyUnpackedRuntimeFiles(appOutDir: string): Promise { - const unpackedNodeModules = NodePath.join( - appOutDir, - "resources", - "app.asar.unpacked", - "node_modules", - ); +export function resolveUnpackedNodeModules(context: DesktopAfterPackContext): string { + const resourcesDirectory = + context.electronPlatformName === "darwin" + ? NodePath.join( + context.appOutDir, + `${context.packager.appInfo.productFilename}.app`, + "Contents", + "Resources", + ) + : NodePath.join(context.appOutDir, "resources"); + + return NodePath.join(resourcesDirectory, "app.asar.unpacked", "node_modules"); +} + +export async function verifyUnpackedRuntimeFiles(context: DesktopAfterPackContext): Promise { + const unpackedNodeModules = resolveUnpackedNodeModules(context); const missingFiles: string[] = []; for (const relativeFile of REQUIRED_UNPACKED_RUNTIME_FILES) { @@ -44,5 +59,5 @@ export async function verifyUnpackedRuntimeFiles(appOutDir: string): Promise { - await verifyUnpackedRuntimeFiles(context.appOutDir); + await verifyUnpackedRuntimeFiles(context); }