Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions scripts/build-desktop-artifact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
createStagePatchedDependencies,
createBuildConfig,
DESKTOP_ASAR_UNPACK,
DESKTOP_AFTER_PACK_HOOK_FILE,
InvalidMacPasskeyRpDomainError,
InvalidMacPasskeyPublishableKeyError,
InvalidMockUpdateServerPortError,
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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<string, unknown>;
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");
Expand Down
8 changes: 8 additions & 0 deletions scripts/build-desktop-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1385,6 +1386,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
readonly provisioningProfilePath: string;
}
| undefined,
afterPackHookPath?: string,
) {
const buildConfig: Record<string, unknown> = {
appId: DESKTOP_APP_ID,
Expand All @@ -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);
Expand Down Expand Up @@ -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: {
Expand All @@ -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,
Expand Down
76 changes: 76 additions & 0 deletions scripts/desktop-after-pack.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof verifyUnpackedRuntimeFiles>[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,
);
});
});
63 changes: 63 additions & 0 deletions scripts/desktop-after-pack.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
await verifyUnpackedRuntimeFiles(context);
}
Loading