c17ac9e1e7
Completes the two-tier cache contract from step 9 / docs/CACHE_CONTRACT.md
on the extension side. The Python side shipped in the Python repo at
33c495a.
Host (rcjav-host.py):
- fetch_rules_info() memoizes per-script-path calls to
`rc-jav.py --print-rules-info` so handle_cache_status doesn't pay
the Python startup cost on every poll.
- _cache_freshness_fields(data, rules_info) computes the new
cache_schema / id_rules / id_rules_signature trio + their three
*_match booleans + cache_state ('fresh' / 'stale_by_rules' /
'schema_mismatch' / 'missing'). Legacy version:3 caches still on
disk report as stale_by_rules with cache_schema_match=True (we'll
migrate them at next load_cache).
- New handle_reextract_ids() action forwards to
`rc-jav.py --reextract --format json` with a 5-minute timeout.
background.js:
- New `reextract-ids` message forwards to host with a 300s timeout.
options-cache.js + options-library-issues.js:
- renderCacheContractBanner() paints a three-state banner above the
per-remote list: green ✓ fresh / amber ! stale-by-rules (with
"Re-extract IDs (fast, no rescan)" chip button) / red ✗ schema
mismatch. Includes a snippet of the cache signature for diagnostics.
- Delegated click handler in options-library-issues.js catches
.cache-reextract, sends the message, shows transient
"Re-extracting…" state, and replaces the button with a per-summary
line ("Re-extracted N IDs · X changed · Y unchanged · Z dropped").
- rules_info_error from the host surfaces as its own amber line above
the banner.
node --check passes on background.js, options-cache.js,
options-library-issues.js individually and on the concatenation of all
four script files. python -m py_compile passes on rcjav-host.py.
Behavioral verification requires reloading the unpacked extension and
running through:
- Check Cache → banner shows "stale by rules" amber (legacy v3 cache)
- Click "Re-extract IDs" → fast path runs, summary appears
- Check Cache again → banner now shows "Cache up to date" green
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1137 lines
40 KiB
JavaScript
1137 lines
40 KiB
JavaScript
// 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 */ }
|
||
});
|