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
80 changes: 80 additions & 0 deletions packages/installer/src/backups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { readFileInAsar, readHeaderHash } from "./asar.js";
import type { InstallerState } from "./state.js";

interface PrepareBackupSetOptions {
appRoot: string;
asarPath: string;
backupDir: string;
previousState: InstallerState | null;
}

/**
* Keep the global backup set aligned with the app installation being patched.
* A repeated install of an already-patched app must retain its pristine backup;
* a new app build must replace backups from the previous build.
*/
export function prepareBackupSet({
appRoot,
asarPath,
backupDir,
previousState,
}: PrepareBackupSetOptions): boolean {
mkdirSync(backupDir, { recursive: true });

const backupAsar = join(backupDir, "app.asar");
const currentHash = readHeaderHash(asarPath).headerHash;
const sameInstall = previousState?.appRoot === appRoot;
const currentIsPatched = hasCodexPlusPlusMarker(asarPath);

if (sameInstall && currentHash === previousState.patchedAsarHash) {
const backupHash = readBackupHash(backupAsar);
if (backupHash !== previousState.originalAsarHash) {
throw new Error(
"Codex++ backup does not match the patched app. Restore or reinstall " +
"Codex before running install again; the current app cannot be safely backed up.",
);
}
return false;
}

if (currentIsPatched) {
throw new Error(
"Codex is already patched, but it does not match the saved installer state. " +
"Restore or reinstall Codex before running install again.",
);
}

const backupHash = readBackupHash(backupAsar);
if (
sameInstall &&
currentHash === previousState.originalAsarHash &&
backupHash === previousState.originalAsarHash
) {
return false;
}

const rotated = readdirSync(backupDir).length > 0;
if (rotated) {
rmSync(backupDir, { recursive: true, force: true });
mkdirSync(backupDir, { recursive: true });
}
return rotated;
}

function hasCodexPlusPlusMarker(asarPath: string): boolean {
const pkg = JSON.parse(readFileInAsar(asarPath, "package.json").toString()) as {
__codexpp?: unknown;
};
return pkg.__codexpp !== undefined;
}

function readBackupHash(backupAsar: string): string | null {
if (!existsSync(backupAsar)) return null;
try {
return readHeaderHash(backupAsar).headerHash;
} catch {
return null;
}
}
3 changes: 2 additions & 1 deletion packages/installer/src/codex-window-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ interface ServiceFactoryAssignment {
}

