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;
|
}
|
}
|
}
|