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