const RECORD_SCHEMA = 1; export const OWNED_TAB_STORAGE_KEY = "project_info_dynamic_owned_tab_v1"; export const SLOT_STATE_STORAGE_KEY = "project_info_dynamic_slot_state_v1"; export const FIXED_LIFECYCLE = Object.freeze({ CLOSED: "OWNED_TAB_CLOSED", ALREADY_CLOSED: "OWNED_TAB_ALREADY_CLOSED", IDENTITY_DRIFT: "OWNED_TAB_IDENTITY_DRIFT", CLOSE_FAILED: "OWNED_TAB_CLOSE_FAILED", PEER_CLOSED: "E_NATIVE_PEER_CLOSED", TIMEOUT: "E_NATIVE_TIMEOUT" }); const HEX32 = /^[0-9a-f]{32}$/u; const HEX64 = /^[0-9a-f]{64}$/u; const SLOT_MATERIAL = /^dynamic-slot:(0|[1-9][0-9]{0,15})$/u; const SLOT_STATES = new Set(["STARTED", "COMPLETE", "FAILED"]); const SLOT_HISTORY_LIMIT = 336; function plain(value) { if (value === null || typeof value !== "object" || Array.isArray(value)) return false; const prototype = Object.getPrototypeOf(value); return prototype === Object.prototype || prototype === null; } function exactKeys(value, keys) { return plain(value) && Object.keys(value).sort().join("\0") === [...keys].sort().join("\0"); } export function canonicalDynamicUrl(value) { const parsed = new URL(String(value)); const parts = parsed.pathname.split("/").filter(Boolean); if (parsed.protocol !== "https:" || parsed.hostname !== "space.bilibili.com" || parsed.search !== "" || parsed.hash !== "" || parts.length !== 2 || !/^[1-9][0-9]{0,19}$/u.test(parts[0]) || parts[1] !== "dynamic") { throw new Error("E_ACTION"); } return `https://space.bilibili.com/${parts[0]}/dynamic`; } function tabLocationMatches(tab, expected) { for (const raw of [tab?.url, tab?.pendingUrl]) { if (typeof raw !== "string") continue; try { const parsed = new URL(raw); const canonical = canonicalDynamicUrl(`${parsed.origin}${parsed.pathname}`); if (canonical === expected && parsed.search === "" && parsed.hash === "") return true; } catch { // A non-canonical location never proves ownership. } } return false; } function exactRecord(value) { const keys = ["schema", "slot_id", "lease_id", "tab_id", "window_id", "target_url"]; if (!exactKeys(value, keys) || value.schema !== RECORD_SCHEMA || !HEX64.test(value.slot_id) || !HEX32.test(value.lease_id) || !Number.isInteger(value.tab_id) || value.tab_id < 0 || !Number.isInteger(value.window_id) || value.window_id < 0) return null; let target; try { target = canonicalDynamicUrl(value.target_url); } catch { return null; } if (target !== value.target_url) return null; return Object.freeze({...value}); } function markerValue(record) { return `${record.slot_id}:${record.lease_id}`; } function setOwnershipMarker(expected) { document.documentElement.dataset.projectInfoDynamicOwner = expected; return document.documentElement.dataset.projectInfoDynamicOwner; } function readOwnershipMarker() { return document.documentElement.dataset.projectInfoDynamicOwner || null; } async function sleep(milliseconds) { await new Promise((resolve) => setTimeout(resolve, milliseconds)); } export class OwnedTabLifecycle { constructor(chromeApi, {randomHex, delay = sleep, pollMilliseconds = 100, maxPolls = 300} = {}) { this.chrome = chromeApi; this.randomHex = randomHex || ((bytes) => { const value = new Uint8Array(bytes); crypto.getRandomValues(value); return [...value].map((byte) => byte.toString(16).padStart(2, "0")).join(""); }); this.delay = delay; this.pollMilliseconds = pollMilliseconds; this.maxPolls = maxPolls; this.lastDiagnostic = null; } async load() { const container = await this.chrome.storage.session.get(OWNED_TAB_STORAGE_KEY); const raw = plain(container) ? container[OWNED_TAB_STORAGE_KEY] : null; if (raw === undefined || raw === null) return null; const record = exactRecord(raw); if (record !== null) return record; await this.chrome.storage.session.remove(OWNED_TAB_STORAGE_KEY); this.lastDiagnostic = FIXED_LIFECYCLE.IDENTITY_DRIFT; return null; } async cleanupFresh(record, tab) { // This capability never escapes create(). The tab id comes directly from // tabs.create, so a pre-existing user tab is never an eligible target. try { await this.chrome.storage.session.remove(OWNED_TAB_STORAGE_KEY); } catch { /* best effort */ } const tabId = record?.tab_id ?? tab?.id; const windowId = record?.window_id ?? tab?.windowId; if (!Number.isInteger(tabId) || !Number.isInteger(windowId) || tab?.id !== tabId || tab?.windowId !== windowId) return; try { await this.chrome.tabs.remove(tabId); this.lastDiagnostic = FIXED_LIFECYCLE.CLOSED; } catch { this.lastDiagnostic = FIXED_LIFECYCLE.CLOSE_FAILED; } } async create(slotId, targetUrl, leaseId = null) { if (!HEX64.test(slotId)) throw new Error("E_SLOT_ID"); const target = canonicalDynamicUrl(targetUrl); const tab = await this.chrome.tabs.create({url: target, active: false}); if (!Number.isInteger(tab?.id) || !Number.isInteger(tab?.windowId)) { await this.cleanupFresh(null, tab); throw new Error("E_TAB_CREATE"); } const record = exactRecord({ schema: RECORD_SCHEMA, slot_id: slotId, lease_id: leaseId === null ? this.randomHex(16) : leaseId, tab_id: tab.id, window_id: tab.windowId, target_url: target }); if (record === null) { await this.cleanupFresh(null, tab); throw new Error("E_TAB_IDENTITY"); } try { await this.chrome.storage.session.set({[OWNED_TAB_STORAGE_KEY]: record}); const ready = await this.waitUntilComplete(record); const injected = await this.chrome.scripting.executeScript({ target: {tabId: ready.id}, func: setOwnershipMarker, args: [markerValue(record)] }); if (!Array.isArray(injected) || injected.length !== 1 || injected[0]?.result !== markerValue(record)) { throw new Error("E_TAB_MARKER"); } return {record, tab: ready}; } catch (error) { await this.cleanupFresh(record, tab); throw error; } } async waitUntilComplete(record) { for (let index = 0; index < this.maxPolls; index += 1) { const tab = await this.chrome.tabs.get(record.tab_id); if (tab?.id !== record.tab_id || tab?.windowId !== record.window_id || !tabLocationMatches(tab, record.target_url)) { throw new Error("E_TAB_IDENTITY"); } if (tab.status === "complete") return tab; await this.delay(this.pollMilliseconds); } throw new Error("E_TAB_TIMEOUT"); } async cleanup(recordValue = null) { const record = exactRecord(recordValue) || await this.load(); if (record === null) return FIXED_LIFECYCLE.ALREADY_CLOSED; let tab; try { tab = await this.chrome.tabs.get(record.tab_id); } catch { await this.chrome.storage.session.remove(OWNED_TAB_STORAGE_KEY); this.lastDiagnostic = FIXED_LIFECYCLE.ALREADY_CLOSED; return this.lastDiagnostic; } let marker = null; try { const observed = await this.chrome.scripting.executeScript({ target: {tabId: record.tab_id}, func: readOwnershipMarker }); if (Array.isArray(observed) && observed.length === 1) marker = observed[0]?.result ?? null; } catch { marker = null; } if (tab?.id !== record.tab_id || tab?.windowId !== record.window_id || !tabLocationMatches(tab, record.target_url) || marker !== markerValue(record)) { await this.chrome.storage.session.remove(OWNED_TAB_STORAGE_KEY); this.lastDiagnostic = FIXED_LIFECYCLE.IDENTITY_DRIFT; return this.lastDiagnostic; } // Retire the durable capability before the sole non-atomic tab mutation. // A failed removal is terminal: never retry or navigate the numeric tab id. await this.chrome.storage.session.remove(OWNED_TAB_STORAGE_KEY); try { await this.chrome.tabs.remove(record.tab_id); this.lastDiagnostic = FIXED_LIFECYCLE.CLOSED; } catch { this.lastDiagnostic = FIXED_LIFECYCLE.CLOSE_FAILED; } return this.lastDiagnostic; } } function exactSlotEntry(value) { const keys = ["schema", "slot_material", "slot_number", "slot_id", "lease_id", "state", "result_code"]; if (!exactKeys(value, keys) || value.schema !== 1 || !SLOT_MATERIAL.test(value.slot_material) || !Number.isSafeInteger(value.slot_number) || value.slot_number < 0 || value.slot_material !== `dynamic-slot:${value.slot_number}` || !HEX64.test(value.slot_id) || !HEX32.test(value.lease_id) || !SLOT_STATES.has(value.state)) return null; if (value.state === "STARTED" && value.result_code !== null) return null; if (value.state === "COMPLETE" && value.result_code !== "SLOT_COMPLETE") return null; if (value.state === "FAILED" && (typeof value.result_code !== "string" || !/^E_[A-Z0-9_]+$/u.test(value.result_code))) return null; return Object.freeze({...value}); } function exactSlotRoot(value) { if (!exactKeys(value, ["schema", "entries"]) || value.schema !== 1 || !Array.isArray(value.entries) || value.entries.length > SLOT_HISTORY_LIMIT) return null; const entries = []; const seen = new Set(); for (const raw of value.entries) { const entry = exactSlotEntry(raw); if (entry === null || seen.has(entry.slot_material)) return null; seen.add(entry.slot_material); entries.push(entry); } if (entries.filter((entry) => entry.state === "STARTED").length > 1) return null; return Object.freeze({schema: 1, entries: Object.freeze(entries)}); } export class SlotStateStore { constructor(chromeApi, {randomHex, historyLimit = SLOT_HISTORY_LIMIT} = {}) { this.chrome = chromeApi; this.historyLimit = historyLimit; this.randomHex = randomHex || ((bytes) => { const value = new Uint8Array(bytes); crypto.getRandomValues(value); return [...value].map((byte) => byte.toString(16).padStart(2, "0")).join(""); }); if (!Number.isSafeInteger(historyLimit) || historyLimit < 2 || historyLimit > SLOT_HISTORY_LIMIT) { throw new Error("E_SLOT_STATE"); } } async read() { const container = await this.chrome.storage.local.get(SLOT_STATE_STORAGE_KEY); const raw = plain(container) ? container[SLOT_STATE_STORAGE_KEY] : undefined; if (raw === undefined) return Object.freeze({schema: 1, entries: Object.freeze([])}); const root = exactSlotRoot(raw); if (root === null) throw new Error("E_SLOT_STATE"); return root; } async claim(slotMaterial, slotId) { if (!SLOT_MATERIAL.test(slotMaterial) || !HEX64.test(slotId)) throw new Error("E_SLOT_STATE"); const slotNumber = Number(slotMaterial.slice("dynamic-slot:".length)); if (!Number.isSafeInteger(slotNumber) || slotMaterial !== `dynamic-slot:${slotNumber}`) { throw new Error("E_SLOT_STATE"); } const root = await this.read(); const existing = root.entries.find((entry) => entry.slot_material === slotMaterial); if (existing !== undefined) { if (existing.slot_id !== slotId) throw new Error("E_SLOT_STATE"); return {disposition: existing.state === "STARTED" ? "RESUME" : "TERMINAL", entry: existing}; } if (root.entries.some((entry) => entry.state === "STARTED")) return {disposition: "BUSY", entry: null}; const entry = exactSlotEntry({ schema: 1, slot_material: slotMaterial, slot_number: slotNumber, slot_id: slotId, lease_id: this.randomHex(16), state: "STARTED", result_code: null }); if (entry === null) throw new Error("E_SLOT_STATE"); const terminal = root.entries.filter((item) => item.state !== "STARTED"); const kept = terminal.slice(Math.max(0, terminal.length - (this.historyLimit - 1))); const next = {schema: 1, entries: [...kept, entry]}; await this.chrome.storage.local.set({[SLOT_STATE_STORAGE_KEY]: next}); const rebound = await this.read(); const stored = rebound.entries.find((item) => item.slot_material === slotMaterial); if (stored === undefined || JSON.stringify(stored) !== JSON.stringify(entry)) throw new Error("E_SLOT_STATE"); return {disposition: "CLAIMED", entry: stored}; } async started() { const root = await this.read(); return root.entries.find((entry) => entry.state === "STARTED") ?? null; } async finish(entryValue, state, resultCode) { const expected = exactSlotEntry(entryValue); if (expected === null || expected.state !== "STARTED" || !["COMPLETE", "FAILED"].includes(state)) { throw new Error("E_SLOT_STATE"); } const candidate = exactSlotEntry({...expected, state, result_code: resultCode}); if (candidate === null) throw new Error("E_SLOT_STATE"); const root = await this.read(); const index = root.entries.findIndex((item) => item.slot_material === expected.slot_material); if (index < 0 || JSON.stringify(root.entries[index]) !== JSON.stringify(expected)) throw new Error("E_SLOT_STATE"); const entries = root.entries.map((item, itemIndex) => itemIndex === index ? candidate : item); await this.chrome.storage.local.set({[SLOT_STATE_STORAGE_KEY]: {schema: 1, entries}}); const rebound = await this.read(); const stored = rebound.entries.find((item) => item.slot_material === expected.slot_material); if (stored === undefined || JSON.stringify(stored) !== JSON.stringify(candidate)) throw new Error("E_SLOT_STATE"); return stored; } } export class NativePortTransport { constructor(port, {timeoutMilliseconds = 120000} = {}) { this.port = port; this.timeoutMilliseconds = timeoutMilliseconds; this.closed = false; this.writeChain = Promise.resolve(); this.listeners = new Set(); this.disconnectListeners = new Set(); // Install handlers before the caller can perform its first write. port.onMessage.addListener((message) => { for (const listener of [...this.listeners]) listener(message); }); port.onDisconnect.addListener(() => { this.closed = true; for (const listener of [...this.disconnectListeners]) listener(FIXED_LIFECYCLE.PEER_CLOSED); }); } onMessage(listener) { this.listeners.add(listener); } onDisconnect(listener) { this.disconnectListeners.add(listener); } send(message) { const operation = this.writeChain.then(() => { if (this.closed) throw new Error(FIXED_LIFECYCLE.PEER_CLOSED); try { this.port.postMessage(message); } catch { this.closed = true; throw new Error(FIXED_LIFECYCLE.PEER_CLOSED); } if (this.closed) throw new Error(FIXED_LIFECYCLE.PEER_CLOSED); }); this.writeChain = operation.catch(() => undefined); return operation; } disconnect() { if (this.closed) return; this.closed = true; try { this.port.disconnect(); } catch { /* peer already closed */ } } } export async function runNativeSession({transport, session}) { return await new Promise((resolve, reject) => { let settled = false; const timer = setTimeout(() => finish(new Error(FIXED_LIFECYCLE.TIMEOUT)), transport.timeoutMilliseconds); const finish = (error, value) => { if (settled) return; settled = true; clearTimeout(timer); transport.disconnect(); if (error) reject(error); else resolve(value); }; transport.onDisconnect((code) => finish(new Error(code))); transport.onMessage((message) => { Promise.resolve(session.accept(message)).then(async (reply) => { if (reply !== null) await transport.send(reply); if (message?.type === "HOST_COMMIT_RESULT") finish(null, message.result ?? null); }).catch((error) => finish(error)); }); transport.send(session.helloFrame()).catch((error) => finish(error)); }); } export class SlotCoordinator { constructor({lifecycle, slotStore, connect, createSession, hashSlot, collectPage}) { this.lifecycle = lifecycle; this.slotStore = slotStore; this.connect = connect; this.createSession = createSession; this.hashSlot = hashSlot; this.collectPage = collectPage; this.active = false; this.operationTail = Promise.resolve(); } async serialize(operation) { const prior = this.operationTail; let release; this.operationTail = new Promise((resolve) => { release = resolve; }); await prior.catch(() => undefined); try { return await operation(); } finally { release(); } } async recoverUnlocked() { const started = await this.slotStore.started(); if (started === null) { await this.lifecycle.cleanup(); return null; } const prior = await this.lifecycle.load(); if (prior !== null && (prior.slot_id !== started.slot_id || prior.lease_id !== started.lease_id)) { throw new Error("E_SLOT_STATE"); } const lifecycle_code = prior === null ? FIXED_LIFECYCLE.ALREADY_CLOSED : await this.lifecycle.cleanup(prior); await this.slotStore.finish(started, "FAILED", "E_SLOT_INTERRUPTED"); return Object.freeze({ slot_material: started.slot_material, lease_id: started.lease_id, result_code: "E_SLOT_INTERRUPTED", lifecycle_code }); } async recover() { return await this.serialize(() => this.recoverUnlocked()); } async runUnlocked(slotMaterial) { let owned = null; let claim = null; let outcome = null; try { const recovered = await this.recoverUnlocked(); if (recovered?.slot_material === slotMaterial) { return {status: "FAILED", error_code: recovered.result_code}; } const slotId = await this.hashSlot(slotMaterial); if (!HEX64.test(slotId)) throw new Error("E_SLOT_STATE"); claim = await this.slotStore.claim(slotMaterial, slotId); if (claim.disposition === "BUSY") return {status: "SKIPPED_OVERLAP"}; if (claim.disposition === "TERMINAL") { return {status: "SKIPPED_TERMINAL", terminal_state: claim.entry.state, result_code: claim.entry.result_code}; } if (claim.disposition === "RESUME") { const prior = await this.lifecycle.load(); if (prior !== null && (prior.slot_id !== claim.entry.slot_id || prior.lease_id !== claim.entry.lease_id)) { throw new Error("E_SLOT_STATE"); } if (prior !== null) await this.lifecycle.cleanup(prior); await this.slotStore.finish(claim.entry, "FAILED", "E_SLOT_INTERRUPTED"); return {status: "FAILED", error_code: "E_SLOT_INTERRUPTED"}; } await this.lifecycle.cleanup(); const lifecycle = this.lifecycle; const collectPage = this.collectPage; const api = { async prepare(action) { if (!plain(action) || !["reload", "goto"].includes(action.kind)) throw new Error("E_ACTION"); return {action_id: action.action_id, kind: "goto", url: canonicalDynamicUrl(action.url)}; }, async dispatch(prepared) { owned = await lifecycle.create(slotId, prepared.url, claim.entry.lease_id); return {action_id: prepared.action_id, tab_id: owned.record.tab_id, url: prepared.url}; }, async observe(result) { return await lifecycle.chrome.scripting.executeScript({ target: {tabId: result.tab_id}, func: collectPage, args: [result.url] }).then((items) => { if (!Array.isArray(items) || items.length !== 1) throw new Error("E_OBSERVATION_COUNT"); return items[0].result; }); } }; const transport = this.connect(); const result = await runNativeSession({transport, session: this.createSession(api)}); outcome = {status: "COMPLETE", result}; } catch (error) { const code = typeof error?.message === "string" && /^E_[A-Z0-9_]+$/u.test(error.message) ? error.message : "E_SLOT_FAILED"; outcome = {status: "FAILED", error_code: code}; } finally { if (owned !== null) await this.lifecycle.cleanup(owned.record); } if (claim?.disposition === "CLAIMED") { try { await this.slotStore.finish( claim.entry, outcome?.status === "COMPLETE" ? "COMPLETE" : "FAILED", outcome?.status === "COMPLETE" ? "SLOT_COMPLETE" : (outcome?.error_code || "E_SLOT_FAILED") ); } catch { return {status: "FAILED", error_code: "E_SLOT_STATE"}; } } return outcome || {status: "FAILED", error_code: "E_SLOT_FAILED"}; } async run(slotMaterial) { if (this.active) return {status: "SKIPPED_OVERLAP"}; this.active = true; try { return await this.serialize(() => this.runUnlocked(slotMaterial)); } finally { // The serialized operation includes terminal append and readback. this.active = false; } } }