// rclonex background service worker. // Owns the native-messaging port, dispatches lookups, draws badge/notifications. 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 }; // ----- 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; console.warn("rclonex: native host disconnected", err); nativePort = null; for (const [, p] of pending) p.reject(new Error(err?.message || "host disconnected")); pending.clear(); }); 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; function nativeCall(payload, timeoutMs = NATIVE_CALL_TIMEOUT_MS) { const port = ensurePort(); const reqId = nextReqId++; const msg = Object.assign({ req_id: reqId }, payload); return new Promise((resolve, reject) => { const timer = setTimeout(() => { if (pending.has(reqId)) { pending.delete(reqId); reject(new Error(`native host timeout after ${timeoutMs}ms (action=${payload.action || "?"})`)); } }, timeoutMs); pending.set(reqId, { resolve: (v) => { clearTimeout(timer); resolve(v); }, reject: (e) => { clearTimeout(timer); 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; 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"; } // ----- ID extraction ----- // Ask the content script (which has DOM + adapters). Fall back to tab.title if injection // hasn't run (e.g. chrome:// pages). const BUILTIN_ID_NORMALIZERS = [ { re: /\bFC2-?PPV-?(\d{4,})\b/i, fmt: "FC2-PPV-$1" }, { re: /\bFC2-(\d{4,})\b/i, fmt: "FC2-PPV-$1" }, ]; function applyNormalizers(text, userList) { const all = [...(userList || []), ...BUILTIN_ID_NORMALIZERS]; for (const n of all) { let re; try { re = n.re instanceof RegExp ? n.re : new RegExp(n.re, "i"); } catch { continue; } const m = text.match(re); if (m) return n.fmt.replace(/\$(\d)/g, (_, i) => m[+i] || ""); } return null; } function traceTextExtraction(text, userList) { const raw = String(text || ""); if (!raw) return { id: null, source: "none", raw: "" }; const all = [...(userList || []), ...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, }; } let m = raw.match(/\b([A-Za-z][A-Za-z0-9]{1,})-(\d{2,7})[a-zA-Z]?\b/); if (!m) m = raw.match(/\b([A-Za-z][A-Za-z0-9]{1,})(\d{3,5})[a-zA-Z]?\b/); return m ? { id: `${m[1].toUpperCase()}-${m[2]}`, 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"); const fromNormalizer = applyNormalizers(title, settings.idNormalizers || []); if (fromNormalizer) return fromNormalizer.toUpperCase(); // Optional trailing letter (e.g. IBW-902z) absorbed but not part of the ID. let m = title.match(/\b([A-Za-z][A-Za-z0-9]{1,})-(\d{2,7})[a-zA-Z]?\b/); if (!m) m = title.match(/\b([A-Za-z][A-Za-z0-9]{1,})(\d{3,5})[a-zA-Z]?\b/); if (!m) return null; return `${m[1].toUpperCase()}-${m[2]}`; } 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, }; } async function recordActivity(tab, result, trigger = "page") { try { const row = summarizeActivity(tab, result, trigger); 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 {} } 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(); const prof = (settings.profiles || []).find((p) => p.name === settings.activeProfile); result = await nativeCall({ action: "search", id, quick: !!settings.quickMode, stale_hours: settings.cacheStaleHours || 24, rcjav_path: settings.rcjavPath || "", part_patterns: settings.partPatterns || [], source_override: prof ? (prof.source || []) : [], target_override: prof ? (prof.target || []) : [], }); 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; } function startBadgeSpinner(tabId) { if (typeof tabId !== "number") return; stopBadgeSpinner(tabId); const frames = ["-", "\\", "|", "/"]; let i = 0; chrome.action.setBadgeBackgroundColor({ tabId, color: "#446" }); chrome.action.setBadgeText({ tabId, text: frames[i] }); const handle = setInterval(() => { i = (i + 1) % frames.length; chrome.action.setBadgeText({ tabId, text: frames[i] }); }, 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; chrome.action.setBadgeText({ tabId, text }); 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 async function ensureContextMenu() { const s = await getSettings(); chrome.contextMenus.removeAll(() => { if (s.triggers.contextMenu) { chrome.contextMenus.create({ id: "rclonex-check-page", title: "rclone-jav: Scan", contexts: ["page", "link", "selection"], }); } }); } 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 */ } } chrome.contextMenus.onClicked.addListener(async (info, tab) => { if (info.menuItemId !== "rclonex-check-page" || !tab) return; const s = await getSettings(); const r = await checkTab(tab); 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) => { 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 prof = (settings.profiles || []).find((p) => p.name === settings.activeProfile); 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 || [], source_override: prof ? (prof.source || []) : [], target_override: prof ? (prof.target || []) : [], }); 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 prof = (settings.profiles || []).find((p) => p.name === settings.activeProfile); 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 || [], source_override: prof ? (prof.source || []) : [], target_override: prof ? (prof.target || []) : [], }, 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 === "dupe-review") { (async () => { try { const settings = await getSettings(); const prof = (settings.profiles || []).find((p) => p.name === settings.activeProfile); const r = await nativeCall({ action: "dupe_review", rcjav_path: settings.rcjavPath || "", part_patterns: settings.partPatterns || [], source_override: prof ? (prof.source || []) : [], target_override: prof ? (prof.target || []) : [], }, 600_000); sendResponse(r); } catch (e) { sendResponse({ ok: false, error: e.message }); } })(); return true; } if (msg.type === "delete_batch") { (async () => { try { const settings = await getSettings(); if (!settings.enableDelete) { sendResponse({ ok: false, error: "deletion is disabled in options" }); return; } const r = await nativeCall({ action: "delete_batch", paths: msg.paths, mode: settings.deleteMode || "trash", trash_dir: settings.trashDir || "", rcjav_path: settings.rcjavPath || "", }, 600_000); sendResponse(r); } catch (e) { sendResponse({ ok: false, error: e.message }); } })(); return true; } if (msg.type === "delete-skipped") { (async () => { try { const settings = await getSettings(); if (!settings.enableDelete) { sendResponse({ ok: false, error: "deletion is disabled in options" }); return; } const r = await nativeCall({ action: "delete_skipped", paths: msg.paths, mode: settings.deleteMode || "trash", trash_dir: settings.trashDir || "", rcjav_path: settings.rcjavPath || "", }, 120_000); sendResponse(r); } catch (e) { sendResponse({ ok: false, error: e.message }); } })(); return true; } if (msg.type === "get-keep-ranking") { (async () => { try { const settings = await getSettings(); const r = await nativeCall({ action: "get_keep_ranking", rcjav_path: settings.rcjavPath || "" }, 10_000); sendResponse(r); } catch (e) { sendResponse({ ok: false, error: e.message }); } })(); return true; } if (msg.type === "save-keep-ranking") { (async () => { try { const settings = await getSettings(); const r = await nativeCall({ action: "save_keep_ranking", rcjav_path: settings.rcjavPath || "", keep_ranking: msg.keep_ranking, }, 10_000); sendResponse(r); } catch (e) { sendResponse({ ok: false, error: e.message }); } })(); return true; } if (msg.type === "library_issues") { (async () => { try { const settings = await getSettings(); const r = await nativeCall({ action: "library_issues", rcjav_path: settings.rcjavPath || "", }, 60_000); sendResponse(r); } catch (e) { sendResponse({ ok: false, error: e.message }); } })(); return true; } if (msg.type === "rename_file") { (async () => { try { const settings = await getSettings(); const r = await nativeCall({ action: "rename_file", rcjav_path: settings.rcjavPath || "", remote: msg.remote, old_path: msg.old_path, new_path: msg.new_path, }, 60_000); sendResponse(r); } catch (e) { sendResponse({ ok: false, error: e.message }); } })(); return true; } if (msg.type === "rename_files_batch") { (async () => { try { const settings = await getSettings(); const r = await nativeCall({ action: "rename_files_batch", rcjav_path: settings.rcjavPath || "", renames: msg.renames, }, 300_000); sendResponse(r); } catch (e) { sendResponse({ ok: false, error: e.message }); } })(); return true; } 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: ["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: ["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 === "delete-file") { (async () => { const settings = await getSettings(); if (!settings.enableDelete) { sendResponse({ ok: false, error: "deletion is disabled in options" }); return; } try { const r = await nativeCall({ action: "delete", path: msg.path, mode: settings.deleteMode || "trash", trash_dir: settings.trashDir || "", rcjav_path: settings.rcjavPath || "", }); sendResponse(r); } catch (e) { sendResponse({ ok: false, error: e.message }); } })(); return true; } if (msg.type === "get-settings") { (async () => sendResponse(await getSettings()))(); return true; } if (msg.type === "diagnostics") { (async () => { try { const settings = await getSettings(); const r = await nativeCall({ action: "diagnostics", rcjav_path: settings.rcjavPath || "", extension_settings: { quick_mode: settings.quickMode !== false, active_profile: settings.activeProfile || "", profile_count: (settings.profiles || []).length, auto_every_page: !!settings.triggers?.autoEveryPage, auto_known_sites: !!settings.triggers?.autoKnownSites, }, }); sendResponse(r); } catch (e) { sendResponse({ ok: false, error: e.message, error_kind: classifyNativeError(e), extension_id: chrome.runtime.id }); } })(); return true; } if (msg.type === "cache-status") { (async () => { try { const settings = await getSettings(); const r = await nativeCall({ action: "cache_status", rcjav_path: settings.rcjavPath || "", stale_hours: settings.cacheStaleHours || 24, }); sendResponse(r); } catch (e) { sendResponse({ ok: false, error: e.message, error_kind: classifyNativeError(e), extension_id: chrome.runtime.id }); } })(); return true; } if (msg.type === "reextract-ids") { (async () => { try { const settings = await getSettings(); const r = await nativeCall({ action: "reextract_ids", rcjav_path: settings.rcjavPath || "", }, 300_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 === "host-status") { (async () => { try { const settings = await getSettings(); const r = await nativeCall({ action: "host_status", rcjav_path: settings.rcjavPath || "", extension_id: chrome.runtime.id, }, 20_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 === "repair-host") { (async () => { try { const settings = await getSettings(); const r = await nativeCall({ action: "host_repair", rcjav_path: settings.rcjavPath || "", extension_id: chrome.runtime.id, }, 20_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 === "ping-host") { (async () => { try { const settings = await getSettings(); const r = await nativeCall({ action: "ping", rcjav_path: msg.rcjavPath ?? settings.rcjavPath ?? "" }); sendResponse(r); } catch (e) { sendResponse({ ok: false, error: e.message }); } })(); return true; } if (msg.type === "run-scan") { (async () => { try { const settings = await getSettings(); if (settings.scanPaused) { sendResponse({ ok: false, paused: true, error: "scanning paused" }); return; } // Scans can take minutes — use a longer timeout (10 min cap). const r = await nativeCall( { action: "scan", scan_since: msg.scanSince || "", scan_roots: Array.isArray(msg.scanRoots) ? msg.scanRoots : [], rcjav_path: settings.rcjavPath || "", part_patterns: settings.partPatterns || [], timeout: 600, }, 600_000, ); sendResponse(r); } catch (e) { sendResponse({ ok: false, error: e.message }); } })(); return true; } if (msg.type === "scan-cancel") { (async () => { try { const settings = await getSettings(); const r = await nativeCall({ action: "scan_cancel", rcjav_path: settings.rcjavPath || "" }, 5_000); sendResponse(r); } catch (e) { sendResponse({ ok: false, error: e.message }); } })(); return true; } if (msg.type === "scan-progress") { (async () => { try { const settings = await getSettings(); const r = await nativeCall({ action: "scan_progress", rcjav_path: settings.rcjavPath || "" }, 5_000); sendResponse(r); } catch (e) { sendResponse({ ok: false, error: e.message }); } })(); return true; } if (msg.type === "scan-clear") { (async () => { try { const settings = await getSettings(); const r = await nativeCall({ action: "scan_clear", rcjav_path: settings.rcjavPath || "" }, 5_000); sendResponse(r); } catch (e) { sendResponse({ ok: false, error: e.message }); } })(); return true; } if (msg.type === "list-remotes") { (async () => { try { const settings = await getSettings(); const r = await nativeCall({ action: "list_remotes", rcjav_path: settings.rcjavPath || "" }, 20_000); sendResponse(r); } catch (e) { sendResponse({ ok: false, error: e.message }); } })(); return true; } if (msg.type === "recent-deletes") { (async () => { try { const settings = await getSettings(); const r = await nativeCall({ action: "recent_deletes", limit: msg.limit || 20, rcjav_path: settings.rcjavPath || "" }); sendResponse(r); } catch (e) { sendResponse({ ok: false, error: e.message }); } })(); return true; } if (msg.type === "undo-delete") { (async () => { const settings = await getSettings(); if (!settings.enableDelete) { sendResponse({ ok: false, error: "deletion is disabled in options" }); return; } try { const r = await nativeCall({ action: "undo_delete", dst: msg.dst, path: msg.path, rcjav_path: settings.rcjavPath || "" }); sendResponse(r); } catch (e) { sendResponse({ ok: false, error: e.message }); } })(); return true; } }); // On install / startup, build context menu chrome.runtime.onInstalled.addListener(() => ensureContextMenu()); chrome.runtime.onStartup.addListener(() => 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("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 */ } });