933 lines
30 KiB
JavaScript
933 lines
30 KiB
JavaScript
(function attachBackgroundTabRuntime(root, factory) {
|
|
root.MultiPageBackgroundTabRuntime = factory();
|
|
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundTabRuntimeModule() {
|
|
function createTabRuntime(deps = {}) {
|
|
const {
|
|
addLog,
|
|
chrome,
|
|
getSourceLabel,
|
|
getState,
|
|
isLocalhostOAuthCallbackUrl,
|
|
isRetryableContentScriptTransportError,
|
|
LOG_PREFIX,
|
|
matchesSourceUrlFamily,
|
|
setState,
|
|
sleepWithStop,
|
|
STOP_ERROR_MESSAGE,
|
|
throwIfStopped,
|
|
} = deps;
|
|
|
|
const pendingCommands = new Map();
|
|
|
|
function normalizeAutomationWindowId(value) {
|
|
if (value === null || value === undefined || value === '') {
|
|
return null;
|
|
}
|
|
const numeric = Math.floor(Number(value));
|
|
return Number.isInteger(numeric) && numeric >= 0 ? numeric : null;
|
|
}
|
|
|
|
async function getAutomationWindowId(options = {}) {
|
|
const directWindowId = normalizeAutomationWindowId(
|
|
options.automationWindowId ?? options.windowId ?? null
|
|
);
|
|
if (directWindowId !== null) {
|
|
return directWindowId;
|
|
}
|
|
|
|
const state = await getState();
|
|
return normalizeAutomationWindowId(state?.automationWindowId);
|
|
}
|
|
|
|
async function withAutomationWindowScope(queryInfo = {}, options = {}) {
|
|
const windowId = await getAutomationWindowId(options);
|
|
if (windowId === null) {
|
|
return { ...(queryInfo || {}) };
|
|
}
|
|
const scoped = {
|
|
...(queryInfo || {}),
|
|
windowId,
|
|
};
|
|
delete scoped.currentWindow;
|
|
return scoped;
|
|
}
|
|
|
|
async function queryTabsInAutomationWindow(queryInfo = {}, options = {}) {
|
|
const scopedQuery = await withAutomationWindowScope(queryInfo, options);
|
|
try {
|
|
return await chrome.tabs.query(scopedQuery);
|
|
} catch (error) {
|
|
if (Object.prototype.hasOwnProperty.call(scopedQuery, 'windowId')) {
|
|
await setState({ automationWindowId: null }).catch(() => {});
|
|
return chrome.tabs.query(queryInfo || {});
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function createAutomationTab(createProperties = {}, options = {}) {
|
|
const windowId = await getAutomationWindowId(options);
|
|
const properties = {
|
|
...(createProperties || {}),
|
|
...(windowId !== null ? { windowId } : {}),
|
|
};
|
|
|
|
try {
|
|
return await chrome.tabs.create(properties);
|
|
} catch (error) {
|
|
if (windowId !== null) {
|
|
await setState({ automationWindowId: null }).catch(() => {});
|
|
return chrome.tabs.create(createProperties || {});
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function isTabInAutomationWindow(tabOrId, options = {}) {
|
|
const windowId = await getAutomationWindowId(options);
|
|
if (windowId === null) {
|
|
return true;
|
|
}
|
|
|
|
let tab = tabOrId;
|
|
if (Number.isInteger(tabOrId)) {
|
|
if (typeof chrome?.tabs?.get !== 'function') {
|
|
return true;
|
|
}
|
|
tab = await chrome.tabs.get(tabOrId).catch(() => null);
|
|
}
|
|
|
|
const tabWindowId = normalizeAutomationWindowId(tab?.windowId);
|
|
return tabWindowId === null || tabWindowId === windowId;
|
|
}
|
|
|
|
async function sleepOrStop(ms) {
|
|
if (typeof sleepWithStop === 'function') {
|
|
await sleepWithStop(ms);
|
|
return;
|
|
}
|
|
|
|
const start = Date.now();
|
|
while (Date.now() - start < ms) {
|
|
throwIfStopped();
|
|
await new Promise((resolve) => setTimeout(resolve, Math.min(100, ms - (Date.now() - start))));
|
|
}
|
|
}
|
|
|
|
function waitForTabUpdateComplete(tabId, timeoutMs = 30000) {
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
let stopTimer = null;
|
|
|
|
const cleanup = () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
clearTimeout(stopTimer);
|
|
chrome.tabs.onUpdated.removeListener(listener);
|
|
};
|
|
|
|
const resolveSafely = () => {
|
|
cleanup();
|
|
resolve();
|
|
};
|
|
|
|
const rejectSafely = (error) => {
|
|
cleanup();
|
|
reject(error);
|
|
};
|
|
|
|
const listener = (updatedTabId, info) => {
|
|
if (updatedTabId === tabId && info.status === 'complete') {
|
|
resolveSafely();
|
|
}
|
|
};
|
|
|
|
const timer = setTimeout(resolveSafely, timeoutMs);
|
|
chrome.tabs.onUpdated.addListener(listener);
|
|
|
|
const pollStop = () => {
|
|
if (settled) return;
|
|
try {
|
|
throwIfStopped();
|
|
} catch (error) {
|
|
rejectSafely(error);
|
|
return;
|
|
}
|
|
stopTimer = setTimeout(pollStop, 100);
|
|
};
|
|
|
|
pollStop();
|
|
});
|
|
}
|
|
|
|
async function getTabRegistry() {
|
|
const state = await getState();
|
|
return state.tabRegistry || {};
|
|
}
|
|
|
|
async function registerTab(source, tabId) {
|
|
const registry = await getTabRegistry();
|
|
let windowId = null;
|
|
if (chrome?.tabs?.get && Number.isInteger(tabId)) {
|
|
const tab = await chrome.tabs.get(tabId).catch(() => null);
|
|
if (tab && !(await isTabInAutomationWindow(tab))) {
|
|
console.log(LOG_PREFIX, `Ignored tab registration outside automation window: ${source} -> ${tabId}`);
|
|
return;
|
|
}
|
|
windowId = normalizeAutomationWindowId(tab?.windowId);
|
|
}
|
|
registry[source] = {
|
|
tabId,
|
|
ready: true,
|
|
...(windowId !== null ? { windowId } : {}),
|
|
};
|
|
await setState({ tabRegistry: registry });
|
|
console.log(LOG_PREFIX, `Tab registered: ${source} -> ${tabId}`);
|
|
}
|
|
|
|
async function isTabAlive(source) {
|
|
const registry = await getTabRegistry();
|
|
const entry = registry[source];
|
|
if (!entry) return false;
|
|
try {
|
|
const tab = await chrome.tabs.get(entry.tabId);
|
|
if (!(await isTabInAutomationWindow(tab))) {
|
|
registry[source] = null;
|
|
await setState({ tabRegistry: registry });
|
|
return false;
|
|
}
|
|
return true;
|
|
} catch {
|
|
registry[source] = null;
|
|
await setState({ tabRegistry: registry });
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function getTabId(source) {
|
|
const registry = await getTabRegistry();
|
|
const tabId = registry[source]?.tabId || null;
|
|
if (!Number.isInteger(tabId)) {
|
|
return null;
|
|
}
|
|
if (!(await isTabInAutomationWindow(tabId))) {
|
|
registry[source] = null;
|
|
await setState({ tabRegistry: registry });
|
|
return null;
|
|
}
|
|
return tabId;
|
|
}
|
|
|
|
async function rememberSourceLastUrl(source, url) {
|
|
if (!source || !url) return;
|
|
const state = await getState();
|
|
const sourceLastUrls = { ...(state.sourceLastUrls || {}) };
|
|
sourceLastUrls[source] = url;
|
|
await setState({ sourceLastUrls });
|
|
}
|
|
|
|
async function closeConflictingTabsForSource(source, currentUrl, options = {}) {
|
|
const { excludeTabIds = [] } = options;
|
|
const excluded = new Set(excludeTabIds.filter((id) => Number.isInteger(id)));
|
|
const state = await getState();
|
|
const lastUrl = state.sourceLastUrls?.[source];
|
|
const referenceUrls = [currentUrl, lastUrl].filter(Boolean);
|
|
|
|
if (!referenceUrls.length) return;
|
|
|
|
const tabs = await queryTabsInAutomationWindow({});
|
|
const matchedIds = tabs
|
|
.filter((tab) => Number.isInteger(tab.id) && !excluded.has(tab.id))
|
|
.filter((tab) => referenceUrls.some((refUrl) => matchesSourceUrlFamily(source, tab.url, refUrl)))
|
|
.map((tab) => tab.id);
|
|
|
|
if (!matchedIds.length) return;
|
|
|
|
await chrome.tabs.remove(matchedIds).catch(() => { });
|
|
|
|
const registry = await getTabRegistry();
|
|
if (registry[source]?.tabId && matchedIds.includes(registry[source].tabId)) {
|
|
registry[source] = null;
|
|
await setState({ tabRegistry: registry });
|
|
}
|
|
|
|
await addLog(`已关闭 ${matchedIds.length} 个旧的${getSourceLabel(source)}标签页。`, 'info');
|
|
}
|
|
|
|
function isLocalhostOAuthCallbackTabMatch(callbackUrl, candidateUrl) {
|
|
if (!isLocalhostOAuthCallbackUrl(callbackUrl) || !isLocalhostOAuthCallbackUrl(candidateUrl)) {
|
|
return false;
|
|
}
|
|
|
|
const callback = new URL(callbackUrl);
|
|
const candidate = new URL(candidateUrl);
|
|
return callback.origin === candidate.origin
|
|
&& callback.pathname === candidate.pathname
|
|
&& callback.searchParams.get('code') === candidate.searchParams.get('code')
|
|
&& callback.searchParams.get('state') === candidate.searchParams.get('state');
|
|
}
|
|
|
|
async function closeLocalhostCallbackTabs(callbackUrl, options = {}) {
|
|
if (!isLocalhostOAuthCallbackUrl(callbackUrl)) return 0;
|
|
|
|
const { excludeTabIds = [] } = options;
|
|
const excluded = new Set(excludeTabIds.filter((id) => Number.isInteger(id)));
|
|
const tabs = await queryTabsInAutomationWindow({});
|
|
const matchedIds = tabs
|
|
.filter((tab) => Number.isInteger(tab.id) && !excluded.has(tab.id))
|
|
.filter((tab) => isLocalhostOAuthCallbackTabMatch(callbackUrl, tab.url))
|
|
.map((tab) => tab.id);
|
|
|
|
if (!matchedIds.length) return 0;
|
|
|
|
await chrome.tabs.remove(matchedIds).catch(() => { });
|
|
|
|
const registry = await getTabRegistry();
|
|
if (registry['signup-page']?.tabId && matchedIds.includes(registry['signup-page'].tabId)) {
|
|
registry['signup-page'] = null;
|
|
await setState({ tabRegistry: registry });
|
|
}
|
|
|
|
await addLog(`已关闭 ${matchedIds.length} 个匹配当前 OAuth callback 的 localhost 残留标签页。`, 'info');
|
|
return matchedIds.length;
|
|
}
|
|
|
|
function buildLocalhostCleanupPrefix(rawUrl) {
|
|
if (!isLocalhostOAuthCallbackUrl(rawUrl)) return '';
|
|
const parsed = new URL(rawUrl);
|
|
const segments = parsed.pathname.split('/').filter(Boolean);
|
|
if (!segments.length) return parsed.origin;
|
|
return `${parsed.origin}/${segments[0]}`;
|
|
}
|
|
|
|
async function closeTabsByUrlPrefix(prefix, options = {}) {
|
|
if (!prefix) return 0;
|
|
|
|
const { excludeTabIds = [], excludeUrls = [], excludeLocalhostCallbacks = false } = options;
|
|
const excluded = new Set(excludeTabIds.filter((id) => Number.isInteger(id)));
|
|
const excludedUrls = new Set((Array.isArray(excludeUrls) ? excludeUrls : []).filter(Boolean));
|
|
const tabs = await queryTabsInAutomationWindow({});
|
|
const matchedIds = tabs
|
|
.filter((tab) => Number.isInteger(tab.id) && !excluded.has(tab.id))
|
|
.filter((tab) => typeof tab.url === 'string' && !excludedUrls.has(tab.url))
|
|
.filter((tab) => !(excludeLocalhostCallbacks && isLocalhostOAuthCallbackUrl(tab.url)))
|
|
.filter((tab) => typeof tab.url === 'string' && tab.url.startsWith(prefix))
|
|
.filter((tab) => !isLocalhostOAuthCallbackUrl(tab.url))
|
|
.map((tab) => tab.id);
|
|
|
|
if (!matchedIds.length) return 0;
|
|
|
|
await chrome.tabs.remove(matchedIds).catch(() => { });
|
|
await addLog(`已关闭 ${matchedIds.length} 个匹配 ${prefix} 的 localhost 残留标签页。`, 'info');
|
|
return matchedIds.length;
|
|
}
|
|
|
|
async function pingContentScriptOnTab(tabId) {
|
|
if (!Number.isInteger(tabId)) return null;
|
|
try {
|
|
return await chrome.tabs.sendMessage(tabId, {
|
|
type: 'PING',
|
|
source: 'background',
|
|
payload: {},
|
|
});
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function waitForTabUrlFamily(source, tabId, referenceUrl, options = {}) {
|
|
const { timeoutMs = 15000, retryDelayMs = 400 } = options;
|
|
const start = Date.now();
|
|
|
|
while (Date.now() - start < timeoutMs) {
|
|
try {
|
|
const tab = await chrome.tabs.get(tabId);
|
|
if (matchesSourceUrlFamily(source, tab.url, referenceUrl)) {
|
|
return tab;
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
await sleepOrStop(retryDelayMs);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function waitForTabUrlMatch(tabId, matcher, options = {}) {
|
|
const { timeoutMs = 15000, retryDelayMs = 400 } = options;
|
|
const start = Date.now();
|
|
|
|
while (Date.now() - start < timeoutMs) {
|
|
try {
|
|
const tab = await chrome.tabs.get(tabId);
|
|
if (matcher(tab.url || '', tab)) {
|
|
return tab;
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
await sleepOrStop(retryDelayMs);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function waitForTabComplete(tabId, options = {}) {
|
|
const { timeoutMs = 15000, retryDelayMs = 300 } = options;
|
|
const start = Date.now();
|
|
|
|
while (Date.now() - start < timeoutMs) {
|
|
try {
|
|
const tab = await chrome.tabs.get(tabId);
|
|
if (tab?.status === 'complete') {
|
|
return tab;
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
await sleepOrStop(retryDelayMs);
|
|
}
|
|
|
|
try {
|
|
return await chrome.tabs.get(tabId);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function waitForTabStableComplete(tabId, options = {}) {
|
|
const {
|
|
timeoutMs = 30000,
|
|
retryDelayMs = 300,
|
|
stableMs = 1000,
|
|
initialDelayMs = 0,
|
|
} = options;
|
|
const start = Date.now();
|
|
let lastUrl = '';
|
|
let lastStatus = '';
|
|
let stableStartedAt = 0;
|
|
let lastTab = null;
|
|
|
|
if (initialDelayMs > 0) {
|
|
await sleepOrStop(initialDelayMs);
|
|
}
|
|
|
|
while (Date.now() - start < timeoutMs) {
|
|
throwIfStopped();
|
|
try {
|
|
lastTab = await chrome.tabs.get(tabId);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const currentUrl = String(lastTab?.url || '');
|
|
const currentStatus = String(lastTab?.status || '');
|
|
if (currentStatus === 'complete') {
|
|
if (currentUrl !== lastUrl || currentStatus !== lastStatus || !stableStartedAt) {
|
|
stableStartedAt = Date.now();
|
|
}
|
|
if (Date.now() - stableStartedAt >= stableMs) {
|
|
return lastTab;
|
|
}
|
|
} else {
|
|
stableStartedAt = 0;
|
|
}
|
|
|
|
lastUrl = currentUrl;
|
|
lastStatus = currentStatus;
|
|
await sleepOrStop(retryDelayMs);
|
|
}
|
|
|
|
return lastTab;
|
|
}
|
|
|
|
async function ensureContentScriptReadyOnTab(source, tabId, options = {}) {
|
|
const {
|
|
inject = null,
|
|
injectSource = null,
|
|
timeoutMs = 30000,
|
|
retryDelayMs = 700,
|
|
logMessage = '',
|
|
logStep = null,
|
|
logStepKey = '',
|
|
} = options;
|
|
|
|
const start = Date.now();
|
|
let lastError = null;
|
|
let logged = false;
|
|
let attempt = 0;
|
|
|
|
console.log(
|
|
LOG_PREFIX,
|
|
`[ensureContentScriptReadyOnTab] start ${source} tab=${tabId}, timeout=${timeoutMs}ms, inject=${Array.isArray(inject) ? inject.join(',') : 'none'}`
|
|
);
|
|
|
|
while (Date.now() - start < timeoutMs) {
|
|
attempt += 1;
|
|
const pong = await pingContentScriptOnTab(tabId);
|
|
if (pong?.ok && (!pong.source || pong.source === source)) {
|
|
console.log(LOG_PREFIX, `[ensureContentScriptReadyOnTab] ready ${source} tab=${tabId} on attempt ${attempt} after ${Date.now() - start}ms`);
|
|
await registerTab(source, tabId);
|
|
return;
|
|
}
|
|
|
|
if (!inject || !inject.length) {
|
|
throw new Error(`${getSourceLabel(source)} 内容脚本未就绪,且未提供可用的注入文件。`);
|
|
}
|
|
|
|
const registry = await getTabRegistry();
|
|
if (registry[source]) {
|
|
registry[source].ready = false;
|
|
await setState({ tabRegistry: registry });
|
|
}
|
|
|
|
try {
|
|
if (injectSource) {
|
|
await chrome.scripting.executeScript({
|
|
target: { tabId },
|
|
func: (injectedSource) => {
|
|
window.__MULTIPAGE_SOURCE = injectedSource;
|
|
},
|
|
args: [injectSource],
|
|
});
|
|
}
|
|
|
|
await chrome.scripting.executeScript({
|
|
target: { tabId },
|
|
files: inject,
|
|
});
|
|
} catch (err) {
|
|
lastError = err;
|
|
console.warn(LOG_PREFIX, `[ensureContentScriptReadyOnTab] inject attempt ${attempt} failed for ${source} tab=${tabId}: ${err?.message || err}`);
|
|
}
|
|
|
|
const pongAfterInject = await pingContentScriptOnTab(tabId);
|
|
if (pongAfterInject?.ok && (!pongAfterInject.source || pongAfterInject.source === source)) {
|
|
console.log(LOG_PREFIX, `[ensureContentScriptReadyOnTab] ready after inject ${source} tab=${tabId} on attempt ${attempt} after ${Date.now() - start}ms`);
|
|
await registerTab(source, tabId);
|
|
return;
|
|
}
|
|
|
|
if (logMessage && !logged) {
|
|
console.warn(LOG_PREFIX, `[ensureContentScriptReadyOnTab] ${source} tab=${tabId} still not ready after ${Date.now() - start}ms`);
|
|
await addLog(logMessage, 'warn', {
|
|
step: logStep,
|
|
stepKey: logStepKey,
|
|
});
|
|
logged = true;
|
|
}
|
|
|
|
await sleepOrStop(retryDelayMs);
|
|
}
|
|
|
|
throw lastError || new Error(`${getSourceLabel(source)} 内容脚本长时间未就绪。`);
|
|
}
|
|
|
|
function getContentScriptResponseTimeoutMs(message) {
|
|
if (!message || typeof message !== 'object') return 30000;
|
|
if (message.type === 'EXECUTE_STEP' && Number(message.step) === 6) return 75000;
|
|
if (message.type === 'POLL_EMAIL') {
|
|
const maxAttempts = Math.max(1, Number(message.payload?.maxAttempts) || 1);
|
|
const intervalMs = Math.max(0, Number(message.payload?.intervalMs) || 0);
|
|
return Math.max(45000, maxAttempts * intervalMs + 25000);
|
|
}
|
|
if (message.type === 'FILL_CODE') return Number(message.step) === 7 ? 45000 : 30000;
|
|
if (message.type === 'PREPARE_SIGNUP_VERIFICATION') return 45000;
|
|
return 30000;
|
|
}
|
|
|
|
function resolveResponseTimeoutMs(message, requestedResponseTimeoutMs, remainingTimeoutMs = null) {
|
|
const fallbackTimeoutMs = getContentScriptResponseTimeoutMs(message);
|
|
const requestedTimeoutMs = Number.isFinite(Number(requestedResponseTimeoutMs))
|
|
? Math.max(1, Math.floor(Number(requestedResponseTimeoutMs)))
|
|
: fallbackTimeoutMs;
|
|
if (!Number.isFinite(Number(remainingTimeoutMs))) {
|
|
return requestedTimeoutMs;
|
|
}
|
|
return Math.max(1, Math.min(requestedTimeoutMs, Math.floor(Number(remainingTimeoutMs))));
|
|
}
|
|
|
|
function getMessageDebugLabel(source, message, tabId = null) {
|
|
const parts = [source || 'unknown', message?.type || 'UNKNOWN'];
|
|
if (Number.isInteger(message?.step)) parts.push(`step=${message.step}`);
|
|
if (Number.isInteger(tabId)) parts.push(`tab=${tabId}`);
|
|
return parts.join(' ');
|
|
}
|
|
|
|
function summarizeMessageResultForDebug(result) {
|
|
if (result === undefined) return 'undefined';
|
|
if (result === null) return 'null';
|
|
if (typeof result !== 'object') return JSON.stringify(result);
|
|
const summary = {};
|
|
for (const key of ['ok', 'error', 'stopped', 'source', 'step']) {
|
|
if (key in result) summary[key] = result[key];
|
|
}
|
|
if (result.payload && typeof result.payload === 'object') {
|
|
summary.payloadKeys = Object.keys(result.payload);
|
|
}
|
|
return JSON.stringify(summary);
|
|
}
|
|
|
|
function sendTabMessageWithTimeout(tabId, source, message, responseTimeoutMs = getContentScriptResponseTimeoutMs(message)) {
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
const startedAt = Date.now();
|
|
const debugLabel = getMessageDebugLabel(source, message, tabId);
|
|
|
|
console.log(LOG_PREFIX, `[sendTabMessageWithTimeout] dispatch ${debugLabel}, timeout=${responseTimeoutMs}ms`);
|
|
|
|
const timer = setTimeout(() => {
|
|
if (settled) return;
|
|
settled = true;
|
|
const seconds = Math.ceil(responseTimeoutMs / 1000);
|
|
console.warn(LOG_PREFIX, `[sendTabMessageWithTimeout] timeout ${debugLabel} after ${Date.now() - startedAt}ms`);
|
|
reject(new Error(`Content script on ${source} did not respond in ${seconds}s. Try refreshing the tab and retry.`));
|
|
}, responseTimeoutMs);
|
|
|
|
chrome.tabs.sendMessage(tabId, message)
|
|
.then((value) => {
|
|
const elapsed = Date.now() - startedAt;
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
console.log(LOG_PREFIX, `[sendTabMessageWithTimeout] response ${debugLabel} after ${elapsed}ms: ${summarizeMessageResultForDebug(value)}`);
|
|
resolve(value);
|
|
})
|
|
.catch((error) => {
|
|
const elapsed = Date.now() - startedAt;
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
console.warn(LOG_PREFIX, `[sendTabMessageWithTimeout] rejection ${debugLabel} after ${elapsed}ms: ${error?.message || error}`);
|
|
reject(error);
|
|
});
|
|
});
|
|
}
|
|
|
|
function queueCommand(source, message, timeout = 15000) {
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
pendingCommands.delete(source);
|
|
reject(new Error(`Content script on ${source} did not respond in ${timeout / 1000}s. Try refreshing the tab and retry.`));
|
|
}, timeout);
|
|
pendingCommands.set(source, {
|
|
message,
|
|
resolve,
|
|
reject,
|
|
timer,
|
|
responseTimeoutMs: timeout,
|
|
});
|
|
console.log(LOG_PREFIX, `Command queued for ${source} (waiting for ready)`);
|
|
});
|
|
}
|
|
|
|
function flushCommand(source, tabId) {
|
|
const pending = pendingCommands.get(source);
|
|
if (pending) {
|
|
clearTimeout(pending.timer);
|
|
pendingCommands.delete(source);
|
|
sendTabMessageWithTimeout(tabId, source, pending.message, pending.responseTimeoutMs).then(pending.resolve).catch(pending.reject);
|
|
console.log(LOG_PREFIX, `Flushed queued command to ${source} (tab ${tabId})`);
|
|
}
|
|
}
|
|
|
|
function cancelPendingCommands(reason = STOP_ERROR_MESSAGE) {
|
|
for (const [source, pending] of pendingCommands.entries()) {
|
|
clearTimeout(pending.timer);
|
|
pending.reject(new Error(reason));
|
|
pendingCommands.delete(source);
|
|
console.log(LOG_PREFIX, `Cancelled queued command for ${source}`);
|
|
}
|
|
}
|
|
|
|
async function reuseOrCreateTab(source, url, options = {}) {
|
|
if (options.forceNew) {
|
|
await closeConflictingTabsForSource(source, url);
|
|
const tab = await createAutomationTab({ url, active: true }, options);
|
|
|
|
if (options.inject) {
|
|
await waitForTabUpdateComplete(tab.id);
|
|
if (options.injectSource) {
|
|
await chrome.scripting.executeScript({
|
|
target: { tabId: tab.id },
|
|
func: (injectedSource) => {
|
|
window.__MULTIPAGE_SOURCE = injectedSource;
|
|
},
|
|
args: [options.injectSource],
|
|
});
|
|
}
|
|
await chrome.scripting.executeScript({
|
|
target: { tabId: tab.id },
|
|
files: options.inject,
|
|
});
|
|
}
|
|
|
|
await rememberSourceLastUrl(source, url);
|
|
return tab.id;
|
|
}
|
|
|
|
const alive = await isTabAlive(source);
|
|
if (alive) {
|
|
const tabId = await getTabId(source);
|
|
await closeConflictingTabsForSource(source, url, { excludeTabIds: [tabId] });
|
|
const currentTab = await chrome.tabs.get(tabId);
|
|
const sameUrl = currentTab.url === url;
|
|
const shouldReloadOnReuse = sameUrl && options.reloadIfSameUrl;
|
|
|
|
const registry = await getTabRegistry();
|
|
if (sameUrl) {
|
|
await chrome.tabs.update(tabId, { active: true });
|
|
if (shouldReloadOnReuse) {
|
|
if (registry[source]) registry[source].ready = false;
|
|
await setState({ tabRegistry: registry });
|
|
await chrome.tabs.reload(tabId);
|
|
await waitForTabUpdateComplete(tabId);
|
|
}
|
|
|
|
if (options.inject) {
|
|
if (registry[source]) registry[source].ready = false;
|
|
await setState({ tabRegistry: registry });
|
|
if (options.injectSource) {
|
|
await chrome.scripting.executeScript({
|
|
target: { tabId },
|
|
func: (injectedSource) => {
|
|
window.__MULTIPAGE_SOURCE = injectedSource;
|
|
},
|
|
args: [options.injectSource],
|
|
});
|
|
}
|
|
await chrome.scripting.executeScript({
|
|
target: { tabId },
|
|
files: options.inject,
|
|
});
|
|
await sleepOrStop(500);
|
|
}
|
|
|
|
await rememberSourceLastUrl(source, url);
|
|
return tabId;
|
|
}
|
|
|
|
if (registry[source]) registry[source].ready = false;
|
|
await setState({ tabRegistry: registry });
|
|
await chrome.tabs.update(tabId, { url, active: true });
|
|
|
|
await waitForTabUpdateComplete(tabId);
|
|
|
|
if (options.inject) {
|
|
if (options.injectSource) {
|
|
await chrome.scripting.executeScript({
|
|
target: { tabId },
|
|
func: (injectedSource) => {
|
|
window.__MULTIPAGE_SOURCE = injectedSource;
|
|
},
|
|
args: [options.injectSource],
|
|
});
|
|
}
|
|
await chrome.scripting.executeScript({
|
|
target: { tabId },
|
|
files: options.inject,
|
|
});
|
|
}
|
|
|
|
await sleepOrStop(500);
|
|
await rememberSourceLastUrl(source, url);
|
|
return tabId;
|
|
}
|
|
|
|
await closeConflictingTabsForSource(source, url);
|
|
const tab = await createAutomationTab({ url, active: true }, options);
|
|
|
|
if (options.inject) {
|
|
await waitForTabUpdateComplete(tab.id);
|
|
if (options.injectSource) {
|
|
await chrome.scripting.executeScript({
|
|
target: { tabId: tab.id },
|
|
func: (injectedSource) => {
|
|
window.__MULTIPAGE_SOURCE = injectedSource;
|
|
},
|
|
args: [options.injectSource],
|
|
});
|
|
}
|
|
await chrome.scripting.executeScript({
|
|
target: { tabId: tab.id },
|
|
files: options.inject,
|
|
});
|
|
}
|
|
|
|
await rememberSourceLastUrl(source, url);
|
|
return tab.id;
|
|
}
|
|
|
|
async function sendToContentScript(source, message, options = {}) {
|
|
throwIfStopped();
|
|
const { responseTimeoutMs = getContentScriptResponseTimeoutMs(message) } = options;
|
|
const registry = await getTabRegistry();
|
|
const entry = registry[source];
|
|
|
|
if (!entry || !entry.ready) {
|
|
throwIfStopped();
|
|
return queueCommand(source, message, responseTimeoutMs);
|
|
}
|
|
|
|
const alive = await isTabAlive(source);
|
|
throwIfStopped();
|
|
if (!alive) {
|
|
return queueCommand(source, message, responseTimeoutMs);
|
|
}
|
|
|
|
throwIfStopped();
|
|
return sendTabMessageWithTimeout(entry.tabId, source, message, responseTimeoutMs);
|
|
}
|
|
|
|
async function sendToContentScriptResilient(source, message, options = {}) {
|
|
const {
|
|
timeoutMs = 30000,
|
|
retryDelayMs = 600,
|
|
logMessage = '',
|
|
logStep = null,
|
|
logStepKey = '',
|
|
responseTimeoutMs,
|
|
} = options;
|
|
const start = Date.now();
|
|
let lastError = null;
|
|
let logged = false;
|
|
let attempt = 0;
|
|
|
|
while (Date.now() - start < timeoutMs) {
|
|
throwIfStopped();
|
|
attempt += 1;
|
|
const remainingTimeoutMs = Math.max(1, timeoutMs - (Date.now() - start));
|
|
const effectiveResponseTimeoutMs = resolveResponseTimeoutMs(
|
|
message,
|
|
responseTimeoutMs,
|
|
remainingTimeoutMs
|
|
);
|
|
|
|
try {
|
|
return await sendToContentScript(
|
|
source,
|
|
message,
|
|
{ responseTimeoutMs: effectiveResponseTimeoutMs }
|
|
);
|
|
} catch (err) {
|
|
const retryable = isRetryableContentScriptTransportError(err);
|
|
if (!retryable) {
|
|
throw err;
|
|
}
|
|
|
|
lastError = err;
|
|
if (logMessage && !logged) {
|
|
await addLog(logMessage, 'warn', {
|
|
step: logStep,
|
|
stepKey: logStepKey,
|
|
});
|
|
logged = true;
|
|
}
|
|
|
|
await sleepOrStop(retryDelayMs);
|
|
}
|
|
}
|
|
|
|
throw lastError || new Error(`等待 ${getSourceLabel(source)} 重新就绪超时。`);
|
|
}
|
|
|
|
async function sendToMailContentScriptResilient(mail, message, options = {}) {
|
|
const {
|
|
timeoutMs = 45000,
|
|
maxRecoveryAttempts = 2,
|
|
logStep = null,
|
|
logStepKey = '',
|
|
responseTimeoutMs,
|
|
} = options;
|
|
const start = Date.now();
|
|
let lastError = null;
|
|
let recoveries = 0;
|
|
let logged = false;
|
|
|
|
while (Date.now() - start < timeoutMs) {
|
|
throwIfStopped();
|
|
const remainingTimeoutMs = Math.max(1, timeoutMs - (Date.now() - start));
|
|
const effectiveResponseTimeoutMs = resolveResponseTimeoutMs(
|
|
message,
|
|
responseTimeoutMs,
|
|
remainingTimeoutMs
|
|
);
|
|
|
|
try {
|
|
return await sendToContentScript(
|
|
mail.source,
|
|
message,
|
|
{ responseTimeoutMs: effectiveResponseTimeoutMs }
|
|
);
|
|
} catch (err) {
|
|
if (!isRetryableContentScriptTransportError(err)) {
|
|
throw err;
|
|
}
|
|
|
|
lastError = err;
|
|
if (!logged) {
|
|
await addLog(`${mail.label} 页面通信异常,正在尝试让邮箱页重新就绪...`, 'warn', {
|
|
step: logStep,
|
|
stepKey: logStepKey,
|
|
});
|
|
logged = true;
|
|
}
|
|
|
|
if (recoveries >= maxRecoveryAttempts) {
|
|
break;
|
|
}
|
|
|
|
recoveries += 1;
|
|
await reuseOrCreateTab(mail.source, mail.url, {
|
|
inject: mail.inject,
|
|
injectSource: mail.injectSource,
|
|
reloadIfSameUrl: true,
|
|
});
|
|
await sleepOrStop(800);
|
|
}
|
|
}
|
|
|
|
throw lastError || new Error(`${mail.label} 页面未能重新就绪。`);
|
|
}
|
|
|
|
return {
|
|
buildLocalhostCleanupPrefix,
|
|
cancelPendingCommands,
|
|
closeConflictingTabsForSource,
|
|
closeLocalhostCallbackTabs,
|
|
closeTabsByUrlPrefix,
|
|
createAutomationTab,
|
|
ensureContentScriptReadyOnTab,
|
|
flushCommand,
|
|
getAutomationWindowId,
|
|
getContentScriptResponseTimeoutMs,
|
|
getMessageDebugLabel,
|
|
getTabId,
|
|
getTabRegistry,
|
|
isLocalhostOAuthCallbackTabMatch,
|
|
isTabAlive,
|
|
isTabInAutomationWindow,
|
|
pingContentScriptOnTab,
|
|
queueCommand,
|
|
queryTabsInAutomationWindow,
|
|
registerTab,
|
|
rememberSourceLastUrl,
|
|
resolveResponseTimeoutMs,
|
|
reuseOrCreateTab,
|
|
sendTabMessageWithTimeout,
|
|
sendToContentScript,
|
|
sendToContentScriptResilient,
|
|
sendToMailContentScriptResilient,
|
|
summarizeMessageResultForDebug,
|
|
waitForTabComplete,
|
|
waitForTabStableComplete,
|
|
waitForTabUrlFamily,
|
|
waitForTabUrlMatch,
|
|
};
|
|
}
|
|
|
|
return {
|
|
createTabRuntime,
|
|
};
|
|
});
|