diff --git a/background.js b/background.js index 03e822f..168c71c 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', @@ -175,6 +176,23 @@ 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 || { + contributionMode: false, + contributionModeExpected: false, + 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(); @@ -276,6 +294,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 或程序自动生成后写入。 @@ -1117,6 +1136,67 @@ 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, + contributionModeExpected: true, + panelMode: 'cpa', + customPassword: '', + accountRunHistoryTextEnabled: false, + }; + } + + return { + ...CONTRIBUTION_RUNTIME_DEFAULTS, + contributionMode: false, + contributionModeExpected: 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); } @@ -1267,15 +1347,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 || [], @@ -5192,6 +5278,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) { @@ -5931,7 +6026,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, @@ -6004,6 +6099,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter scheduleAutoRun, selectLuckmailPurchase, setCurrentHotmailAccount, + setContributionMode, setEmailState, setEmailStateSilently, setIcloudAliasPreservedState, @@ -6016,7 +6112,9 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter setStepStatus, skipAutoRunCountdown, skipStep, + startContributionFlow: (...args) => contributionOAuthManager?.startContributionFlow?.(...args), startAutoRunLoop, + pollContributionStatus: (...args) => contributionOAuthManager?.pollContributionStatus?.(...args), syncHotmailAccounts, testHotmailAccountMailAccess, upsertHotmailAccount, @@ -6347,7 +6445,26 @@ 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, + 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 链接...`); + 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); @@ -6951,7 +7068,76 @@ 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?.contributionModeExpected && !state?.contributionMode) { + throw new Error('步骤 10:当前自动流程预期使用贡献模式,但运行态 contributionMode 已丢失,已阻止回退到普通 CPA / SUB2API 提交。请重新进入贡献模式后再点击自动。'); + } + 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 a855907..9a03afb 100644 --- a/background/account-run-history.js +++ b/background/account-run-history.js @@ -322,6 +322,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..1a59f7f --- /dev/null +++ b/background/contribution-oauth.js @@ -0,0 +1,723 @@ +(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, + contributionModeExpected: 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 bdcd0be..0755ad5 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -57,6 +57,7 @@ notifyStepComplete, notifyStepError, patchHotmailAccount, + pollContributionStatus, registerTab, requestStop, handleCloudflareSecurityBlocked, @@ -65,6 +66,7 @@ scheduleAutoRun, selectLuckmailPurchase, setCurrentHotmailAccount, + setContributionMode, setEmailState, setEmailStateSilently, setIcloudAliasPreservedState, @@ -77,6 +79,7 @@ setStepStatus, skipAutoRunCountdown, skipStep, + startContributionFlow, startAutoRunLoop, syncHotmailAccounts, testHotmailAccountMailAccess, @@ -289,6 +292,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)) { @@ -340,6 +386,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('已有自动运行倒计时计划,请先取消或立即开始。'); @@ -354,6 +403,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/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 7dd6bf8..938b08e 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -100,7 +100,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; @@ -228,12 +230,19 @@ header { 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); +} + /* ============================================================ Theme Toggle ============================================================ */ @@ -541,6 +550,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 3dc8cec..36fc09d 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -52,6 +52,8 @@ + + + + +
CPA
@@ -151,7 +176,7 @@
-
+
账户密码
-
+
本地同步 -
+
-
+
+
+
+ 验证码 +
+
验证码重发
+ diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index f8bf5df..575dd4b 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -33,6 +33,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'); @@ -80,6 +88,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'); @@ -175,6 +184,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'); @@ -776,18 +786,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; @@ -870,6 +885,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; } @@ -1311,6 +1332,7 @@ function collectSettingsPayload() { const selectedCloudflareTempEmailDomain = normalizeCloudflareTempEmailDomainValue( !cloudflareTempEmailDomainEditMode ? selectTempEmailDomain.value : tempEmailActiveDomain ) || tempEmailActiveDomain; + const contributionModeEnabled = Boolean(latestState?.contributionMode); return { panelMode: selectPanelMode.value, vpsUrl: inputVpsUrl.value.trim(), @@ -1321,14 +1343,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(), @@ -1453,7 +1479,9 @@ function updateAccountRunHistorySettingsUI() { return; } - rowAccountRunHistoryHelperBaseUrl.style.display = inputAccountRunHistoryTextEnabled.checked ? '' : 'none'; + rowAccountRunHistoryHelperBaseUrl.style.display = inputAccountRunHistoryTextEnabled.checked && !latestState?.contributionMode + ? '' + : 'none'; } function setSettingsCardLocked(locked) { @@ -1624,6 +1652,7 @@ function applyAutoRunStatus(payload = currentAutoRun) { syncScheduledCountdownTicker(); updateStopButtonState(scheduled || paused || locked || Object.values(getStepStatuses()).some(status => status === 'running')); updateConfigMenuControls(); + renderContributionMode(); } function initializeManualStepActions() { @@ -1805,6 +1834,7 @@ async function restoreState() { updateStatusDisplay(latestState); updateProgressCounter(); + renderContributionMode(); } catch (err) { console.error('Failed to restore state:', err); } @@ -2064,7 +2094,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) { @@ -2626,6 +2656,7 @@ function updateButtonStates() { if (checkboxAutoDeleteIcloud) checkboxAutoDeleteIcloud.disabled = disableIcloudControls; if (btnContributionMode) btnContributionMode.disabled = isContributionButtonLocked(); updateStopButtonState(anyRunning || autoScheduled || isAutoRunPausedPhase() || autoLocked); + renderContributionMode(); } function updateStopButtonState(active) { @@ -3002,7 +3033,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() { @@ -3362,9 +3452,65 @@ 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, + contributionMode: Boolean(latestState?.contributionMode), + 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; @@ -4064,12 +4210,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)); @@ -4172,6 +4326,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..85054eb --- /dev/null +++ b/tests/background-contribution-mode.test.js @@ -0,0 +1,516 @@ +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, + contributionModeExpected: 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, + contributionModeExpected: 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, + contributionModeExpected: 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('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 = {}; + 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:contributionMode=true,正在通过公开贡献接口申请 OAuth 链接...' }, + { 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; +}); + +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; +}); 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 140c3e9..6f2525e 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -37,7 +37,8 @@ - 向后台发送命令 - 接收后台广播并更新 UI - 动态渲染步骤列表 -- 顶部提供一个临时 `贡献` 按钮,用于直接打开账号贡献上传页 +- 管理顶部“贡献”按钮与贡献模式主面板;贡献模式本身是 sidepanel 的运行态 UI 模式,不是新的 `panelMode` 来源 +- 在贡献模式下复用同一套主自动流程启动,并在面板内展示贡献链路的 `OAUTH / 回调 / 总状态` 三块实时状态 - 在日志区通过“记录”按钮打开独立的邮箱记录覆盖层,并展示成功/失败/停止/重试统计与分页列表 - 查询 GitHub Releases 并展示更新卡片;当前更新服务会区分 `Pro` 与 legacy `v` 两个版本族,排序时优先保持版本族语义一致,同时会在读取缓存后重新排序,避免旧缓存把 `v` 版本误显示为比 `Pro` 更新 @@ -119,6 +120,15 @@ 保存运行态: - 当前步骤状态 +- 当前 sidepanel UI 模式 `contributionMode` +- 贡献 OAuth 会话字段: + - `contributionSessionId` + - `contributionAuthUrl` + - `contributionAuthState` + - `contributionCallbackUrl` + - `contributionStatus` + - `contributionStatusMessage` + - `contributionLastPollAt` - OAuth 链接 - 当前邮箱 / 密码 - 第 8 步固定的验证码页显示邮箱 `step8VerificationTargetEmail` @@ -147,6 +157,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 的接码模式。 @@ -161,22 +176,34 @@ - `ICLOUD_LOGIN_REQUIRED` - `ICLOUD_ALIASES_CHANGED` -### 4.4 临时贡献入口 +### 4.4 贡献模式链路 -当前 `dev` 分支里的贡献能力先以“临时上传入口”形式提供,而不是完整接入主自动链。 - -流程: +贡献模式的目标不是新增一个 provider,而是在 sidepanel 内开启一套“贡献账号”专用 UI,并把公开贡献服务并进原有 10 步主链: 1. 用户点击顶部 `贡献` -2. 扩展调用统一的新标签页打开逻辑 -3. 浏览器直接打开 `https://apikey.qzz.io/` +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. `退出贡献模式` 会清理贡献会话相关运行态,`重置` 只重置主流程状态,不会自动退出贡献模式 -边界: +这条链路的关键边界是: -- 当前这颗按钮不参与 1~10 步主流程 -- 当前这颗按钮不调用后台管理接口 -- 当前这颗按钮不保存额外运行态 -- 当步骤正在运行或自动流程处于锁定状态时,按钮会禁用,避免和主流程冲突 +- 扩展会调用公开贡献 API,但这条调用被折叠进主 10 步自动链,而不是额外分出一条独立“贡献 OAuth 小流程” +- 扩展不会保存 token +- 扩展不会持有任何管理密钥 +- 扩展代码中不能直接调用 `/v0/management/*` +- 扩展不会直接访问生产管理面或生产 auth-dir +- 安全边界仍然在服务端;扩展只负责公开 OAuth 贡献前端流程 +- 退出贡献模式后,要恢复普通模式 UI,并恢复持久配置中的自定义密码/本地同步偏好 ## 5. 内容脚本通信链路 @@ -332,6 +359,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 文件: @@ -384,6 +416,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 4584e64..13dcd4c 100644 --- a/项目开发规范(AI协作).md +++ b/项目开发规范(AI协作).md @@ -109,14 +109,27 @@ 6. 是否挂在正确的职责域中 7. 文档 -### 3.4 新增顶部入口按钮 +### 3.4 新增运行态模式 -如果新增的是 sidepanel 顶部快捷入口按钮,至少同步检查: +如果新增的是“运行态 UI 模式”而不是持久配置或新来源,必须先把边界说清楚,再落代码。 -1. 是否应该复用现有确认弹窗与统一跳转逻辑 -2. 是否需要在步骤运行中或自动运行中禁用 -3. 是否补了最小 HTML 接线测试和按钮行为测试 -4. 是否同步更新结构文档与链路文档 +当前约定示例: + +- `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. 测试规范 diff --git a/项目文件结构说明.md b/项目文件结构说明.md index 4d90214..db04dae 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;当前顶部 `贡献` 按钮会在新标签页中直接打开账号贡献上传页。 +- `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 配置解析。 @@ -163,6 +166,7 @@ - `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-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 管理器模块接线与空态渲染。