import { readFileSync, writeFileSync, copyFileSync, existsSync } from "node:fs"; import { createHash } from "node:crypto"; import { join, dirname, relative } from "node:path"; import type { Manifest } from "../types.js"; import { TEMPLATE_DIR } from "../paths.js"; export const BRIDGE_POLYFILL = "identity-polyfill.js"; export const BRIDGE_PAGE = "page-bridge.js"; export const BRIDGE_PAGE_CS = "page-bridge-cs.js"; /** Placeholder the bridge templates carry; replaced with the real Chrome id when derivable. */ const EXT_ID_PLACEHOLDER = "__C2S_EXTENSION_ID__"; /** * Derive an extension's Chrome id from its manifest `key` (base64 DER public key). * Chrome's rule: SHA-256 the decoded key, take the first 16 bytes, and map each * nibble 0–15 to a–p. Returns undefined for an unpacked extension (no key) or a * malformed key, in which case the templates fall back to the live runtime id. */ export function deriveChromeId(manifest: Manifest): string | undefined { const key = manifest.key; if (typeof key !== "string" || key.length === 0) return undefined; // Buffer.from(str, "base64") never throws — it decodes what it can and ignores // invalid chars — so a length check (not a try/catch) is the real malformed-key // guard. An empty decode means the key had no valid base64 at all. const der = Buffer.from(key, "base64"); if (der.length === 0) return undefined; const digest = createHash("sha256").update(der).digest(); let id = ""; for (let i = 0; i < 16; i++) { id += String.fromCharCode(97 + (digest[i] >> 4)); id += String.fromCharCode(97 + (digest[i] & 0x0f)); } return id; } /** Replace the build-time extension-id placeholder in a staged template file. */ function substituteExtId(filePath: string, chromeId: string | undefined): void { if (!chromeId || !existsSync(filePath)) return; const src = readFileSync(filePath, "utf-8"); if (!src.includes(EXT_ID_PLACEHOLDER)) return; writeFileSync(filePath, src.split(EXT_ID_PLACEHOLDER).join(chromeId), "utf-8"); } interface WarEntry { resources: string[]; matches: string[]; use_dynamic_url?: boolean; } /** * Wire the Safari OAuth bridge into a staged MV3 extension. * * Safari gives web pages no `chrome` namespace and routes externally_connectable * messages by the *Safari* extension id, but pages hardcode the *Chrome* id — so * the page↔extension OAuth handshake (launchWebAuthFlow + the `oauth_redirect` * callback message) silently dies. This emits three bridge assets and rewires the * manifest so the handshake completes: * - identity-polyfill.js : shims chrome.identity in the SW + captures the SW's * onMessageExternal handler and re-dispatches bridged page messages to it. * - page-bridge.js : MAIN-world fake `chrome.runtime` that relays over * window.postMessage. * - page-bridge-cs.js : isolated-world relay page→SW (and back). * * No-op unless the extension has a background service worker. Mutates `manifest`. */ export function applyOAuthBridge(stageDir: string, manifest: Manifest, chromeId?: string): string[] { const notes: string[] = []; // A leading-slash service_worker path ("/sw.js") is root-relative in Chrome; // normalize it to manifest-relative or the relative() math in // injectPolyfillImport emits a cwd-derived garbage import that kills the SW. const rawSw = manifest.background?.service_worker; const sw = typeof rawSw === "string" ? rawSw.replace(/^\/+/, "") : rawSw; if (!sw) return notes; // only MV3 service-worker extensions have this handshake // 1. Emit the identity polyfill — it shims chrome.identity in the SW and is // imported below regardless of whether the page bridge gets wired. Only the // polyfill is always needed; the page-bridge templates are checked later, // where they're used, so a missing page bridge doesn't kill the SW shim. if (!existsSync(join(TEMPLATE_DIR, BRIDGE_POLYFILL))) { notes.push(`OAuth bridge template "${BRIDGE_POLYFILL}" is missing from the install; skipping chrome.identity bridge.`); return notes; } // The bridge templates carry a placeholder Chrome id. When the caller passes the // extension's real Chrome id (derived from the source manifest `key`) we bake it // in; otherwise the placeholder stays and the templates fall back to the live // runtime id at execution time. Either way the bridge works for ANY extension. copyFileSync(join(TEMPLATE_DIR, BRIDGE_POLYFILL), join(stageDir, BRIDGE_POLYFILL)); substituteExtId(join(stageDir, BRIDGE_POLYFILL), chromeId); // launchWebAuthFlow watches the auth tab via chrome.webNavigation; Chrome // extensions using identity typically don't declare it, so add it here (only for // identity users — an unused permission on every extension draws review scrutiny) // or the polyfill's onBeforeNavigate wiring throws (webNavigation is undefined). const usesIdentity = Array.isArray(manifest.permissions) && manifest.permissions.some((p) => typeof p === "string" && (p === "identity" || p.startsWith("identity."))); if (usesIdentity && Array.isArray(manifest.permissions) && !manifest.permissions.includes("webNavigation")) { manifest.permissions.push("webNavigation"); } // 2. The SW (or its loader) must run the polyfill FIRST so the bridge receiver // and chrome.identity shim install before the bundle evaluates. injectPolyfillImport(stageDir, sw); // 3. (No background.type mutation here.) convertServiceWorkerToBackgroundPage // runs later in the pipeline and overwrites manifest.background entirely with // { page, persistent:false }, loading the SW via