import { readdirSync, existsSync, mkdtempSync, copyFileSync, rmSync, statSync } from "node:fs";
import { join, basename } from "node:path";
import { homedir, tmpdir } from "node:os";
import { run } from "../util.js";
/** storage.local key the --debug ring buffer persists under (see debug-ring.js). */
export const DEBUG_LOG_KEY = "__viaduct_debug_log__";
/** Safari keeps each installed web extension's storage.local as a SQLite file
* on disk: /.Extension ()/LocalStorage.db, table
* extension_storage (key TEXT, value TEXT with JSON values). Readable with the
* extension installed and Safari open — no console needed. Safari Technology
* Preview keeps the same layout in its own container; both are searched. */
export const SAFARI_EXTENSION_STORAGE_DIR = join(
homedir(),
"Library", "Containers", "com.apple.Safari", "Data", "Library", "WebKit", "WebExtensions", "Default",
);
export const SAFARI_TP_EXTENSION_STORAGE_DIR = join(
homedir(),
"Library", "Containers", "com.apple.SafariTechnologyPreview", "Data", "Library", "WebKit", "WebExtensions", "Default",
);
export interface DebugLogEntry {
/** epoch ms */
t: number;
/** background | content | page */
ctx: string;
msg: string;
}
/** Case/spacing/punctuation-insensitive containment, so both an app name
* ("TWP Translate Web Pages") and a bundle id fragment match the storage dir
* ("com.viaduct.TWPTranslateWebPagesd221b48f.Extension (TEAM)"). */
function normalize(s: string): string {
return s.toLowerCase().replace(/[^a-z0-9]/g, "");
}
function storageDirs(baseDir: string): string[] {
let names: string[];
try {
names = readdirSync(baseDir);
} catch {
return [];
}
return names.filter((n) => existsSync(join(baseDir, n, "LocalStorage.db"))).map((n) => join(baseDir, n));
}
/** When the same extension has storage in more than one browser, the one that
* wrote most recently is the one being exercised. */
function lastWrite(dir: string): number {
let t = 0;
for (const f of ["LocalStorage.db", "LocalStorage.db-wal"]) {
try { t = Math.max(t, statSync(join(dir, f)).mtimeMs); } catch {}
}
return t;
}
/**
* Read the persisted --debug ring buffer of an installed converted extension
* straight from Safari's on-disk storage. Throws with an actionable message on
* every miss (no store, ambiguous name, no log recorded).
*
* The DB is WAL-journaled and possibly live under Safari, so the file trio is
* copied to a scratch dir first and queried read-only there — never touching
* Safari's own handle.
*/
export function readDebugLog(
query: string,
baseDirs: string | string[] = [SAFARI_EXTENSION_STORAGE_DIR, SAFARI_TP_EXTENSION_STORAGE_DIR],
): { entries: DebugLogEntry[]; dir: string } {
const bases = Array.isArray(baseDirs) ? baseDirs : [baseDirs];
const dirs = bases.flatMap(storageDirs);
if (dirs.length === 0) {
throw new Error(`No Safari web-extension storage found under ${bases.join(" or ")} — is a converted extension installed and enabled?`);
}
const q = normalize(query);
const matches = q ? dirs.filter((d) => normalize(basename(d)).includes(q)) : [];
if (matches.length === 0) {
throw new Error(`No installed extension matches "${query}". Extensions with storage on disk:\n ${dirs.join("\n ")}`);
}
// The same bundle id in both browsers is one extension, not an ambiguity.
const names = new Set(matches.map((d) => basename(d)));
if (names.size > 1) {
throw new Error(`"${query}" matches ${names.size} extensions — be more specific:\n ${matches.join("\n ")}`);
}
const dir = matches.reduce((best, d) => (lastWrite(d) > lastWrite(best) ? d : best));
const scratch = mkdtempSync(join(tmpdir(), "viaduct-logs-"));
try {
// Copy the WAL trio so the read-only query sees a consistent snapshot even
// while Safari holds the live database.
for (const f of ["LocalStorage.db", "LocalStorage.db-wal", "LocalStorage.db-shm"]) {
const src = join(dir, f);
if (existsSync(src)) copyFileSync(src, join(scratch, f));
}
const res = run("/usr/bin/sqlite3", [
"-readonly",
join(scratch, "LocalStorage.db"),
`SELECT value FROM extension_storage WHERE key='${DEBUG_LOG_KEY}';`,
]);
if (res.code !== 0) {
throw new Error(`Could not read ${join(dir, "LocalStorage.db")}: ${res.stderr.trim() || `sqlite3 exited ${res.code}`}`);
}
const raw = res.stdout.trim();
if (!raw) {
throw new Error(
`${basename(dir)} has no recorded debug log. The ring buffer only exists in a --debug conversion — ` +
`re-convert with --debug, reinstall, exercise the extension, then retry.`,
);
}
let entries: DebugLogEntry[];
try {
entries = JSON.parse(raw) as DebugLogEntry[];
} catch {
throw new Error(`${basename(dir)}: the stored ${DEBUG_LOG_KEY} value is not valid JSON.`);
}
if (!Array.isArray(entries)) {
throw new Error(`${basename(dir)}: the stored ${DEBUG_LOG_KEY} value is not a log array.`);
}
return { entries, dir };
} finally {
rmSync(scratch, { recursive: true, force: true });
}
}
/** One line per entry: ISO timestamp, context label, message. */
export function formatDebugLog(entries: DebugLogEntry[]): string {
return entries
.map((e) => `${new Date(e.t).toISOString()} [${e.ctx}] ${e.msg}`)
.join("\n");
}