Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ npm install camoufox-js
```

The Camoufox browser itself is downloaded with `npx camoufox-js fetch` into the
per-user cache directory (`~/.cache/camoufox` on Linux). Set the
per-user cache directory (`$XDG_CACHE_HOME/camoufox` or `~/.cache/camoufox` on Linux). Set the
`CAMOUFOX_INSTALL_DIR` environment variable to install and resolve it from a
custom location instead — useful in containers and CI images where the home
directory is ephemeral or persisted separately (similar to Playwright's
Expand Down
71 changes: 56 additions & 15 deletions src/pkgman.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { Writable } from "node:stream";
import { setTimeout } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import AdmZip from "adm-zip";
import cliProgress, { Options } from "cli-progress";
import cliProgress, { type Options } from "cli-progress";
import prettyBytes from "pretty-bytes";
import { CONSTRAINTS } from "./__version__.js";
import {
Expand Down Expand Up @@ -271,9 +271,12 @@ export class CamoufoxFetcher extends GitHubDownloader {
return Buffer.from(await response.arrayBuffer());
}

async extractZip(zipFile: string | Buffer): Promise<void> {
async extractZip(
zipFile: string | Buffer,
destDir: string = INSTALL_DIR.toString(),
): Promise<void> {
const zip = new AdmZip(zipFile);
zip.extractAllTo(INSTALL_DIR.toString(), true);
zip.extractAllTo(destDir, true);
}

static cleanup(): boolean {
Expand All @@ -284,37 +287,69 @@ export class CamoufoxFetcher extends GitHubDownloader {
return false;
}

setVersion(): void {
setVersion(destDir: string = INSTALL_DIR.toString()): void {
fs.writeFileSync(
path.join(INSTALL_DIR.toString(), "version.json"),
path.join(destDir, "version.json"),
JSON.stringify({ version: this.version, release: this.release }),
);
}

// Remove staging/backup dirs left behind by an interrupted install.
private static removeLeftovers(installDir: string): void {
const parent = path.dirname(installDir);
const base = path.basename(installDir);
for (const name of fs.readdirSync(parent)) {
if (
name.startsWith(`${base}.staging-`) ||
name.startsWith(`${base}.old-`)
) {
fs.rmSync(path.join(parent, name), { recursive: true, force: true });
Comment thread
Pijukatel marked this conversation as resolved.
Outdated
}
}
}

// Swap the staged install in; restore the previous one if that fails.
private static swapIn(stagingDir: string, installDir: string): void {
const backupDir = fs.existsSync(installDir)
? `${installDir}.old-${process.pid}`
: undefined;
if (backupDir) fs.renameSync(installDir, backupDir);
Comment thread
Pijukatel marked this conversation as resolved.
Outdated
try {
fs.renameSync(stagingDir, installDir);
} catch (e) {
if (backupDir) fs.renameSync(backupDir, installDir);
throw e;
}
if (backupDir) fs.rmSync(backupDir, { recursive: true, force: true });
}

async install(): Promise<void> {
await this.init();
await CamoufoxFetcher.cleanup();
// Set up outside the try so finally can always tear down the ~600MB staging dir.
const installDir = INSTALL_DIR.toString();
fs.mkdirSync(path.dirname(installDir), { recursive: true });
CamoufoxFetcher.removeLeftovers(installDir);
// Staged next to INSTALL_DIR so the final rename stays on one filesystem.
// Set up outside the try so finally can always tear down the ~600MB staging dirs.
const stagingDir = fs.mkdtempSync(`${installDir}.staging-`);
Comment thread
Pijukatel marked this conversation as resolved.
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "camoufox-"));
const tempFilePath = path.join(tempDir, "camoufox.zip");
const tempFileStream = fs.createWriteStream(tempFilePath);
try {
fs.mkdirSync(INSTALL_DIR, { recursive: true });

await webdl(this.url, "Downloading Camoufox...", true, tempFileStream);
await new Promise((r) => tempFileStream.close(r));

await this.extractZip(tempFilePath);
this.setVersion();
await this.extractZip(tempFilePath, stagingDir);
this.setVersion(stagingDir);

if (OS_NAME !== "win") {
execFileSync("chmod", ["-R", "755", INSTALL_DIR.toString()]);
execFileSync("chmod", ["-R", "755", stagingDir]);
}

CamoufoxFetcher.swapIn(stagingDir, installDir);

console.log("Camoufox successfully installed.");
} catch (e) {
console.error(`Error installing Camoufox: ${e}`);
await CamoufoxFetcher.cleanup();
throw e;
} finally {
// Best-effort teardown: a throw here would mask the real result, and the caller is fire-and-forget.
Expand All @@ -326,8 +361,9 @@ export class CamoufoxFetcher extends GitHubDownloader {
);
}
fs.rmSync(tempDir, { recursive: true, force: true });
fs.rmSync(stagingDir, { recursive: true, force: true });
} catch (cleanupErr) {
console.error(`Failed to remove staging dir ${tempDir}: ${cleanupErr}`);
console.error(`Failed to remove staging dir: ${cleanupErr}`);
}
}
}
Expand Down Expand Up @@ -382,7 +418,12 @@ function userCacheDir(appName: string): string {
} else if (OS_NAME === "mac") {
return path.join(os.homedir(), "Library", "Caches", appName);
} else {
return path.join(os.homedir(), ".cache", appName);
// Per the XDG spec a relative XDG_CACHE_HOME is invalid and ignored.
const xdg = process.env.XDG_CACHE_HOME;
return path.join(
xdg && path.isAbsolute(xdg) ? xdg : path.join(os.homedir(), ".cache"),
appName,
);
}
}

Expand Down
69 changes: 57 additions & 12 deletions test/pkgman.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import AdmZip from "adm-zip";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";

// INSTALL_DIR is a module-level constant, so each case re-imports the module
Expand All @@ -19,6 +20,28 @@ describe("INSTALL_DIR", () => {
expect(path.isAbsolute(INSTALL_DIR.toString())).toBe(true);
});

test.skipIf(process.platform !== "linux")(
"honors an absolute XDG_CACHE_HOME on Linux",
async () => {
vi.stubEnv("CAMOUFOX_INSTALL_DIR", "");
vi.stubEnv("XDG_CACHE_HOME", "/xdg-cache");
vi.resetModules();
const { INSTALL_DIR } = await import("../src/pkgman");
expect(INSTALL_DIR).toBe(path.join("/xdg-cache", "camoufox"));
},
);

test.skipIf(process.platform !== "linux")(
"ignores a relative XDG_CACHE_HOME",
async () => {
vi.stubEnv("CAMOUFOX_INSTALL_DIR", "");
vi.stubEnv("XDG_CACHE_HOME", "relative/cache");
vi.resetModules();
const { INSTALL_DIR } = await import("../src/pkgman");
expect(INSTALL_DIR).toBe(path.join(os.homedir(), ".cache", "camoufox"));
},
);

test("CAMOUFOX_INSTALL_DIR overrides the install location", async () => {
const target = path.join("custom", "camoufox-install");
vi.stubEnv("CAMOUFOX_INSTALL_DIR", target);
Expand Down Expand Up @@ -63,13 +86,16 @@ function failingFetch() {
}));
}

// A fetch that writes a few bytes and completes cleanly.
// A fetch that serves a small real zip and completes cleanly.
function succeedingFetch() {
const zip = new AdmZip();
zip.addFile("camoufox", Buffer.from("binary"));
const bytes = new Uint8Array(zip.toBuffer());
return vi.fn(async () => ({
ok: true,
headers: { get: () => "0" },
body: (async function* () {
yield new Uint8Array([1, 2, 3]);
yield bytes;
})(),
}));
}
Expand Down Expand Up @@ -99,40 +125,59 @@ describe("CamoufoxFetcher.install cleanup", () => {
fs.rmSync(tmp, { recursive: true, force: true });
});

// Staging dirs install() creates: <tmpdir>/camoufox-<6 random chars>.
// Dirs install() creates: <tmpdir>/camoufox-<6 random chars>,
// <install dir>.staging-<6 random chars> and <install dir>.old-<pid>.
function stagingDirs(): string[] {
return fs
.readdirSync(tmp)
.filter((n) => /^camoufox-[A-Za-z0-9]{6}$/.test(n));
.filter((n) =>
/^(camoufox-[A-Za-z0-9]{6}|install\.(staging|old)-.+)$/.test(n),
);
}

async function installWith(fetchImpl: ReturnType<typeof vi.fn>) {
const { CamoufoxFetcher } = await import("../src/pkgman");
const fetcher = new CamoufoxFetcher();
// Skip the release lookup; just hand install() a URL to download.
// Skip the release lookup; just hand install() a URL and version.
vi.spyOn(fetcher, "init").mockImplementation(async () => {
(fetcher as unknown as { _url: string })._url =
"https://example.test/camoufox.zip";
const f = fetcher as unknown as { _url: string; _version_obj: unknown };
f._url = "https://example.test/camoufox.zip";
f._version_obj = { version: "1.0", release: "beta.1" };
});
// Don't extract the placeholder zip or write a version file.
vi.spyOn(fetcher, "extractZip").mockResolvedValue(undefined);
vi.spyOn(fetcher, "setVersion").mockImplementation(() => {});
vi.stubGlobal("fetch", fetchImpl);
vi.stubGlobal("console", { ...console, error: vi.fn(), log: vi.fn() });
return fetcher;
}

test("removes the staging dir when the download fails", async () => {
test("keeps the previous install when the download fails", async () => {
const marker = path.join(installDir, "version.json");
fs.writeFileSync(marker, "{}");
const fetcher = await installWith(failingFetch());
// The original download error must survive, and no staging dir is left.
await expect(fetcher.install()).rejects.toThrow("connection reset");
expect(stagingDirs()).toEqual([]);
expect(fs.existsSync(marker)).toBe(true);
});

test("removes the staging dir after a successful install", async () => {
test("replaces the previous install after a successful install", async () => {
const marker = path.join(installDir, "old-file");
fs.writeFileSync(marker, "");
// Leftovers from an interrupted earlier install must be swept.
fs.mkdirSync(path.join(tmp, "install.staging-abc123"));
fs.mkdirSync(path.join(tmp, "install.old-1"));
const fetcher = await installWith(succeedingFetch());
// A successful install must resolve (not throw) and leave no staging dir.
await expect(fetcher.install()).resolves.toBeUndefined();
expect(stagingDirs()).toEqual([]);
expect(fs.existsSync(marker)).toBe(false);
expect(fs.readdirSync(installDir).sort()).toEqual([
"camoufox",
"version.json",
]);
expect(
JSON.parse(
fs.readFileSync(path.join(installDir, "version.json"), "utf8"),
),
).toEqual({ version: "1.0", release: "beta.1" });
});
});
Loading