Skip to content
Open
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
110 changes: 95 additions & 15 deletions packages/installer/src/asar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,22 @@
*/
import asar from "@electron/asar";
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync, mkdtempSync, rmSync, cpSync, existsSync, renameSync, unlinkSync } from "node:fs";
import {
createReadStream,
existsSync,
lstatSync,
mkdtempSync,
readFileSync,
readdirSync,
readlinkSync,
renameSync,
rmSync,
cpSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { join, relative, sep } from "node:path";
import { setTimeout as delay } from "node:timers/promises";

export interface AsarHeaderInfo {
Expand Down Expand Up @@ -49,17 +62,14 @@ export async function patchAsar(
const outAsar = join(work, "app.asar");

// Snapshot which files were unpacked in the ORIGINAL asar before we touch
// anything; we'll feed that exact set back to createPackageWithOptions.
const originalUnpackGlob = collectUnpackGlob(asarPath);
// anything; we'll feed that exact set back during repack.
const originalUnpackedPaths = collectUnpackedPaths(asarPath);

try {
asar.extractAll(asarPath, extractDir);
await mutate(extractDir);

await asar.createPackageWithOptions(extractDir, outAsar, {
globOptions: { dot: true },
...(originalUnpackGlob ? { unpack: originalUnpackGlob } : {}),
});
await createPackagePreservingUnpacked(extractDir, outAsar, originalUnpackedPaths);

// Atomic-ish replace: write next to the target, then rename. This prevents
// a denied write (e.g. macOS App Management TCC) from leaving the bundle
Expand All @@ -77,6 +87,7 @@ export async function patchAsar(
try { unlinkSync(stagingPath); } catch { /* best effort */ }
throw annotatePermError(e, asarPath);
}
asar.uncache(asarPath);
return readHeaderHash(asarPath);
} finally {
await cleanupTempTree(work);
Expand Down Expand Up @@ -113,19 +124,88 @@ function isTransientCleanupError(error: unknown): boolean {
* MODULE_NOT_FOUND when something requires the module — exactly the failure
* mode we hit before this fix.
*/
function collectUnpackGlob(asarPath: string): string | undefined {
async function createPackagePreservingUnpacked(
extractDir: string,
outAsar: string,
unpackedPaths: Set<string>,
): Promise<void> {
if (unpackedPaths.size === 0) {
await asar.createPackageWithOptions(extractDir, outAsar, {
globOptions: { dot: true },
});
return;
}

const streams = collectAsarStreams(extractDir, unpackedPaths);
await asar.createPackageFromStreams(outAsar, streams);
}

function collectAsarStreams(
root: string,
unpackedPaths: Set<string>,
): Parameters<typeof asar.createPackageFromStreams>[1] {
const streams: Parameters<typeof asar.createPackageFromStreams>[1] = [];
collectAsarStreamsInto(root, root, unpackedPaths, streams);
return streams;
}

function collectAsarStreamsInto(
root: string,
current: string,
unpackedPaths: Set<string>,
streams: Parameters<typeof asar.createPackageFromStreams>[1],
): void {
const entries = readdirSync(current).sort((a, b) => a.localeCompare(b));
for (const name of entries) {
const full = join(current, name);
const stat = lstatSync(full);
const archivePath = toArchivePath(root, full);
if (!archivePath) continue;

if (stat.isDirectory()) {
streams.push({ type: "directory", path: archivePath, unpacked: false });
collectAsarStreamsInto(root, full, unpackedPaths, streams);
continue;
}

const unpacked = unpackedPaths.has(archivePath);
if (stat.isSymbolicLink()) {
streams.push({
type: "link",
path: archivePath,
streamGenerator: () => createReadStream(full),
unpacked,
stat,
symlink: readlinkSync(full),
});
continue;
}

if (stat.isFile()) {
streams.push({
type: "file",
path: archivePath,
streamGenerator: () => createReadStream(full),
unpacked,
stat,
});
}
}
}

function toArchivePath(root: string, full: string): string {
return relative(root, full).split(sep).join("/");
}

function collectUnpackedPaths(asarPath: string): Set<string> {
const sibling = `${asarPath}.unpacked`;
if (!existsSync(sibling)) return undefined;
if (!existsSync(sibling)) return new Set();
const raw = (asar as unknown as {
getRawHeader: (p: string) => { header: { files?: Record<string, unknown> } };
}).getRawHeader(asarPath);
const paths: string[] = [];
walk(raw.header as Record<string, unknown>, "", paths);
if (paths.length === 0) return undefined;
// `unpack` is matched against absolute filenames, so prefix each archive path
// with `**` to match regardless of the temporary extraction directory.
const patterns = paths.map((p) => `**${p}`);
return patterns.length === 1 ? patterns[0] : `{${patterns.join(",")}}`;
return new Set(paths.map((path) => path.replace(/^\//, "")));
}

function walk(node: Record<string, unknown>, prefix: string, out: string[]): void {
Expand Down
17 changes: 13 additions & 4 deletions packages/installer/src/commands/repair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,10 @@ export async function repair(opts: Opts = {}): Promise<void> {
const codex = locateCodex(opts.app ?? state?.appRoot);
repairedAppRoot = codex.appRoot;
codexWasRunning = isCodexRunning(codex.appRoot);
if (codexWasRunning && process.platform === "darwin" && promptRestartCodexToRepatch(codex.appRoot)) {
if (shouldPromptToQuitBeforeRepair(codexWasRunning, opts) && promptRestartCodexToRepatch(codex.appRoot)) {
reopenAfterRepair = true;
codexWasRunning = false;
} else if (codexWasRunning && process.platform === "darwin") {
} else if (shouldPromptToQuitBeforeRepair(codexWasRunning, opts)) {
if (!opts.quiet) {
console.log(kleur.yellow("Repair postponed. Codex is still running without the updated Codex++ patch."));
}
Expand Down Expand Up @@ -219,8 +219,17 @@ function settleOptions(opts: Opts, updateModeFile: string): SettleOptions {
};
}

function isWatcherRepair(opts: Opts): boolean {
return opts.watcher === true || process.env.CODEX_PLUSPLUS_WATCHER === "1";
export function shouldPromptToQuitBeforeRepair(
codexWasRunning: boolean,
opts: Pick<Opts, "watcher"> = {},
env: NodeJS.ProcessEnv = process.env,
currentPlatform: NodeJS.Platform = process.platform,
): boolean {
return codexWasRunning && currentPlatform === "darwin" && !isWatcherRepair(opts, env);
}

function isWatcherRepair(opts: Pick<Opts, "watcher">, env: NodeJS.ProcessEnv = process.env): boolean {
return opts.watcher === true || env.CODEX_PLUSPLUS_WATCHER === "1";
}

async function waitForMacAppUpdateToSettle(appRoot: string | undefined, opts: SettleOptions = {}): Promise<void> {
Expand Down
54 changes: 45 additions & 9 deletions packages/installer/src/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,7 @@ function locateMac(override?: string): CodexInstall {
resourcesDir,
asarPath: join(resourcesDir, "app.asar"),
metaPath: join(appRoot, "Contents", "Info.plist"),
electronBinary: join(
appRoot,
"Contents",
"Frameworks",
"Electron Framework.framework",
"Versions",
"A",
"Electron Framework",
),
electronBinary: resolveMacFrameworkBinary(appRoot),
executable: join(appRoot, "Contents", "MacOS", info.executable),
appName: info.name,
bundleId: info.bundleId,
Expand All @@ -90,6 +82,50 @@ function locateMac(override?: string): CodexInstall {
};
}

function resolveMacFrameworkBinary(appRoot: string): string {
const frameworksDir = join(appRoot, "Contents", "Frameworks");
const preferred = [
join(frameworksDir, "Electron Framework.framework", "Versions", "A", "Electron Framework"),
join(frameworksDir, "Electron Framework.framework", "Electron Framework"),
join(frameworksDir, "Codex Framework.framework", "Codex Framework"),
join(frameworksDir, "Codex Framework.framework", "Versions", "Current", "Codex Framework"),
];
const foundPreferred = preferred.find((candidate) => existsSync(candidate));
if (foundPreferred) return foundPreferred;

if (existsSync(frameworksDir)) {
try {
for (const entry of readdirSync(frameworksDir)) {
if (!/^(Electron|Codex) Framework\.framework$/i.test(entry)) continue;
const framework = join(frameworksDir, entry);
const binaryName = entry.replace(/\.framework$/i, "");
const candidates = [
join(framework, binaryName),
join(framework, "Versions", "Current", binaryName),
join(framework, "Versions", "A", binaryName),
...versionedFrameworkBinaries(framework, binaryName),
];
const found = candidates.find((candidate) => existsSync(candidate));
if (found) return found;
}
} catch {}
}

return preferred[0];
}

function versionedFrameworkBinaries(framework: string, binaryName: string): string[] {
const versionsDir = join(framework, "Versions");
if (!existsSync(versionsDir)) return [];
try {
return readdirSync(versionsDir)
.filter((version) => version !== "Current")
.map((version) => join(versionsDir, version, binaryName));
} catch {
return [];
}
}

function findMacCodexApps(dir: string): string[] {
if (!existsSync(dir)) return [];
try {
Expand Down
70 changes: 68 additions & 2 deletions packages/installer/test/asar-cleanup.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import assert from "node:assert/strict";
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import asar from "@electron/asar";
import { createReadStream, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { cleanupTempTree } from "../src/asar";
import { cleanupTempTree, patchAsar, readHeaderHash } from "../src/asar";

test("asar temp cleanup removes extracted work trees", async () => {
const root = mkdtempSync(join(tmpdir(), "codexpp-asar-cleanup-"));
Expand All @@ -14,3 +15,68 @@ test("asar temp cleanup removes extracted work trees", async () => {

assert.equal(existsSync(root), false);
});

test("patchAsar preserves many unpacked files without a giant glob", async () => {
const root = mkdtempSync(join(tmpdir(), "codexpp-asar-unpacked-"));
try {
const source = join(root, "src");
const archive = join(root, "app.asar");
mkdirSync(join(source, "long", "nested", "tree"), { recursive: true });
writeFileSync(join(source, "package.json"), JSON.stringify({ main: "before.js" }));

const streams: Parameters<typeof asar.createPackageFromStreams>[1] = [
{ type: "directory", path: "long", unpacked: false },
{ type: "directory", path: "long/nested", unpacked: false },
{ type: "directory", path: "long/nested/tree", unpacked: false },
{
type: "file",
path: "package.json",
streamGenerator: () => createReadStream(join(source, "package.json")),
unpacked: false,
stat: readFileStat(join(source, "package.json")),
},
];

for (let i = 0; i < 700; i++) {
const file = join(source, "long", "nested", "tree", `file-${String(i).padStart(4, "0")}.txt`);
writeFileSync(file, `file ${i}`);
streams.push({
type: "file",
path: `long/nested/tree/file-${String(i).padStart(4, "0")}.txt`,
streamGenerator: () => createReadStream(file),
unpacked: true,
stat: readFileStat(file),
});
}

await asar.createPackageFromStreams(archive, streams);
await patchAsar(archive, (dir) => {
writeFileSync(join(dir, "package.json"), JSON.stringify({ main: "after.js" }));
});

const packageJson = JSON.parse(asar.extractFile(archive, "package.json").toString("utf8"));
assert.equal(packageJson.main, "after.js");
const unpackedFile = readFileSync(join(root, "app.asar.unpacked", "long", "nested", "tree", "file-0000.txt"), "utf8");
assert.equal(unpackedFile, "file 0");
const { header } = readHeaderHash(archive);
const unpackedCount = countUnpacked(header as Record<string, unknown>);
assert.equal(unpackedCount, 700);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

function readFileStat(path: string) {
return statSync(path);
}

function countUnpacked(node: Record<string, unknown>): number {
const files = (node as { files?: Record<string, Record<string, unknown>> }).files;
if (!files) return 0;
let count = 0;
for (const value of Object.values(files)) {
if ((value as { files?: unknown }).files) count += countUnpacked(value);
else if ((value as { unpacked?: boolean }).unpacked) count++;
}
return count;
}
12 changes: 12 additions & 0 deletions packages/installer/test/tweak-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { buildCliFailureIssueUrl, buildPatchFailureIssueUrl, isMacAppManagementE
import { findCodexMainCandidates } from "../src/commands/install";
import { createTweak } from "../src/commands/create-tweak";
import { devTweak } from "../src/commands/dev-tweak";
import { shouldPromptToQuitBeforeRepair } from "../src/commands/repair";
import { safeMode } from "../src/commands/safe-mode";
import {
ensureCliExecutable,
Expand Down Expand Up @@ -422,6 +423,17 @@ test("watcher runs self-update and app repair as separate steps", () => {
assert.match(script, /update[\s\S]+\|\| true;[\s\S]+repair/);
});

test("watcher repair does not block on pre-patch quit prompt", () => {
assert.equal(shouldPromptToQuitBeforeRepair(true, {}, {}, "darwin"), true);
assert.equal(shouldPromptToQuitBeforeRepair(true, { watcher: true }, {}, "darwin"), false);
assert.equal(
shouldPromptToQuitBeforeRepair(true, {}, { CODEX_PLUSPLUS_WATCHER: "1" }, "darwin"),
false,
);
assert.equal(shouldPromptToQuitBeforeRepair(false, { watcher: true }, {}, "darwin"), false);
assert.equal(shouldPromptToQuitBeforeRepair(true, {}, {}, "linux"), false);
});

test("launchd watcher script clears stale log entries before each run", () => {
const script = watcherShellScript("/tmp/codex plusplus/watch'er.log");

Expand Down