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..14fbb86d587 --- /dev/null +++ b/scripts/desktop-after-pack.test.ts @@ -0,0 +1,76 @@ +// @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 { 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(platform: "darwin" | "linux" | "win32"): Promise<{ + readonly root: 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, `${platform}-unpacked`); + const productFilename = "T3 Code"; + const context = { + appOutDir, + electronPlatformName: platform, + packager: { appInfo: { productFilename } }, + }; + const unpackedNodeModules = NodePath.join( + appOutDir, + ...(platform === "darwin" + ? [`${productFilename}.app`, "Contents", "Resources"] + : ["resources"]), + "app.asar.unpacked", + "node_modules", + ); + + 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, context, unpackedNodeModules }; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => NodeFSP.rm(directory, { recursive: true, force: true })), + ); +}); + +describe("desktop afterPack", () => { + it.each(["win32", "linux", "darwin"] as const)( + "accepts a complete %s unpacked runtime closure", + async (platform) => { + const fixture = await createPackagedRuntimeFixture(platform); + + 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("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.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 new file mode 100644 index 00000000000..eaa86c81096 --- /dev/null +++ b/scripts/desktop-after-pack.ts @@ -0,0 +1,63 @@ +// @effect-diagnostics nodeBuiltinImport:off - electron-builder hooks run outside an Effect runtime. +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +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; + readonly electronPlatformName: string; + readonly packager: { + readonly appInfo: { + readonly productFilename: string; + }; + }; +} + +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) { + try { + await NodeFSP.access(NodePath.join(unpackedNodeModules, ...relativeFile.split("/"))); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + missingFiles.push(relativeFile); + } + } + + 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 { + await verifyUnpackedRuntimeFiles(context); +}