Files
ext-rclone-jav/background.js
T

1309 lines
50 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// rclonex background service worker.
// Owns the native-messaging port, dispatches lookups, draws badge/notifications.
// Shared ID-extraction primitives. Same file is listed in manifest
// content_scripts[] for the content script context.
importScripts("src/shared/id-extract.js");
const HOST_NAME = "com.rcjav.host";
const DEFAULT_SETTINGS = {
triggers: {
autoEveryPage: false,
autoKnownSites: false,
autoPageLoad: true,
autoSpaNavigation: true,
toolbarClick: true, // popup
contextMenu: true,
keyboardShortcut: true,
},
knownSitePatterns: [],
quickMode: true,
cacheStaleHours: 24,
scanPaused: false,
rcjavPath: "", // empty = use host's built-in default
showOverlay: true,
overlayPosition: "top-right", // top-left, top-right, bottom-left, bottom-right
overlayDuration: 5, // seconds
overlayGlow: false, // subtle outer glow
overlayGlowColor: "#6ec1ff", // glow hue when enabled
overlayGlowBlur: 10, // softness in px
overlayGlowSpread: 0, // expansion in px
overlayGlowOpacity: 0.35, // 0.051.0
// No-match overlay (opt-in, separate from match overlay)
noMatchOverlay: false,
noMatchPosition: "top-right",
noMatchDuration: 5,
noMatchGlow: false,
noMatchGlowColor: "#ff6666",
noMatchGlowBlur: 10,
noMatchGlowSpread: 0,
noMatchGlowOpacity: 0.35,
// Deletion (opt-in, off by default).
enableDelete: false,
deleteMode: "trash", // "trash" or "permanent"
trashDir: "cq:personal-files/.rclone-jav-trash",
partPatterns: [],
// Multi-library profiles. Each profile overrides source/target for searches.
// Empty array = no profiles defined (use rc-jav config.json defaults).
profiles: [], // [{name: string, source: string[], target: string[]}]
activeProfile: "", // name of active profile; "" = use config.json defaults
// Out-of-band alerts. Empty URL disables. PC label identifies which machine
// fired the alert when both PCs share settings via sync.
discordWebhookUrl: "",
pcLabel: "",
};
// ----- settings helpers -----
// Deep-merge stored settings on top of defaults so a partial save (older version,
// imported file missing keys) doesn't blank out nested objects like `triggers`.
// One level deep is enough — that's the only nesting we ship.
function mergeSettings(defaults, stored) {
const out = Object.assign({}, defaults);
if (!stored || typeof stored !== "object") return out;
for (const k of Object.keys(stored)) {
const dv = defaults[k];
const sv = stored[k];
if (dv && typeof dv === "object" && !Array.isArray(dv)
&& sv && typeof sv === "object" && !Array.isArray(sv)) {
out[k] = Object.assign({}, dv, sv);
} else {
out[k] = sv;
}
}
return out;
}
async function getSettings() {
const got = await chrome.storage.sync.get("settings");
return mergeSettings(DEFAULT_SETTINGS, got.settings);
}
async function setSettings(s) {
await chrome.storage.sync.set({ settings: s });
}
// ----- native messaging -----
let nativePort = null;
const pending = new Map(); // requestId -> {resolve, reject}
let nextReqId = 1;
let pendingPicker = null;
const badgeSpinners = new Map(); // tabId -> interval handle
// MV3 evicts the service worker after ~30s of inactivity. A long-running native
// call (--quick on a slow remote, --scan-backed search of a large library) can
// outlive that window and the reply would arrive to a fresh SW with an empty
// `pending` Map. Keep the SW alive by pulsing a Chrome API call while we have
// outstanding native requests.
let keepaliveTimer = null;
function pulseKeepalive() {
// Any extension API call resets the idle timer. getPlatformInfo is cheap.
chrome.runtime.getPlatformInfo(() => { /* noop */ });
if (pending.size > 0) {
keepaliveTimer = setTimeout(pulseKeepalive, 20_000);
} else {
keepaliveTimer = null;
}
}
function ensureKeepalive() {
if (keepaliveTimer == null && pending.size > 0) {
keepaliveTimer = setTimeout(pulseKeepalive, 20_000);
}
}
function ensurePort() {
if (nativePort) return nativePort;
try {
nativePort = chrome.runtime.connectNative(HOST_NAME);
} catch (e) {
console.error("rclonex: connectNative failed", e);
throw e;
}
nativePort.onMessage.addListener((msg) => {
const p = pending.get(msg.req_id);
if (p) {
pending.delete(msg.req_id);
p.resolve(msg);
}
});
nativePort.onDisconnect.addListener(() => {
const err = chrome.runtime.lastError;
const msg = err?.message || "host disconnected";
console.warn("rclonex: native host disconnected", err);
nativePort = null;
// Reject inflight pending — each gets its own log entry via the reject
// wrapper installed in nativeCall.
const inflight = pending.size;
for (const [, p] of pending) p.reject(new Error(msg));
pending.clear();
// Also log a marker so the user can see WHEN the port itself dropped
// (independent of any specific in-flight call).
recordRpc({
ts: Date.now(), action: "__port_disconnect__", ok: false, latency_ms: 0,
error: msg, error_kind: "disconnected", inflight,
});
});
return nativePort;
}
// Hard cap so a hung host (rclone deadlock, host crash mid-reply, Chrome silently
// disconnecting the port over the 1 MiB cap) doesn't leave UI spinners forever.
const NATIVE_CALL_TIMEOUT_MS = 90_000;
// Rolling RPC log so the user can diagnose intermittent "Error when communicating
// with the native messaging host" failures (host crash, port disconnect, 1 MiB
// response cap, SW recycle). Persisted in chrome.storage.local because the SW
// can be evicted between the call and the user opening Diagnostics.
const NATIVE_LOG_KEY = "rclonejavNativeLog";
const NATIVE_LOG_MAX = 200;
// Serialize concurrent recordRpc writes via a promise-chain lock. Chrome
// storage.local has no atomic-append guarantee; without serialization, two
// callers in the same tick (e.g. onDisconnect rejecting N pending requests
// alongside the __port_disconnect__ marker) each do get-then-set against the
// same stale old-value and last-write-wins eats intermediate entries.
// Pattern mirrors the tabvault _rcjavTrace lock and the ensureContextMenu
// lock for the same class of read-modify-write race on extension storage.
let _rpcLogLock = Promise.resolve();
function recordRpc(entry) {
_rpcLogLock = _rpcLogLock.then(async () => {
try {
const got = await chrome.storage.local.get(NATIVE_LOG_KEY);
const old = Array.isArray(got[NATIVE_LOG_KEY]) ? got[NATIVE_LOG_KEY] : [];
await chrome.storage.local.set({
[NATIVE_LOG_KEY]: [entry, ...old].slice(0, NATIVE_LOG_MAX),
});
} catch (_) { /* storage full / SW dying — never break the lock chain */ }
});
// Out-of-band alert path runs OUTSIDE the lock — its own internal rate-limit
// file (HOST_ALERT_KEY) has a separate race tracked as L-1 in the queue.
// Notification sticks in Windows Action Center until dismissed
// (requireInteraction:true) so the user catches it after sleep or hours away.
maybeNotifyHostError(entry);
return _rpcLogLock;
}
const ALARM_ERROR_KINDS = new Set([
"disconnected", "timeout", "post_failed", "exception", "host_error",
]);
const HOST_ALERT_KEY = "rclonejavLastHostAlertTs";
const HOST_ALERT_MIN_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
// Serialize the rate-limit read/check/write through a dedicated lock (NOT
// shared with _rpcLogLock — different storage key, different invariant). On a
// burst like onDisconnect rejecting N pending requests in the same tick, all
// callers reached this without serialization and each read the same stale
// lastTs, all passed the < HOST_ALERT_MIN_INTERVAL_MS check, all wrote a new
// ts, all fired a notification + Discord post. With the lock, only the first
// caller's check passes; the rest read the freshly-written ts and bail.
let _hostAlertLock = Promise.resolve();
function maybeNotifyHostError(entry) {
if (!entry || entry.ok) return;
const kind = entry.error_kind || "";
if (!ALARM_ERROR_KINDS.has(kind)) return;
// __port_disconnect__ fires alongside per-call rejects — coalesce by keying
// the rate-limit on the wall clock; both will hit the same window.
_hostAlertLock = _hostAlertLock.then(async () => {
try {
const now = Date.now();
const got = await chrome.storage.local.get(HOST_ALERT_KEY);
const lastTs = Number(got[HOST_ALERT_KEY]) || 0;
if (now - lastTs < HOST_ALERT_MIN_INTERVAL_MS) return;
await chrome.storage.local.set({ [HOST_ALERT_KEY]: now });
const errText = (entry.error || "").slice(0, 200);
const actionText = entry.action === "__port_disconnect__"
? `Port disconnected (${entry.inflight || 0} inflight)`
: `Action: ${entry.action || "?"}`;
chrome.notifications.create(
"rclonejav-host-error-" + now,
{
type: "basic",
iconUrl: chrome.runtime.getURL("icons/icon-128.png"),
title: `rclone-jav: native host ${kind}`,
message: `${actionText}\n${errText}`,
contextMessage: "Click to open Diagnostics",
requireInteraction: true,
priority: 2,
},
() => { void chrome.runtime.lastError; },
);
// Discord webhook — out-of-band channel for "away from PC" coverage.
// Same rate limit applies (we just consumed it above). Fire-and-forget
// so the lock releases quickly; postDiscordAlert is already non-blocking
// (fetch is sync-to-call, awaits the network in background).
const settings = await getSettings();
if (settings.discordWebhookUrl) {
postDiscordAlert(entry, settings).catch(() => {});
}
} catch (_) { /* never break the lock chain */ }
});
return _hostAlertLock;
}
// Discord webhook colors (decimal RGB). Red for disconnects/exceptions, orange
// for timeouts, yellow for host-side ok:false (less severe — host responded).
const DISCORD_COLOR_BY_KIND = {
disconnected: 0xff5050, // red
exception: 0xff5050,
post_failed: 0xff5050,
timeout: 0xffa500, // orange
host_error: 0xffd400, // yellow
};
async function postDiscordAlert(entry, settings) {
const url = (settings.discordWebhookUrl || "").trim();
if (!url || !/^https:\/\/(?:discord\.com|discordapp\.com)\/api\/webhooks\//.test(url)) return;
const kind = entry.error_kind || "unknown";
const color = DISCORD_COLOR_BY_KIND[kind] ?? 0xff5050;
// Wrap raw identifiers in inline code so Discord doesn't render symbols like
// __ or * as Markdown (e.g. `__port_disconnect__` would italicize otherwise).
// Only backticks need escaping inside inline code.
const codeInline = (s) => "`" + String(s).replace(/`/g, "ˋ") + "`";
// Errors can contain backticks too — fence as a multiline code block so any
// markdown chars (* _ ~ | > \) render literally.
const codeBlock = (s) => "```\n" + String(s).replace(/```/g, "```") + "\n```";
const fields = [];
if (entry.action) fields.push({ name: "Action", value: codeInline(String(entry.action).slice(0, 100)), inline: true });
if (entry.latency_ms != null) fields.push({ name: "Latency", value: `${entry.latency_ms}ms`, inline: true });
if (entry.inflight != null) fields.push({ name: "Inflight", value: String(entry.inflight), inline: true });
if (settings.pcLabel) fields.push({ name: "PC", value: codeInline(String(settings.pcLabel).slice(0, 60)), inline: true });
const errText = (entry.error || "").slice(0, 1800);
const payload = {
username: "rclone-jav",
embeds: [{
title: `Native host ${kind}`,
description: errText ? codeBlock(errText) : "(no error message)",
color,
timestamp: new Date(entry.ts || Date.now()).toISOString(),
fields,
footer: { text: "rclone-jav extension" },
}],
};
try {
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
await chrome.storage.local.set({
lastDiscordSend: { ts: Date.now(), status: resp.status, ok: resp.ok },
});
} catch (e) {
await chrome.storage.local.set({
lastDiscordSend: { ts: Date.now(), status: 0, ok: false, error: e.message || String(e) },
});
}
}
// Allow the Options page to trigger a test post without faking an error.
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg && msg.type === "test-discord-webhook") {
(async () => {
const settings = await getSettings();
if (!settings.discordWebhookUrl) {
sendResponse({ ok: false, error: "no webhook URL configured" });
return;
}
await postDiscordAlert({
ts: Date.now(),
action: "test-alert",
error_kind: "host_error",
error: "Test alert from Options → Setup → Alerts. If you see this in Discord, the webhook works.",
latency_ms: 0,
}, settings);
const got = await chrome.storage.local.get("lastDiscordSend");
sendResponse({ ok: got.lastDiscordSend?.ok, status: got.lastDiscordSend?.status, error: got.lastDiscordSend?.error });
})();
return true; // async sendResponse
}
});
chrome.notifications.onClicked.addListener(async (notificationId) => {
if (!notificationId.startsWith("rclonejav-host-error-")) return;
try {
await chrome.notifications.clear(notificationId);
await chrome.storage.local.set({ optionsActivePane: "diagnostics" });
await chrome.runtime.openOptionsPage();
} catch (_) { /* user dismissed before we got here */ }
});
function nativeCall(payload, timeoutMs = NATIVE_CALL_TIMEOUT_MS) {
const port = ensurePort();
const reqId = nextReqId++;
const msg = Object.assign({ req_id: reqId }, payload);
const action = payload.action || "?";
const startedAt = Date.now();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
if (pending.has(reqId)) {
pending.delete(reqId);
const err = new Error(`native host timeout after ${timeoutMs}ms (action=${action})`);
recordRpc({
ts: startedAt, action, ok: false, latency_ms: Date.now() - startedAt,
error: err.message, error_kind: "timeout",
});
reject(err);
}
}, timeoutMs);
pending.set(reqId, {
resolve: (v) => {
clearTimeout(timer);
let respBytes = -1;
try { respBytes = JSON.stringify(v).length; } catch {}
recordRpc({
ts: startedAt, action, ok: !!(v && v.ok),
latency_ms: Date.now() - startedAt,
resp_bytes: respBytes,
truncated: !!(v && v.truncated),
truncated_reason: v && v.truncated_reason || "",
error: v && !v.ok ? (v.error || "") : null,
error_kind: v && !v.ok ? (v.error_kind || "host_error") : null,
});
resolve(v);
},
reject: (e) => {
clearTimeout(timer);
recordRpc({
ts: startedAt, action, ok: false, latency_ms: Date.now() - startedAt,
error: e && e.message || String(e),
error_kind: classifyNativeError(e),
});
reject(e);
},
});
ensureKeepalive();
try { port.postMessage(msg); }
catch (e) {
clearTimeout(timer);
pending.delete(reqId);
// The port is in a broken state — null it so the next call reconnects
// instead of reusing the dead handle and failing the same way.
if (nativePort === port) nativePort = null;
recordRpc({
ts: startedAt, action, ok: false, latency_ms: Date.now() - startedAt,
error: e && e.message || String(e), error_kind: "post_failed",
});
reject(e);
}
});
}
function classifyNativeError(err) {
const msg = String(err && err.message ? err.message : err || "");
if (/forbidden/i.test(msg)) return "forbidden";
if (/not found|specified native messaging host/i.test(msg)) return "not_found";
if (/disconnected/i.test(msg)) return "disconnected";
if (/timeout/i.test(msg)) return "timeout";
return "unknown";
}
// Active profile's source/target overrides, or empty arrays if no profile selected.
function profileOverrides(settings) {
const prof = (settings.profiles || []).find((p) => p.name === settings.activeProfile);
return {
source_override: prof ? (prof.source || []) : [],
target_override: prof ? (prof.target || []) : [],
};
}
// Factory for table-driven native-message handlers. `buildPayload(msg, settings)`
// returns the payload to forward (rcjav_path is filled from settings if absent).
// Opts: timeout, paused (bail if scanPaused), requireDelete (bail if !enableDelete).
function nativeProxy(buildPayload, opts = {}) {
return (msg, sendResponse) => {
(async () => {
try {
const settings = await getSettings();
if (opts.paused && settings.scanPaused) {
sendResponse({ ok: false, paused: true, error: "scanning paused" });
return;
}
if (opts.requireDelete && !settings.enableDelete) {
sendResponse({ ok: false, error: "deletion is disabled in options" });
return;
}
const payload = buildPayload(msg, settings) || {};
if (!("rcjav_path" in payload)) payload.rcjav_path = settings.rcjavPath || "";
const r = await nativeCall(payload, opts.timeout || NATIVE_CALL_TIMEOUT_MS);
sendResponse(r);
} catch (e) {
sendResponse({
ok: false,
error: e.message,
error_kind: classifyNativeError(e),
extension_id: chrome.runtime.id,
});
}
})();
};
}
// Native-action proxies. Each entry forwards a fixed action to the host with a
// small per-call payload. Hand-written handlers (check-tab, manual-query,
// bulk-query, picker, activity log, settings) live in the onMessage listener.
const NATIVE_PROXIES = {
"dupe-review": nativeProxy(
(_msg, s) => ({ action: "dupe_review", part_patterns: s.partPatterns || [], ...profileOverrides(s) }),
{ timeout: 600_000 },
),
"library_issues": nativeProxy(() => ({ action: "library_issues" }), { timeout: 60_000 }),
"rename_file": nativeProxy(
(msg) => ({ action: "rename_file", remote: msg.remote, old_path: msg.old_path, new_path: msg.new_path }),
{ timeout: 60_000 },
),
"rename_files_batch": nativeProxy(
(msg) => ({ action: "rename_files_batch", renames: msg.renames }),
{ timeout: 300_000 },
),
// Deletion (requires opt-in setting)
"delete-file": nativeProxy(
(msg, s) => ({ action: "delete", path: msg.path, mode: s.deleteMode || "trash", trash_dir: s.trashDir || "" }),
{ requireDelete: true },
),
"delete_batch": nativeProxy(
(msg, s) => ({ action: "delete_batch", paths: msg.paths, mode: s.deleteMode || "trash", trash_dir: s.trashDir || "" }),
{ timeout: 600_000, requireDelete: true },
),
"delete-skipped": nativeProxy(
(msg, s) => ({ action: "delete_skipped", paths: msg.paths, mode: s.deleteMode || "trash", trash_dir: s.trashDir || "" }),
{ timeout: 120_000, requireDelete: true },
),
"recent-deletes": nativeProxy((msg) => ({ action: "recent_deletes", limit: msg.limit || 20 })),
"undo-delete": nativeProxy(
(msg) => ({ action: "undo_delete", dst: msg.dst, path: msg.path }),
{ requireDelete: true },
),
// Keep-ranking editor
"get-keep-ranking": nativeProxy(() => ({ action: "get_keep_ranking" }), { timeout: 10_000 }),
"save-keep-ranking": nativeProxy(
(msg) => ({ action: "save_keep_ranking", keep_ranking: msg.keep_ranking }),
{ timeout: 10_000 },
),
// Diagnostics / host status
"diagnostics": nativeProxy((_msg, s) => ({
action: "diagnostics",
extension_settings: {
quick_mode: s.quickMode !== false,
active_profile: s.activeProfile || "",
profile_count: (s.profiles || []).length,
auto_every_page: !!s.triggers?.autoEveryPage,
auto_known_sites: !!s.triggers?.autoKnownSites,
},
})),
"host-status": nativeProxy(() => ({ action: "host_status", extension_id: chrome.runtime.id }), { timeout: 20_000 }),
"repair-host": nativeProxy(() => ({ action: "host_repair", extension_id: chrome.runtime.id }), { timeout: 20_000 }),
"ping-host": nativeProxy((msg, s) => ({ action: "ping", rcjav_path: msg.rcjavPath ?? s.rcjavPath ?? "" })),
// Cache contract
"cache-status": nativeProxy((_msg, s) => ({ action: "cache_status", stale_hours: s.cacheStaleHours || 24 })),
"reextract-ids": nativeProxy(() => ({ action: "reextract_ids" }), { timeout: 300_000 }),
// Scans
"run-scan": nativeProxy(
(msg, s) => ({
action: "scan",
scan_since: msg.scanSince || "",
scan_roots: Array.isArray(msg.scanRoots) ? msg.scanRoots : [],
part_patterns: s.partPatterns || [],
timeout: 600,
}),
{ timeout: 600_000, paused: true },
),
"scan-cancel": nativeProxy(() => ({ action: "scan_cancel" }), { timeout: 5_000 }),
"scan-progress": nativeProxy(() => ({ action: "scan_progress" }), { timeout: 5_000 }),
"scan-clear": nativeProxy(() => ({ action: "scan_clear" }), { timeout: 5_000 }),
"list-remotes": nativeProxy(() => ({ action: "list_remotes" }), { timeout: 20_000 }),
"clear-events-log": nativeProxy(() => ({ action: "clear_events_log" }), { timeout: 5_000 }),
"save-alerts-config": nativeProxy(
(msg) => ({
action: "save_alerts_config",
discord_webhook_url: msg.discordWebhookUrl || "",
pc_label: msg.pcLabel || "",
}),
{ timeout: 5_000 },
),
"test-host-alert": nativeProxy(() => ({ action: "test_alerts_config" }), { timeout: 10_000 }),
};
// ----- ID extraction -----
// Ask the content script (which has DOM + adapters). Fall back to tab.title if injection
// hasn't run (e.g. chrome:// pages). Core regexes + normalizers live in
// src/shared/id-extract.js so background and content stay in lockstep.
function traceTextExtraction(text, userList) {
const raw = String(text || "");
if (!raw) return { id: null, source: "none", raw: "" };
const all = [...(userList || []), ...RCJAV_IDS.BUILTIN_ID_NORMALIZERS];
for (let index = 0; index < all.length; index++) {
const n = all[index];
let re;
try { re = n.re instanceof RegExp ? n.re : new RegExp(n.re, "i"); } catch { continue; }
const m = raw.match(re);
if (!m) continue;
const id = n.fmt.replace(/\$(\d)/g, (_, i) => m[+i] || "").toUpperCase();
return {
id,
source: index < (userList || []).length ? "custom normalizer" : "built-in normalizer",
raw: raw.slice(0, 300),
pattern: String(n.re),
replacement: n.fmt,
};
}
const id = RCJAV_IDS.normalizeId(raw);
return id
? { id, source: "generic ID regex", raw: raw.slice(0, 300) }
: { id: null, source: "none", raw: raw.slice(0, 300) };
}
async function extractIdFromTitle(title) {
if (!title) return null;
const { settings = {} } = await chrome.storage.sync.get("settings");
return RCJAV_IDS.normalizeId(title, settings.idNormalizers || []);
}
async function extractIdFromTab(tab) {
if (!tab || tab.id == null) return null;
try {
const r = await chrome.tabs.sendMessage(tab.id, { type: "extract-id" });
if (r && r.id) return r;
} catch (e) {
// Content script not present (e.g. brave://, chrome://, file://). Use title fallback.
}
const id = await extractIdFromTitle(tab.title);
return id ? { id, source: "title" } : null;
}
// ----- core: check a tab -----
function tabCacheKey(tabId) { return `tabResult:${tabId}`; }
const ACTIVITY_KEY = "recentActivity";
const ACTIVITY_MAX = 60;
function summarizeActivity(tab, result, trigger = "page") {
const timings = result && result.timings ? result.timings : {};
const url = tab && tab.url ? tab.url : "";
const title = tab && tab.title ? tab.title : "";
const id = result && result.id ? result.id : "";
const ok = !!(result && result.ok);
const hits = Number(result && result.hits) || 0;
const noId = result?.reason === "no JAV ID found";
return {
ts: Date.now(),
trigger,
id,
ok,
hits,
outcome: ok ? (hits > 0 ? "hit" : "miss") : (noId ? "no_id" : result?.paused ? "paused" : "error"),
reason: result?.reason || result?.error || "",
mode: result?.cached ? "session" : result?.search_mode || "",
url: url.slice(0, 700),
title: title.slice(0, 300),
total_ms: Number.isFinite(timings.total_ms) ? timings.total_ms : null,
native_ms: Number.isFinite(timings.native_ms) ? timings.native_ms : null,
extract_ms: Number.isFinite(timings.extract_ms) ? timings.extract_ms : null,
};
}
// Same Promise-chain lock pattern as `_rpcLogLock` (M-6). recordActivity
// gets called concurrently from multiple paths (autocheck on tab events,
// manual search, context-menu selection search, batch checks) and shares
// the same chrome.storage.local read-modify-write race. Mirror fix for M-6
// caught during Phase 3 verification.
let _activityLogLock = Promise.resolve();
async function recordActivity(tab, result, trigger = "page") {
const row = summarizeActivity(tab, result, trigger);
// Skip no_id outcomes — every page lacking a JAV ID would otherwise flood
// the log on auto-every-page mode and drown out real hits/misses.
if (row.outcome === "no_id") return;
_activityLogLock = _activityLogLock.then(async () => {
try {
const got = await chrome.storage.local.get(ACTIVITY_KEY);
const old = Array.isArray(got[ACTIVITY_KEY]) ? got[ACTIVITY_KEY] : [];
await chrome.storage.local.set({ [ACTIVITY_KEY]: [row, ...old].slice(0, ACTIVITY_MAX) });
} catch {}
});
return _activityLogLock;
}
async function persistTabResult(tab, result) {
if (!tab || tab.id == null) return;
try {
await chrome.storage.session.set({
[tabCacheKey(tab.id)]: { url: tab.url || "", result, ts: Date.now() },
});
} catch {}
}
// URL fragment (hash) never affects server response or our ID extraction. Strip it
// when comparing URLs so in-page anchor jumps (e.g. `#gallery-1`, `#gallery-2`) don't
// invalidate the tab cache or re-trigger scans. See exoticaz.to gallery navigation.
function stripHash(url) {
if (typeof url !== "string") return url;
const i = url.indexOf("#");
return i === -1 ? url : url.slice(0, i);
}
// Tracks the last full URL we saw per tab so we can detect hash-only changes
// (which Chrome still reports via `chrome.tabs.onUpdated` with `changeInfo.url`).
const tabLastUrl = new Map(); // tabId -> last full url seen
async function getTabResult(tab) {
if (!tab || tab.id == null) return null;
try {
const got = await chrome.storage.session.get(tabCacheKey(tab.id));
const entry = got[tabCacheKey(tab.id)];
if (!entry) return null;
if (stripHash(entry.url) !== stripHash(tab.url || "")) return null;
return entry;
} catch { return null; }
}
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
if (!changeInfo.url) return;
const prev = tabLastUrl.get(tabId);
// Hash-only change → keep the cached result; same page, just a fragment jump.
if (prev && stripHash(prev) === stripHash(changeInfo.url)) return;
chrome.storage.session.remove(tabCacheKey(tabId));
});
chrome.tabs.onRemoved.addListener((tabId) => {
stopBadgeSpinner(tabId);
chrome.storage.session.remove(tabCacheKey(tabId));
lastAutoCheck.delete(tabId);
tabLastUrl.delete(tabId);
const t = pendingSpaTimer.get(tabId);
if (t) { clearTimeout(t); pendingSpaTimer.delete(tabId); }
});
async function checkTab(tab) {
if (!tab) return { ok: false, reason: "no tab" };
const settings = await getSettings();
if (settings.scanPaused) {
if (tab.id != null) setBadge(tab.id, "Ⅱ", "#66551f");
const r = { ok: false, paused: true, reason: "scanning paused" };
await recordActivity(tab, r, "page");
return r;
}
const t0 = performance.now();
startBadgeSpinner(tab.id);
const extractStart = performance.now();
const ext = await extractIdFromTab(tab);
const extractMs = Math.round(performance.now() - extractStart);
const id = ext && ext.id;
if (!id) {
stopBadgeSpinner(tab.id);
setBadge(tab.id, "?", "#888");
const r = {
ok: false,
reason: "no JAV ID found",
no_match_kind: "no_id",
no_match_title: "No JAV ID extracted",
no_match_detail: "The page title, URL, and matching site adapters did not yield an ID.",
title: tab.title,
};
await persistTabResult(tab, r);
await recordActivity(tab, r, "page");
return r;
}
let result;
try {
const nativeStart = performance.now();
result = await nativeCall({
action: "search",
id,
quick: !!settings.quickMode,
stale_hours: settings.cacheStaleHours || 24,
rcjav_path: settings.rcjavPath || "",
part_patterns: settings.partPatterns || [],
...profileOverrides(settings),
});
result.timings = Object.assign({}, result.timings || {}, {
extract_ms: extractMs,
native_ms: Math.round(performance.now() - nativeStart),
total_ms: Math.round(performance.now() - t0),
});
} catch (e) {
stopBadgeSpinner(tab.id);
setBadge(tab.id, "!", "#c33");
const r = {
ok: false,
reason: "host error: " + e.message,
error: e.message,
error_kind: classifyNativeError(e),
extension_id: chrome.runtime.id,
id,
};
await persistTabResult(tab, r);
await recordActivity(tab, r, "page");
return r;
}
const hits = result.hits || 0;
stopBadgeSpinner(tab.id);
if (result.ok) {
setBadge(tab.id, hits > 0 ? String(hits) : "✗", hits > 0 ? "#3a7" : "#a33");
} else {
setBadge(tab.id, "!", "#c33");
}
const r = Object.assign({ id, cached: false, ts: Date.now() }, result);
await persistTabResult(tab, r);
await recordActivity(tab, r, "page");
return r;
}
// Suppress "No tab with id: N" rejections that fire when the user closes the
// tab mid-operation. Any other unexpected error is logged but not re-thrown so
// the spinner loop doesn't kill itself.
function _swallowTabGone(p) {
if (p && typeof p.catch === "function") {
p.catch((e) => {
if (e && /No tab with id/i.test(e.message || "")) return;
console.warn("[rclone-jav] tab op failed:", e);
});
}
}
function startBadgeSpinner(tabId) {
if (typeof tabId !== "number") return;
stopBadgeSpinner(tabId);
const frames = ["-", "\\", "|", "/"];
let i = 0;
_swallowTabGone(chrome.action.setBadgeBackgroundColor({ tabId, color: "#446" }));
_swallowTabGone(chrome.action.setBadgeText({ tabId, text: frames[i] }));
const handle = setInterval(() => {
i = (i + 1) % frames.length;
// Stop firing once the tab is gone — keeps the interval from spamming
// errors every 180ms after the user closed the tab.
chrome.action.setBadgeText({ tabId, text: frames[i] }).catch((e) => {
if (e && /No tab with id/i.test(e.message || "")) stopBadgeSpinner(tabId);
});
}, 180);
badgeSpinners.set(tabId, handle);
}
function stopBadgeSpinner(tabId) {
if (typeof tabId !== "number") return;
const handle = badgeSpinners.get(tabId);
if (!handle) return;
clearInterval(handle);
badgeSpinners.delete(tabId);
}
function setBadge(tabId, text, color) {
if (typeof tabId !== "number") return;
_swallowTabGone(chrome.action.setBadgeText({ tabId, text }));
_swallowTabGone(chrome.action.setBadgeBackgroundColor({ tabId, color }));
}
function notify(title, message) {
chrome.notifications.create({
type: "basic",
iconUrl: chrome.runtime.getURL("icons/icon-128.png"),
title,
message,
}, () => {
// Some platforms reject notifications silently or report icon failures via
// runtime.lastError. Touch it here so Brave does not leave an uncaught
// extension error on the management page.
void chrome.runtime.lastError;
});
}
// ----- triggers -----
// Context menu
// Serialize concurrent ensureContextMenu calls. With the M-2 fix added a
// top-level call at module init, it can race with the settings-changed /
// onInstalled / onStartup handlers — two overlapping removeAll → create
// cycles produce "Cannot create item with duplicate id" because the second
// create runs while the first's items haven't been removed yet. The
// Promise-chain lock keeps invocations strictly sequential.
let _contextMenuLock = Promise.resolve();
function ensureContextMenu() {
_contextMenuLock = _contextMenuLock.then(async () => {
try {
const s = await getSettings();
await new Promise((resolve) =>
chrome.contextMenus.removeAll(() => { void chrome.runtime.lastError; resolve(); })
);
if (!s.triggers.contextMenu) return;
try {
chrome.contextMenus.create({
id: "rclonex-check-page",
title: "rclone-jav: Scan",
contexts: ["page", "link", "selection"],
});
} catch (_) { void chrome.runtime.lastError; }
try {
chrome.contextMenus.create({
id: "rclonex-search-selection",
title: "rclone-jav: Search \"%s\"",
contexts: ["selection"],
});
} catch (_) { void chrome.runtime.lastError; }
} catch (_) { /* never break the lock chain */ }
});
return _contextMenuLock;
}
async function stashAndShow(r) {
// Stash latest result so popup shows it instead of re-running.
await chrome.storage.session.set({ lastResult: { ...r, ts: Date.now() } });
// Try notification (may be silently blocked).
if (r.id) notify(r.id, r.message || (r.hits > 0 ? `${r.hits} hit(s)` : "NOT FOUND"));
else notify("rclonex", r.reason || "no result");
// Open the popup so the table is visible. openPopup is MV3, gated to user gestures
// — keyboard shortcut qualifies.
try { await chrome.action.openPopup(); } catch (e) { /* not all platforms support */ }
}
// Send the match/no-match overlay to a tab, using the user's overlay settings.
// Mirrors the same logic in the auto-detection path.
async function sendOverlay(tab, r, s) {
if (!tab || !r || !r.ok || !r.id) return;
// Always stash so popup shows latest if user opens it.
await chrome.storage.session.set({ lastResult: { ...r, ts: Date.now() } });
try {
if (r.hits > 0 && s.showOverlay !== false) {
await chrome.tabs.sendMessage(tab.id, {
type: "show-overlay",
kind: "match",
result: r,
position: s.overlayPosition || "top-right",
duration: Number(s.overlayDuration) || 5,
glow: !!s.overlayGlow,
glowColor: s.overlayGlowColor || "#6ec1ff",
glowBlur: Number(s.overlayGlowBlur ?? 10),
glowSpread: Number(s.overlayGlowSpread ?? 0),
glowOpacity: Number(s.overlayGlowOpacity ?? 0.35),
});
} else if (r.hits === 0 && s.noMatchOverlay === true) {
await chrome.tabs.sendMessage(tab.id, {
type: "show-overlay",
kind: "no-match",
result: r,
position: s.noMatchPosition || "top-right",
duration: Number(s.noMatchDuration) || 5,
glow: !!s.noMatchGlow,
glowColor: s.noMatchGlowColor || "#ff6666",
glowBlur: Number(s.noMatchGlowBlur ?? 10),
glowSpread: Number(s.noMatchGlowSpread ?? 0),
glowOpacity: Number(s.noMatchGlowOpacity ?? 0.35),
});
}
} catch (e) { /* content script may not be present on chrome:// pages */ }
}
async function searchSelection(tab, selectionText) {
if (!tab || typeof selectionText !== "string") return null;
const text = selectionText.trim();
if (!text) return null;
const settings = await getSettings();
if (settings.scanPaused) {
if (tab.id != null) setBadge(tab.id, "Ⅱ", "#66551f");
return { ok: false, paused: true, reason: "scanning paused" };
}
const t0 = performance.now();
startBadgeSpinner(tab.id);
// Try to extract a canonical JAV ID from the selection. If it parses, search
// by id (cache prefix lookup). Otherwise fall back to --name substring search.
const normalized = RCJAV_IDS.normalizeId(text, settings.idNormalizers || []);
let result;
try {
const nativeStart = performance.now();
result = await nativeCall({
action: "search",
...(normalized ? { id: normalized } : { name: text }),
quick: !!settings.quickMode,
stale_hours: settings.cacheStaleHours || 24,
rcjav_path: settings.rcjavPath || "",
part_patterns: settings.partPatterns || [],
...profileOverrides(settings),
});
result.timings = Object.assign({}, result.timings || {}, {
native_ms: Math.round(performance.now() - nativeStart),
total_ms: Math.round(performance.now() - t0),
});
} catch (e) {
stopBadgeSpinner(tab.id);
setBadge(tab.id, "!", "#c33");
const r = {
ok: false,
reason: "host error: " + e.message,
error: e.message,
error_kind: classifyNativeError(e),
extension_id: chrome.runtime.id,
id: normalized || text,
};
await persistTabResult(tab, r);
await recordActivity(tab, r, "selection");
return r;
}
const hits = result.hits || 0;
stopBadgeSpinner(tab.id);
if (result.ok) {
setBadge(tab.id, hits > 0 ? String(hits) : "✗", hits > 0 ? "#3a7" : "#a33");
} else {
setBadge(tab.id, "!", "#c33");
}
const r = Object.assign({
id: normalized || text,
selection: text,
cached: false,
ts: Date.now(),
}, result);
await persistTabResult(tab, r);
await recordActivity(tab, r, "selection");
return r;
}
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
if (!tab) return;
const s = await getSettings();
if (info.menuItemId === "rclonex-check-page") {
const r = await checkTab(tab);
await sendOverlay(tab, r, s);
} else if (info.menuItemId === "rclonex-search-selection") {
const r = await searchSelection(tab, info.selectionText);
if (r) await sendOverlay(tab, r, s);
}
});
// Keyboard
chrome.commands.onCommand.addListener(async (cmd) => {
if (cmd !== "check-current-page") return;
const s = await getSettings();
if (!s.triggers.keyboardShortcut) return;
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab) return;
const r = await checkTab(tab);
await stashAndShow(r);
});
// Auto-trigger via tab update event (handles both "auto every page" and "auto known sites").
// Normal page loads fire on `status: "complete"`. SPA in-page navigations only emit a
// `changeInfo.url` (no following `complete`), so we arm a deferred timer on url-change
// and let `complete` cancel it if it arrives — that way regular loads don't double-fire
// or fire before the DOM/title are ready.
const lastAutoCheck = new Map(); // tabId -> ts of last fired check
const pendingSpaTimer = new Map(); // tabId -> setTimeout handle awaiting SPA settle
const AUTO_DEBOUNCE_MS = 800;
const SPA_SETTLE_MS = 800;
async function maybeAutoCheck(tabId, tab, reason) {
const s = await getSettings();
if (reason === "complete" && s.triggers.autoPageLoad === false) return;
if (reason === "spa-url" && s.triggers.autoSpaNavigation === false) return;
let shouldRun = false;
if (s.triggers.autoEveryPage) shouldRun = true;
else if (s.triggers.autoKnownSites && matchesKnownSite(tab.url, s.knownSitePatterns)) shouldRun = true;
if (!shouldRun) return;
const now = Date.now();
const prev = lastAutoCheck.get(tabId) || 0;
if (now - prev < AUTO_DEBOUNCE_MS) return;
lastAutoCheck.set(tabId, now);
const r = await checkTab(tab);
if (!r || !r.ok || !r.id) return;
await sendOverlay(tab, r, s);
}
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (!tab || !tab.url) return;
// Status reached "complete": DOM and title are settled. Cancel any pending
// SPA timer for this tab and fire immediately.
if (changeInfo.status === "complete") {
const t = pendingSpaTimer.get(tabId);
if (t) { clearTimeout(t); pendingSpaTimer.delete(tabId); }
tabLastUrl.set(tabId, tab.url);
maybeAutoCheck(tabId, tab, "complete");
return;
}
// SPA in-page navigation: only url changes, never status. Defer the check so
// the page has a chance to update its title/DOM, and so a real reload (which
// also emits a url change before complete) doesn't double-fire.
if (typeof changeInfo.url === "string") {
const prevUrl = tabLastUrl.get(tabId);
tabLastUrl.set(tabId, changeInfo.url);
// Hash-only change (e.g. `#gallery-2` anchor jump) is purely client-side UI
// state. Suppress the SPA scan trigger so anchor navigation doesn't re-fire.
if (prevUrl && stripHash(prevUrl) === stripHash(changeInfo.url)) return;
const prev = pendingSpaTimer.get(tabId);
if (prev) clearTimeout(prev);
const handle = setTimeout(() => {
pendingSpaTimer.delete(tabId);
// Refetch the tab so we see the latest title; the one in this closure
// may be stale by now.
chrome.tabs.get(tabId, (fresh) => {
if (chrome.runtime.lastError || !fresh) return;
maybeAutoCheck(tabId, fresh, "spa-url");
});
}, SPA_SETTLE_MS);
pendingSpaTimer.set(tabId, handle);
}
});
function matchesKnownSite(url, patterns) {
if (!patterns || patterns.length === 0) return false;
let host;
try { host = new URL(url).hostname; } catch { return false; }
for (const p of patterns) {
// Bare domain matches the apex AND any subdomain. Wildcard form does the same.
const variants = [p];
if (p.startsWith("*.")) variants.push(p.slice(2));
else if (!p.includes("*")) variants.push("*." + p);
for (const v of variants) {
const re = new RegExp("^" + v.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$", "i");
if (re.test(host)) return true;
}
}
return false;
}
// ----- popup / options bridge -----
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
// Table-driven native-action proxies handle the bulk of host forwarding.
// Hand-written handlers (below) cover anything with custom logic — tab lookup,
// activity recording, picker injection, settings round-trip, etc.
const proxy = NATIVE_PROXIES[msg.type];
if (proxy) { proxy(msg, sendResponse); return true; }
if (msg.type === "check-tab") {
(async () => {
let tab;
if (msg.tabId) tab = await chrome.tabs.get(msg.tabId);
else [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
// Honor cache unless caller forces a refresh.
if (!msg.force) {
const cached = await getTabResult(tab);
if (cached) {
sendResponse(Object.assign({ tab }, cached.result, { cached: true, ts: cached.ts }));
return;
}
}
const r = await checkTab(tab);
sendResponse(Object.assign({ tab, cached: false }, r));
})();
return true; // async response
}
if (msg.type === "settings-changed") {
ensureContextMenu();
sendResponse({ ok: true });
return false;
}
if (msg.type === "recent-activity") {
(async () => {
try {
const got = await chrome.storage.local.get(ACTIVITY_KEY);
sendResponse({ ok: true, entries: Array.isArray(got[ACTIVITY_KEY]) ? got[ACTIVITY_KEY] : [] });
} catch (e) { sendResponse({ ok: false, error: e.message }); }
})();
return true;
}
if (msg.type === "clear-recent-activity") {
(async () => {
try {
await chrome.storage.local.set({ [ACTIVITY_KEY]: [] });
sendResponse({ ok: true });
} catch (e) { sendResponse({ ok: false, error: e.message }); }
})();
return true;
}
if (msg.type === "manual-query") {
(async () => {
try {
const settings = await getSettings();
if (settings.scanPaused) {
await recordActivity(null, { ok: false, paused: true, id: msg.id || "", reason: "scanning paused" }, "manual");
sendResponse({ ok: false, paused: true, error: "scanning paused" });
return;
}
const nativeStart = performance.now();
const r = await nativeCall({
action: msg.action || "search",
id: msg.id,
name: msg.name,
quick: msg.quick,
stale_hours: settings.cacheStaleHours || 24,
rcjav_path: settings.rcjavPath || "",
part_patterns: settings.partPatterns || [],
...profileOverrides(settings),
});
r.timings = Object.assign({}, r.timings || {}, {
native_ms: Math.round(performance.now() - nativeStart),
total_ms: Math.round(performance.now() - nativeStart),
});
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
await recordActivity(activeTab, Object.assign({ id: msg.id || "", cached: false }, r), "manual");
sendResponse(r);
} catch (e) {
await recordActivity(null, { ok: false, id: msg.id || "", reason: e.message }, "manual");
sendResponse({ ok: false, error: e.message, error_kind: classifyNativeError(e), extension_id: chrome.runtime.id });
}
})();
return true;
}
if (msg.type === "bulk-query") {
(async () => {
try {
const settings = await getSettings();
if (settings.scanPaused) {
sendResponse({ ok: false, paused: true, error: "scanning paused" });
return;
}
const r = await nativeCall({
action: "bulk_search",
queries: msg.queries || [],
quick: !!msg.quick,
stale_hours: settings.cacheStaleHours || 24,
rcjav_path: settings.rcjavPath || "",
part_patterns: settings.partPatterns || [],
...profileOverrides(settings),
}, 600_000);
sendResponse(r);
} catch (e) { sendResponse({ ok: false, error: e.message, error_kind: classifyNativeError(e), extension_id: chrome.runtime.id }); }
})();
return true;
}
if (msg.type === "open-bulk-check") {
openBulkCheckWindow();
sendResponse({ ok: true });
return false;
}
if (msg.type === "start-picker") {
(async () => {
// Find most-recently-active normal web tab (not the options page itself).
const all = await chrome.tabs.query({});
const web = all.filter((t) => t.url && /^https?:/.test(t.url));
web.sort((a, b) => (b.lastAccessed || 0) - (a.lastAccessed || 0));
const tab = web[0];
if (!tab) {
sendResponse({ ok: false, error: "no http/https tab open — switch to the target site first" });
return;
}
try {
// Bring the target tab to the front so user can click on it.
await chrome.tabs.update(tab.id, { active: true });
if (tab.windowId != null) await chrome.windows.update(tab.windowId, { focused: true });
// Force-inject content.js — handles tabs that were opened before the extension
// (or before its most recent reload).
try {
await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["src/shared/id-extract.js", "content.js"] });
} catch (e) { /* may fail on chrome:// pages, fine */ }
await chrome.tabs.sendMessage(tab.id, { type: "start-picker" });
pendingPicker = { tabId: tab.id, sender: msg.from || "options" };
sendResponse({ ok: true, tabId: tab.id, url: tab.url });
} catch (e) { sendResponse({ ok: false, error: e.message }); }
})();
return true;
}
if (msg.type === "test-active-page") {
(async () => {
const all = await chrome.tabs.query({});
const web = all.filter((t) => t.url && /^https?:/.test(t.url));
web.sort((a, b) => (b.lastAccessed || 0) - (a.lastAccessed || 0));
const tab = web[0];
if (!tab) {
sendResponse({ ok: false, error: "no http/https tab open" });
return;
}
try {
try {
await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["src/shared/id-extract.js", "content.js"] });
} catch (e) { /* content script may already be present */ }
const extracted = await chrome.tabs.sendMessage(tab.id, {
type: "trace-extract-id",
adapters: Array.isArray(msg.adapters) ? msg.adapters : undefined,
normalizers: Array.isArray(msg.normalizers) ? msg.normalizers : undefined,
});
sendResponse({ ok: true, tab: { id: tab.id, url: tab.url, title: tab.title }, extracted });
} catch (e) {
sendResponse({ ok: false, error: e.message, tab: { id: tab.id, url: tab.url, title: tab.title } });
}
})();
return true;
}
if (msg.type === "test-id-text") {
(async () => {
try {
const settings = await getSettings();
const normalizers = Array.isArray(msg.normalizers) ? msg.normalizers : (settings.idNormalizers || []);
sendResponse({ ok: true, extracted: traceTextExtraction(msg.text || "", normalizers) });
} catch (e) { sendResponse({ ok: false, error: e.message }); }
})();
return true;
}
if (msg.type === "picker-result" || msg.type === "picker-cancelled") {
// Forward to options page via storage.session — easiest cross-page bus
chrome.storage.session.set({ lastPickerResult: { ...msg, ts: Date.now() } });
sendResponse({ ok: true });
return false;
}
if (msg.type === "get-settings") {
(async () => sendResponse(await getSettings()))();
return true;
}
});
// On install / startup, build context menu.
chrome.runtime.onInstalled.addListener(() => ensureContextMenu());
chrome.runtime.onStartup.addListener(() => ensureContextMenu());
// FIX M-2 (bugs-fix-queue.md): chrome.contextMenus entries do NOT persist across
// MV3 service-worker evictions per Chrome's contextMenus lifecycle contract.
// The two listeners above only fire on install / browser startup, NOT on routine
// SW evict→wake cycles (toolbar click, alarm, message wake, etc.). Without this
// top-level call, the right-click "rclone-jav: Scan" / "rclone-jav: Search"
// entries silently disappear from web pages after the SW idles out (~30 s) and
// gets re-evaluated from a non-startup trigger. Recovery previously required
// reloading the extension OR triggering a settings-changed message.
//
// Top-level invocation runs on EVERY SW evaluation — install, startup, idle
// wake, alarm wake, message wake. ensureContextMenu calls removeAll first so
// the duplicate fire after onInstalled / onStartup is idempotent and harmless.
ensureContextMenu();
// ---------- Bulk Check standalone window ----------
const BULK_CHECK_WIN_KEY = "bulkCheckWindowId";
async function openBulkCheckWindow() {
const session = chrome.storage.session;
try {
const stored = session ? await session.get(BULK_CHECK_WIN_KEY) : {};
const existingId = stored?.[BULK_CHECK_WIN_KEY];
if (existingId) {
try {
await chrome.windows.update(existingId, { focused: true, drawAttention: true });
return;
} catch {
if (session) await session.remove(BULK_CHECK_WIN_KEY);
}
}
const win = await chrome.windows.create({
url: chrome.runtime.getURL("src/bulk-check/bulk-check.html"),
type: "popup",
width: 640,
height: 540,
});
if (session && win?.id != null) {
await session.set({ [BULK_CHECK_WIN_KEY]: win.id });
}
} catch (e) {
console.warn("openBulkCheckWindow failed:", e);
}
}
chrome.windows.onRemoved.addListener(async (windowId) => {
const session = chrome.storage.session;
if (!session) return;
try {
const stored = await session.get(BULK_CHECK_WIN_KEY);
if (stored?.[BULK_CHECK_WIN_KEY] === windowId) {
await session.remove(BULK_CHECK_WIN_KEY);
}
} catch { /* ignore */ }
});