From 2bb34f90643f4d757a78957846cf5eb3be8b6d50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=B4=E5=9C=A3=E4=BD=91?= Date: Tue, 12 May 2026 11:06:56 +0800 Subject: [PATCH] fix(tabs): lock automation to selected window --- background.js | 46 +++++-- background/contribution-oauth.js | 5 +- background/ip-proxy-core.js | 83 +++++++++-- background/message-router.js | 47 +++++++ background/panel-bridge.js | 5 +- background/tab-runtime.js | 129 ++++++++++++++++-- sidepanel/ip-proxy-panel.js | 5 +- sidepanel/sidepanel.js | 76 +++++++++-- tests/background-tab-runtime-module.test.js | 72 ++++++++++ tests/message-router-window-lock.test.js | 91 ++++++++++++ tests/paypal-approve-detection.test.js | 37 +++++ ...sidepanel-auto-run-content-refresh.test.js | 3 + tests/sidepanel-window-lock.test.js | 119 ++++++++++++++++ 13 files changed, 682 insertions(+), 36 deletions(-) create mode 100644 tests/message-router-window-lock.test.js create mode 100644 tests/sidepanel-window-lock.test.js diff --git a/background.js b/background.js index c084731..b65b5d1 100644 --- a/background.js +++ b/background.js @@ -874,6 +874,7 @@ const DEFAULT_STATE = { codex2apiSessionId: null, // Codex2API OAuth 会话 ID。 codex2apiOAuthState: null, // Codex2API OAuth state。 plusCheckoutTabId: null, // Plus checkout / PayPal 标签页 ID。 + automationWindowId: null, // 当前任务锁定的浏览器窗口 ID,避免新标签页跑到其它窗口。 plusCheckoutUrl: null, // Plus checkout 运行时短链,不写入持久配置。 plusCheckoutCountry: 'DE', plusCheckoutCurrency: 'EUR', @@ -3409,6 +3410,7 @@ async function resetState() { 'luckmailPreserveTagId', 'luckmailPreserveTagName', 'preferredIcloudHost', + 'automationWindowId', ...CONTRIBUTION_RUNTIME_KEYS, ]), getPersistedSettings(), @@ -3478,6 +3480,10 @@ async function resetState() { freeReusablePhoneActivation, phoneReusableActivationPool, preferredIcloudHost: prev.preferredIcloudHost || '', + automationWindowId: Number.isInteger(Number(prev.automationWindowId)) + && Number(prev.automationWindowId) >= 0 + ? Number(prev.automationWindowId) + : null, }); } @@ -5542,7 +5548,7 @@ async function pollCloudflareTempEmailVerificationCode(step, state, pollPayload async function getOpenIcloudHostPreference() { try { - const tabs = await chrome.tabs.query({ + const tabs = await queryTabsInAutomationWindow({ url: ICLOUD_TAB_URL_PATTERNS, }); @@ -5690,7 +5696,7 @@ function shouldEmitIcloudTransientLog(key, windowMs = 1500) { } async function openIcloudLoginPage(preferredUrl) { - const tabs = await chrome.tabs.query({ + const tabs = await queryTabsInAutomationWindow({ url: ICLOUD_TAB_URL_PATTERNS, }); const preferredHost = new URL(preferredUrl).host; @@ -5714,7 +5720,7 @@ async function openIcloudLoginPage(preferredUrl) { return existingAnyIcloudTab.id; } - const created = await chrome.tabs.create({ url: preferredUrl, active: true }); + const created = await createAutomationTab({ url: preferredUrl, active: true }); return created.id; } @@ -5930,7 +5936,7 @@ async function ensureIcloudMailContextTab(tabs = [], targetHost = '', preferredH await chrome.tabs.update(mailTabs[0].id, { url: fallbackMailUrl, active: false }); await waitForIcloudMailTabReady(mailTabs[0].id, 9000); try { - return await chrome.tabs.query({ + return await queryTabsInAutomationWindow({ url: ICLOUD_TAB_URL_PATTERNS, }); } catch { @@ -5955,13 +5961,13 @@ async function ensureIcloudMailContextTab(tabs = [], targetHost = '', preferredH await chrome.tabs.update(anyIcloudTab.id, { url: fallbackMailUrl, active: false }); await waitForIcloudMailTabReady(anyIcloudTab.id, 9000); } else { - const created = await chrome.tabs.create({ url: fallbackMailUrl, active: false }); + const created = await createAutomationTab({ url: fallbackMailUrl, active: false }); await waitForIcloudMailTabReady(created?.id, 9000); } } catch {} try { - return await chrome.tabs.query({ + return await queryTabsInAutomationWindow({ url: ICLOUD_TAB_URL_PATTERNS, }); } catch { @@ -6004,7 +6010,7 @@ async function icloudRequestViaPageContext(method, url, options = {}) { const targetHost = configuredHost || normalizeIcloudHost(new URL(url).hostname); const preferredHost = configuredHost || normalizeIcloudHost(state?.preferredIcloudHost); - let tabs = await chrome.tabs.query({ + let tabs = await queryTabsInAutomationWindow({ url: ICLOUD_TAB_URL_PATTERNS, }); tabs = await ensureIcloudMailContextTab(tabs, targetHost, preferredHost); @@ -6387,7 +6393,7 @@ async function resolveIcloudPremiumMailServiceViaPageContext(setupUrls, state, o const errors = []; let tabs = []; try { - tabs = await chrome.tabs.query({ + tabs = await queryTabsInAutomationWindow({ url: ICLOUD_TAB_URL_PATTERNS, }); } catch (err) { @@ -7109,6 +7115,22 @@ async function getTabId(source) { return tabRuntime.getTabId(source); } +async function getAutomationWindowId(options = {}) { + return tabRuntime.getAutomationWindowId(options); +} + +async function createAutomationTab(createProperties = {}, options = {}) { + return tabRuntime.createAutomationTab(createProperties, options); +} + +async function queryTabsInAutomationWindow(queryInfo = {}, options = {}) { + return tabRuntime.queryTabsInAutomationWindow(queryInfo, options); +} + +async function isTabInAutomationWindow(tabOrId, options = {}) { + return tabRuntime.isTabInAutomationWindow(tabOrId, options); +} + function parseUrlSafely(rawUrl) { if (typeof navigationUtils !== 'undefined' && navigationUtils?.parseUrlSafely) { return navigationUtils.parseUrlSafely(rawUrl); @@ -9981,7 +10003,9 @@ const contributionOAuthManager = self.MultiPageBackgroundContributionOAuth?.crea broadcastDataUpdate, chrome, closeLocalhostCallbackTabs, + createAutomationTab, getState, + queryTabsInAutomationWindow, setState, }); contributionOAuthManager?.ensureCallbackListeners?.(); @@ -11179,6 +11203,7 @@ const panelBridge = self.MultiPageBackgroundPanelBridge?.createPanelBridge({ chrome, addLog, closeConflictingTabsForSource, + createAutomationTab, ensureContentScriptReadyOnTab, getPanelMode, normalizeCodex2ApiUrl, @@ -11465,6 +11490,7 @@ const plusCheckoutCreateExecutor = self.MultiPageBackgroundPlusCheckoutCreate?.c addLog, chrome, completeStepFromBackground, + createAutomationTab, ensureContentScriptReadyOnTabUntilStopped, fetch: typeof fetch === 'function' ? fetch.bind(globalThis) : null, markCurrentRegistrationAccountUsed, @@ -11488,6 +11514,7 @@ const plusCheckoutBillingExecutor = self.MultiPageBackgroundPlusCheckoutBilling? getTabId, isTabAlive, markCurrentRegistrationAccountUsed, + queryTabsInAutomationWindow, sendTabMessageUntilStopped, setState, sleepWithStop, @@ -11503,6 +11530,7 @@ const goPayManualConfirmExecutor = self.MultiPageBackgroundGoPayManualConfirm?.c getTabId, isTabAlive, registerTab, + createAutomationTab, setState, }); const payPalApproveExecutor = self.MultiPageBackgroundPayPalApprove?.createPayPalApproveExecutor({ @@ -11510,6 +11538,7 @@ const payPalApproveExecutor = self.MultiPageBackgroundPayPalApprove?.createPayPa chrome, completeStepFromBackground, ensureContentScriptReadyOnTabUntilStopped, + queryTabsInAutomationWindow, getTabId, isTabAlive, sendTabMessageUntilStopped, @@ -11525,6 +11554,7 @@ const goPayApproveExecutor = self.MultiPageBackgroundGoPayApprove?.createGoPayAp ensureContentScriptReadyOnTabUntilStopped, getTabId, isTabAlive, + queryTabsInAutomationWindow, registerTab, sendTabMessageUntilStopped, setState, diff --git a/background/contribution-oauth.js b/background/contribution-oauth.js index 3928dda..e0ec423 100644 --- a/background/contribution-oauth.js +++ b/background/contribution-oauth.js @@ -35,6 +35,7 @@ broadcastDataUpdate, chrome, closeLocalhostCallbackTabs, + createAutomationTab = null, getState, setState, } = deps; @@ -437,7 +438,9 @@ } if (!tab) { - tab = await chrome.tabs.create({ url: normalizedUrl, active: true }); + tab = typeof createAutomationTab === 'function' + ? await createAutomationTab({ url: normalizedUrl, active: true }) + : await chrome.tabs.create({ url: normalizedUrl, active: true }); } await applyRuntimeUpdates({ diff --git a/background/ip-proxy-core.js b/background/ip-proxy-core.js index e78e159..3802e7c 100644 --- a/background/ip-proxy-core.js +++ b/background/ip-proxy-core.js @@ -30,6 +30,60 @@ let ipProxyLastRuntimeError = { fatal: false, at: 0, }; + +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 createAutomationScopedTab(createProperties = {}, options = {}) { + const windowId = normalizeAutomationWindowId( + options?.automationWindowId + ?? options?.windowId + ?? options?.state?.automationWindowId + ?? null + ); + if (windowId === null) { + return chrome.tabs.create(createProperties || {}); + } + + try { + return await chrome.tabs.create({ + ...(createProperties || {}), + windowId, + }); + } catch { + return chrome.tabs.create(createProperties || {}); + } +} + +async function queryAutomationScopedTabs(queryInfo = {}, options = {}) { + const windowId = normalizeAutomationWindowId( + options?.automationWindowId + ?? options?.windowId + ?? options?.state?.automationWindowId + ?? null + ); + if (windowId === null) { + return chrome.tabs.query(queryInfo || {}); + } + + const scopedQuery = { + ...(queryInfo || {}), + windowId, + }; + delete scopedQuery.currentWindow; + try { + return await chrome.tabs.query(scopedQuery); + } catch { + return chrome.tabs.query(queryInfo || {}); + } +} + + const IP_PROXY_EXIT_PROBE_ENDPOINTS = [ 'http://ip-api.com/json?lang=en', 'http://ipinfo.io/json', @@ -1974,8 +2028,8 @@ function extractProbeHostFromTabUrl(rawUrl = '') { } } -async function pickExistingPageContextProbeTabId() { - const tabs = await chrome.tabs.query({}); +async function pickExistingPageContextProbeTabId(options = {}) { + const tabs = await queryAutomationScopedTabs({}, options); const candidates = tabs .filter((tab) => Number.isInteger(tab?.id)) .filter((tab) => isPageContextProbeHost(extractProbeHostFromTabUrl(tab?.url || ''))); @@ -2345,10 +2399,10 @@ async function detectIpProxyTargetReachabilityByPageContext(options = {}) { const targetUrl = appendProbeCacheBuster(endpoint, '_multipage_proxy_target'); try { if (!Number.isInteger(tabId)) { - const tab = await chrome.tabs.create({ + const tab = await createAutomationScopedTab({ url: targetUrl, active: false, - }); + }, options); tabId = Number(tab?.id) || null; createdTabId = tabId; navigationErrorTracker.setTabId(tabId); @@ -2424,15 +2478,15 @@ async function detectProxyExitInfoByPageContext(options = {}) { let createdProbeTabId = null; const probeUrl = `${IP_PROXY_PAGE_CONTEXT_PROBE_URL}?_multipage_proxy_probe=${Date.now()}`; try { - const tab = await chrome.tabs.create({ + const tab = await createAutomationScopedTab({ url: probeUrl, active: false, - }); + }, options); probeTabId = Number(tab?.id) || null; createdProbeTabId = probeTabId; } catch (error) { errors.push(`probe:page_context:create_probe_tab_failed:${error?.message || error}`); - probeTabId = await pickExistingPageContextProbeTabId(); + probeTabId = await pickExistingPageContextProbeTabId(options); } if (!Number.isInteger(probeTabId)) { @@ -2544,10 +2598,10 @@ async function detectProxyExitInfoByPageContext(options = {}) { errors.push(`probe:page_context:script_failed:${scriptError?.message || scriptError}`); if (!createdProbeTabId && isProbeErrorPageExecutionError(scriptError)) { const retryUrl = `${IP_PROXY_PAGE_CONTEXT_PROBE_URL}?_multipage_proxy_probe=${Date.now()}&retry=1`; - const retryTab = await chrome.tabs.create({ + const retryTab = await createAutomationScopedTab({ url: retryUrl, active: false, - }); + }, options); const retryTabId = Number(retryTab?.id) || 0; if (retryTabId > 0) { createdProbeTabId = retryTabId; @@ -2635,6 +2689,9 @@ async function detectProxyExitInfo(options = {}) { timeoutMs, errors, probeEndpoints, + automationWindowId: options?.automationWindowId, + windowId: options?.windowId, + state: options?.state, }); if (pageResult?.ip) { return pageResult; @@ -2669,6 +2726,9 @@ async function detectProxyExitInfo(options = {}) { timeoutMs: Math.max(5000, Math.min(9000, timeoutMs)), errors, probeEndpoints, + automationWindowId: options?.automationWindowId, + windowId: options?.windowId, + state: options?.state, }); errors.push(`probe:bg:abort_storm_page_fallback_result:${String(pageResult?.source || 'unknown')}`); if (pageResult?.ip) { @@ -3308,6 +3368,7 @@ async function applyIpProxySettingsFromState(state = {}, options = {}) { // 后台探测仅作为补充兜底,避免单一路径误判。 preferPageContext: true, allowBackgroundFallback: true, + state, }).catch(() => ({ ip: '', region: '' })); if (!exit?.ip && Boolean(status?.hasAuth)) { appendIpProxyAuthDiagnosticsToErrors(diagnostics); @@ -3339,6 +3400,7 @@ async function applyIpProxySettingsFromState(state = {}, options = {}) { const reachability = await detectIpProxyTargetReachabilityByPageContext({ timeoutMs: IP_PROXY_TARGET_REACHABILITY_TIMEOUT_MS, errors: targetDiagnostics, + state, }).catch((error) => ({ reachable: false, endpoint: IP_PROXY_TARGET_REACHABILITY_ENDPOINTS[0], @@ -3675,6 +3737,7 @@ async function probeIpProxyExit(options = {}) { username: String(state?.ipProxyUsername || '').trim(), preferPageContext: true, allowBackgroundFallback: true, + state, }).catch(() => ({ ip: '', region: '', endpoint: '', source: '' })); const status = { enabled: false, @@ -3774,6 +3837,7 @@ async function probeIpProxyExit(options = {}) { // 手动探测与自动应用保持一致:先页面上下文,再后台补充。 preferPageContext: true, allowBackgroundFallback: true, + state, }).catch(() => ({ ip: '', region: '' })); if (!exit?.ip && Boolean(statusSeed?.hasAuth)) { appendIpProxyAuthDiagnosticsToErrors(diagnostics); @@ -3800,6 +3864,7 @@ async function probeIpProxyExit(options = {}) { const reachability = await detectIpProxyTargetReachabilityByPageContext({ timeoutMs: IP_PROXY_TARGET_REACHABILITY_TIMEOUT_MS, errors: targetDiagnostics, + state, }).catch((error) => ({ reachable: false, endpoint: IP_PROXY_TARGET_REACHABILITY_ENDPOINTS[0], diff --git a/background/message-router.js b/background/message-router.js index 50adced..9a5449f 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -210,6 +210,34 @@ : ''; } + 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; + } + + function resolveAutomationWindowIdFromMessage(message = {}, sender = {}) { + return normalizeAutomationWindowId( + message?.payload?.automationWindowId + ?? message?.payload?.windowId + ?? message?.automationWindowId + ?? message?.windowId + ?? sender?.tab?.windowId + ?? null + ); + } + + async function lockAutomationWindowFromMessage(message = {}, sender = {}) { + const windowId = resolveAutomationWindowIdFromMessage(message, sender); + if (windowId === null) { + return null; + } + await setState({ automationWindowId: windowId }); + return windowId; + } + async function syncStepAccountIdentityFromPayload(payload = {}) { const identifierType = String(payload?.accountIdentifierType || '').trim().toLowerCase(); const signupPhoneNumber = resolveSignupPhonePayload(payload); @@ -821,6 +849,7 @@ case 'EXECUTE_STEP': { clearStopRequest(); if (message.source === 'sidepanel') { + await lockAutomationWindowFromMessage(message, sender); await ensureManualInteractionAllowed('手动执行步骤'); } const step = message.payload.step; @@ -848,6 +877,9 @@ case 'AUTO_RUN': { clearStopRequest(); + if (message.source === 'sidepanel') { + await lockAutomationWindowFromMessage(message, sender); + } if (Boolean(message.payload?.contributionMode) && typeof setContributionMode === 'function') { await setContributionMode(true); if (typeof setState === 'function') { @@ -873,6 +905,9 @@ case 'SCHEDULE_AUTO_RUN': { clearStopRequest(); + if (message.source === 'sidepanel') { + await lockAutomationWindowFromMessage(message, sender); + } if (Boolean(message.payload?.contributionMode) && typeof setContributionMode === 'function') { await setContributionMode(true); if (typeof setState === 'function') { @@ -894,6 +929,9 @@ case 'START_SCHEDULED_AUTO_RUN_NOW': { clearStopRequest(); + if (message.source === 'sidepanel') { + await lockAutomationWindowFromMessage(message, sender); + } const started = await launchAutoRunTimerPlan('manual', { expectedKinds: [AUTO_RUN_TIMER_KIND_SCHEDULED_START], }); @@ -913,6 +951,9 @@ case 'SKIP_AUTO_RUN_COUNTDOWN': { clearStopRequest(); + if (message.source === 'sidepanel') { + await lockAutomationWindowFromMessage(message, sender); + } const skipped = await skipAutoRunCountdown(); if (!skipped) { throw new Error('当前没有可立即开始的倒计时。'); @@ -922,6 +963,9 @@ case 'RESUME_AUTO_RUN': { clearStopRequest(); + if (message.source === 'sidepanel') { + await lockAutomationWindowFromMessage(message, sender); + } if (message.payload.email) { await setEmailState(message.payload.email); } @@ -1117,6 +1161,9 @@ } case 'PROBE_IP_PROXY_EXIT': { + if (message.source === 'sidepanel') { + await lockAutomationWindowFromMessage(message, sender); + } if (typeof probeIpProxyExit !== 'function') { throw new Error('IP 代理出口检测能力尚未接入。'); } diff --git a/background/panel-bridge.js b/background/panel-bridge.js index 8e204b5..03b5bc9 100644 --- a/background/panel-bridge.js +++ b/background/panel-bridge.js @@ -6,6 +6,7 @@ chrome, addLog, closeConflictingTabsForSource, + createAutomationTab = null, ensureContentScriptReadyOnTab, getPanelMode, normalizeCodex2ApiUrl, @@ -266,7 +267,9 @@ const injectFiles = ['content/utils.js', 'content/sub2api-panel.js']; await closeConflictingTabsForSource('sub2api-panel', sub2apiUrl); - const tab = await chrome.tabs.create({ url: sub2apiUrl, active: true }); + const tab = typeof createAutomationTab === 'function' + ? await createAutomationTab({ url: sub2apiUrl, active: true }) + : await chrome.tabs.create({ url: sub2apiUrl, active: true }); const tabId = tab.id; await rememberSourceLastUrl('sub2api-panel', sub2apiUrl); diff --git a/background/tab-runtime.js b/background/tab-runtime.js index a5a6395..7429101 100644 --- a/background/tab-runtime.js +++ b/background/tab-runtime.js @@ -19,6 +19,88 @@ 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); @@ -86,7 +168,20 @@ async function registerTab(source, tabId) { const registry = await getTabRegistry(); - registry[source] = { tabId, ready: true }; + 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}`); } @@ -96,7 +191,12 @@ const entry = registry[source]; if (!entry) return false; try { - await chrome.tabs.get(entry.tabId); + 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; @@ -107,7 +207,16 @@ async function getTabId(source) { const registry = await getTabRegistry(); - return registry[source]?.tabId || null; + 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) { @@ -127,7 +236,7 @@ if (!referenceUrls.length) return; - const tabs = await chrome.tabs.query({}); + 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))) @@ -164,7 +273,7 @@ const { excludeTabIds = [] } = options; const excluded = new Set(excludeTabIds.filter((id) => Number.isInteger(id))); - const tabs = await chrome.tabs.query({}); + const tabs = await queryTabsInAutomationWindow({}); const matchedIds = tabs .filter((tab) => Number.isInteger(tab.id) && !excluded.has(tab.id)) .filter((tab) => isLocalhostOAuthCallbackTabMatch(callbackUrl, tab.url)) @@ -198,7 +307,7 @@ 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 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)) @@ -534,7 +643,7 @@ async function reuseOrCreateTab(source, url, options = {}) { if (options.forceNew) { await closeConflictingTabsForSource(source, url); - const tab = await chrome.tabs.create({ url, active: true }); + const tab = await createAutomationTab({ url, active: true }, options); if (options.inject) { await waitForTabUpdateComplete(tab.id); @@ -626,7 +735,7 @@ } await closeConflictingTabsForSource(source, url); - const tab = await chrome.tabs.create({ url, active: true }); + const tab = await createAutomationTab({ url, active: true }, options); if (options.inject) { await waitForTabUpdateComplete(tab.id); @@ -787,16 +896,20 @@ closeConflictingTabsForSource, closeLocalhostCallbackTabs, closeTabsByUrlPrefix, + createAutomationTab, ensureContentScriptReadyOnTab, flushCommand, + getAutomationWindowId, getContentScriptResponseTimeoutMs, getMessageDebugLabel, getTabId, getTabRegistry, isLocalhostOAuthCallbackTabMatch, isTabAlive, + isTabInAutomationWindow, pingContentScriptOnTab, queueCommand, + queryTabsInAutomationWindow, registerTab, rememberSourceLastUrl, resolveResponseTimeoutMs, diff --git a/sidepanel/ip-proxy-panel.js b/sidepanel/ip-proxy-panel.js index c18a4c8..97d7641 100644 --- a/sidepanel/ip-proxy-panel.js +++ b/sidepanel/ip-proxy-panel.js @@ -1618,7 +1618,10 @@ async function changeIpProxyExitBySession(options = {}) { async function probeIpProxyExit(options = {}) { const { silent = false } = options; - const response = await chrome.runtime.sendMessage({ + const sendMessage = typeof sendSidepanelMessage === 'function' + ? sendSidepanelMessage + : (message) => chrome.runtime.sendMessage(message); + const response = await sendMessage({ type: 'PROBE_IP_PROXY_EXIT', source: 'sidepanel', payload: {}, diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 92c9f87..1d559c1 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -1151,6 +1151,66 @@ let currentReleaseSnapshot = null; let currentContributionContentSnapshot = null; let contributionContentSnapshotRequestInFlight = null; +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 getCurrentSidepanelWindowId() { + if (chrome?.windows?.getCurrent) { + try { + const currentWindow = await chrome.windows.getCurrent(); + const windowId = normalizeAutomationWindowId(currentWindow?.id); + if (windowId !== null) { + return windowId; + } + } catch (error) { + console.warn('Failed to get current sidepanel window:', error?.message || error); + } + } + + return normalizeAutomationWindowId(latestState?.automationWindowId); +} + +function shouldAttachAutomationWindow(message = {}) { + const source = String(message?.source || '').trim(); + if (source && source !== 'sidepanel') { + return false; + } + return [ + 'EXECUTE_STEP', + 'AUTO_RUN', + 'SCHEDULE_AUTO_RUN', + 'RESUME_AUTO_RUN', + 'START_SCHEDULED_AUTO_RUN_NOW', + 'SKIP_AUTO_RUN_COUNTDOWN', + 'PROBE_IP_PROXY_EXIT', + ].includes(String(message?.type || '').trim()); +} + +async function sendSidepanelMessage(message = {}) { + const payload = { + ...(message || {}), + source: message?.source || 'sidepanel', + }; + if (shouldAttachAutomationWindow(payload)) { + const windowId = await getCurrentSidepanelWindowId(); + if (windowId !== null) { + payload.payload = { + ...(payload.payload || {}), + automationWindowId: windowId, + }; + syncLatestState({ automationWindowId: windowId }); + } + } + return chrome.runtime.sendMessage(payload); +} + +window.sendSidepanelMessage = sendSidepanelMessage; + const DEFAULT_SUB2API_GROUP_OPTIONS = ['codex', 'openai-plus']; const editableListPickerModule = window.SidepanelEditableListPicker || {}; const normalizeEditableListValues = editableListPickerModule.normalizeEditableListValues @@ -11186,12 +11246,12 @@ stepsList?.addEventListener('click', async (event) => { syncLatestState({ customPassword: inputPassword.value }); } if (shouldExecuteStep3WithSignupPhoneIdentity(latestState)) { - const response = await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } }); + const response = await sendSidepanelMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } }); if (response?.error) { throw new Error(response.error); } } else if (selectMailProvider.value === 'hotmail-api' || isLuckmailProvider()) { - const response = await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } }); + const response = await sendSidepanelMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } }); if (response?.error) { throw new Error(response.error); } @@ -11201,7 +11261,7 @@ stepsList?.addEventListener('click', async (event) => { showToast(selectMailProvider.value === GMAIL_PROVIDER ? '请先填写 Gmail 原邮箱。' : '请先填写 2925 邮箱前缀。', 'warn'); return; } - const response = await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step, emailPrefix } }); + const response = await sendSidepanelMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step, emailPrefix } }); if (response?.error) { throw new Error(response.error); } @@ -11222,13 +11282,13 @@ stepsList?.addEventListener('click', async (event) => { if (!validateCurrentRegistrationEmail(email, { showToastOnFailure: true })) { return; } - const response = await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step, email } }); + const response = await sendSidepanelMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step, email } }); if (response?.error) { throw new Error(response.error); } } } else { - const response = await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } }); + const response = await sendSidepanelMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } }); if (response?.error) { throw new Error(response.error); } @@ -11490,7 +11550,7 @@ async function startAutoRunFromCurrentSettings() { btnAutoRun.innerHTML = delayEnabled ? ' 计划中...' : ' 运行中...'; - const response = await chrome.runtime.sendMessage({ + const response = await sendSidepanelMessage({ type: delayEnabled ? 'SCHEDULE_AUTO_RUN' : 'AUTO_RUN', source: 'sidepanel', payload: { @@ -11532,14 +11592,14 @@ btnAutoContinue.addEventListener('click', async () => { return; } autoContinueBar.style.display = 'none'; - await chrome.runtime.sendMessage({ type: 'RESUME_AUTO_RUN', source: 'sidepanel', payload: { email } }); + await sendSidepanelMessage({ type: 'RESUME_AUTO_RUN', source: 'sidepanel', payload: { email } }); }); btnAutoRunNow?.addEventListener('click', async () => { try { btnAutoRunNow.disabled = true; const waitingInterval = currentAutoRun.phase === 'waiting_interval'; - await chrome.runtime.sendMessage({ + await sendSidepanelMessage({ type: waitingInterval ? 'SKIP_AUTO_RUN_COUNTDOWN' : 'START_SCHEDULED_AUTO_RUN_NOW', source: 'sidepanel', payload: {}, diff --git a/tests/background-tab-runtime-module.test.js b/tests/background-tab-runtime-module.test.js index 35e6ef8..bea20c9 100644 --- a/tests/background-tab-runtime-module.test.js +++ b/tests/background-tab-runtime-module.test.js @@ -188,3 +188,75 @@ test('tab runtime waitForTabStableComplete waits through a late navigation after assert.equal(result?.status, 'complete'); assert.ok(getCalls >= 4); }); + +test('tab runtime opens new automation tabs in the locked window', async () => { + const source = fs.readFileSync('background/tab-runtime.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageBackgroundTabRuntime;`)(globalScope); + const created = []; + const runtime = api.createTabRuntime({ + LOG_PREFIX: '[test]', + addLog: async () => {}, + chrome: { + tabs: { + create: async (payload) => { + created.push(payload); + return { id: 17, windowId: payload.windowId, url: payload.url }; + }, + get: async () => ({ id: 17, windowId: 100, url: 'https://example.com' }), + query: async () => [], + onUpdated: { + addListener: () => {}, + removeListener: () => {}, + }, + }, + }, + getSourceLabel: (sourceName) => sourceName || 'unknown', + getState: async () => ({ + automationWindowId: 100, + tabRegistry: {}, + sourceLastUrls: {}, + }), + matchesSourceUrlFamily: () => false, + setState: async () => {}, + throwIfStopped: () => {}, + }); + + await runtime.reuseOrCreateTab('signup-page', 'https://example.com'); + + assert.equal(created.length, 1); + assert.equal(created[0].windowId, 100); +}); + +test('tab runtime scopes tab queries to the locked automation window', async () => { + const source = fs.readFileSync('background/tab-runtime.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageBackgroundTabRuntime;`)(globalScope); + const queries = []; + const runtime = api.createTabRuntime({ + LOG_PREFIX: '[test]', + addLog: async () => {}, + chrome: { + tabs: { + get: async () => ({ id: 1, windowId: 22, url: 'https://example.com' }), + query: async (queryInfo) => { + queries.push(queryInfo); + return []; + }, + }, + }, + getSourceLabel: (sourceName) => sourceName || 'unknown', + getState: async () => ({ + automationWindowId: 22, + tabRegistry: {}, + sourceLastUrls: {}, + }), + matchesSourceUrlFamily: () => false, + setState: async () => {}, + throwIfStopped: () => {}, + }); + + await runtime.queryTabsInAutomationWindow({ active: true, currentWindow: true }); + + assert.deepEqual(queries[0], { active: true, windowId: 22 }); +}); diff --git a/tests/message-router-window-lock.test.js b/tests/message-router-window-lock.test.js new file mode 100644 index 0000000..89f6afd --- /dev/null +++ b/tests/message-router-window-lock.test.js @@ -0,0 +1,91 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('background/message-router.js', 'utf8'); + +function extractFunction(name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map((marker) => source.indexOf(marker)) + .find((index) => index >= 0); + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let index = start; index < source.length; index += 1) { + const ch = source[index]; + if (ch === '(') parenDepth += 1; + if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) signatureEnded = true; + } + if (ch === '{' && signatureEnded) { + braceStart = index; + break; + } + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end += 1) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +test('message router locks automation window from sidepanel payload', async () => { + const bundle = [ + extractFunction('normalizeAutomationWindowId'), + extractFunction('resolveAutomationWindowIdFromMessage'), + extractFunction('lockAutomationWindowFromMessage'), + ].join('\n'); + + const api = new Function(` +const updates = []; +async function setState(update) { + updates.push(update); +} +${bundle} +return { + lockAutomationWindowFromMessage, + updates, +}; +`)(); + + const windowId = await api.lockAutomationWindowFromMessage({ + payload: { automationWindowId: 88 }, + }); + + assert.equal(windowId, 88); + assert.deepEqual(api.updates, [{ automationWindowId: 88 }]); +}); + +test('message router can fall back to sender tab window id', async () => { + const bundle = [ + extractFunction('normalizeAutomationWindowId'), + extractFunction('resolveAutomationWindowIdFromMessage'), + ].join('\n'); + + const api = new Function(` +${bundle} +return { resolveAutomationWindowIdFromMessage }; +`)(); + + assert.equal( + api.resolveAutomationWindowIdFromMessage({}, { tab: { windowId: 19 } }), + 19 + ); +}); diff --git a/tests/paypal-approve-detection.test.js b/tests/paypal-approve-detection.test.js index 9c2072f..0fa1458 100644 --- a/tests/paypal-approve-detection.test.js +++ b/tests/paypal-approve-detection.test.js @@ -18,6 +18,7 @@ function createExecutor({ getTabId = async (source) => (source === 'paypal-flow' ? 1 : null), isTabAlive = async () => true, queryTabs = [], + queryTabsInAutomationWindow = null, }) { const api = loadModule(); const events = { @@ -61,6 +62,7 @@ function createExecutor({ ensureContentScriptReadyOnTabUntilStopped: async () => {}, getTabId, isTabAlive, + ...(typeof queryTabsInAutomationWindow === 'function' ? { queryTabsInAutomationWindow } : {}), sendTabMessageUntilStopped: async (_tabId, _source, message) => { events.messages.push(message.type); if (message.type === 'PAYPAL_GET_STATE') { @@ -406,6 +408,41 @@ test('PayPal approve discovers an already open unregistered PayPal tab', async ( assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), true); }); +test('PayPal approve discovers PayPal tabs through the locked automation window query', async () => { + const queries = []; + const { executor, events } = createExecutor({ + pageStates: [ + { needsLogin: false, approveReady: true }, + ], + submitResults: [], + getTabId: async () => null, + isTabAlive: async () => false, + queryTabsInAutomationWindow: async (queryInfo) => { + queries.push(queryInfo); + return [ + { + id: 9, + active: true, + currentWindow: true, + url: 'https://www.paypal.com/pay/?token=BA-window', + }, + ]; + }, + tabUrls: [ + 'https://www.paypal.com/pay/?token=BA-window', + ], + }); + + await executor.executePayPalApprove({ + paypalEmail: 'user@example.com', + paypalPassword: 'secret', + }); + + assert.deepEqual(queries, [{}]); + assert.deepEqual(events.updatedTabs, [{ tabId: 9, updateInfo: { active: true } }]); + assert.deepEqual(events.completed.map((item) => item.step), [8]); +}); + test('PayPal approve auto-detects split email then password pages', async () => { const { executor, events } = createExecutor({ pageStates: [ diff --git a/tests/sidepanel-auto-run-content-refresh.test.js b/tests/sidepanel-auto-run-content-refresh.test.js index f2b8f3d..950bd07 100644 --- a/tests/sidepanel-auto-run-content-refresh.test.js +++ b/tests/sidepanel-auto-run-content-refresh.test.js @@ -87,6 +87,9 @@ const console = { events.push({ type: 'warn', args }); }, }; +async function sendSidepanelMessage(message) { + return chrome.runtime.sendMessage(message); +} async function persistCurrentSettingsForAction() { events.push({ type: 'sync-settings' }); ${persistImpl ? `return (${persistImpl})(events, { diff --git a/tests/sidepanel-window-lock.test.js b/tests/sidepanel-window-lock.test.js new file mode 100644 index 0000000..e307ae5 --- /dev/null +++ b/tests/sidepanel-window-lock.test.js @@ -0,0 +1,119 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('sidepanel/sidepanel.js', 'utf8'); + +function extractFunction(name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map((marker) => source.indexOf(marker)) + .find((index) => index >= 0); + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let index = start; index < source.length; index += 1) { + const ch = source[index]; + if (ch === '(') parenDepth += 1; + if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) signatureEnded = true; + } + if (ch === '{' && signatureEnded) { + braceStart = index; + break; + } + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end += 1) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +function createApi() { + const bundle = [ + extractFunction('normalizeAutomationWindowId'), + extractFunction('getCurrentSidepanelWindowId'), + extractFunction('shouldAttachAutomationWindow'), + extractFunction('sendSidepanelMessage'), + ].join('\n'); + + return new Function(` +let latestState = {}; +const events = []; +const chrome = { + windows: { + async getCurrent() { + events.push({ type: 'get-current-window' }); + return { id: 321 }; + }, + }, + runtime: { + async sendMessage(message) { + events.push({ type: 'send', message }); + return { ok: true }; + }, + }, +}; +const console = { warn() {} }; +function syncLatestState(patch) { + latestState = { ...latestState, ...patch }; + events.push({ type: 'sync', patch }); +} +${bundle} +return { + sendSidepanelMessage, + shouldAttachAutomationWindow, + getEvents() { + return events; + }, + getLatestState() { + return latestState; + }, +}; +`)(); +} + +test('sidepanel attaches the current window id when starting automation actions', async () => { + const api = createApi(); + + await api.sendSidepanelMessage({ + type: 'AUTO_RUN', + source: 'sidepanel', + payload: { totalRuns: 1 }, + }); + + const sent = api.getEvents().find((entry) => entry.type === 'send').message; + assert.equal(sent.payload.automationWindowId, 321); + assert.equal(api.getLatestState().automationWindowId, 321); +}); + +test('sidepanel leaves non-automation messages unchanged', async () => { + const api = createApi(); + + await api.sendSidepanelMessage({ + type: 'SAVE_SETTING', + source: 'sidepanel', + payload: { emailPrefix: 'demo' }, + }); + + const events = api.getEvents(); + assert.equal(events.some((entry) => entry.type === 'get-current-window'), false); + assert.deepEqual(events.find((entry) => entry.type === 'send').message.payload, { emailPrefix: 'demo' }); +});