diff --git a/background.js b/background.js index 271edeb..01bb275 100644 --- a/background.js +++ b/background.js @@ -490,6 +490,7 @@ const PERSISTED_SETTING_DEFAULTS = { currentPayPalAccountId: '', autoRunSkipFailures: false, autoRunFallbackThreadIntervalMinutes: 0, + oauthFlowTimeoutEnabled: true, autoRunDelayEnabled: false, autoRunDelayMinutes: 30, autoStepDelaySeconds: null, @@ -1466,6 +1467,7 @@ function normalizePersistentSettingValue(key, value) { case 'plusPaymentMethod': return String(value || '').trim().toLowerCase() === 'gopay' ? 'gopay' : 'paypal'; case 'autoRunSkipFailures': + case 'oauthFlowTimeoutEnabled': case 'autoRunDelayEnabled': case 'phoneVerificationEnabled': case 'plusModeEnabled': @@ -8915,6 +8917,7 @@ const phoneVerificationHelpers = self.MultiPageBackgroundPhoneVerification?.crea DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS, DEFAULT_PHONE_CODE_POLL_ROUNDS, ensureStep8SignupPageReady, + getOAuthFlowRemainingMs, getOAuthFlowStepTimeoutMs, getState, HERO_SMS_COUNTRY_ID, @@ -9670,6 +9673,16 @@ function normalizeOAuthFlowSourceUrl(value) { async function startOAuthFlowTimeoutWindow(options = {}) { const step = Number(options.step) || 7; + const state = options.state || await getState(); + if (state?.oauthFlowTimeoutEnabled === false) { + await setState({ + oauthFlowDeadlineAt: null, + oauthFlowDeadlineSourceUrl: null, + }); + await addLog(`步骤 ${step}:已拿到新的 OAuth 登录地址,授权后链总超时已关闭,仅保留各步骤本地等待超时。`, 'info'); + return null; + } + const deadlineAt = Date.now() + OAUTH_FLOW_TIMEOUT_MS; await setState({ oauthFlowDeadlineAt: deadlineAt, @@ -9683,6 +9696,10 @@ async function getOAuthFlowRemainingMs(options = {}) { const step = Number(options.step) || 7; const actionLabel = String(options.actionLabel || '后续授权流程').trim() || '后续授权流程'; const state = options.state || await getState(); + if (state?.oauthFlowTimeoutEnabled === false) { + return null; + } + const deadlineAt = normalizeOAuthFlowDeadlineAt(state?.oauthFlowDeadlineAt); const deadlineSourceUrl = normalizeOAuthFlowSourceUrl(state?.oauthFlowDeadlineSourceUrl); const currentOauthUrl = normalizeOAuthFlowSourceUrl(options.oauthUrl !== undefined ? options.oauthUrl : state?.oauthUrl); diff --git a/background/message-router.js b/background/message-router.js index 21f1c6b..3bb81d7 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -705,10 +705,16 @@ ? Boolean(updates.plusModeEnabled) : Boolean(currentState?.plusModeEnabled); const stepModeChanged = modeChanged || (nextPlusModeEnabled && plusPaymentChanged); + const oauthFlowTimeoutDisabled = Object.prototype.hasOwnProperty.call(updates, 'oauthFlowTimeoutEnabled') + && updates.oauthFlowTimeoutEnabled === false; await setPersistentSettings(updates); const stateUpdates = { ...updates, ...sessionUpdates, + ...(oauthFlowTimeoutDisabled ? { + oauthFlowDeadlineAt: null, + oauthFlowDeadlineSourceUrl: null, + } : {}), }; if (stepModeChanged && typeof getStepIdsForState === 'function') { const nextStateForSteps = { ...currentState, ...stateUpdates }; diff --git a/background/steps/confirm-oauth.js b/background/steps/confirm-oauth.js index e172945..37d9cd9 100644 --- a/background/steps/confirm-oauth.js +++ b/background/steps/confirm-oauth.js @@ -9,6 +9,7 @@ clickWithDebugger, completeStepFromBackground, ensureStep8SignupPageReady, + getOAuthFlowRemainingMs, getOAuthFlowStepTimeoutMs, getStep8CallbackUrlFromNavigation, getStep8CallbackUrlFromTabUpdate, @@ -34,6 +35,9 @@ setStep8TabUpdatedListener, } = deps; + const LOCALHOST_CALLBACK_LOCAL_TIMEOUT_MS = 240000; + const CALLBACK_TIMEOUT_CHECK_INTERVAL_MS = 1000; + function getVisibleStep(state, fallback = 9) { const visibleStep = Math.floor(Number(state?.visibleStep) || 0); return visibleStep > 0 ? visibleStep : fallback; @@ -54,17 +58,17 @@ await addLog(`步骤 ${visibleStep}:正在监听 localhost 回调地址...`); - let callbackTimeoutMs = 240000; + let callbackTimeoutMs = LOCALHOST_CALLBACK_LOCAL_TIMEOUT_MS; let timeoutRecoveryAttempted = false; while (true) { try { callbackTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' - ? await getOAuthFlowStepTimeoutMs(240000, { + ? await getOAuthFlowStepTimeoutMs(LOCALHOST_CALLBACK_LOCAL_TIMEOUT_MS, { step: visibleStep, actionLabel: 'OAuth localhost 回调', oauthUrl: activeState?.oauthUrl || '', }) - : 240000; + : LOCALHOST_CALLBACK_LOCAL_TIMEOUT_MS; break; } catch (error) { if (timeoutRecoveryAttempted || typeof recoverOAuthLocalhostTimeout !== 'function') { @@ -86,8 +90,14 @@ return new Promise((resolve, reject) => { let resolved = false; let signupTabId = null; + const callbackWaitStartedAt = Date.now(); + let timeoutCheckTimer = null; const cleanupListener = () => { + if (timeoutCheckTimer) { + clearTimeout(timeoutCheckTimer); + timeoutCheckTimer = null; + } cleanupStep8NavigationListeners(); setStep8PendingReject(null); }; @@ -95,7 +105,6 @@ const rejectStep9 = (error) => { if (resolved) return; resolved = true; - clearTimeout(timeout); cleanupListener(); reject(error); }; @@ -105,7 +114,6 @@ resolved = true; cleanupListener(); - clearTimeout(timeout); addLog(`步骤 ${visibleStep}:已捕获 localhost 地址:${callbackUrl}`, 'ok').then(() => { return completeStepFromBackground(visibleStep, { localhostUrl: callbackUrl }); @@ -116,9 +124,39 @@ }); }; - const timeout = setTimeout(() => { - rejectStep9(new Error(`${Math.round(callbackTimeoutMs / 1000)} 秒内未捕获到 localhost 回调跳转,步骤 ${visibleStep} 的点击可能被拦截了。`)); - }, callbackTimeoutMs); + const checkCallbackTimeout = async () => { + if (resolved) { + return; + } + const elapsedMs = Date.now() - callbackWaitStartedAt; + if (elapsedMs >= LOCALHOST_CALLBACK_LOCAL_TIMEOUT_MS) { + rejectStep9(new Error(`${Math.round(LOCALHOST_CALLBACK_LOCAL_TIMEOUT_MS / 1000)} 秒内未捕获到 localhost 回调跳转,步骤 ${visibleStep} 的点击可能被拦截了。`)); + return; + } + + if (typeof getOAuthFlowRemainingMs === 'function') { + try { + await getOAuthFlowRemainingMs({ + step: visibleStep, + actionLabel: 'OAuth localhost 回调', + oauthUrl: activeState?.oauthUrl || '', + }); + } catch (error) { + rejectStep9(error); + return; + } + } else if (elapsedMs >= callbackTimeoutMs) { + rejectStep9(new Error(`${Math.round(callbackTimeoutMs / 1000)} 秒内未捕获到 localhost 回调跳转,步骤 ${visibleStep} 的点击可能被拦截了。`)); + return; + } + + timeoutCheckTimer = setTimeout(checkCallbackTimeout, CALLBACK_TIMEOUT_CHECK_INTERVAL_MS); + }; + + timeoutCheckTimer = setTimeout( + checkCallbackTimeout, + Math.min(CALLBACK_TIMEOUT_CHECK_INTERVAL_MS, Math.max(1, callbackTimeoutMs)) + ); setStep8PendingReject((error) => { rejectStep9(error); diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 0443be8..87eca16 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -394,6 +394,20 @@ +
+ 授权总超时 +
+ + 关闭后只取消 Step 7 后链总预算 +
+
OAuth 等待中... diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index b5d8e29..f9748f8 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -288,6 +288,7 @@ const inputAutoSkipFailuresThreadIntervalMinutes = document.getElementById('inpu const inputAutoDelayEnabled = document.getElementById('input-auto-delay-enabled'); const inputAutoDelayMinutes = document.getElementById('input-auto-delay-minutes'); const inputAutoStepDelaySeconds = document.getElementById('input-auto-step-delay-seconds'); +const inputOAuthFlowTimeoutEnabled = document.getElementById('input-oauth-flow-timeout-enabled'); const inputVerificationResendCount = document.getElementById('input-verification-resend-count'); const rowPhoneVerificationEnabled = document.getElementById('row-phone-verification-enabled'); const btnTogglePhoneVerificationSection = document.getElementById('btn-toggle-phone-verification-section'); @@ -2650,6 +2651,9 @@ function collectSettingsPayload() { autoRunDelayEnabled: inputAutoDelayEnabled.checked, autoRunDelayMinutes: normalizeAutoDelayMinutes(inputAutoDelayMinutes.value), autoStepDelaySeconds: normalizeAutoStepDelaySeconds(inputAutoStepDelaySeconds.value), + oauthFlowTimeoutEnabled: inputOAuthFlowTimeoutEnabled + ? Boolean(inputOAuthFlowTimeoutEnabled.checked) + : true, phoneVerificationEnabled: Boolean(inputPhoneVerificationEnabled?.checked), verificationResendCount: normalizeVerificationResendCount( inputVerificationResendCount?.value, @@ -4202,6 +4206,11 @@ function applySettingsState(state) { inputAutoDelayEnabled.checked = Boolean(state?.autoRunDelayEnabled); inputAutoDelayMinutes.value = String(normalizeAutoDelayMinutes(state?.autoRunDelayMinutes)); inputAutoStepDelaySeconds.value = formatAutoStepDelayInputValue(state?.autoStepDelaySeconds); + if (inputOAuthFlowTimeoutEnabled) { + inputOAuthFlowTimeoutEnabled.checked = state?.oauthFlowTimeoutEnabled !== undefined + ? Boolean(state.oauthFlowTimeoutEnabled) + : true; + } if (inputVerificationResendCount) { const restoredVerificationResendCount = state?.verificationResendCount !== undefined ? state.verificationResendCount @@ -7765,6 +7774,11 @@ inputAutoDelayMinutes.addEventListener('blur', () => { saveSettings({ silent: true }).catch(() => { }); }); +inputOAuthFlowTimeoutEnabled?.addEventListener('change', () => { + markSettingsDirty(true); + saveSettings({ silent: true }).catch(() => { }); +}); + inputPhoneVerificationEnabled?.addEventListener('change', () => { if (inputPhoneVerificationEnabled.checked) { setPhoneVerificationSectionExpanded(true); @@ -8357,6 +8371,9 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { if (message.payload.autoStepDelaySeconds !== undefined) { inputAutoStepDelaySeconds.value = formatAutoStepDelayInputValue(message.payload.autoStepDelaySeconds); } + if (message.payload.oauthFlowTimeoutEnabled !== undefined && inputOAuthFlowTimeoutEnabled) { + inputOAuthFlowTimeoutEnabled.checked = Boolean(message.payload.oauthFlowTimeoutEnabled); + } if ( ( message.payload.verificationResendCount !== undefined diff --git a/tests/background-auth-chain-guard.test.js b/tests/background-auth-chain-guard.test.js index c0a6356..9f251d2 100644 --- a/tests/background-auth-chain-guard.test.js +++ b/tests/background-auth-chain-guard.test.js @@ -296,3 +296,99 @@ return { assert.equal(timeoutMs, 15000); }); + +test('oauth timeout budget clamps local timeout when enabled by default', async () => { + const api = new Function(` +const LOG_PREFIX = '[test]'; +const OAUTH_FLOW_TIMEOUT_MS = 5 * 60 * 1000; +${extractFunction('buildOAuthFlowTimeoutError')} +${extractFunction('normalizeOAuthFlowDeadlineAt')} +${extractFunction('normalizeOAuthFlowSourceUrl')} +${extractFunction('getOAuthFlowRemainingMs')} +${extractFunction('getOAuthFlowStepTimeoutMs')} +return { + getOAuthFlowStepTimeoutMs, +}; +`)(); + + const timeoutMs = await api.getOAuthFlowStepTimeoutMs(15000, { + step: 8, + actionLabel: '登录验证码流程', + state: { + oauthUrl: 'https://oauth.example/current', + oauthFlowDeadlineAt: Date.now() + 1200, + oauthFlowDeadlineSourceUrl: 'https://oauth.example/current', + }, + }); + + assert(timeoutMs <= 1200); + assert(timeoutMs >= 1000); +}); + +test('oauth timeout budget disabled mode ignores active deadlines', async () => { + const api = new Function(` +const LOG_PREFIX = '[test]'; +const OAUTH_FLOW_TIMEOUT_MS = 5 * 60 * 1000; +${extractFunction('buildOAuthFlowTimeoutError')} +${extractFunction('normalizeOAuthFlowDeadlineAt')} +${extractFunction('normalizeOAuthFlowSourceUrl')} +${extractFunction('getOAuthFlowRemainingMs')} +${extractFunction('getOAuthFlowStepTimeoutMs')} +return { + getOAuthFlowStepTimeoutMs, +}; +`)(); + + const timeoutMs = await api.getOAuthFlowStepTimeoutMs(15000, { + step: 9, + actionLabel: 'OAuth localhost 回调', + state: { + oauthFlowTimeoutEnabled: false, + oauthUrl: 'https://oauth.example/current', + oauthFlowDeadlineAt: Date.now() - 1000, + oauthFlowDeadlineSourceUrl: 'https://oauth.example/current', + }, + }); + + assert.equal(timeoutMs, 15000); +}); + +test('startOAuthFlowTimeoutWindow clears stale deadline when timeout is disabled', async () => { + const events = { + stateUpdates: [], + logs: [], + }; + const api = new Function('events', ` +const OAUTH_FLOW_TIMEOUT_MS = 5 * 60 * 1000; +async function getState() { + return { + oauthFlowTimeoutEnabled: false, + oauthFlowDeadlineAt: Date.now() - 1000, + oauthFlowDeadlineSourceUrl: 'https://oauth.example/old', + }; +} +async function setState(update) { + events.stateUpdates.push(update); +} +async function addLog(message, level) { + events.logs.push({ message, level }); +} +${extractFunction('normalizeOAuthFlowSourceUrl')} +${extractFunction('startOAuthFlowTimeoutWindow')} +return { + startOAuthFlowTimeoutWindow, +}; +`)(events); + + const result = await api.startOAuthFlowTimeoutWindow({ + step: 7, + oauthUrl: 'https://oauth.example/current', + }); + + assert.equal(result, null); + assert.deepStrictEqual(events.stateUpdates, [{ + oauthFlowDeadlineAt: null, + oauthFlowDeadlineSourceUrl: null, + }]); + assert.match(events.logs[0].message, /授权后链总超时已关闭/); +}); diff --git a/tests/background-cloudflare-temp-email-settings.test.js b/tests/background-cloudflare-temp-email-settings.test.js index e44404f..c58f5b5 100644 --- a/tests/background-cloudflare-temp-email-settings.test.js +++ b/tests/background-cloudflare-temp-email-settings.test.js @@ -143,3 +143,81 @@ return { domains: ['mail.example.com'], }); }); + +test('oauth flow timeout setting is persisted and normalized as boolean', () => { + const api = new Function(` +const DEFAULT_VERIFICATION_RESEND_COUNT = 4; +const PERSISTED_SETTING_DEFAULTS = { + panelMode: 'cpa', + oauthFlowTimeoutEnabled: true, + autoStepDelaySeconds: null, + verificationResendCount: DEFAULT_VERIFICATION_RESEND_COUNT, + mailProvider: '163', + mail2925Mode: 'provide', + emailGenerator: 'duck', + autoDeleteUsedIcloudAlias: false, + accountRunHistoryTextEnabled: false, + cloudflareTempEmailUseRandomSubdomain: false, + cloudflareTempEmailDomain: '', + cloudflareTempEmailDomains: [], +}; +const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS); +function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : 'cpa'; } +function normalizeLocalCpaStep9Mode(value) { return value === 'bypass' ? 'bypass' : 'submit'; } +function normalizeAutoRunFallbackThreadIntervalMinutes(value) { return Number(value) || 0; } +function normalizeAutoRunDelayMinutes(value) { return Number(value) || 30; } +function normalizeAutoStepDelaySeconds(value, fallback = null) { return value == null || value === '' ? fallback : Number(value); } +function normalizeVerificationResendCount(value, fallback) { return Number.isFinite(Number(value)) ? Number(value) : fallback; } +function normalizeMailProvider(value) { return String(value || '').trim().toLowerCase() || '163'; } +function normalizeMail2925Mode(value) { return String(value || '').trim().toLowerCase() === 'receive' ? 'receive' : 'provide'; } +function normalizeEmailGenerator(value) { return String(value || '').trim().toLowerCase() || 'duck'; } +function normalizeIcloudHost(value) { return ''; } +function normalizeIcloudTargetMailboxType(value) { return String(value || '').trim() || 'icloud-inbox'; } +function normalizeIcloudForwardMailProvider(value) { return String(value || '').trim() || 'qq'; } +function normalizeIcloudFetchMode(value) { return String(value || '').trim() === 'always_new' ? 'always_new' : 'reuse_existing'; } +function normalizeAccountRunHistoryHelperBaseUrl(value) { return String(value || '').trim(); } +function normalizeHotmailServiceMode(value) { return String(value || '').trim().toLowerCase() === 'remote' ? 'remote' : 'local'; } +function normalizeHotmailRemoteBaseUrl(value) { return String(value || '').trim(); } +function normalizeHotmailLocalBaseUrl(value) { return String(value || '').trim(); } +function normalizeLuckmailBaseUrl(value) { return String(value || '').trim(); } +function normalizeLuckmailEmailType(value) { return String(value || '').trim(); } +function normalizeCloudflareDomain(value) { return String(value || '').trim().toLowerCase(); } +function normalizeCloudflareDomains(value) { return Array.isArray(value) ? value.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean) : []; } +function normalizeCustomEmailPool(value) { return Array.isArray(value) ? value : []; } +function normalizeCloudflareTempEmailBaseUrl(value) { return String(value || '').trim(); } +function normalizeCloudflareTempEmailAddress(value = '') { return String(value || '').trim().toLowerCase(); } +function normalizeCloudflareTempEmailReceiveMailbox(value = '') { return String(value || '').trim().toLowerCase(); } +function normalizeCloudflareTempEmailDomain(value) { return String(value || '').trim().toLowerCase(); } +function normalizeCloudflareTempEmailDomains(value) { return Array.isArray(value) ? value.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean) : []; } +function normalizeHotmailAccounts(value) { return Array.isArray(value) ? value : []; } +function normalizeMail2925Accounts(value) { return Array.isArray(value) ? value : []; } +function normalizePayPalAccounts(value) { return Array.isArray(value) ? value : []; } +function normalizeHeroSmsAcquirePriority(value) { return String(value || '').trim() === 'price' ? 'price' : 'country'; } +function normalizeHeroSmsMaxPrice(value) { return String(value || '').trim(); } +function normalizeHeroSmsCountryFallback(value) { return Array.isArray(value) ? value : []; } +function normalizePhoneVerificationReplacementLimit(value) { return Number(value) || 3; } +function normalizePhoneCodeWaitSeconds(value) { return Number(value) || 60; } +function normalizePhoneCodeTimeoutWindows(value) { return Number(value) || 2; } +function normalizePhoneCodePollIntervalSeconds(value) { return Number(value) || 5; } +function normalizePhoneCodePollMaxRounds(value) { return Number(value) || 4; } +function resolveLegacyAutoStepDelaySeconds() { return undefined; } +${extractFunction('normalizePersistentSettingValue')} +${extractFunction('buildPersistentSettingsPayload')} +return { + buildPersistentSettingsPayload, + normalizePersistentSettingValue, +}; + `)(); + + assert.equal(api.normalizePersistentSettingValue('oauthFlowTimeoutEnabled', 0), false); + assert.equal(api.normalizePersistentSettingValue('oauthFlowTimeoutEnabled', 1), true); + + assert.deepEqual(api.buildPersistentSettingsPayload({ + oauthFlowTimeoutEnabled: false, + }), { + oauthFlowTimeoutEnabled: false, + }); + + const defaults = api.buildPersistentSettingsPayload({}, { fillDefaults: true }); + assert.equal(defaults.oauthFlowTimeoutEnabled, true); +}); diff --git a/tests/sidepanel-contribution-mode.test.js b/tests/sidepanel-contribution-mode.test.js index 4d99008..70141d9 100644 --- a/tests/sidepanel-contribution-mode.test.js +++ b/tests/sidepanel-contribution-mode.test.js @@ -220,6 +220,7 @@ const inputAutoSkipFailuresThreadIntervalMinutes = { value: '5' }; const inputAutoDelayEnabled = { checked: true }; const inputAutoDelayMinutes = { value: '30' }; const inputAutoStepDelaySeconds = { value: '10' }; +const inputOAuthFlowTimeoutEnabled = { checked: true }; const inputVerificationResendCount = { value: '6' }; const DEFAULT_VERIFICATION_RESEND_COUNT = 4; const DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT = 3; diff --git a/tests/sidepanel-icloud-provider.test.js b/tests/sidepanel-icloud-provider.test.js index d6a5c98..39aacf3 100644 --- a/tests/sidepanel-icloud-provider.test.js +++ b/tests/sidepanel-icloud-provider.test.js @@ -128,6 +128,7 @@ const inputAutoSkipFailuresThreadIntervalMinutes = { value: '0' }; const inputAutoDelayEnabled = { checked: false }; const inputAutoDelayMinutes = { value: '30' }; const inputAutoStepDelaySeconds = { value: '' }; +const inputOAuthFlowTimeoutEnabled = { checked: true }; const inputVerificationResendCount = { value: '4' }; const DEFAULT_VERIFICATION_RESEND_COUNT = 4; const DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT = 3; @@ -375,6 +376,7 @@ const inputAutoSkipFailuresThreadIntervalMinutes = { value: '' }; const inputAutoDelayEnabled = { checked: false }; const inputAutoDelayMinutes = { value: '' }; const inputAutoStepDelaySeconds = { value: '' }; +const inputOAuthFlowTimeoutEnabled = { checked: true }; const inputVerificationResendCount = { value: '' }; const inputPhoneVerificationEnabled = { checked: false }; const DEFAULT_PHONE_VERIFICATION_ENABLED = false; diff --git a/tests/sidepanel-mail2925-base-email.test.js b/tests/sidepanel-mail2925-base-email.test.js index 0b51963..a66515c 100644 --- a/tests/sidepanel-mail2925-base-email.test.js +++ b/tests/sidepanel-mail2925-base-email.test.js @@ -199,6 +199,7 @@ const inputAutoSkipFailuresThreadIntervalMinutes = { value: '0' }; const inputAutoDelayEnabled = { checked: false }; const inputAutoDelayMinutes = { value: '30' }; const inputAutoStepDelaySeconds = { value: '' }; +const inputOAuthFlowTimeoutEnabled = { checked: true }; const inputVerificationResendCount = { value: '4' }; const DEFAULT_VERIFICATION_RESEND_COUNT = 4; const DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT = 3; diff --git a/tests/sidepanel-phone-verification-settings.test.js b/tests/sidepanel-phone-verification-settings.test.js index fb280bd..cd7c5c1 100644 --- a/tests/sidepanel-phone-verification-settings.test.js +++ b/tests/sidepanel-phone-verification-settings.test.js @@ -77,6 +77,9 @@ test('sidepanel html exposes phone verification toggle and dedicated HeroSMS row assert.match(html, /id="row-phone-code-timeout-windows"/); assert.match(html, /id="row-phone-code-poll-interval-seconds"/); assert.match(html, /id="row-phone-code-poll-max-rounds"/); + assert.match(html, /id="row-oauth-flow-timeout"/); + assert.match(html, /id="input-oauth-flow-timeout-enabled"/); + assert.match(html, /只取消 Step 7 后链总预算/); assert.doesNotMatch(html, /id="input-account-run-history-text-enabled"/); }); @@ -222,6 +225,7 @@ const inputAutoSkipFailuresThreadIntervalMinutes = { value: '0' }; const inputAutoDelayEnabled = { checked: false }; const inputAutoDelayMinutes = { value: '30' }; const inputAutoStepDelaySeconds = { value: '' }; +const inputOAuthFlowTimeoutEnabled = { checked: false }; const inputPhoneVerificationEnabled = { checked: true }; const inputVerificationResendCount = { value: '4' }; const inputHeroSmsApiKey = { value: 'demo-key' }; @@ -299,6 +303,7 @@ return { collectSettingsPayload }; const payload = api.collectSettingsPayload(); assert.equal(payload.phoneVerificationEnabled, true); + assert.equal(payload.oauthFlowTimeoutEnabled, false); assert.equal(payload.accountRunHistoryTextEnabled, true); assert.equal(payload.accountRunHistoryHelperBaseUrl, 'http://127.0.0.1:17373'); assert.equal(payload.heroSmsApiKey, 'demo-key'); diff --git a/tests/step9-oauth-timeout-toggle.test.js b/tests/step9-oauth-timeout-toggle.test.js new file mode 100644 index 0000000..00803a1 --- /dev/null +++ b/tests/step9-oauth-timeout-toggle.test.js @@ -0,0 +1,184 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const step9ModuleSource = fs.readFileSync('background/steps/confirm-oauth.js', 'utf8'); + +test('step9 observes disabled oauth timeout while waiting for localhost callback', async () => { + const api = new Function('step9ModuleSource', ` +const self = {}; +let webNavListener = null; +let webNavCommittedListener = null; +let step8TabUpdatedListener = null; +let step8PendingReject = null; +let cleanupCalls = 0; +let remainingCalls = 0; +let recoveryCalls = 0; +let completePayload = null; +const callbackUrl = 'http://localhost:1455/auth/callback?code=abc&state=xyz'; + +const chrome = { + webNavigation: { + onBeforeNavigate: { + addListener(listener) { + webNavListener = listener; + setTimeout(() => { + if (typeof webNavListener === 'function') { + webNavListener({ tabId: 123, url: callbackUrl }); + } + }, 25); + }, + removeListener() {}, + }, + onCommitted: { + addListener(listener) { + webNavCommittedListener = listener; + }, + removeListener() {}, + }, + }, + tabs: { + onUpdated: { + addListener(listener) { + step8TabUpdatedListener = listener; + }, + removeListener() {}, + }, + async update() {}, + }, +}; + +async function addLog() {} +function cleanupStep8NavigationListeners() { + cleanupCalls += 1; + webNavListener = null; + webNavCommittedListener = null; + step8TabUpdatedListener = null; +} +function throwIfStep8SettledOrStopped(resolved) { + if (resolved) throw new Error('already resolved'); +} +async function getTabId() { return 123; } +async function isTabAlive() { return true; } +async function ensureStep8SignupPageReady() {} +async function getOAuthFlowStepTimeoutMs(defaultTimeoutMs, options = {}) { + if (options.actionLabel === 'OAuth localhost 回调') { + return 1; + } + return defaultTimeoutMs; +} +async function getOAuthFlowRemainingMs() { + remainingCalls += 1; + return null; +} +async function recoverOAuthLocalhostTimeout() { + recoveryCalls += 1; + throw new Error('should not recover when timeout has been disabled'); +} +async function waitForStep8Ready() { + return { + consentReady: true, + url: 'https://auth.openai.com/sign-in-with-chatgpt/codex/consent', + }; +} +async function triggerStep8ContentStrategy() {} +async function waitForStep8ClickEffect() { + return { progressed: true, reason: 'url_changed', url: 'https://chatgpt.com/' }; +} +function getStep8CallbackUrlFromNavigation(details, signupTabId) { + return Number(details?.tabId) === Number(signupTabId) ? details.url : ''; +} +function getStep8CallbackUrlFromTabUpdate() { return ''; } +function getStep8EffectLabel() { return 'URL 已变化'; } +async function prepareStep8DebuggerClick() { return { rect: { centerX: 10, centerY: 10 } }; } +async function clickWithDebugger() {} +async function reloadStep8ConsentPage() {} +async function reuseOrCreateTab() { return 123; } +async function sleepWithStop() {} +function setWebNavListener(listener) { webNavListener = listener; } +function getWebNavListener() { return webNavListener; } +function setWebNavCommittedListener(listener) { webNavCommittedListener = listener; } +function getWebNavCommittedListener() { return webNavCommittedListener; } +function setStep8TabUpdatedListener(listener) { step8TabUpdatedListener = listener; } +function getStep8TabUpdatedListener() { return step8TabUpdatedListener; } +function setStep8PendingReject(handler) { step8PendingReject = handler; } +async function completeStepFromBackground(step, payload) { + completePayload = { step, payload }; +} + +const STEP8_CLICK_RETRY_DELAY_MS = 1; +const STEP8_READY_WAIT_TIMEOUT_MS = 5; +const STEP8_MAX_ROUNDS = 1; +const STEP8_STRATEGIES = [ + { mode: 'content', strategy: 'requestSubmit', label: 'form.requestSubmit' }, +]; + +${step9ModuleSource} + +const executor = self.MultiPageBackgroundStep9.createStep9Executor({ + addLog, + chrome, + cleanupStep8NavigationListeners, + clickWithDebugger, + completeStepFromBackground, + ensureStep8SignupPageReady, + getOAuthFlowRemainingMs, + getOAuthFlowStepTimeoutMs, + getStep8CallbackUrlFromNavigation, + getStep8CallbackUrlFromTabUpdate, + getStep8EffectLabel, + getTabId, + getWebNavCommittedListener, + getWebNavListener, + getStep8TabUpdatedListener, + isTabAlive, + prepareStep8DebuggerClick, + recoverOAuthLocalhostTimeout, + reloadStep8ConsentPage, + reuseOrCreateTab, + setStep8PendingReject, + setStep8TabUpdatedListener, + setWebNavCommittedListener, + setWebNavListener, + sleepWithStop, + STEP8_CLICK_RETRY_DELAY_MS, + STEP8_MAX_ROUNDS, + STEP8_READY_WAIT_TIMEOUT_MS, + STEP8_STRATEGIES, + throwIfStep8SettledOrStopped, + triggerStep8ContentStrategy, + waitForStep8ClickEffect, + waitForStep8Ready, +}); + +return { + executeStep9: executor.executeStep9, + snapshot() { + return { + cleanupCalls, + remainingCalls, + recoveryCalls, + completePayload, + hasPendingReject: Boolean(step8PendingReject), + }; + }, +}; +`)(step9ModuleSource); + + await api.executeStep9({ + oauthUrl: 'https://auth.openai.com/original-oauth', + visibleStep: 9, + }); + + const snapshot = api.snapshot(); + assert.equal(snapshot.recoveryCalls, 0); + assert.equal(snapshot.remainingCalls >= 1, true); + assert.equal(snapshot.cleanupCalls >= 1, true); + assert.equal(snapshot.hasPendingReject, false); + assert.deepEqual(snapshot.completePayload, { + step: 9, + payload: { + localhostUrl: 'http://localhost:1455/auth/callback?code=abc&state=xyz', + }, + }); +}); diff --git a/tests/step9-timeout-recovery.test.js b/tests/step9-timeout-recovery.test.js index eb4606d..e623472 100644 --- a/tests/step9-timeout-recovery.test.js +++ b/tests/step9-timeout-recovery.test.js @@ -11,6 +11,7 @@ let step8TabUpdatedListener = null; let step8PendingReject = null; let cleanupCalls = 0; let timeoutCalls = 0; +let remainingCalls = 0; let recoveryCalls = 0; let completePayload = null; const logs = []; @@ -25,7 +26,7 @@ const chrome = { if (typeof webNavListener === 'function') { webNavListener({ tabId: 123, url: callbackUrl }); } - }, 0); + }, 25); }, removeListener() {}, }, @@ -107,6 +108,11 @@ async function recoverOAuthLocalhostTimeout(details = {}) { }; } +async function getOAuthFlowRemainingMs() { + remainingCalls += 1; + return null; +} + function getStep8CallbackUrlFromNavigation(details, signupTabId) { if ( Number(signupTabId) === Number(details?.tabId) @@ -163,6 +169,7 @@ const executor = self.MultiPageBackgroundStep9.createStep9Executor({ clickWithDebugger, completeStepFromBackground, ensureStep8SignupPageReady, + getOAuthFlowRemainingMs, getOAuthFlowStepTimeoutMs, getStep8CallbackUrlFromNavigation, getStep8CallbackUrlFromTabUpdate, @@ -197,6 +204,7 @@ return { return { cleanupCalls, timeoutCalls, + remainingCalls, recoveryCalls, completePayload, hasPendingReject: Boolean(step8PendingReject), diff --git a/项目完整链路说明.md b/项目完整链路说明.md index 96bfe81..0a236a1 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -171,6 +171,7 @@ - iCloud 相关偏好 - LuckMail API 配置 - 自动运行默认配置 +- OAuth 授权后链总超时开关 `oauthFlowTimeoutEnabled`:默认开启;关闭后会立即清空已存在的 OAuth 总预算 deadline,仅保留各步骤本地等待超时 - 账号运行历史 `accountRunHistory`(以邮箱为主键,保存该邮箱最近一次状态:成功/失败/停止) - 账号运行历史本地同步 helper 地址 @@ -455,6 +456,7 @@ IP 代理模块在同步、切换、Change、出口探测和自动运行成功 - HeroSMS 号码当前最多复用 3 次成功注册;超过上限后会清空可复用激活记录,下次重新申请新号码。 - 如果同一个号码在重发短信后 60 秒仍收不到验证码,后台会抛出“回到步骤 7 重新拿新号码”的恢复错误,而不是把当前号码无限重试下去。 +- Step 9 在等待 localhost callback 时会动态读取 `oauthFlowTimeoutEnabled`:开启时继续受 Step 7 后链总预算约束;关闭时仅保留本地 240 秒回调等待上限,不再因 5 分钟总预算触发回跳 Step 7。 ### Step 10 diff --git a/项目文件结构说明.md b/项目文件结构说明.md index 843afca..95f02ed 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -21,7 +21,7 @@ - `.gitignore`:定义仓库忽略规则,当前忽略 `docs/md/`、`.github/`、`_metadata/`、`.vscode/` 等目录。 - `LICENSE`:项目许可证文件。 - `README.md`:面向使用者的项目介绍、安装说明、能力清单与操作指引。 -- `background.js`:扩展后台 Service Worker 入口壳,负责模块装配、初始化、全局常量、少量保留的领域函数与运行入口。 +- `background.js`:扩展后台 Service Worker 入口壳,负责模块装配、初始化、全局常量、少量保留的领域函数与运行入口;当前新增 `oauthFlowTimeoutEnabled` 持久化配置,并统一承接 OAuth 授权后链总预算开关与剩余预算计算。 - `cloudflare-temp-email-utils.js`:Cloudflare Temp Email 相关的纯工具函数,负责 URL、域名、邮件内容与 MIME 数据归一化。 - `hotmail-utils.js`:Hotmail 账号与验证码提取相关的纯工具函数,负责账号筛选、验证码匹配、第三方接口数据归一化。 - `icloud-utils.js`:iCloud 隐私邮箱相关的纯工具函数,负责 host、别名列表、保留状态、已用状态等归一化。 @@ -51,7 +51,7 @@ - `background/ip-proxy-provider-711proxy.js`:711Proxy provider 规则模块,负责从账号串中识别和写回 `region / session / sessTime` 等参数,并在固定账号模式下把侧栏配置转换为最终生效的代理账号。 - `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层;当前额外承接 `add-phone / 手机号页` 这类认证 fatal 错误的共享判定,并会把 Step 2 的“手机号输入模式未切成功”与真正的 auth `add-phone` 页面区分开,避免自动运行误停机。 - `background/mail-2925-session.js`:2925 会话模块,负责 2925 账号池持久化、当前账号切换、cookie 清理登出、自动登录、命中“子邮箱已达上限邮箱”后的 24 小时禁用与自动切号。 -- `background/message-router.js`:后台消息路由层,负责处理 `chrome.runtime.onMessage` 进入的所有业务消息;当前额外接入 PayPal 账号池的新增与切换、2925 账号池的新增、导入、切换、登录、禁用与删除消息,以及 IP 代理的同步、切换、Change 与出口检测消息。 +- `background/message-router.js`:后台消息路由层,负责处理 `chrome.runtime.onMessage` 进入的所有业务消息;当前额外接入 PayPal 账号池的新增与切换、2925 账号池的新增、导入、切换、登录、禁用与删除消息,以及 IP 代理的同步、切换、Change 与出口检测消息;当侧栏关闭 `oauthFlowTimeoutEnabled` 时,会立即清空已存在的 OAuth 总预算 deadline。 - `background/navigation-utils.js`:导航与 URL 判断工具层,负责 callback、入口页、CPA / SUB2API / Codex2API 地址归一化、来源标签页家族判断与步骤跳转相关判断。 - `background/paypal-account-store.js`:PayPal 账号池持久化模块,负责保存账号列表、切换当前账号,并把当前选中账号同步回兼容字段 `paypalEmail / paypalPassword`。 - `background/phone-verification-flow.js`:手机号验证共享流程模块,负责在 OAuth 链路命中 `add-phone / phone-verification` 页面后向 HeroSMS 申请或复用号码、轮询短信验证码、提交手机号码与短信验证码,并在号码长期收不到短信时把后续自动流拉回步骤 7 重新拿号。 @@ -63,7 +63,7 @@ ## `background/steps/` - `background/steps/clear-login-cookies.js`:步骤 6 实现,负责登录前 Cookie 清理。 -- `background/steps/confirm-oauth.js`:步骤 9 实现,负责 OAuth 同意页按钮定位、点击、localhost 回调监听与回调完成。 +- `background/steps/confirm-oauth.js`:步骤 9 实现,负责 OAuth 同意页按钮定位、点击、localhost 回调监听与回调完成;等待 callback 期间会动态检查 OAuth 总预算开关,关闭后仅保留本地回调等待超时。 - `background/steps/create-plus-checkout.js`:Plus 模式第 6 步实现,负责在已登录 ChatGPT 页面创建 Plus checkout session,打开 `chatgpt.com/checkout/openai_ie/{checkout_session_id}` 短链,并记录 checkout 运行态。 - `background/steps/fetch-login-code.js`:步骤 8 实现,负责登录验证码阶段的邮箱轮询、验证码回填与回退控制;验证码获取后直接提交,不再在提交前回放步骤 7 刷新 OAuth;对非 2925 provider 会固定当前验证码页显示邮箱作为本次 Step 8 的目标邮箱;当 provider 为 2925 时,会在轮询前先确保当前 2925 账号已自动登录。 - `background/steps/fetch-signup-code.js`:步骤 4 实现,负责注册验证码阶段的页面准备与验证码流程入口;当 provider 为 2925 时,会在轮询前先确保当前 2925 账号已自动登录。 @@ -146,7 +146,7 @@ receive 模式把账号池开关一起隐藏;当前在 `邮箱生成` 区域新增 `自定义邮箱池` 选项和多行邮箱池输入框,并在 `邮箱服务 = 自定义邮箱` 时 额外显示 `自定义号池` 文本框;当邮箱服务为 iCloud 时,额外提供目标邮箱类型与转发邮箱 provider 配置,用于选择直接从 iCloud 收件箱收 码或从 QQ / 网易 / Gmail 转发目标邮箱收码;来源下拉框当前支持 `CPA / SUB2API / Codex2API`,其中 Codex2API 额外提供后台地址和管理密 - 钥配置行;设置卡片新增 `IP代理` 开关与代理配置折叠区,支持 711Proxy 账号密码模式、代理状态卡与出口检测按钮;设置卡片新增 `Plus 模式` 开关与 PayPal 账号下拉框,右侧使用公共表单弹窗添加账号;Hotmail / 2925 两个账号池当前都使用统一的头部“添加账号/取消添加”按钮和共享表单容器。 + 钥配置行;设置卡片新增 `IP代理` 开关与代理配置折叠区,支持 711Proxy 账号密码模式、代理状态卡与出口检测按钮;设置卡片新增 `Plus 模式` 开关与 PayPal 账号下拉框,右侧使用公共表单弹窗添加账号;Hotmail / 2925 两个账号池当前都使用统一的头部“添加账号/取消添加”按钮和共享表单容器;设置卡片还新增“授权总超时”开关,用于控制 Step 7 后链 5 分钟总预算。 - `sidepanel/paypal-manager.js`:侧边栏 PayPal 账号管理器,负责 Plus 模式下的账号下拉框渲染、添加账号弹窗、保存账号与切换当前账号。 - `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、共享验证码自动重发次数配置与广播接收,并装 配 Hotmail / 2925 / iCloud / LuckMail / 贡献模式 / 邮箱记录面板 / 贡献内容更新服务;当前贡献模式的“开始贡献”会直接复用主自动流程启 @@ -161,7 +161,7 @@ 会在 sidepanel 初始化时拉取并恢复到本地保存的国家/API 设置,接码开关关闭时会直接隐藏相关配置;账号记录快照同步改为默认自动模式,只 要本地 helper 可用就会自动落盘;新来源 Codex2API 在这里仅补充来源配置接线、表单显隐和 Step 10 按钮文案,不承接协议业务逻辑; Hotmail / 2925 的新增表单显隐统一接到 `sidepanel/account-pool-ui.js` 这一层共享 helper;IP 代理配置保存、服务配置快照、开关应用和运行态回显由这里接入消息路由,具体解析/状态渲染下沉到 `sidepanel/ip-proxy-panel.js`;侧边栏初始化与点击“自动”前会刷新一次贡献站 - 公开内容摘要,并按本地关闭版本决定是否展示轻提示,同时在首次初始化后按现有规则决定是否弹出新手引导提示。 + 公开内容摘要,并按本地关闭版本决定是否展示轻提示,同时在首次初始化后按现有规则决定是否弹出新手引导提示;当前会保存、回显并热更新 `oauthFlowTimeoutEnabled` 设置。 - `sidepanel/update-service.js`:侧边栏更新检查服务,负责 GitHub Releases 查询、`Ultra` / 历史 `Pro` / legacy `v` 版本族排序、缓存读取与版本展示。 ## `tests/`