const IDENT_RE = /^[$A-Za-z_][$A-Za-z0-9_]*$/;
const BUILD_FLAVOR_CALL_RE = /([$A-Za-z_][$A-Za-z0-9_]*)\(\{\s*buildFlavor\s*:/g;
const BUILD_FLAVOR_CALL_RE =
/([$A-Za-z_][$A-Za-z0-9_]*)\(\{[^{}]*?\bbuildFlavor\s*:/g;
const WINDOW_SERVICE_FINGERPRINTS = [
"allowDevtools:",
"allowDebugMenu:",
Expand Down
26 changes: 18 additions & 8 deletions packages/installer/src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import { setIntegrity, getIntegrity } from "../integrity.js";
import { writeFuse } from "../fuses.js";
import { adHocSign, clearQuarantine, signatureInfo } from "../codesign.js";
import { readPlist } from "../plist.js";
import { writeState } from "../state.js";
import { readState, writeState } from "../state.js";
import { prepareBackupSet } from "../backups.js";
import { installWatcher, type WatcherKind } from "../watcher.js";
import { CODEX_PLUSPLUS_VERSION } from "../version.js";
import { installDefaultTweaks } from "../default-tweaks.js";
Expand Down Expand Up @@ -59,6 +60,13 @@ export async function install(opts: Opts = {}): Promise<void> {
step(formatCliShimResult(installCliShims(paths.binDir)));

// 1. Backup originals.
const backupRotated = prepareBackupSet({
appRoot: codex.appRoot,
asarPath: codex.asarPath,
backupDir: paths.backup,
previousState: readState(paths.stateFile),
});
if (backupRotated) step("Retired backup from previous Codex build");
const pristineAppBackup = codex.platform === "darwin" ? join(paths.backup, "Codex.app") : null;
const backupAsar = join(paths.backup, "app.asar");
const backupAsarUnpacked = join(paths.backup, "app.asar.unpacked");
Expand Down Expand Up @@ -236,14 +244,16 @@ function patchCodexWindowServices(appDir: string, originalMain: string): void {
throw new Error("Codex window services hook point not found");
}

function findCodexMainCandidates(appDir: string, originalMain: string): string[] {
export function findCodexMainCandidates(appDir: string, originalMain: string): string[] {
const out = [resolve(appDir, originalMain)];
const buildDir = resolve(appDir, ".vite", "build");
try {
for (const name of readdirSync(buildDir)) {
if (/^main-.*\.js$/.test(name)) out.push(resolve(buildDir, name));
}
} catch {}
const originalMainDir = resolve(appDir, originalMain, "..");
for (const buildDir of [originalMainDir, resolve(appDir, ".vite", "build")]) {
try {
for (const name of readdirSync(buildDir)) {
if (/^main-.*\.js$/.test(name)) out.push(resolve(buildDir, name));
}
} catch {}
}
return [...new Set(out)].filter((p) => existsSync(p));
}

Expand Down
73 changes: 64 additions & 9 deletions packages/installer/src/platform.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { existsSync, readdirSync, statSync } from "node:fs";
import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
import { homedir, platform } from "node:os";
import { basename, join } from "node:path";
import { basename, join, resolve } from "node:path";
import { readPlist } from "./plist.js";

export type Platform = "darwin" | "win32" | "linux";
Expand Down Expand Up @@ -167,34 +167,89 @@ function locateWin(override?: string): CodexInstall {
}

function locateLinux(override?: string): CodexInstall {
// Codex isn't yet shipped on Linux at time of writing; assume an Electron-style
// unpacked install or a deb/rpm in /opt.
// Linux builds are distributed by community ports today. Support unpacked
// Electron installs from deb/rpm packages as well as user-local symlinked
// installs used by am-will/codex-app.
const candidates = [
override,
"/usr/lib/codex-desktop",
"/opt/codex-desktop/current",
"/opt/codex-desktop",
"/opt/Codex",
"/opt/codex",
join(homedir(), ".local", "opt", "codex-desktop", "current"),
join(homedir(), ".local", "opt", "codex-desktop"),
join(homedir(), ".local", "share", "codex-desktop", "current"),
join(homedir(), ".local", "share", "codex-desktop"),
join(homedir(), ".local", "share", "Codex"),
].filter(Boolean) as string[];
const appRoot = candidates.find((p) => existsSync(join(p, "resources", "app.asar")));
if (!appRoot) {
const install = candidates.map(resolveLinuxInstall).find((p): p is LinuxInstallCandidate => p !== null);
if (!install) {
throw new Error(
`[!] Codex App Not Found\n\n` +
`Ensure Codex is installed in a supported Linux location.\n` +
`Tried:\n ${candidates.join("\n ")}\n\n` +
`If Codex is somewhere else, rerun with --app pointing at its install folder.`,
);
}
const resourcesDir = join(appRoot, "resources");
const { appRoot, resourcesDir, executable } = install;
return {
appRoot,
resourcesDir,
asarPath: join(resourcesDir, "app.asar"),
metaPath: null,
electronBinary: join(appRoot, "codex"),
executable: join(appRoot, "codex"),
electronBinary: executable,
executable,
appName: "Codex",
bundleId: null,
channel: "stable",
platform: "linux",
};
}

interface LinuxInstallCandidate {
appRoot: string;
resourcesDir: string;
executable: string;
}

function resolveLinuxInstall(candidate: string): LinuxInstallCandidate | null {
let resolved = candidate;
try {
resolved = realpathSync(candidate);
} catch {
// Keep the original path so the directory checks below can fail normally.
}

const roots: string[] = [];
if (existsSync(resolved)) {
try {
const stat = statSync(resolved);
if (stat.isDirectory()) {
roots.push(resolved);
if (basename(resolved) === "resources") roots.push(resolve(resolved, ".."));
} else if (stat.isFile()) {
roots.push(resolve(resolved, ".."));
}
} catch {}
}
roots.push(resolved);

for (const root of [...new Set(roots)]) {
const resourcesDir = join(root, "resources");
if (!existsSync(join(resourcesDir, "app.asar"))) continue;
const executable = findLinuxExecutable(root);
if (!executable) continue;
return { appRoot: root, resourcesDir, executable };
}
return null;
}

function findLinuxExecutable(appRoot: string): string | null {
const candidates = [
"Codex",
"codex-desktop",
"codex",
].map((name) => join(appRoot, name));
return candidates.find((p) => existsSync(p)) ?? null;
}
Loading