Files
FlowPilot/background/tab-runtime.js
T
QLHazyCoder 4603f512fb chore: preserve step-aware logging after upstream sync
- 吸收 master 的核心改动:按可见步骤归类日志显示,覆盖 OAuth、手机号验证、平台验证和内容脚本日志链路。

- 本地补充修复:保留平台验证成功后的手机号接码确认收尾,避免 #193/#194 恢复的手机号复用链路被合并覆盖。

- 影响范围:background routing、phone verification、platform verify、content scripts、sidepanel 与相关测试/文档。
2026-05-04 04:41:49 +08:00

820 lines
27 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();
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();
registry[source] = { tabId, ready: true };
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 {
await chrome.tabs.get(entry.tabId);
return true;
} catch {
registry[source] = null;
await setState({ tabRegistry: registry });
return false;
}
}
async function getTabId(source) {
const registry = await getTabRegistry();
return registry[source]?.tabId || null;
}
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 chrome.tabs.query({});
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 chrome.tabs.query({});
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 chrome.tabs.query({});
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 chrome.tabs.create({ url, active: true });
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 chrome.tabs.create({ url, active: true });
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,
ensureContentScriptReadyOnTab,
flushCommand,
getContentScriptResponseTimeoutMs,
getMessageDebugLabel,
getTabId,
getTabRegistry,
isLocalhostOAuthCallbackTabMatch,
isTabAlive,
pingContentScriptOnTab,
queueCommand,
registerTab,
rememberSourceLastUrl,
resolveResponseTimeoutMs,
reuseOrCreateTab,
sendTabMessageWithTimeout,
sendToContentScript,
sendToContentScriptResilient,
sendToMailContentScriptResilient,
summarizeMessageResultForDebug,
waitForTabComplete,
waitForTabStableComplete,
waitForTabUrlFamily,
waitForTabUrlMatch,
};
}
return {
createTabRuntime,
};
});