From c994d7ae124965615033ff641db3d78d0bddf69e Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sat, 18 Apr 2026 23:51:56 +0800 Subject: [PATCH 01/15] =?UTF-8?q?=E8=B4=A1=E7=8C=AE=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=BC=80=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background.js | 177 ++++- background/account-run-history.js | 3 + background/contribution-oauth.js | 722 +++++++++++++++++++++ background/message-router.js | 46 ++ sidepanel/contribution-mode.js | 403 ++++++++++++ sidepanel/sidepanel.css | 119 +++- sidepanel/sidepanel.html | 39 +- sidepanel/sidepanel.js | 175 ++++- tests/background-contribution-mode.test.js | 441 +++++++++++++ tests/background-luckmail.test.js | 21 +- tests/sidepanel-contribution-mode.test.js | 445 +++++++++++++ 项目完整链路说明.md | 56 ++ 项目开发规范(AI协作).md | 22 + 项目文件结构说明.md | 8 +- 14 files changed, 2658 insertions(+), 19 deletions(-) create mode 100644 background/contribution-oauth.js create mode 100644 sidepanel/contribution-mode.js create mode 100644 tests/background-contribution-mode.test.js create mode 100644 tests/sidepanel-contribution-mode.test.js diff --git a/background.js b/background.js index 45a3243..779312e 100644 --- a/background.js +++ b/background.js @@ -3,6 +3,7 @@ importScripts( 'managed-alias-utils.js', 'background/account-run-history.js', + 'background/contribution-oauth.js', 'background/panel-bridge.js', 'background/generated-email-helpers.js', 'background/signup-flow-helpers.js', @@ -177,6 +178,22 @@ const DISPLAY_TIMEZONE = 'Asia/Shanghai'; const MICROSOFT_TOKEN_DNR_RULE_ID = 1001; const PERSISTENT_ALIAS_STATE_KEYS = ['manualAliasUsage', 'preservedAliases']; const ACCOUNT_RUN_HISTORY_STORAGE_KEY = 'accountRunHistory'; +const CONTRIBUTION_RUNTIME_DEFAULTS = self.MultiPageBackgroundContributionOAuth?.RUNTIME_DEFAULTS || { + ...CONTRIBUTION_RUNTIME_DEFAULTS, + contributionSessionId: '', + contributionAuthUrl: '', + contributionAuthState: '', + contributionCallbackUrl: '', + contributionStatus: '', + contributionStatusMessage: '', + contributionLastPollAt: 0, + contributionCallbackStatus: 'idle', + contributionCallbackMessage: '', + contributionAuthOpenedAt: 0, + contributionAuthTabId: 0, +}; +const CONTRIBUTION_RUNTIME_KEYS = self.MultiPageBackgroundContributionOAuth?.RUNTIME_KEYS + || Object.keys(CONTRIBUTION_RUNTIME_DEFAULTS); initializeSessionStorageAccess(); setupDeclarativeNetRequestRules(); @@ -278,6 +295,7 @@ const PRE_LOGIN_COOKIE_CLEAR_ORIGINS = [ const DEFAULT_STATE = { currentStep: 0, // 当前流程执行到的步骤编号。 stepStatuses: Object.fromEntries(STEP_IDS.map((stepId) => [stepId, 'pending'])), + ...CONTRIBUTION_RUNTIME_DEFAULTS, oauthUrl: null, // 运行时抓取到的 OAuth 地址,不要手动预填。 email: null, // 运行时邮箱,由程序自动获取并写入,不能手动预填。 password: null, // 运行时实际密码,由 customPassword 或程序自动生成后写入。 @@ -1119,6 +1137,65 @@ async function setPasswordState(password) { broadcastDataUpdate({ password }); } +function buildContributionModeState(enabled, persistedSettings = {}, currentState = {}) { + const currentContributionState = {}; + for (const key of CONTRIBUTION_RUNTIME_KEYS) { + currentContributionState[key] = currentState[key] !== undefined + ? currentState[key] + : CONTRIBUTION_RUNTIME_DEFAULTS[key]; + } + + if (enabled) { + return { + ...currentContributionState, + contributionMode: true, + panelMode: 'cpa', + customPassword: '', + accountRunHistoryTextEnabled: false, + }; + } + + return { + ...CONTRIBUTION_RUNTIME_DEFAULTS, + contributionMode: false, + panelMode: persistedSettings.panelMode || DEFAULT_STATE.panelMode, + customPassword: persistedSettings.customPassword || '', + accountRunHistoryTextEnabled: Boolean(persistedSettings.accountRunHistoryTextEnabled), + }; +} + +async function setContributionMode(enabled) { + const normalizedEnabled = Boolean(enabled); + const [persistedSettings, currentState] = await Promise.all([ + getPersistedSettings(), + getState(), + ]); + + if (normalizedEnabled) { + await setPersistentSettings({ panelMode: 'cpa' }); + } + + const updates = buildContributionModeState(normalizedEnabled, { + ...persistedSettings, + ...(normalizedEnabled ? { panelMode: 'cpa' } : {}), + }, currentState); + + await setState(updates); + const nextState = await getState(); + const contributionBroadcast = {}; + for (const key of CONTRIBUTION_RUNTIME_KEYS) { + contributionBroadcast[key] = nextState[key]; + } + broadcastDataUpdate({ + ...contributionBroadcast, + panelMode: nextState.panelMode, + customPassword: nextState.customPassword, + accountRunHistoryTextEnabled: nextState.accountRunHistoryTextEnabled, + accountRunHistoryHelperBaseUrl: nextState.accountRunHistoryHelperBaseUrl, + }); + return nextState; +} + function getLuckmailUsedPurchases(state = {}) { return normalizeLuckmailUsedPurchases(state?.luckmailUsedPurchases); } @@ -1269,15 +1346,21 @@ async function resetState() { 'luckmailPreserveTagId', 'luckmailPreserveTagName', 'preferredIcloudHost', + ...CONTRIBUTION_RUNTIME_KEYS, ]), getPersistedSettings(), getPersistedAliasState(), ]); + const contributionModeState = buildContributionModeState(Boolean(prev.contributionMode), { + ...persistedSettings, + ...(prev.contributionMode ? { panelMode: 'cpa' } : {}), + }, prev); await chrome.storage.session.clear(); await chrome.storage.session.set({ ...DEFAULT_STATE, ...persistedSettings, ...persistedAliasState, + ...contributionModeState, seenCodes: prev.seenCodes || [], seenInbucketMailIds: prev.seenInbucketMailIds || [], accounts: prev.accounts || [], @@ -5215,6 +5298,15 @@ const accountRunHistoryHelpers = self.MultiPageBackgroundAccountRunHistory?.crea getState, normalizeAccountRunHistoryHelperBaseUrl, }); +const contributionOAuthManager = self.MultiPageBackgroundContributionOAuth?.createContributionOAuthManager({ + addLog, + broadcastDataUpdate, + chrome, + closeLocalhostCallbackTabs, + getState, + setState, +}); +contributionOAuthManager?.ensureCallbackListeners?.(); async function broadcastAccountRunHistoryUpdate() { if (!accountRunHistoryHelpers?.getPersistedAccountRunHistory) { @@ -5944,7 +6036,7 @@ const stepExecutorsByKey = { 'oauth-login': (state) => step7Executor.executeStep7(state), 'fetch-login-code': (state) => step8Executor.executeStep8(state), 'confirm-oauth': (state) => step9Executor.executeStep9(state), - 'platform-verify': (state) => step10Executor.executeStep10(state), + 'platform-verify': (state) => executeStep10(state), }; const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter({ addLog, @@ -6016,6 +6108,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter scheduleAutoRun, selectLuckmailPurchase, setCurrentHotmailAccount, + setContributionMode, setEmailState, setEmailStateSilently, setIcloudAliasPreservedState, @@ -6028,7 +6121,9 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter setStepStatus, skipAutoRunCountdown, skipStep, + startContributionFlow: (...args) => contributionOAuthManager?.startContributionFlow?.(...args), startAutoRunLoop, + pollContributionStatus: (...args) => contributionOAuthManager?.pollContributionStatus?.(...args), syncHotmailAccounts, testHotmailAccountMailAccess, upsertHotmailAccount, @@ -6359,6 +6454,20 @@ async function runPreStep6CookieCleanup() { // ============================================================ async function refreshOAuthUrlBeforeStep6(state) { + if (state?.contributionMode && contributionOAuthManager?.startContributionFlow) { + await addLog('步骤 7:贡献模式正在申请贡献登录地址...'); + const contributionState = await contributionOAuthManager.startContributionFlow({ + nickname: state.email, + openAuthTab: false, + stateOverride: state, + }); + const oauthUrl = String(contributionState?.contributionAuthUrl || '').trim(); + if (!oauthUrl) { + throw new Error('贡献模式未返回可用的登录地址,请稍后重试。'); + } + await handleStepData(1, { oauthUrl }); + return oauthUrl; + } await addLog(`步骤 7:正在刷新登录用的 ${getPanelModeLabel(state)} OAuth 链接...`); console.log(LOG_PREFIX, '[refreshOAuthUrlBeforeStep6] requesting fresh OAuth directly from panel'); const refreshResult = await requestOAuthUrlFromPanel(state, { logLabel: '步骤 7' }); @@ -6972,7 +7081,73 @@ async function executeStep9(state) { // Step 10: 平台回调验证 // ============================================================ +async function executeContributionStep10(state) { + if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) { + throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。'); + } + if (!state.localhostUrl) { + throw new Error('缺少 localhost 回调地址,请先完成步骤 9。'); + } + if (!state.contributionSessionId) { + throw new Error('缺少贡献会话信息,请重新从步骤 7 开始。'); + } + if (!contributionOAuthManager?.pollContributionStatus) { + throw new Error('贡献 OAuth 流程尚未接入,无法完成贡献模式的步骤 10。'); + } + + await addLog('步骤 10:贡献模式正在提交回调并等待最终结果...'); + + let latestState = await getState(); + const callbackUrl = latestState.localhostUrl || state.localhostUrl; + + if (!latestState.contributionCallbackUrl && contributionOAuthManager?.handleCapturedCallback) { + latestState = await contributionOAuthManager.handleCapturedCallback(callbackUrl, { + source: 'step10', + }); + } else { + latestState = await contributionOAuthManager.pollContributionStatus({ + reason: 'step10_initial', + stateOverride: latestState, + }); + } + + const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' + ? await getOAuthFlowStepTimeoutMs(120000, { + step: 10, + actionLabel: '贡献流程最终结果', + }) + : 120000; + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + const status = String(latestState.contributionStatus || '').trim().toLowerCase(); + if (contributionOAuthManager?.isContributionFinalStatus?.(status)) { + if (status === 'auto_approved' || status === 'manual_review_required') { + await addLog(`步骤 10:贡献流程已结束,最终状态:${latestState.contributionStatusMessage || status}`, status === 'auto_approved' ? 'ok' : 'warn'); + await completeStepFromBackground(10, { + contributionStatus: status, + contributionStatusMessage: latestState.contributionStatusMessage || '', + localhostUrl: callbackUrl, + }); + return; + } + throw new Error(latestState.contributionStatusMessage || '贡献流程失败。'); + } + + await sleepWithStop(2500); + latestState = await contributionOAuthManager.pollContributionStatus({ + reason: 'step10_wait_final', + stateOverride: latestState, + }); + } + + throw new Error('步骤 10:等待贡献流程最终结果超时。'); +} + async function executeStep10(state) { + if (state?.contributionMode) { + return executeContributionStep10(state); + } return step10Executor.executeStep10(state); } diff --git a/background/account-run-history.js b/background/account-run-history.js index 28404ab..6ebde78 100644 --- a/background/account-run-history.js +++ b/background/account-run-history.js @@ -298,6 +298,9 @@ } function shouldSyncAccountRunHistorySnapshot(state = {}) { + if (Boolean(state.contributionMode)) { + return false; + } if (!Boolean(state.accountRunHistoryTextEnabled)) { return false; } diff --git a/background/contribution-oauth.js b/background/contribution-oauth.js new file mode 100644 index 0000000..9eb2033 --- /dev/null +++ b/background/contribution-oauth.js @@ -0,0 +1,722 @@ +(function attachBackgroundContributionOAuth(root, factory) { + root.MultiPageBackgroundContributionOAuth = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundContributionOAuthModule() { + const API_BASE_URL = 'https://apikey.qzz.io/oauth/api'; + const ACTIVE_STATUSES = new Set(['started', 'waiting', 'processing']); + const FINAL_STATUSES = new Set(['auto_approved', 'auto_rejected', 'manual_review_required', 'expired', 'error']); + const CALLBACK_FINAL_STATUSES = new Set(['submitted', 'not_required']); + const CALLBACK_WAITING_STATUSES = new Set(['idle', 'waiting', 'captured', 'failed', 'submitting']); + + const RUNTIME_DEFAULTS = { + contributionMode: false, + contributionSessionId: '', + contributionAuthUrl: '', + contributionAuthState: '', + contributionCallbackUrl: '', + contributionStatus: '', + contributionStatusMessage: '', + contributionLastPollAt: 0, + contributionCallbackStatus: 'idle', + contributionCallbackMessage: '', + contributionAuthOpenedAt: 0, + contributionAuthTabId: 0, + }; + + const RUNTIME_KEYS = Object.keys(RUNTIME_DEFAULTS); + + function createContributionOAuthManager(deps = {}) { + const { + addLog, + broadcastDataUpdate, + chrome, + closeLocalhostCallbackTabs, + getState, + setState, + } = deps; + + let listenersBound = false; + + function normalizeString(value = '') { + return String(value || '').trim(); + } + + function normalizePositiveInteger(value, fallback = 0) { + const numeric = Math.floor(Number(value) || 0); + return numeric > 0 ? numeric : fallback; + } + + function normalizeContributionStatus(value = '') { + const normalized = normalizeString(value).toLowerCase(); + switch (normalized) { + case 'started': + return 'started'; + case 'waiting': + case 'wait': + return 'waiting'; + case 'processing': + return 'processing'; + case 'auto_approved': + case 'approved': + return 'auto_approved'; + case 'auto_rejected': + case 'rejected': + return 'auto_rejected'; + case 'manual_review_required': + case 'manual_review': + return 'manual_review_required'; + case 'expired': + case 'timeout': + return 'expired'; + case 'error': + case 'failed': + return 'error'; + default: + return ''; + } + } + + function normalizeContributionCallbackStatus(value = '') { + const normalized = normalizeString(value).toLowerCase(); + switch (normalized) { + case 'idle': + return 'idle'; + case 'waiting': + case 'pending': + return 'waiting'; + case 'captured': + return 'captured'; + case 'submitting': + case 'processing': + return 'submitting'; + case 'submitted': + case 'success': + case 'done': + return 'submitted'; + case 'not_required': + case 'no_need': + case 'no_callback_required': + return 'not_required'; + case 'failed': + case 'error': + return 'failed'; + default: + return ''; + } + } + + function isContributionFinalStatus(status = '') { + return FINAL_STATUSES.has(normalizeContributionStatus(status)); + } + + function getStatusLabel(status = '') { + switch (normalizeContributionStatus(status)) { + case 'started': + return '已生成登录地址'; + case 'waiting': + return '等待授权完成'; + case 'processing': + return '已授权,正在自动审核'; + case 'auto_approved': + return '贡献成功,已自动导入'; + case 'auto_rejected': + return '贡献未通过自动审核'; + case 'manual_review_required': + return '已提交,等待人工处理'; + case 'expired': + return '贡献会话已超时'; + case 'error': + return '贡献流程失败'; + default: + return '等待开始贡献'; + } + } + + function getCallbackLabel(status = '') { + switch (normalizeContributionCallbackStatus(status)) { + case 'waiting': + case 'idle': + return '等待回调'; + case 'captured': + return '已捕获回调地址'; + case 'submitting': + return '正在提交回调'; + case 'submitted': + return '已提交回调'; + case 'not_required': + return '当前流程无需手动回调'; + case 'failed': + return '回调提交失败'; + default: + return '等待回调'; + } + } + + function unwrapPayload(payload) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return {}; + } + + if (payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)) { + return { ...payload.data, ...payload }; + } + + return payload; + } + + function getErrorMessage(payload, responseStatus = 500) { + const details = [ + payload?.message, + payload?.detail, + payload?.error, + payload?.reason, + ] + .map((item) => normalizeString(item)) + .find(Boolean); + + if (details) { + return details; + } + + return `贡献服务请求失败(HTTP ${responseStatus})。`; + } + + async function fetchContributionJson(endpoint, options = {}) { + const controller = new AbortController(); + const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 15000)); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + method: options.method || 'GET', + headers: { + Accept: 'application/json', + ...(options.body ? { 'Content-Type': 'application/json' } : {}), + }, + body: options.body ? JSON.stringify(options.body) : undefined, + signal: controller.signal, + }); + + let rawPayload = {}; + try { + rawPayload = await response.json(); + } catch { + rawPayload = {}; + } + + const payload = unwrapPayload(rawPayload); + if (!response.ok || payload.ok === false) { + const error = new Error(getErrorMessage(payload, response.status)); + error.payload = payload; + error.responseStatus = response.status; + throw error; + } + + return payload; + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error('贡献服务请求超时,请稍后重试。'); + } + throw error; + } finally { + clearTimeout(timer); + } + } + + function pickContributionState(state = {}) { + const picked = {}; + for (const key of RUNTIME_KEYS) { + picked[key] = state[key] !== undefined ? state[key] : RUNTIME_DEFAULTS[key]; + } + return picked; + } + + async function applyRuntimeUpdates(updates = {}) { + if (!updates || typeof updates !== 'object' || Array.isArray(updates) || Object.keys(updates).length === 0) { + return getState(); + } + + await setState(updates); + broadcastDataUpdate(updates); + return getState(); + } + + function extractAuthStateFromUrl(authUrl = '') { + try { + return new URL(authUrl).searchParams.get('state') || ''; + } catch { + return ''; + } + } + + function buildNickname(state = {}, preferredNickname = '') { + const nickname = normalizeString(preferredNickname) + || normalizeString(state.email) + || normalizeString(state.contributionNickname); + return nickname || 'codex-extension-user'; + } + + function buildStatusMessage(status, payload = {}) { + const label = getStatusLabel(status); + const details = [ + payload.status_message, + payload.statusMessage, + payload.message, + payload.detail, + payload.reason, + ] + .map((item) => normalizeString(item)) + .find(Boolean); + + if (!details || details === label) { + return label; + } + + return `${label}:${details}`; + } + + function buildCallbackMessage(status, payload = {}) { + const label = getCallbackLabel(status); + const details = [ + payload.callback_message, + payload.callbackMessage, + payload.message, + payload.detail, + payload.reason, + ] + .map((item) => normalizeString(item)) + .find(Boolean); + + if (!details || details === label) { + return label; + } + + return `${label}:${details}`; + } + + function looksLikeNoNeedCallback(payload = {}) { + const combined = [ + payload.message, + payload.detail, + payload.reason, + payload.error, + ] + .map((item) => normalizeString(item).toLowerCase()) + .filter(Boolean) + .join(' '); + + return Boolean(payload.callback_required === false + || payload.callbackRequired === false + || /无需手动|鏃犻渶鎵嬪姩|not required|already enabled/i.test(combined)); + } + + function deriveCallbackState(payload = {}, state = {}) { + const existingStatus = normalizeContributionCallbackStatus(state.contributionCallbackStatus); + const callbackUrl = normalizeString( + payload.callback_url + || payload.callbackUrl + || state.contributionCallbackUrl + ); + const explicitStatus = normalizeContributionCallbackStatus( + payload.callback_status + || payload.callbackStatus + ); + + if (explicitStatus) { + return { + status: explicitStatus, + message: buildCallbackMessage(explicitStatus, payload), + callbackUrl, + }; + } + + if (payload.callback_submitted === true || payload.callbackSubmitted === true) { + return { + status: 'submitted', + message: buildCallbackMessage('submitted', payload), + callbackUrl, + }; + } + + if (looksLikeNoNeedCallback(payload)) { + return { + status: 'not_required', + message: buildCallbackMessage('not_required', payload), + callbackUrl, + }; + } + + if (callbackUrl) { + return { + status: CALLBACK_FINAL_STATUSES.has(existingStatus) ? existingStatus : 'captured', + message: buildCallbackMessage(CALLBACK_FINAL_STATUSES.has(existingStatus) ? existingStatus : 'captured', payload), + callbackUrl, + }; + } + + if (CALLBACK_FINAL_STATUSES.has(existingStatus) || existingStatus === 'failed') { + return { + status: existingStatus, + message: normalizeString(state.contributionCallbackMessage) || buildCallbackMessage(existingStatus), + callbackUrl: normalizeString(state.contributionCallbackUrl), + }; + } + + return { + status: 'waiting', + message: buildCallbackMessage('waiting', payload), + callbackUrl: '', + }; + } + + function isContributionCallbackUrl(rawUrl, state = {}) { + const urlText = normalizeString(rawUrl); + if (!urlText) { + return false; + } + + let parsed; + try { + parsed = new URL(urlText); + } catch { + return false; + } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + return false; + } + + const code = normalizeString(parsed.searchParams.get('code')); + const authState = normalizeString(parsed.searchParams.get('state')); + if (!code || !authState) { + return false; + } + + const hostLooksLocal = ['localhost', '127.0.0.1'].includes(parsed.hostname); + const pathLooksLikeCallback = /callback/i.test(parsed.pathname || ''); + if (!hostLooksLocal && !pathLooksLikeCallback) { + return false; + } + + const expectedState = normalizeString(state.contributionAuthState); + return !expectedState || expectedState === authState; + } + + async function openContributionAuthUrl(authUrl, options = {}) { + const normalizedUrl = normalizeString(authUrl); + if (!normalizedUrl) { + throw new Error('贡献服务未返回有效的登录地址。'); + } + + const currentState = options.stateOverride || await getState(); + const preferredTabId = normalizePositiveInteger(options.tabId || currentState.contributionAuthTabId, 0); + let tab = null; + + if (preferredTabId) { + tab = await chrome.tabs.update(preferredTabId, { + url: normalizedUrl, + active: true, + }).catch(() => null); + } + + if (!tab) { + tab = await chrome.tabs.create({ url: normalizedUrl, active: true }); + } + + await applyRuntimeUpdates({ + contributionAuthUrl: normalizedUrl, + contributionAuthOpenedAt: Date.now(), + contributionAuthTabId: normalizePositiveInteger(tab?.id, 0), + }); + + return tab; + } + + async function fetchContributionResult(sessionId) { + try { + return await fetchContributionJson(`/result?session_id=${encodeURIComponent(sessionId)}`); + } catch (error) { + if (typeof addLog === 'function') { + await addLog(`贡献模式:获取最终结果失败:${error.message}`, 'warn'); + } + return null; + } + } + + async function submitContributionCallback(callbackUrl, options = {}) { + const currentState = options.stateOverride || await getState(); + const sessionId = normalizeString(currentState.contributionSessionId); + const normalizedUrl = normalizeString(callbackUrl); + + if (!sessionId || !normalizedUrl) { + return currentState; + } + + const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus); + if (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting') { + return currentState; + } + + await applyRuntimeUpdates({ + contributionCallbackUrl: normalizedUrl, + contributionCallbackStatus: 'submitting', + contributionCallbackMessage: buildCallbackMessage('submitting'), + }); + + try { + const payload = await fetchContributionJson('/submit-callback', { + method: 'POST', + body: { + session_id: sessionId, + callback_url: normalizedUrl, + }, + }); + + const nextStatus = looksLikeNoNeedCallback(payload) ? 'not_required' : 'submitted'; + await applyRuntimeUpdates({ + contributionCallbackUrl: normalizedUrl, + contributionCallbackStatus: nextStatus, + contributionCallbackMessage: buildCallbackMessage(nextStatus, payload), + }); + + if (typeof closeLocalhostCallbackTabs === 'function') { + await closeLocalhostCallbackTabs(normalizedUrl).catch(() => {}); + } + + return await pollContributionStatus({ reason: options.reason || 'submit_callback' }); + } catch (error) { + if (looksLikeNoNeedCallback(error?.payload || {})) { + await applyRuntimeUpdates({ + contributionCallbackUrl: normalizedUrl, + contributionCallbackStatus: 'not_required', + contributionCallbackMessage: buildCallbackMessage('not_required', error.payload || {}), + }); + + if (typeof closeLocalhostCallbackTabs === 'function') { + await closeLocalhostCallbackTabs(normalizedUrl).catch(() => {}); + } + + return pollContributionStatus({ reason: options.reason || 'submit_callback_not_required' }).catch(() => getState()); + } + + await applyRuntimeUpdates({ + contributionCallbackUrl: normalizedUrl, + contributionCallbackStatus: 'failed', + contributionCallbackMessage: `回调提交失败:${error.message}`, + }); + + if (typeof addLog === 'function') { + await addLog(`贡献模式:回调提交失败:${error.message}`, 'warn'); + } + + throw error; + } + } + + async function handleCapturedCallback(rawUrl, metadata = {}) { + const currentState = await getState(); + if (!normalizeString(currentState.contributionSessionId) || !currentState.contributionMode) { + return currentState; + } + if (!isContributionCallbackUrl(rawUrl, currentState)) { + return currentState; + } + + const normalizedUrl = normalizeString(rawUrl); + const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus); + if ( + normalizedUrl + && normalizeString(currentState.contributionCallbackUrl) === normalizedUrl + && (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting') + ) { + return currentState; + } + + await applyRuntimeUpdates({ + contributionCallbackUrl: normalizedUrl, + contributionCallbackStatus: 'captured', + contributionCallbackMessage: buildCallbackMessage('captured'), + }); + + if (typeof addLog === 'function') { + await addLog(`贡献模式:已捕获回调地址(${metadata.source || 'unknown'})。`, 'info'); + } + + try { + return await submitContributionCallback(normalizedUrl, { + reason: metadata.source || 'navigation', + stateOverride: await getState(), + }); + } catch { + return getState(); + } + } + + async function pollContributionStatus(options = {}) { + const currentState = options.stateOverride || await getState(); + const sessionId = normalizeString(currentState.contributionSessionId); + if (!sessionId) { + return currentState; + } + + const payload = await fetchContributionJson(`/status?session_id=${encodeURIComponent(sessionId)}`); + const nextStatus = normalizeContributionStatus(payload.status || payload.state || payload.phase) || currentState.contributionStatus || 'waiting'; + let finalPayload = null; + + if (isContributionFinalStatus(nextStatus)) { + finalPayload = await fetchContributionResult(sessionId); + } + + const mergedPayload = finalPayload ? { ...payload, ...finalPayload } : payload; + const normalizedStatus = normalizeContributionStatus(mergedPayload.status || mergedPayload.state || mergedPayload.phase) || nextStatus; + const callbackState = deriveCallbackState(mergedPayload, currentState); + const updates = { + contributionLastPollAt: Date.now(), + contributionStatus: normalizedStatus, + contributionStatusMessage: buildStatusMessage(normalizedStatus, mergedPayload), + contributionCallbackUrl: callbackState.callbackUrl, + contributionCallbackStatus: callbackState.status, + contributionCallbackMessage: callbackState.message, + }; + + const authUrl = normalizeString(mergedPayload.auth_url || mergedPayload.authUrl); + if (authUrl) { + updates.contributionAuthUrl = authUrl; + } + + const authState = normalizeString(mergedPayload.state || mergedPayload.auth_state || mergedPayload.authState) + || (authUrl ? extractAuthStateFromUrl(authUrl) : ''); + if (authState) { + updates.contributionAuthState = authState; + } + + await applyRuntimeUpdates(updates); + const nextState = await getState(); + + if ( + normalizeString(nextState.contributionCallbackUrl) + && CALLBACK_WAITING_STATUSES.has(normalizeContributionCallbackStatus(nextState.contributionCallbackStatus)) + ) { + try { + return await submitContributionCallback(nextState.contributionCallbackUrl, { + reason: options.reason || 'status_poll', + stateOverride: nextState, + }); + } catch { + return getState(); + } + } + + return nextState; + } + + async function startContributionFlow(options = {}) { + const currentState = options.stateOverride || await getState(); + const shouldOpenAuthTab = options.openAuthTab !== false; + if (!currentState.contributionMode) { + throw new Error('请先进入贡献模式。'); + } + + const currentSessionId = normalizeString(currentState.contributionSessionId); + const currentStatus = normalizeContributionStatus(currentState.contributionStatus); + if (currentSessionId && ACTIVE_STATUSES.has(currentStatus)) { + if (normalizeString(currentState.contributionAuthUrl)) { + if (shouldOpenAuthTab) { + await openContributionAuthUrl(currentState.contributionAuthUrl, { + stateOverride: currentState, + }).catch(() => null); + } + } + return pollContributionStatus({ reason: 'resume_existing' }); + } + + const payload = await fetchContributionJson('/start', { + method: 'POST', + body: { + nickname: buildNickname(currentState, options.nickname), + source: 'cpa', + channel: 'codex-extension', + }, + }); + + const sessionId = normalizeString(payload.session_id || payload.sessionId); + const authUrl = normalizeString(payload.auth_url || payload.authUrl); + const authState = normalizeString(payload.state || payload.auth_state || payload.authState) || extractAuthStateFromUrl(authUrl); + if (!sessionId || !authUrl) { + throw new Error('贡献服务未返回有效的 session_id 或 auth_url。'); + } + + await applyRuntimeUpdates({ + contributionSessionId: sessionId, + contributionAuthUrl: authUrl, + contributionAuthState: authState, + contributionCallbackUrl: '', + contributionStatus: normalizeContributionStatus(payload.status) || 'started', + contributionStatusMessage: buildStatusMessage(normalizeContributionStatus(payload.status) || 'started', payload), + contributionLastPollAt: 0, + contributionCallbackStatus: 'waiting', + contributionCallbackMessage: buildCallbackMessage('waiting'), + contributionAuthOpenedAt: 0, + contributionAuthTabId: 0, + }); + + if (shouldOpenAuthTab) { + await openContributionAuthUrl(authUrl); + } + return pollContributionStatus({ reason: 'after_start' }); + } + + function onNavigationEvent(details = {}, source) { + if (details?.frameId !== undefined && Number(details.frameId) !== 0) { + return; + } + handleCapturedCallback(details?.url || '', { + source, + tabId: normalizePositiveInteger(details?.tabId, 0), + }).catch(() => {}); + } + + function onTabUpdated(tabId, changeInfo, tab) { + const candidateUrl = normalizeString(changeInfo?.url || tab?.url); + if (!candidateUrl) { + return; + } + handleCapturedCallback(candidateUrl, { + source: 'tabs.onUpdated', + tabId: normalizePositiveInteger(tabId, 0), + }).catch(() => {}); + } + + function ensureCallbackListeners() { + if (listenersBound) { + return; + } + + chrome.webNavigation.onCommitted.addListener((details) => { + onNavigationEvent(details, 'webNavigation.onCommitted'); + }); + chrome.webNavigation.onHistoryStateUpdated.addListener((details) => { + onNavigationEvent(details, 'webNavigation.onHistoryStateUpdated'); + }); + chrome.tabs.onUpdated.addListener(onTabUpdated); + listenersBound = true; + } + + return { + ensureCallbackListeners, + handleCapturedCallback, + isContributionCallbackUrl, + isContributionFinalStatus, + pollContributionStatus, + startContributionFlow, + submitContributionCallback, + }; + } + + return { + ACTIVE_STATUSES, + FINAL_STATUSES, + RUNTIME_DEFAULTS, + RUNTIME_KEYS, + createContributionOAuthManager, + }; +}); diff --git a/background/message-router.js b/background/message-router.js index 8eeea3f..fb93f4d 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -56,6 +56,7 @@ notifyStepComplete, notifyStepError, patchHotmailAccount, + pollContributionStatus, registerTab, requestStop, handleCloudflareSecurityBlocked, @@ -64,6 +65,7 @@ scheduleAutoRun, selectLuckmailPurchase, setCurrentHotmailAccount, + setContributionMode, setEmailState, setEmailStateSilently, setIcloudAliasPreservedState, @@ -76,6 +78,7 @@ setStepStatus, skipAutoRunCountdown, skipStep, + startContributionFlow, startAutoRunLoop, syncHotmailAccounts, testHotmailAccountMailAccess, @@ -288,6 +291,49 @@ return { ok: true }; } + case 'SET_CONTRIBUTION_MODE': { + const enabled = Boolean(message.payload?.enabled); + const state = await ensureManualInteractionAllowed(enabled ? '进入贡献模式' : '退出贡献模式'); + if (Object.values(state.stepStatuses || {}).some((status) => status === 'running')) { + throw new Error(enabled ? '当前有步骤正在执行,无法进入贡献模式。' : '当前有步骤正在执行,无法退出贡献模式。'); + } + if (typeof setContributionMode !== 'function') { + throw new Error('贡献模式切换能力未接入。'); + } + return { + ok: true, + state: await setContributionMode(enabled), + }; + } + + case 'START_CONTRIBUTION_FLOW': { + const state = await ensureManualInteractionAllowed('开始贡献'); + if (Object.values(state.stepStatuses || {}).some((status) => status === 'running')) { + throw new Error('当前有步骤正在执行,无法开始贡献流程。'); + } + if (typeof startContributionFlow !== 'function') { + throw new Error('贡献 OAuth 流程尚未接入。'); + } + return { + ok: true, + state: await startContributionFlow({ + nickname: message.payload?.nickname, + }), + }; + } + + case 'POLL_CONTRIBUTION_STATUS': { + if (typeof pollContributionStatus !== 'function') { + throw new Error('贡献状态轮询能力尚未接入。'); + } + return { + ok: true, + state: await pollContributionStatus({ + reason: message.payload?.reason || 'sidepanel_poll', + }), + }; + } + case 'CLEAR_ACCOUNT_RUN_HISTORY': { const state = await getState(); if (isAutoRunLockedState(state)) { diff --git a/sidepanel/contribution-mode.js b/sidepanel/contribution-mode.js new file mode 100644 index 0000000..abe59e2 --- /dev/null +++ b/sidepanel/contribution-mode.js @@ -0,0 +1,403 @@ +(function attachSidepanelContributionMode(globalScope) { + const ACTIVE_STATUSES = new Set(['started', 'waiting', 'processing']); + const FINAL_STATUSES = new Set(['auto_approved', 'auto_rejected', 'manual_review_required', 'expired', 'error']); + const DEFAULT_COPY = '当前账号将用于支持项目维护。扩展会自动申请贡献登录地址并持续跟踪授权状态;如检测到回调地址,会自动提交,无需手动复制。'; + + function createContributionModeManager(context = {}) { + const { + state, + dom, + helpers, + runtime, + constants = {}, + } = context; + + const contributionUploadUrl = constants.contributionUploadUrl || 'https://apikey.qzz.io/'; + const pollIntervalMs = Math.max(1500, Math.floor(Number(constants.pollIntervalMs) || 2500)); + + const hiddenRows = [ + dom.rowVpsUrl, + dom.rowVpsPassword, + dom.rowLocalCpaStep9Mode, + dom.rowSub2ApiUrl, + dom.rowSub2ApiEmail, + dom.rowSub2ApiPassword, + dom.rowSub2ApiGroup, + dom.rowSub2ApiDefaultProxy, + dom.rowCustomPassword, + dom.rowAccountRunHistoryTextEnabled, + dom.rowAccountRunHistoryHelperBaseUrl, + ].filter(Boolean); + + let actionInFlight = false; + let pollInFlight = false; + let pollTimer = null; + + function getLatestState() { + return state.getLatestState?.() || {}; + } + + function normalizeString(value = '') { + return String(value || '').trim(); + } + + function normalizeStatus(value = '') { + const normalized = normalizeString(value).toLowerCase(); + if (ACTIVE_STATUSES.has(normalized) || FINAL_STATUSES.has(normalized)) { + return normalized; + } + return ''; + } + + function normalizeCallbackStatus(value = '') { + const normalized = normalizeString(value).toLowerCase(); + switch (normalized) { + case 'waiting': + case 'captured': + case 'submitting': + case 'submitted': + case 'not_required': + case 'failed': + case 'idle': + return normalized; + default: + return ''; + } + } + + function isContributionModeEnabled(currentState = getLatestState()) { + return Boolean(currentState.contributionMode); + } + + function hasActiveContributionSession(currentState = getLatestState()) { + const status = normalizeStatus(currentState.contributionStatus); + return Boolean(normalizeString(currentState.contributionSessionId) && status && !FINAL_STATUSES.has(status)); + } + + function isModeSwitchBlocked() { + return Boolean(helpers.isModeSwitchBlocked?.(getLatestState())); + } + + function setContributionHidden(element, hidden) { + element?.classList.toggle('is-contribution-hidden', hidden); + } + + function syncContributionRows(enabled) { + hiddenRows.forEach((row) => { + setContributionHidden(row, enabled); + }); + } + + function syncContributionButton(enabled, blocked) { + if (!dom.btnContributionMode) { + return; + } + + dom.btnContributionMode.classList.toggle('is-active', enabled); + dom.btnContributionMode.setAttribute('aria-pressed', String(enabled)); + dom.btnContributionMode.disabled = enabled || blocked; + dom.btnContributionMode.title = blocked + ? '当前流程运行中,暂时不能切换贡献模式' + : (enabled ? '当前已在贡献模式' : '进入贡献模式'); + } + + function stopPolling() { + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } + } + + function schedulePolling(delayMs = pollIntervalMs) { + stopPolling(); + if (!isContributionModeEnabled() || !hasActiveContributionSession()) { + return; + } + + pollTimer = setTimeout(() => { + pollOnce({ silentError: true }).catch(() => {}); + }, delayMs); + } + + function ensurePolling() { + if (!isContributionModeEnabled() || !hasActiveContributionSession()) { + stopPolling(); + return; + } + + if (!pollTimer && !pollInFlight) { + schedulePolling(1200); + } + } + + function getOauthStatusText(currentState = getLatestState()) { + const status = normalizeStatus(currentState.contributionStatus); + const hasAuthUrl = Boolean(normalizeString(currentState.contributionAuthUrl)); + if (!normalizeString(currentState.contributionSessionId) || !hasAuthUrl) { + return '未生成登录地址'; + } + if (status === 'waiting') { + return '等待授权完成'; + } + if (status === 'processing' || status === 'auto_approved' || status === 'auto_rejected' || status === 'manual_review_required') { + return '授权已完成'; + } + if (status === 'expired' || status === 'error') { + return '授权失败'; + } + if (Number(currentState.contributionAuthOpenedAt) > 0) { + return '已打开授权页'; + } + return '登录地址已生成'; + } + + function getCallbackStatusText(currentState = getLatestState()) { + const status = normalizeCallbackStatus(currentState.contributionCallbackStatus); + switch (status) { + case 'captured': + return '已捕获回调地址'; + case 'submitting': + return '正在提交回调'; + case 'submitted': + return '已提交回调'; + case 'not_required': + return '当前流程无需手动回调'; + case 'failed': + return '回调提交失败'; + case 'waiting': + case 'idle': + default: + return normalizeString(currentState.contributionCallbackUrl) + ? '已捕获回调地址' + : '等待回调'; + } + } + + function getSummaryText(currentState = getLatestState()) { + return normalizeString(currentState.contributionStatusMessage) || DEFAULT_COPY; + } + + async function requestContributionMode(enabled) { + const response = await runtime.sendMessage({ + type: 'SET_CONTRIBUTION_MODE', + source: 'sidepanel', + payload: { enabled: Boolean(enabled) }, + }); + + if (response?.error) { + throw new Error(response.error); + } + if (!response?.state) { + throw new Error('贡献模式切换后未返回最新状态。'); + } + + helpers.applySettingsState?.(response.state); + helpers.updateStatusDisplay?.(response.state); + render(); + } + + async function pollOnce(options = {}) { + if (pollInFlight || !isContributionModeEnabled() || !hasActiveContributionSession()) { + if (!hasActiveContributionSession()) { + stopPolling(); + } + return; + } + + pollInFlight = true; + try { + const response = await runtime.sendMessage({ + type: 'POLL_CONTRIBUTION_STATUS', + source: 'sidepanel', + payload: { + reason: options.reason || 'sidepanel_poll', + }, + }); + + if (response?.error) { + throw new Error(response.error); + } + if (response?.state) { + helpers.applySettingsState?.(response.state); + helpers.updateStatusDisplay?.(response.state); + } + } finally { + pollInFlight = false; + render(); + if (hasActiveContributionSession()) { + schedulePolling(); + } else { + stopPolling(); + } + } + } + + async function startContributionFlow() { + if (typeof helpers.startContributionAutoRun !== 'function') { + throw new Error('贡献模式尚未接入主自动流程启动能力。'); + } + + const started = await helpers.startContributionAutoRun(); + if (!started) { + return; + } + + helpers.showToast?.('贡献自动流程已启动。', 'info', 1800); + render(); + } + + async function enterContributionMode() { + const confirmed = await helpers.openConfirmModal?.({ + title: '贡献账号', + message: '是否确认给作者 QLHazyCoder 提供账号以支持继续维护项目?', + confirmLabel: '确定', + confirmVariant: 'btn-primary', + }); + + if (!confirmed) { + return; + } + + await requestContributionMode(true); + helpers.showToast?.('已进入贡献模式。', 'success', 1800); + } + + async function exitContributionMode() { + stopPolling(); + await requestContributionMode(false); + helpers.showToast?.('已退出贡献模式。', 'info', 1800); + } + + function render() { + const currentState = getLatestState(); + const enabled = isContributionModeEnabled(currentState); + const blocked = isModeSwitchBlocked(); + + if (enabled && dom.selectPanelMode) { + dom.selectPanelMode.value = 'cpa'; + } + + helpers.updatePanelModeUI?.(); + helpers.updateAccountRunHistorySettingsUI?.(); + + if (dom.contributionModePanel) { + dom.contributionModePanel.hidden = !enabled; + } + if (dom.contributionModeText) { + dom.contributionModeText.textContent = DEFAULT_COPY; + } + if (dom.contributionOauthStatus) { + dom.contributionOauthStatus.textContent = getOauthStatusText(currentState); + } + if (dom.contributionCallbackStatus) { + dom.contributionCallbackStatus.textContent = getCallbackStatusText(currentState); + } + if (dom.contributionModeSummary) { + dom.contributionModeSummary.textContent = getSummaryText(currentState); + } + + syncContributionRows(enabled); + syncContributionButton(enabled, blocked); + + if (dom.selectPanelMode) { + dom.selectPanelMode.disabled = enabled; + } + + if (dom.btnStartContribution) { + dom.btnStartContribution.disabled = actionInFlight || blocked; + } + + if (dom.btnOpenContributionUpload) { + dom.btnOpenContributionUpload.disabled = false; + } + + if (dom.btnExitContributionMode) { + dom.btnExitContributionMode.disabled = actionInFlight || blocked; + dom.btnExitContributionMode.title = blocked ? '当前流程运行中,暂时不能退出贡献模式' : '退出贡献模式'; + } + + if (dom.btnOpenAccountRecords) { + dom.btnOpenAccountRecords.disabled = enabled; + } + + if (enabled) { + helpers.closeConfigMenu?.(); + helpers.closeAccountRecordsPanel?.(); + ensurePolling(); + } else { + stopPolling(); + } + + helpers.updateConfigMenuControls?.(); + } + + function bindEvents() { + dom.btnContributionMode?.addEventListener('click', async () => { + if (actionInFlight) { + return; + } + actionInFlight = true; + render(); + try { + await enterContributionMode(); + } catch (error) { + helpers.showToast?.(error.message, 'error'); + } finally { + actionInFlight = false; + render(); + } + }); + + dom.btnStartContribution?.addEventListener('click', async () => { + if (actionInFlight) { + return; + } + actionInFlight = true; + render(); + try { + await startContributionFlow(); + } catch (error) { + helpers.showToast?.(error.message, 'error'); + } finally { + actionInFlight = false; + render(); + } + }); + + dom.btnOpenContributionUpload?.addEventListener('click', () => { + try { + helpers.openExternalUrl?.(contributionUploadUrl); + } catch (error) { + helpers.showToast?.(`打开上传页面失败:${error.message}`, 'error'); + } + }); + + dom.btnExitContributionMode?.addEventListener('click', async () => { + if (actionInFlight) { + return; + } + actionInFlight = true; + render(); + try { + await exitContributionMode(); + } catch (error) { + helpers.showToast?.(error.message, 'error'); + } finally { + actionInFlight = false; + render(); + } + }); + } + + return { + bindEvents, + pollOnce, + render, + stopPolling, + }; + } + + globalScope.SidepanelContributionMode = { + createContributionModeManager, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index 55aedf5..afa9b9e 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -96,7 +96,9 @@ body { header { display: flex; justify-content: space-between; - align-items: center; + align-items: flex-start; + flex-wrap: wrap; + gap: 10px; position: sticky; top: 0; z-index: 200; @@ -183,6 +185,18 @@ header { align-items: center; gap: 4px; flex-shrink: 0; + flex-wrap: wrap; + justify-content: flex-end; +} + +.btn-contribution-mode { + padding-inline: 10px; +} + +.btn-contribution-mode.is-active { + border-color: color-mix(in srgb, var(--orange) 58%, var(--border)); + background: color-mix(in srgb, var(--orange) 14%, var(--bg-base)); + color: var(--orange); } /* ============================================================ @@ -310,6 +324,7 @@ header { display: flex; align-items: center; gap: 4px; + flex-wrap: wrap; } .run-count-input { @@ -491,6 +506,108 @@ header { pointer-events: none; } +.is-contribution-hidden { + display: none !important; +} + +.contribution-mode-panel { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + border: 1px solid color-mix(in srgb, var(--orange) 28%, var(--border)); + border-radius: var(--radius-md); + background: + linear-gradient(180deg, + color-mix(in srgb, var(--orange) 10%, var(--bg-base)), + color-mix(in srgb, var(--bg-surface) 96%, var(--bg-base)) + ); +} + +.contribution-mode-panel[hidden] { + display: none !important; +} + +.contribution-mode-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.contribution-mode-badge { + padding: 4px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--orange) 16%, var(--bg-base)); + color: var(--orange); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; +} + +.contribution-mode-text { + margin: 0; + font-size: 13px; + line-height: 1.6; + color: var(--text-secondary); +} + +.contribution-mode-status-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.contribution-mode-status-card { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-base) 92%, var(--orange-soft)); +} + +.contribution-mode-status-label { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + color: var(--text-muted); +} + +.contribution-mode-status-value { + font-size: 13px; + font-weight: 600; + line-height: 1.5; + color: var(--text-primary); +} + +.contribution-mode-summary { + padding: 10px 12px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-base) 88%, var(--orange-soft)); + color: var(--text-secondary); + font-size: 12px; + line-height: 1.6; +} + +.contribution-mode-actions { + display: flex; + flex-direction: column; + gap: 8px; +} + +.contribution-mode-actions .btn { + width: 100%; + justify-content: center; +} + +@media (max-width: 540px) { + .contribution-mode-status-grid { + grid-template-columns: 1fr; + } +} + .section-mini-header { display: flex; align-items: center; diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index f405a4e..e0af5e8 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -46,6 +46,8 @@ + + + + +
CPA
@@ -145,7 +170,7 @@
-
+
账户密码
-
+
本地同步 -
+
-
+
+
+
+ 验证码 +
+
验证码重发
+ diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index cc4b3a5..de5c20c 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -30,6 +30,14 @@ const updateCardSummary = document.getElementById('update-card-summary'); const updateReleaseList = document.getElementById('update-release-list'); const btnOpenRelease = document.getElementById('btn-open-release'); const settingsCard = document.getElementById('settings-card'); +const contributionModePanel = document.getElementById('contribution-mode-panel'); +const contributionModeText = document.getElementById('contribution-mode-text'); +const contributionOauthStatus = document.getElementById('contribution-oauth-status'); +const contributionCallbackStatus = document.getElementById('contribution-callback-status'); +const contributionModeSummary = document.getElementById('contribution-mode-summary'); +const btnStartContribution = document.getElementById('btn-start-contribution'); +const btnOpenContributionUpload = document.getElementById('btn-open-contribution-upload'); +const btnExitContributionMode = document.getElementById('btn-exit-contribution-mode'); const displayOauthUrl = document.getElementById('display-oauth-url'); const displayLocalhostUrl = document.getElementById('display-localhost-url'); const displayStatus = document.getElementById('display-status'); @@ -43,6 +51,7 @@ const btnTogglePassword = document.getElementById('btn-toggle-password'); const btnSaveSettings = document.getElementById('btn-save-settings'); const btnStop = document.getElementById('btn-stop'); const btnReset = document.getElementById('btn-reset'); +const btnContributionMode = document.getElementById('btn-contribution-mode'); const stepsProgress = document.getElementById('steps-progress'); const btnAutoRun = document.getElementById('btn-auto-run'); const btnAutoContinue = document.getElementById('btn-auto-continue'); @@ -76,6 +85,7 @@ const rowSub2ApiGroup = document.getElementById('row-sub2api-group'); const inputSub2ApiGroup = document.getElementById('input-sub2api-group'); const rowSub2ApiDefaultProxy = document.getElementById('row-sub2api-default-proxy'); const inputSub2ApiDefaultProxy = document.getElementById('input-sub2api-default-proxy'); +const rowCustomPassword = document.getElementById('row-custom-password'); const selectMailProvider = document.getElementById('select-mail-provider'); const btnMailLogin = document.getElementById('btn-mail-login'); const rowMail2925Mode = document.getElementById('row-mail-2925-mode'); @@ -171,6 +181,7 @@ 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 inputVerificationResendCount = document.getElementById('input-verification-resend-count'); +const rowAccountRunHistoryTextEnabled = document.getElementById('row-account-run-history-text-enabled'); const inputAccountRunHistoryTextEnabled = document.getElementById('input-account-run-history-text-enabled'); const rowAccountRunHistoryHelperBaseUrl = document.getElementById('row-account-run-history-helper-base-url'); const inputAccountRunHistoryHelperBaseUrl = document.getElementById('input-account-run-history-helper-base-url'); @@ -771,18 +782,23 @@ async function openAutoRunFallbackRiskConfirmModal(totalRuns, fallbackThreadInte function updateConfigMenuControls() { const disabled = configActionInFlight || settingsSaveInFlight; + const contributionModeEnabled = Boolean(latestState?.contributionMode); + if (contributionModeEnabled && configMenuOpen) { + configMenuOpen = false; + } const importLocked = disabled + || contributionModeEnabled || currentAutoRun.autoRunning || Object.values(getStepStatuses()).some((status) => status === 'running'); if (btnConfigMenu) { - btnConfigMenu.disabled = disabled; + btnConfigMenu.disabled = disabled || contributionModeEnabled; btnConfigMenu.setAttribute('aria-expanded', String(configMenuOpen)); } if (configMenu) { - configMenu.hidden = !configMenuOpen; + configMenu.hidden = contributionModeEnabled || !configMenuOpen; } if (btnExportSettings) { - btnExportSettings.disabled = disabled; + btnExportSettings.disabled = disabled || contributionModeEnabled; } if (btnImportSettings) { btnImportSettings.disabled = importLocked; @@ -865,6 +881,12 @@ function hasSavedProgress(state = latestState) { return Object.values(statuses).some((status) => status !== 'pending'); } +function isContributionModeSwitchBlocked(state = latestState) { + const statuses = getStepStatuses(state); + const anyRunning = Object.values(statuses).some((status) => status === 'running'); + return anyRunning || isAutoRunLockedPhase() || isAutoRunPausedPhase() || isAutoRunScheduledPhase(); +} + function shouldOfferAutoModeChoice(state = latestState) { return hasSavedProgress(state) && getFirstUnfinishedStep(state) !== null; } @@ -1300,6 +1322,7 @@ function collectSettingsPayload() { const selectedCloudflareTempEmailDomain = normalizeCloudflareTempEmailDomainValue( !cloudflareTempEmailDomainEditMode ? selectTempEmailDomain.value : tempEmailActiveDomain ) || tempEmailActiveDomain; + const contributionModeEnabled = Boolean(latestState?.contributionMode); return { panelMode: selectPanelMode.value, vpsUrl: inputVpsUrl.value.trim(), @@ -1310,14 +1333,18 @@ function collectSettingsPayload() { sub2apiPassword: inputSub2ApiPassword.value, sub2apiGroupName: inputSub2ApiGroup.value.trim(), sub2apiDefaultProxyName: inputSub2ApiDefaultProxy.value.trim(), - customPassword: inputPassword.value, + ...(contributionModeEnabled ? {} : { + customPassword: inputPassword.value, + }), mailProvider: selectMailProvider.value, mail2925Mode: getSelectedMail2925Mode(), emailGenerator: selectEmailGenerator.value, autoDeleteUsedIcloudAlias: checkboxAutoDeleteIcloud?.checked, icloudHostPreference: selectIcloudHostPreference?.value || 'auto', - accountRunHistoryTextEnabled: Boolean(inputAccountRunHistoryTextEnabled?.checked), - accountRunHistoryHelperBaseUrl: normalizeAccountRunHistoryHelperBaseUrlValue(inputAccountRunHistoryHelperBaseUrl?.value), + ...(contributionModeEnabled ? {} : { + accountRunHistoryTextEnabled: Boolean(inputAccountRunHistoryTextEnabled?.checked), + accountRunHistoryHelperBaseUrl: normalizeAccountRunHistoryHelperBaseUrlValue(inputAccountRunHistoryHelperBaseUrl?.value), + }), ...buildManagedAliasBaseEmailPayload(), inbucketHost: inputInbucketHost.value.trim(), inbucketMailbox: inputInbucketMailbox.value.trim(), @@ -1442,7 +1469,9 @@ function updateAccountRunHistorySettingsUI() { return; } - rowAccountRunHistoryHelperBaseUrl.style.display = inputAccountRunHistoryTextEnabled.checked ? '' : 'none'; + rowAccountRunHistoryHelperBaseUrl.style.display = inputAccountRunHistoryTextEnabled.checked && !latestState?.contributionMode + ? '' + : 'none'; } function setSettingsCardLocked(locked) { @@ -1613,6 +1642,7 @@ function applyAutoRunStatus(payload = currentAutoRun) { syncScheduledCountdownTicker(); updateStopButtonState(scheduled || paused || locked || Object.values(getStepStatuses()).some(status => status === 'running')); updateConfigMenuControls(); + renderContributionMode(); } function initializeManualStepActions() { @@ -1794,6 +1824,7 @@ async function restoreState() { updateStatusDisplay(latestState); updateProgressCounter(); + renderContributionMode(); } catch (err) { console.error('Failed to restore state:', err); } @@ -2008,7 +2039,7 @@ async function initializeReleaseInfo() { } function syncPasswordField(state) { - inputPassword.value = state.customPassword || state.password || ''; + inputPassword.value = state?.contributionMode ? '' : (state.customPassword || state.password || ''); } function isCustomMailProvider(provider = selectMailProvider.value) { @@ -2569,6 +2600,7 @@ function updateButtonStates() { if (selectIcloudHostPreference) selectIcloudHostPreference.disabled = disableIcloudControls; if (checkboxAutoDeleteIcloud) checkboxAutoDeleteIcloud.disabled = disableIcloudControls; updateStopButtonState(anyRunning || autoScheduled || isAutoRunPausedPhase() || autoLocked); + renderContributionMode(); } function updateStopButtonState(active) { @@ -2943,7 +2975,66 @@ const renderAccountRecords = accountRecordsManager?.render || (() => { }); const bindAccountRecordEvents = accountRecordsManager?.bindEvents || (() => { }); +const closeAccountRecordsPanel = accountRecordsManager?.closePanel + || (() => { }); bindAccountRecordEvents(); +const contributionModeManager = window.SidepanelContributionMode?.createContributionModeManager({ + state: { + getLatestState: () => latestState, + }, + dom: { + btnConfigMenu, + btnContributionMode, + contributionCallbackStatus, + btnExitContributionMode, + btnOpenAccountRecords, + btnOpenContributionUpload, + btnStartContribution, + contributionModePanel, + contributionModeSummary, + contributionModeText, + contributionOauthStatus, + rowAccountRunHistoryHelperBaseUrl, + rowAccountRunHistoryTextEnabled, + rowCustomPassword, + rowLocalCpaStep9Mode, + rowSub2ApiDefaultProxy, + rowSub2ApiEmail, + rowSub2ApiGroup, + rowSub2ApiPassword, + rowSub2ApiUrl, + rowVpsPassword, + rowVpsUrl, + selectPanelMode, + }, + helpers: { + applySettingsState, + closeAccountRecordsPanel, + closeConfigMenu, + getContributionNickname: () => latestState?.email || '', + isModeSwitchBlocked: isContributionModeSwitchBlocked, + openConfirmModal, + openExternalUrl, + showToast, + startContributionAutoRun: () => startAutoRunFromCurrentSettings(), + updateAccountRunHistorySettingsUI, + updateConfigMenuControls, + updatePanelModeUI, + updateStatusDisplay, + }, + runtime: { + sendMessage: (message) => chrome.runtime.sendMessage(message), + }, + constants: { + contributionOauthUrl: 'https://apikey.qzz.io/oauth/', + contributionUploadUrl: 'https://apikey.qzz.io/', + }, +}); +const renderContributionMode = contributionModeManager?.render + || (() => { }); +const bindContributionModeEvents = contributionModeManager?.bindEvents + || (() => { }); +bindContributionModeEvents(); renderStepsList(); async function exportSettingsFile() { @@ -3287,9 +3378,64 @@ autoStartModal?.addEventListener('click', (event) => { }); btnAutoStartClose?.addEventListener('click', () => resolveModalChoice(null)); +async function startAutoRunFromCurrentSettings() { + const totalRuns = getRunCountValue(); + let mode = 'restart'; + const autoRunSkipFailures = inputAutoSkipFailures.checked; + const fallbackThreadIntervalMinutes = normalizeAutoRunThreadIntervalMinutes( + inputAutoSkipFailuresThreadIntervalMinutes.value + ); + inputAutoSkipFailuresThreadIntervalMinutes.value = String(fallbackThreadIntervalMinutes); + + if (shouldOfferAutoModeChoice()) { + const startStep = getFirstUnfinishedStep(); + const runningStep = getRunningSteps()[0] ?? null; + const choice = await openAutoStartChoiceDialog(startStep, { runningStep }); + if (!choice) { + return false; + } + mode = choice; + } + + if (shouldWarnAutoRunFallbackRisk(totalRuns, autoRunSkipFailures) + && !isAutoRunFallbackRiskPromptDismissed()) { + const result = await openAutoRunFallbackRiskConfirmModal(totalRuns, fallbackThreadIntervalMinutes); + if (!result.confirmed) { + return false; + } + if (result.dismissPrompt) { + setAutoRunFallbackRiskPromptDismissed(true); + } + } + + btnAutoRun.disabled = true; + inputRunCount.disabled = true; + const delayEnabled = inputAutoDelayEnabled.checked; + const delayMinutes = normalizeAutoDelayMinutes(inputAutoDelayMinutes.value); + inputAutoDelayMinutes.value = String(delayMinutes); + btnAutoRun.innerHTML = delayEnabled + ? ' 璁″垝涓?..' + : ' 杩愯涓?..'; + const response = await chrome.runtime.sendMessage({ + type: delayEnabled ? 'SCHEDULE_AUTO_RUN' : 'AUTO_RUN', + source: 'sidepanel', + payload: { + totalRuns, + delayMinutes, + autoRunSkipFailures, + mode, + }, + }); + if (response?.error) { + throw new Error(response.error); + } + return true; +} + // Auto Run btnAutoRun.addEventListener('click', async () => { try { + return await startAutoRunFromCurrentSettings(); const totalRuns = getRunCountValue(); let mode = 'restart'; const autoRunSkipFailures = inputAutoSkipFailures.checked; @@ -3989,12 +4135,20 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { if (message.payload.email !== undefined) { inputEmail.value = message.payload.email || ''; } - if (message.payload.password !== undefined) { - inputPassword.value = message.payload.password || ''; + if ( + message.payload.password !== undefined + || message.payload.customPassword !== undefined + || message.payload.contributionMode !== undefined + ) { + syncPasswordField(latestState || {}); } if (message.payload.localCpaStep9Mode !== undefined) { setLocalCpaStep9Mode(message.payload.localCpaStep9Mode); } + if (message.payload.panelMode !== undefined) { + selectPanelMode.value = message.payload.panelMode || 'cpa'; + updatePanelModeUI(); + } if (message.payload.oauthUrl !== undefined) { displayOauthUrl.textContent = message.payload.oauthUrl || '等待中...'; displayOauthUrl.classList.toggle('has-value', Boolean(message.payload.oauthUrl)); @@ -4097,6 +4251,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { ) ); } + renderContributionMode(); break; } diff --git a/tests/background-contribution-mode.test.js b/tests/background-contribution-mode.test.js new file mode 100644 index 0000000..bc34e6c --- /dev/null +++ b/tests/background-contribution-mode.test.js @@ -0,0 +1,441 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const backgroundSource = fs.readFileSync('background.js', 'utf8'); + +function extractFunction(source, 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 i = start; i < source.length; i += 1) { + const ch = source[i]; + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (ch === '{' && signatureEnded) { + braceStart = i; + 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 createMockResponse(ok, status, payload) { + return { + ok, + status, + async json() { + return payload; + }, + }; +} + +test('background imports contribution oauth module and keeps contribution runtime out of persisted settings', () => { + const persistedStart = backgroundSource.indexOf('const PERSISTED_SETTING_DEFAULTS = {'); + const persistedEnd = backgroundSource.indexOf('const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);'); + const defaultStateStart = backgroundSource.indexOf('const DEFAULT_STATE = {'); + const defaultStateEnd = backgroundSource.indexOf('async function getState()'); + + const persistedBlock = backgroundSource.slice(persistedStart, persistedEnd); + const defaultStateBlock = backgroundSource.slice(defaultStateStart, defaultStateEnd); + + assert.match(backgroundSource, /background\/contribution-oauth\.js/); + assert.doesNotMatch(persistedBlock, /contributionSessionId|contributionAuthUrl|contributionCallbackUrl|contributionStatus/); + assert.match(defaultStateBlock, /contributionMode:\s*false|CONTRIBUTION_RUNTIME_DEFAULTS/); +}); + +test('contribution oauth module exposes a factory', () => { + const source = fs.readFileSync('background/contribution-oauth.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', 'fetch', `${source}; return self.MultiPageBackgroundContributionOAuth;`)( + globalScope, + async () => createMockResponse(true, 200, { ok: true }) + ); + + assert.equal(typeof api?.createContributionOAuthManager, 'function'); + assert.equal(Array.isArray(api?.RUNTIME_KEYS), true); +}); + +test('buildContributionModeState preserves active contribution runtime while forcing CPA mode', () => { + const bundle = extractFunction(backgroundSource, 'buildContributionModeState'); + + const api = new Function(` +const DEFAULT_STATE = { panelMode: 'cpa' }; +const CONTRIBUTION_RUNTIME_DEFAULTS = { + contributionMode: false, + contributionSessionId: '', + contributionAuthUrl: '', + contributionAuthState: '', + contributionCallbackUrl: '', + contributionStatus: '', + contributionStatusMessage: '', + contributionLastPollAt: 0, + contributionCallbackStatus: 'idle', + contributionCallbackMessage: '', + contributionAuthOpenedAt: 0, + contributionAuthTabId: 0, +}; +const CONTRIBUTION_RUNTIME_KEYS = Object.keys(CONTRIBUTION_RUNTIME_DEFAULTS); +${bundle} +return { buildContributionModeState }; +`)(); + + assert.deepStrictEqual( + api.buildContributionModeState(true, { + panelMode: 'sub2api', + customPassword: 'Secret123!', + accountRunHistoryTextEnabled: true, + }, { + contributionSessionId: 'session-001', + contributionAuthUrl: 'https://auth.example.com', + contributionStatus: 'waiting', + contributionCallbackStatus: 'waiting', + }), + { + contributionMode: true, + contributionSessionId: 'session-001', + contributionAuthUrl: 'https://auth.example.com', + contributionAuthState: '', + contributionCallbackUrl: '', + contributionStatus: 'waiting', + contributionStatusMessage: '', + contributionLastPollAt: 0, + contributionCallbackStatus: 'waiting', + contributionCallbackMessage: '', + contributionAuthOpenedAt: 0, + contributionAuthTabId: 0, + panelMode: 'cpa', + customPassword: '', + accountRunHistoryTextEnabled: false, + } + ); + + assert.deepStrictEqual( + api.buildContributionModeState(false, { + panelMode: 'sub2api', + customPassword: 'Secret123!', + accountRunHistoryTextEnabled: true, + }, { + contributionSessionId: 'session-001', + contributionAuthUrl: 'https://auth.example.com', + contributionStatus: 'waiting', + }), + { + contributionMode: false, + contributionSessionId: '', + contributionAuthUrl: '', + contributionAuthState: '', + contributionCallbackUrl: '', + contributionStatus: '', + contributionStatusMessage: '', + contributionLastPollAt: 0, + contributionCallbackStatus: 'idle', + contributionCallbackMessage: '', + contributionAuthOpenedAt: 0, + contributionAuthTabId: 0, + panelMode: 'sub2api', + customPassword: 'Secret123!', + accountRunHistoryTextEnabled: true, + } + ); +}); + +test('resetState preserves contribution runtime across reset', () => { + assert.match(backgroundSource, /CONTRIBUTION_RUNTIME_KEYS/); + assert.match(backgroundSource, /const contributionModeState = buildContributionModeState/); + assert.match(backgroundSource, /\.\.\.contributionModeState/); +}); + +test('message router handles contribution mode, start flow, and status polling messages', async () => { + const source = fs.readFileSync('background/message-router.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope); + + const calls = []; + const router = api.createMessageRouter({ + ensureManualInteractionAllowed: async () => ({ + stepStatuses: { 1: 'pending', 2: 'completed' }, + contributionMode: true, + }), + pollContributionStatus: async (options) => { + calls.push({ type: 'poll', options }); + return { contributionStatus: 'waiting' }; + }, + setContributionMode: async (enabled) => { + calls.push({ type: 'toggle', enabled }); + return { + contributionMode: Boolean(enabled), + panelMode: 'cpa', + }; + }, + startContributionFlow: async (options) => { + calls.push({ type: 'start', options }); + return { + contributionMode: true, + contributionSessionId: 'session-001', + contributionStatus: 'started', + }; + }, + }); + + const enableResponse = await router.handleMessage({ + type: 'SET_CONTRIBUTION_MODE', + payload: { enabled: true }, + }); + const startResponse = await router.handleMessage({ + type: 'START_CONTRIBUTION_FLOW', + payload: { nickname: '阿青' }, + }); + const pollResponse = await router.handleMessage({ + type: 'POLL_CONTRIBUTION_STATUS', + payload: { reason: 'test_poll' }, + }); + + assert.equal(enableResponse.ok, true); + assert.equal(startResponse.ok, true); + assert.equal(pollResponse.ok, true); + assert.deepStrictEqual(calls, [ + { type: 'toggle', enabled: true }, + { type: 'start', options: { nickname: '阿青' } }, + { type: 'poll', options: { reason: 'test_poll' } }, + ]); +}); + +test('account run history snapshot sync is disabled in contribution mode', () => { + const source = fs.readFileSync('background/account-run-history.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageBackgroundAccountRunHistory;`)(globalScope); + + const helpers = api.createAccountRunHistoryHelpers({ + addLog: async () => {}, + buildLocalHelperEndpoint: (baseUrl, path) => `${baseUrl}${path}`, + chrome: { + storage: { + local: { + get: async () => ({}), + set: async () => {}, + }, + }, + }, + getErrorMessage: (error) => error?.message || String(error || ''), + getState: async () => ({}), + normalizeAccountRunHistoryHelperBaseUrl: (value) => String(value || '').trim(), + }); + + assert.equal( + helpers.shouldSyncAccountRunHistorySnapshot({ + contributionMode: true, + accountRunHistoryTextEnabled: true, + accountRunHistoryHelperBaseUrl: 'http://127.0.0.1:17373', + }), + false + ); + + assert.equal( + helpers.shouldSyncAccountRunHistorySnapshot({ + contributionMode: false, + accountRunHistoryTextEnabled: true, + accountRunHistoryHelperBaseUrl: 'http://127.0.0.1:17373', + }), + true + ); +}); + +test('contribution oauth manager starts session, opens auth url, polls real statuses, and treats callback submit no-op as success', async () => { + const source = fs.readFileSync('background/contribution-oauth.js', 'utf8'); + const globalScope = {}; + const fetchCalls = []; + const tabCalls = []; + const closeCallbackCalls = []; + let currentState = { + contributionMode: true, + email: 'user@example.com', + contributionSessionId: '', + contributionStatus: '', + contributionCallbackStatus: 'idle', + }; + const broadcasts = []; + + const api = new Function('self', 'fetch', `${source}; return self.MultiPageBackgroundContributionOAuth;`)( + globalScope, + async (url, options = {}) => { + fetchCalls.push({ url, options }); + if (String(url).endsWith('/start')) { + return createMockResponse(true, 200, { + ok: true, + session_id: 'session-001', + state: 'oauth-state-001', + auth_url: 'https://auth.example.com/oauth?state=oauth-state-001', + message: '登录地址已生成', + }); + } + if (String(url).includes('/status?')) { + return createMockResponse(true, 200, { + ok: true, + session_id: 'session-001', + status: 'waiting', + message: '等待 OAuth 回调完成', + }); + } + if (String(url).endsWith('/submit-callback')) { + return createMockResponse(false, 400, { + ok: false, + message: '当前已启用自动回调转发,无需手动粘贴回调 URL。', + callback_url_received: true, + }); + } + return createMockResponse(true, 200, { + ok: true, + session_id: 'session-001', + status: 'processing', + message: '授权已完成,正在自动审核并导入。', + }); + } + ); + + const manager = api.createContributionOAuthManager({ + addLog: async () => {}, + broadcastDataUpdate(updates) { + broadcasts.push(updates); + currentState = { ...currentState, ...updates }; + }, + chrome: { + tabs: { + async create(payload) { + tabCalls.push(payload); + return { id: 88, url: payload.url }; + }, + async update() { + return null; + }, + onUpdated: { addListener() {} }, + }, + webNavigation: { + onCommitted: { addListener() {} }, + onHistoryStateUpdated: { addListener() {} }, + }, + }, + closeLocalhostCallbackTabs: async (callbackUrl) => { + closeCallbackCalls.push(callbackUrl); + }, + getState: async () => currentState, + setState: async (updates) => { + currentState = { ...currentState, ...updates }; + }, + }); + + const startedState = await manager.startContributionFlow(); + assert.equal(startedState.contributionSessionId, 'session-001'); + assert.equal(startedState.contributionAuthState, 'oauth-state-001'); + assert.equal(startedState.contributionStatus, 'waiting'); + assert.equal(startedState.contributionAuthTabId, 88); + assert.equal(tabCalls.length, 1); + assert.match(fetchCalls[0].url, /\/start$/); + assert.match(fetchCalls[1].url, /\/status\?/); + + const callbackState = await manager.handleCapturedCallback( + 'http://localhost:1455/auth/callback?code=abc123&state=oauth-state-001', + { source: 'test' } + ); + + assert.equal(callbackState.contributionCallbackUrl, 'http://localhost:1455/auth/callback?code=abc123&state=oauth-state-001'); + assert.equal(callbackState.contributionCallbackStatus, 'not_required'); + assert.equal(closeCallbackCalls[0], 'http://localhost:1455/auth/callback?code=abc123&state=oauth-state-001'); + assert.ok(fetchCalls.some((call) => String(call.url).endsWith('/submit-callback'))); + assert.ok(broadcasts.some((item) => item.contributionCallbackStatus === 'captured')); + assert.ok(broadcasts.some((item) => item.contributionCallbackStatus === 'not_required')); +}); + +test('refreshOAuthUrlBeforeStep6 uses contribution oauth session instead of panel bridge in contribution mode', async () => { + const bundle = extractFunction(backgroundSource, 'refreshOAuthUrlBeforeStep6'); + const calls = []; + + const api = new Function(` +${bundle} +return { refreshOAuthUrlBeforeStep6 }; +`)(); + + globalThis.addLog = async (message) => { + calls.push({ type: 'log', message }); + }; + globalThis.contributionOAuthManager = { + async startContributionFlow(options) { + calls.push({ type: 'contribution', options }); + return { + contributionAuthUrl: 'https://auth.example.com/oauth?state=oauth-state-001', + }; + }, + }; + globalThis.handleStepData = async (step, payload) => { + calls.push({ type: 'step', step, payload }); + }; + globalThis.getPanelModeLabel = () => 'CPA'; + globalThis.requestOAuthUrlFromPanel = async () => { + calls.push({ type: 'panel' }); + return { oauthUrl: 'https://panel.example.com/oauth' }; + }; + globalThis.LOG_PREFIX = '[test]'; + + const oauthUrl = await api.refreshOAuthUrlBeforeStep6({ + contributionMode: true, + email: 'user@example.com', + }); + + assert.equal(oauthUrl, 'https://auth.example.com/oauth?state=oauth-state-001'); + assert.deepStrictEqual(calls, [ + { type: 'log', message: '步骤 7:贡献模式正在申请贡献登录地址...' }, + { + type: 'contribution', + options: { + nickname: 'user@example.com', + openAuthTab: false, + stateOverride: { + contributionMode: true, + email: 'user@example.com', + }, + }, + }, + { + type: 'step', + step: 1, + payload: { + oauthUrl: 'https://auth.example.com/oauth?state=oauth-state-001', + }, + }, + ]); + + delete globalThis.addLog; + delete globalThis.contributionOAuthManager; + delete globalThis.handleStepData; + delete globalThis.getPanelModeLabel; + delete globalThis.requestOAuthUrlFromPanel; + delete globalThis.LOG_PREFIX; +}); diff --git a/tests/background-luckmail.test.js b/tests/background-luckmail.test.js index fa1cc18..300cd3b 100644 --- a/tests/background-luckmail.test.js +++ b/tests/background-luckmail.test.js @@ -406,7 +406,10 @@ return { }); test('resetState preserves LuckMail session config, used map, and preserve tag cache while clearing runtime purchase state', async () => { - const bundle = extractFunction('resetState'); + const bundle = [ + extractFunction('buildContributionModeState'), + extractFunction('resetState'), + ].join('\n'); const factory = new Function([ 'let cleared = false;', @@ -418,6 +421,7 @@ test('resetState preserves LuckMail session config, used map, and preserve tag c " luckmailBaseUrl: 'https://mails.luckyous.com',", " luckmailEmailType: 'ms_graph',", " luckmailDomain: '',", + " panelMode: 'cpa',", ' luckmailUsedPurchases: {},', ' luckmailPreserveTagId: 0,', " luckmailPreserveTagName: '保留',", @@ -425,6 +429,21 @@ test('resetState preserves LuckMail session config, used map, and preserve tag c " currentLuckmailMailCursor: { messageId: 'stale' },", ' email: null,', '};', + 'const CONTRIBUTION_RUNTIME_DEFAULTS = {', + ' contributionMode: false,', + " contributionSessionId: '',", + " contributionAuthUrl: '',", + " contributionAuthState: '',", + " contributionCallbackUrl: '',", + " contributionStatus: '',", + " contributionStatusMessage: '',", + ' contributionLastPollAt: 0,', + " contributionCallbackStatus: 'idle',", + " contributionCallbackMessage: '',", + ' contributionAuthOpenedAt: 0,', + ' contributionAuthTabId: 0,', + '};', + 'const CONTRIBUTION_RUNTIME_KEYS = Object.keys(CONTRIBUTION_RUNTIME_DEFAULTS);', 'function normalizeLuckmailBaseUrl(value) {', " const normalized = String(value || '').trim() || 'https://mails.luckyous.com';", " return normalized.replace(/\\/$/, '');", diff --git a/tests/sidepanel-contribution-mode.test.js b/tests/sidepanel-contribution-mode.test.js new file mode 100644 index 0000000..13df435 --- /dev/null +++ b/tests/sidepanel-contribution-mode.test.js @@ -0,0 +1,445 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const sidepanelSource = fs.readFileSync('sidepanel/sidepanel.js', 'utf8'); + +function extractFunction(name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map((marker) => sidepanelSource.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 i = start; i < sidepanelSource.length; i += 1) { + const ch = sidepanelSource[i]; + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (ch === '{' && signatureEnded) { + braceStart = i; + break; + } + } + + let depth = 0; + let end = braceStart; + for (; end < sidepanelSource.length; end += 1) { + const ch = sidepanelSource[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return sidepanelSource.slice(start, end); +} + +function createClassList() { + const values = new Set(); + return { + add(name) { + values.add(name); + }, + remove(name) { + values.delete(name); + }, + toggle(name, force) { + if (force === undefined) { + if (values.has(name)) { + values.delete(name); + return false; + } + values.add(name); + return true; + } + if (force) { + values.add(name); + return true; + } + values.delete(name); + return false; + }, + contains(name) { + return values.has(name); + }, + }; +} + +function createElement(initial = {}) { + return { + disabled: Boolean(initial.disabled), + hidden: Boolean(initial.hidden), + value: initial.value || '', + title: '', + textContent: initial.textContent || '', + listeners: {}, + attributes: {}, + classList: createClassList(), + addEventListener(type, handler) { + this.listeners[type] = handler; + }, + setAttribute(name, value) { + this.attributes[name] = String(value); + }, + }; +} + +test('sidepanel html contains contribution mode runtime UI and loads the module before sidepanel bootstrap', () => { + const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8'); + const moduleIndex = html.indexOf(''); + const sidepanelIndex = html.indexOf(''); + + assert.match(html, /id="btn-contribution-mode"/); + assert.match(html, /id="contribution-mode-panel"/); + assert.match(html, /id="contribution-oauth-status"/); + assert.match(html, /id="contribution-callback-status"/); + assert.match(html, /id="contribution-mode-summary"/); + assert.match(html, /id="btn-start-contribution"/); + assert.match(html, /id="btn-open-contribution-upload"/); + assert.match(html, /id="btn-exit-contribution-mode"/); + assert.notEqual(moduleIndex, -1); + assert.notEqual(sidepanelIndex, -1); + assert.ok(moduleIndex < sidepanelIndex); +}); + +test('collectSettingsPayload omits custom password and local sync settings in contribution mode', () => { + const bundle = extractFunction('collectSettingsPayload'); + + const api = new Function(` +let latestState = { contributionMode: true }; +let cloudflareDomainEditMode = false; +let cloudflareTempEmailDomainEditMode = false; +const selectCfDomain = { value: 'example.com' }; +const selectTempEmailDomain = { value: 'mail.example.com' }; +const selectPanelMode = { value: 'cpa' }; +const inputVpsUrl = { value: 'https://panel.example.com' }; +const inputVpsPassword = { value: 'panel-secret' }; +const inputSub2ApiUrl = { value: 'https://sub.example.com' }; +const inputSub2ApiEmail = { value: 'user@example.com' }; +const inputSub2ApiPassword = { value: 'sub-secret' }; +const inputSub2ApiGroup = { value: ' codex ' }; +const inputSub2ApiDefaultProxy = { value: ' proxy-a ' }; +const inputPassword = { value: 'Secret123!' }; +const selectMailProvider = { value: '163' }; +const selectEmailGenerator = { value: 'duck' }; +const checkboxAutoDeleteIcloud = { checked: true }; +const selectIcloudHostPreference = { value: 'auto' }; +const inputAccountRunHistoryTextEnabled = { checked: true }; +const inputAccountRunHistoryHelperBaseUrl = { value: 'http://127.0.0.1:17373' }; +const inputInbucketHost = { value: 'inbucket.local' }; +const inputInbucketMailbox = { value: 'demo' }; +const inputHotmailRemoteBaseUrl = { value: 'https://hotmail.example.com' }; +const inputHotmailLocalBaseUrl = { value: 'http://127.0.0.1:17373' }; +const inputLuckmailApiKey = { value: 'lk-api-key' }; +const inputLuckmailBaseUrl = { value: 'https://mails.example.com' }; +const selectLuckmailEmailType = { value: 'ms_graph' }; +const inputLuckmailDomain = { value: 'luckmail.example.com' }; +const inputTempEmailBaseUrl = { value: 'https://temp.example.com' }; +const inputTempEmailAdminAuth = { value: 'admin-secret' }; +const inputTempEmailCustomAuth = { value: 'custom-secret' }; +const inputTempEmailReceiveMailbox = { value: 'relay@example.com' }; +const inputAutoSkipFailures = { checked: false }; +const inputAutoSkipFailuresThreadIntervalMinutes = { value: '5' }; +const inputAutoDelayEnabled = { checked: true }; +const inputAutoDelayMinutes = { value: '30' }; +const inputAutoStepDelaySeconds = { value: '10' }; +const inputVerificationResendCount = { value: '6' }; +const DEFAULT_VERIFICATION_RESEND_COUNT = 4; + +function getCloudflareDomainsFromState() { return { domains: ['example.com'], activeDomain: 'example.com' }; } +function normalizeCloudflareDomainValue(value) { return String(value || '').trim(); } +function getCloudflareTempEmailDomainsFromState() { return { domains: ['mail.example.com'], activeDomain: 'mail.example.com' }; } +function normalizeCloudflareTempEmailDomainValue(value) { return String(value || '').trim(); } +function getSelectedLocalCpaStep9Mode() { return 'submit'; } +function getSelectedMail2925Mode() { return 'provide'; } +function normalizeAccountRunHistoryHelperBaseUrlValue(value) { return String(value || '').trim(); } +function buildManagedAliasBaseEmailPayload() { return { gmailBaseEmail: '', mail2925BaseEmail: '', emailPrefix: '' }; } +function getSelectedHotmailServiceMode() { return 'local'; } +function normalizeLuckmailBaseUrl(value) { return String(value || '').trim(); } +function normalizeLuckmailEmailType(value) { return String(value || '').trim(); } +function normalizeCloudflareTempEmailBaseUrlValue(value) { return String(value || '').trim(); } +function normalizeCloudflareTempEmailReceiveMailboxValue(value) { return String(value || '').trim(); } +function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) || 0; } +function normalizeAutoDelayMinutes(value) { return Number(value) || 30; } +function normalizeAutoStepDelaySeconds(value) { return value === '' ? null : Number(value); } +function normalizeVerificationResendCount(value, fallback) { return Number.isFinite(Number(value)) ? Number(value) : fallback; } +${bundle} +return { + collectSettingsPayload, + setLatestState(nextState) { latestState = nextState; }, +}; +`)(); + + const contributionPayload = api.collectSettingsPayload(); + assert.equal('customPassword' in contributionPayload, false); + assert.equal('accountRunHistoryTextEnabled' in contributionPayload, false); + assert.equal('accountRunHistoryHelperBaseUrl' in contributionPayload, false); + + api.setLatestState({ contributionMode: false }); + const normalPayload = api.collectSettingsPayload(); + assert.equal(normalPayload.customPassword, 'Secret123!'); + assert.equal(normalPayload.accountRunHistoryTextEnabled, true); + assert.equal(normalPayload.accountRunHistoryHelperBaseUrl, 'http://127.0.0.1:17373'); +}); + +test('contribution mode manager enters mode, starts main auto flow, polls contribution status, and exits cleanly', async () => { + const source = fs.readFileSync('sidepanel/contribution-mode.js', 'utf8'); + const windowObject = {}; + const timers = []; + + const api = new Function('window', 'setTimeout', 'clearTimeout', `${source}; return window.SidepanelContributionMode;`)( + windowObject, + (handler) => { + timers.push(handler); + return timers.length; + }, + (id) => { + if (timers[id - 1]) { + timers[id - 1] = null; + } + } + ); + + assert.equal(typeof api?.createContributionModeManager, 'function'); + + let latestState = { + contributionMode: false, + panelMode: 'sub2api', + contributionSessionId: '', + contributionStatus: '', + contributionStatusMessage: '', + contributionCallbackStatus: 'idle', + contributionCallbackMessage: '', + email: 'user@example.com', + }; + let blocked = false; + let appliedState = null; + let statusState = null; + let closeConfigMenuCount = 0; + let closeAccountRecordsCount = 0; + let contributionAutoRunStartCount = 0; + let updatePanelModeCount = 0; + let updateSyncUiCount = 0; + let updateConfigMenuCount = 0; + const toasts = []; + const openedUrls = []; + const sentMessages = []; + + const dom = { + btnConfigMenu: createElement(), + btnContributionMode: createElement(), + btnExitContributionMode: createElement(), + btnOpenAccountRecords: createElement(), + btnOpenContributionUpload: createElement(), + btnStartContribution: createElement(), + contributionCallbackStatus: createElement(), + contributionModePanel: createElement({ hidden: true }), + contributionModeSummary: createElement(), + contributionModeText: createElement(), + contributionOauthStatus: createElement(), + rowAccountRunHistoryHelperBaseUrl: createElement(), + rowAccountRunHistoryTextEnabled: createElement(), + rowCustomPassword: createElement(), + rowLocalCpaStep9Mode: createElement(), + rowSub2ApiDefaultProxy: createElement(), + rowSub2ApiEmail: createElement(), + rowSub2ApiGroup: createElement(), + rowSub2ApiPassword: createElement(), + rowSub2ApiUrl: createElement(), + rowVpsPassword: createElement(), + rowVpsUrl: createElement(), + selectPanelMode: createElement({ value: 'sub2api' }), + }; + + const manager = api.createContributionModeManager({ + state: { + getLatestState: () => latestState, + }, + dom, + helpers: { + applySettingsState(nextState) { + latestState = nextState; + appliedState = nextState; + }, + closeAccountRecordsPanel() { + closeAccountRecordsCount += 1; + }, + closeConfigMenu() { + closeConfigMenuCount += 1; + }, + getContributionNickname() { + return latestState.email; + }, + isModeSwitchBlocked() { + return blocked; + }, + openConfirmModal: async () => true, + openExternalUrl(url) { + openedUrls.push(url); + }, + showToast(message, type) { + toasts.push({ message, type }); + }, + async startContributionAutoRun() { + contributionAutoRunStartCount += 1; + latestState = { + ...latestState, + contributionSessionId: 'session-002', + contributionAuthUrl: 'https://auth.example.com/oauth?state=oauth-state-002', + contributionAuthState: 'oauth-state-002', + contributionStatus: 'started', + contributionStatusMessage: '\u5df2\u751f\u6210\u767b\u5f55\u5730\u5740', + contributionCallbackStatus: 'waiting', + contributionCallbackMessage: '\u7b49\u5f85\u56de\u8c03', + }; + return true; + }, + updateAccountRunHistorySettingsUI() { + updateSyncUiCount += 1; + }, + updateConfigMenuControls() { + updateConfigMenuCount += 1; + }, + updatePanelModeUI() { + updatePanelModeCount += 1; + }, + updateStatusDisplay(nextState) { + statusState = nextState; + }, + }, + runtime: { + sendMessage: async (message) => { + sentMessages.push(message); + if (message.type === 'SET_CONTRIBUTION_MODE') { + return { + state: message.payload.enabled + ? { + contributionMode: true, + panelMode: 'cpa', + contributionSessionId: '', + contributionAuthUrl: '', + contributionAuthState: '', + contributionStatus: '', + contributionStatusMessage: '', + contributionCallbackStatus: 'idle', + contributionCallbackMessage: '', + email: latestState.email, + } + : { + contributionMode: false, + panelMode: 'cpa', + contributionSessionId: '', + contributionAuthUrl: '', + contributionAuthState: '', + contributionCallbackUrl: '', + contributionStatus: '', + contributionStatusMessage: '', + contributionCallbackStatus: 'idle', + contributionCallbackMessage: '', + email: latestState.email, + }, + }; + } + if (message.type === 'POLL_CONTRIBUTION_STATUS') { + return { + state: { + ...latestState, + contributionStatus: 'processing', + contributionStatusMessage: '已授权,正在自动审核', + contributionCallbackStatus: 'not_required', + contributionCallbackMessage: '当前流程无需手动回调', + }, + }; + } + return {}; + }, + }, + constants: { + contributionUploadUrl: 'https://apikey.qzz.io/', + pollIntervalMs: 2500, + }, + }); + + manager.render(); + assert.equal(dom.contributionModePanel.hidden, true); + assert.equal(dom.btnContributionMode.disabled, false); + + manager.bindEvents(); + await dom.btnContributionMode.listeners.click(); + + assert.equal(dom.contributionModePanel.hidden, false); + assert.equal(dom.selectPanelMode.value, 'cpa'); + assert.equal(dom.selectPanelMode.disabled, true); + assert.equal(dom.btnOpenAccountRecords.disabled, true); + assert.equal(dom.contributionOauthStatus.textContent, '\u672a\u751f\u6210\u767b\u5f55\u5730\u5740'); + assert.equal(dom.contributionCallbackStatus.textContent, '\u7b49\u5f85\u56de\u8c03'); + assert.equal(dom.contributionModeSummary.textContent.length > 0, true); + assert.equal(dom.btnContributionMode.classList.contains('is-active'), true); + assert.equal(dom.rowVpsUrl.classList.contains('is-contribution-hidden'), true); + assert.ok(closeConfigMenuCount >= 1); + assert.ok(closeAccountRecordsCount >= 1); + assert.ok(updatePanelModeCount >= 1); + assert.ok(updateSyncUiCount >= 1); + assert.ok(updateConfigMenuCount >= 1); + assert.equal(timers.length, 0); + + await dom.btnStartContribution.listeners.click(); + assert.equal(contributionAutoRunStartCount, 1); + assert.equal(appliedState.contributionSessionId, ''); + assert.equal(latestState.contributionSessionId, 'session-002'); + assert.equal(latestState.contributionStatus, 'started'); + assert.equal(timers.length > 0, true); + + await manager.pollOnce({ reason: 'test_poll' }); + assert.equal(statusState.contributionStatus, 'processing'); + assert.equal(dom.contributionOauthStatus.textContent, '\u6388\u6743\u5df2\u5b8c\u6210'); + assert.equal(dom.contributionCallbackStatus.textContent, '\u5f53\u524d\u6d41\u7a0b\u65e0\u9700\u624b\u52a8\u56de\u8c03'); + assert.equal(dom.contributionModeSummary.textContent, '\u5df2\u6388\u6743\uff0c\u6b63\u5728\u81ea\u52a8\u5ba1\u6838'); + + dom.btnOpenContributionUpload.listeners.click(); + assert.deepStrictEqual(openedUrls, ['https://apikey.qzz.io/']); + + await dom.btnExitContributionMode.listeners.click(); + manager.render(); + assert.equal(dom.contributionModePanel.hidden, true); + assert.equal(dom.btnContributionMode.classList.contains('is-active'), false); + assert.equal(dom.selectPanelMode.disabled, false); + assert.equal(dom.rowVpsUrl.classList.contains('is-contribution-hidden'), false); + assert.deepStrictEqual( + sentMessages.map((message) => message.type), + ['SET_CONTRIBUTION_MODE', 'POLL_CONTRIBUTION_STATUS', 'SET_CONTRIBUTION_MODE'] + ); + assert.deepStrictEqual( + toasts.map((item) => item.message), + ['\u5df2\u8fdb\u5165\u8d21\u732e\u6a21\u5f0f\u3002', '\u8d21\u732e\u81ea\u52a8\u6d41\u7a0b\u5df2\u542f\u52a8\u3002', '\u5df2\u9000\u51fa\u8d21\u732e\u6a21\u5f0f\u3002'] + ); + + blocked = true; + latestState = { + contributionMode: true, + panelMode: 'cpa', + contributionSessionId: 'session-002', + contributionAuthUrl: 'https://auth.example.com/oauth?state=oauth-state-002', + contributionStatus: 'waiting', + contributionStatusMessage: '\u7b49\u5f85\u6388\u6743\u5b8c\u6210', + contributionCallbackStatus: 'waiting', + contributionCallbackMessage: '\u7b49\u5f85\u56de\u8c03', + }; + manager.render(); + assert.equal(dom.btnExitContributionMode.disabled, true); + manager.stopPolling(); +}); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index f12fc5e..e5b8480 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -37,6 +37,8 @@ - 向后台发送命令 - 接收后台广播并更新 UI - 动态渲染步骤列表 +- 管理顶部“贡献”按钮与贡献模式主面板;贡献模式本身是 sidepanel 的运行态 UI 模式,不是新的 `panelMode` 来源 +- 在贡献模式下复用同一套主自动流程启动,并在面板内展示贡献链路的 `OAUTH / 回调 / 总状态` 三块实时状态 - 在日志区通过“记录”按钮打开独立的邮箱记录覆盖层,并展示成功/失败/停止/重试统计与分页列表 - 查询 GitHub Releases 并展示更新卡片;当前更新服务会区分 `Pro` 与 legacy `v` 两个版本族,排序时优先保持版本族语义一致,同时会在读取缓存后重新排序,避免旧缓存把 `v` 版本误显示为比 `Pro` 更新 @@ -118,6 +120,15 @@ 保存运行态: - 当前步骤状态 +- 当前 sidepanel UI 模式 `contributionMode` +- 贡献 OAuth 会话字段: + - `contributionSessionId` + - `contributionAuthUrl` + - `contributionAuthState` + - `contributionCallbackUrl` + - `contributionStatus` + - `contributionStatusMessage` + - `contributionLastPollAt` - OAuth 链接 - 当前邮箱 / 密码 - localhost 回调地址 @@ -141,6 +152,11 @@ - 账号运行历史 `accountRunHistory`(以邮箱为主键,保存该邮箱最近一次状态:成功/失败/停止) - 账号运行历史本地同步开关与 helper 地址 +注意: + +- `contributionMode` 不属于持久配置,也不参与导入/导出;它只存在于运行态 +- `panelMode` 仍然只表示 `cpa | sub2api` 来源,不能把贡献模式实现成新的来源枚举 + 当启用了独立的账号运行历史本地同步配置时,账号运行历史会通过 [scripts/hotmail_helper.py](c:/Users/projectf/Downloads/codex注册扩展/scripts/hotmail_helper.py) 整体同步写入 `data/account-run-history.json` 快照文件,便于开发者直接查看完整记录。 这条配置链路独立于 `mailProvider` 和 Hotmail 的接码模式。 @@ -155,6 +171,35 @@ - `ICLOUD_LOGIN_REQUIRED` - `ICLOUD_ALIASES_CHANGED` +### 4.4 贡献模式链路 + +贡献模式的目标不是新增一个 provider,而是在 sidepanel 内开启一套“贡献账号”专用 UI,并把公开贡献服务并进原有 10 步主链: + +1. 用户点击顶部 `贡献` +2. sidepanel 复用现有 `openConfirmModal(...)` 打开确认弹窗 +3. 用户确认后,后台通过 `SET_CONTRIBUTION_MODE` 切换运行态 +4. 进入贡献模式后会强制 `panelMode = cpa`,并临时清空运行态 `customPassword`、禁用运行态账号记录快照同步 +5. sidepanel 隐藏 CPA 管理地址、管理密钥、SUB2API 配置、自定义密码、本地同步等普通模式配置,并禁用来源选择、配置菜单和记录入口 +6. 用户点击 `开始贡献` 后,不再单独走一条旁路 OAuth 流程,而是直接复用顶部 `自动` 的同一套主自动流 +7. 步骤 1~6 仍按原来的注册自动化执行 +8. 当主流程进入步骤 7 时,后台改为调用公开接口 `POST https://apikey.qzz.io/oauth/api/start` 申请贡献登录地址,而不是去 CPA / SUB2API 面板刷新 OAuth +9. 步骤 7 拿到 `session_id / auth_url / state` 后,继续沿用原有登录链路进入授权页 +10. 步骤 9 仍负责捕获 localhost callback;贡献模式下后台也会持续监听导航变化,必要时提前兼容处理 callback +11. 步骤 10 在贡献模式下不再打开 CPA 管理页,而是围绕公开贡献会话做 callback 提交兼容和最终状态确认;当状态进入 `auto_approved / auto_rejected / manual_review_required / expired / error` 时结束 +12. 当前服务端如果返回“无需手动提交 callback”,扩展会把它视为兼容成功态,而不是报错 +13. `已有认证文件?前往上传` 继续打开 `https://apikey.qzz.io/` +14. `退出贡献模式` 会清理贡献会话相关运行态,`重置` 只重置主流程状态,不会自动退出贡献模式 + +这条链路的关键边界是: + +- 扩展会调用公开贡献 API,但这条调用被折叠进主 10 步自动链,而不是额外分出一条独立“贡献 OAuth 小流程” +- 扩展不会保存 token +- 扩展不会持有任何管理密钥 +- 扩展代码中不能直接调用 `/v0/management/*` +- 扩展不会直接访问生产管理面或生产 auth-dir +- 安全边界仍然在服务端;扩展只负责公开 OAuth 贡献前端流程 +- 退出贡献模式后,要恢复普通模式 UI,并恢复持久配置中的自定义密码/本地同步偏好 + ## 5. 内容脚本通信链路 ### 5.1 READY 机制 @@ -303,6 +348,11 @@ 6. 自动运行一旦进入步骤 7 之后的链路,若后续步骤报错且认证页未进入 `https://auth.openai.com/add-phone`,则统一回到步骤 7 重新开始授权流程 7. CPA 回调阶段固定为步骤 9,不再支持“第七步回调”跳过步骤 7/8 +贡献模式补充: + +- 贡献模式下,步骤 7 不再从 CPA / SUB2API 面板刷新 OAuth,而是直接调用公开贡献接口 `/oauth/api/start` +- 贡献接口返回的 `auth_url` 会写回运行态 `oauthUrl`,后续步骤 7 / 8 / 9 继续复用现有授权链路 + ### Step 8 文件: @@ -350,6 +400,12 @@ 8. 追加账号运行历史成功记录 9. 做成功后的清理与标记 +贡献模式补充: + +- 贡献模式下,步骤 10 不再打开 CPA 管理页 +- 步骤 10 会先尝试提交已捕获的 callback URL,随后轮询公开贡献状态,直到进入最终态 +- `auto_approved` 与 `manual_review_required` 视为主流程完成;`auto_rejected / expired / error` 视为当前轮失败 + ## 7. 邮箱与 provider 链路 ### 7.1 2026-04-17 补充:Gmail / 2925 统一别名邮箱链路 diff --git a/项目开发规范(AI协作).md b/项目开发规范(AI协作).md index fdc753f..13dcd4c 100644 --- a/项目开发规范(AI协作).md +++ b/项目开发规范(AI协作).md @@ -109,6 +109,28 @@ 6. 是否挂在正确的职责域中 7. 文档 +### 3.4 新增运行态模式 + +如果新增的是“运行态 UI 模式”而不是持久配置或新来源,必须先把边界说清楚,再落代码。 + +当前约定示例: + +- `contributionMode` 是 sidepanel 的运行态 UI 模式,不是新的 `panelMode` +- `panelMode` 仍然只允许 `cpa | sub2api` +- 运行态模式不能混进 `PERSISTED_SETTING_DEFAULTS` +- 运行态模式不能混进配置导入/导出 +- 如果运行态模式会临时覆盖某些持久配置的显示值,必须同时处理好“退出模式后恢复”和“自动保存不能误覆盖原配置”这两个问题 +- 如果运行态模式要隐藏某一行 UI,必须先检查这行里是否绑定了不该一起隐藏的其他设置;必要时先拆行,再做显隐 + +当前贡献模式补充约定: + +- 贡献模式属于 `CPA` 来源下的特殊业务模式,不是新的 provider +- 贡献模式允许扩展内承接公开 OAuth 交互,但只能调用公开接口,不能触碰 `/v0/management/*` +- 如果产品要求“开始贡献”和主自动流走同一条链路,则优先把贡献服务接入步骤 7 / 10,而不是额外分出一套平行 mini-flow +- 贡献流程的后台公开 OAuth 状态机应优先收敛到独立模块,例如 `background/contribution-oauth.js` +- 贡献模式的侧栏按钮、状态展示和轮询调度应优先收敛到独立 manager,例如 `sidepanel/contribution-mode.js` +- 如果服务端当前返回“无需手动提交 callback”,扩展端必须把它当兼容成功态处理,不能简单按 HTTP 非 200 直接视为失败 + ## 4. 测试规范 ### 4.1 原则 diff --git a/项目文件结构说明.md b/项目文件结构说明.md index b6c2b8c..a6ae7d3 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -42,6 +42,7 @@ - `background/account-run-history.js`:邮箱记录模块,负责以邮箱为主键维护最新记录(同邮箱后续状态覆盖),在步骤 2 设定邮箱后支持先写入“停止/未完成”占位状态,统一归一化成功/失败/停止三态并落地到 `chrome.storage.local`,支持清理记录,并在启用独立本地同步配置后把完整快照同步到本地 helper。 - `background/auto-run-controller.js`:自动运行主控制器,封装多轮执行、重试、轮次摘要、线程间隔与倒计时恢复逻辑;当前自动流程会绑定 `autoRunSessionId`,手动停止后旧的倒计时计划、旧重试链路和旧恢复入口不会再复活已失效的自动运行;fresh-attempt reset 时会额外保留 `gmailBaseEmail` 与 `mail2925BaseEmail`,避免自动流程重置后丢失别名基邮箱配置。 +- `background/contribution-oauth.js`:贡献模式的公开 OAuth 流程模块,负责调用 `apikey.qzz.io` 的公开贡献接口、保存贡献会话运行态、在主 10 步流程里为步骤 7 提供贡献登录地址、在步骤 9/10 衔接 callback 捕获与兼容提交 `/oauth/api/submit-callback`,并把真实服务端状态映射回 sidepanel 运行态。 - `background/generated-email-helpers.js`:生成邮箱辅助层,除了 Duck / Cloudflare / iCloud / Cloudflare Temp Email,也统一承接 Gmail / 2925 的别名邮箱生成入口。 - `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层;当前额外承接 `add-phone / 手机号页` 这类认证 fatal 错误的共享判定。 - `background/message-router.js`:后台消息路由层,负责处理 `chrome.runtime.onMessage` 进入的所有业务消息。 @@ -113,9 +114,10 @@ - `sidepanel/icloud-manager.js`:侧边栏 iCloud 隐私邮箱管理器,负责列表、筛选、保留、删除和批量操作。 - `sidepanel/luckmail-manager.js`:侧边栏 LuckMail 管理器,负责邮箱列表、筛选、启停、保留与批量操作。 - `sidepanel/account-records-manager.js`:侧边栏邮箱记录面板管理器,负责“记录”按钮、覆盖层开关、分页列表、成功/失败/停止统计摘要和清理确认。 +- `sidepanel/contribution-mode.js`:侧边栏贡献模式管理器,负责顶部“贡献”按钮、确认弹窗、贡献模式显隐、复用主自动流程启动、侧栏内贡献状态轮询、上传页跳转,以及贡献模式下对来源选择、配置入口、记录入口和敏感配置行的禁用与隐藏。 - `sidepanel/sidepanel.css`:侧边栏样式文件。 -- `sidepanel/sidepanel.html`:侧边栏页面结构;当前步骤列表已改为动态容器,日志区提供“记录”按钮并挂接邮箱记录覆盖层,同时新增共享“验证码重发”次数输入,并加载 `managed-alias-utils.js`,把旧的“邮箱前缀”字段语义改为“别名基邮箱”。 -- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、独立本地同步配置、共享验证码自动重发次数配置与广播接收,并装配 Hotmail / iCloud / LuckMail / 邮箱记录面板 manager;同时把 Gmail / 2925 的基邮箱输入、完整注册邮箱输入、自动生成按钮与兼容性校验统一接到共享别名逻辑上。 +- `sidepanel/sidepanel.html`:侧边栏页面结构;当前步骤列表已改为动态容器,日志区提供“记录”按钮并挂接邮箱记录覆盖层,顶部新增“贡献”按钮并在设置卡片中新增贡献模式主面板;贡献面板内展示 `OAUTH / 回调 / 总状态` 三块真实运行态信息,同时把“本地同步”与“验证码重发”拆成独立行以避免特殊模式隐藏时互相影响;页面继续加载 `managed-alias-utils.js`,并把旧的“邮箱前缀”字段语义改为“别名基邮箱”。 +- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、独立本地同步配置、共享验证码自动重发次数配置与广播接收,并装配 Hotmail / iCloud / LuckMail / 贡献模式 / 邮箱记录面板 manager;当前贡献模式的“开始贡献”会直接复用主自动流程启动逻辑,而独立 manager 负责贡献运行态展示与轮询,同时把 Gmail / 2925 的基邮箱输入、完整注册邮箱输入、自动生成按钮与兼容性校验统一接到共享别名逻辑上。 - `sidepanel/update-service.js`:侧边栏更新检查服务,负责 GitHub Releases 查询、`Pro` / `v` 双版本族排序、缓存读取与版本展示。 ## `tests/` @@ -130,6 +132,7 @@ - `tests/background-auto-run-module.test.js`:测试自动运行控制器模块已接入且导出工厂。 - `tests/background-account-run-history-module.test.js`:测试邮箱记录模块已接入、导出工厂,能够保留并归一化停止记录、归一化失败标签、计算自动重试次数,并支持清理后同步完整快照。 - `tests/background-account-history-settings.test.js`:测试账号运行历史的独立配置项归一化,不再复用 Hotmail 模式作为启停条件。 +- `tests/background-contribution-mode.test.js`:测试贡献模式的后台运行态与公开 OAuth 接入,覆盖 `contributionMode` 只存在于运行态、`SET_CONTRIBUTION_MODE / POLL_CONTRIBUTION_STATUS` 消息接入、reset 保留贡献运行态,以及 callback 自动提交在“无需手动提交”场景下的兼容处理。 - `tests/background-generated-email-module.test.js`:测试生成邮箱辅助模块已接入且导出工厂。 - `tests/background-icloud.test.js`:测试 iCloud 相关后台纯函数与别名收尾逻辑。 - `tests/background-icloud-mail-provider.test.js`:测试 iCloud 邮箱 provider 配置解析。 @@ -161,6 +164,7 @@ - `tests/microsoft-email.test.js`:测试 Microsoft 邮件拉取与验证码提取逻辑。 - `tests/sidepanel-hotmail-manager.test.js`:测试侧边栏 Hotmail 管理器模块接线与空态渲染。 - `tests/sidepanel-account-records-manager.test.js`:测试侧边栏邮箱记录覆盖层的 HTML 接入、helper 地址归一化与 manager 渲染逻辑。 +- `tests/sidepanel-contribution-mode.test.js`:测试侧边栏贡献模式的 HTML 接线、runtime-only 设置保护,以及贡献模式 manager 复用主自动流启动、状态轮询和退出清理逻辑。 - `tests/sidepanel-icloud-manager.test.js`:测试侧边栏 iCloud 管理器模块接线与空态渲染。 - `tests/sidepanel-icloud-provider.test.js`:测试侧边栏 iCloud 登录地址解析逻辑。 - `tests/sidepanel-luckmail-manager.test.js`:测试侧边栏 LuckMail 管理器模块接线与空态渲染。 From 2fe04f6f2230597cd4071dbd2f12bab1d7a947f3 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sun, 19 Apr 2026 12:52:16 +0800 Subject: [PATCH 02/15] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=20isContributi?= =?UTF-8?q?onButtonLocked=20=E9=80=BB=E8=BE=91=EF=BC=8C=E7=A1=AE=E4=BF=9D?= =?UTF-8?q?=E5=9C=A8=E8=87=AA=E5=8A=A8=E8=BF=90=E8=A1=8C=E6=97=B6=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E5=8F=AF=E7=94=A8=EF=BC=8C=E5=B9=B6=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sidepanel/sidepanel.js | 10 ++- tests/sidepanel-contribution-button.test.js | 95 +++++++++++++++++++-- 项目完整链路说明.md | 3 +- 3 files changed, 99 insertions(+), 9 deletions(-) diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index f8bf5df..b9d6858 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -923,9 +923,17 @@ function syncAutoRunState(source = {}) { } function isContributionButtonLocked() { + const autoActive = currentAutoRun.autoRunning + || isAutoRunLockedPhase() + || isAutoRunPausedPhase() + || isAutoRunScheduledPhase(); + if (autoActive) { + return false; + } + const statuses = getStepStatuses(); const anyRunning = Object.values(statuses).some((status) => status === 'running'); - return anyRunning || isAutoRunLockedPhase() || isAutoRunPausedPhase() || isAutoRunScheduledPhase(); + return anyRunning; } function isAutoRunLockedPhase() { diff --git a/tests/sidepanel-contribution-button.test.js b/tests/sidepanel-contribution-button.test.js index 3cee4f1..de02cec 100644 --- a/tests/sidepanel-contribution-button.test.js +++ b/tests/sidepanel-contribution-button.test.js @@ -51,7 +51,7 @@ function extractFunction(name) { test('sidepanel html contains contribution button in header', () => { const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8'); assert.match(html, /id="btn-contribution-mode"/); - assert.match(html, />贡献\u8d21\u732e { @@ -87,18 +87,96 @@ return { ]); }); -test('openContributionUploadPage blocks while flow is running', async () => { +test('isContributionButtonLocked keeps contribution button available during auto-run', () => { const bundle = [ + extractFunction('isContributionButtonLocked'), + ].join('\n'); + + const api = new Function(` +const currentAutoRun = { autoRunning: true, phase: 'running' }; +function getStepStatuses() { + return { 1: 'running', 2: 'pending' }; +} +function isAutoRunLockedPhase() { + return true; +} +function isAutoRunPausedPhase() { + return false; +} +function isAutoRunScheduledPhase() { + return false; +} +${bundle} +return { isContributionButtonLocked }; +`)(); + + assert.equal(api.isContributionButtonLocked(), false); +}); + +test('openContributionUploadPage remains available during auto-run', async () => { + const bundle = [ + extractFunction('isContributionButtonLocked'), + extractFunction('openContributionUploadPage'), + ].join('\n'); + + const api = new Function(` +const calls = []; +const CONTRIBUTION_UPLOAD_URL = 'https://apikey.qzz.io/'; +const currentAutoRun = { autoRunning: true, phase: 'running' }; +function getStepStatuses() { + return { 1: 'running', 2: 'pending' }; +} +function isAutoRunLockedPhase() { + return true; +} +function isAutoRunPausedPhase() { + return false; +} +function isAutoRunScheduledPhase() { + return false; +} +function openExternalUrl(url) { + calls.push({ type: 'open', url }); +} +${bundle} +return { + openContributionUploadPage, + getCalls() { + return calls; + }, +}; +`)(); + + const result = await api.openContributionUploadPage(); + assert.equal(result, true); + assert.deepStrictEqual(api.getCalls(), [ + { + type: 'open', + url: 'https://apikey.qzz.io/', + }, + ]); +}); + +test('openContributionUploadPage blocks while manual flow is running', async () => { + const bundle = [ + extractFunction('isContributionButtonLocked'), extractFunction('openContributionUploadPage'), ].join('\n'); const api = new Function(` const CONTRIBUTION_UPLOAD_URL = 'https://apikey.qzz.io/'; -function isContributionButtonLocked() { - return true; +const currentAutoRun = { autoRunning: false, phase: 'idle' }; +function getStepStatuses() { + return { 1: 'running', 2: 'pending' }; } -async function openConfirmModal() { - throw new Error('should not open modal'); +function isAutoRunLockedPhase() { + return false; +} +function isAutoRunPausedPhase() { + return false; +} +function isAutoRunScheduledPhase() { + return false; } function openExternalUrl() { throw new Error('should not open url'); @@ -109,6 +187,9 @@ return { openContributionUploadPage }; await assert.rejects( () => api.openContributionUploadPage(), - /当前流程运行中/ + (error) => { + assert.match(error.message, /\u5f53\u524d\u6d41\u7a0b\u8fd0\u884c\u4e2d/); + return true; + } ); }); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index 140c3e9..90385bf 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -176,7 +176,8 @@ - 当前这颗按钮不参与 1~10 步主流程 - 当前这颗按钮不调用后台管理接口 - 当前这颗按钮不保存额外运行态 -- 当步骤正在运行或自动流程处于锁定状态时,按钮会禁用,避免和主流程冲突 +- Auto 运行中不会跟随主流程按钮一起被禁用,仍可直接点击打开上传页 +- 手动单步流程运行时,按钮仍会禁用,避免和同页操作冲突 ## 5. 内容脚本通信链路 From 17e1c672f1c78ccfbdd2ad8b5e89c3236f6dc7f3 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sun, 19 Apr 2026 16:54:15 +0800 Subject: [PATCH 03/15] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=81=9C?= =?UTF-8?q?=E6=AD=A2=E8=AE=B0=E5=BD=95=E6=8E=A8=E6=96=AD=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E5=BD=92=E4=B8=80=E5=8C=96=E6=AD=A5?= =?UTF-8?q?=E9=AA=A4=E7=8A=B6=E6=80=81=E5=B9=B6=E6=9B=B4=E6=96=B0=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background.js | 84 ++++++- background/account-run-history.js | 19 +- ...kground-account-run-history-module.test.js | 32 ++- tests/background-stop-gap-record.test.js | 237 ++++++++++++++++++ .../sidepanel-account-records-manager.test.js | 3 +- tests/step8-stop-cleanup.test.js | 6 + 项目完整链路说明.md | 8 +- 项目文件结构说明.md | 9 +- 8 files changed, 379 insertions(+), 19 deletions(-) create mode 100644 tests/background-stop-gap-record.test.js diff --git a/background.js b/background.js index 03e822f..b614309 100644 --- a/background.js +++ b/background.js @@ -4064,6 +4064,79 @@ function getRunningSteps(statuses = {}) { .sort((a, b) => a - b); } +function inferStoppedRecordStep(state = {}) { + const statuses = { ...DEFAULT_STATE.stepStatuses, ...(state?.stepStatuses || {}) }; + const stepIds = Object.keys(statuses) + .map((step) => Number(step)) + .filter(Number.isFinite) + .sort((left, right) => left - right); + + const runningSteps = stepIds.filter((step) => statuses[step] === 'running'); + if (runningSteps.length) { + return runningSteps[0]; + } + + const hasProgress = stepIds.some((step) => statuses[step] !== 'pending'); + if (!hasProgress) { + return null; + } + + for (const step of stepIds) { + const status = statuses[step] || 'pending'; + if (!(status === 'completed' || status === 'manual_completed' || status === 'skipped')) { + return step; + } + } + + return null; +} + +function resolveAccountRunRecordStatusForStop(status, state = {}) { + const normalizedStatus = String(status || '').trim().toLowerCase(); + if (normalizedStatus === 'stopped') { + const inferredStep = inferStoppedRecordStep(state); + if (Number.isInteger(inferredStep) && inferredStep > 0) { + return `step${inferredStep}_stopped`; + } + } + return status; +} + +function extractStoppedStepFromRecordStatus(status = '') { + const match = String(status || '').trim().toLowerCase().match(/^step(\d+)_stopped$/); + if (!match) { + return null; + } + const step = Number(match[1]); + return Number.isInteger(step) && step > 0 ? step : null; +} + +function resolveAccountRunRecordReasonForStop(status, reason = '') { + const text = String(reason || '').trim(); + const stoppedStep = extractStoppedStepFromRecordStatus(status); + + if (!stoppedStep) { + if (!text || text === STOP_ERROR_MESSAGE || /^流程已被用户停止。?$/.test(text)) { + return '流程已停止。'; + } + return text; + } + + if (!text || text === STOP_ERROR_MESSAGE || /^流程已被用户停止。?$/.test(text)) { + return `步骤 ${stoppedStep} 已被用户停止。`; + } + + if (/流程尚未完成/.test(text) || /已使用邮箱/.test(text)) { + return `步骤 ${stoppedStep} 已停止:邮箱已设置,流程尚未完成。`; + } + + if (/步骤\s*\d+\s*已(?:被用户)?停止/.test(text)) { + return text.replace(/步骤\s*\d+/, `步骤 ${stoppedStep}`); + } + + return text; +} + function getAutoRunStatusPayload(phase, payload = {}) { const normalizedPayload = { ...payload, @@ -4932,6 +5005,8 @@ async function markRunningStepsStopped() { async function requestStop(options = {}) { const { logMessage = '已收到停止请求,正在取消当前操作...' } = options; const state = await getState(); + const runningSteps = getRunningSteps(state.stepStatuses); + const inferredStopStep = inferStoppedRecordStep(state); const timerPlan = getPendingAutoRunTimerPlan(state); if (timerPlan?.kind === AUTO_RUN_TIMER_KIND_SCHEDULED_START && !autoRunActive) { @@ -4979,6 +5054,10 @@ async function requestStop(options = {}) { await addLog(logMessage, 'warn'); await broadcastStopToContentScripts(); + if (!runningSteps.length && Number.isInteger(inferredStopStep) && inferredStopStep > 0) { + await appendAndBroadcastAccountRunRecord('stopped', state, STOP_ERROR_MESSAGE); + } + for (const waiter of stepWaiters.values()) { waiter.reject(new Error(STOP_ERROR_MESSAGE)); } @@ -5208,7 +5287,10 @@ async function appendAndBroadcastAccountRunRecord(status, stateOverride = null, return null; } - const record = await accountRunHistoryHelpers.appendAccountRunRecord(status, stateOverride, reason); + const state = stateOverride || await getState(); + const resolvedStatus = resolveAccountRunRecordStatusForStop(status, state); + const resolvedReason = resolveAccountRunRecordReasonForStop(resolvedStatus, reason); + const record = await accountRunHistoryHelpers.appendAccountRunRecord(resolvedStatus, state, resolvedReason); if (!record) { return null; } diff --git a/background/account-run-history.js b/background/account-run-history.js index a855907..415764d 100644 --- a/background/account-run-history.js +++ b/background/account-run-history.js @@ -39,9 +39,9 @@ return ''; } - function extractFailedStep(status = '', detail = '') { + function extractRecordStep(status = '', detail = '') { const normalizedStatus = String(status || '').trim().toLowerCase(); - const statusMatch = normalizedStatus.match(/^step(\d+)_failed$/); + const statusMatch = normalizedStatus.match(/^step(\d+)_(?:failed|stopped)$/); if (statusMatch) { const step = Number(statusMatch[1]); return Number.isInteger(step) && step > 0 ? step : null; @@ -73,6 +73,9 @@ return '流程完成'; } if (finalStatus === 'stopped') { + if (Number.isInteger(failedStep) && failedStep > 0) { + return `步骤 ${failedStep} 停止`; + } return '流程已停止'; } if (finalStatus !== 'failed') { @@ -152,7 +155,7 @@ const failedStepCandidate = Number(record.failedStep); const failedStep = Number.isInteger(failedStepCandidate) && failedStepCandidate > 0 ? failedStepCandidate - : extractFailedStep(record.finalStatus || record.status || '', failureDetail); + : extractRecordStep(record.finalStatus || record.status || '', failureDetail); const autoRunContext = normalizeAutoRunContext(record.autoRunContext); const retryCount = normalizeRetryCount( record.retryCount !== undefined @@ -160,6 +163,8 @@ : ((autoRunContext?.attemptRun || 0) > 1 ? autoRunContext.attemptRun - 1 : 0) ); const source = normalizeSource(record.source || (autoRunContext ? 'auto' : 'manual')); + const computedFailureLabel = buildFailureLabel(finalStatus, failedStep, failureDetail); + const rawFailureLabel = String(record.failureLabel || '').trim(); return { recordId: String(record.recordId || '').trim() || buildRecordId(email), @@ -168,7 +173,9 @@ finalStatus, finishedAt, retryCount, - failureLabel: String(record.failureLabel || '').trim() || buildFailureLabel(finalStatus, failedStep, failureDetail), + failureLabel: finalStatus === 'stopped' + ? computedFailureLabel + : (rawFailureLabel || computedFailureLabel), failureDetail, failedStep: Number.isInteger(failedStep) && failedStep > 0 ? failedStep : null, source, @@ -215,7 +222,9 @@ } const failureDetail = finalStatus === 'failed' || finalStatus === 'stopped' ? String(reason || '').trim() : ''; - const failedStep = finalStatus === 'failed' ? extractFailedStep(status, failureDetail) : null; + const failedStep = finalStatus === 'failed' || finalStatus === 'stopped' + ? extractRecordStep(status, failureDetail) + : null; const source = Boolean(state.autoRunning) ? 'auto' : 'manual'; const autoRunContext = source === 'auto' ? buildAutoRunContextFromState(state) : null; const retryCount = source === 'auto' ? getRetryCountFromState(state) : 0; diff --git a/tests/background-account-run-history-module.test.js b/tests/background-account-run-history-module.test.js index 1b428c1..4345d6a 100644 --- a/tests/background-account-run-history-module.test.js +++ b/tests/background-account-run-history-module.test.js @@ -100,18 +100,42 @@ test('account run history helper upgrades old records, keeps stopped items and s assert.equal(fetchCalled, false); assert.equal(helpers.shouldAppendAccountRunTextFile({ accountRunHistoryTextEnabled: false, accountRunHistoryHelperBaseUrl: 'http://127.0.0.1:17373' }), false); assert.equal(helpers.shouldAppendAccountRunTextFile({ accountRunHistoryTextEnabled: true, accountRunHistoryHelperBaseUrl: 'http://127.0.0.1:17373' }), true); - const stoppedRecord = helpers.buildAccountRunHistoryRecord({ email: 'a@b.com', password: 'x' }, 'stopped', 'stop'); + const stoppedRecord = helpers.buildAccountRunHistoryRecord( + { email: 'a@b.com', password: 'x' }, + 'step7_stopped', + '步骤 7 已被用户停止' + ); assert.equal(stoppedRecord.recordId, 'a@b.com'); assert.equal(stoppedRecord.email, 'a@b.com'); assert.equal(stoppedRecord.password, 'x'); assert.equal(stoppedRecord.finalStatus, 'stopped'); assert.equal(stoppedRecord.retryCount, 0); - assert.equal(stoppedRecord.failureLabel, '流程已停止'); - assert.equal(stoppedRecord.failureDetail, 'stop'); - assert.equal(stoppedRecord.failedStep, null); + assert.equal(stoppedRecord.failureLabel, '步骤 7 停止'); + assert.equal(stoppedRecord.failureDetail, '步骤 7 已被用户停止'); + assert.equal(stoppedRecord.failedStep, 7); assert.equal(stoppedRecord.source, 'manual'); assert.equal(stoppedRecord.autoRunContext, null); assert.ok(stoppedRecord.finishedAt); + + const genericStoppedRecord = helpers.buildAccountRunHistoryRecord({ email: 'stop@b.com', password: 'y' }, 'stopped', 'stop'); + assert.equal(genericStoppedRecord.failureLabel, '流程已停止'); + assert.equal(genericStoppedRecord.failedStep, null); + + const normalizedStoppedRecord = helpers.normalizeAccountRunHistoryRecord({ + recordId: 'legacy-stop@example.com', + email: 'legacy-stop@example.com', + password: 'secret', + finalStatus: 'stopped', + finishedAt: '2026-04-17T00:12:00.000Z', + retryCount: 0, + failureLabel: '流程已停止', + failureDetail: '步骤 7 已被用户停止。', + failedStep: 7, + source: 'manual', + autoRunContext: null, + }); + assert.equal(normalizedStoppedRecord.failureLabel, '步骤 7 停止'); + assert.equal(normalizedStoppedRecord.failedStep, 7); }); test('account run history helper clears persisted records and syncs full snapshot payload to local helper', async () => { diff --git a/tests/background-stop-gap-record.test.js b/tests/background-stop-gap-record.test.js new file mode 100644 index 0000000..2a10935 --- /dev/null +++ b/tests/background-stop-gap-record.test.js @@ -0,0 +1,237 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('background.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 i = start; i < source.length; i += 1) { + const ch = source[i]; + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (ch === '{' && signatureEnded) { + braceStart = i; + 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('generic stopped record resolves to next unfinished step during execution gap', async () => { + const bundle = [ + extractFunction('getRunningSteps'), + extractFunction('inferStoppedRecordStep'), + extractFunction('resolveAccountRunRecordStatusForStop'), + extractFunction('extractStoppedStepFromRecordStatus'), + extractFunction('resolveAccountRunRecordReasonForStop'), + extractFunction('appendAndBroadcastAccountRunRecord'), + ].join('\n'); + +const api = new Function(` +const STEP_IDS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; +const STOP_ERROR_MESSAGE = '流程已被用户停止。'; +const DEFAULT_STATE = { + stepStatuses: Object.fromEntries(STEP_IDS.map((step) => [step, 'pending'])), +}; +let captured = null; +const accountRunHistoryHelpers = { + appendAccountRunRecord: async (status, state, reason) => { + captured = { status, state, reason }; + return { status, state, reason }; + }, +}; +async function broadcastAccountRunHistoryUpdate() {} +async function getState() { + return {}; +} +${bundle} +return { + inferStoppedRecordStep, + resolveAccountRunRecordStatusForStop, + resolveAccountRunRecordReasonForStop, + appendAndBroadcastAccountRunRecord, + getCaptured() { + return captured; + }, +}; +`)(); + + const state = { + email: 'user@example.com', + password: 'secret', + stepStatuses: { + 1: 'completed', + 2: 'completed', + 3: 'completed', + 4: 'completed', + 5: 'completed', + 6: 'completed', + 7: 'pending', + 8: 'pending', + 9: 'pending', + 10: 'pending', + }, + }; + + assert.equal(api.inferStoppedRecordStep(state), 7); + assert.equal(api.resolveAccountRunRecordStatusForStop('stopped', state), 'step7_stopped'); + assert.equal(api.resolveAccountRunRecordReasonForStop('step7_stopped', '流程已被用户停止。'), '步骤 7 已被用户停止。'); + assert.equal( + api.resolveAccountRunRecordReasonForStop('step2_stopped', '步骤 2 已使用邮箱,流程尚未完成。'), + '步骤 2 已停止:邮箱已设置,流程尚未完成。' + ); + + await api.appendAndBroadcastAccountRunRecord('stopped', state, '流程已被用户停止。'); + assert.deepStrictEqual(api.getCaptured(), { + status: 'step7_stopped', + state, + reason: '步骤 7 已被用户停止。', + }); +}); + +test('requestStop appends a stopped record for the next unfinished step when no step is running', async () => { + const bundle = [ + extractFunction('normalizeAutoRunSessionId'), + extractFunction('clearCurrentAutoRunSessionId'), + extractFunction('cleanupStep8NavigationListeners'), + extractFunction('rejectPendingStep8'), + extractFunction('getRunningSteps'), + extractFunction('inferStoppedRecordStep'), + extractFunction('requestStop'), + ].join('\n'); + + const api = new Function(` +let stopRequested = false; +let autoRunActive = false; +let autoRunCurrentRun = 0; +let autoRunTotalRuns = 1; +let autoRunAttemptRun = 0; +let autoRunSessionId = 99; +let webNavListener = null; +let webNavCommittedListener = null; +let step8TabUpdatedListener = null; +let step8PendingReject = null; +let resumeWaiter = null; +const STOP_ERROR_MESSAGE = '流程已被用户停止。'; +const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start'; +const DEFAULT_STATE = { + stepStatuses: Object.fromEntries([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((step) => [step, 'pending'])), +}; +const stepWaiters = new Map(); +const appended = []; +const logs = []; +const chrome = { + webNavigation: { + onBeforeNavigate: { removeListener() {} }, + onCommitted: { removeListener() {} }, + }, + tabs: { + onUpdated: { removeListener() {} }, + }, +}; + +function cancelPendingCommands() {} +function getPendingAutoRunTimerPlan() { + return null; +} +async function cancelScheduledAutoRun() {} +async function clearAutoRunTimerAlarm() {} +function clearStopRequest() { + stopRequested = false; +} +async function addLog(message, level) { + logs.push({ message, level }); +} +async function broadcastStopToContentScripts() {} +async function markRunningStepsStopped() {} +async function broadcastAutoRunStatus() {} +async function appendAndBroadcastAccountRunRecord(status, state, reason) { + appended.push({ status, state, reason }); + return { status, state, reason }; +} +async function getState() { + return { + email: 'user@example.com', + password: 'secret', + stepStatuses: { + 1: 'completed', + 2: 'completed', + 3: 'completed', + 4: 'completed', + 5: 'completed', + 6: 'completed', + 7: 'pending', + 8: 'pending', + 9: 'pending', + 10: 'pending', + }, + }; +} + +${bundle} + +return { + requestStop, + snapshot() { + return { appended, logs, stopRequested, autoRunSessionId }; + }, +}; +`)(); + + await api.requestStop(); + const state = api.snapshot(); + + assert.deepStrictEqual(state.appended, [{ + status: 'stopped', + state: { + email: 'user@example.com', + password: 'secret', + stepStatuses: { + 1: 'completed', + 2: 'completed', + 3: 'completed', + 4: 'completed', + 5: 'completed', + 6: 'completed', + 7: 'pending', + 8: 'pending', + 9: 'pending', + 10: 'pending', + }, + }, + reason: '流程已被用户停止。', + }]); + assert.equal(state.autoRunSessionId, 0); + assert.equal(state.stopRequested, true); +}); diff --git a/tests/sidepanel-account-records-manager.test.js b/tests/sidepanel-account-records-manager.test.js index 4d884d7..49a56d6 100644 --- a/tests/sidepanel-account-records-manager.test.js +++ b/tests/sidepanel-account-records-manager.test.js @@ -207,7 +207,7 @@ test('account records manager supports filter chips and partial multi-select del finalStatus: 'stopped', finishedAt: '2026-04-17T04:28:00.000Z', retryCount: 1, - failureLabel: '流程已停止', + failureLabel: '步骤 7 停止', }, ], }; @@ -303,6 +303,7 @@ test('account records manager supports filter chips and partial multi-select del assert.doesNotMatch(list.innerHTML, /success@example\.com/); assert.match(list.innerHTML, /failed@example\.com/); assert.match(list.innerHTML, /stopped@example\.com/); + assert.match(list.innerHTML, /步骤 7 停止/); btnToggleAccountRecordsSelection.listeners.click(); diff --git a/tests/step8-stop-cleanup.test.js b/tests/step8-stop-cleanup.test.js index e49ca4e..587d973 100644 --- a/tests/step8-stop-cleanup.test.js +++ b/tests/step8-stop-cleanup.test.js @@ -58,6 +58,8 @@ const helperBundle = [ extractFunction(helperSource, 'cleanupStep8NavigationListeners'), extractFunction(helperSource, 'rejectPendingStep8'), extractFunction(helperSource, 'throwIfStep8SettledOrStopped'), + extractFunction(helperSource, 'getRunningSteps'), + extractFunction(helperSource, 'inferStoppedRecordStep'), extractFunction(helperSource, 'requestStop'), ].join('\n'); @@ -75,6 +77,9 @@ let autoRunTotalRuns = 3; let autoRunAttemptRun = 4; let autoRunSessionId = 99; const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start'; +const DEFAULT_STATE = { + stepStatuses: Object.fromEntries([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((step) => [step, 'pending'])), +}; const STEP8_CLICK_RETRY_DELAY_MS = 500; const STEP8_MAX_ROUNDS = 5; const STEP8_READY_WAIT_TIMEOUT_MS = 30000; @@ -137,6 +142,7 @@ async function addLog() {} async function broadcastStopToContentScripts() {} async function markRunningStepsStopped() {} async function broadcastAutoRunStatus() {} +async function appendAndBroadcastAccountRunRecord() {} async function getState() { return { autoRunning: false }; } diff --git a/项目完整链路说明.md b/项目完整链路说明.md index 90385bf..985c603 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -38,7 +38,7 @@ - 接收后台广播并更新 UI - 动态渲染步骤列表 - 顶部提供一个临时 `贡献` 按钮,用于直接打开账号贡献上传页 -- 在日志区通过“记录”按钮打开独立的邮箱记录覆盖层,并展示成功/失败/停止/重试统计与分页列表 +- 在日志区通过“记录”按钮打开独立的邮箱记录覆盖层,并展示成功/失败/停止/重试统计、按状态筛选、分页列表与多选删除操作 - 查询 GitHub Releases 并展示更新卡片;当前更新服务会区分 `Pro` 与 legacy `v` 两个版本族,排序时优先保持版本族语义一致,同时会在读取缓存后重新排序,避免旧缓存把 `v` 版本误显示为比 `Pro` 更新 ### 2.2 Background Service Worker @@ -144,7 +144,7 @@ - iCloud 相关偏好 - LuckMail API 配置 - 自动运行默认配置 -- 账号运行历史 `accountRunHistory`(以邮箱为主键,保存该邮箱最近一次状态:成功/失败/停止) +- 账号运行历史 `accountRunHistory`(以邮箱为主键,保存该邮箱最近一次状态:成功/失败/停止;停止记录会优先归一到具体步骤,如“步骤 7 停止”) - 账号运行历史本地同步开关与 helper 地址 当启用了独立的账号运行历史本地同步配置时,账号运行历史会通过 [scripts/hotmail_helper.py](c:/Users/projectf/Downloads/codex注册扩展/scripts/hotmail_helper.py) 整体同步写入 `data/account-run-history.json` 快照文件,便于开发者直接查看完整记录。 @@ -238,7 +238,7 @@ 1. 解析本轮应使用的邮箱 2. 打开或复用注册页 3. 点击注册入口并提交邮箱 -4. 以当前邮箱先写入一条“停止(流程尚未完成)”的记录占位 +4. 以当前邮箱先写入一条“停止(流程尚未完成)”的记录占位;该占位记录后续会随真实停止点归一成“步骤 X 停止”或被成功/失败状态覆盖 5. 等待邮箱提交后的真实落地页 6. 如果进入密码页,则继续执行 Step 3 7. 如果直接进入邮箱验证码页,则自动跳过 Step 3 并进入 Step 4 @@ -495,7 +495,7 @@ - 当前轮重试 - 下一轮继续 7. 当前轮最终失败时,写入邮箱记录,并在自动重试链路中累计重试次数 - - 手动停止与自动停止会写入“停止”状态(若后续同邮箱成功/失败,会被覆盖为最新状态) + - 手动停止与自动停止会写入“停止”状态;如果能确定当前运行步骤或下一未完成步骤,则会优先归一成“步骤 X 停止”(若后续同邮箱成功/失败,会被覆盖为最新状态) 8. 如果配置了线程间隔,则挂计时计划;计时计划会带上当前 `autoRunSessionId` 9. 旧 timer / 旧 alarm / 旧恢复入口只有在 session 仍有效时才允许恢复执行;Stop 会立即使当前 session 失效,防止“停止后旧倒计时又把流程重新拉起” 10. 所有轮次结束后输出汇总,并清空当前 session 标识 diff --git a/项目文件结构说明.md b/项目文件结构说明.md index 4d90214..9aab010 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -40,7 +40,7 @@ ## `background/` -- `background/account-run-history.js`:邮箱记录模块,负责以邮箱为主键维护最新记录(同邮箱后续状态覆盖),在步骤 2 设定邮箱后支持先写入“停止/未完成”占位状态,统一归一化成功/失败/停止三态并落地到 `chrome.storage.local`,支持清理记录,并在启用独立本地同步配置后把完整快照同步到本地 helper。 +- `background/account-run-history.js`:邮箱记录模块,负责以邮箱为主键维护最新记录(同邮箱后续状态覆盖),在步骤 2 设定邮箱后支持先写入“停止/未完成”占位状态,统一归一化成功/失败/停止三态并落地到 `chrome.storage.local`,停止记录会尽量推断并归一到具体步骤标签(如“步骤 7 停止”),支持清理记录与按选中记录局部删除,并在启用独立本地同步配置后把完整快照同步到本地 helper。 - `background/auto-run-controller.js`:自动运行主控制器,封装多轮执行、重试、轮次摘要、线程间隔与倒计时恢复逻辑;当前自动流程会绑定 `autoRunSessionId`,手动停止后旧的倒计时计划、旧重试链路和旧恢复入口不会再复活已失效的自动运行;fresh-attempt reset 时会额外保留 `gmailBaseEmail` 与 `mail2925BaseEmail`,避免自动流程重置后丢失别名基邮箱配置。 - `background/generated-email-helpers.js`:生成邮箱辅助层,除了 Duck / Cloudflare / iCloud / Cloudflare Temp Email,也统一承接 Gmail / 2925 的别名邮箱生成入口。 - `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层;当前额外承接 `add-phone / 手机号页` 这类认证 fatal 错误的共享判定。 @@ -112,7 +112,7 @@ - `sidepanel/hotmail-manager.js`:侧边栏 Hotmail 账号池管理器,负责列表、导入、验证、测试收信和批量操作。 - `sidepanel/icloud-manager.js`:侧边栏 iCloud 隐私邮箱管理器,负责列表、筛选、保留、删除和批量操作。 - `sidepanel/luckmail-manager.js`:侧边栏 LuckMail 管理器,负责邮箱列表、筛选、启停、保留与批量操作。 -- `sidepanel/account-records-manager.js`:侧边栏邮箱记录面板管理器,负责“记录”按钮、覆盖层开关、分页列表、成功/失败/停止统计摘要和清理确认。 +- `sidepanel/account-records-manager.js`:侧边栏邮箱记录面板管理器,负责“记录”按钮、覆盖层开关、分页列表、成功/失败/停止统计摘要、按状态筛选、多选删除、清理确认,以及多层弹窗下的工具栏交互状态管理。 - `sidepanel/sidepanel.css`:侧边栏样式文件。 - `sidepanel/sidepanel.html`:侧边栏页面结构;当前步骤列表已改为动态容器,日志区提供“记录”按钮并挂接邮箱记录覆盖层,顶部新增临时 `贡献` 按钮用于直接打开账号贡献上传页,同时继续加载 `managed-alias-utils.js`,把旧的“邮箱前缀”字段语义改为“别名基邮箱”。 - `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、独立本地同步配置、共享验证码自动重发次数配置与广播接收,并装配 Hotmail / iCloud / LuckMail / 邮箱记录面板 manager;当前顶部 `贡献` 按钮会在新标签页中直接打开账号贡献上传页。 @@ -128,7 +128,8 @@ - `tests/auto-run-step6-restart.test.js`:测试自动运行在后半段授权链路遇错时会回到步骤 7 重开,并在命中 add-phone 或泛化手机号页 fatal 错误时停止重开。 - `tests/auto-step-random-delay.test.js`:测试自动运行步间延迟与旧配置键兼容解析。 - `tests/background-auto-run-module.test.js`:测试自动运行控制器模块已接入且导出工厂。 -- `tests/background-account-run-history-module.test.js`:测试邮箱记录模块已接入、导出工厂,能够保留并归一化停止记录、归一化失败标签、计算自动重试次数,并支持清理后同步完整快照。 +- `tests/background-account-run-history-module.test.js`:测试邮箱记录模块已接入、导出工厂,能够保留并归一化停止记录、归一化失败/停止标签、计算自动重试次数,并支持清理后同步完整快照。 +- `tests/background-stop-gap-record.test.js`:测试通用 stopped 记录在步骤空档期会归一到“下一未完成步骤停止”,并校验停止原因文案归一化。 - `tests/background-account-history-settings.test.js`:测试账号运行历史的独立配置项归一化,不再复用 Hotmail 模式作为启停条件。 - `tests/background-generated-email-module.test.js`:测试生成邮箱辅助模块已接入且导出工厂。 - `tests/background-icloud.test.js`:测试 iCloud 相关后台纯函数与别名收尾逻辑。 @@ -162,7 +163,7 @@ - `tests/microsoft-email.test.js`:测试 Microsoft 邮件拉取与验证码提取逻辑。 - `tests/sidepanel-hotmail-manager.test.js`:测试侧边栏 Hotmail 管理器模块接线与空态渲染。 - `tests/sidepanel-contribution-button.test.js`:测试侧边栏顶部 `贡献` 按钮的 HTML 接线,以及直接打开账号贡献上传页的最小行为。 -- `tests/sidepanel-account-records-manager.test.js`:测试侧边栏邮箱记录覆盖层的 HTML 接入、helper 地址归一化与 manager 渲染逻辑。 +- `tests/sidepanel-account-records-manager.test.js`:测试侧边栏邮箱记录覆盖层的 HTML 接入、helper 地址归一化、筛选/多选删除交互,以及确认弹窗层级高于记录覆盖层。 - `tests/sidepanel-icloud-manager.test.js`:测试侧边栏 iCloud 管理器模块接线与空态渲染。 - `tests/sidepanel-icloud-provider.test.js`:测试侧边栏 iCloud 登录地址解析逻辑。 - `tests/sidepanel-luckmail-manager.test.js`:测试侧边栏 LuckMail 管理器模块接线与空态渲染。 From 7cb1bda6729f5b36225a935459085d4ac6a0935d Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sun, 19 Apr 2026 18:59:32 +0800 Subject: [PATCH 04/15] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=20OAuth=20?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=EF=BC=8C=E8=B0=83=E6=95=B4=E6=AD=A5=E9=AA=A4?= =?UTF-8?q?=206=20=E5=92=8C=E6=AD=A5=E9=AA=A4=207=20=E7=9A=84=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E6=B7=BB=E5=8A=A0=E7=9B=B8=E5=85=B3=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background.js | 157 ++++++++++++++-- background/panel-bridge.js | 4 +- background/steps/fetch-login-code.js | 27 +-- background/steps/oauth-login.js | 1 + content/signup-page.js | 2 +- content/vps-panel.js | 2 +- tests/background-auth-chain-guard.test.js | 186 +++++++++++++++++++ tests/background-panel-bridge-module.test.js | 6 + tests/background-step6-retry-limit.test.js | 1 + tests/background-step7-recovery.test.js | 28 +-- tests/step8-restart-step7-error.test.js | 20 +- 项目文件结构说明.md | 6 +- 12 files changed, 372 insertions(+), 68 deletions(-) create mode 100644 tests/background-auth-chain-guard.test.js diff --git a/background.js b/background.js index b614309..b5958ac 100644 --- a/background.js +++ b/background.js @@ -321,6 +321,7 @@ const DEFAULT_STATE = { signupVerificationRequestedAt: null, loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, + oauthFlowDeadlineSourceUrl: null, currentHotmailAccountId: null, preferredIcloudHost: '', }; @@ -3975,6 +3976,7 @@ function getDownstreamStateResets(step) { signupVerificationRequestedAt: null, loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, + oauthFlowDeadlineSourceUrl: null, lastSignupCode: null, lastLoginCode: null, localhostUrl: null, @@ -3987,6 +3989,7 @@ function getDownstreamStateResets(step) { signupVerificationRequestedAt: null, loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, + oauthFlowDeadlineSourceUrl: null, lastSignupCode: null, lastLoginCode: null, localhostUrl: null, @@ -3998,6 +4001,7 @@ function getDownstreamStateResets(step) { signupVerificationRequestedAt: null, loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, + oauthFlowDeadlineSourceUrl: null, lastSignupCode: null, lastLoginCode: null, localhostUrl: null, @@ -4008,6 +4012,7 @@ function getDownstreamStateResets(step) { lastLoginCode: null, loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, + oauthFlowDeadlineSourceUrl: null, localhostUrl: null, }; } @@ -4612,7 +4617,11 @@ async function skipStep(step) { return { ok: true, step, status: 'skipped' }; } -function throwIfStopped() { +function throwIfStopped(error = null) { + const errorMessage = typeof error === 'string' ? error : error?.message; + if (errorMessage === STOP_ERROR_MESSAGE) { + throw error instanceof Error ? error : new Error(STOP_ERROR_MESSAGE); + } if (stopRequested) { throw new Error(STOP_ERROR_MESSAGE); } @@ -4796,6 +4805,7 @@ async function handleStepData(step, payload) { await setState({ localhostUrl: payload.localhostUrl, oauthFlowDeadlineAt: null, + oauthFlowDeadlineSourceUrl: null, }); broadcastDataUpdate({ localhostUrl: payload.localhostUrl }); } @@ -4993,6 +5003,59 @@ async function waitForRunningStepsToFinish(payload = {}) { return currentState; } +const AUTH_CHAIN_STEP_IDS = new Set([7, 8, 9, 10]); +let activeTopLevelAuthChainExecution = null; + +function isAuthChainStep(step) { + return AUTH_CHAIN_STEP_IDS.has(Number(step)); +} + +async function acquireTopLevelAuthChainExecution(step) { + const normalizedStep = Number(step); + if (!isAuthChainStep(normalizedStep)) { + return { + joined: false, + release() {}, + }; + } + + if (activeTopLevelAuthChainExecution) { + const activeExecution = activeTopLevelAuthChainExecution; + await addLog( + `步骤 ${normalizedStep}:检测到步骤 ${activeExecution.step} 正在运行,本次请求将复用当前授权链,不再重复启动。`, + 'warn' + ); + const result = await activeExecution.promise; + if (result?.error) { + throw result.error; + } + return { + joined: true, + release() {}, + }; + } + + let settleExecution = () => {}; + const promise = new Promise((resolve) => { + settleExecution = (error = null) => resolve({ error }); + }); + const execution = { + step: normalizedStep, + promise, + }; + activeTopLevelAuthChainExecution = execution; + + return { + joined: false, + release(error = null) { + if (activeTopLevelAuthChainExecution === execution) { + activeTopLevelAuthChainExecution = null; + } + settleExecution(error); + }, + }; +} + async function markRunningStepsStopped() { const state = await getState(); const runningSteps = getRunningSteps(state.stepStatuses); @@ -5089,21 +5152,29 @@ async function requestStop(options = {}) { async function executeStep(step, options = {}) { const { deferRetryableTransportError = false } = options; console.log(LOG_PREFIX, `Executing step ${step}`); - throwIfStopped(); - await setStepStatus(step, 'running'); - await addLog(`步骤 ${step} 开始执行`); - await humanStepDelay(); - - const state = await getState(); - - // Set flow start time on first step - if (step === 1 && !state.flowStartTime) { - await setState({ flowStartTime: Date.now() }); + const authChainClaim = await acquireTopLevelAuthChainExecution(step); + if (authChainClaim.joined) { + return; } + let executionError = null; + throwIfStopped(); try { + await setStepStatus(step, 'running'); + await addLog(`步骤 ${step} 开始执行`); + await humanStepDelay(); + + const state = await getState(); + + // Set flow start time on first step + if (step === 1 && !state.flowStartTime) { + await setState({ flowStartTime: Date.now() }); + } + await stepRegistry.executeStep(step, state); } catch (err) { + executionError = err; + const state = await getState(); if (isStopError(err)) { await setStepStatus(step, 'stopped'); await addLog(`步骤 ${step} 已被用户停止`, 'warn'); @@ -5125,6 +5196,8 @@ async function executeStep(step, options = {}) { ); } throw err; + } finally { + authChainClaim.release(executionError); } } @@ -5963,7 +6036,6 @@ const step8Executor = self.MultiPageBackgroundStep8?.createStep8Executor({ CLOUDFLARE_TEMP_EMAIL_PROVIDER, confirmCustomVerificationStepBypass: verificationFlowHelpers.confirmCustomVerificationStepBypass, ensureStep8VerificationPageReady, - executeStep7: (...args) => executeStep7(...args), getOAuthFlowRemainingMs, getOAuthFlowStepTimeoutMs, getPanelMode, @@ -5975,9 +6047,9 @@ const step8Executor = self.MultiPageBackgroundStep8?.createStep8Executor({ isVerificationMailPollingError, LUCKMAIL_PROVIDER, resolveVerificationStep: verificationFlowHelpers.resolveVerificationStep, + rerunStep7ForStep8Recovery: (...args) => rerunStep7ForStep8Recovery(...args), reuseOrCreateTab, setState, - setStepStatus, shouldUseCustomRegistrationEmail, sleepWithStop, STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS, @@ -6455,10 +6527,18 @@ function normalizeOAuthFlowDeadlineAt(value) { return Math.floor(numeric); } +function normalizeOAuthFlowSourceUrl(value) { + const normalized = String(value || '').trim(); + return normalized || null; +} + async function startOAuthFlowTimeoutWindow(options = {}) { const step = Number(options.step) || 7; const deadlineAt = Date.now() + OAUTH_FLOW_TIMEOUT_MS; - await setState({ oauthFlowDeadlineAt: deadlineAt }); + await setState({ + oauthFlowDeadlineAt: deadlineAt, + oauthFlowDeadlineSourceUrl: normalizeOAuthFlowSourceUrl(options.oauthUrl), + }); await addLog(`步骤 ${step}:已拿到新的 OAuth 登录地址,开始 6 分钟倒计时。`, 'info'); return deadlineAt; } @@ -6468,10 +6548,22 @@ async function getOAuthFlowRemainingMs(options = {}) { const actionLabel = String(options.actionLabel || '后续授权流程').trim() || '后续授权流程'; const state = options.state || await getState(); const deadlineAt = normalizeOAuthFlowDeadlineAt(state?.oauthFlowDeadlineAt); + const deadlineSourceUrl = normalizeOAuthFlowSourceUrl(state?.oauthFlowDeadlineSourceUrl); + const currentOauthUrl = normalizeOAuthFlowSourceUrl(options.oauthUrl !== undefined ? options.oauthUrl : state?.oauthUrl); if (!deadlineAt) { return null; } + if (deadlineSourceUrl && currentOauthUrl && deadlineSourceUrl !== currentOauthUrl) { + console.warn(LOG_PREFIX, '[oauth-flow] ignoring stale deadline due to oauth url mismatch', { + step, + actionLabel, + deadlineSourceUrl, + currentOauthUrl, + }); + return null; + } + const remainingMs = deadlineAt - Date.now(); if (remainingMs <= 0) { throw buildOAuthFlowTimeoutError(step, actionLabel); @@ -6617,6 +6709,43 @@ async function ensureStep8VerificationPageReady(options = {}) { throw new Error(`当前未进入登录验证码页面,请先重新完成步骤 7。当前状态:${stateLabel}.${urlPart}`.trim()); } +async function rerunStep7ForStep8Recovery(options = {}) { + const { + logMessage = '步骤 8:正在回到步骤 7,重新发起登录验证码流程...', + postStepDelayMs = 3000, + } = options; + + throwIfStopped(); + const initialState = await getState(); + await addLog(logMessage, 'warn'); + await setStepStatus(7, 'running'); + await addLog('步骤 7 开始执行'); + + try { + await step7Executor.executeStep7(initialState); + } catch (err) { + const latestState = await getState(); + if (isStopError(err)) { + await setStepStatus(7, 'stopped'); + await addLog('步骤 7 已被用户停止', 'warn'); + await appendManualAccountRunRecordIfNeeded('step7_stopped', latestState, getErrorMessage(err)); + throw err; + } + if (isTerminalSecurityBlockedError(err)) { + await handleCloudflareSecurityBlocked(err); + throw new Error(STOP_ERROR_MESSAGE); + } + await setStepStatus(7, 'failed'); + await addLog(`步骤 7 失败:${getErrorMessage(err)}`, 'error'); + await appendManualAccountRunRecordIfNeeded('step7_failed', latestState, getErrorMessage(err)); + throw err; + } + + if (postStepDelayMs > 0) { + await sleepWithStop(postStepDelayMs); + } +} + async function executeStep6() { return step6Executor.executeStep6(); } diff --git a/background/panel-bridge.js b/background/panel-bridge.js index b25af20..ad97e6c 100644 --- a/background/panel-bridge.js +++ b/background/panel-bridge.js @@ -60,7 +60,7 @@ source: 'background', payload: { vpsPassword: state.vpsPassword, - logStep: 6, + logStep: 7, }, }, { timeoutMs: 30000, @@ -121,7 +121,7 @@ sub2apiPassword: state.sub2apiPassword, sub2apiGroupName: groupName, sub2apiDefaultProxyName: state.sub2apiDefaultProxyName, - logStep: 6, + logStep: 7, }, }, { responseTimeoutMs: SUB2API_STEP1_RESPONSE_TIMEOUT_MS, diff --git a/background/steps/fetch-login-code.js b/background/steps/fetch-login-code.js index 0046dd7..d147bb3 100644 --- a/background/steps/fetch-login-code.js +++ b/background/steps/fetch-login-code.js @@ -8,7 +8,6 @@ CLOUDFLARE_TEMP_EMAIL_PROVIDER, confirmCustomVerificationStepBypass, ensureStep8VerificationPageReady, - executeStep7, getOAuthFlowRemainingMs, getOAuthFlowStepTimeoutMs, getMailConfig, @@ -19,17 +18,16 @@ isVerificationMailPollingError, LUCKMAIL_PROVIDER, resolveVerificationStep, + rerunStep7ForStep8Recovery, reuseOrCreateTab, setState, - setStepStatus, shouldUseCustomRegistrationEmail, - sleepWithStop, STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS, STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS, throwIfStopped, } = deps; - async function getStep8ReadyTimeoutMs(actionLabel) { + async function getStep8ReadyTimeoutMs(actionLabel, expectedOauthUrl = '') { if (typeof getOAuthFlowStepTimeoutMs !== 'function') { return 15000; } @@ -37,10 +35,11 @@ return getOAuthFlowStepTimeoutMs(15000, { step: 8, actionLabel, + oauthUrl: expectedOauthUrl, }); } - function getStep8RemainingTimeResolver() { + function getStep8RemainingTimeResolver(expectedOauthUrl = '') { if (typeof getOAuthFlowRemainingMs !== 'function') { return undefined; } @@ -48,6 +47,7 @@ return async (details = {}) => getOAuthFlowRemainingMs({ step: 8, actionLabel: details.actionLabel || '登录验证码流程', + oauthUrl: expectedOauthUrl, }); } @@ -73,7 +73,7 @@ throwIfStopped(); const pageState = await ensureStep8VerificationPageReady({ - timeoutMs: await getStep8ReadyTimeoutMs('确认登录验证码页已就绪'), + timeoutMs: await getStep8ReadyTimeoutMs('确认登录验证码页已就绪', state?.oauthUrl || ''), }); const shouldCompareVerificationEmail = mail.provider !== '2925'; const displayedVerificationEmail = shouldCompareVerificationEmail @@ -131,7 +131,7 @@ step8VerificationTargetEmail: displayedVerificationEmail || '', }, mail, { filterAfterTimestamp: stepStartedAt, - getRemainingTimeMs: getStep8RemainingTimeResolver(), + getRemainingTimeMs: getStep8RemainingTimeResolver(state?.oauthUrl || ''), requestFreshCodeFirst: false, targetEmail: fixedTargetEmail, resendIntervalMs: (mail.provider === HOTMAIL_PROVIDER || mail.provider === '2925') @@ -140,19 +140,6 @@ }); } - async function rerunStep7ForStep8Recovery(options = {}) { - const { - logMessage = '步骤 8:正在回到步骤 7,重新发起登录验证码流程...', - postStepDelayMs = 3000, - } = options; - const currentState = await getState(); - await addLog(logMessage, 'warn'); - await executeStep7(currentState); - if (postStepDelayMs > 0) { - await sleepWithStop(postStepDelayMs); - } - } - function isStep8RestartStep7Error(error) { const message = String(error?.message || error || ''); return /STEP8_RESTART_STEP7::/i.test(message); diff --git a/background/steps/oauth-login.js b/background/steps/oauth-login.js index facf21e..596ac9f 100644 --- a/background/steps/oauth-login.js +++ b/background/steps/oauth-login.js @@ -45,6 +45,7 @@ ? await getOAuthFlowStepTimeoutMs(180000, { step: 7, actionLabel: 'OAuth 登录并进入验证码页', + oauthUrl, }) : 180000; diff --git a/content/signup-page.js b/content/signup-page.js index 4ef7f78..de736d4 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -1671,7 +1671,7 @@ async function fillVerificationCode(step, payload) { } // ============================================================ -// Step 6: Login with registered account (on OAuth auth page) +// Step 7: Login with registered account (on OAuth auth page) // ============================================================ async function waitForStep6EmailSubmitTransition(emailSubmittedAt, timeout = 12000) { diff --git a/content/vps-panel.js b/content/vps-panel.js index 28b3c4f..4e84d49 100644 --- a/content/vps-panel.js +++ b/content/vps-panel.js @@ -1,4 +1,4 @@ -// content/vps-panel.js — Content script for CPA panel (steps 1, 9) +// content/vps-panel.js — Content script for CPA panel (steps 7, 10 / OAuth URL request) // Injected on: CPA panel (user-configured URL) // // Actual DOM structure (after login click): diff --git a/tests/background-auth-chain-guard.test.js b/tests/background-auth-chain-guard.test.js new file mode 100644 index 0000000..3fec903 --- /dev/null +++ b/tests/background-auth-chain-guard.test.js @@ -0,0 +1,186 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('background.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 i = start; i < source.length; i += 1) { + const ch = source[i]; + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (ch === '{' && signatureEnded) { + braceStart = i; + 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('throwIfStopped rethrows an explicit stop error even when stopRequested has been cleared', () => { + const api = new Function(` +const STOP_ERROR_MESSAGE = '流程已被用户停止。'; +let stopRequested = false; +${extractFunction('isStopError')} +${extractFunction('throwIfStopped')} +return { + run(error) { + throwIfStopped(error); + }, +}; +`)(); + + assert.throws( + () => api.run(new Error('流程已被用户停止。')), + /流程已被用户停止。/ + ); +}); + +test('executeStep reuses the active top-level auth chain instead of starting a duplicate step', async () => { + const api = new Function(` +const LOG_PREFIX = '[test]'; +const STOP_ERROR_MESSAGE = '流程已被用户停止。'; +const AUTH_CHAIN_STEP_IDS = new Set([7, 8, 9, 10]); +let activeTopLevelAuthChainExecution = null; +let stopRequested = false; +let releaseStep8 = null; +const events = { + logs: [], + statusCalls: [], + registryCalls: [], +}; +const state = { + stepStatuses: {}, +}; + +async function addLog(message, level = 'info') { + events.logs.push({ message, level }); +} +async function setStepStatus(step, status) { + state.stepStatuses[step] = status; + events.statusCalls.push({ step, status }); +} +async function humanStepDelay() {} +async function getState() { + return { + flowStartTime: null, + stepStatuses: { ...state.stepStatuses }, + }; +} +function getErrorMessage(error) { + return error?.message || String(error || ''); +} +async function appendManualAccountRunRecordIfNeeded() {} +function isTerminalSecurityBlockedError() { + return false; +} +async function handleCloudflareSecurityBlocked() {} +function doesStepUseCompletionSignal() { + return false; +} +function isRetryableContentScriptTransportError() { + return false; +} +const stepRegistry = { + async executeStep(step) { + events.registryCalls.push(step); + if (step === 8) { + await new Promise((resolve) => { + releaseStep8 = resolve; + }); + } + }, +}; + +${extractFunction('isStopError')} +${extractFunction('throwIfStopped')} +${extractFunction('isAuthChainStep')} +${extractFunction('acquireTopLevelAuthChainExecution')} +${extractFunction('executeStep')} + +return { + executeStep, + releaseStep8() { + if (releaseStep8) { + releaseStep8(); + } + }, + snapshot() { + return events; + }, +}; +`)(); + + const firstRun = api.executeStep(8); + await new Promise((resolve) => setImmediate(resolve)); + const duplicateRun = api.executeStep(7); + await new Promise((resolve) => setImmediate(resolve)); + api.releaseStep8(); + + await firstRun; + await duplicateRun; + + const events = api.snapshot(); + assert.deepStrictEqual(events.registryCalls, [8]); + assert.deepStrictEqual(events.statusCalls, [ + { step: 8, status: 'running' }, + ]); + assert.ok(events.logs.some(({ message }) => /复用当前授权链/.test(message))); +}); + +test('oauth timeout budget ignores stale deadlines from an old oauth url', async () => { + const api = new Function(` +const LOG_PREFIX = '[test]'; +const OAUTH_FLOW_TIMEOUT_MS = 6 * 60 * 1000; +${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/old', + }, + }); + + assert.equal(timeoutMs, 15000); +}); diff --git a/tests/background-panel-bridge-module.test.js b/tests/background-panel-bridge-module.test.js index 1faf2d5..8473571 100644 --- a/tests/background-panel-bridge-module.test.js +++ b/tests/background-panel-bridge-module.test.js @@ -15,3 +15,9 @@ test('panel bridge module exposes a factory', () => { assert.equal(typeof api?.createPanelBridge, 'function'); }); + +test('panel bridge requests oauth url with step 7 log label payload', () => { + const source = fs.readFileSync('background/panel-bridge.js', 'utf8'); + assert.match(source, /logStep:\s*7/); + assert.doesNotMatch(source, /logStep:\s*6/); +}); diff --git a/tests/background-step6-retry-limit.test.js b/tests/background-step6-retry-limit.test.js index 865b9c5..af1783c 100644 --- a/tests/background-step6-retry-limit.test.js +++ b/tests/background-step6-retry-limit.test.js @@ -172,6 +172,7 @@ test('step 7 starts a new oauth timeout window for each refreshed oauth url', as options: { step: 7, actionLabel: 'OAuth 登录并进入验证码页', + oauthUrl: 'https://oauth.example/latest', }, }, ]); diff --git a/tests/background-step7-recovery.test.js b/tests/background-step7-recovery.test.js index e906140..c117f69 100644 --- a/tests/background-step7-recovery.test.js +++ b/tests/background-step7-recovery.test.js @@ -10,8 +10,7 @@ test('step 8 submits login verification directly without replaying step 7', asyn const calls = { ensureReady: 0, ensureReadyOptions: [], - executeStep7: 0, - sleep: [], + rerunStep7: 0, resolveOptions: null, setStates: [], }; @@ -32,8 +31,8 @@ test('step 8 submits login verification directly without replaying step 7', asyn calls.ensureReadyOptions.push(options || null); return { state: 'verification_page', displayedEmail: 'display.user@example.com' }; }, - executeStep7: async () => { - calls.executeStep7 += 1; + rerunStep7ForStep8Recovery: async () => { + calls.rerunStep7 += 1; }, getOAuthFlowRemainingMs: async () => 5000, getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 5000), @@ -59,9 +58,6 @@ test('step 8 submits login verification directly without replaying step 7', asyn }, setStepStatus: async () => {}, shouldUseCustomRegistrationEmail: () => false, - sleepWithStop: async (ms) => { - calls.sleep.push(ms); - }, STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000, STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8, throwIfStopped: () => {}, @@ -79,8 +75,7 @@ test('step 8 submits login verification directly without replaying step 7', asyn assert.equal(calls.resolveOptions.beforeSubmit, undefined); assert.equal(calls.ensureReady, 1); - assert.equal(calls.executeStep7, 0); - assert.deepStrictEqual(calls.sleep, []); + assert.equal(calls.rerunStep7, 0); assert.equal(calls.resolveOptions.filterAfterTimestamp, 123456); assert.equal(typeof calls.resolveOptions.getRemainingTimeMs, 'function'); assert.equal(await calls.resolveOptions.getRemainingTimeMs({ actionLabel: '登录验证码流程' }), 5000); @@ -107,7 +102,7 @@ test('step 8 disables resend interval for 2925 mailbox polling', async () => { CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', confirmCustomVerificationStepBypass: async () => {}, ensureStep8VerificationPageReady: async () => ({ state: 'verification_page' }), - executeStep7: async () => {}, + rerunStep7ForStep8Recovery: async () => {}, getOAuthFlowRemainingMs: async () => 8000, getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 8000), getMailConfig: () => ({ @@ -130,7 +125,6 @@ test('step 8 disables resend interval for 2925 mailbox polling', async () => { setState: async () => {}, setStepStatus: async () => {}, shouldUseCustomRegistrationEmail: () => false, - sleepWithStop: async () => {}, STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000, STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8, throwIfStopped: () => {}, @@ -161,7 +155,7 @@ test('step 8 falls back to the run email when the verification page does not exp CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', confirmCustomVerificationStepBypass: async () => {}, ensureStep8VerificationPageReady: async () => ({ state: 'verification_page', displayedEmail: '' }), - executeStep7: async () => {}, + rerunStep7ForStep8Recovery: async () => {}, getOAuthFlowRemainingMs: async () => 8000, getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 8000), getMailConfig: () => ({ @@ -184,7 +178,6 @@ test('step 8 falls back to the run email when the verification page does not exp setState: async () => {}, setStepStatus: async () => {}, shouldUseCustomRegistrationEmail: () => false, - sleepWithStop: async () => {}, STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000, STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8, throwIfStopped: () => {}, @@ -201,7 +194,7 @@ test('step 8 falls back to the run email when the verification page does not exp test('step 8 does not rerun step 7 when verification submit lands on add-phone', async () => { const calls = { - executeStep7: 0, + rerunStep7: 0, logs: [], }; @@ -217,8 +210,8 @@ test('step 8 does not rerun step 7 when verification submit lands on add-phone', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', confirmCustomVerificationStepBypass: async () => {}, ensureStep8VerificationPageReady: async () => ({ state: 'verification_page' }), - executeStep7: async () => { - calls.executeStep7 += 1; + rerunStep7ForStep8Recovery: async () => { + calls.rerunStep7 += 1; }, getOAuthFlowRemainingMs: async () => 8000, getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 8000), @@ -242,7 +235,6 @@ test('step 8 does not rerun step 7 when verification submit lands on add-phone', setState: async () => {}, setStepStatus: async () => {}, shouldUseCustomRegistrationEmail: () => false, - sleepWithStop: async () => {}, STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000, STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8, throwIfStopped: () => {}, @@ -257,6 +249,6 @@ test('step 8 does not rerun step 7 when verification submit lands on add-phone', /add-phone/ ); - assert.equal(calls.executeStep7, 0); + assert.equal(calls.rerunStep7, 0); assert.ok(!calls.logs.some(({ message }) => /准备从步骤 7 重新开始/.test(message))); }); diff --git a/tests/step8-restart-step7-error.test.js b/tests/step8-restart-step7-error.test.js index 2ad8622..621be5e 100644 --- a/tests/step8-restart-step7-error.test.js +++ b/tests/step8-restart-step7-error.test.js @@ -117,10 +117,10 @@ return { test('step 8 reruns step 7 when auth page enters login timeout retry state', async () => { const calls = { - executeStep7: 0, + rerunStep7: 0, ensureReady: 0, logs: [], - sleeps: [], + rerunOptions: [], resolveCalls: 0, }; @@ -142,8 +142,9 @@ test('step 8 reruns step 7 when auth page enters login timeout retry state', asy } return { state: 'verification_page' }; }, - executeStep7: async () => { - calls.executeStep7 += 1; + rerunStep7ForStep8Recovery: async (options) => { + calls.rerunStep7 += 1; + calls.rerunOptions.push(options || null); }, getOAuthFlowRemainingMs: async () => 8000, getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 8000), @@ -167,9 +168,6 @@ test('step 8 reruns step 7 when auth page enters login timeout retry state', asy setState: async () => {}, setStepStatus: async () => {}, shouldUseCustomRegistrationEmail: () => false, - sleepWithStop: async (ms) => { - calls.sleeps.push(ms); - }, STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000, STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 3, throwIfStopped: () => {}, @@ -181,9 +179,13 @@ test('step 8 reruns step 7 when auth page enters login timeout retry state', asy oauthUrl: 'https://oauth.example/latest', }); - assert.equal(calls.executeStep7, 1); + assert.equal(calls.rerunStep7, 1); assert.equal(calls.ensureReady, 2); assert.equal(calls.resolveCalls, 1); assert.equal(calls.logs.some(({ message }) => /重新开始|重新发起/.test(message)), true); - assert.deepStrictEqual(calls.sleeps, [3000]); + assert.deepStrictEqual(calls.rerunOptions, [ + { + logMessage: '步骤 8:认证页进入重试/超时报错状态,正在回到步骤 7 重新发起登录流程...', + }, + ]); }); diff --git a/项目文件结构说明.md b/项目文件结构说明.md index 9aab010..15ddeb6 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -46,7 +46,7 @@ - `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层;当前额外承接 `add-phone / 手机号页` 这类认证 fatal 错误的共享判定。 - `background/message-router.js`:后台消息路由层,负责处理 `chrome.runtime.onMessage` 进入的所有业务消息。 - `background/navigation-utils.js`:导航与 URL 判断工具层,负责 callback、入口页、CPA / SUB2API 地址、步骤跳转相关判断。 -- `background/panel-bridge.js`:CPA / SUB2API 面板桥接层,封装 OAuth 地址获取所需的页面打开、脚本注入和通信。 +- `background/panel-bridge.js`:CPA / SUB2API 面板桥接层,封装 OAuth 地址获取所需的页面打开、脚本注入和通信;当前 `REQUEST_OAUTH_URL` 链路的面板日志会统一按步骤 7 记录,避免继续误标为旧的步骤 6。 - `background/signup-flow-helpers.js`:注册页辅助层,负责打开注册入口、等待密码页以及解析当前流程所用邮箱;在 Gmail / 2925 模式下会优先复用已经存在且仍兼容的完整注册邮箱,只有不兼容或为空时才重新生成。 - `background/tab-runtime.js`:标签页与内容脚本运行时基础设施,封装标签注册、冲突清理、消息超时、注入重试与队列;当前等待标签完成、注入后的短暂延迟和内容脚本重试等待都已做 Stop 感知,避免用户停止后后台还继续等待并恢复执行。 - `background/verification-flow.js`:注册/登录验证码共享流程层,封装重发、轮询、提交、失败回退、自定义邮箱跳过、共享验证码自动重发次数配置以及 2925 长轮询参数;当前验证码提交重试上限为 7 次,步骤 8 提交登录验证码后若页面进入 `add-phone / 手机号页`,会直接抛出 fatal 错误而不是把当前步骤视为成功,并在 2925 提交成功后异步触发“删除全部邮件”的清理消息。 @@ -55,11 +55,11 @@ - `background/steps/clear-login-cookies.js`:步骤 6 实现,负责登录前 Cookie 清理。 - `background/steps/confirm-oauth.js`:步骤 9 实现,负责 OAuth 同意页按钮定位、点击、localhost 回调监听与回调完成。 -- `background/steps/fetch-login-code.js`:步骤 8 实现,负责登录验证码阶段的邮箱轮询、验证码回填与回退控制;验证码获取后直接提交,不再在提交前回放步骤 7 刷新 OAuth;对非 2925 provider 会固定当前验证码页显示邮箱作为本次 Step 8 的目标邮箱。 +- `background/steps/fetch-login-code.js`:步骤 8 实现,负责登录验证码阶段的邮箱轮询、验证码回填与回退控制;验证码获取后直接提交,不再在提交前回放步骤 7 刷新 OAuth;对非 2925 provider 会固定当前验证码页显示邮箱作为本次 Step 8 的目标邮箱;当步骤 8 需要回退到步骤 7 重开授权链时,会走后台统一恢复入口,避免直接并发拉起新的 Step 7。 - `background/steps/fetch-signup-code.js`:步骤 4 实现,负责注册验证码阶段的页面准备与验证码流程入口。 - `background/steps/fill-password.js`:步骤 3 实现,负责密码生成、保存、回填与提交。 - `background/steps/fill-profile.js`:步骤 5 实现,负责姓名、生日填写并把资料提交给注册页内容脚本。 -- `background/steps/oauth-login.js`:步骤 7 实现,负责刷新 OAuth 链接、登录和确保进入验证码页;普通可恢复登录态失败会按上限重试,但一旦识别到认证流程进入 `add-phone / 手机号页`,会立即退出步骤 7 内部重试。 +- `background/steps/oauth-login.js`:步骤 7 实现,负责刷新 OAuth 链接、登录和确保进入验证码页;普通可恢复登录态失败会按上限重试,但一旦识别到认证流程进入 `add-phone / 手机号页`,会立即退出步骤 7 内部重试;当前每次拿到新的 OAuth 链接都会刷新对应的 6 分钟授权预算,并把预算与具体 OAuth URL 绑定,避免旧链路 deadline 污染新链路。 - `background/steps/open-chatgpt.js`:步骤 1 实现,负责打开 ChatGPT 官网并确认入口就绪。 - `background/steps/platform-verify.js`:步骤 10 实现,负责 CPA / SUB2API 回调验证。 - `background/steps/registry.js`:步骤注册表工厂,负责用稳定的步骤元数据映射到执行器。 From 01849505e4cd9223ccc1e0c31ac96a0c9cdfcb84 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sun, 19 Apr 2026 23:35:35 +0800 Subject: [PATCH 05/15] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=B4=A1?= =?UTF-8?q?=E7=8C=AE=E6=A8=A1=E5=BC=8F=E6=94=AF=E6=8C=81=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86=E5=92=8C=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background.js | 13 +++- background/contribution-oauth.js | 1 + background/message-router.js | 6 ++ sidepanel/sidepanel.js | 1 + tests/background-contribution-mode.test.js | 75 ++++++++++++++++++++++ 5 files changed, 95 insertions(+), 1 deletion(-) diff --git a/background.js b/background.js index 779312e..ea7dfe3 100644 --- a/background.js +++ b/background.js @@ -179,7 +179,8 @@ const MICROSOFT_TOKEN_DNR_RULE_ID = 1001; const PERSISTENT_ALIAS_STATE_KEYS = ['manualAliasUsage', 'preservedAliases']; const ACCOUNT_RUN_HISTORY_STORAGE_KEY = 'accountRunHistory'; const CONTRIBUTION_RUNTIME_DEFAULTS = self.MultiPageBackgroundContributionOAuth?.RUNTIME_DEFAULTS || { - ...CONTRIBUTION_RUNTIME_DEFAULTS, + contributionMode: false, + contributionModeExpected: false, contributionSessionId: '', contributionAuthUrl: '', contributionAuthState: '', @@ -1149,6 +1150,7 @@ function buildContributionModeState(enabled, persistedSettings = {}, currentStat return { ...currentContributionState, contributionMode: true, + contributionModeExpected: true, panelMode: 'cpa', customPassword: '', accountRunHistoryTextEnabled: false, @@ -1158,6 +1160,7 @@ function buildContributionModeState(enabled, persistedSettings = {}, currentStat return { ...CONTRIBUTION_RUNTIME_DEFAULTS, contributionMode: false, + contributionModeExpected: false, panelMode: persistedSettings.panelMode || DEFAULT_STATE.panelMode, customPassword: persistedSettings.customPassword || '', accountRunHistoryTextEnabled: Boolean(persistedSettings.accountRunHistoryTextEnabled), @@ -6454,7 +6457,11 @@ async function runPreStep6CookieCleanup() { // ============================================================ async function refreshOAuthUrlBeforeStep6(state) { + if (state?.contributionModeExpected && !state?.contributionMode) { + throw new Error('步骤 7:当前自动流程预期使用贡献模式,但运行态 contributionMode 已丢失,已阻止回退到普通 CPA 面板。请重新进入贡献模式后再点击自动。'); + } if (state?.contributionMode && contributionOAuthManager?.startContributionFlow) { + await addLog('步骤 7:contributionMode=true,正在通过公开贡献接口申请 OAuth 链接...', 'info'); await addLog('步骤 7:贡献模式正在申请贡献登录地址...'); const contributionState = await contributionOAuthManager.startContributionFlow({ nickname: state.email, @@ -6469,6 +6476,7 @@ async function refreshOAuthUrlBeforeStep6(state) { return oauthUrl; } await addLog(`步骤 7:正在刷新登录用的 ${getPanelModeLabel(state)} OAuth 链接...`); + await addLog(`步骤 7:contributionMode=${Boolean(state?.contributionMode)},当前将回退到 ${getPanelModeLabel(state)} 面板刷新 OAuth。`, 'warn'); console.log(LOG_PREFIX, '[refreshOAuthUrlBeforeStep6] requesting fresh OAuth directly from panel'); const refreshResult = await requestOAuthUrlFromPanel(state, { logLabel: '步骤 7' }); await handleStepData(1, refreshResult); @@ -7145,6 +7153,9 @@ async function executeContributionStep10(state) { } async function executeStep10(state) { + if (state?.contributionModeExpected && !state?.contributionMode) { + throw new Error('步骤 10:当前自动流程预期使用贡献模式,但运行态 contributionMode 已丢失,已阻止回退到普通 CPA / SUB2API 提交。请重新进入贡献模式后再点击自动。'); + } if (state?.contributionMode) { return executeContributionStep10(state); } diff --git a/background/contribution-oauth.js b/background/contribution-oauth.js index 9eb2033..1a59f7f 100644 --- a/background/contribution-oauth.js +++ b/background/contribution-oauth.js @@ -9,6 +9,7 @@ const RUNTIME_DEFAULTS = { contributionMode: false, + contributionModeExpected: false, contributionSessionId: '', contributionAuthUrl: '', contributionAuthState: '', diff --git a/background/message-router.js b/background/message-router.js index fb93f4d..d478806 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -372,6 +372,9 @@ case 'AUTO_RUN': { clearStopRequest(); + if (Boolean(message.payload?.contributionMode) && typeof setContributionMode === 'function') { + await setContributionMode(true); + } const state = await getState(); if (getPendingAutoRunTimerPlan(state)) { throw new Error('已有自动运行倒计时计划,请先取消或立即开始。'); @@ -386,6 +389,9 @@ case 'SCHEDULE_AUTO_RUN': { clearStopRequest(); + if (Boolean(message.payload?.contributionMode) && typeof setContributionMode === 'function') { + await setContributionMode(true); + } const totalRuns = normalizeRunCount(message.payload?.totalRuns || 1); return await scheduleAutoRun(totalRuns, { delayMinutes: message.payload?.delayMinutes, diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index de5c20c..edc44bb 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -3423,6 +3423,7 @@ async function startAutoRunFromCurrentSettings() { totalRuns, delayMinutes, autoRunSkipFailures, + contributionMode: Boolean(latestState?.contributionMode), mode, }, }); diff --git a/tests/background-contribution-mode.test.js b/tests/background-contribution-mode.test.js index bc34e6c..85054eb 100644 --- a/tests/background-contribution-mode.test.js +++ b/tests/background-contribution-mode.test.js @@ -91,6 +91,7 @@ test('buildContributionModeState preserves active contribution runtime while for const DEFAULT_STATE = { panelMode: 'cpa' }; const CONTRIBUTION_RUNTIME_DEFAULTS = { contributionMode: false, + contributionModeExpected: false, contributionSessionId: '', contributionAuthUrl: '', contributionAuthState: '', @@ -121,6 +122,7 @@ return { buildContributionModeState }; }), { contributionMode: true, + contributionModeExpected: true, contributionSessionId: 'session-001', contributionAuthUrl: 'https://auth.example.com', contributionAuthState: '', @@ -150,6 +152,7 @@ return { buildContributionModeState }; }), { contributionMode: false, + contributionModeExpected: false, contributionSessionId: '', contributionAuthUrl: '', contributionAuthState: '', @@ -229,6 +232,50 @@ test('message router handles contribution mode, start flow, and status polling m ]); }); +test('message router re-syncs contribution mode before AUTO_RUN when sidepanel payload marks contributionMode=true', async () => { + const source = fs.readFileSync('background/message-router.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope); + + const calls = []; + const router = api.createMessageRouter({ + clearStopRequest: () => {}, + getPendingAutoRunTimerPlan: () => null, + getState: async () => ({ + contributionMode: false, + stepStatuses: {}, + }), + normalizeRunCount: (value) => Number(value) || 1, + setContributionMode: async (enabled) => { + calls.push({ type: 'toggle', enabled }); + return { contributionMode: true }; + }, + setState: async (updates) => { + calls.push({ type: 'setState', updates }); + }, + startAutoRunLoop: (totalRuns, options) => { + calls.push({ type: 'startAutoRunLoop', totalRuns, options }); + }, + }); + + const response = await router.handleMessage({ + type: 'AUTO_RUN', + payload: { + totalRuns: 2, + autoRunSkipFailures: true, + mode: 'restart', + contributionMode: true, + }, + }); + + assert.equal(response.ok, true); + assert.deepStrictEqual(calls, [ + { type: 'toggle', enabled: true }, + { type: 'setState', updates: { autoRunSkipFailures: true } }, + { type: 'startAutoRunLoop', totalRuns: 2, options: { autoRunSkipFailures: true, mode: 'restart' } }, + ]); +}); + test('account run history snapshot sync is disabled in contribution mode', () => { const source = fs.readFileSync('background/account-run-history.js', 'utf8'); const globalScope = {}; @@ -411,6 +458,7 @@ return { refreshOAuthUrlBeforeStep6 }; assert.equal(oauthUrl, 'https://auth.example.com/oauth?state=oauth-state-001'); assert.deepStrictEqual(calls, [ + { type: 'log', message: '步骤 7:contributionMode=true,正在通过公开贡献接口申请 OAuth 链接...' }, { type: 'log', message: '步骤 7:贡献模式正在申请贡献登录地址...' }, { type: 'contribution', @@ -439,3 +487,30 @@ return { refreshOAuthUrlBeforeStep6 }; delete globalThis.requestOAuthUrlFromPanel; delete globalThis.LOG_PREFIX; }); + +test('executeStep10 blocks silent fallback when contributionModeExpected=true but contributionMode=false', async () => { + const bundle = extractFunction(backgroundSource, 'executeStep10'); + + const api = new Function(` +${bundle} +return { executeStep10 }; +`)(); + + globalThis.executeContributionStep10 = async () => ({ ok: true }); + globalThis.step10Executor = { + async executeStep10() { + return { ok: true }; + }, + }; + + await assert.rejects( + () => api.executeStep10({ + contributionModeExpected: true, + contributionMode: false, + }), + /步骤 10:当前自动流程预期使用贡献模式/ + ); + + delete globalThis.executeContributionStep10; + delete globalThis.step10Executor; +}); From 0d0cf4b2af990754b3255fe0e037a780cb996631 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Mon, 20 Apr 2026 01:05:09 +0800 Subject: [PATCH 06/15] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=20OAuth=20?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E4=B8=AD=E7=9A=84=E6=97=A5=E5=BF=97=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=EF=BC=8C=E5=A2=9E=E5=BC=BA=E5=AF=86=E7=A0=81=E7=BC=BA?= =?UTF-8?q?=E5=A4=B1=E6=97=B6=E7=9A=84=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E6=B7=BB=E5=8A=A0=E7=9B=B8=E5=85=B3=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background.js | 8 +- content/signup-page.js | 12 +- tests/background-contribution-mode.test.js | 60 +++++++- tests/step6-passwordless-otp-login.test.js | 161 +++++++++++++++++++++ 4 files changed, 232 insertions(+), 9 deletions(-) create mode 100644 tests/step6-passwordless-otp-login.test.js diff --git a/background.js b/background.js index f48e539..76e20b9 100644 --- a/background.js +++ b/background.js @@ -6600,11 +6600,10 @@ async function runPreStep6CookieCleanup() { async function refreshOAuthUrlBeforeStep6(state) { if (state?.contributionModeExpected && !state?.contributionMode) { - throw new Error('步骤 7:当前自动流程预期使用贡献模式,但运行态 contributionMode 已丢失,已阻止回退到普通 CPA 面板。请重新进入贡献模式后再点击自动。'); + throw new Error('步骤 7:当前自动流程预期使用贡献模式,但运行态 contributionMode 已丢失,已阻止回退到普通 CPA / SUB2API 链路。请重新进入贡献模式后再点击自动。'); } if (state?.contributionMode && contributionOAuthManager?.startContributionFlow) { - await addLog('步骤 7:contributionMode=true,正在通过公开贡献接口申请 OAuth 链接...', 'info'); - await addLog('步骤 7:贡献模式正在申请贡献登录地址...'); + await addLog('步骤 7:contributionMode=true,走公开贡献接口,正在申请 OAuth 登录地址...', 'info'); const contributionState = await contributionOAuthManager.startContributionFlow({ nickname: state.email, openAuthTab: false, @@ -6617,8 +6616,7 @@ async function refreshOAuthUrlBeforeStep6(state) { await handleStepData(1, { oauthUrl }); return oauthUrl; } - await addLog(`步骤 7:正在刷新登录用的 ${getPanelModeLabel(state)} OAuth 链接...`); - await addLog(`步骤 7:contributionMode=${Boolean(state?.contributionMode)},当前将回退到 ${getPanelModeLabel(state)} 面板刷新 OAuth。`, 'warn'); + await addLog(`步骤 7:contributionMode=false,走普通 CPA / SUB2API 链路(当前面板:${getPanelModeLabel(state)}),正在刷新 OAuth 登录地址...`, 'info'); console.log(LOG_PREFIX, '[refreshOAuthUrlBeforeStep6] requesting fresh OAuth directly from panel'); const refreshResult = await requestOAuthUrlFromPanel(state, { logLabel: '步骤 7' }); await handleStepData(1, refreshResult); diff --git a/content/signup-page.js b/content/signup-page.js index de736d4..104138f 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -1913,10 +1913,18 @@ async function step6SwitchToOneTimeCodeLogin(snapshot) { async function step6LoginFromPasswordPage(payload, snapshot) { const currentSnapshot = normalizeStep6Snapshot(snapshot || inspectLoginAuthState()); + const hasPassword = Boolean(String(payload?.password || '').trim()); if (currentSnapshot.passwordInput) { - if (!payload.password) { - throw new Error('登录时缺少密码,步骤 7 无法继续。'); + if (!hasPassword) { + if (currentSnapshot.switchTrigger) { + log('步骤 7:当前未提供密码,改走一次性验证码登录。', 'warn'); + return step6SwitchToOneTimeCodeLogin(currentSnapshot); + } + + return createStep6RecoverableResult('missing_password_and_one_time_code_trigger', currentSnapshot, { + message: '登录时未提供密码,且当前页面没有可用的一次性验证码登录入口。', + }); } log('步骤 7:已进入密码页,准备填写密码...'); diff --git a/tests/background-contribution-mode.test.js b/tests/background-contribution-mode.test.js index 85054eb..aab3403 100644 --- a/tests/background-contribution-mode.test.js +++ b/tests/background-contribution-mode.test.js @@ -458,8 +458,7 @@ return { refreshOAuthUrlBeforeStep6 }; assert.equal(oauthUrl, 'https://auth.example.com/oauth?state=oauth-state-001'); assert.deepStrictEqual(calls, [ - { type: 'log', message: '步骤 7:contributionMode=true,正在通过公开贡献接口申请 OAuth 链接...' }, - { type: 'log', message: '步骤 7:贡献模式正在申请贡献登录地址...' }, + { type: 'log', message: '步骤 7:contributionMode=true,走公开贡献接口,正在申请 OAuth 登录地址...' }, { type: 'contribution', options: { @@ -488,6 +487,63 @@ return { refreshOAuthUrlBeforeStep6 }; delete globalThis.LOG_PREFIX; }); +test('refreshOAuthUrlBeforeStep6 logs the normal CPA/SUB2API path explicitly when contributionMode=false', async () => { + const bundle = extractFunction(backgroundSource, 'refreshOAuthUrlBeforeStep6'); + const calls = []; + + const api = new Function(` +${bundle} +return { refreshOAuthUrlBeforeStep6 }; +`)(); + + globalThis.addLog = async (message) => { + calls.push({ type: 'log', message }); + }; + globalThis.contributionOAuthManager = { + async startContributionFlow() { + calls.push({ type: 'contribution' }); + return { + contributionAuthUrl: 'https://auth.example.com/oauth?state=unexpected', + }; + }, + }; + globalThis.handleStepData = async (step, payload) => { + calls.push({ type: 'step', step, payload }); + }; + globalThis.getPanelModeLabel = () => 'SUB2API'; + globalThis.requestOAuthUrlFromPanel = async () => { + calls.push({ type: 'panel' }); + return { oauthUrl: 'https://panel.example.com/oauth' }; + }; + globalThis.LOG_PREFIX = '[test]'; + + const oauthUrl = await api.refreshOAuthUrlBeforeStep6({ + contributionMode: false, + panelMode: 'sub2api', + email: 'user@example.com', + }); + + assert.equal(oauthUrl, 'https://panel.example.com/oauth'); + assert.deepStrictEqual(calls, [ + { type: 'log', message: '步骤 7:contributionMode=false,走普通 CPA / SUB2API 链路(当前面板:SUB2API),正在刷新 OAuth 登录地址...' }, + { type: 'panel' }, + { + type: 'step', + step: 1, + payload: { + oauthUrl: 'https://panel.example.com/oauth', + }, + }, + ]); + + delete globalThis.addLog; + delete globalThis.contributionOAuthManager; + delete globalThis.handleStepData; + delete globalThis.getPanelModeLabel; + delete globalThis.requestOAuthUrlFromPanel; + delete globalThis.LOG_PREFIX; +}); + test('executeStep10 blocks silent fallback when contributionModeExpected=true but contributionMode=false', async () => { const bundle = extractFunction(backgroundSource, 'executeStep10'); diff --git a/tests/step6-passwordless-otp-login.test.js b/tests/step6-passwordless-otp-login.test.js new file mode 100644 index 0000000..ee19863 --- /dev/null +++ b/tests/step6-passwordless-otp-login.test.js @@ -0,0 +1,161 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('content/signup-page.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 i = start; i < source.length; i += 1) { + const ch = source[i]; + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (ch === '{' && signatureEnded) { + braceStart = i; + break; + } + } + if (braceStart < 0) { + throw new Error(`missing body for function ${name}`); + } + + 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); +} + +const bundle = extractFunction('step6LoginFromPasswordPage'); + +function createApi() { + return new Function(` +${bundle} +return { step6LoginFromPasswordPage }; +`)(); +} + +function cleanupGlobals() { + delete globalThis.normalizeStep6Snapshot; + delete globalThis.inspectLoginAuthState; + delete globalThis.log; + delete globalThis.step6SwitchToOneTimeCodeLogin; + delete globalThis.createStep6RecoverableResult; + delete globalThis.fillInput; + delete globalThis.humanPause; + delete globalThis.sleep; + delete globalThis.triggerLoginSubmitAction; + delete globalThis.waitForStep6PasswordSubmitTransition; +} + +test('step6LoginFromPasswordPage switches to one-time-code login when password is missing but switch trigger exists', async () => { + const api = createApi(); + const logs = []; + const snapshot = { + state: 'password_page', + passwordInput: { id: 'password' }, + switchTrigger: { id: 'otp' }, + }; + + globalThis.normalizeStep6Snapshot = (value) => value; + globalThis.inspectLoginAuthState = () => snapshot; + globalThis.log = (message, level = 'info') => { + logs.push({ message, level }); + }; + globalThis.step6SwitchToOneTimeCodeLogin = async (value) => { + assert.strictEqual(value, snapshot); + return { step6Outcome: 'success', via: 'switch_to_one_time_code_login' }; + }; + globalThis.createStep6RecoverableResult = () => { + throw new Error('should not create recoverable result when switch trigger exists'); + }; + globalThis.fillInput = () => { + throw new Error('should not fill password when password is missing'); + }; + globalThis.humanPause = async () => {}; + globalThis.sleep = async () => {}; + globalThis.triggerLoginSubmitAction = async () => {}; + globalThis.waitForStep6PasswordSubmitTransition = async () => { + throw new Error('should not submit password when password is missing'); + }; + + try { + const result = await api.step6LoginFromPasswordPage({ email: 'user@example.com', password: '' }, snapshot); + + assert.deepStrictEqual(result, { step6Outcome: 'success', via: 'switch_to_one_time_code_login' }); + assert.deepStrictEqual(logs, [ + { message: '步骤 7:当前未提供密码,改走一次性验证码登录。', level: 'warn' }, + ]); + } finally { + cleanupGlobals(); + } +}); + +test('step6LoginFromPasswordPage returns a recoverable result when password is missing and no one-time-code trigger exists', async () => { + const api = createApi(); + const snapshot = { + state: 'password_page', + passwordInput: { id: 'password' }, + switchTrigger: null, + }; + + globalThis.normalizeStep6Snapshot = (value) => value; + globalThis.inspectLoginAuthState = () => snapshot; + globalThis.log = () => {}; + globalThis.step6SwitchToOneTimeCodeLogin = async () => { + throw new Error('should not switch without a one-time-code trigger'); + }; + globalThis.createStep6RecoverableResult = (reason, stateSnapshot, details) => ({ + step6Outcome: 'recoverable', + reason, + stateSnapshot, + ...details, + }); + globalThis.fillInput = () => { + throw new Error('should not fill password when password is missing'); + }; + globalThis.humanPause = async () => {}; + globalThis.sleep = async () => {}; + globalThis.triggerLoginSubmitAction = async () => {}; + globalThis.waitForStep6PasswordSubmitTransition = async () => { + throw new Error('should not submit password when password is missing'); + }; + + try { + const result = await api.step6LoginFromPasswordPage({ email: 'user@example.com', password: '' }, snapshot); + + assert.deepStrictEqual(result, { + step6Outcome: 'recoverable', + reason: 'missing_password_and_one_time_code_trigger', + stateSnapshot: snapshot, + message: '登录时未提供密码,且当前页面没有可用的一次性验证码登录入口。', + }); + } finally { + cleanupGlobals(); + } +}); From c2c5e3e3346c83a6f3a22aaaa18ccc356ec4257d Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Mon, 20 Apr 2026 01:17:36 +0800 Subject: [PATCH 07/15] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E8=B4=A1?= =?UTF-8?q?=E7=8C=AE=E6=A8=A1=E5=BC=8F=E5=92=8C=20OAuth=20=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=E4=B8=AD=E7=9A=84=E7=8A=B6=E6=80=81=E6=B6=88=E6=81=AF?= =?UTF-8?q?=EF=BC=8C=E7=AE=80=E5=8C=96=E5=9B=9E=E8=B0=83=E5=A4=84=E7=90=86?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E5=A2=9E=E5=BC=BA=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background/contribution-oauth.js | 60 +++---------------- sidepanel/contribution-mode.js | 9 +-- tests/background-contribution-mode.test.js | 68 +++++++++++++++++----- tests/sidepanel-contribution-mode.test.js | 12 ++-- 4 files changed, 71 insertions(+), 78 deletions(-) diff --git a/background/contribution-oauth.js b/background/contribution-oauth.js index 1a59f7f..a734307 100644 --- a/background/contribution-oauth.js +++ b/background/contribution-oauth.js @@ -4,7 +4,7 @@ const API_BASE_URL = 'https://apikey.qzz.io/oauth/api'; const ACTIVE_STATUSES = new Set(['started', 'waiting', 'processing']); const FINAL_STATUSES = new Set(['auto_approved', 'auto_rejected', 'manual_review_required', 'expired', 'error']); - const CALLBACK_FINAL_STATUSES = new Set(['submitted', 'not_required']); + const CALLBACK_FINAL_STATUSES = new Set(['submitted']); const CALLBACK_WAITING_STATUSES = new Set(['idle', 'waiting', 'captured', 'failed', 'submitting']); const RUNTIME_DEFAULTS = { @@ -93,10 +93,6 @@ case 'success': case 'done': return 'submitted'; - case 'not_required': - case 'no_need': - case 'no_callback_required': - return 'not_required'; case 'failed': case 'error': return 'failed'; @@ -114,13 +110,13 @@ case 'started': return '已生成登录地址'; case 'waiting': - return '等待授权完成'; + return '等待提交回调'; case 'processing': - return '已授权,正在自动审核'; + return '已提交回调,等待 CPA 确认'; case 'auto_approved': - return '贡献成功,已自动导入'; + return '贡献成功,CPA 已确认'; case 'auto_rejected': - return '贡献未通过自动审核'; + return '贡献未通过确认'; case 'manual_review_required': return '已提交,等待人工处理'; case 'expired': @@ -143,8 +139,6 @@ return '正在提交回调'; case 'submitted': return '已提交回调'; - case 'not_required': - return '当前流程无需手动回调'; case 'failed': return '回调提交失败'; default: @@ -294,22 +288,6 @@ return `${label}:${details}`; } - function looksLikeNoNeedCallback(payload = {}) { - const combined = [ - payload.message, - payload.detail, - payload.reason, - payload.error, - ] - .map((item) => normalizeString(item).toLowerCase()) - .filter(Boolean) - .join(' '); - - return Boolean(payload.callback_required === false - || payload.callbackRequired === false - || /无需手动|鏃犻渶鎵嬪姩|not required|already enabled/i.test(combined)); - } - function deriveCallbackState(payload = {}, state = {}) { const existingStatus = normalizeContributionCallbackStatus(state.contributionCallbackStatus); const callbackUrl = normalizeString( @@ -338,14 +316,6 @@ }; } - if (looksLikeNoNeedCallback(payload)) { - return { - status: 'not_required', - message: buildCallbackMessage('not_required', payload), - callbackUrl, - }; - } - if (callbackUrl) { return { status: CALLBACK_FINAL_STATUSES.has(existingStatus) ? existingStatus : 'captured', @@ -387,8 +357,10 @@ } const code = normalizeString(parsed.searchParams.get('code')); + const errorText = normalizeString(parsed.searchParams.get('error')) + || normalizeString(parsed.searchParams.get('error_description')); const authState = normalizeString(parsed.searchParams.get('state')); - if (!code || !authState) { + if ((!code && !errorText) || !authState) { return false; } @@ -472,7 +444,7 @@ }, }); - const nextStatus = looksLikeNoNeedCallback(payload) ? 'not_required' : 'submitted'; + const nextStatus = 'submitted'; await applyRuntimeUpdates({ contributionCallbackUrl: normalizedUrl, contributionCallbackStatus: nextStatus, @@ -485,20 +457,6 @@ return await pollContributionStatus({ reason: options.reason || 'submit_callback' }); } catch (error) { - if (looksLikeNoNeedCallback(error?.payload || {})) { - await applyRuntimeUpdates({ - contributionCallbackUrl: normalizedUrl, - contributionCallbackStatus: 'not_required', - contributionCallbackMessage: buildCallbackMessage('not_required', error.payload || {}), - }); - - if (typeof closeLocalhostCallbackTabs === 'function') { - await closeLocalhostCallbackTabs(normalizedUrl).catch(() => {}); - } - - return pollContributionStatus({ reason: options.reason || 'submit_callback_not_required' }).catch(() => getState()); - } - await applyRuntimeUpdates({ contributionCallbackUrl: normalizedUrl, contributionCallbackStatus: 'failed', diff --git a/sidepanel/contribution-mode.js b/sidepanel/contribution-mode.js index abe59e2..3a7d7af 100644 --- a/sidepanel/contribution-mode.js +++ b/sidepanel/contribution-mode.js @@ -1,7 +1,7 @@ (function attachSidepanelContributionMode(globalScope) { const ACTIVE_STATUSES = new Set(['started', 'waiting', 'processing']); const FINAL_STATUSES = new Set(['auto_approved', 'auto_rejected', 'manual_review_required', 'expired', 'error']); - const DEFAULT_COPY = '当前账号将用于支持项目维护。扩展会自动申请贡献登录地址并持续跟踪授权状态;如检测到回调地址,会自动提交,无需手动复制。'; + const DEFAULT_COPY = '当前账号将用于支持项目维护。扩展会自动申请贡献登录地址并持续跟踪授权状态;如检测到回调地址,会自动提交,并继续等待 CPA 最终确认。'; function createContributionModeManager(context = {}) { const { @@ -56,7 +56,6 @@ case 'captured': case 'submitting': case 'submitted': - case 'not_required': case 'failed': case 'idle': return normalized; @@ -137,10 +136,10 @@ return '未生成登录地址'; } if (status === 'waiting') { - return '等待授权完成'; + return '等待提交回调'; } if (status === 'processing' || status === 'auto_approved' || status === 'auto_rejected' || status === 'manual_review_required') { - return '授权已完成'; + return status === 'processing' ? '已提交回调' : '授权已结束'; } if (status === 'expired' || status === 'error') { return '授权失败'; @@ -160,8 +159,6 @@ return '正在提交回调'; case 'submitted': return '已提交回调'; - case 'not_required': - return '当前流程无需手动回调'; case 'failed': return '回调提交失败'; case 'waiting': diff --git a/tests/background-contribution-mode.test.js b/tests/background-contribution-mode.test.js index aab3403..ef736fe 100644 --- a/tests/background-contribution-mode.test.js +++ b/tests/background-contribution-mode.test.js @@ -316,12 +316,13 @@ test('account run history snapshot sync is disabled in contribution mode', () => ); }); -test('contribution oauth manager starts session, opens auth url, polls real statuses, and treats callback submit no-op as success', async () => { +test('contribution oauth manager starts session, opens auth url, submits callback, and continues polling final status', async () => { const source = fs.readFileSync('background/contribution-oauth.js', 'utf8'); const globalScope = {}; const fetchCalls = []; const tabCalls = []; const closeCallbackCalls = []; + let statusPollCount = 0; let currentState = { contributionMode: true, email: 'user@example.com', @@ -345,26 +346,31 @@ test('contribution oauth manager starts session, opens auth url, polls real stat }); } if (String(url).includes('/status?')) { + statusPollCount += 1; + if (statusPollCount === 1) { + return createMockResponse(true, 200, { + ok: true, + session_id: 'session-001', + status: 'waiting', + message: '等待提交回调。', + }); + } return createMockResponse(true, 200, { ok: true, session_id: 'session-001', - status: 'waiting', - message: '等待 OAuth 回调完成', + status: 'processing', + message: '回调地址已提交给 CPA,正在等待结果确认。', }); } if (String(url).endsWith('/submit-callback')) { - return createMockResponse(false, 400, { - ok: false, - message: '当前已启用自动回调转发,无需手动粘贴回调 URL。', - callback_url_received: true, + return createMockResponse(true, 200, { + ok: true, + session_id: 'session-001', + status: 'processing', + message: '回调地址已提交给 CPA,正在等待结果确认。', }); } - return createMockResponse(true, 200, { - ok: true, - session_id: 'session-001', - status: 'processing', - message: '授权已完成,正在自动审核并导入。', - }); + return createMockResponse(true, 200, { ok: true }); } ); @@ -414,11 +420,43 @@ test('contribution oauth manager starts session, opens auth url, polls real stat ); assert.equal(callbackState.contributionCallbackUrl, 'http://localhost:1455/auth/callback?code=abc123&state=oauth-state-001'); - assert.equal(callbackState.contributionCallbackStatus, 'not_required'); + assert.equal(callbackState.contributionCallbackStatus, 'submitted'); + assert.equal(callbackState.contributionStatus, 'processing'); assert.equal(closeCallbackCalls[0], 'http://localhost:1455/auth/callback?code=abc123&state=oauth-state-001'); assert.ok(fetchCalls.some((call) => String(call.url).endsWith('/submit-callback'))); assert.ok(broadcasts.some((item) => item.contributionCallbackStatus === 'captured')); - assert.ok(broadcasts.some((item) => item.contributionCallbackStatus === 'not_required')); + assert.ok(broadcasts.some((item) => item.contributionCallbackStatus === 'submitted')); +}); + +test('contribution oauth manager accepts localhost callback urls that contain error and state', async () => { + const source = fs.readFileSync('background/contribution-oauth.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', 'fetch', `${source}; return self.MultiPageBackgroundContributionOAuth;`)( + globalScope, + async () => createMockResponse(true, 200, { ok: true }) + ); + + const manager = api.createContributionOAuthManager({ + chrome: { + tabs: { + onUpdated: { addListener() {} }, + }, + webNavigation: { + onCommitted: { addListener() {} }, + onHistoryStateUpdated: { addListener() {} }, + }, + }, + getState: async () => ({}), + setState: async () => ({}), + }); + + assert.equal( + manager.isContributionCallbackUrl( + 'http://localhost:1455/auth/callback?error=access_denied&state=oauth-state-001', + { contributionAuthState: 'oauth-state-001' } + ), + true + ); }); test('refreshOAuthUrlBeforeStep6 uses contribution oauth session instead of panel bridge in contribution mode', async () => { diff --git a/tests/sidepanel-contribution-mode.test.js b/tests/sidepanel-contribution-mode.test.js index 13df435..343e0df 100644 --- a/tests/sidepanel-contribution-mode.test.js +++ b/tests/sidepanel-contribution-mode.test.js @@ -359,9 +359,9 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri state: { ...latestState, contributionStatus: 'processing', - contributionStatusMessage: '已授权,正在自动审核', - contributionCallbackStatus: 'not_required', - contributionCallbackMessage: '当前流程无需手动回调', + contributionStatusMessage: '已提交回调,等待 CPA 确认', + contributionCallbackStatus: 'submitted', + contributionCallbackMessage: '已提交回调', }, }; } @@ -406,9 +406,9 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri await manager.pollOnce({ reason: 'test_poll' }); assert.equal(statusState.contributionStatus, 'processing'); - assert.equal(dom.contributionOauthStatus.textContent, '\u6388\u6743\u5df2\u5b8c\u6210'); - assert.equal(dom.contributionCallbackStatus.textContent, '\u5f53\u524d\u6d41\u7a0b\u65e0\u9700\u624b\u52a8\u56de\u8c03'); - assert.equal(dom.contributionModeSummary.textContent, '\u5df2\u6388\u6743\uff0c\u6b63\u5728\u81ea\u52a8\u5ba1\u6838'); + assert.equal(dom.contributionOauthStatus.textContent, '\u5df2\u63d0\u4ea4\u56de\u8c03'); + assert.equal(dom.contributionCallbackStatus.textContent, '\u5df2\u63d0\u4ea4\u56de\u8c03'); + assert.equal(dom.contributionModeSummary.textContent, '\u5df2\u63d0\u4ea4\u56de\u8c03\uff0c\u7b49\u5f85 CPA \u786e\u8ba4'); dom.btnOpenContributionUpload.listeners.click(); assert.deepStrictEqual(openedUrls, ['https://apikey.qzz.io/']); From 95750056ad4fe859ff21e14aa2a6ef3a597781f2 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Mon, 20 Apr 2026 01:18:59 +0800 Subject: [PATCH 08/15] =?UTF-8?q?feat:=20=E7=A7=BB=E9=99=A4=E8=B4=A1?= =?UTF-8?q?=E7=8C=AE=E6=A8=A1=E5=BC=8F=E7=A1=AE=E8=AE=A4=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=EF=BC=8C=E7=AE=80=E5=8C=96=E8=BF=9B=E5=85=A5=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=EF=BC=8C=E6=9B=B4=E6=96=B0=E7=9B=B8=E5=85=B3=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sidepanel/contribution-mode.js | 11 -- sidepanel/sidepanel.html | 4 +- sidepanel/sidepanel.js | 18 -- tests/sidepanel-contribution-button.test.js | 195 +------------------- tests/sidepanel-contribution-mode.test.js | 4 +- 项目完整链路说明.md | 27 ++- 6 files changed, 26 insertions(+), 233 deletions(-) diff --git a/sidepanel/contribution-mode.js b/sidepanel/contribution-mode.js index abe59e2..d0b9b35 100644 --- a/sidepanel/contribution-mode.js +++ b/sidepanel/contribution-mode.js @@ -247,17 +247,6 @@ } async function enterContributionMode() { - const confirmed = await helpers.openConfirmModal?.({ - title: '贡献账号', - message: '是否确认给作者 QLHazyCoder 提供账号以支持继续维护项目?', - confirmLabel: '确定', - confirmVariant: 'btn-primary', - }); - - if (!confirmed) { - return; - } - await requestContributionMode(true); helpers.showToast?.('已进入贡献模式。', 'success', 1800); } diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 36fc09d..f851304 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -35,7 +35,7 @@
+ aria-pressed="false" title="进入贡献模式">贡献 -

当前账号将用于支持项目维护。扩展会自动申请贡献登录地址并持续跟踪授权状态;如检测到回调地址,会自动提交,无需手动复制。

+
+ 贡献昵称 + +
+
+ QQ + +
OAUTH diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 351b7f9..bd31724 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -35,6 +35,8 @@ const btnOpenRelease = document.getElementById('btn-open-release'); const settingsCard = document.getElementById('settings-card'); const contributionModePanel = document.getElementById('contribution-mode-panel'); const contributionModeText = document.getElementById('contribution-mode-text'); +const inputContributionNickname = document.getElementById('input-contribution-nickname'); +const inputContributionQq = document.getElementById('input-contribution-qq'); const contributionOauthStatus = document.getElementById('contribution-oauth-status'); const contributionCallbackStatus = document.getElementById('contribution-callback-status'); const contributionModeSummary = document.getElementById('contribution-mode-summary'); @@ -1763,6 +1765,12 @@ function applySettingsState(state) { if (inputAccountRunHistoryHelperBaseUrl) { inputAccountRunHistoryHelperBaseUrl.value = normalizeAccountRunHistoryHelperBaseUrlValue(state?.accountRunHistoryHelperBaseUrl); } + if (inputContributionNickname) { + inputContributionNickname.value = state?.contributionNickname || ''; + } + if (inputContributionQq) { + inputContributionQq.value = state?.contributionQq || ''; + } setManagedAliasBaseEmailInputForProvider(restoredMailProvider, state); inputInbucketHost.value = state?.inbucketHost || ''; inputInbucketMailbox.value = state?.inbucketMailbox || ''; @@ -3041,6 +3049,8 @@ const contributionModeManager = window.SidepanelContributionMode?.createContribu dom: { btnConfigMenu, btnContributionMode, + inputContributionNickname, + inputContributionQq, contributionCallbackStatus, btnExitContributionMode, btnOpenAccountRecords, @@ -3068,6 +3078,10 @@ const contributionModeManager = window.SidepanelContributionMode?.createContribu closeAccountRecordsPanel, closeConfigMenu, getContributionNickname: () => latestState?.email || '', + getContributionProfile: () => ({ + nickname: String(inputContributionNickname?.value || '').trim(), + qq: String(inputContributionQq?.value || '').trim(), + }), isModeSwitchBlocked: isContributionModeSwitchBlocked, openConfirmModal, openExternalUrl, @@ -3446,6 +3460,8 @@ async function startAutoRunFromCurrentSettings() { const totalRuns = getRunCountValue(); let mode = 'restart'; const autoRunSkipFailures = inputAutoSkipFailures.checked; + const contributionNickname = String(inputContributionNickname?.value || '').trim(); + const contributionQq = String(inputContributionQq?.value || '').trim(); const fallbackThreadIntervalMinutes = normalizeAutoRunThreadIntervalMinutes( inputAutoSkipFailuresThreadIntervalMinutes.value ); @@ -3488,6 +3504,8 @@ async function startAutoRunFromCurrentSettings() { delayMinutes, autoRunSkipFailures, contributionMode: Boolean(latestState?.contributionMode), + contributionNickname, + contributionQq, mode, }, }); diff --git a/tests/background-contribution-mode.test.js b/tests/background-contribution-mode.test.js index ef736fe..e30c482 100644 --- a/tests/background-contribution-mode.test.js +++ b/tests/background-contribution-mode.test.js @@ -92,6 +92,8 @@ const DEFAULT_STATE = { panelMode: 'cpa' }; const CONTRIBUTION_RUNTIME_DEFAULTS = { contributionMode: false, contributionModeExpected: false, + contributionNickname: '', + contributionQq: '', contributionSessionId: '', contributionAuthUrl: '', contributionAuthState: '', @@ -123,6 +125,8 @@ return { buildContributionModeState }; { contributionMode: true, contributionModeExpected: true, + contributionNickname: '', + contributionQq: '', contributionSessionId: 'session-001', contributionAuthUrl: 'https://auth.example.com', contributionAuthState: '', @@ -153,6 +157,8 @@ return { buildContributionModeState }; { contributionMode: false, contributionModeExpected: false, + contributionNickname: '', + contributionQq: '', contributionSessionId: '', contributionAuthUrl: '', contributionAuthState: '', @@ -215,7 +221,7 @@ test('message router handles contribution mode, start flow, and status polling m }); const startResponse = await router.handleMessage({ type: 'START_CONTRIBUTION_FLOW', - payload: { nickname: '阿青' }, + payload: { nickname: '阿青', qq: '123456' }, }); const pollResponse = await router.handleMessage({ type: 'POLL_CONTRIBUTION_STATUS', @@ -227,7 +233,7 @@ test('message router handles contribution mode, start flow, and status polling m assert.equal(pollResponse.ok, true); assert.deepStrictEqual(calls, [ { type: 'toggle', enabled: true }, - { type: 'start', options: { nickname: '阿青' } }, + { type: 'start', options: { nickname: '阿青', qq: '123456' } }, { type: 'poll', options: { reason: 'test_poll' } }, ]); }); @@ -260,17 +266,20 @@ test('message router re-syncs contribution mode before AUTO_RUN when sidepanel p const response = await router.handleMessage({ type: 'AUTO_RUN', - payload: { - totalRuns: 2, - autoRunSkipFailures: true, - mode: 'restart', - contributionMode: true, - }, - }); + payload: { + totalRuns: 2, + autoRunSkipFailures: true, + mode: 'restart', + contributionMode: true, + contributionNickname: '阿青', + contributionQq: '123456', + }, + }); assert.equal(response.ok, true); assert.deepStrictEqual(calls, [ { type: 'toggle', enabled: true }, + { type: 'setState', updates: { contributionNickname: '阿青', contributionQq: '123456' } }, { type: 'setState', updates: { autoRunSkipFailures: true } }, { type: 'startAutoRunLoop', totalRuns: 2, options: { autoRunSkipFailures: true, mode: 'restart' } }, ]); @@ -412,6 +421,8 @@ test('contribution oauth manager starts session, opens auth url, submits callbac assert.equal(startedState.contributionAuthTabId, 88); assert.equal(tabCalls.length, 1); assert.match(fetchCalls[0].url, /\/start$/); + assert.match(String(fetchCalls[0].options.body || ''), /"nickname":"user@example\.com"/); + assert.match(String(fetchCalls[0].options.body || ''), /"qq":""/); assert.match(fetchCalls[1].url, /\/status\?/); const callbackState = await manager.handleCapturedCallback( diff --git a/tests/sidepanel-contribution-mode.test.js b/tests/sidepanel-contribution-mode.test.js index 98aa4ff..d41a214 100644 --- a/tests/sidepanel-contribution-mode.test.js +++ b/tests/sidepanel-contribution-mode.test.js @@ -111,6 +111,8 @@ test('sidepanel html contains contribution mode runtime UI and loads the module assert.match(html, /id="btn-start-contribution"/); assert.match(html, /id="btn-open-contribution-upload"/); assert.match(html, /id="btn-exit-contribution-mode"/); + assert.match(html, /id="input-contribution-nickname"/); + assert.match(html, /id="input-contribution-qq"/); assert.notEqual(moduleIndex, -1); assert.notEqual(sidepanelIndex, -1); assert.ok(moduleIndex < sidepanelIndex); @@ -246,6 +248,8 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri btnOpenAccountRecords: createElement(), btnOpenContributionUpload: createElement(), btnStartContribution: createElement(), + inputContributionNickname: createElement({ value: '贡献者昵称' }), + inputContributionQq: createElement({ value: '123456' }), contributionCallbackStatus: createElement(), contributionModePanel: createElement({ hidden: true }), contributionModeSummary: createElement(), @@ -284,6 +288,12 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri getContributionNickname() { return latestState.email; }, + getContributionProfile() { + return { + nickname: dom.inputContributionNickname.value, + qq: dom.inputContributionQq.value, + }; + }, isModeSwitchBlocked() { return blocked; }, @@ -332,6 +342,8 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri ? { contributionMode: true, panelMode: 'cpa', + contributionNickname: '', + contributionQq: '', contributionSessionId: '', contributionAuthUrl: '', contributionAuthState: '', @@ -344,6 +356,8 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri : { contributionMode: false, panelMode: 'cpa', + contributionNickname: '', + contributionQq: '', contributionSessionId: '', contributionAuthUrl: '', contributionAuthState: '', @@ -367,6 +381,14 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri }, }; } + if (message.type === 'SET_CONTRIBUTION_PROFILE') { + latestState = { + ...latestState, + contributionNickname: message.payload.nickname, + contributionQq: message.payload.qq, + }; + return { state: latestState }; + } return {}; }, }, @@ -399,11 +421,16 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri assert.ok(updateConfigMenuCount >= 1); assert.equal(timers.length, 0); + dom.inputContributionNickname.value = '贡献者昵称'; + dom.inputContributionQq.value = '123456'; + await dom.btnStartContribution.listeners.click(); assert.equal(contributionAutoRunStartCount, 1); assert.equal(appliedState.contributionSessionId, ''); assert.equal(latestState.contributionSessionId, 'session-002'); assert.equal(latestState.contributionStatus, 'started'); + const contributionProfileMessage = sentMessages.find((message) => message.type === 'SET_CONTRIBUTION_PROFILE'); + assert.deepStrictEqual(contributionProfileMessage?.payload, { nickname: '贡献者昵称', qq: '123456' }); assert.equal(timers.length > 0, true); await manager.pollOnce({ reason: 'test_poll' }); @@ -423,7 +450,7 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri assert.equal(dom.rowVpsUrl.classList.contains('is-contribution-hidden'), false); assert.deepStrictEqual( sentMessages.map((message) => message.type), - ['SET_CONTRIBUTION_MODE', 'POLL_CONTRIBUTION_STATUS', 'SET_CONTRIBUTION_MODE'] + ['SET_CONTRIBUTION_MODE', 'SET_CONTRIBUTION_PROFILE', 'POLL_CONTRIBUTION_STATUS', 'SET_CONTRIBUTION_MODE'] ); assert.deepStrictEqual( toasts.map((item) => item.message), @@ -434,6 +461,8 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri latestState = { contributionMode: true, panelMode: 'cpa', + contributionNickname: '贡献者昵称', + contributionQq: '123456', contributionSessionId: 'session-002', contributionAuthUrl: 'https://auth.example.com/oauth?state=oauth-state-002', contributionStatus: 'waiting', From ffecba6ab3026f82f35ea6f6f6227caa53d81f6c Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Mon, 20 Apr 2026 13:15:39 +0800 Subject: [PATCH 12/15] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B02925=E9=82=AE?= =?UTF-8?q?=E7=AE=B1=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E9=AA=8C=E8=AF=81=E7=A0=81=E9=87=8D=E5=8F=91=E6=9C=BA?= =?UTF-8?q?=E5=88=B6=EF=BC=8C=E8=B0=83=E6=95=B4=E6=B5=8B=E8=AF=95=E7=94=A8?= =?UTF-8?q?=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background/steps/fetch-login-code.js | 5 +- background/steps/fetch-signup-code.js | 5 +- background/verification-flow.js | 8 +-- content/mail-2925.js | 36 ++----------- tests/mail-2925-content.test.js | 22 ++++++-- tests/verification-flow-polling.test.js | 67 +++++++++++++++++++++++++ 项目完整链路说明.md | 9 ++-- 项目文件结构说明.md | 4 +- 8 files changed, 108 insertions(+), 48 deletions(-) diff --git a/background/steps/fetch-login-code.js b/background/steps/fetch-login-code.js index d147bb3..fb7c78c 100644 --- a/background/steps/fetch-login-code.js +++ b/background/steps/fetch-login-code.js @@ -60,6 +60,7 @@ if (mail.error) throw new Error(mail.error); const stepStartedAt = Date.now(); + const verificationSessionKey = `8:${stepStartedAt}`; const authTabId = await getTabId('signup-page'); if (authTabId) { @@ -130,7 +131,9 @@ ...state, step8VerificationTargetEmail: displayedVerificationEmail || '', }, mail, { - filterAfterTimestamp: stepStartedAt, + filterAfterTimestamp: mail.provider === '2925' ? 0 : stepStartedAt, + sessionKey: verificationSessionKey, + disableTimeBudgetCap: mail.provider === '2925', getRemainingTimeMs: getStep8RemainingTimeResolver(state?.oauthUrl || ''), requestFreshCodeFirst: false, targetEmail: fixedTargetEmail, diff --git a/background/steps/fetch-signup-code.js b/background/steps/fetch-signup-code.js index e7fe682..a729946 100644 --- a/background/steps/fetch-signup-code.js +++ b/background/steps/fetch-signup-code.js @@ -25,6 +25,7 @@ const mail = getMailConfig(state); if (mail.error) throw new Error(mail.error); const stepStartedAt = Date.now(); + const verificationSessionKey = `4:${stepStartedAt}`; const signupTabId = await getTabId('signup-page'); if (!signupTabId) { throw new Error('认证页面标签页已关闭,无法继续步骤 4。'); @@ -91,7 +92,9 @@ } await resolveVerificationStep(4, state, mail, { - filterAfterTimestamp: stepStartedAt, + filterAfterTimestamp: mail.provider === '2925' ? 0 : stepStartedAt, + sessionKey: verificationSessionKey, + disableTimeBudgetCap: mail.provider === '2925', requestFreshCodeFirst: mail.provider === HOTMAIL_PROVIDER ? false : true, resendIntervalMs: (mail.provider === HOTMAIL_PROVIDER || mail.provider === '2925') ? 0 diff --git a/background/verification-flow.js b/background/verification-flow.js index 76e18b2..d751359 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -164,9 +164,10 @@ const nextPayload = { ...payload }; const intervalMs = Math.max(1, Number(nextPayload.intervalMs) || 3000); const baseMaxAttempts = Math.max(1, Number(nextPayload.maxAttempts) || 1); + const disableTimeBudgetCap = Boolean(options.disableTimeBudgetCap); const remainingMs = await getRemainingTimeBudgetMs(step, options, actionLabel); - if (remainingMs !== null) { + if (!disableTimeBudgetCap && remainingMs !== null) { nextPayload.maxAttempts = Math.max( 1, Math.min(baseMaxAttempts, Math.floor(Math.max(0, remainingMs - 1000) / intervalMs) + 1) @@ -174,7 +175,7 @@ } const defaultResponseTimeoutMs = Math.max(45000, nextPayload.maxAttempts * intervalMs + 25000); - const responseTimeoutMs = remainingMs === null + const responseTimeoutMs = disableTimeBudgetCap || remainingMs === null ? defaultResponseTimeoutMs : Math.max(1000, Math.min(defaultResponseTimeoutMs, remainingMs)); @@ -564,7 +565,7 @@ getLegacyVerificationResendCountDefault(step, { requestFreshCodeFirst }) ) : getConfiguredVerificationResendCount(step, state, { requestFreshCodeFirst }); - const maxSubmitAttempts = 7; + const maxSubmitAttempts = 15; const resendIntervalMs = Math.max(0, Number(options.resendIntervalMs) || 0); let lastResendAt = Number(options.lastResendAt) || 0; @@ -609,6 +610,7 @@ for (let attempt = 1; attempt <= maxSubmitAttempts; attempt++) { const pollOptions = { excludeCodes: [...rejectedCodes], + disableTimeBudgetCap: Boolean(options.disableTimeBudgetCap), getRemainingTimeMs: options.getRemainingTimeMs, maxResendRequests: remainingAutomaticResendCount, resendIntervalMs, diff --git a/content/mail-2925.js b/content/mail-2925.js index 404477c..6b7f33f 100644 --- a/content/mail-2925.js +++ b/content/mail-2925.js @@ -53,6 +53,10 @@ async function persistSeenCodes() { } function buildSeenCodeSessionKey(step, payload = {}) { + const explicitSessionKey = String(payload?.sessionKey || '').trim(); + if (explicitSessionKey) { + return explicitSessionKey; + } const timestamp = Number(payload?.filterAfterTimestamp); if (Number.isFinite(timestamp) && timestamp > 0) { return `${step}:${timestamp}`; @@ -551,11 +555,7 @@ async function handlePollEmail(step, payload) { throw new Error('2925 邮箱列表未加载完成,请确认当前已打开收件箱。'); } - const knownMailIds = getCurrentMailIds(initialItems); log(`步骤 ${step}:邮件列表已加载,共 ${initialItems.length} 封邮件`); - log(`步骤 ${step}:已记录当前 ${knownMailIds.size} 封旧邮件快照`); - - const FALLBACK_AFTER = 3; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { log(`步骤 ${step}:正在轮询 2925 邮箱,第 ${attempt}/${maxAttempts} 次`); @@ -568,26 +568,10 @@ async function handlePollEmail(step, payload) { const items = findMailItems(); if (items.length > 0) { - const useFallback = attempt > FALLBACK_AFTER; - const newMailIds = new Set(); - - items.forEach((item, index) => { - const itemId = getMailItemId(item, index); - if (!knownMailIds.has(itemId)) { - newMailIds.add(itemId); - } - }); - for (let index = 0; index < items.length; index += 1) { const item = items[index]; - const itemId = getMailItemId(item, index); - const isNewMail = newMailIds.has(itemId); const itemTimestamp = parseMailItemTimestamp(item); - if (!useFallback && !isNewMail) { - continue; - } - const previewText = getMailItemText(item); if (!matchesMailFilters(previewText, senderFilters, subjectFilters)) { continue; @@ -613,21 +597,11 @@ async function handlePollEmail(step, payload) { seenCodes.add(candidateCode); persistSeenCodes(); - const source = useFallback && !isNewMail - ? (bodyCode ? '回退匹配邮件正文' : '回退匹配邮件') - : (bodyCode ? '新邮件正文' : '新邮件'); + const source = bodyCode ? '邮件正文' : '邮件预览'; const timeLabel = itemTimestamp ? `,时间:${new Date(itemTimestamp).toLocaleString('zh-CN', { hour12: false })}` : ''; log(`步骤 ${step}:已找到验证码:${candidateCode}(来源:${source}${timeLabel})`, 'ok'); return { ok: true, code: candidateCode, emailTimestamp: Date.now() }; } - - items.forEach((item, index) => { - knownMailIds.add(getMailItemId(item, index)); - }); - } - - if (attempt === FALLBACK_AFTER + 1) { - log(`步骤 ${step}:连续 ${FALLBACK_AFTER} 次未发现新邮件,开始回退到首封匹配邮件`, 'warn'); } if (attempt < maxAttempts) { diff --git a/tests/mail-2925-content.test.js b/tests/mail-2925-content.test.js index 30df390..0b2f037 100644 --- a/tests/mail-2925-content.test.js +++ b/tests/mail-2925-content.test.js @@ -60,13 +60,22 @@ let refreshCalls = 0; const clickOrder = []; const readAndDeleteCalls = []; const seenCodes = new Set(); +const deletedMailIds = new Set(); const baselineMail = { id: 'baseline', text: 'OpenAI newsletter without code' }; const newMail = { id: 'new', text: 'OpenAI verification code 654321' }; function findMailItems() { - if (state === 'detail') return []; - if (state === 'baseline') return [baselineMail]; - return [baselineMail, newMail]; + const items = []; + if (state === 'detail') return items; + if (state === 'baseline' || state === 'with-new') { + if (!deletedMailIds.has('baseline')) { + items.push(baselineMail); + } + } + if (state === 'with-new' && !deletedMailIds.has('new')) { + items.push(newMail); + } + return items; } function getMailItemId(item) { @@ -99,7 +108,9 @@ async function sleepRandom() {} async function returnToInbox() { clickOrder.push('inbox'); - state = 'baseline'; + if (state === 'detail') { + state = 'baseline'; + } return true; } @@ -113,6 +124,7 @@ async function refreshInbox() { async function openMailAndDeleteAfterRead(item) { readAndDeleteCalls.push(item.id); + deletedMailIds.add(item.id); return item.id === 'new' ? 'Your ChatGPT code is 654321' : 'No code here'; } @@ -142,7 +154,7 @@ return { assert.equal(result.code, '654321'); assert.deepEqual(api.getClickOrder(), ['inbox', 'refresh', 'inbox', 'refresh']); - assert.deepEqual(api.getReadAndDeleteCalls(), ['new']); + assert.deepEqual(api.getReadAndDeleteCalls(), ['baseline', 'new']); }); test('handlePollEmail ignores targetEmail and still tests any matching ChatGPT mail', async () => { diff --git a/tests/verification-flow-polling.test.js b/tests/verification-flow-polling.test.js index 60ee929..5cb5b40 100644 --- a/tests/verification-flow-polling.test.js +++ b/tests/verification-flow-polling.test.js @@ -301,6 +301,73 @@ test('verification flow caps mail polling timeout to the remaining oauth budget' assert.equal(mailPollCalls[0].payload.maxAttempts, 2); }); +test('verification flow keeps 2925 mailbox polling at 15 refresh attempts even when oauth budget is smaller', 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({ + type: message.type, + 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', + mailProvider: '2925', + lastLoginCode: null, + }, + { provider: '2925', label: '2925 邮箱' }, + { + getRemainingTimeMs: async () => 5000, + resendIntervalMs: 0, + disableTimeBudgetCap: true, + } + ); + + const pollCall = mailPollCalls.find((entry) => entry.type === 'POLL_EMAIL'); + assert.ok(pollCall); + assert.equal(pollCall.payload.maxAttempts, 15); + assert.ok(pollCall.options.timeoutMs >= 250000); +}); + test('verification flow keeps Hotmail request timestamp filtering on the first poll', async () => { const pollPayloads = []; diff --git a/项目完整链路说明.md b/项目完整链路说明.md index 86b6c13..c99d8a2 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -304,12 +304,11 @@ 补充行为: -- `2925` provider 会拉长单轮轮询窗口,并关闭 Step 4 / 8 的自动重发间隔,减少因邮件延迟过早判负。 -- 普通邮箱仍会携带 `filterAfterTimestamp` 做时间窗筛选;`2925` 不再依赖时间窗,而是以“当前收件箱快照 + 刷新后新增邮件”作为优先匹配依据。 +- `2925` provider 会关闭 Step 4 / 8 的自动重发间隔 25 秒节流;每次“重新发送验证码”之间,会在邮箱页内部执行一轮固定 15 次的刷新轮询,不再因 OAuth 剩余时间预算而缩短。 +- 普通邮箱仍会携带 `filterAfterTimestamp` 做时间窗筛选;`2925` 当前既不依赖时间窗,也不再做“新旧邮件快照差集”比较,而是每次刷新后直接遍历当前列表中的匹配邮件。 - 自动重新发送验证码次数现在使用 sidepanel 里的单一“验证码重发”配置;普通邮箱仍按 25 秒间隔节流,Hotmail / 2925 不走这个 25 秒间隔。Step 4 若启用先请求新验证码,会先消耗一次当前步骤的自动重发次数。 -- 验证码提交重试上限当前为 7 次;页面明确拒绝验证码时,会在上限内继续拉取新验证码并重提。 -- `2925` 内容脚本会把每次成功读到的目标邮件立即删除;同一验证码步骤启动后,试过的验证码会按“步骤 ID + 启动时间”隔离缓存,不会在本次步骤里重复提交。 -- `2925` 在连续 3 轮未发现新增邮件后,会回退检查列表里现存的匹配邮件;每轮轮询之间只刷新 1 次收件箱列表。 +- 验证码提交重试上限当前为 15 次;页面明确拒绝验证码时,会在上限内继续拉取新验证码并重提。 +- `2925` 内容脚本会把每一封实际打开检测的邮件立即删除;同一验证码步骤启动后,试过的验证码会按“步骤 ID + 启动时间”隔离缓存,不会在本次步骤里重复提交;如果再次遇到相同验证码,对应邮件也会在读取后立即删除,避免后续反复打开。 - `2925` 当前不再对邮件里的收件邮箱做比对,只要邮件内容命中 ChatGPT / OpenAI 验证码过滤条件,就会尝试该邮件。 - 当验证码最终提交成功后,后台会异步向 2925 邮箱页发送 `DELETE_ALL_EMAILS`,执行“全选 + 删除”清理剩余邮件,不阻塞主流程。 diff --git a/项目文件结构说明.md b/项目文件结构说明.md index db04dae..4600b97 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -50,7 +50,7 @@ - `background/panel-bridge.js`:CPA / SUB2API 面板桥接层,封装 OAuth 地址获取所需的页面打开、脚本注入和通信。 - `background/signup-flow-helpers.js`:注册页辅助层,负责打开注册入口、等待密码页以及解析当前流程所用邮箱;在 Gmail / 2925 模式下会优先复用已经存在且仍兼容的完整注册邮箱,只有不兼容或为空时才重新生成。 - `background/tab-runtime.js`:标签页与内容脚本运行时基础设施,封装标签注册、冲突清理、消息超时、注入重试与队列;当前等待标签完成、注入后的短暂延迟和内容脚本重试等待都已做 Stop 感知,避免用户停止后后台还继续等待并恢复执行。 -- `background/verification-flow.js`:注册/登录验证码共享流程层,封装重发、轮询、提交、失败回退、自定义邮箱跳过、共享验证码自动重发次数配置以及 2925 长轮询参数;当前验证码提交重试上限为 7 次,步骤 8 提交登录验证码后若页面进入 `add-phone / 手机号页`,会直接抛出 fatal 错误而不是把当前步骤视为成功,并在 2925 提交成功后异步触发“删除全部邮件”的清理消息。 +- `background/verification-flow.js`:注册/登录验证码共享流程层,封装重发、轮询、提交、失败回退、自定义邮箱跳过、共享验证码自动重发次数配置以及 2925 长轮询参数;当前验证码提交重试上限为 15 次,2925 每次重发验证码之间都会固定跑完一轮 15 次邮箱刷新轮询,步骤 8 提交登录验证码后若页面进入 `add-phone / 手机号页`,会直接抛出 fatal 错误而不是把当前步骤视为成功,并在 2925 提交成功后异步触发“删除全部邮件”的清理消息。 ## `background/steps/` @@ -75,7 +75,7 @@ - `content/icloud-mail.js`:iCloud 邮箱页面脚本,负责在 iCloud Mail 页面中读取邮件详情和验证码。 - `content/inbucket-mail.js`:Inbucket 邮箱轮询脚本,负责在 Inbucket 页面中读取、删除验证码邮件。 - `content/mail-163.js`:163 / 163 VIP 邮箱轮询脚本,负责网页邮箱验证码读取和邮件清理。 -- `content/mail-2925.js`:2925 邮箱页面脚本,负责 2925 邮箱收件轮询、按步骤会话隔离“已试验证码”、命中邮件后立即删当前邮件,以及在成功后配合后台执行整箱清理。 +- `content/mail-2925.js`:2925 邮箱页面脚本,负责 2925 邮箱收件轮询、按步骤会话隔离“已试验证码”、在每次重发验证码之间执行一轮最多 15 次的邮箱刷新轮询、命中邮件后立即删当前邮件,以及在成功后配合后台执行整箱清理。 - `content/qq-mail.js`:QQ 邮箱轮询脚本,负责网页邮箱验证码读取。 - `content/signup-page.js`:注册、登录、授权主内容脚本,负责 OpenAI / ChatGPT 页面上的步骤执行;其中 Step 2 / 3 的延迟点击与延迟提交在真正触发前会先检查 Stop 状态,避免停止后页面继续自动点击。 - `content/sub2api-panel.js`:SUB2API 后台内容脚本,负责获取 OAuth 地址和提交 localhost 回调;当前承接步骤 10。 From d17ac3afdec0783e668fddb9ed843dd18314ab45 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Mon, 20 Apr 2026 13:51:50 +0800 Subject: [PATCH 13/15] chore(release): bump version to Pro4.3 --- manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest.json b/manifest.json index f238467..ccbeb41 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, "name": "多页面自动化", - "version": "4.0", - "version_name": "Pro4.0", + "version": "4.3", + "version_name": "Pro4.3", "description": "用于自动执行多步骤 OAuth 注册流程", "permissions": [ "sidePanel", From 4107e4d3783c6280e2b8f0a191d11f047686aa40 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Mon, 20 Apr 2026 15:08:45 +0800 Subject: [PATCH 14/15] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E6=B5=81=E7=A8=8B=EF=BC=8C=E5=A4=84=E7=90=86=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=B7=B2=E5=AD=98=E5=9C=A8=E9=94=99=E8=AF=AF=EF=BC=8C?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=9B=B8=E5=85=B3=E9=80=BB=E8=BE=91=E5=92=8C?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + background.js | 12 ++ background/auto-run-controller.js | 40 ++++- background/logging-status.js | 6 + content/auth-page-recovery.js | 16 +- content/signup-page.js | 35 +++- tests/auth-page-recovery.test.js | 22 +++ tests/auto-run-add-phone-stop.test.js | 163 ++++++++++++++++++ tests/auto-run-step4-restart.test.js | 122 +++++++++++++ tests/signup-verification-state-guard.test.js | 2 + 项目完整链路说明.md | 1 + 11 files changed, 417 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7204332..d4adf4d 100644 --- a/README.md +++ b/README.md @@ -502,6 +502,7 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。 进入邮箱轮询前,脚本会先确认认证页是否已经进入验证码页面;如果注册认证流程出现 `糟糕,出错了 / 操作超时(Operation timed out)`,或 `/email-verification` 上的 `405 / Route Error` 且带有 `重试` 按钮,会先通过共享恢复逻辑最多自动点击 5 次 `重试`,必要时回到密码页重新提交,再继续等待验证码页面。 在 `Auto` 模式下,如果 Step 4 当前轮失败,后台不会立刻丢弃这轮邮箱;而是沿用当前邮箱回到 Step 1 重新开始当前轮,避免刚拿到的邮箱被直接换掉。 +但如果 Step 4 的认证重试页正文里出现 `user_already_exists`,则会直接判定“当前用户已存在”,不会点击 `重试`,而是立即结束当前轮;开启自动重试时会直接继续下一轮。 支持: diff --git a/background.js b/background.js index cc44a84..b479a39 100644 --- a/background.js +++ b/background.js @@ -4018,6 +4018,14 @@ function isRestartCurrentAttemptError(error) { return /当前邮箱已存在,需要重新开始新一轮/.test(message); } +function isSignupUserAlreadyExistsFailure(error) { + if (typeof loggingStatus !== 'undefined' && loggingStatus?.isSignupUserAlreadyExistsFailure) { + return loggingStatus.isSignupUserAlreadyExistsFailure(error); + } + const message = getErrorMessage(error); + return /SIGNUP_USER_ALREADY_EXISTS::|user_already_exists/i.test(message); +} + function isStep9RecoverableAuthError(error) { const message = String(typeof error === 'string' ? error : error?.message || ''); return /STEP9_OAUTH_RETRY::/i.test(message) @@ -5511,6 +5519,7 @@ const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoR hasSavedProgress, isAddPhoneAuthFailure, isRestartCurrentAttemptError, + isSignupUserAlreadyExistsFailure, isStopError, launchAutoRunTimerPlan, normalizeAutoRunFallbackThreadIntervalMinutes, @@ -5818,6 +5827,9 @@ async function runAutoSequenceFromStep(startStep, context = {}) { } if (step === 4) { + if (isSignupUserAlreadyExistsFailure(err)) { + throw err; + } step4RestartCount += 1; const preservedState = await getState(); const preservedEmail = String(preservedState.email || '').trim(); diff --git a/background/auto-run-controller.js b/background/auto-run-controller.js index 36cc6d9..1f4d58c 100644 --- a/background/auto-run-controller.js +++ b/background/auto-run-controller.js @@ -23,6 +23,7 @@ hasSavedProgress, isAddPhoneAuthFailure, isRestartCurrentAttemptError, + isSignupUserAlreadyExistsFailure, isStopError, launchAutoRunTimerPlan, normalizeAutoRunFallbackThreadIntervalMinutes, @@ -458,7 +459,9 @@ const reason = getErrorMessage(err); roundSummary.failureReasons.push(reason); const blockedByAddPhone = typeof isAddPhoneAuthFailure === 'function' && isAddPhoneAuthFailure(err); - const canRetry = !blockedByAddPhone && autoRunSkipFailures && attemptRun < maxAttemptsForRound; + const blockedBySignupUserAlreadyExists = typeof isSignupUserAlreadyExistsFailure === 'function' + && isSignupUserAlreadyExistsFailure(err); + const canRetry = !blockedByAddPhone && !blockedBySignupUserAlreadyExists && autoRunSkipFailures && attemptRun < maxAttemptsForRound; await setState({ autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), @@ -499,6 +502,41 @@ break; } + if (blockedBySignupUserAlreadyExists) { + roundSummary.status = 'failed'; + roundSummary.finalFailureReason = reason; + await setState({ + autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), + }); + await appendRoundRecordIfNeeded('failed', reason); + cancelPendingCommands('当前轮因 user_already_exists 已终止。'); + await broadcastStopToContentScripts(); + if (!autoRunSkipFailures) { + await addLog( + `第 ${targetRun}/${totalRuns} 轮触发 user_already_exists/用户已存在,自动重试未开启,当前自动运行将停止。`, + 'warn' + ); + stoppedEarly = true; + await broadcastAutoRunStatus('stopped', { + currentRun: targetRun, + totalRuns, + attemptRun, + sessionId: 0, + }); + break; + } + + await addLog(`第 ${targetRun}/${totalRuns} 轮触发 user_already_exists/用户已存在,本轮将直接失败并跳过剩余重试。`, 'warn'); + await addLog( + targetRun < totalRuns + ? `第 ${targetRun}/${totalRuns} 轮因 user_already_exists/用户已存在提前结束,自动流程将继续下一轮。` + : `第 ${targetRun}/${totalRuns} 轮因 user_already_exists/用户已存在提前结束,已无后续轮次,本次自动运行结束。`, + 'warn' + ); + forceFreshTabsNextRun = true; + break; + } + if (canRetry) { const retryIndex = attemptRun; if (isRestartCurrentAttemptError(err)) { diff --git a/background/logging-status.js b/background/logging-status.js index f685fd3..dc6659c 100644 --- a/background/logging-status.js +++ b/background/logging-status.js @@ -91,6 +91,11 @@ return /当前邮箱已存在,需要重新开始新一轮/.test(message); } + function isSignupUserAlreadyExistsFailure(error) { + const message = getErrorMessage(error); + return /SIGNUP_USER_ALREADY_EXISTS::|user_already_exists/i.test(message); + } + function isStep9RecoverableAuthError(error) { const message = String(typeof error === 'string' ? error : error?.message || ''); return /STEP9_OAUTH_RETRY::/i.test(message) @@ -162,6 +167,7 @@ hasSavedProgress, isLegacyStep9RecoverableAuthError, isRestartCurrentAttemptError, + isSignupUserAlreadyExistsFailure, isStep9RecoverableAuthError, isStepDoneStatus, isVerificationMailPollingError, diff --git a/content/auth-page-recovery.js b/content/auth-page-recovery.js index 70b192e..63417f8 100644 --- a/content/auth-page-recovery.js +++ b/content/auth-page-recovery.js @@ -70,8 +70,9 @@ ? routeErrorPattern.test(text) : false; const maxCheckAttemptsBlocked = /max_check_attempts/i.test(text); + const userAlreadyExistsBlocked = /user_already_exists/i.test(text); - if (!titleMatched && !detailMatched && !routeErrorMatched && !maxCheckAttemptsBlocked) { + if (!titleMatched && !detailMatched && !routeErrorMatched && !maxCheckAttemptsBlocked && !userAlreadyExistsBlocked) { return null; } @@ -84,6 +85,7 @@ detailMatched, routeErrorMatched, maxCheckAttemptsBlocked, + userAlreadyExistsBlocked, }; } @@ -153,6 +155,12 @@ ); } + if (retryState.userAlreadyExistsBlocked) { + throw new Error( + 'SIGNUP_USER_ALREADY_EXISTS::步骤 4:检测到 user_already_exists,说明当前用户已存在,当前轮将直接停止。' + ); + } + if (retryState.retryButton && retryState.retryEnabled) { idlePollCount = 0; clickCount += 1; @@ -204,6 +212,12 @@ ); } + if (finalRetryState.userAlreadyExistsBlocked) { + throw new Error( + 'SIGNUP_USER_ALREADY_EXISTS::步骤 4:检测到 user_already_exists,说明当前用户已存在,当前轮将直接停止。' + ); + } + throw new Error( `${logLabel || `步骤 ${step || '?'}:重试页恢复`}失败:已连续点击“重试” ${maxClickAttempts} 次,页面仍未恢复。URL: ${location.href}` ); diff --git a/content/signup-page.js b/content/signup-page.js index 5cb30a0..f536b92 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -612,6 +612,7 @@ const STEP5_SUBMIT_ERROR_PATTERN = /无法根据该信息创建帐户|请重试| const AUTH_TIMEOUT_ERROR_TITLE_PATTERN = /糟糕,出错了|something\s+went\s+wrong|oops/i; const AUTH_TIMEOUT_ERROR_DETAIL_PATTERN = /operation\s+timed\s+out|timed\s+out|请求超时|操作超时/i; const AUTH_ROUTE_ERROR_PATTERN = /405\s+method\s+not\s+allowed|route\s+error.*405/i; +const SIGNUP_USER_ALREADY_EXISTS_ERROR_PREFIX = 'SIGNUP_USER_ALREADY_EXISTS::'; const SIGNUP_EMAIL_EXISTS_PATTERN = /与此电子邮件地址相关联的帐户已存在|account\s+associated\s+with\s+this\s+email\s+address\s+already\s+exists|email\s+address.*already\s+exists/i; const authPageRecovery = self.MultiPageAuthPageRecovery?.createAuthPageRecovery?.({ @@ -663,6 +664,12 @@ function getVerificationErrorText() { return messages.find((text) => INVALID_VERIFICATION_CODE_PATTERN.test(text)) || ''; } +function createSignupUserAlreadyExistsError() { + return new Error( + `${SIGNUP_USER_ALREADY_EXISTS_ERROR_PREFIX}步骤 4:检测到 user_already_exists,说明当前用户已存在,当前轮将直接停止。` + ); +} + function isStep5Ready() { return Boolean( document.querySelector('input[name="name"], input[autocomplete="name"], input[name="birthday"], input[name="age"], [role="spinbutton"][data-type="year"]') @@ -981,8 +988,9 @@ function getAuthTimeoutErrorPageState(options = {}) { const detailMatched = AUTH_TIMEOUT_ERROR_DETAIL_PATTERN.test(text); const routeErrorMatched = AUTH_ROUTE_ERROR_PATTERN.test(text); const maxCheckAttemptsBlocked = /max_check_attempts/i.test(text); + const userAlreadyExistsBlocked = /user_already_exists/i.test(text); - if (!titleMatched && !detailMatched && !routeErrorMatched && !maxCheckAttemptsBlocked) { + if (!titleMatched && !detailMatched && !routeErrorMatched && !maxCheckAttemptsBlocked && !userAlreadyExistsBlocked) { return null; } @@ -995,6 +1003,7 @@ function getAuthTimeoutErrorPageState(options = {}) { detailMatched, routeErrorMatched, maxCheckAttemptsBlocked, + userAlreadyExistsBlocked, }; } @@ -1073,6 +1082,9 @@ async function recoverCurrentAuthRetryPage(payload = {}) { if (retryState.maxCheckAttemptsBlocked) { throw new Error('CF_SECURITY_BLOCKED::您已触发Cloudflare 安全防护系统,已完全停止流程,请不要短时间内多次进行重新发送验证码,连续刷新、反复点击重试会加重风控;请先关闭页面等待 15-30 分钟,让系统的临时限制自动解除。或者更换浏览器'); } + if (retryState.userAlreadyExistsBlocked) { + throw createSignupUserAlreadyExistsError(); + } if (retryState.retryButton && retryState.retryEnabled) { idlePollCount = 0; clickCount += 1; @@ -1113,6 +1125,9 @@ async function recoverCurrentAuthRetryPage(payload = {}) { if (finalRetryState.maxCheckAttemptsBlocked) { throw new Error('CF_SECURITY_BLOCKED::您已触发Cloudflare 安全防护系统,已完全停止流程,请不要短时间内多次进行重新发送验证码,连续刷新、反复点击重试会加重风控;请先关闭页面等待 15-30 分钟,让系统的临时限制自动解除。或者更换浏览器'); } + if (finalRetryState.userAlreadyExistsBlocked) { + throw createSignupUserAlreadyExistsError(); + } throw new Error(`${logLabel || `步骤 ${step || '?'}:重试页恢复`}失败:已连续点击“重试” ${maxClickAttempts} 次,页面仍未恢复。URL: ${location.href}`); } @@ -1430,6 +1445,7 @@ function inspectSignupVerificationState() { return { state: 'error', retryButton: timeoutPage?.retryButton || null, + userAlreadyExistsBlocked: Boolean(timeoutPage?.userAlreadyExistsBlocked), }; } @@ -1503,6 +1519,9 @@ async function prepareSignupVerificationFlow(payload = {}, timeout = 30000) { recoveryRound += 1; if (snapshot.state === 'error') { + if (snapshot.userAlreadyExistsBlocked) { + throw createSignupUserAlreadyExistsError(); + } await recoverCurrentAuthRetryPage({ flow: 'signup', logLabel: `${prepareLogLabel}:检测到注册认证重试页,正在点击“重试”恢复(第 ${recoveryRound}/${maxRecoveryRounds} 次)`, @@ -1549,6 +1568,13 @@ async function waitForVerificationSubmitOutcome(step, timeout) { while (Date.now() - start < resolvedTimeout) { throwIfStopped(); + if (step === 4) { + const signupRetryState = getCurrentAuthRetryPageState('signup'); + if (signupRetryState?.userAlreadyExistsBlocked) { + throw createSignupUserAlreadyExistsError(); + } + } + const errorText = getVerificationErrorText(); if (errorText) { return { invalidCode: true, errorText }; @@ -1569,6 +1595,13 @@ async function waitForVerificationSubmitOutcome(step, timeout) { await sleep(150); } + if (step === 4) { + const signupRetryState = getCurrentAuthRetryPageState('signup'); + if (signupRetryState?.userAlreadyExistsBlocked) { + throw createSignupUserAlreadyExistsError(); + } + } + if (isVerificationPageStillVisible()) { return { invalidCode: true, diff --git a/tests/auth-page-recovery.test.js b/tests/auth-page-recovery.test.js index 9271eab..b48287d 100644 --- a/tests/auth-page-recovery.test.js +++ b/tests/auth-page-recovery.test.js @@ -204,3 +204,25 @@ test('auth page recovery throws cloudflare security blocked error on max_check_a ); }); +test('auth page recovery throws signup user already exists error without clicking retry', async () => { + const state = { + clickCount: 0, + pageText: 'Something went wrong. user_already_exists.', + pathname: '/email-verification', + retryVisible: true, + }; + const api = createRecoveryApi(state); + + await assert.rejects( + () => api.recoverAuthRetryPage({ + logLabel: '步骤 4:检测到注册认证重试页,正在点击“重试”恢复', + pathPatterns: [/\/email-verification(?:[/?#]|$)/i], + step: 4, + timeoutMs: 1000, + }), + /SIGNUP_USER_ALREADY_EXISTS::/ + ); + + assert.equal(state.clickCount, 0); +}); + diff --git a/tests/auto-run-add-phone-stop.test.js b/tests/auto-run-add-phone-stop.test.js index 0ca3f7e..509a4c0 100644 --- a/tests/auto-run-add-phone-stop.test.js +++ b/tests/auto-run-add-phone-stop.test.js @@ -168,3 +168,166 @@ test('auto-run controller skips add-phone failures to the next round instead of assert.equal(runtime.state.autoRunActive, false); assert.equal(runtime.state.autoRunSessionId, 0); }); + +test('auto-run controller skips user_already_exists failures to the next round instead of retrying the same round', async () => { + const events = { + logs: [], + broadcasts: [], + accountRecords: [], + runCalls: 0, + }; + + let currentState = { + stepStatuses: {}, + vpsUrl: 'https://example.com/vps', + vpsPassword: 'secret', + customPassword: '', + autoRunSkipFailures: true, + autoRunFallbackThreadIntervalMinutes: 0, + autoRunDelayEnabled: false, + autoRunDelayMinutes: 30, + autoStepDelaySeconds: null, + mailProvider: '163', + emailGenerator: 'duck', + gmailBaseEmail: '', + mail2925BaseEmail: '', + emailPrefix: 'demo', + inbucketHost: '', + inbucketMailbox: '', + cloudflareDomain: '', + cloudflareDomains: [], + tabRegistry: {}, + sourceLastUrls: {}, + autoRunRoundSummaries: [], + }; + + const runtime = { + state: { + autoRunActive: false, + autoRunCurrentRun: 0, + autoRunTotalRuns: 1, + autoRunAttemptRun: 0, + autoRunSessionId: 0, + }, + get() { + return { ...this.state }; + }, + set(updates = {}) { + this.state = { ...this.state, ...updates }; + }, + }; + + let sessionSeed = 0; + + const controller = api.createAutoRunController({ + addLog: async (message, level = 'info') => { + events.logs.push({ message, level }); + }, + appendAccountRunRecord: async (status, _state, reason) => { + events.accountRecords.push({ status, reason }); + return { status, reason }; + }, + AUTO_RUN_MAX_RETRIES_PER_ROUND: 3, + AUTO_RUN_RETRY_DELAY_MS: 3000, + AUTO_RUN_TIMER_KIND_BEFORE_RETRY: 'before_retry', + AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS: 'between_rounds', + broadcastAutoRunStatus: async (phase, payload = {}) => { + events.broadcasts.push({ phase, ...payload }); + currentState = { + ...currentState, + autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase), + autoRunPhase: phase, + autoRunCurrentRun: payload.currentRun ?? runtime.state.autoRunCurrentRun, + autoRunTotalRuns: payload.totalRuns ?? runtime.state.autoRunTotalRuns, + autoRunAttemptRun: payload.attemptRun ?? runtime.state.autoRunAttemptRun, + autoRunSessionId: payload.sessionId ?? runtime.state.autoRunSessionId, + }; + }, + broadcastStopToContentScripts: async () => {}, + cancelPendingCommands: () => {}, + clearStopRequest: () => {}, + createAutoRunSessionId: () => { + sessionSeed += 1; + return sessionSeed; + }, + getAutoRunStatusPayload: (phase, payload = {}) => ({ + autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase), + autoRunPhase: phase, + autoRunCurrentRun: payload.currentRun ?? 0, + autoRunTotalRuns: payload.totalRuns ?? 1, + autoRunAttemptRun: payload.attemptRun ?? 0, + autoRunSessionId: payload.sessionId ?? 0, + }), + getErrorMessage: (error) => error?.message || String(error || ''), + getFirstUnfinishedStep: () => 1, + getPendingAutoRunTimerPlan: () => null, + getRunningSteps: () => [], + getState: async () => ({ + ...currentState, + stepStatuses: { ...(currentState.stepStatuses || {}) }, + tabRegistry: { ...(currentState.tabRegistry || {}) }, + sourceLastUrls: { ...(currentState.sourceLastUrls || {}) }, + }), + getStopRequested: () => false, + hasSavedProgress: () => false, + isAddPhoneAuthFailure: () => false, + isRestartCurrentAttemptError: () => false, + isSignupUserAlreadyExistsFailure: (error) => /SIGNUP_USER_ALREADY_EXISTS::|user_already_exists/i.test(error?.message || String(error || '')), + isStopError: (error) => (error?.message || String(error || '')) === '流程已被用户停止。', + launchAutoRunTimerPlan: async () => false, + normalizeAutoRunFallbackThreadIntervalMinutes: (value) => Math.max(0, Math.floor(Number(value) || 0)), + persistAutoRunTimerPlan: async () => ({}), + resetState: async () => { + currentState = { + ...currentState, + stepStatuses: {}, + tabRegistry: {}, + sourceLastUrls: {}, + }; + }, + runAutoSequenceFromStep: async () => { + events.runCalls += 1; + if (events.runCalls === 1) { + throw new Error('SIGNUP_USER_ALREADY_EXISTS::步骤 4:检测到 user_already_exists,说明当前用户已存在,当前轮将直接停止。'); + } + }, + runtime, + setState: async (updates = {}) => { + currentState = { + ...currentState, + ...updates, + stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses, + tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry, + sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls, + }; + }, + sleepWithStop: async () => {}, + throwIfAutoRunSessionStopped: (sessionId) => { + if (sessionId && sessionId !== runtime.state.autoRunSessionId) { + throw new Error('流程已被用户停止。'); + } + }, + waitForRunningStepsToFinish: async () => currentState, + chrome: { + runtime: { + sendMessage() { + return Promise.resolve(); + }, + }, + }, + }); + + await controller.autoRunLoop(2, { + autoRunSkipFailures: true, + mode: 'restart', + }); + + assert.equal(events.runCalls, 2, 'user_already_exists failure should skip the current round and continue with the next round'); + assert.equal(events.broadcasts.some(({ phase }) => phase === 'retrying'), false, 'user_already_exists failure should not enter same-round retrying'); + assert.equal(events.accountRecords.length, 1, 'user_already_exists should still persist a failed round record'); + assert.equal(events.accountRecords[0].status, 'failed'); + assert.match(events.accountRecords[0].reason, /SIGNUP_USER_ALREADY_EXISTS::/); + assert.ok(events.logs.some(({ message }) => /继续下一轮/.test(message))); + assert.equal(runtime.state.autoRunActive, false); + assert.equal(runtime.state.autoRunSessionId, 0); +}); diff --git a/tests/auto-run-step4-restart.test.js b/tests/auto-run-step4-restart.test.js index 6cd4c94..2d9b683 100644 --- a/tests/auto-run-step4-restart.test.js +++ b/tests/auto-run-step4-restart.test.js @@ -55,6 +55,7 @@ function extractFunction(name) { const bundle = [ extractFunction('isAddPhoneAuthUrl'), extractFunction('isAddPhoneAuthState'), + extractFunction('isSignupUserAlreadyExistsFailure'), extractFunction('getPostStep6AutoRestartDecision'), extractFunction('runAutoSequenceFromStep'), ].join('\n'); @@ -207,3 +208,124 @@ return { assert.equal(currentState.password, 'Secret123!'); assert.equal(events.logs.some(({ message }) => /沿用当前邮箱回到步骤 1 重新开始/.test(message)), true); }); + +test('auto-run does not restart step 4 current attempt when user_already_exists is detected', async () => { + const api = new Function(` +const AUTO_STEP_DELAYS = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0 }; +const LAST_STEP_ID = 10; +const FINAL_OAUTH_CHAIN_START_STEP = 7; +const chrome = { + tabs: { + update: async () => {}, + }, + runtime: { + sendMessage: async () => {}, + }, +}; + +let currentState = { + email: 'existing@example.com', + password: 'Secret123!', + mailProvider: '163', + stepStatuses: { + 1: 'pending', + 2: 'pending', + 3: 'pending', + 4: 'pending', + 5: 'pending', + 6: 'pending', + 7: 'pending', + 8: 'pending', + 9: 'pending', + 10: 'pending', + }, +}; +const events = { + steps: [], + invalidations: [], + logs: [], +}; + +async function addLog(message, level = 'info') { + events.logs.push({ message, level }); +} + +async function ensureAutoEmailReady() { + return currentState.email; +} + +async function broadcastAutoRunStatus() {} + +async function getState() { + return currentState; +} + +async function setState(updates) { + currentState = { + ...currentState, + ...updates, + stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses, + }; +} + +function isStopError(error) { + return (error?.message || String(error || '')) === '流程已被用户停止。'; +} + +function isStepDoneStatus(status) { + return status === 'completed' || status === 'manual_completed' || status === 'skipped'; +} + +async function executeStepAndWait(step) { + events.steps.push(step); + if (step === 4) { + throw new Error('SIGNUP_USER_ALREADY_EXISTS::步骤 4:检测到 user_already_exists,说明当前用户已存在,当前轮将直接停止。'); + } +} + +async function getTabId() { + return 1; +} + +async function invalidateDownstreamAfterStepRestart(step, options = {}) { + events.invalidations.push({ step, options }); +} + +function getLoginAuthStateLabel(state) { + return state || 'unknown'; +} + +function getErrorMessage(error) { + return error?.message || String(error || ''); +} + +async function getLoginAuthStateFromContent() { + return { state: 'password_page', url: 'https://auth.openai.com/log-in' }; +} + +${bundle} + +return { + async run() { + try { + await runAutoSequenceFromStep(1, { + targetRun: 1, + totalRuns: 1, + attemptRuns: 1, + continued: false, + }); + return { events, currentState, error: null }; + } catch (error) { + return { events, currentState, error: error.message }; + } + }, +}; +`)(); + + const result = await api.run(); + + assert.match(result.error, /SIGNUP_USER_ALREADY_EXISTS::/); + assert.deepStrictEqual(result.events.invalidations, []); + assert.deepStrictEqual(result.events.steps, [1, 2, 3, 4]); + assert.equal(result.events.logs.some(({ message }) => /沿用当前邮箱回到步骤 1 重新开始/.test(message)), false); +}); diff --git a/tests/signup-verification-state-guard.test.js b/tests/signup-verification-state-guard.test.js index f36efe2..4414636 100644 --- a/tests/signup-verification-state-guard.test.js +++ b/tests/signup-verification-state-guard.test.js @@ -138,6 +138,7 @@ return { assert.deepStrictEqual(api.run(), { state: 'error', retryButton: { textContent: 'Try again' }, + userAlreadyExistsBlocked: false, }); }); @@ -188,5 +189,6 @@ return { assert.deepStrictEqual(api.run(), { state: 'error', retryButton: { textContent: 'Try again' }, + userAlreadyExistsBlocked: false, }); }); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index c99d8a2..2568ca7 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -584,6 +584,7 @@ - 新增共享恢复层:`content/auth-page-recovery.js`。 - Step 4 在等待注册验证码页时,如果命中认证页 `Try again / 重试` 页,或 `/email-verification` 上的 `405 / Route Error` 重试页,会先通过共享恢复逻辑最多自动点击 5 次 `重试` 尝试恢复,再继续回到密码页重提和验证码页确认流程。 +- 但如果 Step 4 的认证重试页正文中出现 `user_already_exists`,则会直接视为“当前用户已存在”:不点击 `重试`,不再回到步骤 1 重开当前轮,而是立即结束当前轮;开启自动重试时直接进入下一轮。 - Step 7 在识别到登录超时报错页时,会先通过共享恢复逻辑最多自动点击 5 次 `重试` 尝试恢复当前页面;若仍未恢复,则按原有可恢复失败逻辑重跑 Step 7。 - Step 8 如果发现认证页已经进入登录超时报错/重试页,会直接报错并回到 Step 7 重新开始,而不是在 Step 8 内部点击 `重试`。 - 任意认证页重试页如果正文中出现 `max_check_attempts`,会被视为 Cloudflare 风控触发:后台立刻完全停止流程,侧边栏会复用现有确认弹窗提示等待 15~30 分钟后再试,避免继续刷新或反复重试加重风控,确认按钮显示为“我知道了”。 From 8dd6daccd1e9de0e4849deb4185be31f46da9fad Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Mon, 20 Apr 2026 15:42:35 +0800 Subject: [PATCH 15/15] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA2925=E9=82=AE?= =?UTF-8?q?=E7=AE=B1=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91=EF=BC=8C=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E9=82=AE=E4=BB=B6=E9=A2=84=E6=B8=85=E7=A9=BA=E6=9C=BA?= =?UTF-8?q?=E5=88=B6=EF=BC=8C=E6=9B=B4=E6=96=B0=E7=9B=B8=E5=85=B3=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background/verification-flow.js | 54 +++++++++++++++++++ content/mail-2925.js | 20 +++++-- tests/mail-2925-content.test.js | 7 +++ tests/verification-flow-polling.test.js | 70 ++++++++++++++++++++++++- 4 files changed, 146 insertions(+), 5 deletions(-) diff --git a/background/verification-flow.js b/background/verification-flow.js index d751359..f21b143 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -237,6 +237,58 @@ return requestedAt; } + function shouldPreclear2925Mailbox(step, mail) { + return mail?.provider === '2925' && (step === 4 || step === 8); + } + + async function clear2925MailboxBeforePolling(step, mail, options = {}) { + if (!shouldPreclear2925Mailbox(step, mail)) { + return; + } + + throwIfStopped(); + await addLog(`步骤 ${step}:开始刷新 2925 邮箱前先清空全部邮件,避免读取旧验证码邮件。`, 'warn'); + + try { + const responseTimeoutMs = await getResponseTimeoutMsForStep( + step, + options, + 15000, + '清空 2925 邮箱历史邮件' + ); + const result = await sendToMailContentScriptResilient( + mail, + { + type: 'DELETE_ALL_EMAILS', + step, + source: 'background', + payload: {}, + }, + { + timeoutMs: responseTimeoutMs, + responseTimeoutMs, + maxRecoveryAttempts: 2, + } + ); + + if (result?.error) { + throw new Error(result.error); + } + + if (result?.deleted === false) { + await addLog(`步骤 ${step}:未能确认 2925 邮箱已清空,将继续刷新等待新邮件。`, 'warn'); + return; + } + + await addLog(`步骤 ${step}:2925 邮箱已预先清空,开始刷新等待新邮件。`, 'info'); + } catch (err) { + if (isStopError(err)) { + throw err; + } + await addLog(`步骤 ${step}:预清空 2925 邮箱失败,将继续刷新等待新邮件:${err.message}`, 'warn'); + } + } + function triggerPostSuccessMailboxCleanup(step, mail) { if (mail?.provider !== '2925') { return; @@ -573,6 +625,8 @@ return nextFilterAfterTimestamp; }; + await clear2925MailboxBeforePolling(step, mail, options); + if (requestFreshCodeFirst) { if (remainingAutomaticResendCount <= 0) { await addLog(`步骤 ${step}:当前自动重新发送验证码次数为 0,将直接使用当前时间窗口轮询邮箱。`, 'info'); diff --git a/content/mail-2925.js b/content/mail-2925.js index 6b7f33f..92ccb2b 100644 --- a/content/mail-2925.js +++ b/content/mail-2925.js @@ -99,8 +99,11 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } if (message.type === 'DELETE_ALL_EMAILS') { - Promise.resolve(deleteAllMailboxEmails(message.step)).catch(() => {}); - sendResponse({ ok: true }); + Promise.resolve(deleteAllMailboxEmails(message.step)).then((deleted) => { + sendResponse({ ok: true, deleted }); + }).catch((err) => { + sendResponse({ ok: false, error: err?.message || String(err || '删除邮件失败') }); + }); return true; } @@ -478,6 +481,10 @@ async function openMailAndDeleteAfterRead(item, step) { async function deleteAllMailboxEmails(step) { try { await returnToInbox(); + const initialItems = findMailItems(); + if (initialItems.length === 0) { + return true; + } const selectAllControl = findSelectAllControl(); if (!selectAllControl) { @@ -495,8 +502,15 @@ async function deleteAllMailboxEmails(step) { } simulateClick(deleteButton); + for (let attempt = 0; attempt < 20; attempt += 1) { + await sleep(250); + if (findMailItems().length === 0) { + return true; + } + } + await sleepRandom(200, 500); - return true; + return findMailItems().length === 0; } catch (err) { console.warn(MAIL2925_PREFIX, `Step ${step}: delete-all cleanup failed:`, err?.message || err); return false; diff --git a/tests/mail-2925-content.test.js b/tests/mail-2925-content.test.js index 0b2f037..bbc9900 100644 --- a/tests/mail-2925-content.test.js +++ b/tests/mail-2925-content.test.js @@ -431,12 +431,17 @@ test('deleteAllMailboxEmails selects all messages and clicks delete', async () = const calls = []; const selectAllControl = { kind: 'select-all' }; const deleteButton = { kind: 'delete' }; +let mailboxCleared = false; async function returnToInbox() { calls.push('inbox'); return true; } +function findMailItems() { + return mailboxCleared ? [] : [{ id: 'mail-1' }]; +} + function findSelectAllControl() { return selectAllControl; } @@ -456,11 +461,13 @@ function simulateClick(node) { } if (node === deleteButton) { calls.push('delete'); + mailboxCleared = true; return; } throw new Error('unexpected node'); } +async function sleep() {} async function sleepRandom() {} const console = { warn() {} }; diff --git a/tests/verification-flow-polling.test.js b/tests/verification-flow-polling.test.js index 5cb5b40..70314ed 100644 --- a/tests/verification-flow-polling.test.js +++ b/tests/verification-flow-polling.test.js @@ -111,7 +111,7 @@ test('verification flow runs beforeSubmit hook before filling the code', async ( ]); }); -test('verification flow triggers 2925 mailbox cleanup only after code submission succeeds', async () => { +test('verification flow clears 2925 mailbox before polling and after successful login code submission', async () => { const mailMessages = []; const helpers = api.createVerificationFlowHelpers({ @@ -169,7 +169,73 @@ test('verification flow triggers 2925 mailbox cleanup only after code submission await new Promise((resolve) => setTimeout(resolve, 0)); - assert.deepStrictEqual(mailMessages, ['POLL_EMAIL', 'DELETE_ALL_EMAILS']); + assert.deepStrictEqual(mailMessages, ['DELETE_ALL_EMAILS', 'POLL_EMAIL', 'DELETE_ALL_EMAILS']); +}); + +test('verification flow clears 2925 mailbox before polling and after successful signup code submission', async () => { + const mailMessages = []; + + 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 {}; + } + if (message.type === 'RESEND_VERIFICATION_CODE') { + return {}; + } + return {}; + }, + sendToMailContentScriptResilient: async (_mail, message) => { + mailMessages.push(message.type); + if (message.type === 'POLL_EMAIL') { + return { code: '654321', emailTimestamp: 123 }; + } + return { ok: true, deleted: true }; + }, + setState: async () => {}, + setStepStatus: async () => {}, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + VERIFICATION_POLL_MAX_ROUNDS: 5, + }); + + await helpers.resolveVerificationStep( + 4, + { + email: 'user@example.com', + mailProvider: '2925', + lastSignupCode: null, + }, + { provider: '2925', label: '2925 邮箱' }, + { + requestFreshCodeFirst: false, + } + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.deepStrictEqual(mailMessages, ['DELETE_ALL_EMAILS', 'POLL_EMAIL', 'DELETE_ALL_EMAILS']); }); test('verification flow treats add-phone after login code submit as fatal instead of completing step 8', async () => {