diff --git a/background.js b/background.js index bfbbbb4..dfd2bee 100644 --- a/background.js +++ b/background.js @@ -136,6 +136,7 @@ const HUMAN_STEP_DELAY_MIN = 700; const HUMAN_STEP_DELAY_MAX = 2200; const STEP6_MAX_ATTEMPTS = 3; const STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS = 8; +const OAUTH_FLOW_TIMEOUT_MS = 6 * 60 * 1000; const SUB2API_STEP1_RESPONSE_TIMEOUT_MS = 90000; const SUB2API_STEP9_RESPONSE_TIMEOUT_MS = 120000; const DEFAULT_SUB2API_URL = 'https://sub2api.hisence.fun/admin/accounts'; @@ -315,6 +316,7 @@ const DEFAULT_STATE = { autoRunCountdownNote: '', signupVerificationRequestedAt: null, loginVerificationRequestedAt: null, + oauthFlowDeadlineAt: null, currentHotmailAccountId: null, preferredIcloudHost: '', }; @@ -3879,6 +3881,7 @@ function getDownstreamStateResets(step) { lastEmailTimestamp: null, signupVerificationRequestedAt: null, loginVerificationRequestedAt: null, + oauthFlowDeadlineAt: null, lastSignupCode: null, lastLoginCode: null, localhostUrl: null, @@ -3890,6 +3893,7 @@ function getDownstreamStateResets(step) { lastEmailTimestamp: null, signupVerificationRequestedAt: null, loginVerificationRequestedAt: null, + oauthFlowDeadlineAt: null, lastSignupCode: null, lastLoginCode: null, localhostUrl: null, @@ -3900,6 +3904,7 @@ function getDownstreamStateResets(step) { lastEmailTimestamp: null, signupVerificationRequestedAt: null, loginVerificationRequestedAt: null, + oauthFlowDeadlineAt: null, lastSignupCode: null, lastLoginCode: null, localhostUrl: null, @@ -3909,6 +3914,7 @@ function getDownstreamStateResets(step) { return { lastLoginCode: null, loginVerificationRequestedAt: null, + oauthFlowDeadlineAt: null, localhostUrl: null, }; } @@ -4569,7 +4575,10 @@ async function handleStepData(step, payload) { if (!isLocalhostOAuthCallbackUrl(payload.localhostUrl)) { throw new Error('步骤 9 返回了无效的 localhost OAuth 回调地址。'); } - await setState({ localhostUrl: payload.localhostUrl }); + await setState({ + localhostUrl: payload.localhostUrl, + oauthFlowDeadlineAt: null, + }); broadcastDataUpdate({ localhostUrl: payload.localhostUrl }); } break; @@ -5689,6 +5698,7 @@ const step7Executor = self.MultiPageBackgroundStep7?.createStep7Executor({ completeStepFromBackground, getErrorMessage, getLoginAuthStateLabel, + getOAuthFlowStepTimeoutMs, getState, isStep6RecoverableResult, isStep6SuccessResult, @@ -5697,6 +5707,7 @@ const step7Executor = self.MultiPageBackgroundStep7?.createStep7Executor({ sendToContentScriptResilient, shouldSkipLoginVerificationForCpaCallback, skipLoginVerificationStepsForCpaCallback, + startOAuthFlowTimeoutWindow, STEP6_MAX_ATTEMPTS, throwIfStopped, }); @@ -5707,6 +5718,8 @@ const step8Executor = self.MultiPageBackgroundStep8?.createStep8Executor({ confirmCustomVerificationStepBypass: verificationFlowHelpers.confirmCustomVerificationStepBypass, ensureStep8VerificationPageReady, executeStep7: (...args) => executeStep7(...args), + getOAuthFlowRemainingMs, + getOAuthFlowStepTimeoutMs, getPanelMode, getMailConfig, getState, @@ -6180,6 +6193,64 @@ async function refreshOAuthUrlBeforeStep6(state) { return refreshResult.oauthUrl; } +function buildOAuthFlowTimeoutError(step, actionLabel = '后续授权流程') { + return new Error( + `步骤 ${step}:从拿到 OAuth 登录地址开始,${Math.round(OAUTH_FLOW_TIMEOUT_MS / 60000)} 分钟内未完成${actionLabel},结束当前链路,准备从步骤 7 重新开始。` + ); +} + +function normalizeOAuthFlowDeadlineAt(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) { + return null; + } + return Math.floor(numeric); +} + +async function startOAuthFlowTimeoutWindow(options = {}) { + const step = Number(options.step) || 7; + const deadlineAt = Date.now() + OAUTH_FLOW_TIMEOUT_MS; + await setState({ oauthFlowDeadlineAt: deadlineAt }); + await addLog(`步骤 ${step}:已拿到新的 OAuth 登录地址,开始 6 分钟倒计时。`, 'info'); + return deadlineAt; +} + +async function getOAuthFlowRemainingMs(options = {}) { + const step = Number(options.step) || 7; + const actionLabel = String(options.actionLabel || '后续授权流程').trim() || '后续授权流程'; + const state = options.state || await getState(); + const deadlineAt = normalizeOAuthFlowDeadlineAt(state?.oauthFlowDeadlineAt); + if (!deadlineAt) { + return null; + } + + const remainingMs = deadlineAt - Date.now(); + if (remainingMs <= 0) { + throw buildOAuthFlowTimeoutError(step, actionLabel); + } + + return remainingMs; +} + +async function getOAuthFlowStepTimeoutMs(defaultTimeoutMs, options = {}) { + const normalizedDefault = Math.max(1000, Number(defaultTimeoutMs) || 1000); + const reserveMs = Math.max(0, Number(options.reserveMs) || 0); + const remainingMs = await getOAuthFlowRemainingMs(options); + if (remainingMs === null) { + return normalizedDefault; + } + + const budgetMs = remainingMs - reserveMs; + if (budgetMs <= 0) { + throw buildOAuthFlowTimeoutError( + Number(options.step) || 7, + String(options.actionLabel || '后续授权流程').trim() || '后续授权流程' + ); + } + + return Math.max(1000, Math.min(normalizedDefault, budgetMs)); +} + function isStep6SuccessResult(result) { return result?.step6Outcome === 'success'; } @@ -6259,8 +6330,9 @@ async function getLoginAuthStateFromContent(options = {}) { payload: {}, }, { - timeoutMs: 15000, - retryDelayMs: 600, + timeoutMs: options.timeoutMs ?? 15000, + retryDelayMs: options.retryDelayMs ?? 600, + responseTimeoutMs: options.responseTimeoutMs ?? (options.timeoutMs ?? 15000), logMessage, } ); @@ -6272,8 +6344,8 @@ async function getLoginAuthStateFromContent(options = {}) { return result || {}; } -async function ensureStep8VerificationPageReady() { - const pageState = await getLoginAuthStateFromContent(); +async function ensureStep8VerificationPageReady(options = {}) { + const pageState = await getLoginAuthStateFromContent(options); if (pageState.state === 'verification_page') { return pageState; } @@ -6287,6 +6359,7 @@ async function skipLoginVerificationStepsForCpaCallback() { await setState({ lastLoginCode: null, loginVerificationRequestedAt: null, + oauthFlowDeadlineAt: null, }); await setStepStatus(7, 'skipped'); await addLog('步骤 7:当前已选择“第七步回调”,直接跳过步骤 7、8。', 'warn'); @@ -6438,7 +6511,7 @@ async function waitForStep8Ready(tabId, timeoutMs = STEP8_READY_WAIT_TIMEOUT_MS) flow: 'auth', logLabel: '步骤 9:检测到认证页重试页,正在点击“重试”恢复', step: 8, - timeoutMs: Math.max(5000, Math.min(12000, timeoutMs)), + timeoutMs: Math.max(1000, Math.min(12000, timeoutMs)), }); retryRecovered = true; await sleepWithStop(250); @@ -6465,9 +6538,11 @@ async function waitForStep8Ready(tabId, timeoutMs = STEP8_READY_WAIT_TIMEOUT_MS) throw new Error('步骤 9:长时间未进入 OAuth 同意页,无法定位“继续”按钮。'); } -async function prepareStep8DebuggerClick(tabId) { +async function prepareStep8DebuggerClick(tabId, options = {}) { + const timeoutMs = options.timeoutMs ?? 15000; + const responseTimeoutMs = options.responseTimeoutMs ?? timeoutMs; await ensureStep8SignupPageReady(tabId, { - timeoutMs: 15000, + timeoutMs, logMessage: '步骤 9:认证页内容脚本已失联,正在恢复后继续定位按钮...', }); const result = await sendToContentScriptResilient('signup-page', { @@ -6475,7 +6550,8 @@ async function prepareStep8DebuggerClick(tabId) { source: 'background', payload: {}, }, { - timeoutMs: 15000, + timeoutMs, + responseTimeoutMs, retryDelayMs: 600, logMessage: '步骤 9:认证页正在切换,等待 OAuth 同意页按钮重新就绪...', }); @@ -6487,9 +6563,11 @@ async function prepareStep8DebuggerClick(tabId) { return result; } -async function triggerStep8ContentStrategy(tabId, strategy) { +async function triggerStep8ContentStrategy(tabId, strategy, options = {}) { + const timeoutMs = options.timeoutMs ?? 15000; + const responseTimeoutMs = options.responseTimeoutMs ?? timeoutMs; await ensureStep8SignupPageReady(tabId, { - timeoutMs: 15000, + timeoutMs, logMessage: '步骤 9:认证页内容脚本已失联,正在恢复后继续点击“继续”按钮...', }); const result = await sendToContentScriptResilient('signup-page', { @@ -6501,7 +6579,8 @@ async function triggerStep8ContentStrategy(tabId, strategy) { enabledTimeoutMs: 3000, }, }, { - timeoutMs: 15000, + timeoutMs, + responseTimeoutMs, retryDelayMs: 600, logMessage: '步骤 9:认证页正在切换,等待“继续”按钮重新就绪...', }); @@ -6514,8 +6593,11 @@ async function triggerStep8ContentStrategy(tabId, strategy) { } async function recoverAuthRetryPageOnTab(tabId, payload = {}, options = {}) { + const readyTimeoutMs = options.readyTimeoutMs ?? 15000; + const timeoutMs = options.timeoutMs ?? 15000; + const responseTimeoutMs = options.responseTimeoutMs ?? timeoutMs; await ensureStep8SignupPageReady(tabId, { - timeoutMs: options.readyTimeoutMs ?? 15000, + timeoutMs: readyTimeoutMs, retryDelayMs: options.retryDelayMs ?? 600, logMessage: options.readyLogMessage || '步骤 9:认证页内容脚本已失联,正在恢复后继续处理重试页...', }); @@ -6524,7 +6606,8 @@ async function recoverAuthRetryPageOnTab(tabId, payload = {}, options = {}) { source: 'background', payload, }, { - timeoutMs: options.timeoutMs ?? 15000, + timeoutMs, + responseTimeoutMs, retryDelayMs: options.retryDelayMs ?? 600, logMessage: options.logMessage || '步骤 9:认证页正在切换,等待“重试”按钮重新就绪...', }); @@ -6603,7 +6686,7 @@ async function waitForStep8ClickEffect(tabId, baselineUrl, timeoutMs = STEP8_CLI flow: 'auth', logLabel: '步骤 9:点击“继续”后进入重试页,正在点击“重试”恢复', step: 8, - timeoutMs: Math.max(5000, Math.min(12000, timeoutMs)), + timeoutMs: Math.max(1000, Math.min(12000, timeoutMs)), }); return { progressed: false, @@ -6616,7 +6699,7 @@ async function waitForStep8ClickEffect(tabId, baselineUrl, timeoutMs = STEP8_CLI if (!recovered) { recovered = true; await ensureStep8SignupPageReady(tabId, { - timeoutMs: Math.max(3000, Math.min(8000, timeoutMs)), + timeoutMs: Math.max(1000, Math.min(8000, timeoutMs)), logMessage: '步骤 9:点击后认证页正在重载,正在等待内容脚本重新就绪...', }).catch(() => null); continue; @@ -6662,6 +6745,7 @@ const step9Executor = self.MultiPageBackgroundStep9?.createStep9Executor({ clickWithDebugger, completeStepFromBackground, ensureStep8SignupPageReady, + getOAuthFlowStepTimeoutMs, getStep8CallbackUrlFromNavigation, getStep8CallbackUrlFromTabUpdate, getStep8EffectLabel, diff --git a/background/steps/confirm-oauth.js b/background/steps/confirm-oauth.js index b43a937..1c9076c 100644 --- a/background/steps/confirm-oauth.js +++ b/background/steps/confirm-oauth.js @@ -9,6 +9,7 @@ clickWithDebugger, completeStepFromBackground, ensureStep8SignupPageReady, + getOAuthFlowStepTimeoutMs, getStep8CallbackUrlFromNavigation, getStep8CallbackUrlFromTabUpdate, getStep8EffectLabel, @@ -39,6 +40,13 @@ await addLog('步骤 9:正在监听 localhost 回调地址...'); + const callbackTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' + ? await getOAuthFlowStepTimeoutMs(120000, { + step: 9, + actionLabel: 'OAuth localhost 回调', + }) + : 120000; + return new Promise((resolve, reject) => { let resolved = false; let signupTabId = null; @@ -74,7 +82,7 @@ const timeout = setTimeout(() => { rejectStep9(new Error('120 秒内未捕获到 localhost 回调跳转,步骤 9 的点击可能被拦截了。')); - }, 120000); + }, callbackTimeoutMs); setStep8PendingReject((error) => { rejectStep9(error); @@ -114,13 +122,26 @@ chrome.webNavigation.onCommitted.addListener(deps.getWebNavCommittedListener()); chrome.tabs.onUpdated.addListener(deps.getStep8TabUpdatedListener()); await ensureStep8SignupPageReady(signupTabId, { - timeoutMs: 15000, + timeoutMs: typeof getOAuthFlowStepTimeoutMs === 'function' + ? await getOAuthFlowStepTimeoutMs(15000, { + step: 9, + actionLabel: '等待 OAuth 同意页内容脚本就绪', + }) + : 15000, logMessage: '步骤 9:认证页内容脚本尚未就绪,正在等待页面恢复...', }); for (let round = 1; round <= STEP8_MAX_ROUNDS && !resolved; round++) { throwIfStep8SettledOrStopped(resolved); - const pageState = await waitForStep8Ready(signupTabId, STEP8_READY_WAIT_TIMEOUT_MS); + const pageState = await waitForStep8Ready( + signupTabId, + typeof getOAuthFlowStepTimeoutMs === 'function' + ? await getOAuthFlowStepTimeoutMs(STEP8_READY_WAIT_TIMEOUT_MS, { + step: 9, + actionLabel: '等待 OAuth 同意页出现', + }) + : STEP8_READY_WAIT_TIMEOUT_MS + ); if (!pageState?.consentReady) { await sleepWithStop(STEP8_CLICK_RETRY_DELAY_MS); continue; @@ -131,18 +152,45 @@ await addLog(`步骤 9:第 ${round}/${STEP8_MAX_ROUNDS} 轮尝试点击“继续”(${strategy.label})...`); if (strategy.mode === 'debugger') { - const clickTarget = await prepareStep8DebuggerClick(signupTabId); + const clickActionTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' + ? await getOAuthFlowStepTimeoutMs(15000, { + step: 9, + actionLabel: '定位 OAuth 同意页继续按钮', + }) + : 15000; + const clickTarget = await prepareStep8DebuggerClick(signupTabId, { + timeoutMs: clickActionTimeoutMs, + responseTimeoutMs: clickActionTimeoutMs, + }); throwIfStep8SettledOrStopped(resolved); await clickWithDebugger(signupTabId, clickTarget?.rect); } else { - await triggerStep8ContentStrategy(signupTabId, strategy.strategy); + const clickActionTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' + ? await getOAuthFlowStepTimeoutMs(15000, { + step: 9, + actionLabel: '点击 OAuth 同意页继续按钮', + }) + : 15000; + await triggerStep8ContentStrategy(signupTabId, strategy.strategy, { + timeoutMs: clickActionTimeoutMs, + responseTimeoutMs: clickActionTimeoutMs, + }); } if (resolved) { return; } - const effect = await waitForStep8ClickEffect(signupTabId, pageState.url); + const effect = await waitForStep8ClickEffect( + signupTabId, + pageState.url, + typeof getOAuthFlowStepTimeoutMs === 'function' + ? await getOAuthFlowStepTimeoutMs(15000, { + step: 9, + actionLabel: '等待 OAuth 同意页点击生效', + }) + : 15000 + ); if (resolved) { return; } @@ -163,7 +211,15 @@ } await addLog(`步骤 9:${strategy.label} 本轮点击后页面无反应,正在刷新认证页后重试(下一轮 ${round + 1}/${STEP8_MAX_ROUNDS})...`, 'warn'); - await reloadStep8ConsentPage(signupTabId); + await reloadStep8ConsentPage( + signupTabId, + typeof getOAuthFlowStepTimeoutMs === 'function' + ? await getOAuthFlowStepTimeoutMs(30000, { + step: 9, + actionLabel: '刷新 OAuth 同意页', + }) + : 30000 + ); await sleepWithStop(STEP8_CLICK_RETRY_DELAY_MS); } } catch (err) { diff --git a/background/steps/fetch-login-code.js b/background/steps/fetch-login-code.js index beb4f60..1a33954 100644 --- a/background/steps/fetch-login-code.js +++ b/background/steps/fetch-login-code.js @@ -9,6 +9,8 @@ confirmCustomVerificationStepBypass, ensureStep8VerificationPageReady, executeStep7, + getOAuthFlowRemainingMs, + getOAuthFlowStepTimeoutMs, getPanelMode, getMailConfig, getState, @@ -29,6 +31,28 @@ throwIfStopped, } = deps; + async function getStep8ReadyTimeoutMs(actionLabel) { + if (typeof getOAuthFlowStepTimeoutMs !== 'function') { + return 15000; + } + + return getOAuthFlowStepTimeoutMs(15000, { + step: 8, + actionLabel, + }); + } + + function getStep8RemainingTimeResolver() { + if (typeof getOAuthFlowRemainingMs !== 'function') { + return undefined; + } + + return async (details = {}) => getOAuthFlowRemainingMs({ + step: 8, + actionLabel: details.actionLabel || '登录验证码流程', + }); + } + async function runStep8Attempt(state) { const mail = getMailConfig(state); if (mail.error) throw new Error(mail.error); @@ -45,7 +69,9 @@ } throwIfStopped(); - await ensureStep8VerificationPageReady(); + await ensureStep8VerificationPageReady({ + timeoutMs: await getStep8ReadyTimeoutMs('确认登录验证码页已就绪'), + }); await addLog('步骤 8:登录验证码页面已就绪,开始获取验证码。', 'info'); if (shouldUseCustomRegistrationEmail(state)) { @@ -82,7 +108,8 @@ let step7ReplayCompleted = false; await resolveVerificationStep(8, state, mail, { - filterAfterTimestamp: mail.provider === HOTMAIL_PROVIDER ? undefined : Math.max(0, stepStartedAt - 60000), + filterAfterTimestamp: stepStartedAt, + getRemainingTimeMs: getStep8RemainingTimeResolver(), requestFreshCodeFirst: false, resendIntervalMs: (mail.provider === HOTMAIL_PROVIDER || mail.provider === '2925') ? 0 @@ -98,7 +125,9 @@ logMessage: '步骤 8:正在重新获取最新 CPA OAuth 链接,并快速重走步骤 7...', postStepDelayMs: 1200, }); - await ensureStep8VerificationPageReady(); + await ensureStep8VerificationPageReady({ + timeoutMs: await getStep8ReadyTimeoutMs('重放步骤 7 后重新确认登录验证码页'), + }); await addLog('步骤 8:登录验证码页面已重新就绪,开始回填刚才获取到的验证码。', 'info'); } : undefined, }); @@ -122,6 +151,7 @@ await setState({ lastLoginCode: null, loginVerificationRequestedAt: null, + oauthFlowDeadlineAt: null, }); await setStepStatus(8, 'skipped'); await addLog('步骤 8:当前已选择“第七步回调”,本轮无需获取登录验证码。', 'warn'); diff --git a/background/steps/fetch-signup-code.js b/background/steps/fetch-signup-code.js index 6a04aa7..7484c1e 100644 --- a/background/steps/fetch-signup-code.js +++ b/background/steps/fetch-signup-code.js @@ -87,7 +87,7 @@ } await resolveVerificationStep(4, state, mail, { - filterAfterTimestamp: mail.provider === HOTMAIL_PROVIDER ? undefined : stepStartedAt, + filterAfterTimestamp: stepStartedAt, requestFreshCodeFirst: mail.provider === HOTMAIL_PROVIDER ? false : true, resendIntervalMs: (mail.provider === HOTMAIL_PROVIDER || mail.provider === '2925') ? 0 diff --git a/background/steps/oauth-login.js b/background/steps/oauth-login.js index 6e49e92..69044c0 100644 --- a/background/steps/oauth-login.js +++ b/background/steps/oauth-login.js @@ -7,6 +7,7 @@ completeStepFromBackground, getErrorMessage, getLoginAuthStateLabel, + getOAuthFlowStepTimeoutMs, getState, isStep6RecoverableResult, isStep6SuccessResult, @@ -15,6 +16,7 @@ sendToContentScriptResilient, shouldSkipLoginVerificationForCpaCallback, skipLoginVerificationStepsForCpaCallback, + startOAuthFlowTimeoutWindow, STEP6_MAX_ATTEMPTS, throwIfStopped, } = deps; @@ -38,6 +40,15 @@ const currentState = attempt === 1 ? state : await getState(); const password = currentState.password || currentState.customPassword || ''; const oauthUrl = await refreshOAuthUrlBeforeStep6(currentState); + if (typeof startOAuthFlowTimeoutWindow === 'function') { + await startOAuthFlowTimeoutWindow({ step: 7, oauthUrl }); + } + const loginTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' + ? await getOAuthFlowStepTimeoutMs(180000, { + step: 7, + actionLabel: 'OAuth 登录并进入验证码页', + }) + : 180000; if (attempt === 1) { await addLog('步骤 7:正在打开最新 OAuth 链接并登录...'); @@ -59,7 +70,8 @@ }, }, { - timeoutMs: 180000, + timeoutMs: loginTimeoutMs, + responseTimeoutMs: loginTimeoutMs, retryDelayMs: 700, logMessage: '步骤 7:认证页正在切换,等待页面重新就绪后继续登录...', } diff --git a/background/tab-runtime.js b/background/tab-runtime.js index 4153cc6..ea1650c 100644 --- a/background/tab-runtime.js +++ b/background/tab-runtime.js @@ -547,7 +547,12 @@ } async function sendToContentScriptResilient(source, message, options = {}) { - const { timeoutMs = 30000, retryDelayMs = 600, logMessage = '' } = options; + const { + timeoutMs = 30000, + retryDelayMs = 600, + logMessage = '', + responseTimeoutMs, + } = options; const start = Date.now(); let lastError = null; let logged = false; @@ -558,7 +563,11 @@ attempt += 1; try { - return await sendToContentScript(source, message); + return await sendToContentScript( + source, + message, + responseTimeoutMs !== undefined ? { responseTimeoutMs } : {} + ); } catch (err) { const retryable = isRetryableContentScriptTransportError(err); if (!retryable) { @@ -579,7 +588,11 @@ } async function sendToMailContentScriptResilient(mail, message, options = {}) { - const { timeoutMs = 45000, maxRecoveryAttempts = 2 } = options; + const { + timeoutMs = 45000, + maxRecoveryAttempts = 2, + responseTimeoutMs, + } = options; const start = Date.now(); let lastError = null; let recoveries = 0; @@ -589,7 +602,11 @@ throwIfStopped(); try { - return await sendToContentScript(mail.source, message); + return await sendToContentScript( + mail.source, + message, + responseTimeoutMs !== undefined ? { responseTimeoutMs } : {} + ); } catch (err) { if (!isRetryableContentScriptTransportError(err)) { throw err; diff --git a/background/verification-flow.js b/background/verification-flow.js index 91b912a..61b8d0a 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -134,7 +134,58 @@ }; } - async function requestVerificationCodeResend(step) { + async function getRemainingTimeBudgetMs(step, options = {}, actionLabel = '') { + const resolver = typeof options.getRemainingTimeMs === 'function' + ? options.getRemainingTimeMs + : null; + if (!resolver) { + return null; + } + + const value = await resolver({ step, actionLabel }); + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return null; + } + + return Math.max(0, Math.floor(numeric)); + } + + async function getResponseTimeoutMsForStep(step, options = {}, fallbackMs = 30000, actionLabel = '') { + const remainingMs = await getRemainingTimeBudgetMs(step, options, actionLabel); + if (remainingMs === null) { + return Math.max(1000, Number(fallbackMs) || 1000); + } + + return Math.max(1000, Math.min(Math.max(1000, Number(fallbackMs) || 1000), remainingMs)); + } + + async function applyMailPollingTimeBudget(step, payload, options = {}, actionLabel = '') { + const nextPayload = { ...payload }; + const intervalMs = Math.max(1, Number(nextPayload.intervalMs) || 3000); + const baseMaxAttempts = Math.max(1, Number(nextPayload.maxAttempts) || 1); + const remainingMs = await getRemainingTimeBudgetMs(step, options, actionLabel); + + if (remainingMs !== null) { + nextPayload.maxAttempts = Math.max( + 1, + Math.min(baseMaxAttempts, Math.floor(Math.max(0, remainingMs - 1000) / intervalMs) + 1) + ); + } + + const defaultResponseTimeoutMs = Math.max(45000, nextPayload.maxAttempts * intervalMs + 25000); + const responseTimeoutMs = remainingMs === null + ? defaultResponseTimeoutMs + : Math.max(1000, Math.min(defaultResponseTimeoutMs, remainingMs)); + + return { + payload: nextPayload, + responseTimeoutMs, + timeoutMs: responseTimeoutMs, + }; + } + + async function requestVerificationCodeResend(step, options = {}) { throwIfStopped(); const signupTabId = await getTabId('signup-page'); if (!signupTabId) { @@ -150,6 +201,13 @@ step, source: 'background', payload: {}, + }, { + responseTimeoutMs: await getResponseTimeoutMsForStep( + step, + options, + 30000, + `重新发送${getVerificationCodeLabel(step)}验证码` + ), }); if (result && result.error) { @@ -211,7 +269,7 @@ for (let round = 1; round <= totalRounds; round++) { throwIfStopped(); if (round > 1) { - lastResendAt = await requestVerificationCodeResend(step); + lastResendAt = await requestVerificationCodeResend(step, pollOverrides); usedResendRequests += 1; if (onResendRequestedAt) { const nextFilterAfterTimestamp = await onResendRequestedAt(lastResendAt); @@ -237,17 +295,24 @@ } try { + const timedPoll = await applyMailPollingTimeBudget( + step, + payload, + pollOverrides, + `轮询${getVerificationCodeLabel(step)}验证码邮箱` + ); const result = await sendToMailContentScriptResilient( mail, { type: 'POLL_EMAIL', step, source: 'background', - payload, + payload: timedPoll.payload, }, { - timeoutMs: 45000, + timeoutMs: timedPoll.timeoutMs, maxRecoveryAttempts: 2, + responseTimeoutMs: timedPoll.responseTimeoutMs, } ); @@ -307,23 +372,26 @@ if (mail.provider === HOTMAIL_PROVIDER) { const hotmailPollConfig = getHotmailVerificationPollConfig(step); - return pollHotmailVerificationCode(step, state, { + const timedPoll = await applyMailPollingTimeBudget(step, { ...getVerificationPollPayload(step, state), ...hotmailPollConfig, ...cleanPollOverrides, - }); + }, cleanPollOverrides, `轮询${getVerificationCodeLabel(step)}验证码邮箱`); + return pollHotmailVerificationCode(step, state, timedPoll.payload); } if (mail.provider === LUCKMAIL_PROVIDER) { - return pollLuckmailVerificationCode(step, state, { + const timedPoll = await applyMailPollingTimeBudget(step, { ...getVerificationPollPayload(step, state), ...cleanPollOverrides, - }); + }, cleanPollOverrides, `轮询${getVerificationCodeLabel(step)}验证码邮箱`); + return pollLuckmailVerificationCode(step, state, timedPoll.payload); } if (mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER) { - return pollCloudflareTempEmailVerificationCode(step, state, { + const timedPoll = await applyMailPollingTimeBudget(step, { ...getVerificationPollPayload(step, state), ...cleanPollOverrides, - }); + }, cleanPollOverrides, `轮询${getVerificationCodeLabel(step)}验证码邮箱`); + return pollCloudflareTempEmailVerificationCode(step, state, timedPoll.payload); } if (Number(pollOverrides.resendIntervalMs) > 0) { @@ -348,7 +416,7 @@ for (let round = 1; round <= maxRounds; round++) { throwIfStopped(); if (round > 1) { - const requestedAt = await requestVerificationCodeResend(step); + const requestedAt = await requestVerificationCodeResend(step, pollOverrides); usedResendRequests += 1; if (typeof onResendRequestedAt === 'function') { const nextFilterAfterTimestamp = await onResendRequestedAt(requestedAt); @@ -365,17 +433,24 @@ }); try { + const timedPoll = await applyMailPollingTimeBudget( + step, + payload, + pollOverrides, + `轮询${getVerificationCodeLabel(step)}验证码邮箱` + ); const result = await sendToMailContentScriptResilient( mail, { type: 'POLL_EMAIL', step, source: 'background', - payload, + payload: timedPoll.payload, }, { - timeoutMs: 45000, + timeoutMs: timedPoll.timeoutMs, maxRecoveryAttempts: 2, + responseTimeoutMs: timedPoll.responseTimeoutMs, } ); @@ -410,7 +485,7 @@ throw lastError || new Error(`步骤 ${step}:无法获取新的${getVerificationCodeLabel(step)}验证码。`); } - async function submitVerificationCode(step, code) { + async function submitVerificationCode(step, code, options = {}) { const signupTabId = await getTabId('signup-page'); if (!signupTabId) { throw new Error('认证页面标签页已关闭,无法填写验证码。'); @@ -422,6 +497,13 @@ step, source: 'background', payload: { code }, + }, { + responseTimeoutMs: await getResponseTimeoutMsForStep( + step, + options, + step === 7 ? 45000 : 30000, + `填写${getVerificationCodeLabel(step)}验证码` + ), }); if (result && result.error) { @@ -459,22 +541,7 @@ const resendIntervalMs = Math.max(0, Number(options.resendIntervalMs) || 0); let lastResendAt = Number(options.lastResendAt) || 0; - const updateFilterAfterTimestampForVerificationStep = async (requestedAt) => { - if ((step !== 4 && step !== 8) || !requestedAt) { - return nextFilterAfterTimestamp; - } - - if (mail.provider === HOTMAIL_PROVIDER) { - nextFilterAfterTimestamp = getHotmailVerificationRequestTimestamp(step, { - ...state, - ...(step === 4 - ? { signupVerificationRequestedAt: requestedAt } - : { loginVerificationRequestedAt: requestedAt }), - }); - } else { - nextFilterAfterTimestamp = Math.max(0, Number(requestedAt) - 60000); - } - + const updateFilterAfterTimestampForVerificationStep = async (_requestedAt) => { return nextFilterAfterTimestamp; }; @@ -483,7 +550,7 @@ await addLog(`步骤 ${step}:当前自动重新发送验证码次数为 0,将直接使用当前时间窗口轮询邮箱。`, 'info'); } else { try { - lastResendAt = await requestVerificationCodeResend(step); + lastResendAt = await requestVerificationCodeResend(step, options); remainingAutomaticResendCount -= 1; await updateFilterAfterTimestampForVerificationStep(lastResendAt); await addLog(`步骤 ${step}:已先请求一封新的${getVerificationCodeLabel(step)}验证码,再开始轮询邮箱。`, 'warn'); @@ -499,14 +566,23 @@ if (mail.provider === HOTMAIL_PROVIDER) { const initialDelayMs = Number(options.initialDelayMs ?? hotmailPollConfig.initialDelayMs) || 0; if (initialDelayMs > 0) { + const remainingMs = await getRemainingTimeBudgetMs( + step, + options, + `等待${getVerificationCodeLabel(step)}验证码邮件到达` + ); + const delayMs = remainingMs === null + ? initialDelayMs + : Math.min(initialDelayMs, Math.max(0, remainingMs)); await addLog(`步骤 ${step}:等待 ${Math.round(initialDelayMs / 1000)} 秒,让 Hotmail 验证码邮件先到达...`, 'info'); - await sleepWithStop(initialDelayMs); + await sleepWithStop(delayMs); } } for (let attempt = 1; attempt <= maxSubmitAttempts; attempt++) { const pollOptions = { excludeCodes: [...rejectedCodes], + getRemainingTimeMs: options.getRemainingTimeMs, maxResendRequests: remainingAutomaticResendCount, resendIntervalMs, lastResendAt, @@ -533,7 +609,7 @@ }); } throwIfStopped(); - const submitResult = await submitVerificationCode(step, result.code); + const submitResult = await submitVerificationCode(step, result.code, options); if (submitResult.invalidCode) { rejectedCodes.add(result.code); @@ -559,7 +635,7 @@ continue; } - lastResendAt = await requestVerificationCodeResend(step); + lastResendAt = await requestVerificationCodeResend(step, options); remainingAutomaticResendCount -= 1; await updateFilterAfterTimestampForVerificationStep(lastResendAt); await addLog(`步骤 ${step}:提交失败后已请求新验证码(${attempt + 1}/${maxSubmitAttempts})...`, 'warn'); diff --git a/tests/background-step6-retry-limit.test.js b/tests/background-step6-retry-limit.test.js index d870d4c..aa0fd46 100644 --- a/tests/background-step6-retry-limit.test.js +++ b/tests/background-step6-retry-limit.test.js @@ -76,3 +76,56 @@ test('step 7 retries up to configured limit and then fails', async () => { assert.equal(events.sendCalls, 3); assert.equal(events.completed, 0); }); + +test('step 7 starts a new oauth timeout window for each refreshed oauth url', async () => { + const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageBackgroundStep7;`)(globalScope); + + const events = { + startedWindows: [], + timeoutRequests: [], + }; + + const executor = api.createStep7Executor({ + addLog: async () => {}, + completeStepFromBackground: async () => {}, + getErrorMessage: (error) => error?.message || String(error || ''), + getLoginAuthStateLabel: (state) => state || 'unknown', + getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs, options) => { + events.timeoutRequests.push({ defaultTimeoutMs, options }); + return 5000; + }, + getState: async () => ({ email: 'user@example.com', password: 'secret' }), + isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable', + isStep6SuccessResult: (result) => result?.step6Outcome === 'success', + refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/latest', + reuseOrCreateTab: async () => {}, + sendToContentScriptResilient: async (_source, _message, options) => ({ + step6Outcome: 'success', + usedTimeoutMs: options.timeoutMs, + }), + shouldSkipLoginVerificationForCpaCallback: () => false, + skipLoginVerificationStepsForCpaCallback: async () => {}, + startOAuthFlowTimeoutWindow: async (payload) => { + events.startedWindows.push(payload); + }, + STEP6_MAX_ATTEMPTS: 3, + throwIfStopped: () => {}, + }); + + await executor.executeStep7({ email: 'user@example.com', password: 'secret' }); + + assert.deepStrictEqual(events.startedWindows, [ + { step: 7, oauthUrl: 'https://oauth.example/latest' }, + ]); + assert.deepStrictEqual(events.timeoutRequests, [ + { + defaultTimeoutMs: 180000, + options: { + step: 7, + actionLabel: 'OAuth 登录并进入验证码页', + }, + }, + ]); +}); diff --git a/tests/background-step7-recovery.test.js b/tests/background-step7-recovery.test.js index b39645b..b9e74e0 100644 --- a/tests/background-step7-recovery.test.js +++ b/tests/background-step7-recovery.test.js @@ -9,10 +9,13 @@ const api = new Function('self', `${source}; return self.MultiPageBackgroundStep test('step 8 refreshes CPA oauth via step 7 replay before submitting verification code', async () => { const calls = { ensureReady: 0, + ensureReadyOptions: [], executeStep7: 0, sleep: [], resolveOptions: null, }; + const realDateNow = Date.now; + Date.now = () => 123456; const executor = api.createStep8Executor({ addLog: async () => {}, @@ -23,13 +26,16 @@ test('step 8 refreshes CPA oauth via step 7 replay before submitting verificatio }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', confirmCustomVerificationStepBypass: async () => {}, - ensureStep8VerificationPageReady: async () => { + ensureStep8VerificationPageReady: async (options) => { calls.ensureReady += 1; + calls.ensureReadyOptions.push(options || null); return { state: 'verification_page' }; }, executeStep7: async () => { calls.executeStep7 += 1; }, + getOAuthFlowRemainingMs: async () => 5000, + getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 5000), getMailConfig: () => ({ provider: 'qq', label: 'QQ 邮箱', @@ -61,17 +67,28 @@ test('step 8 refreshes CPA oauth via step 7 replay before submitting verificatio throwIfStopped: () => {}, }); - await executor.executeStep8({ - email: 'user@example.com', - password: 'secret', - oauthUrl: 'https://oauth.example/latest', - }); + try { + await executor.executeStep8({ + email: 'user@example.com', + password: 'secret', + oauthUrl: 'https://oauth.example/latest', + }); + } finally { + Date.now = realDateNow; + } assert.equal(typeof calls.resolveOptions.beforeSubmit, 'function'); assert.equal(calls.ensureReady, 2); assert.equal(calls.executeStep7, 1); assert.deepStrictEqual(calls.sleep, [1200]); + assert.equal(calls.resolveOptions.filterAfterTimestamp, 123456); + assert.equal(typeof calls.resolveOptions.getRemainingTimeMs, 'function'); + assert.equal(await calls.resolveOptions.getRemainingTimeMs({ actionLabel: '登录验证码流程' }), 5000); assert.equal(calls.resolveOptions.resendIntervalMs, 25000); + assert.deepStrictEqual(calls.ensureReadyOptions, [ + { timeoutMs: 5000 }, + { timeoutMs: 5000 }, + ]); }); test('step 8 disables resend interval for 2925 mailbox polling', async () => { @@ -88,6 +105,8 @@ test('step 8 disables resend interval for 2925 mailbox polling', async () => { confirmCustomVerificationStepBypass: async () => {}, ensureStep8VerificationPageReady: async () => ({ state: 'verification_page' }), executeStep7: async () => {}, + getOAuthFlowRemainingMs: async () => 8000, + getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 8000), getMailConfig: () => ({ provider: '2925', label: '2925 邮箱', @@ -124,4 +143,5 @@ test('step 8 disables resend interval for 2925 mailbox polling', async () => { assert.equal(capturedOptions.resendIntervalMs, 0); assert.equal(capturedOptions.beforeSubmit, undefined); + assert.equal(typeof capturedOptions.getRemainingTimeMs, 'function'); }); diff --git a/tests/verification-flow-polling.test.js b/tests/verification-flow-polling.test.js index aecadc9..7a279da 100644 --- a/tests/verification-flow-polling.test.js +++ b/tests/verification-flow-polling.test.js @@ -109,6 +109,70 @@ test('verification flow runs beforeSubmit hook before filling the code', async ( ]); }); +test('verification flow caps mail polling timeout to the remaining oauth budget', async () => { + const mailPollCalls = []; + + const helpers = api.createVerificationFlowHelpers({ + addLog: async () => {}, + chrome: { + tabs: { + update: async () => {}, + }, + }, + CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', + completeStepFromBackground: async () => {}, + confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), + getHotmailVerificationPollConfig: () => ({}), + getHotmailVerificationRequestTimestamp: () => 0, + getState: async () => ({}), + getTabId: async () => 1, + HOTMAIL_PROVIDER: 'hotmail-api', + isStopError: () => false, + LUCKMAIL_PROVIDER: 'luckmail-api', + MAIL_2925_VERIFICATION_INTERVAL_MS: 15000, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15, + pollCloudflareTempEmailVerificationCode: async () => ({}), + pollHotmailVerificationCode: async () => ({}), + pollLuckmailVerificationCode: async () => ({}), + sendToContentScript: async (_source, message) => { + if (message.type === 'FILL_CODE') { + return {}; + } + return {}; + }, + sendToMailContentScriptResilient: async (_mail, message, options) => { + mailPollCalls.push({ + payload: message.payload, + options, + }); + return { code: '654321', emailTimestamp: 123 }; + }, + setState: async () => {}, + setStepStatus: async () => {}, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + VERIFICATION_POLL_MAX_ROUNDS: 5, + }); + + await helpers.resolveVerificationStep( + 8, + { + email: 'user@example.com', + lastLoginCode: null, + }, + { provider: 'qq', label: 'QQ 邮箱' }, + { + getRemainingTimeMs: async () => 5000, + resendIntervalMs: 0, + } + ); + + assert.ok(mailPollCalls.length >= 1); + assert.equal(mailPollCalls[0].options.timeoutMs, 5000); + assert.equal(mailPollCalls[0].options.responseTimeoutMs, 5000); + assert.equal(mailPollCalls[0].payload.maxAttempts, 2); +}); + test('verification flow keeps Hotmail request timestamp filtering on the first poll', async () => { const pollPayloads = []; @@ -162,10 +226,8 @@ test('verification flow keeps Hotmail request timestamp filtering on the first p assert.equal(pollPayloads[0].filterAfterTimestamp, 87654); }); -test('verification flow refreshes Hotmail signup filter timestamp after step 4 resend', async () => { +test('verification flow keeps fixed filter timestamp after step 4 resend', async () => { const pollPayloads = []; - const realDateNow = Date.now; - Date.now = () => 200000; let submitCount = 0; @@ -213,24 +275,22 @@ test('verification flow refreshes Hotmail signup filter timestamp after step 4 r VERIFICATION_POLL_MAX_ROUNDS: 5, }); - try { - await helpers.resolveVerificationStep( - 4, - { - email: 'user@example.com', - signupVerificationRequestedAt: 100000, - lastSignupCode: null, - }, - { provider: 'hotmail-api', label: 'Hotmail' }, - {} - ); - } finally { - Date.now = realDateNow; - } + await helpers.resolveVerificationStep( + 4, + { + email: 'user@example.com', + signupVerificationRequestedAt: 100000, + lastSignupCode: null, + }, + { provider: 'hotmail-api', label: 'Hotmail' }, + { + filterAfterTimestamp: 123456, + } + ); assert.equal(pollPayloads.length, 2); - assert.equal(pollPayloads[0].filterAfterTimestamp, 85000); - assert.equal(pollPayloads[1].filterAfterTimestamp, 185000); + assert.equal(pollPayloads[0].filterAfterTimestamp, 123456); + assert.equal(pollPayloads[1].filterAfterTimestamp, 123456); }); test('verification flow uses configured signup resend count for step 4', async () => { diff --git a/项目完整链路说明.md b/项目完整链路说明.md index 4fd8c3c..b2a8a95 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -246,7 +246,7 @@ 补充行为: - `2925` provider 会拉长单轮轮询窗口,并关闭 Step 4 / 8 的自动重发间隔,减少因邮件延迟过早判负。 -- Hotmail 在 Step 4 / 8 首轮轮询时会优先使用最近一次验证码请求时间作为时间窗口;如果中途触发重发,轮询窗口也会同步前移,避免旧邮件反复命中。 +- Step 4 / 8 会在当前验证码步骤启动时固定一次邮件筛选时间窗;本步后续即使触发重新发送验证码,也继续沿用这一次启动时间作为筛选基准,不再随着重发时间前移。 - 自动重新发送验证码次数现在使用 sidepanel 里的单一“验证码重发”配置;普通邮箱仍按 25 秒间隔节流,Hotmail / 2925 不走这个 25 秒间隔。Step 4 若启用先请求新验证码,会先消耗一次当前步骤的自动重发次数。 - CPA 模式下,Step 8 在真正回填登录验证码前,会先刷新最新 OAuth 地址并快速重走一次 Step 7,降低验证码等待过久导致 OAuth 过期的概率。