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..2464329 100644
--- a/background/ip-proxy-core.js
+++ b/background/ip-proxy-core.js
@@ -30,6 +30,65 @@ let ipProxyLastRuntimeError = {
fatal: false,
at: 0,
};
+
+function normalizeAutomationWindowId(value) {
+ if (value === null || value === undefined || value === '') {
+ return null;
+ }
+ 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
+ ?? options?.windowId
+ ?? options?.state?.automationWindowId
+ ?? null
+ );
+ if (windowId === null) {
+ return chrome.tabs.create(createProperties || {});
+ }
+
+ try {
+ return await chrome.tabs.create({
+ ...(createProperties || {}),
+ windowId,
+ });
+ } catch (error) {
+ throw buildAutomationWindowUnavailableError(error);
+ }
+}
+
+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 (error) {
+ throw buildAutomationWindowUnavailableError(error);
+ }
+}
+
+
const IP_PROXY_EXIT_PROBE_ENDPOINTS = [
'http://ip-api.com/json?lang=en',
'http://ipinfo.io/json',
@@ -1974,8 +2033,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 +2404,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 +2483,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 +2603,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 +2694,9 @@ async function detectProxyExitInfo(options = {}) {
timeoutMs,
errors,
probeEndpoints,
+ automationWindowId: options?.automationWindowId,
+ windowId: options?.windowId,
+ state: options?.state,
});
if (pageResult?.ip) {
return pageResult;
@@ -2669,6 +2731,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 +3373,7 @@ async function applyIpProxySettingsFromState(state = {}, options = {}) {
// 后台探测仅作为补充兜底,避免单一路径误判。
preferPageContext: true,
allowBackgroundFallback: true,
+ state,
}).catch(() => ({ ip: '', region: '' }));
if (!exit?.ip && Boolean(status?.hasAuth)) {
appendIpProxyAuthDiagnosticsToErrors(diagnostics);
@@ -3339,6 +3405,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 +3742,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 +3842,7 @@ async function probeIpProxyExit(options = {}) {
// 手动探测与自动应用保持一致:先页面上下文,再后台补充。
preferPageContext: true,
allowBackgroundFallback: true,
+ state,
}).catch(() => ({ ip: '', region: '' }));
if (!exit?.ip && Boolean(statusSeed?.hasAuth)) {
appendIpProxyAuthDiagnosticsToErrors(diagnostics);
@@ -3800,6 +3869,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..f3127a0 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 = 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/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/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/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/background/tab-runtime.js b/background/tab-runtime.js
index a5a6395..f08f1a3 100644
--- a/background/tab-runtime.js
+++ b/background/tab-runtime.js
@@ -19,6 +19,91 @@
const pendingCommands = new Map();
+ function normalizeAutomationWindowId(value) {
+ if (value === null || value === undefined || value === '') {
+ return null;
+ }
+ 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
+ );
+ 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')) {
+ throw buildAutomationWindowUnavailableError(error);
+ }
+ 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) {
+ throw buildAutomationWindowUnavailableError(error);
+ }
+ 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 +171,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 +194,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 +210,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 +239,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 +276,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 +310,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 +646,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 +738,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 +899,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..7e7c5b3 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 = 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-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-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');
},
diff --git a/tests/background-tab-runtime-module.test.js b/tests/background-tab-runtime-module.test.js
index 35e6ef8..b1186bd 100644
--- a/tests/background-tab-runtime-module.test.js
+++ b/tests/background-tab-runtime-module.test.js
@@ -188,3 +188,154 @@ 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 });
+});
+
+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 }]);
+});
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/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: [
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' });
+});