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 1/4] 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' }); +}); From 80c3e6217f10a618bd40e614994efa50136c0821 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:07:11 +0800 Subject: [PATCH 2/4] fix(tabs): avoid raising automation window --- background/signup-flow-helpers.js | 19 ++--------- background/steps/submit-signup-email.js | 32 ++++--------------- .../background-signup-step2-branching.test.js | 17 +++++++++- 3 files changed, 25 insertions(+), 43 deletions(-) diff --git a/background/signup-flow-helpers.js b/background/signup-flow-helpers.js index 0009105..35f372e 100644 --- a/background/signup-flow-helpers.js +++ b/background/signup-flow-helpers.js @@ -36,22 +36,9 @@ return null; } - if (typeof chrome?.tabs?.get === 'function' && typeof chrome?.windows?.update === 'function') { - try { - const tab = await chrome.tabs.get(tabId); - const windowId = Number(tab?.windowId); - if (Number.isInteger(windowId) && windowId >= 0) { - await chrome.windows.update(windowId, { state: 'normal', focused: true }).catch(() => {}); - await chrome.windows.update(windowId, { - focused: true, - width: 1200, - height: 900, - }).catch(() => {}); - } - } catch { - // Best-effort only. Step 2 still has content-side entry retries. - } - } + // Do not request window focus here. The automation tab is already + // locked to the selected Chrome window; raising that window would + // interrupt the user's active workspace. if (typeof addLog === 'function') { await addLog( diff --git a/background/steps/submit-signup-email.js b/background/steps/submit-signup-email.js index 49e75f0..c484f09 100644 --- a/background/steps/submit-signup-email.js +++ b/background/steps/submit-signup-email.js @@ -185,31 +185,11 @@ }); } - async function normalizeSignupTabWindowForStep2(tabId) { - if ( - !Number.isInteger(tabId) - || typeof chrome?.tabs?.get !== 'function' - || typeof chrome?.windows?.update !== 'function' - ) { - return; - } - - try { - const tab = await chrome.tabs.get(tabId); - const windowId = Number(tab?.windowId); - if (!Number.isInteger(windowId) || windowId < 0) { - return; - } - - await chrome.windows.update(windowId, { state: 'normal', focused: true }).catch(() => {}); - await chrome.windows.update(windowId, { - focused: true, - width: 1200, - height: 900, - }).catch(() => {}); - } catch { - // Best-effort only: content-side retries still handle layout recovery. - } + async function keepSignupTabWindowInBackgroundForStep2(tabId) { + // Intentionally no-op: the task tab is locked to the selected Chrome + // window by the tab-runtime layer. Step 2 must not focus/raise that + // window while the user is working in another app or browser window. + void tabId; } async function ensureSignupPhoneEntryReady(tabId) { @@ -257,7 +237,7 @@ signupTabId = (await ensureSignupEntryPageReady(2)).tabId; } else { await chrome.tabs.update(signupTabId, { active: true }); - await normalizeSignupTabWindowForStep2(signupTabId); + await keepSignupTabWindowInBackgroundForStep2(signupTabId); await waitForStep2SignupTabToSettle( signupTabId, '步骤 2:已切换到注册页标签,正在等待页面加载完成并额外稳定 3 秒...' diff --git a/tests/background-signup-step2-branching.test.js b/tests/background-signup-step2-branching.test.js index 7a3b663..864f685 100644 --- a/tests/background-signup-step2-branching.test.js +++ b/tests/background-signup-step2-branching.test.js @@ -442,7 +442,12 @@ test('step 2 waits for the existing signup tab to settle before probing the entr update: async () => { events.push('tab-update'); }, - get: async () => ({ url: 'https://chatgpt.com/' }), + get: async () => ({ id: 17, windowId: 91, url: 'https://chatgpt.com/' }), + }, + windows: { + update: async () => { + throw new Error('step 2 must not focus or raise the automation window'); + }, }, }, completeStepFromBackground: async (step, payload) => { @@ -565,6 +570,16 @@ test('signup flow helper waits for the signup entry tab to settle for step 2 bef logs.push({ message, level, meta }); }, buildGeneratedAliasEmail: () => '', + chrome: { + tabs: { + get: async () => ({ id: 23, windowId: 92, url: 'https://chatgpt.com/' }), + }, + windows: { + update: async () => { + throw new Error('signup entry helper must not focus or raise the automation window'); + }, + }, + }, ensureContentScriptReadyOnTab: async () => { events.push('content-ready'); }, From 7a5b7a26cf260adcad184f5e65393ba2a66142c1 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:10:03 +0800 Subject: [PATCH 3/4] fix(tabs): scope payment task tab lookup --- background/steps/create-plus-checkout.js | 5 ++- background/steps/fill-plus-checkout.js | 8 +++-- background/steps/gopay-approve.js | 6 +++- background/steps/gopay-manual-confirm.js | 5 ++- background/steps/paypal-approve.js | 6 +++- ...us-checkout-billing-tab-resolution.test.js | 34 +++++++++++++++++++ 6 files changed, 58 insertions(+), 6 deletions(-) diff --git a/background/steps/create-plus-checkout.js b/background/steps/create-plus-checkout.js index 5ac76a5..e7e334f 100644 --- a/background/steps/create-plus-checkout.js +++ b/background/steps/create-plus-checkout.js @@ -16,6 +16,7 @@ addLog: rawAddLog = async () => {}, chrome, completeStepFromBackground, + createAutomationTab = null, ensureContentScriptReadyOnTabUntilStopped, fetch: fetchImpl = null, registerTab, @@ -63,7 +64,9 @@ } async function openFreshChatGptTabForCheckoutCreate() { - const tab = await chrome.tabs.create({ url: PLUS_CHECKOUT_ENTRY_URL, active: true }); + const tab = typeof createAutomationTab === 'function' + ? await createAutomationTab({ url: PLUS_CHECKOUT_ENTRY_URL, active: true }) + : await chrome.tabs.create({ url: PLUS_CHECKOUT_ENTRY_URL, active: true }); const tabId = Number(tab?.id); if (!Number.isInteger(tabId)) { throw new Error('步骤 6:打开 ChatGPT 页面失败,无法创建订阅页。'); diff --git a/background/steps/fill-plus-checkout.js b/background/steps/fill-plus-checkout.js index a6a5766..43748aa 100644 --- a/background/steps/fill-plus-checkout.js +++ b/background/steps/fill-plus-checkout.js @@ -88,6 +88,7 @@ getTabId, isTabAlive, markCurrentRegistrationAccountUsed, + queryTabsInAutomationWindow = null, setState, sleepWithStop, waitForTabCompleteUntilStopped, @@ -1364,13 +1365,16 @@ return null; } - const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true }).catch(() => []); + const queryTabs = typeof queryTabsInAutomationWindow === 'function' + ? queryTabsInAutomationWindow + : (queryInfo) => chrome.tabs.query(queryInfo); + const activeTabs = await queryTabs({ active: true, currentWindow: true }).catch(() => []); const activeCheckoutTab = activeTabs.find((tab) => Number.isInteger(tab?.id) && isPlusCheckoutUrl(tab.url)); if (activeCheckoutTab) { return activeCheckoutTab.id; } - const checkoutTabs = await chrome.tabs.query({ url: 'https://chatgpt.com/checkout/*' }).catch(() => []); + const checkoutTabs = await queryTabs({ url: 'https://chatgpt.com/checkout/*' }).catch(() => []); const checkoutTab = checkoutTabs.find((tab) => Number.isInteger(tab?.id) && isPlusCheckoutUrl(tab.url)); return checkoutTab?.id || null; } diff --git a/background/steps/gopay-approve.js b/background/steps/gopay-approve.js index 4f3d085..5505679 100644 --- a/background/steps/gopay-approve.js +++ b/background/steps/gopay-approve.js @@ -21,6 +21,7 @@ ensureContentScriptReadyOnTabUntilStopped, getTabId, isTabAlive, + queryTabsInAutomationWindow = null, registerTab, sendTabMessageUntilStopped, setState, @@ -110,7 +111,10 @@ if (!chrome?.tabs?.query) { return 0; } - const tabs = await chrome.tabs.query({}).catch(() => []); + const queryTabs = typeof queryTabsInAutomationWindow === 'function' + ? queryTabsInAutomationWindow + : (queryInfo) => chrome.tabs.query(queryInfo); + const tabs = await queryTabs({}).catch(() => []); const candidates = (Array.isArray(tabs) ? tabs : []) .filter((tab) => Number.isInteger(tab?.id) && predicate(tab.url || '')); if (!candidates.length) { diff --git a/background/steps/gopay-manual-confirm.js b/background/steps/gopay-manual-confirm.js index f33724c..0fe4a7a 100644 --- a/background/steps/gopay-manual-confirm.js +++ b/background/steps/gopay-manual-confirm.js @@ -10,6 +10,7 @@ addLog, broadcastDataUpdate, chrome, + createAutomationTab = null, getTabId, isTabAlive, registerTab, @@ -48,7 +49,9 @@ throw new Error('步骤 7:无法自动重新打开 GoPay 订阅页。'); } - const tab = await chrome.tabs.create({ url: checkoutUrl, active: true }); + const tab = typeof createAutomationTab === 'function' + ? await createAutomationTab({ url: checkoutUrl, active: true }) + : await chrome.tabs.create({ url: checkoutUrl, active: true }); const tabId = Number(tab?.id) || 0; if (!tabId) { throw new Error('步骤 7:重新打开 GoPay 订阅页失败。'); diff --git a/background/steps/paypal-approve.js b/background/steps/paypal-approve.js index f385599..64c518f 100644 --- a/background/steps/paypal-approve.js +++ b/background/steps/paypal-approve.js @@ -15,6 +15,7 @@ ensureContentScriptReadyOnTabUntilStopped, getTabId, isTabAlive, + queryTabsInAutomationWindow = null, sendTabMessageUntilStopped, setState, sleepWithStop, @@ -48,7 +49,10 @@ return 0; } - const tabs = await chrome.tabs.query({}).catch(() => []); + const queryTabs = typeof queryTabsInAutomationWindow === 'function' + ? queryTabsInAutomationWindow + : (queryInfo) => chrome.tabs.query(queryInfo); + const tabs = await queryTabs({}).catch(() => []); const candidates = (Array.isArray(tabs) ? tabs : []) .filter((tab) => Number.isInteger(tab?.id) && isPayPalUrl(tab.url || '')); if (!candidates.length) { diff --git a/tests/plus-checkout-billing-tab-resolution.test.js b/tests/plus-checkout-billing-tab-resolution.test.js index 648731e..c279698 100644 --- a/tests/plus-checkout-billing-tab-resolution.test.js +++ b/tests/plus-checkout-billing-tab-resolution.test.js @@ -82,6 +82,7 @@ function createExecutorHarness({ fetchImpl = null, getAddressSeedForCountry = () => createAddressSeed(), getState = null, + queryTabsInAutomationWindow = null, markCurrentRegistrationAccountUsed = async () => {}, probeIpProxyExit = null, onSetState = null, @@ -161,6 +162,7 @@ function createExecutorHarness({ getTabId: async () => null, isTabAlive: async () => false, markCurrentRegistrationAccountUsed, + ...(typeof queryTabsInAutomationWindow === 'function' ? { queryTabsInAutomationWindow } : {}), setState: async (updates) => { events.states.push(updates); if (typeof onSetState === 'function') { @@ -240,6 +242,38 @@ test('Plus checkout billing uses the current checkout tab when step 6 did not re assert.equal(events.logs.some((entry) => /当前已在 Plus Checkout 页面/.test(entry.message)), true); }); +test('Plus checkout billing searches checkout tabs inside the locked automation window', async () => { + const queries = []; + const { checkoutTab, executor } = createExecutorHarness({ + frames: [{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }], + queryTabsInAutomationWindow: async (queryInfo) => { + queries.push(queryInfo); + if (queryInfo?.active) { + return []; + } + if (queryInfo?.url === 'https://chatgpt.com/checkout/*') { + return [checkoutTab]; + } + return []; + }, + stateByFrame: { + 0: { + hasPayPal: true, + paypalCandidates: [{ tag: 'button', text: 'PayPal' }], + billingFieldsVisible: true, + hasSubscribeButton: true, + }, + }, + }); + + await executor.executePlusCheckoutBilling({}); + + assert.deepEqual(queries, [ + { active: true, currentWindow: true }, + { url: 'https://chatgpt.com/checkout/*' }, + ]); +}); + test('Plus checkout billing sends the billing command to the iframe that contains PayPal', async () => { const { events, executor } = createExecutorHarness({ frames: [ From 8b9adb90856b0befe8ef17a02c51c75c9ca74dbd Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Tue, 12 May 2026 15:35:43 +0800 Subject: [PATCH 4/4] fix(tabs): stop fallback outside locked window --- background/ip-proxy-core.js | 15 ++-- background/message-router.js | 2 +- background/tab-runtime.js | 13 ++-- sidepanel/sidepanel.js | 2 +- tests/background-ip-proxy-core.test.js | 43 +++++++++++ tests/background-tab-runtime-module.test.js | 79 +++++++++++++++++++++ 6 files changed, 142 insertions(+), 12 deletions(-) diff --git a/background/ip-proxy-core.js b/background/ip-proxy-core.js index 3802e7c..2464329 100644 --- a/background/ip-proxy-core.js +++ b/background/ip-proxy-core.js @@ -35,10 +35,15 @@ function normalizeAutomationWindowId(value) { if (value === null || value === undefined || value === '') { return null; } - const numeric = Math.floor(Number(value)); + const numeric = Number(value); return Number.isInteger(numeric) && numeric >= 0 ? numeric : null; } +function buildAutomationWindowUnavailableError(error) { + const suffix = error?.message ? ` 原因:${error.message}` : ''; + return new Error(`自动任务窗口已不可用,请在目标 Chrome 窗口重新打开侧边栏并启动任务。${suffix}`); +} + async function createAutomationScopedTab(createProperties = {}, options = {}) { const windowId = normalizeAutomationWindowId( options?.automationWindowId @@ -55,8 +60,8 @@ async function createAutomationScopedTab(createProperties = {}, options = {}) { ...(createProperties || {}), windowId, }); - } catch { - return chrome.tabs.create(createProperties || {}); + } catch (error) { + throw buildAutomationWindowUnavailableError(error); } } @@ -78,8 +83,8 @@ async function queryAutomationScopedTabs(queryInfo = {}, options = {}) { delete scopedQuery.currentWindow; try { return await chrome.tabs.query(scopedQuery); - } catch { - return chrome.tabs.query(queryInfo || {}); + } catch (error) { + throw buildAutomationWindowUnavailableError(error); } } diff --git a/background/message-router.js b/background/message-router.js index 9a5449f..f3127a0 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -214,7 +214,7 @@ if (value === null || value === undefined || value === '') { return null; } - const numeric = Math.floor(Number(value)); + const numeric = Number(value); return Number.isInteger(numeric) && numeric >= 0 ? numeric : null; } diff --git a/background/tab-runtime.js b/background/tab-runtime.js index 7429101..f08f1a3 100644 --- a/background/tab-runtime.js +++ b/background/tab-runtime.js @@ -23,10 +23,15 @@ if (value === null || value === undefined || value === '') { return null; } - const numeric = Math.floor(Number(value)); + const numeric = Number(value); return Number.isInteger(numeric) && numeric >= 0 ? numeric : null; } + function buildAutomationWindowUnavailableError(error) { + const suffix = error?.message ? ` 原因:${error.message}` : ''; + return new Error(`自动任务窗口已不可用,请在目标 Chrome 窗口重新打开侧边栏并启动任务。${suffix}`); + } + async function getAutomationWindowId(options = {}) { const directWindowId = normalizeAutomationWindowId( options.automationWindowId ?? options.windowId ?? null @@ -58,8 +63,7 @@ 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 buildAutomationWindowUnavailableError(error); } throw error; } @@ -76,8 +80,7 @@ return await chrome.tabs.create(properties); } catch (error) { if (windowId !== null) { - await setState({ automationWindowId: null }).catch(() => {}); - return chrome.tabs.create(createProperties || {}); + throw buildAutomationWindowUnavailableError(error); } throw error; } diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 1d559c1..7e7c5b3 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -1155,7 +1155,7 @@ function normalizeAutomationWindowId(value) { if (value === null || value === undefined || value === '') { return null; } - const numeric = Math.floor(Number(value)); + const numeric = Number(value); return Number.isInteger(numeric) && numeric >= 0 ? numeric : null; } diff --git a/tests/background-ip-proxy-core.test.js b/tests/background-ip-proxy-core.test.js index c0f4561..b3b8146 100644 --- a/tests/background-ip-proxy-core.test.js +++ b/tests/background-ip-proxy-core.test.js @@ -44,6 +44,8 @@ ${coreSource} return { applyExitRegionExpectation, buildIpProxyPacScript, + chrome, + createAutomationScopedTab, buildIpProxyRoutingStatePatch, applyTargetReachabilityExpectation, getAccountModeProxyPoolFromState, @@ -51,6 +53,7 @@ return { normalizeProxyPoolEntries, parseProxyExitProbePayload, parseIpProxyLine, + queryAutomationScopedTabs, resolveExitProbeEndpoints, resolveIpProxyAutoSwitchThreshold, resolveTargetReachabilityEndpoints, @@ -135,6 +138,46 @@ test('IP proxy routing state patch keeps exit probe endpoint for diagnostics', ( assert.equal(patch.ipProxyAppliedExitEndpoint, 'https://ipinfo.io/json'); }); +test('IP proxy page probes do not fall back to other windows when the locked window is unavailable', async () => { + const api = loadIpProxyCore(); + const created = []; + const queries = []; + api.chrome.tabs = { + create: async (payload) => { + created.push(payload); + if (payload.windowId === 77) { + throw new Error('No window with id: 77'); + } + return { id: 12, windowId: payload.windowId, url: payload.url }; + }, + query: async (queryInfo) => { + queries.push(queryInfo); + if (queryInfo.windowId === 77) { + throw new Error('No window with id: 77'); + } + return [{ id: 99, windowId: 1, url: 'https://ipinfo.io/json' }]; + }, + }; + + await assert.rejects( + () => api.createAutomationScopedTab( + { url: 'https://ipinfo.io/json', active: false }, + { state: { automationWindowId: 77 } } + ), + /自动任务窗口已不可用/ + ); + await assert.rejects( + () => api.queryAutomationScopedTabs( + { url: 'https://ipinfo.io/*' }, + { state: { automationWindowId: 77 } } + ), + /自动任务窗口已不可用/ + ); + + assert.deepEqual(created, [{ url: 'https://ipinfo.io/json', active: false, windowId: 77 }]); + assert.deepEqual(queries, [{ url: 'https://ipinfo.io/*', windowId: 77 }]); +}); + test('711 fixed-account mode applies region and sticky session parameters', () => { const api = loadIpProxyCore(); const pool = api.getAccountModeProxyPoolFromState({ diff --git a/tests/background-tab-runtime-module.test.js b/tests/background-tab-runtime-module.test.js index bea20c9..b1186bd 100644 --- a/tests/background-tab-runtime-module.test.js +++ b/tests/background-tab-runtime-module.test.js @@ -260,3 +260,82 @@ test('tab runtime scopes tab queries to the locked automation window', async () assert.deepEqual(queries[0], { active: true, windowId: 22 }); }); + +test('tab runtime does not create tabs outside an unavailable 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); + if (payload.windowId === 44) { + throw new Error('No window with id: 44'); + } + return { id: 99, windowId: payload.windowId, url: payload.url }; + }, + get: async () => ({ id: 1, windowId: 44, url: 'https://example.com' }), + query: async () => [], + }, + }, + getSourceLabel: (sourceName) => sourceName || 'unknown', + getState: async () => ({ + automationWindowId: 44, + tabRegistry: {}, + sourceLastUrls: {}, + }), + matchesSourceUrlFamily: () => false, + setState: async () => {}, + throwIfStopped: () => {}, + }); + + await assert.rejects( + () => runtime.createAutomationTab({ url: 'https://example.com', active: true }), + /自动任务窗口已不可用/ + ); + + assert.deepEqual(created, [{ url: 'https://example.com', active: true, windowId: 44 }]); +}); + +test('tab runtime does not query all windows when the locked window is unavailable', 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: 55, url: 'https://example.com' }), + query: async (queryInfo) => { + queries.push(queryInfo); + if (queryInfo.windowId === 55) { + throw new Error('No window with id: 55'); + } + return [{ id: 7, windowId: 1, url: 'https://other.example/' }]; + }, + }, + }, + getSourceLabel: (sourceName) => sourceName || 'unknown', + getState: async () => ({ + automationWindowId: 55, + tabRegistry: {}, + sourceLastUrls: {}, + }), + matchesSourceUrlFamily: () => false, + setState: async () => {}, + throwIfStopped: () => {}, + }); + + await assert.rejects( + () => runtime.queryTabsInAutomationWindow({ active: true }), + /自动任务窗口已不可用/ + ); + + assert.deepEqual(queries, [{ active: true, windowId: 55 }]); +});