Files
ext-rclone-jav/background.js
T

1078 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
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
};
// ----- 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 === "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 === "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());