diff --git a/background.js b/background.js index 45a3243..779312e 100644 --- a/background.js +++ b/background.js @@ -3,6 +3,7 @@ importScripts( 'managed-alias-utils.js', 'background/account-run-history.js', + 'background/contribution-oauth.js', 'background/panel-bridge.js', 'background/generated-email-helpers.js', 'background/signup-flow-helpers.js', @@ -177,6 +178,22 @@ const DISPLAY_TIMEZONE = 'Asia/Shanghai'; const MICROSOFT_TOKEN_DNR_RULE_ID = 1001; const PERSISTENT_ALIAS_STATE_KEYS = ['manualAliasUsage', 'preservedAliases']; const ACCOUNT_RUN_HISTORY_STORAGE_KEY = 'accountRunHistory'; +const CONTRIBUTION_RUNTIME_DEFAULTS = self.MultiPageBackgroundContributionOAuth?.RUNTIME_DEFAULTS || { + ...CONTRIBUTION_RUNTIME_DEFAULTS, + contributionSessionId: '', + contributionAuthUrl: '', + contributionAuthState: '', + contributionCallbackUrl: '', + contributionStatus: '', + contributionStatusMessage: '', + contributionLastPollAt: 0, + contributionCallbackStatus: 'idle', + contributionCallbackMessage: '', + contributionAuthOpenedAt: 0, + contributionAuthTabId: 0, +}; +const CONTRIBUTION_RUNTIME_KEYS = self.MultiPageBackgroundContributionOAuth?.RUNTIME_KEYS + || Object.keys(CONTRIBUTION_RUNTIME_DEFAULTS); initializeSessionStorageAccess(); setupDeclarativeNetRequestRules(); @@ -278,6 +295,7 @@ const PRE_LOGIN_COOKIE_CLEAR_ORIGINS = [ const DEFAULT_STATE = { currentStep: 0, // 当前流程执行到的步骤编号。 stepStatuses: Object.fromEntries(STEP_IDS.map((stepId) => [stepId, 'pending'])), + ...CONTRIBUTION_RUNTIME_DEFAULTS, oauthUrl: null, // 运行时抓取到的 OAuth 地址,不要手动预填。 email: null, // 运行时邮箱,由程序自动获取并写入,不能手动预填。 password: null, // 运行时实际密码,由 customPassword 或程序自动生成后写入。 @@ -1119,6 +1137,65 @@ async function setPasswordState(password) { broadcastDataUpdate({ password }); } +function buildContributionModeState(enabled, persistedSettings = {}, currentState = {}) { + const currentContributionState = {}; + for (const key of CONTRIBUTION_RUNTIME_KEYS) { + currentContributionState[key] = currentState[key] !== undefined + ? currentState[key] + : CONTRIBUTION_RUNTIME_DEFAULTS[key]; + } + + if (enabled) { + return { + ...currentContributionState, + contributionMode: true, + panelMode: 'cpa', + customPassword: '', + accountRunHistoryTextEnabled: false, + }; + } + + return { + ...CONTRIBUTION_RUNTIME_DEFAULTS, + contributionMode: false, + panelMode: persistedSettings.panelMode || DEFAULT_STATE.panelMode, + customPassword: persistedSettings.customPassword || '', + accountRunHistoryTextEnabled: Boolean(persistedSettings.accountRunHistoryTextEnabled), + }; +} + +async function setContributionMode(enabled) { + const normalizedEnabled = Boolean(enabled); + const [persistedSettings, currentState] = await Promise.all([ + getPersistedSettings(), + getState(), + ]); + + if (normalizedEnabled) { + await setPersistentSettings({ panelMode: 'cpa' }); + } + + const updates = buildContributionModeState(normalizedEnabled, { + ...persistedSettings, + ...(normalizedEnabled ? { panelMode: 'cpa' } : {}), + }, currentState); + + await setState(updates); + const nextState = await getState(); + const contributionBroadcast = {}; + for (const key of CONTRIBUTION_RUNTIME_KEYS) { + contributionBroadcast[key] = nextState[key]; + } + broadcastDataUpdate({ + ...contributionBroadcast, + panelMode: nextState.panelMode, + customPassword: nextState.customPassword, + accountRunHistoryTextEnabled: nextState.accountRunHistoryTextEnabled, + accountRunHistoryHelperBaseUrl: nextState.accountRunHistoryHelperBaseUrl, + }); + return nextState; +} + function getLuckmailUsedPurchases(state = {}) { return normalizeLuckmailUsedPurchases(state?.luckmailUsedPurchases); } @@ -1269,15 +1346,21 @@ async function resetState() { 'luckmailPreserveTagId', 'luckmailPreserveTagName', 'preferredIcloudHost', + ...CONTRIBUTION_RUNTIME_KEYS, ]), getPersistedSettings(), getPersistedAliasState(), ]); + const contributionModeState = buildContributionModeState(Boolean(prev.contributionMode), { + ...persistedSettings, + ...(prev.contributionMode ? { panelMode: 'cpa' } : {}), + }, prev); await chrome.storage.session.clear(); await chrome.storage.session.set({ ...DEFAULT_STATE, ...persistedSettings, ...persistedAliasState, + ...contributionModeState, seenCodes: prev.seenCodes || [], seenInbucketMailIds: prev.seenInbucketMailIds || [], accounts: prev.accounts || [], @@ -5215,6 +5298,15 @@ const accountRunHistoryHelpers = self.MultiPageBackgroundAccountRunHistory?.crea getState, normalizeAccountRunHistoryHelperBaseUrl, }); +const contributionOAuthManager = self.MultiPageBackgroundContributionOAuth?.createContributionOAuthManager({ + addLog, + broadcastDataUpdate, + chrome, + closeLocalhostCallbackTabs, + getState, + setState, +}); +contributionOAuthManager?.ensureCallbackListeners?.(); async function broadcastAccountRunHistoryUpdate() { if (!accountRunHistoryHelpers?.getPersistedAccountRunHistory) { @@ -5944,7 +6036,7 @@ const stepExecutorsByKey = { 'oauth-login': (state) => step7Executor.executeStep7(state), 'fetch-login-code': (state) => step8Executor.executeStep8(state), 'confirm-oauth': (state) => step9Executor.executeStep9(state), - 'platform-verify': (state) => step10Executor.executeStep10(state), + 'platform-verify': (state) => executeStep10(state), }; const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter({ addLog, @@ -6016,6 +6108,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter scheduleAutoRun, selectLuckmailPurchase, setCurrentHotmailAccount, + setContributionMode, setEmailState, setEmailStateSilently, setIcloudAliasPreservedState, @@ -6028,7 +6121,9 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter setStepStatus, skipAutoRunCountdown, skipStep, + startContributionFlow: (...args) => contributionOAuthManager?.startContributionFlow?.(...args), startAutoRunLoop, + pollContributionStatus: (...args) => contributionOAuthManager?.pollContributionStatus?.(...args), syncHotmailAccounts, testHotmailAccountMailAccess, upsertHotmailAccount, @@ -6359,6 +6454,20 @@ async function runPreStep6CookieCleanup() { // ============================================================ async function refreshOAuthUrlBeforeStep6(state) { + if (state?.contributionMode && contributionOAuthManager?.startContributionFlow) { + await addLog('步骤 7:贡献模式正在申请贡献登录地址...'); + const contributionState = await contributionOAuthManager.startContributionFlow({ + nickname: state.email, + openAuthTab: false, + stateOverride: state, + }); + const oauthUrl = String(contributionState?.contributionAuthUrl || '').trim(); + if (!oauthUrl) { + throw new Error('贡献模式未返回可用的登录地址,请稍后重试。'); + } + await handleStepData(1, { oauthUrl }); + return oauthUrl; + } await addLog(`步骤 7:正在刷新登录用的 ${getPanelModeLabel(state)} OAuth 链接...`); console.log(LOG_PREFIX, '[refreshOAuthUrlBeforeStep6] requesting fresh OAuth directly from panel'); const refreshResult = await requestOAuthUrlFromPanel(state, { logLabel: '步骤 7' }); @@ -6972,7 +7081,73 @@ async function executeStep9(state) { // Step 10: 平台回调验证 // ============================================================ +async function executeContributionStep10(state) { + if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) { + throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。'); + } + if (!state.localhostUrl) { + throw new Error('缺少 localhost 回调地址,请先完成步骤 9。'); + } + if (!state.contributionSessionId) { + throw new Error('缺少贡献会话信息,请重新从步骤 7 开始。'); + } + if (!contributionOAuthManager?.pollContributionStatus) { + throw new Error('贡献 OAuth 流程尚未接入,无法完成贡献模式的步骤 10。'); + } + + await addLog('步骤 10:贡献模式正在提交回调并等待最终结果...'); + + let latestState = await getState(); + const callbackUrl = latestState.localhostUrl || state.localhostUrl; + + if (!latestState.contributionCallbackUrl && contributionOAuthManager?.handleCapturedCallback) { + latestState = await contributionOAuthManager.handleCapturedCallback(callbackUrl, { + source: 'step10', + }); + } else { + latestState = await contributionOAuthManager.pollContributionStatus({ + reason: 'step10_initial', + stateOverride: latestState, + }); + } + + const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' + ? await getOAuthFlowStepTimeoutMs(120000, { + step: 10, + actionLabel: '贡献流程最终结果', + }) + : 120000; + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + const status = String(latestState.contributionStatus || '').trim().toLowerCase(); + if (contributionOAuthManager?.isContributionFinalStatus?.(status)) { + if (status === 'auto_approved' || status === 'manual_review_required') { + await addLog(`步骤 10:贡献流程已结束,最终状态:${latestState.contributionStatusMessage || status}`, status === 'auto_approved' ? 'ok' : 'warn'); + await completeStepFromBackground(10, { + contributionStatus: status, + contributionStatusMessage: latestState.contributionStatusMessage || '', + localhostUrl: callbackUrl, + }); + return; + } + throw new Error(latestState.contributionStatusMessage || '贡献流程失败。'); + } + + await sleepWithStop(2500); + latestState = await contributionOAuthManager.pollContributionStatus({ + reason: 'step10_wait_final', + stateOverride: latestState, + }); + } + + throw new Error('步骤 10:等待贡献流程最终结果超时。'); +} + async function executeStep10(state) { + if (state?.contributionMode) { + return executeContributionStep10(state); + } return step10Executor.executeStep10(state); } diff --git a/background/account-run-history.js b/background/account-run-history.js index 28404ab..6ebde78 100644 --- a/background/account-run-history.js +++ b/background/account-run-history.js @@ -298,6 +298,9 @@ } function shouldSyncAccountRunHistorySnapshot(state = {}) { + if (Boolean(state.contributionMode)) { + return false; + } if (!Boolean(state.accountRunHistoryTextEnabled)) { return false; } diff --git a/background/contribution-oauth.js b/background/contribution-oauth.js new file mode 100644 index 0000000..9eb2033 --- /dev/null +++ b/background/contribution-oauth.js @@ -0,0 +1,722 @@ +(function attachBackgroundContributionOAuth(root, factory) { + root.MultiPageBackgroundContributionOAuth = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundContributionOAuthModule() { + const API_BASE_URL = 'https://apikey.qzz.io/oauth/api'; + const ACTIVE_STATUSES = new Set(['started', 'waiting', 'processing']); + const FINAL_STATUSES = new Set(['auto_approved', 'auto_rejected', 'manual_review_required', 'expired', 'error']); + const CALLBACK_FINAL_STATUSES = new Set(['submitted', 'not_required']); + const CALLBACK_WAITING_STATUSES = new Set(['idle', 'waiting', 'captured', 'failed', 'submitting']); + + const RUNTIME_DEFAULTS = { + contributionMode: false, + contributionSessionId: '', + contributionAuthUrl: '', + contributionAuthState: '', + contributionCallbackUrl: '', + contributionStatus: '', + contributionStatusMessage: '', + contributionLastPollAt: 0, + contributionCallbackStatus: 'idle', + contributionCallbackMessage: '', + contributionAuthOpenedAt: 0, + contributionAuthTabId: 0, + }; + + const RUNTIME_KEYS = Object.keys(RUNTIME_DEFAULTS); + + function createContributionOAuthManager(deps = {}) { + const { + addLog, + broadcastDataUpdate, + chrome, + closeLocalhostCallbackTabs, + getState, + setState, + } = deps; + + let listenersBound = false; + + function normalizeString(value = '') { + return String(value || '').trim(); + } + + function normalizePositiveInteger(value, fallback = 0) { + const numeric = Math.floor(Number(value) || 0); + return numeric > 0 ? numeric : fallback; + } + + function normalizeContributionStatus(value = '') { + const normalized = normalizeString(value).toLowerCase(); + switch (normalized) { + case 'started': + return 'started'; + case 'waiting': + case 'wait': + return 'waiting'; + case 'processing': + return 'processing'; + case 'auto_approved': + case 'approved': + return 'auto_approved'; + case 'auto_rejected': + case 'rejected': + return 'auto_rejected'; + case 'manual_review_required': + case 'manual_review': + return 'manual_review_required'; + case 'expired': + case 'timeout': + return 'expired'; + case 'error': + case 'failed': + return 'error'; + default: + return ''; + } + } + + function normalizeContributionCallbackStatus(value = '') { + const normalized = normalizeString(value).toLowerCase(); + switch (normalized) { + case 'idle': + return 'idle'; + case 'waiting': + case 'pending': + return 'waiting'; + case 'captured': + return 'captured'; + case 'submitting': + case 'processing': + return 'submitting'; + case 'submitted': + case 'success': + case 'done': + return 'submitted'; + case 'not_required': + case 'no_need': + case 'no_callback_required': + return 'not_required'; + case 'failed': + case 'error': + return 'failed'; + default: + return ''; + } + } + + function isContributionFinalStatus(status = '') { + return FINAL_STATUSES.has(normalizeContributionStatus(status)); + } + + function getStatusLabel(status = '') { + switch (normalizeContributionStatus(status)) { + case 'started': + return '已生成登录地址'; + case 'waiting': + return '等待授权完成'; + case 'processing': + return '已授权,正在自动审核'; + case 'auto_approved': + return '贡献成功,已自动导入'; + case 'auto_rejected': + return '贡献未通过自动审核'; + case 'manual_review_required': + return '已提交,等待人工处理'; + case 'expired': + return '贡献会话已超时'; + case 'error': + return '贡献流程失败'; + default: + return '等待开始贡献'; + } + } + + function getCallbackLabel(status = '') { + switch (normalizeContributionCallbackStatus(status)) { + case 'waiting': + case 'idle': + return '等待回调'; + case 'captured': + return '已捕获回调地址'; + case 'submitting': + return '正在提交回调'; + case 'submitted': + return '已提交回调'; + case 'not_required': + return '当前流程无需手动回调'; + case 'failed': + return '回调提交失败'; + default: + return '等待回调'; + } + } + + function unwrapPayload(payload) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return {}; + } + + if (payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)) { + return { ...payload.data, ...payload }; + } + + return payload; + } + + function getErrorMessage(payload, responseStatus = 500) { + const details = [ + payload?.message, + payload?.detail, + payload?.error, + payload?.reason, + ] + .map((item) => normalizeString(item)) + .find(Boolean); + + if (details) { + return details; + } + + return `贡献服务请求失败(HTTP ${responseStatus})。`; + } + + async function fetchContributionJson(endpoint, options = {}) { + const controller = new AbortController(); + const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 15000)); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + method: options.method || 'GET', + headers: { + Accept: 'application/json', + ...(options.body ? { 'Content-Type': 'application/json' } : {}), + }, + body: options.body ? JSON.stringify(options.body) : undefined, + signal: controller.signal, + }); + + let rawPayload = {}; + try { + rawPayload = await response.json(); + } catch { + rawPayload = {}; + } + + const payload = unwrapPayload(rawPayload); + if (!response.ok || payload.ok === false) { + const error = new Error(getErrorMessage(payload, response.status)); + error.payload = payload; + error.responseStatus = response.status; + throw error; + } + + return payload; + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error('贡献服务请求超时,请稍后重试。'); + } + throw error; + } finally { + clearTimeout(timer); + } + } + + function pickContributionState(state = {}) { + const picked = {}; + for (const key of RUNTIME_KEYS) { + picked[key] = state[key] !== undefined ? state[key] : RUNTIME_DEFAULTS[key]; + } + return picked; + } + + async function applyRuntimeUpdates(updates = {}) { + if (!updates || typeof updates !== 'object' || Array.isArray(updates) || Object.keys(updates).length === 0) { + return getState(); + } + + await setState(updates); + broadcastDataUpdate(updates); + return getState(); + } + + function extractAuthStateFromUrl(authUrl = '') { + try { + return new URL(authUrl).searchParams.get('state') || ''; + } catch { + return ''; + } + } + + function buildNickname(state = {}, preferredNickname = '') { + const nickname = normalizeString(preferredNickname) + || normalizeString(state.email) + || normalizeString(state.contributionNickname); + return nickname || 'codex-extension-user'; + } + + function buildStatusMessage(status, payload = {}) { + const label = getStatusLabel(status); + const details = [ + payload.status_message, + payload.statusMessage, + payload.message, + payload.detail, + payload.reason, + ] + .map((item) => normalizeString(item)) + .find(Boolean); + + if (!details || details === label) { + return label; + } + + return `${label}:${details}`; + } + + function buildCallbackMessage(status, payload = {}) { + const label = getCallbackLabel(status); + const details = [ + payload.callback_message, + payload.callbackMessage, + payload.message, + payload.detail, + payload.reason, + ] + .map((item) => normalizeString(item)) + .find(Boolean); + + if (!details || details === label) { + return label; + } + + return `${label}:${details}`; + } + + function looksLikeNoNeedCallback(payload = {}) { + const combined = [ + payload.message, + payload.detail, + payload.reason, + payload.error, + ] + .map((item) => normalizeString(item).toLowerCase()) + .filter(Boolean) + .join(' '); + + return Boolean(payload.callback_required === false + || payload.callbackRequired === false + || /无需手动|鏃犻渶鎵嬪姩|not required|already enabled/i.test(combined)); + } + + function deriveCallbackState(payload = {}, state = {}) { + const existingStatus = normalizeContributionCallbackStatus(state.contributionCallbackStatus); + const callbackUrl = normalizeString( + payload.callback_url + || payload.callbackUrl + || state.contributionCallbackUrl + ); + const explicitStatus = normalizeContributionCallbackStatus( + payload.callback_status + || payload.callbackStatus + ); + + if (explicitStatus) { + return { + status: explicitStatus, + message: buildCallbackMessage(explicitStatus, payload), + callbackUrl, + }; + } + + if (payload.callback_submitted === true || payload.callbackSubmitted === true) { + return { + status: 'submitted', + message: buildCallbackMessage('submitted', payload), + callbackUrl, + }; + } + + if (looksLikeNoNeedCallback(payload)) { + return { + status: 'not_required', + message: buildCallbackMessage('not_required', payload), + callbackUrl, + }; + } + + if (callbackUrl) { + return { + status: CALLBACK_FINAL_STATUSES.has(existingStatus) ? existingStatus : 'captured', + message: buildCallbackMessage(CALLBACK_FINAL_STATUSES.has(existingStatus) ? existingStatus : 'captured', payload), + callbackUrl, + }; + } + + if (CALLBACK_FINAL_STATUSES.has(existingStatus) || existingStatus === 'failed') { + return { + status: existingStatus, + message: normalizeString(state.contributionCallbackMessage) || buildCallbackMessage(existingStatus), + callbackUrl: normalizeString(state.contributionCallbackUrl), + }; + } + + return { + status: 'waiting', + message: buildCallbackMessage('waiting', payload), + callbackUrl: '', + }; + } + + function isContributionCallbackUrl(rawUrl, state = {}) { + const urlText = normalizeString(rawUrl); + if (!urlText) { + return false; + } + + let parsed; + try { + parsed = new URL(urlText); + } catch { + return false; + } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + return false; + } + + const code = normalizeString(parsed.searchParams.get('code')); + const authState = normalizeString(parsed.searchParams.get('state')); + if (!code || !authState) { + return false; + } + + const hostLooksLocal = ['localhost', '127.0.0.1'].includes(parsed.hostname); + const pathLooksLikeCallback = /callback/i.test(parsed.pathname || ''); + if (!hostLooksLocal && !pathLooksLikeCallback) { + return false; + } + + const expectedState = normalizeString(state.contributionAuthState); + return !expectedState || expectedState === authState; + } + + async function openContributionAuthUrl(authUrl, options = {}) { + const normalizedUrl = normalizeString(authUrl); + if (!normalizedUrl) { + throw new Error('贡献服务未返回有效的登录地址。'); + } + + const currentState = options.stateOverride || await getState(); + const preferredTabId = normalizePositiveInteger(options.tabId || currentState.contributionAuthTabId, 0); + let tab = null; + + if (preferredTabId) { + tab = await chrome.tabs.update(preferredTabId, { + url: normalizedUrl, + active: true, + }).catch(() => null); + } + + if (!tab) { + tab = await chrome.tabs.create({ url: normalizedUrl, active: true }); + } + + await applyRuntimeUpdates({ + contributionAuthUrl: normalizedUrl, + contributionAuthOpenedAt: Date.now(), + contributionAuthTabId: normalizePositiveInteger(tab?.id, 0), + }); + + return tab; + } + + async function fetchContributionResult(sessionId) { + try { + return await fetchContributionJson(`/result?session_id=${encodeURIComponent(sessionId)}`); + } catch (error) { + if (typeof addLog === 'function') { + await addLog(`贡献模式:获取最终结果失败:${error.message}`, 'warn'); + } + return null; + } + } + + async function submitContributionCallback(callbackUrl, options = {}) { + const currentState = options.stateOverride || await getState(); + const sessionId = normalizeString(currentState.contributionSessionId); + const normalizedUrl = normalizeString(callbackUrl); + + if (!sessionId || !normalizedUrl) { + return currentState; + } + + const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus); + if (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting') { + return currentState; + } + + await applyRuntimeUpdates({ + contributionCallbackUrl: normalizedUrl, + contributionCallbackStatus: 'submitting', + contributionCallbackMessage: buildCallbackMessage('submitting'), + }); + + try { + const payload = await fetchContributionJson('/submit-callback', { + method: 'POST', + body: { + session_id: sessionId, + callback_url: normalizedUrl, + }, + }); + + const nextStatus = looksLikeNoNeedCallback(payload) ? 'not_required' : 'submitted'; + await applyRuntimeUpdates({ + contributionCallbackUrl: normalizedUrl, + contributionCallbackStatus: nextStatus, + contributionCallbackMessage: buildCallbackMessage(nextStatus, payload), + }); + + if (typeof closeLocalhostCallbackTabs === 'function') { + await closeLocalhostCallbackTabs(normalizedUrl).catch(() => {}); + } + + return await pollContributionStatus({ reason: options.reason || 'submit_callback' }); + } catch (error) { + if (looksLikeNoNeedCallback(error?.payload || {})) { + await applyRuntimeUpdates({ + contributionCallbackUrl: normalizedUrl, + contributionCallbackStatus: 'not_required', + contributionCallbackMessage: buildCallbackMessage('not_required', error.payload || {}), + }); + + if (typeof closeLocalhostCallbackTabs === 'function') { + await closeLocalhostCallbackTabs(normalizedUrl).catch(() => {}); + } + + return pollContributionStatus({ reason: options.reason || 'submit_callback_not_required' }).catch(() => getState()); + } + + await applyRuntimeUpdates({ + contributionCallbackUrl: normalizedUrl, + contributionCallbackStatus: 'failed', + contributionCallbackMessage: `回调提交失败:${error.message}`, + }); + + if (typeof addLog === 'function') { + await addLog(`贡献模式:回调提交失败:${error.message}`, 'warn'); + } + + throw error; + } + } + + async function handleCapturedCallback(rawUrl, metadata = {}) { + const currentState = await getState(); + if (!normalizeString(currentState.contributionSessionId) || !currentState.contributionMode) { + return currentState; + } + if (!isContributionCallbackUrl(rawUrl, currentState)) { + return currentState; + } + + const normalizedUrl = normalizeString(rawUrl); + const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus); + if ( + normalizedUrl + && normalizeString(currentState.contributionCallbackUrl) === normalizedUrl + && (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting') + ) { + return currentState; + } + + await applyRuntimeUpdates({ + contributionCallbackUrl: normalizedUrl, + contributionCallbackStatus: 'captured', + contributionCallbackMessage: buildCallbackMessage('captured'), + }); + + if (typeof addLog === 'function') { + await addLog(`贡献模式:已捕获回调地址(${metadata.source || 'unknown'})。`, 'info'); + } + + try { + return await submitContributionCallback(normalizedUrl, { + reason: metadata.source || 'navigation', + stateOverride: await getState(), + }); + } catch { + return getState(); + } + } + + async function pollContributionStatus(options = {}) { + const currentState = options.stateOverride || await getState(); + const sessionId = normalizeString(currentState.contributionSessionId); + if (!sessionId) { + return currentState; + } + + const payload = await fetchContributionJson(`/status?session_id=${encodeURIComponent(sessionId)}`); + const nextStatus = normalizeContributionStatus(payload.status || payload.state || payload.phase) || currentState.contributionStatus || 'waiting'; + let finalPayload = null; + + if (isContributionFinalStatus(nextStatus)) { + finalPayload = await fetchContributionResult(sessionId); + } + + const mergedPayload = finalPayload ? { ...payload, ...finalPayload } : payload; + const normalizedStatus = normalizeContributionStatus(mergedPayload.status || mergedPayload.state || mergedPayload.phase) || nextStatus; + const callbackState = deriveCallbackState(mergedPayload, currentState); + const updates = { + contributionLastPollAt: Date.now(), + contributionStatus: normalizedStatus, + contributionStatusMessage: buildStatusMessage(normalizedStatus, mergedPayload), + contributionCallbackUrl: callbackState.callbackUrl, + contributionCallbackStatus: callbackState.status, + contributionCallbackMessage: callbackState.message, + }; + + const authUrl = normalizeString(mergedPayload.auth_url || mergedPayload.authUrl); + if (authUrl) { + updates.contributionAuthUrl = authUrl; + } + + const authState = normalizeString(mergedPayload.state || mergedPayload.auth_state || mergedPayload.authState) + || (authUrl ? extractAuthStateFromUrl(authUrl) : ''); + if (authState) { + updates.contributionAuthState = authState; + } + + await applyRuntimeUpdates(updates); + const nextState = await getState(); + + if ( + normalizeString(nextState.contributionCallbackUrl) + && CALLBACK_WAITING_STATUSES.has(normalizeContributionCallbackStatus(nextState.contributionCallbackStatus)) + ) { + try { + return await submitContributionCallback(nextState.contributionCallbackUrl, { + reason: options.reason || 'status_poll', + stateOverride: nextState, + }); + } catch { + return getState(); + } + } + + return nextState; + } + + async function startContributionFlow(options = {}) { + const currentState = options.stateOverride || await getState(); + const shouldOpenAuthTab = options.openAuthTab !== false; + if (!currentState.contributionMode) { + throw new Error('请先进入贡献模式。'); + } + + const currentSessionId = normalizeString(currentState.contributionSessionId); + const currentStatus = normalizeContributionStatus(currentState.contributionStatus); + if (currentSessionId && ACTIVE_STATUSES.has(currentStatus)) { + if (normalizeString(currentState.contributionAuthUrl)) { + if (shouldOpenAuthTab) { + await openContributionAuthUrl(currentState.contributionAuthUrl, { + stateOverride: currentState, + }).catch(() => null); + } + } + return pollContributionStatus({ reason: 'resume_existing' }); + } + + const payload = await fetchContributionJson('/start', { + method: 'POST', + body: { + nickname: buildNickname(currentState, options.nickname), + source: 'cpa', + channel: 'codex-extension', + }, + }); + + const sessionId = normalizeString(payload.session_id || payload.sessionId); + const authUrl = normalizeString(payload.auth_url || payload.authUrl); + const authState = normalizeString(payload.state || payload.auth_state || payload.authState) || extractAuthStateFromUrl(authUrl); + if (!sessionId || !authUrl) { + throw new Error('贡献服务未返回有效的 session_id 或 auth_url。'); + } + + await applyRuntimeUpdates({ + contributionSessionId: sessionId, + contributionAuthUrl: authUrl, + contributionAuthState: authState, + contributionCallbackUrl: '', + contributionStatus: normalizeContributionStatus(payload.status) || 'started', + contributionStatusMessage: buildStatusMessage(normalizeContributionStatus(payload.status) || 'started', payload), + contributionLastPollAt: 0, + contributionCallbackStatus: 'waiting', + contributionCallbackMessage: buildCallbackMessage('waiting'), + contributionAuthOpenedAt: 0, + contributionAuthTabId: 0, + }); + + if (shouldOpenAuthTab) { + await openContributionAuthUrl(authUrl); + } + return pollContributionStatus({ reason: 'after_start' }); + } + + function onNavigationEvent(details = {}, source) { + if (details?.frameId !== undefined && Number(details.frameId) !== 0) { + return; + } + handleCapturedCallback(details?.url || '', { + source, + tabId: normalizePositiveInteger(details?.tabId, 0), + }).catch(() => {}); + } + + function onTabUpdated(tabId, changeInfo, tab) { + const candidateUrl = normalizeString(changeInfo?.url || tab?.url); + if (!candidateUrl) { + return; + } + handleCapturedCallback(candidateUrl, { + source: 'tabs.onUpdated', + tabId: normalizePositiveInteger(tabId, 0), + }).catch(() => {}); + } + + function ensureCallbackListeners() { + if (listenersBound) { + return; + } + + chrome.webNavigation.onCommitted.addListener((details) => { + onNavigationEvent(details, 'webNavigation.onCommitted'); + }); + chrome.webNavigation.onHistoryStateUpdated.addListener((details) => { + onNavigationEvent(details, 'webNavigation.onHistoryStateUpdated'); + }); + chrome.tabs.onUpdated.addListener(onTabUpdated); + listenersBound = true; + } + + return { + ensureCallbackListeners, + handleCapturedCallback, + isContributionCallbackUrl, + isContributionFinalStatus, + pollContributionStatus, + startContributionFlow, + submitContributionCallback, + }; + } + + return { + ACTIVE_STATUSES, + FINAL_STATUSES, + RUNTIME_DEFAULTS, + RUNTIME_KEYS, + createContributionOAuthManager, + }; +}); diff --git a/background/message-router.js b/background/message-router.js index 8eeea3f..fb93f4d 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -56,6 +56,7 @@ notifyStepComplete, notifyStepError, patchHotmailAccount, + pollContributionStatus, registerTab, requestStop, handleCloudflareSecurityBlocked, @@ -64,6 +65,7 @@ scheduleAutoRun, selectLuckmailPurchase, setCurrentHotmailAccount, + setContributionMode, setEmailState, setEmailStateSilently, setIcloudAliasPreservedState, @@ -76,6 +78,7 @@ setStepStatus, skipAutoRunCountdown, skipStep, + startContributionFlow, startAutoRunLoop, syncHotmailAccounts, testHotmailAccountMailAccess, @@ -288,6 +291,49 @@ return { ok: true }; } + case 'SET_CONTRIBUTION_MODE': { + const enabled = Boolean(message.payload?.enabled); + const state = await ensureManualInteractionAllowed(enabled ? '进入贡献模式' : '退出贡献模式'); + if (Object.values(state.stepStatuses || {}).some((status) => status === 'running')) { + throw new Error(enabled ? '当前有步骤正在执行,无法进入贡献模式。' : '当前有步骤正在执行,无法退出贡献模式。'); + } + if (typeof setContributionMode !== 'function') { + throw new Error('贡献模式切换能力未接入。'); + } + return { + ok: true, + state: await setContributionMode(enabled), + }; + } + + case 'START_CONTRIBUTION_FLOW': { + const state = await ensureManualInteractionAllowed('开始贡献'); + if (Object.values(state.stepStatuses || {}).some((status) => status === 'running')) { + throw new Error('当前有步骤正在执行,无法开始贡献流程。'); + } + if (typeof startContributionFlow !== 'function') { + throw new Error('贡献 OAuth 流程尚未接入。'); + } + return { + ok: true, + state: await startContributionFlow({ + nickname: message.payload?.nickname, + }), + }; + } + + case 'POLL_CONTRIBUTION_STATUS': { + if (typeof pollContributionStatus !== 'function') { + throw new Error('贡献状态轮询能力尚未接入。'); + } + return { + ok: true, + state: await pollContributionStatus({ + reason: message.payload?.reason || 'sidepanel_poll', + }), + }; + } + case 'CLEAR_ACCOUNT_RUN_HISTORY': { const state = await getState(); if (isAutoRunLockedState(state)) { diff --git a/sidepanel/contribution-mode.js b/sidepanel/contribution-mode.js new file mode 100644 index 0000000..abe59e2 --- /dev/null +++ b/sidepanel/contribution-mode.js @@ -0,0 +1,403 @@ +(function attachSidepanelContributionMode(globalScope) { + const ACTIVE_STATUSES = new Set(['started', 'waiting', 'processing']); + const FINAL_STATUSES = new Set(['auto_approved', 'auto_rejected', 'manual_review_required', 'expired', 'error']); + const DEFAULT_COPY = '当前账号将用于支持项目维护。扩展会自动申请贡献登录地址并持续跟踪授权状态;如检测到回调地址,会自动提交,无需手动复制。'; + + function createContributionModeManager(context = {}) { + const { + state, + dom, + helpers, + runtime, + constants = {}, + } = context; + + const contributionUploadUrl = constants.contributionUploadUrl || 'https://apikey.qzz.io/'; + const pollIntervalMs = Math.max(1500, Math.floor(Number(constants.pollIntervalMs) || 2500)); + + const hiddenRows = [ + dom.rowVpsUrl, + dom.rowVpsPassword, + dom.rowLocalCpaStep9Mode, + dom.rowSub2ApiUrl, + dom.rowSub2ApiEmail, + dom.rowSub2ApiPassword, + dom.rowSub2ApiGroup, + dom.rowSub2ApiDefaultProxy, + dom.rowCustomPassword, + dom.rowAccountRunHistoryTextEnabled, + dom.rowAccountRunHistoryHelperBaseUrl, + ].filter(Boolean); + + let actionInFlight = false; + let pollInFlight = false; + let pollTimer = null; + + function getLatestState() { + return state.getLatestState?.() || {}; + } + + function normalizeString(value = '') { + return String(value || '').trim(); + } + + function normalizeStatus(value = '') { + const normalized = normalizeString(value).toLowerCase(); + if (ACTIVE_STATUSES.has(normalized) || FINAL_STATUSES.has(normalized)) { + return normalized; + } + return ''; + } + + function normalizeCallbackStatus(value = '') { + const normalized = normalizeString(value).toLowerCase(); + switch (normalized) { + case 'waiting': + case 'captured': + case 'submitting': + case 'submitted': + case 'not_required': + case 'failed': + case 'idle': + return normalized; + default: + return ''; + } + } + + function isContributionModeEnabled(currentState = getLatestState()) { + return Boolean(currentState.contributionMode); + } + + function hasActiveContributionSession(currentState = getLatestState()) { + const status = normalizeStatus(currentState.contributionStatus); + return Boolean(normalizeString(currentState.contributionSessionId) && status && !FINAL_STATUSES.has(status)); + } + + function isModeSwitchBlocked() { + return Boolean(helpers.isModeSwitchBlocked?.(getLatestState())); + } + + function setContributionHidden(element, hidden) { + element?.classList.toggle('is-contribution-hidden', hidden); + } + + function syncContributionRows(enabled) { + hiddenRows.forEach((row) => { + setContributionHidden(row, enabled); + }); + } + + function syncContributionButton(enabled, blocked) { + if (!dom.btnContributionMode) { + return; + } + + dom.btnContributionMode.classList.toggle('is-active', enabled); + dom.btnContributionMode.setAttribute('aria-pressed', String(enabled)); + dom.btnContributionMode.disabled = enabled || blocked; + dom.btnContributionMode.title = blocked + ? '当前流程运行中,暂时不能切换贡献模式' + : (enabled ? '当前已在贡献模式' : '进入贡献模式'); + } + + function stopPolling() { + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } + } + + function schedulePolling(delayMs = pollIntervalMs) { + stopPolling(); + if (!isContributionModeEnabled() || !hasActiveContributionSession()) { + return; + } + + pollTimer = setTimeout(() => { + pollOnce({ silentError: true }).catch(() => {}); + }, delayMs); + } + + function ensurePolling() { + if (!isContributionModeEnabled() || !hasActiveContributionSession()) { + stopPolling(); + return; + } + + if (!pollTimer && !pollInFlight) { + schedulePolling(1200); + } + } + + function getOauthStatusText(currentState = getLatestState()) { + const status = normalizeStatus(currentState.contributionStatus); + const hasAuthUrl = Boolean(normalizeString(currentState.contributionAuthUrl)); + if (!normalizeString(currentState.contributionSessionId) || !hasAuthUrl) { + return '未生成登录地址'; + } + if (status === 'waiting') { + return '等待授权完成'; + } + if (status === 'processing' || status === 'auto_approved' || status === 'auto_rejected' || status === 'manual_review_required') { + return '授权已完成'; + } + if (status === 'expired' || status === 'error') { + return '授权失败'; + } + if (Number(currentState.contributionAuthOpenedAt) > 0) { + return '已打开授权页'; + } + return '登录地址已生成'; + } + + function getCallbackStatusText(currentState = getLatestState()) { + const status = normalizeCallbackStatus(currentState.contributionCallbackStatus); + switch (status) { + case 'captured': + return '已捕获回调地址'; + case 'submitting': + return '正在提交回调'; + case 'submitted': + return '已提交回调'; + case 'not_required': + return '当前流程无需手动回调'; + case 'failed': + return '回调提交失败'; + case 'waiting': + case 'idle': + default: + return normalizeString(currentState.contributionCallbackUrl) + ? '已捕获回调地址' + : '等待回调'; + } + } + + function getSummaryText(currentState = getLatestState()) { + return normalizeString(currentState.contributionStatusMessage) || DEFAULT_COPY; + } + + async function requestContributionMode(enabled) { + const response = await runtime.sendMessage({ + type: 'SET_CONTRIBUTION_MODE', + source: 'sidepanel', + payload: { enabled: Boolean(enabled) }, + }); + + if (response?.error) { + throw new Error(response.error); + } + if (!response?.state) { + throw new Error('贡献模式切换后未返回最新状态。'); + } + + helpers.applySettingsState?.(response.state); + helpers.updateStatusDisplay?.(response.state); + render(); + } + + async function pollOnce(options = {}) { + if (pollInFlight || !isContributionModeEnabled() || !hasActiveContributionSession()) { + if (!hasActiveContributionSession()) { + stopPolling(); + } + return; + } + + pollInFlight = true; + try { + const response = await runtime.sendMessage({ + type: 'POLL_CONTRIBUTION_STATUS', + source: 'sidepanel', + payload: { + reason: options.reason || 'sidepanel_poll', + }, + }); + + if (response?.error) { + throw new Error(response.error); + } + if (response?.state) { + helpers.applySettingsState?.(response.state); + helpers.updateStatusDisplay?.(response.state); + } + } finally { + pollInFlight = false; + render(); + if (hasActiveContributionSession()) { + schedulePolling(); + } else { + stopPolling(); + } + } + } + + async function startContributionFlow() { + if (typeof helpers.startContributionAutoRun !== 'function') { + throw new Error('贡献模式尚未接入主自动流程启动能力。'); + } + + const started = await helpers.startContributionAutoRun(); + if (!started) { + return; + } + + helpers.showToast?.('贡献自动流程已启动。', 'info', 1800); + render(); + } + + async function enterContributionMode() { + const confirmed = await helpers.openConfirmModal?.({ + title: '贡献账号', + message: '是否确认给作者 QLHazyCoder 提供账号以支持继续维护项目?', + confirmLabel: '确定', + confirmVariant: 'btn-primary', + }); + + if (!confirmed) { + return; + } + + await requestContributionMode(true); + helpers.showToast?.('已进入贡献模式。', 'success', 1800); + } + + async function exitContributionMode() { + stopPolling(); + await requestContributionMode(false); + helpers.showToast?.('已退出贡献模式。', 'info', 1800); + } + + function render() { + const currentState = getLatestState(); + const enabled = isContributionModeEnabled(currentState); + const blocked = isModeSwitchBlocked(); + + if (enabled && dom.selectPanelMode) { + dom.selectPanelMode.value = 'cpa'; + } + + helpers.updatePanelModeUI?.(); + helpers.updateAccountRunHistorySettingsUI?.(); + + if (dom.contributionModePanel) { + dom.contributionModePanel.hidden = !enabled; + } + if (dom.contributionModeText) { + dom.contributionModeText.textContent = DEFAULT_COPY; + } + if (dom.contributionOauthStatus) { + dom.contributionOauthStatus.textContent = getOauthStatusText(currentState); + } + if (dom.contributionCallbackStatus) { + dom.contributionCallbackStatus.textContent = getCallbackStatusText(currentState); + } + if (dom.contributionModeSummary) { + dom.contributionModeSummary.textContent = getSummaryText(currentState); + } + + syncContributionRows(enabled); + syncContributionButton(enabled, blocked); + + if (dom.selectPanelMode) { + dom.selectPanelMode.disabled = enabled; + } + + if (dom.btnStartContribution) { + dom.btnStartContribution.disabled = actionInFlight || blocked; + } + + if (dom.btnOpenContributionUpload) { + dom.btnOpenContributionUpload.disabled = false; + } + + if (dom.btnExitContributionMode) { + dom.btnExitContributionMode.disabled = actionInFlight || blocked; + dom.btnExitContributionMode.title = blocked ? '当前流程运行中,暂时不能退出贡献模式' : '退出贡献模式'; + } + + if (dom.btnOpenAccountRecords) { + dom.btnOpenAccountRecords.disabled = enabled; + } + + if (enabled) { + helpers.closeConfigMenu?.(); + helpers.closeAccountRecordsPanel?.(); + ensurePolling(); + } else { + stopPolling(); + } + + helpers.updateConfigMenuControls?.(); + } + + function bindEvents() { + dom.btnContributionMode?.addEventListener('click', async () => { + if (actionInFlight) { + return; + } + actionInFlight = true; + render(); + try { + await enterContributionMode(); + } catch (error) { + helpers.showToast?.(error.message, 'error'); + } finally { + actionInFlight = false; + render(); + } + }); + + dom.btnStartContribution?.addEventListener('click', async () => { + if (actionInFlight) { + return; + } + actionInFlight = true; + render(); + try { + await startContributionFlow(); + } catch (error) { + helpers.showToast?.(error.message, 'error'); + } finally { + actionInFlight = false; + render(); + } + }); + + dom.btnOpenContributionUpload?.addEventListener('click', () => { + try { + helpers.openExternalUrl?.(contributionUploadUrl); + } catch (error) { + helpers.showToast?.(`打开上传页面失败:${error.message}`, 'error'); + } + }); + + dom.btnExitContributionMode?.addEventListener('click', async () => { + if (actionInFlight) { + return; + } + actionInFlight = true; + render(); + try { + await exitContributionMode(); + } catch (error) { + helpers.showToast?.(error.message, 'error'); + } finally { + actionInFlight = false; + render(); + } + }); + } + + return { + bindEvents, + pollOnce, + render, + stopPolling, + }; + } + + globalScope.SidepanelContributionMode = { + createContributionModeManager, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index 55aedf5..afa9b9e 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -96,7 +96,9 @@ body { header { display: flex; justify-content: space-between; - align-items: center; + align-items: flex-start; + flex-wrap: wrap; + gap: 10px; position: sticky; top: 0; z-index: 200; @@ -183,6 +185,18 @@ header { align-items: center; gap: 4px; flex-shrink: 0; + flex-wrap: wrap; + justify-content: flex-end; +} + +.btn-contribution-mode { + padding-inline: 10px; +} + +.btn-contribution-mode.is-active { + border-color: color-mix(in srgb, var(--orange) 58%, var(--border)); + background: color-mix(in srgb, var(--orange) 14%, var(--bg-base)); + color: var(--orange); } /* ============================================================ @@ -310,6 +324,7 @@ header { display: flex; align-items: center; gap: 4px; + flex-wrap: wrap; } .run-count-input { @@ -491,6 +506,108 @@ header { pointer-events: none; } +.is-contribution-hidden { + display: none !important; +} + +.contribution-mode-panel { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + border: 1px solid color-mix(in srgb, var(--orange) 28%, var(--border)); + border-radius: var(--radius-md); + background: + linear-gradient(180deg, + color-mix(in srgb, var(--orange) 10%, var(--bg-base)), + color-mix(in srgb, var(--bg-surface) 96%, var(--bg-base)) + ); +} + +.contribution-mode-panel[hidden] { + display: none !important; +} + +.contribution-mode-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.contribution-mode-badge { + padding: 4px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--orange) 16%, var(--bg-base)); + color: var(--orange); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; +} + +.contribution-mode-text { + margin: 0; + font-size: 13px; + line-height: 1.6; + color: var(--text-secondary); +} + +.contribution-mode-status-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.contribution-mode-status-card { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-base) 92%, var(--orange-soft)); +} + +.contribution-mode-status-label { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + color: var(--text-muted); +} + +.contribution-mode-status-value { + font-size: 13px; + font-weight: 600; + line-height: 1.5; + color: var(--text-primary); +} + +.contribution-mode-summary { + padding: 10px 12px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-base) 88%, var(--orange-soft)); + color: var(--text-secondary); + font-size: 12px; + line-height: 1.6; +} + +.contribution-mode-actions { + display: flex; + flex-direction: column; + gap: 8px; +} + +.contribution-mode-actions .btn { + width: 100%; + justify-content: center; +} + +@media (max-width: 540px) { + .contribution-mode-status-grid { + grid-template-columns: 1fr; + } +} + .section-mini-header { display: flex; align-items: center; diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index f405a4e..e0af5e8 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -46,6 +46,8 @@ + 贡献 @@ -98,6 +100,29 @@ SUB2API + + + 贡献模式 + CPA + + 当前账号将用于支持项目维护。扩展会自动申请贡献登录地址并持续跟踪授权状态;如检测到回调地址,会自动提交,无需手动复制。 + + + OAUTH + 未生成登录地址 + + + 回调 + 等待回调 + + + 等待开始贡献 + + 开始贡献 + 已有认证文件?前往上传 + 退出贡献模式 + + CPA @@ -145,7 +170,7 @@ - + 账户密码 - + 本地同步 - + - + + + + 验证码 + + 验证码重发 +
当前账号将用于支持项目维护。扩展会自动申请贡献登录地址并持续跟踪授权状态;如检测到回调地址,会自动提交,无需手动复制。