Skip to content
Merged
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
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
58 changes: 43 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,56 @@ 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 dirs left behind by an interrupted install.
private static removeLeftovers(installDir: string): void {
const parent = path.dirname(installDir);
const prefix = `${path.basename(installDir)}.staging-`;
for (const name of fs.readdirSync(parent)) {
if (name.startsWith(prefix)) {
fs.rmSync(path.join(parent, name), { 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.
// Resolve a symlinked install dir so the swap replaces its target, not the link.
const installDir = fs.existsSync(INSTALL_DIR)
? fs.realpathSync(INSTALL_DIR)
: INSTALL_DIR.toString();
fs.mkdirSync(path.dirname(installDir), { recursive: true });
CamoufoxFetcher.removeLeftovers(installDir);
// Staged next to the 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]);
}

// Replace the previous install only once the new one is complete.
fs.rmSync(installDir, { recursive: true, force: true });
fs.renameSync(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 +348,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 +405,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
87 changes: 75 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,77 @@ 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>
// and <install dir>.staging-<6 random chars>.
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|target)\.staging-.+)$/.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.skipIf(process.platform === "win32")(
"swaps the target of a symlinked install dir and keeps the link",
async () => {
const target = path.join(tmp, "target");
fs.mkdirSync(target);
fs.writeFileSync(path.join(target, "old-file"), "");
fs.rmSync(installDir, { recursive: true });
fs.symlinkSync(target, installDir);
const fetcher = await installWith(succeedingFetch());
await expect(fetcher.install()).resolves.toBeUndefined();
expect(stagingDirs()).toEqual([]);
expect(fs.lstatSync(installDir).isSymbolicLink()).toBe(true);
expect(fs.readdirSync(target).sort()).toEqual([
"camoufox",
"version.json",
]);
},
);

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"));
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