diff --git a/packages/installer/src/asar.ts b/packages/installer/src/asar.ts index d41cad2..9e63e61 100644 --- a/packages/installer/src/asar.ts +++ b/packages/installer/src/asar.ts @@ -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 { @@ -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 @@ -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); @@ -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, +): Promise { + 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, +): Parameters[1] { + const streams: Parameters[1] = []; + collectAsarStreamsInto(root, root, unpackedPaths, streams); + return streams; +} + +function collectAsarStreamsInto( + root: string, + current: string, + unpackedPaths: Set, + streams: Parameters[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 { 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 } }; }).getRawHeader(asarPath); const paths: string[] = []; walk(raw.header as Record, "", 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, prefix: string, out: string[]): void { diff --git a/packages/installer/src/commands/repair.ts b/packages/installer/src/commands/repair.ts index f0f70c9..5d1e554 100644 --- a/packages/installer/src/commands/repair.ts +++ b/packages/installer/src/commands/repair.ts @@ -131,10 +131,10 @@ export async function repair(opts: Opts = {}): Promise { 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.")); } @@ -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 = {}, + env: NodeJS.ProcessEnv = process.env, + currentPlatform: NodeJS.Platform = process.platform, +): boolean { + return codexWasRunning && currentPlatform === "darwin" && !isWatcherRepair(opts, env); +} + +function isWatcherRepair(opts: Pick, env: NodeJS.ProcessEnv = process.env): boolean { + return opts.watcher === true || env.CODEX_PLUSPLUS_WATCHER === "1"; } async function waitForMacAppUpdateToSettle(appRoot: string | undefined, opts: SettleOptions = {}): Promise { diff --git a/packages/installer/src/platform.ts b/packages/installer/src/platform.ts index a3eb46a..d2c89dc 100644 --- a/packages/installer/src/platform.ts +++ b/packages/installer/src/platform.ts @@ -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, @@ -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 { diff --git a/packages/installer/test/asar-cleanup.test.ts b/packages/installer/test/asar-cleanup.test.ts index a7ac19d..f2451ab 100644 --- a/packages/installer/test/asar-cleanup.test.ts +++ b/packages/installer/test/asar-cleanup.test.ts @@ -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-")); @@ -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[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); + assert.equal(unpackedCount, 700); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +function readFileStat(path: string) { + return statSync(path); +} + +function countUnpacked(node: Record): number { + const files = (node as { files?: Record> }).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; +} diff --git a/packages/installer/test/tweak-commands.test.ts b/packages/installer/test/tweak-commands.test.ts index 0d68073..86e2141 100644 --- a/packages/installer/test/tweak-commands.test.ts +++ b/packages/installer/test/tweak-commands.test.ts @@ -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, @@ -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");