// 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.05–1.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 {} } 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 (entry.url !== (tab.url || "")) return null; return entry; } catch { return null; } } chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { if (changeInfo.url) chrome.storage.session.remove(tabCacheKey(tabId)); }); chrome.tabs.onRemoved.addListener((tabId) => { stopBadgeSpinner(tabId); chrome.storage.session.remove(tabCacheKey(tabId)); lastAutoCheck.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); } 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 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 */ } });