diff --git a/background.js b/background.js index 5c5fbcc..68617f3 100644 --- a/background.js +++ b/background.js @@ -1,13 +1,14 @@ // background.js — Service Worker: orchestration, state, tab management, message routing importScripts( - 'mail-provider-utils.js', 'managed-alias-utils.js', 'mail2925-utils.js', + 'paypal-utils.js', 'background/phone-verification-flow.js', 'background/account-run-history.js', 'background/contribution-oauth.js', 'background/mail-2925-session.js', + 'background/paypal-account-store.js', 'background/ip-proxy-provider-711proxy.js', 'background/ip-proxy-core.js', 'background/panel-bridge.js', @@ -20,18 +21,18 @@ importScripts( 'background/navigation-utils.js', 'background/logging-status.js', 'background/steps/registry.js', - 'data/address-sources.js', 'data/step-definitions.js', + 'data/address-sources.js', 'background/steps/open-chatgpt.js', 'background/steps/submit-signup-email.js', 'background/steps/fill-password.js', 'background/steps/fetch-signup-code.js', 'background/steps/fill-profile.js', + 'background/steps/clear-login-cookies.js', 'background/steps/create-plus-checkout.js', 'background/steps/fill-plus-checkout.js', 'background/steps/paypal-approve.js', 'background/steps/plus-return-confirm.js', - 'background/steps/clear-login-cookies.js', 'background/steps/oauth-login.js', 'background/steps/fetch-login-code.js', 'background/steps/confirm-oauth.js', @@ -42,18 +43,32 @@ importScripts( 'luckmail-utils.js', 'cloudflare-temp-email-utils.js', 'icloud-utils.js', + 'mail-provider-utils.js', 'content/activation-utils.js' ); -const SHARED_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getAllSteps?.() - || self.MultiPageStepDefinitions?.getSteps?.() - || []; -const DEFAULT_STEP_IDS = (self.MultiPageStepDefinitions?.getStepIds?.() || SHARED_STEP_DEFINITIONS +const NORMAL_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getSteps?.({ plusModeEnabled: false }) || []; +const PLUS_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getSteps?.({ plusModeEnabled: true }) || NORMAL_STEP_DEFINITIONS; +const ALL_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getAllSteps?.() || [ + ...NORMAL_STEP_DEFINITIONS, + ...PLUS_STEP_DEFINITIONS, +]; +const STEP_IDS = Array.from(new Set(ALL_STEP_DEFINITIONS + .map((definition) => Number(definition?.id)) + .filter(Number.isFinite))) + .sort((left, right) => left - right); +const NORMAL_STEP_IDS = NORMAL_STEP_DEFINITIONS .map((definition) => Number(definition?.id)) .filter(Number.isFinite) - .sort((left, right) => left - right)); -const STEP_IDS = DEFAULT_STEP_IDS; -const LAST_STEP_ID = DEFAULT_STEP_IDS[DEFAULT_STEP_IDS.length - 1] || 10; + .sort((left, right) => left - right); +const PLUS_STEP_IDS = PLUS_STEP_DEFINITIONS + .map((definition) => Number(definition?.id)) + .filter(Number.isFinite) + .sort((left, right) => left - right); +const LAST_STEP_ID = Math.max( + NORMAL_STEP_IDS[NORMAL_STEP_IDS.length - 1] || 10, + PLUS_STEP_IDS[PLUS_STEP_IDS.length - 1] || 10 +); const FINAL_OAUTH_CHAIN_START_STEP = 7; const { @@ -131,21 +146,19 @@ const { getIcloudSetupUrlForHost, normalizeBooleanMap, normalizeIcloudAliasList, + normalizeIcloudAliasRecord, normalizeIcloudHost, pickReusableIcloudAlias, toNormalizedEmailSet, } = self.IcloudUtils; const { - isRecoverableStep9AuthFailure, -} = self.MultiPageActivationUtils; -const { - getIcloudForwardMailConfig, + getIcloudForwardMailConfig: getSharedIcloudForwardMailConfig, normalizeIcloudForwardMailProvider, normalizeIcloudTargetMailboxType, -} = self.MailProviderUtils || {}; +} = self.MailProviderUtils; const { - getAddressSeedForCountry, -} = self.MultiPageAddressSources || {}; + isRecoverableStep9AuthFailure, +} = self.MultiPageActivationUtils; const LOG_PREFIX = '[MultiPage:bg]'; const DUCK_AUTOFILL_URL = 'https://duckduckgo.com/email/settings/autofill'; @@ -157,6 +170,22 @@ const ICLOUD_LOGIN_URLS = [ 'https://www.icloud.com.cn/', 'https://www.icloud.com/', ]; +const ICLOUD_REQUEST_TIMEOUT_MS = 15000; +const ICLOUD_LIST_MAX_ATTEMPTS = 3; +const ICLOUD_WRITE_MAX_ATTEMPTS = 2; +const ICLOUD_RETRY_DELAYS_MS = [1000, 2500, 5000]; +const ICLOUD_TAB_URL_PATTERNS = [ + 'https://www.icloud.com/*', + 'https://www.icloud.com.cn/*', + 'https://setup.icloud.com/*', + 'https://setup.icloud.com.cn/*', + 'https://*.icloud.com/*', + 'https://*.icloud.com.cn/*', +]; +const ICLOUD_MAILDOMAINWS_CLIENT_BUILD_NUMBER = '2206Hotfix11'; +const ICLOUD_ALIAS_CACHE_MAX_AGE_MS = 6 * 60 * 60 * 1000; +const ICLOUD_TRANSIENT_RETRY_MAX_ATTEMPTS = 2; +const ICLOUD_TRANSIENT_RETRY_DELAY_MS = 1200; const ICLOUD_PROVIDER = 'icloud'; const GMAIL_PROVIDER = 'gmail'; const GMAIL_ALIAS_GENERATOR = 'gmail-alias'; @@ -181,6 +210,10 @@ const DEFAULT_SUB2API_URL = 'https://sub2api.hisence.fun/admin/accounts'; const DEFAULT_CODEX2API_URL = 'http://localhost:8080/admin/accounts'; const DEFAULT_SUB2API_GROUP_NAME = 'codex'; const DEFAULT_SUB2API_PROXY_NAME = ''; +const CONTRIBUTION_SOURCE_CPA = 'cpa'; +const CONTRIBUTION_SOURCE_SUB2API = 'sub2api'; +const CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME = 'codex号池'; +const CONTRIBUTION_SUB2API_PLUS_GROUP_NAME = 'openai-plus'; const DEFAULT_SUB2API_REDIRECT_URI = 'http://localhost:1455/auth/callback'; const DEFAULT_IP_PROXY_SERVICE = '711proxy'; const IP_PROXY_SERVICE_VALUES = ['711proxy', 'lumiproxy', 'iproyal', 'omegaproxy']; @@ -252,11 +285,18 @@ const HERO_SMS_COUNTRY_ID = 52; const HERO_SMS_COUNTRY_LABEL = 'Thailand'; const DISPLAY_TIMEZONE = 'Asia/Shanghai'; const MICROSOFT_TOKEN_DNR_RULE_ID = 1001; -const PERSISTENT_ALIAS_STATE_KEYS = ['manualAliasUsage', 'preservedAliases']; +const PERSISTENT_ALIAS_STATE_KEYS = [ + 'manualAliasUsage', + 'preservedAliases', + 'icloudAliasCache', + 'icloudAliasCacheAt', +]; const ACCOUNT_RUN_HISTORY_STORAGE_KEY = 'accountRunHistory'; const CONTRIBUTION_RUNTIME_DEFAULTS = self.MultiPageBackgroundContributionOAuth?.RUNTIME_DEFAULTS || { contributionMode: false, contributionModeExpected: false, + contributionSource: CONTRIBUTION_SOURCE_SUB2API, + contributionTargetGroupName: CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME, contributionNickname: '', contributionQq: '', contributionSessionId: '', @@ -274,6 +314,66 @@ const CONTRIBUTION_RUNTIME_DEFAULTS = self.MultiPageBackgroundContributionOAuth? const CONTRIBUTION_RUNTIME_KEYS = self.MultiPageBackgroundContributionOAuth?.RUNTIME_KEYS || Object.keys(CONTRIBUTION_RUNTIME_DEFAULTS); +function isPlusModeState(state = {}) { + return Boolean(state?.plusModeEnabled); +} + +function normalizeContributionModeSource(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + return normalized === CONTRIBUTION_SOURCE_SUB2API + ? CONTRIBUTION_SOURCE_SUB2API + : CONTRIBUTION_SOURCE_CPA; +} + +function resolveContributionModeRoutingState(state = {}) { + const currentStatus = String(state?.contributionStatus || '').trim().toLowerCase(); + const currentSource = normalizeContributionModeSource(state?.contributionSource); + const hasActiveSession = Boolean( + String(state?.contributionSessionId || '').trim() + && currentStatus + && !['auto_approved', 'auto_rejected', 'expired', 'error'].includes(currentStatus) + ); + + if (hasActiveSession) { + return { + source: currentSource, + targetGroupName: currentSource === CONTRIBUTION_SOURCE_SUB2API + ? (String(state?.contributionTargetGroupName || '').trim() || CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME) + : '', + }; + } + + const source = CONTRIBUTION_SOURCE_SUB2API; + return { + source, + targetGroupName: isPlusModeState(state) + ? CONTRIBUTION_SUB2API_PLUS_GROUP_NAME + : (String(state?.contributionTargetGroupName || '').trim() || CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME), + }; +} + +function getStepDefinitionsForState(state = {}) { + return isPlusModeState(state) ? PLUS_STEP_DEFINITIONS : NORMAL_STEP_DEFINITIONS; +} + +function getStepIdsForState(state = {}) { + return isPlusModeState(state) ? PLUS_STEP_IDS : NORMAL_STEP_IDS; +} + +function getLastStepIdForState(state = {}) { + const ids = getStepIdsForState(state); + return ids[ids.length - 1] || 10; +} + +function getAuthChainStartStepId(state = {}) { + return isPlusModeState(state) ? 10 : FINAL_OAUTH_CHAIN_START_STEP; +} + +function getStepDefinitionForState(step, state = {}) { + const numericStep = Number(step); + return getStepDefinitionsForState(state).find((definition) => Number(definition.id) === numericStep) || null; +} + initializeSessionStorageAccess(); setupDeclarativeNetRequestRules(); @@ -335,6 +435,10 @@ const PERSISTED_SETTING_DEFAULTS = { codex2apiUrl: DEFAULT_CODEX2API_URL, codex2apiAdminKey: '', customPassword: '', + plusModeEnabled: false, + paypalEmail: '', + paypalPassword: '', + currentPayPalAccountId: '', autoRunSkipFailures: false, autoRunFallbackThreadIntervalMinutes: 0, autoRunDelayEnabled: false, @@ -342,9 +446,6 @@ const PERSISTED_SETTING_DEFAULTS = { autoStepDelaySeconds: null, phoneVerificationEnabled: false, verificationResendCount: DEFAULT_VERIFICATION_RESEND_COUNT, - plusModeEnabled: false, - paypalEmail: '', - paypalPassword: '', mailProvider: '163', mail2925Mode: DEFAULT_MAIL_2925_MODE, mail2925UseAccountPool: false, @@ -378,7 +479,9 @@ const PERSISTED_SETTING_DEFAULTS = { cloudflareTempEmailDomains: [], hotmailAccounts: [], mail2925Accounts: [], + paypalAccounts: [], heroSmsApiKey: '', + heroSmsMaxPrice: '', heroSmsCountryId: HERO_SMS_COUNTRY_ID, heroSmsCountryLabel: HERO_SMS_COUNTRY_LABEL, }; @@ -415,6 +518,8 @@ const DEFAULT_STATE = { accountRunHistory: [], // 账号运行历史快照,实际持久化在 chrome.storage.local。 manualAliasUsage: {}, preservedAliases: {}, + icloudAliasCache: [], + icloudAliasCacheAt: 0, lastEmailTimestamp: null, // 最近一次获取到邮箱数据的运行时时间戳。 lastSignupCode: null, // 注册验证码,运行时由程序自动读取并写入。 lastLoginCode: null, // 登录验证码,运行时由程序自动读取并写入。 @@ -428,6 +533,14 @@ const DEFAULT_STATE = { sub2apiProxyId: null, // SUB2API 本轮使用的代理 ID。 codex2apiSessionId: null, // Codex2API OAuth 会话 ID。 codex2apiOAuthState: null, // Codex2API OAuth state。 + plusCheckoutTabId: null, // Plus checkout / PayPal 标签页 ID。 + plusCheckoutUrl: null, // Plus checkout 运行时短链,不写入持久配置。 + plusCheckoutCountry: 'DE', + plusCheckoutCurrency: 'EUR', + plusBillingCountryText: '', + plusBillingAddress: null, + plusPaypalApprovedAt: null, + plusReturnUrl: '', flowStartTime: null, // 当前流程开始时间。 tabRegistry: {}, // 程序维护的标签页注册表。 sourceLastUrls: {}, // 各来源页面最近一次打开的地址记录。 @@ -453,6 +566,7 @@ const DEFAULT_STATE = { currentLuckmailMailCursor: null, currentPhoneActivation: null, reusablePhoneActivation: null, + pendingPhoneActivationConfirmation: null, autoRunning: false, // 当前是否处于自动运行中。 autoRunPhase: 'idle', // 当前自动运行阶段。 autoRunCurrentRun: 0, // 自动运行当前执行到第几轮。 @@ -469,6 +583,7 @@ const DEFAULT_STATE = { loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, oauthFlowDeadlineSourceUrl: null, + currentPayPalAccountId: null, currentHotmailAccountId: null, currentMail2925AccountId: null, preferredIcloudHost: '', @@ -845,39 +960,6 @@ function normalizePanelMode(value = '') { return 'cpa'; } -function isPlusModeState(state = {}) { - return Boolean(state?.plusModeEnabled); -} - -function getStepDefinitionsForState(state = {}) { - return self.MultiPageStepDefinitions?.getSteps?.({ plusModeEnabled: isPlusModeState(state) }) - || SHARED_STEP_DEFINITIONS; -} - -function getStepIdsForState(state = {}) { - return self.MultiPageStepDefinitions?.getStepIds?.({ plusModeEnabled: isPlusModeState(state) }) - || getStepDefinitionsForState(state) - .map((definition) => Number(definition?.id)) - .filter(Number.isFinite) - .sort((left, right) => left - right); -} - -function getLastStepIdForState(state = {}) { - const fromModule = self.MultiPageStepDefinitions?.getLastStepId?.({ plusModeEnabled: isPlusModeState(state) }); - if (Number.isFinite(fromModule) && fromModule > 0) { - return Number(fromModule); - } - const stepIds = getStepIdsForState(state); - return stepIds[stepIds.length - 1] || LAST_STEP_ID; -} - -function getStepDefinitionForState(step, state = {}) { - const numericStep = Number(step); - const definition = getStepDefinitionsForState(state) - .find((item) => Number(item?.id) === numericStep); - return definition || null; -} - function normalizeMailProvider(value = '') { const normalized = String(value || '').trim().toLowerCase(); switch (normalized) { @@ -1169,6 +1251,8 @@ function normalizePersistentSettingValue(key, value) { return String(value || '').trim(); case 'paypalPassword': return String(value || ''); + case 'currentPayPalAccountId': + return String(value || '').trim(); case 'autoRunSkipFailures': case 'autoRunDelayEnabled': case 'phoneVerificationEnabled': @@ -1200,17 +1284,9 @@ function normalizePersistentSettingValue(key, value) { case 'icloudHostPreference': return normalizeIcloudHost(value) || 'auto'; case 'icloudTargetMailboxType': - return typeof normalizeIcloudTargetMailboxType === 'function' - ? normalizeIcloudTargetMailboxType(value) - : String(value || '').trim().toLowerCase() === 'forward-mailbox' - ? 'forward-mailbox' - : 'icloud-inbox'; + return normalizeIcloudTargetMailboxType(value); case 'icloudForwardMailProvider': - return typeof normalizeIcloudForwardMailProvider === 'function' - ? normalizeIcloudForwardMailProvider(value) - : String(value || '').trim().toLowerCase() === 'gmail' - ? 'gmail' - : 'qq'; + return normalizeIcloudForwardMailProvider(value); case 'icloudFetchMode': return normalizeIcloudFetchMode(value); case 'accountRunHistoryHelperBaseUrl': @@ -1249,8 +1325,12 @@ function normalizePersistentSettingValue(key, value) { return normalizeHotmailAccounts(value); case 'mail2925Accounts': return normalizeMail2925Accounts(value); + case 'paypalAccounts': + return normalizePayPalAccounts(value); case 'heroSmsApiKey': return String(value || ''); + case 'heroSmsMaxPrice': + return String(value || '').trim(); case 'heroSmsCountryId': return Math.max(1, Math.floor(Number(value) || HERO_SMS_COUNTRY_ID)); case 'heroSmsCountryLabel': @@ -1355,15 +1435,24 @@ async function getPersistedSettings() { async function getPersistedAliasState() { try { const stored = await chrome.storage.local.get(PERSISTENT_ALIAS_STATE_KEYS); + const manualAliasUsage = normalizeBooleanMap(stored.manualAliasUsage); + const preservedAliases = normalizeBooleanMap(stored.preservedAliases); return { - manualAliasUsage: normalizeBooleanMap(stored.manualAliasUsage), - preservedAliases: normalizeBooleanMap(stored.preservedAliases), + manualAliasUsage, + preservedAliases, + icloudAliasCache: normalizeIcloudAliasCacheList(stored.icloudAliasCache, { + usedEmails: toNormalizedEmailSet(manualAliasUsage), + preservedEmails: toNormalizedEmailSet(preservedAliases), + }), + icloudAliasCacheAt: Math.max(0, Number(stored.icloudAliasCacheAt) || 0), }; } catch (err) { console.warn(LOG_PREFIX, 'Failed to read persisted iCloud alias state:', err?.message || err); return { manualAliasUsage: {}, preservedAliases: {}, + icloudAliasCache: [], + icloudAliasCacheAt: 0, }; } } @@ -1375,7 +1464,7 @@ async function getState() { getPersistedAliasState(), accountRunHistoryHelpers?.getPersistedAccountRunHistory?.() || [], ]); - return { ...DEFAULT_STATE, ...persistedSettings, ...persistedAliasState, accountRunHistory, ...state }; + return { ...DEFAULT_STATE, ...persistedSettings, ...persistedAliasState, ...state, accountRunHistory }; } async function initializeSessionStorageAccess() { @@ -1402,6 +1491,12 @@ async function setState(updates) { if (Object.prototype.hasOwnProperty.call(updates, 'preservedAliases')) { persistentAliasUpdates.preservedAliases = normalizeBooleanMap(updates.preservedAliases); } + if (Object.prototype.hasOwnProperty.call(updates, 'icloudAliasCache')) { + persistentAliasUpdates.icloudAliasCache = normalizeIcloudAliasCacheList(updates.icloudAliasCache); + } + if (Object.prototype.hasOwnProperty.call(updates, 'icloudAliasCacheAt')) { + persistentAliasUpdates.icloudAliasCacheAt = Math.max(0, Number(updates.icloudAliasCacheAt) || 0); + } if (Object.keys(persistentAliasUpdates).length > 0) { await chrome.storage.local.set(persistentAliasUpdates); } @@ -1509,57 +1604,26 @@ async function setPasswordState(password) { } function buildContributionModeState(enabled, persistedSettings = {}, currentState = {}) { - function normalizeContributionModeSource(value = '') { - const normalized = String(value || '').trim().toLowerCase(); - return normalized === 'sub2api' ? 'sub2api' : 'cpa'; - } - - function resolveContributionModeRoutingState(state = {}) { - const currentStatus = String(state?.contributionStatus || '').trim().toLowerCase(); - const currentSource = normalizeContributionModeSource(state?.contributionSource || state?.panelMode); - const hasActiveSession = Boolean( - String(state?.contributionSessionId || '').trim() - && currentStatus - && !['auto_approved', 'auto_rejected', 'expired', 'error'].includes(currentStatus) - ); - if (hasActiveSession) { - return { - source: currentSource, - targetGroupName: currentSource === 'sub2api' - ? (String(state?.contributionTargetGroupName || '').trim() || 'codex号池') - : '', - }; - } - - const source = 'sub2api'; - return { - source, - targetGroupName: isPlusModeState(state) - ? 'openai-plus' - : (String(state?.contributionTargetGroupName || '').trim() || 'codex号池'), - }; - } - const currentContributionState = {}; for (const key of CONTRIBUTION_RUNTIME_KEYS) { currentContributionState[key] = currentState[key] !== undefined ? currentState[key] : CONTRIBUTION_RUNTIME_DEFAULTS[key]; } - const routingState = resolveContributionModeRoutingState({ - ...persistedSettings, - ...currentState, - ...currentContributionState, - }); if (enabled) { + const routing = resolveContributionModeRoutingState({ + ...persistedSettings, + ...currentState, + ...currentContributionState, + }); return { ...currentContributionState, contributionMode: true, contributionModeExpected: true, - contributionSource: routingState.source, - contributionTargetGroupName: routingState.targetGroupName, - panelMode: routingState.source, + contributionSource: routing.source, + contributionTargetGroupName: routing.targetGroupName, + panelMode: routing.source, customPassword: '', accountRunHistoryTextEnabled: false, }; @@ -1569,8 +1633,6 @@ function buildContributionModeState(enabled, persistedSettings = {}, currentStat ...CONTRIBUTION_RUNTIME_DEFAULTS, contributionMode: false, contributionModeExpected: false, - contributionSource: routingState.source, - contributionTargetGroupName: routingState.targetGroupName, panelMode: persistedSettings.panelMode || DEFAULT_STATE.panelMode, customPassword: persistedSettings.customPassword || '', accountRunHistoryTextEnabled: Boolean(persistedSettings.accountRunHistoryTextEnabled), @@ -1584,19 +1646,7 @@ async function setContributionMode(enabled) { getState(), ]); - const contributionRoutingSource = normalizedEnabled - ? (String(currentState?.contributionSource || persistedSettings?.panelMode || '').trim().toLowerCase() === 'sub2api' - ? 'sub2api' - : 'sub2api') - : (persistedSettings.panelMode || DEFAULT_STATE.panelMode); - if (normalizedEnabled) { - await setPersistentSettings({ panelMode: contributionRoutingSource }); - } - - const updates = buildContributionModeState(normalizedEnabled, { - ...persistedSettings, - ...(normalizedEnabled ? { panelMode: contributionRoutingSource } : {}), - }, currentState); + const updates = buildContributionModeState(normalizedEnabled, persistedSettings, currentState); await setState(updates); const nextState = await getState(); @@ -1712,6 +1762,80 @@ function getEffectiveUsedEmails(state) { return toNormalizedEmailSet(getManualAliasUsageMap(state)); } +function normalizeIcloudAliasCacheList(value = [], options = {}) { + const aliases = Array.isArray(value) ? value : []; + const usedEmails = toNormalizedEmailSet(options.usedEmails); + const preservedEmails = toNormalizedEmailSet(options.preservedEmails); + return aliases + .map((alias) => normalizeIcloudAliasRecord(alias, { usedEmails, preservedEmails })) + .filter(Boolean) + .sort((left, right) => { + if (left.active !== right.active) return left.active ? -1 : 1; + if (left.used !== right.used) return left.used ? 1 : -1; + return String(left.email).localeCompare(String(right.email)); + }); +} + +function getIcloudAliasCacheFromState(state, options = {}) { + const maxAgeMs = Math.max(0, Number(options.maxAgeMs) || ICLOUD_ALIAS_CACHE_MAX_AGE_MS); + const cachedAt = Number(state?.icloudAliasCacheAt || 0); + if (!Array.isArray(state?.icloudAliasCache) || state.icloudAliasCache.length <= 0) { + return []; + } + if (maxAgeMs > 0 && cachedAt > 0 && Date.now() - cachedAt > maxAgeMs) { + return []; + } + return normalizeIcloudAliasCacheList(state.icloudAliasCache, { + usedEmails: getEffectiveUsedEmails(state), + preservedEmails: getPreservedAliasMap(state), + }); +} + +function isLikelyIcloudAliasEmail(value = '') { + const email = String(value || '').trim().toLowerCase(); + if (!email || !email.includes('@')) { + return false; + } + return /@(icloud\.com|me\.com|mac\.com|privaterelay\.appleid\.com)$/.test(email); +} + +function buildIcloudAliasFallbackFromLocalState(state = {}) { + const manualAliasUsage = getManualAliasUsageMap(state); + const preservedAliases = getPreservedAliasMap(state); + const candidates = new Set(); + + for (const email of Object.keys(manualAliasUsage)) { + if (isLikelyIcloudAliasEmail(email)) { + candidates.add(String(email).trim().toLowerCase()); + } + } + for (const email of Object.keys(preservedAliases)) { + if (isLikelyIcloudAliasEmail(email)) { + candidates.add(String(email).trim().toLowerCase()); + } + } + + const currentEmail = String(state?.email || '').trim().toLowerCase(); + if (isLikelyIcloudAliasEmail(currentEmail)) { + candidates.add(currentEmail); + } + + if (!candidates.size) { + return []; + } + + const aliases = Array.from(candidates, (email) => ({ + hme: email, + email, + state: 'active', + active: true, + })); + return normalizeIcloudAliasCacheList(aliases, { + usedEmails: getEffectiveUsedEmails(state), + preservedEmails: preservedAliases, + }); +} + async function setIcloudAliasUsedState(payload = {}, options = {}) { const email = String(payload.email || '').trim().toLowerCase(); if (!email) { @@ -1769,10 +1893,7 @@ async function resetState() { getPersistedSettings(), getPersistedAliasState(), ]); - const contributionModeState = buildContributionModeState(Boolean(prev.contributionMode), { - ...persistedSettings, - ...(prev.contributionMode ? { panelMode: 'cpa' } : {}), - }, prev); + const contributionModeState = buildContributionModeState(Boolean(prev.contributionMode), persistedSettings, prev); await chrome.storage.session.clear(); await chrome.storage.session.set({ ...DEFAULT_STATE, @@ -1823,6 +1944,50 @@ function generatePassword() { return pw.split('').sort(() => Math.random() - 0.5).join(''); } +function normalizePayPalAccount(account = {}) { + if (self.PayPalUtils?.normalizePayPalAccount) { + return self.PayPalUtils.normalizePayPalAccount(account); + } + return { + id: String(account.id || crypto.randomUUID()), + email: String(account.email || '').trim().toLowerCase(), + password: String(account.password || ''), + createdAt: Number.isFinite(Number(account.createdAt)) ? Number(account.createdAt) : Date.now(), + updatedAt: Number.isFinite(Number(account.updatedAt)) ? Number(account.updatedAt) : Date.now(), + lastUsedAt: Number.isFinite(Number(account.lastUsedAt)) ? Number(account.lastUsedAt) : 0, + }; +} + +function normalizePayPalAccounts(accounts) { + if (self.PayPalUtils?.normalizePayPalAccounts) { + return self.PayPalUtils.normalizePayPalAccounts(accounts); + } + return Array.isArray(accounts) ? accounts.map((account) => normalizePayPalAccount(account)) : []; +} + +function findPayPalAccount(accounts, accountId) { + if (self.PayPalUtils?.findPayPalAccount) { + return self.PayPalUtils.findPayPalAccount(accounts, accountId); + } + const normalizedId = String(accountId || '').trim(); + if (!normalizedId) return null; + return normalizePayPalAccounts(accounts).find((account) => account.id === normalizedId) || null; +} + +function upsertPayPalAccountInList(accounts, nextAccount) { + if (self.PayPalUtils?.upsertPayPalAccountInList) { + return self.PayPalUtils.upsertPayPalAccountInList(accounts, nextAccount); + } + const normalizedNext = normalizePayPalAccount(nextAccount); + const list = normalizePayPalAccounts(accounts); + const existingIndex = list.findIndex((account) => account.id === normalizedNext.id); + if (existingIndex >= 0) { + list[existingIndex] = normalizedNext; + return list; + } + return [...list, normalizedNext]; +} + function normalizeHotmailAccount(account = {}) { const normalizedLastAuthAt = Number.isFinite(Number(account.lastAuthAt)) ? Number(account.lastAuthAt) : 0; const normalizedStatus = String( @@ -2015,30 +2180,78 @@ async function setCurrentHotmailAccount(accountId, options = {}) { return account; } -async function ensureHotmailAccountForFlow(options = {}) { - const { allowAllocate = true, markUsed = false, preferredAccountId = null } = options; - const state = await getState(); - const accounts = normalizeHotmailAccounts(state.hotmailAccounts); - const isAccountAllocatable = (candidate) => Boolean(candidate) +function isAuthorizedHotmailRunAccount(candidate) { + return Boolean(candidate) && candidate.status === 'authorized' && !candidate.used && Boolean(candidate.refreshToken); +} + +function isPendingHotmailVerificationCandidate(candidate) { + return Boolean(candidate) + && candidate.status === 'pending' + && !candidate.used + && Boolean(candidate.refreshToken); +} + +function compareHotmailAccountAllocationPriority(left, right) { + const leftUsedAt = Number(left?.lastUsedAt) || 0; + const rightUsedAt = Number(right?.lastUsedAt) || 0; + if (leftUsedAt !== rightUsedAt) { + return leftUsedAt - rightUsedAt; + } + + return String(left?.email || '').localeCompare(String(right?.email || '')); +} + +function pickPendingHotmailAccountForVerification(accounts, options = {}) { + const excludeIds = new Set((options.excludeIds || []).filter(Boolean)); + const candidates = normalizeHotmailAccounts(accounts) + .filter((candidate) => isPendingHotmailVerificationCandidate(candidate) && !excludeIds.has(candidate.id)); + if (!candidates.length) { + return null; + } + + const preferredAccountId = String(options.preferredAccountId || '').trim(); + if (preferredAccountId) { + const preferredCandidate = candidates.find((candidate) => candidate.id === preferredAccountId); + if (preferredCandidate) { + return preferredCandidate; + } + } + + return candidates + .slice() + .sort(compareHotmailAccountAllocationPriority)[0] || null; +} + +async function ensureHotmailAccountForFlow(options = {}) { + const { + allowAllocate = true, + markUsed = false, + preferredAccountId = null, + excludeIds = [], + } = options; + const state = await getState(); + const accounts = normalizeHotmailAccounts(state.hotmailAccounts); + const excludedAccountIds = new Set((excludeIds || []).filter(Boolean)); + const availableAccounts = accounts.filter((candidate) => isAuthorizedHotmailRunAccount(candidate) && !excludedAccountIds.has(candidate.id)); let account = null; - if (preferredAccountId) { + if (preferredAccountId && !excludedAccountIds.has(preferredAccountId)) { account = findHotmailAccount(accounts, preferredAccountId); } - if (!account && state.currentHotmailAccountId) { + if ((!account || !isAuthorizedHotmailRunAccount(account)) && state.currentHotmailAccountId && !excludedAccountIds.has(state.currentHotmailAccountId)) { account = findHotmailAccount(accounts, state.currentHotmailAccountId); } - if ((!account || !isAccountAllocatable(account)) && allowAllocate) { - account = pickHotmailAccountForRun(accounts, {}); + if ((!account || !isAuthorizedHotmailRunAccount(account)) && allowAllocate) { + account = availableAccounts.length ? pickHotmailAccountForRun(availableAccounts, {}) : null; } if (!account) { throw new Error('没有可用的 Hotmail 账号。请先在侧边栏添加至少一个带刷新令牌(refresh token)的账号。'); } - if (!isAccountAllocatable(account)) { + if (!isAuthorizedHotmailRunAccount(account)) { throw new Error(`Hotmail 账号 ${account.email || account.id} 尚未就绪,无法读取邮件。`); } @@ -2376,6 +2589,100 @@ async function verifyHotmailAccount(accountId) { }; } +async function ensureHotmailMailboxReadyForAutoRunRound(options = {}) { + const { + targetRun = 0, + totalRuns = 0, + attemptRun = 1, + } = options; + const state = await getState(); + if (!isHotmailProvider(state)) { + return null; + } + + const buildRoundLabel = () => { + if (targetRun > 0 && totalRuns > 0) { + return `第 ${targetRun}/${totalRuns} 轮`; + } + return '当前轮'; + }; + const exhaustedAccountIds = new Set(); + let preferredAccountId = state.currentHotmailAccountId || null; + let lastError = null; + + while (true) { + throwIfStopped(); + const latestState = await getState(); + const latestAccounts = normalizeHotmailAccounts(latestState.hotmailAccounts); + const remainingAuthorizedAccounts = latestAccounts + .filter((candidate) => isAuthorizedHotmailRunAccount(candidate) && !exhaustedAccountIds.has(candidate.id)); + const remainingPendingAccounts = latestAccounts + .filter((candidate) => isPendingHotmailVerificationCandidate(candidate) && !exhaustedAccountIds.has(candidate.id)); + if (!remainingAuthorizedAccounts.length && !remainingPendingAccounts.length) { + if (lastError) { + throw new Error(`自动运行${buildRoundLabel()}开始前未找到可通过校验的 Hotmail 账号:${lastError.message}`); + } + throw new Error('没有可用的 Hotmail 账号。请先在侧边栏添加至少一个带刷新令牌(refresh token)的账号。'); + } + + let account = null; + if (remainingAuthorizedAccounts.length) { + account = await ensureHotmailAccountForFlow({ + allowAllocate: true, + markUsed: false, + preferredAccountId, + excludeIds: [...exhaustedAccountIds], + }); + } else { + const pendingAccount = pickPendingHotmailAccountForVerification(latestAccounts, { + preferredAccountId, + excludeIds: [...exhaustedAccountIds], + }); + if (!pendingAccount) { + throw new Error('没有可用的 Hotmail 账号。请先在侧边栏添加至少一个带刷新令牌(refresh token)的账号。'); + } + account = await setCurrentHotmailAccount(pendingAccount.id, { + markUsed: false, + syncEmail: true, + }); + await addLog( + `自动运行${buildRoundLabel()}开始前未找到已校验 Hotmail 账号,正在尝试校验待校验账号 ${account.email}。`, + 'warn' + ); + } + + try { + await addLog( + `自动运行${buildRoundLabel()}第 ${attemptRun} 次尝试开始前,正在校验 Hotmail 账号 ${account.email} 的邮箱可用性。`, + 'info' + ); + const result = await verifyHotmailAccount(account.id); + await addLog( + `自动运行${buildRoundLabel()}开始前已校验 Hotmail 账号 ${result.account?.email || account.email},INBOX 当前 ${result.messageCount} 封邮件。`, + 'ok' + ); + return result.account; + } catch (error) { + lastError = error; + exhaustedAccountIds.add(account.id); + preferredAccountId = null; + const latestErrorMessage = error?.message || '未知错误'; + await addLog( + `自动运行${buildRoundLabel()}开始前校验 Hotmail 账号 ${account.email} 失败:${latestErrorMessage}`, + 'warn' + ); + const nextState = await getState(); + const hasRemainingAccounts = normalizeHotmailAccounts(nextState.hotmailAccounts) + .some((candidate) => ( + isAuthorizedHotmailRunAccount(candidate) || isPendingHotmailVerificationCandidate(candidate) + ) && !exhaustedAccountIds.has(candidate.id)); + if (hasRemainingAccounts) { + await addLog(`自动运行${buildRoundLabel()}开始前将切换下一个 Hotmail 账号并重试。`, 'warn'); + } + } + } +} + async function testHotmailAccountMailAccess(accountId) { const state = await getState(); const account = findHotmailAccount(state.hotmailAccounts, accountId); @@ -3673,10 +3980,7 @@ async function pollCloudflareTempEmailVerificationCode(step, state, pollPayload async function getOpenIcloudHostPreference() { try { const tabs = await chrome.tabs.query({ - url: [ - 'https://www.icloud.com/*', - 'https://www.icloud.com.cn/*', - ], + url: ICLOUD_TAB_URL_PATTERNS, }); const activeTab = tabs.find((tab) => tab.active); @@ -3699,9 +4003,9 @@ async function getPreferredIcloudLoginUrl(error = null, state = null) { return getIcloudLoginUrlForHost(configuredHost); } - const messageHint = getIcloudHostHintFromMessage(getErrorMessage(error)); - if (messageHint) { - return getIcloudLoginUrlForHost(messageHint); + const openHost = await getOpenIcloudHostPreference(); + if (openHost) { + return getIcloudLoginUrlForHost(openHost); } const savedHost = normalizeIcloudHost(currentState?.preferredIcloudHost); @@ -3709,9 +4013,9 @@ async function getPreferredIcloudLoginUrl(error = null, state = null) { return getIcloudLoginUrlForHost(savedHost); } - const openHost = await getOpenIcloudHostPreference(); - if (openHost) { - return getIcloudLoginUrlForHost(openHost); + const messageHint = getIcloudHostHintFromMessage(getErrorMessage(error)); + if (messageHint) { + return getIcloudLoginUrlForHost(messageHint); } return ICLOUD_LOGIN_URLS[0]; @@ -3732,35 +4036,111 @@ async function getPreferredIcloudSetupUrls(state = null, error = null) { function isIcloudLoginRequiredError(error) { const message = getErrorMessage(error).toLowerCase(); - return message.includes('could not validate icloud session') - || message.includes('hide my email service was unavailable') - || /\bstatus (401|403|409|421)\b/.test(message); + const hasAuthStatus401 = /\bstatus 401\b/.test(message); + const hasAuthStatus403 = /\bstatus 403\b/.test(message); + const hasTransientStatus = /\bstatus (409|421|429|5\d\d)\b/.test(message); + const hasTransientNetworkHint = message.includes('failed to fetch') + || message.includes('networkerror') + || message.includes('network request failed') + || message.includes('timeout') + || message.includes('timed out') + || message.includes('cors') + || message.includes('address space'); + const hasExplicitLoginHint = message.includes('please sign in') + || message.includes('sign in required') + || message.includes('not logged in') + || message.includes('login required') + || message.includes('re-authentication required') + || message.includes('unauthenticated') + || message.includes('authentication required') + || message.includes('需要先登录') + || message.includes('请先登录'); + const hasSelfPromptHint = message.includes('请先在新打开的 icloud 页面中完成登录') + || message.includes('请先在当前浏览器登录'); + const hasAuthStatusWithExplicitLoginHint = (hasAuthStatus401 || hasAuthStatus403) + && hasExplicitLoginHint; + + // Keep transient validate/network/cors errors out of login-required path. + if (message.includes('could not validate icloud session')) { + return false; + } + if (message.includes('page_context:')) { + return false; + } + if (hasSelfPromptHint) { + return false; + } + if (hasTransientStatus || hasTransientNetworkHint) { + return false; + } + + if (hasAuthStatusWithExplicitLoginHint) { + return true; + } + + if (hasExplicitLoginHint) { + return true; + } + + return false; +} + +function isIcloudTransientContextError(error) { + const message = getErrorMessage(error).toLowerCase(); + return /\bstatus (401|403|409|421|429|5\d\d)\b/.test(message) + || message.includes('could not validate icloud session') + || message.includes('page_context:') + || message.includes('failed to fetch') + || message.includes('networkerror') + || message.includes('network request failed') + || message.includes('cors') + || message.includes('address space') + || message.includes('timeout') + || message.includes('timed out'); } let lastIcloudLoginPromptAt = 0; +const activeIcloudRequestControllers = new Set(); +let lastResolvedIcloudServiceUrl = ''; +const icloudTransientLogThrottle = new Map(); + +function shouldEmitIcloudTransientLog(key, windowMs = 1500) { + const normalizedKey = String(key || '').trim(); + if (!normalizedKey) { + return true; + } + const now = Date.now(); + const lastAt = Number(icloudTransientLogThrottle.get(normalizedKey) || 0); + if (now - lastAt < Math.max(200, Number(windowMs) || 1500)) { + return false; + } + icloudTransientLogThrottle.set(normalizedKey, now); + return true; +} async function openIcloudLoginPage(preferredUrl) { const tabs = await chrome.tabs.query({ - url: [ - 'https://www.icloud.com/*', - 'https://www.icloud.com.cn/*', - ], + url: ICLOUD_TAB_URL_PATTERNS, }); const preferredHost = new URL(preferredUrl).host; - const existing = tabs.find((tab) => { + const preferredIcloudHost = normalizeIcloudHost(preferredHost); + const existingSameHost = tabs.find((tab) => { try { - return new URL(tab.url).host === preferredHost; + return normalizeIcloudHost(new URL(tab.url).host) === preferredIcloudHost; } catch { return false; } }); + const existingAnyIcloudTab = tabs.find((tab) => Number.isInteger(tab?.id)); - if (existing?.id) { - await chrome.tabs.update(existing.id, { active: true }); - if (existing.url !== preferredUrl) { - await chrome.tabs.update(existing.id, { url: preferredUrl }); - } - return existing.id; + if (existingSameHost?.id) { + await chrome.tabs.update(existingSameHost.id, { active: true }); + return existingSameHost.id; + } + + if (existingAnyIcloudTab?.id) { + await chrome.tabs.update(existingAnyIcloudTab.id, { active: true }); + return existingAnyIcloudTab.id; } const created = await chrome.tabs.create({ url: preferredUrl, active: true }); @@ -3797,105 +4177,511 @@ async function promptIcloudLogin(error, actionLabel = 'iCloud 操作') { } async function withIcloudLoginHelp(actionLabel, action) { - try { - return await action(); - } catch (err) { - if (isIcloudLoginRequiredError(err)) { - await promptIcloudLogin(err, actionLabel); - throw new Error('请先在新打开的 iCloud 页面中完成登录,再回来点击“我已登录”。'); + const maxTransientAttempts = Math.max(1, Number(ICLOUD_TRANSIENT_RETRY_MAX_ATTEMPTS) || 1); + const retryDelayMs = Math.max(300, Number(ICLOUD_TRANSIENT_RETRY_DELAY_MS) || 1200); + for (let attempt = 1; attempt <= maxTransientAttempts; attempt += 1) { + try { + return await action(); + } catch (err) { + if (isIcloudLoginRequiredError(err)) { + await promptIcloudLogin(err, actionLabel); + throw new Error('请先在新打开的 iCloud 页面中完成登录,再回来点击“我已登录”。'); + } + if (isIcloudTransientContextError(err)) { + if (attempt < maxTransientAttempts) { + if (shouldEmitIcloudTransientLog(`${actionLabel}:retry:${attempt}/${maxTransientAttempts}`)) { + await addLog(`iCloud:${actionLabel}受网络/上下文波动影响,正在重试(${attempt}/${maxTransientAttempts})...`, 'warn'); + } + await new Promise((resolve) => setTimeout(resolve, retryDelayMs * attempt)); + continue; + } + if (shouldEmitIcloudTransientLog(`${actionLabel}:final`)) { + await addLog(`iCloud:${actionLabel}受网络/上下文波动影响:${getErrorMessage(err)}`, 'warn'); + } + const transientError = new Error('iCloud 别名加载受网络/上下文波动影响,请稍后重试。'); + transientError.code = 'ICLOUD_TRANSIENT_CONTEXT'; + transientError.cause = err; + throw transientError; + } + throw err; } - throw err; + } + throw new Error('iCloud 操作失败:未知错误。'); +} + +function isIcloudApiUrl(url = '') { + const rawUrl = String(url || '').trim(); + if (!rawUrl) { + return false; + } + try { + const parsedUrl = new URL(rawUrl); + if (parsedUrl.protocol !== 'https:') { + return false; + } + const hostname = String(parsedUrl.hostname || '').trim().toLowerCase().replace(/\.$/, ''); + if (!hostname) { + return false; + } + return hostname === 'icloud.com' + || hostname.endsWith('.icloud.com') + || hostname === 'icloud.com.cn' + || hostname.endsWith('.icloud.com.cn'); + } catch { + return false; } } -function getIcloudRequestTargetLabel(url = '') { +function normalizeIcloudServiceUrl(rawUrl = '') { + const value = String(rawUrl || '').trim(); + if (!value) { + return ''; + } try { - const parsed = new URL(String(url || '')); - if (/\/v2\/hme\/list(?:[/?#]|$)/i.test(parsed.pathname)) return '别名列表'; - if (/\/v1\/hme\/generate(?:[/?#]|$)/i.test(parsed.pathname)) return '生成别名'; - if (/\/v1\/hme\/reserve(?:[/?#]|$)/i.test(parsed.pathname)) return '预留别名'; - if (/\/v1\/hme\/delete(?:[/?#]|$)/i.test(parsed.pathname)) return '删除别名'; - } catch {} - return 'iCloud 请求'; + const parsedUrl = new URL(value); + if ((parsedUrl.protocol === 'https:' && parsedUrl.port === '443') + || (parsedUrl.protocol === 'http:' && parsedUrl.port === '80')) { + parsedUrl.port = ''; + } + return parsedUrl.toString().replace(/\/$/, ''); + } catch { + return value.replace(/\/$/, ''); + } } -function getIcloudRetryDelay(attemptIndex = 0) { - const retryDelays = Array.isArray(globalThis.ICLOUD_RETRY_DELAYS_MS) - ? globalThis.ICLOUD_RETRY_DELAYS_MS - : [500, 1200, 2000]; - const index = Math.max(0, Math.floor(Number(attemptIndex) || 0)); - const delay = Number(retryDelays[Math.min(index, retryDelays.length - 1)]); - return Number.isFinite(delay) ? Math.max(0, delay) : 0; +function rememberIcloudServiceUrl(rawUrl = '') { + const normalized = normalizeIcloudServiceUrl(rawUrl); + if (normalized) { + lastResolvedIcloudServiceUrl = normalized; + } + return normalized; +} + +function isIcloudMaildomainwsHost(rawHost = '') { + const host = String(rawHost || '').trim().toLowerCase().replace(/\.$/, ''); + if (!host) { + return false; + } + return host.endsWith('maildomainws.icloud.com') || host.endsWith('maildomainws.icloud.com.cn'); +} + +function appendIcloudClientQueryParams(rawUrl = '') { + const input = String(rawUrl || '').trim(); + if (!input) { + return ''; + } + try { + const parsed = new URL(input); + if (!isIcloudMaildomainwsHost(parsed.hostname)) { + return input; + } + + if (!parsed.searchParams.has('clientBuildNumber')) { + parsed.searchParams.set('clientBuildNumber', ICLOUD_MAILDOMAINWS_CLIENT_BUILD_NUMBER); + } + if (!parsed.searchParams.has('clientMasteringNumber')) { + parsed.searchParams.set('clientMasteringNumber', ICLOUD_MAILDOMAINWS_CLIENT_BUILD_NUMBER); + } + if (!parsed.searchParams.has('clientId')) { + parsed.searchParams.set('clientId', ''); + } + if (!parsed.searchParams.has('dsid')) { + parsed.searchParams.set('dsid', ''); + } + return parsed.toString(); + } catch { + return input; + } +} + +function isIcloudMailPageUrl(rawUrl = '') { + try { + const parsedUrl = new URL(String(rawUrl || '').trim()); + if (!normalizeIcloudHost(parsedUrl.hostname)) { + return false; + } + const pathname = String(parsedUrl.pathname || '').toLowerCase(); + return pathname === '/mail' || pathname.startsWith('/mail/'); + } catch { + return false; + } +} + +async function waitForIcloudMailTabReady(tabId, timeoutMs = 8000) { + if (!Number.isInteger(tabId)) { + return false; + } + const deadline = Date.now() + Math.max(500, Number(timeoutMs) || 8000); + while (Date.now() < deadline) { + try { + const tab = await chrome.tabs.get(tabId); + const status = String(tab?.status || ''); + if (isIcloudMailPageUrl(tab?.url) && status === 'complete') { + return true; + } + } catch { + return false; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + return false; +} + +async function ensureIcloudMailContextTab(tabs = [], targetHost = '', preferredHost = '') { + const tabList = Array.isArray(tabs) ? tabs : []; + if (tabList.some((tab) => isIcloudMailPageUrl(tab?.url))) { + return tabList; + } + + const fallbackHost = targetHost + || preferredHost + || await getOpenIcloudHostPreference() + || 'icloud.com'; + const fallbackMailUrl = getIcloudMailUrlForHost(fallbackHost) || getIcloudMailUrlForHost('icloud.com'); + if (!fallbackMailUrl) { + return tabList; + } + + try { + const created = await chrome.tabs.create({ url: fallbackMailUrl, active: false }); + await waitForIcloudMailTabReady(created?.id, 9000); + } catch {} + + try { + return await chrome.tabs.query({ + url: ICLOUD_TAB_URL_PATTERNS, + }); + } catch { + return tabList; + } +} + +function shouldTryIcloudRequestPageContextFallback(url, status, errorMessage = '') { + if (!isIcloudApiUrl(url)) { + return false; + } + + const normalizedStatus = Number(status) || 0; + if (normalizedStatus === 401 + || normalizedStatus === 403 + || normalizedStatus === 409 + || normalizedStatus === 421 + || normalizedStatus === 429 + || normalizedStatus >= 500) { + return true; + } + + const message = String(errorMessage || '').toLowerCase(); + return message.includes('failed to fetch') + || message.includes('network request failed') + || message.includes('networkerror') + || message.includes('timed out') + || message.includes('timeout') + || message.includes('cors') + || message.includes('address space'); +} + +async function icloudRequestViaPageContext(method, url, options = {}) { + const { + data, + contentType = '', + } = options; + const state = await getState(); + const targetHost = normalizeIcloudHost(new URL(url).hostname); + const preferredHost = normalizeIcloudHost(state?.preferredIcloudHost); + + let tabs = await chrome.tabs.query({ + url: ICLOUD_TAB_URL_PATTERNS, + }); + tabs = await ensureIcloudMailContextTab(tabs, targetHost, preferredHost); + if (!tabs.length) { + throw new Error('page_context:no_icloud_tab'); + } + + const sortedTabs = [...tabs].sort((left, right) => { + const score = (tab) => { + let tabHost = ''; + try { + tabHost = normalizeIcloudHost(new URL(String(tab?.url || '')).hostname); + } catch {} + return (isIcloudMailPageUrl(tab?.url) ? 8 : 0) + + (tab?.active ? 4 : 0) + + (tabHost && tabHost === targetHost ? 2 : 0) + + (tabHost && tabHost === preferredHost ? 1 : 0); + }; + return score(right) - score(left); + }); + const mailTabs = sortedTabs.filter((tab) => isIcloudMailPageUrl(tab?.url)); + const candidateTabs = mailTabs.length ? mailTabs : sortedTabs; + + const errors = []; + for (const tab of candidateTabs) { + if (!Number.isInteger(tab?.id)) { + continue; + } + try { + const injections = await chrome.scripting.executeScript({ + target: { tabId: tab.id, allFrames: false }, + world: 'MAIN', + func: async (requestConfig) => { + const timeoutMs = 15000; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + try { + const headers = requestConfig.hasData + ? { 'Content-Type': requestConfig.contentType || 'application/json' } + : undefined; + const response = await fetch(requestConfig.url, { + method: requestConfig.method, + credentials: 'include', + cache: 'no-store', + mode: 'cors', + headers, + body: requestConfig.hasData ? JSON.stringify(requestConfig.data) : undefined, + signal: controller.signal, + }); + const text = await response.text(); + return { + ok: Boolean(response.ok), + status: Number(response.status) || 0, + text, + error: '', + }; + } catch (err) { + return { + ok: false, + status: 0, + text: '', + error: String(err?.message || err || 'unknown error'), + }; + } finally { + clearTimeout(timeoutId); + } + }, + args: [{ + method, + url, + hasData: data !== undefined, + data: data === undefined ? null : data, + contentType: contentType || '', + }], + }); + + const result = injections?.[0]?.result || null; + if (!result) { + throw new Error('empty result'); + } + if (!result.ok) { + if (result.status) { + throw new Error(`status ${result.status}`); + } + throw new Error(result.error || 'page context request failed'); + } + + if (!String(result.text || '').trim()) { + return {}; + } + + try { + return JSON.parse(result.text); + } catch (parseErr) { + throw new Error(`invalid json: ${getErrorMessage(parseErr)}`); + } + } catch (err) { + errors.push(`tab_${tab.id}:${getErrorMessage(err)}`); + } + } + + throw new Error(errors.length ? errors.join(' | ') : 'page_context:unknown'); +} + +function getIcloudRequestTargetLabel(rawUrl) { + try { + const parsed = new URL(rawUrl); + return `${parsed.host}${parsed.pathname}`; + } catch { + return String(rawUrl || '').trim(); + } +} + +function getIcloudRetryDelay(attemptIndex) { + if (attemptIndex <= 0) return ICLOUD_RETRY_DELAYS_MS[0]; + return ICLOUD_RETRY_DELAYS_MS[Math.min(attemptIndex - 1, ICLOUD_RETRY_DELAYS_MS.length - 1)]; } function isIcloudRetryableStatus(status) { - const code = Number(status); - return code === 408 || code === 409 || code === 421 || code === 425 || code === 429 || code === 500 || code === 502 || code === 503 || code === 504; + return [408, 429, 500, 502, 503, 504].includes(Number(status)); } function isIcloudRetryableError(error) { - const message = String(error?.message || error || '').toLowerCase(); - return /failed to fetch|networkerror|network request failed|timeout|aborted|econnreset|eai_again|temporarily unavailable|503|502|504/.test(message) - || Boolean(error?.networkFailure); + const status = Number(error?.status || error?.responseStatus || 0); + if (status && isIcloudRetryableStatus(status)) { + return true; + } + if (error?.timedOut || error?.networkFailure) { + return true; + } + + const message = getErrorMessage(error).toLowerCase(); + return message.includes('failed to fetch') + || message.includes('networkerror') + || message.includes('network error') + || message.includes('fetch failed') + || message.includes('timed out') + || message.includes('timeout') + || (error?.name === 'AbortError' && !stopRequested); +} + +function abortActiveIcloudRequests() { + for (const controller of [...activeIcloudRequestControllers]) { + try { + controller.abort(); + } catch {} + } + activeIcloudRequestControllers.clear(); } async function icloudRequest(method, url, options = {}) { - const { data } = options; - const maxAttempts = Math.max(1, Math.floor(Number(options.maxAttempts) || 1)); - const logRetries = Boolean(options.logRetries); - const retryLabel = String(options.retryLabel || getIcloudRequestTargetLabel(url) || 'iCloud 请求'); + const { + data, + timeoutMs = ICLOUD_REQUEST_TIMEOUT_MS, + maxAttempts = 1, + retryLabel = '', + logRetries = false, + } = options; + const requestUrl = appendIcloudClientQueryParams(url); + const requestContentType = (() => { + if (data === undefined) { + return ''; + } + try { + return isIcloudMaildomainwsHost(new URL(requestUrl).hostname) + ? 'text/plain;charset=UTF-8' + : 'application/json'; + } catch { + return 'application/json'; + } + })(); let lastError = null; - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - let response; + const totalAttempts = Math.max(1, Number(maxAttempts) || 1); + + for (let attempt = 1; attempt <= totalAttempts; attempt += 1) { + throwIfStopped(); + + const controller = new AbortController(); + let response = null; + let timeoutTriggered = false; + let timeoutId = null; + activeIcloudRequestControllers.add(controller); + try { - response = await fetch(url, { + timeoutId = setTimeout(() => { + timeoutTriggered = true; + try { + controller.abort(); + } catch {} + }, Math.max(1000, Number(timeoutMs) || ICLOUD_REQUEST_TIMEOUT_MS)); + + response = await fetch(requestUrl, { method, credentials: 'include', - headers: data !== undefined ? { 'Content-Type': 'application/json' } : undefined, + headers: requestContentType ? { 'Content-Type': requestContentType } : undefined, body: data !== undefined ? JSON.stringify(data) : undefined, + signal: controller.signal, }); - } catch (err) { - lastError = new Error(`iCloud 请求失败:${method} ${url},${err.message}`); - const canRetry = attempt < maxAttempts && isIcloudRetryableError(err); - if (!canRetry) break; - if (logRetries) { - addLog(`iCloud:${retryLabel}请求异常,准备重试(${attempt}/${maxAttempts}):${err.message}`, 'warn').catch(() => {}); - } - const delayMs = getIcloudRetryDelay(attempt - 1); - if (delayMs > 0) { - await sleepWithStop(delayMs); - } - continue; - } - if (!response.ok) { - const error = new Error(`iCloud 请求失败:${method} ${url},status ${response.status}`); - lastError = error; - const canRetry = attempt < maxAttempts && isIcloudRetryableStatus(response.status); - if (!canRetry) break; - if (logRetries) { - addLog(`iCloud:${retryLabel}返回 status ${response.status},准备重试(${attempt}/${maxAttempts})。`, 'warn').catch(() => {}); - } - const delayMs = getIcloudRetryDelay(attempt - 1); - if (delayMs > 0) { - await sleepWithStop(delayMs); - } - continue; - } + if (!response.ok) { + let responseText = ''; + try { + responseText = normalizeText(await response.text()).slice(0, 240); + } catch {} - try { - if (typeof response.json === 'function') { - return await response.json(); + const error = new Error( + responseText + ? `iCloud 请求失败:${method} ${requestUrl},status ${response.status},body: ${responseText}` + : `iCloud 请求失败:${method} ${requestUrl},status ${response.status}` + ); + error.status = response.status; + throw error; + } + + const rawText = await response.text(); + if (!rawText) { + return {}; + } + + try { + return JSON.parse(rawText); + } catch (err) { + throw new Error(`iCloud 返回的 JSON 无法解析:${method} ${requestUrl},${err.message}`); } - const text = await response.text(); - return text ? JSON.parse(text) : {}; } catch (err) { - throw new Error(`iCloud 返回的 JSON 无法解析:${method} ${url},${err.message}`); + if (stopRequested) { + throw new Error(STOP_ERROR_MESSAGE); + } + + let requestError = err; + if (timeoutTriggered || err?.name === 'AbortError') { + requestError = new Error(`iCloud 请求超时:${method} ${url},${timeoutMs}ms`); + requestError.name = 'IcloudTimeoutError'; + requestError.timedOut = true; + } else if (!requestError?.status) { + const message = getErrorMessage(requestError); + if (/failed to fetch|networkerror|network error|fetch failed/i.test(message)) { + requestError.networkFailure = true; + } + } + + const directErrorMessage = getErrorMessage(requestError) + || `iCloud 请求失败:${method} ${requestUrl}`; + const shouldTryPageContext = shouldTryIcloudRequestPageContextFallback( + requestUrl, + Number(requestError?.status) || 0, + directErrorMessage + ); + if (shouldTryPageContext) { + try { + return await icloudRequestViaPageContext(method, requestUrl, { + data, + contentType: requestContentType || undefined, + }); + } catch (pageContextError) { + const pageContextMessage = getErrorMessage(pageContextError); + if (!pageContextMessage.includes('page_context:no_icloud_tab')) { + const mergedError = new Error(`${directErrorMessage} | page_context:${pageContextMessage}`); + if (requestError?.status) { + mergedError.status = requestError.status; + } + requestError = mergedError; + } + } + } + + lastError = requestError; + const shouldRetry = attempt < totalAttempts && isIcloudRetryableError(requestError); + if (!shouldRetry) { + throw requestError; + } + + if (logRetries) { + const delayMs = getIcloudRetryDelay(attempt); + await addLog( + `iCloud:${retryLabel || getIcloudRequestTargetLabel(requestUrl)} 第 ${attempt}/${totalAttempts} 次失败:${getErrorMessage(requestError)},${Math.round(delayMs / 1000)} 秒后重试...`, + 'warn' + ); + } + + await sleepWithStop(getIcloudRetryDelay(attempt)); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + activeIcloudRequestControllers.delete(controller); } } - throw lastError || new Error(`iCloud 请求失败:${method} ${url}`); + throw lastError || new Error(`iCloud 请求失败:${method} ${requestUrl}`); } async function validateIcloudSession(setupUrl) { @@ -3906,6 +4692,133 @@ async function validateIcloudSession(setupUrl) { return data; } +function shouldTryIcloudPageContextFallback(errors = []) { + const combinedMessage = String((errors || []).join(' | ')).toLowerCase(); + if (!combinedMessage) { + return false; + } + return combinedMessage.includes('status 401') + || combinedMessage.includes('status 403') + || combinedMessage.includes('status 421') + || combinedMessage.includes('networkerror') + || combinedMessage.includes('network request failed') + || combinedMessage.includes('failed to fetch') + || combinedMessage.includes('timed out') + || combinedMessage.includes('timeout') + || combinedMessage.includes('cors'); +} + +async function validateIcloudSessionViaPageContext(tabId, setupUrl) { + const host = new URL(setupUrl).host; + try { + const injections = await chrome.scripting.executeScript({ + target: { tabId, allFrames: false }, + world: 'MAIN', + func: async (targetSetupUrl) => { + const timeoutMs = 12000; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(`${targetSetupUrl}/validate`, { + method: 'POST', + credentials: 'include', + cache: 'no-store', + mode: 'cors', + signal: controller.signal, + }); + const text = await response.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch {} + return { + ok: Boolean(response.ok), + status: Number(response.status) || 0, + data, + error: '', + }; + } catch (err) { + return { + ok: false, + status: 0, + data: null, + error: String(err?.message || err || 'unknown error'), + }; + } finally { + clearTimeout(timeoutId); + } + }, + args: [setupUrl], + }); + + const result = injections?.[0]?.result || null; + if (result?.ok && result?.data?.webservices?.premiummailsettings?.url) { + return { + setupUrl, + serviceUrl: normalizeIcloudServiceUrl(result.data.webservices.premiummailsettings.url), + resolvedBy: 'page_context', + }; + } + + if (result?.status) { + throw new Error(`status ${result.status}`); + } + throw new Error(result?.error || 'page context validate failed'); + } catch (err) { + throw new Error(`${host}: ${getErrorMessage(err)}`); + } +} + +async function resolveIcloudPremiumMailServiceViaPageContext(setupUrls, state) { + const errors = []; + let tabs = []; + try { + tabs = await chrome.tabs.query({ + url: ICLOUD_TAB_URL_PATTERNS, + }); + } catch (err) { + errors.push(`page_context:query_tabs:${getErrorMessage(err)}`); + return { service: null, errors, noTab: false }; + } + + const preferredHost = normalizeIcloudHost(state?.preferredIcloudHost); + tabs = await ensureIcloudMailContextTab(tabs, preferredHost, preferredHost); + if (!tabs.length) { + return { service: null, errors: [], noTab: true }; + } + const sortedTabs = [...tabs].sort((left, right) => { + const leftActive = left?.active ? 1 : 0; + const rightActive = right?.active ? 1 : 0; + if (leftActive !== rightActive) return rightActive - leftActive; + const leftMail = isIcloudMailPageUrl(left?.url) ? 1 : 0; + const rightMail = isIcloudMailPageUrl(right?.url) ? 1 : 0; + if (leftMail !== rightMail) return rightMail - leftMail; + let leftHost = ''; + let rightHost = ''; + try { leftHost = normalizeIcloudHost(new URL(String(left?.url || '')).host); } catch {} + try { rightHost = normalizeIcloudHost(new URL(String(right?.url || '')).host); } catch {} + const leftPreferred = leftHost && leftHost === preferredHost ? 1 : 0; + const rightPreferred = rightHost && rightHost === preferredHost ? 1 : 0; + return rightPreferred - leftPreferred; + }); + + for (const tab of sortedTabs) { + if (!Number.isInteger(tab?.id)) { + continue; + } + for (const setupUrl of setupUrls) { + try { + const service = await validateIcloudSessionViaPageContext(tab.id, setupUrl); + return { service, errors }; + } catch (err) { + errors.push(`page_context:tab_${tab.id}:${getErrorMessage(err)}`); + } + } + } + + return { service: null, errors, noTab: false }; +} + async function resolveIcloudPremiumMailService(options = {}) { const errors = []; const state = await getState(); @@ -3926,13 +4839,35 @@ async function resolveIcloudPremiumMailService(options = {}) { } return { setupUrl, - serviceUrl: String(data.webservices.premiummailsettings.url || '').replace(/\/$/, ''), + serviceUrl: rememberIcloudServiceUrl(data.webservices.premiummailsettings.url), }; } catch (err) { errors.push(`${new URL(setupUrl).host}: ${getErrorMessage(err)}`); } } + if (shouldTryIcloudPageContextFallback(errors)) { + const { + service, + errors: pageContextErrors, + noTab: pageContextNoTab = false, + } = await resolveIcloudPremiumMailServiceViaPageContext(setupUrls, state); + if (service) { + const preferredIcloudHost = normalizeIcloudHost(new URL(service.setupUrl).host); + if (preferredIcloudHost && preferredIcloudHost !== normalizeIcloudHost(state.preferredIcloudHost)) { + await setState({ preferredIcloudHost }); + } + await addLog(`iCloud:后台会话校验失败,已切换页面上下文校验(${new URL(service.setupUrl).host})。`, 'warn'); + return { + ...service, + serviceUrl: rememberIcloudServiceUrl(service.serviceUrl), + }; + } + if (!pageContextNoTab && Array.isArray(pageContextErrors) && pageContextErrors.length) { + errors.push(...pageContextErrors); + } + } + throw new Error(errors.length ? `Could not validate iCloud session. ${errors.join(' | ')}` : 'Could not validate iCloud session. 请先在当前浏览器登录 icloud.com.cn 或 icloud.com。'); @@ -3954,16 +4889,99 @@ async function checkIcloudSession(options = {}) { }); } +async function loadNormalizedIcloudAliases(options = {}) { + const { + resolveOptions = {}, + serviceUrl: initialServiceUrl = '', + silent = false, + } = options; + + let serviceUrl = String(initialServiceUrl || '').trim().replace(/\/$/, ''); + let lastError = null; + + for (let endpointAttempt = 1; endpointAttempt <= 2; endpointAttempt += 1) { + throwIfStopped(); + + if (!serviceUrl) { + const resolved = await resolveIcloudPremiumMailService(resolveOptions); + serviceUrl = resolved.serviceUrl; + } + + try { + if (!silent) { + await addLog(`iCloud:正在从 ${new URL(serviceUrl).host} 加载 Hide My Email 别名列表...`, 'info'); + } + const response = await icloudRequest('GET', `${serviceUrl}/v2/hme/list`, { + timeoutMs: ICLOUD_REQUEST_TIMEOUT_MS, + maxAttempts: ICLOUD_LIST_MAX_ATTEMPTS, + retryLabel: '加载 iCloud 别名列表', + logRetries: true, + }); + const state = await getState(); + return { + serviceUrl, + aliases: normalizeIcloudAliasList(response, { + usedEmails: getEffectiveUsedEmails(state), + preservedEmails: getPreservedAliasMap(state), + }), + }; + } catch (err) { + lastError = err; + if (endpointAttempt >= 2 || !isIcloudRetryableError(err)) { + throw err; + } + await addLog(`iCloud:${new URL(serviceUrl).host} 别名列表请求失败,正在刷新服务节点后重试...`, 'warn'); + serviceUrl = ''; + } + } + + throw lastError || new Error('加载 iCloud 别名列表失败。'); +} + async function listIcloudAliases(options = {}) { - return withIcloudLoginHelp('加载 iCloud 隐私邮箱列表', async () => { - const { serviceUrl } = await resolveIcloudPremiumMailService(options); - const response = await icloudRequest('GET', `${serviceUrl}/v2/hme/list`); - const state = await getState(); - return normalizeIcloudAliasList(response, { - usedEmails: getEffectiveUsedEmails(state), - preservedEmails: getPreservedAliasMap(state), + try { + return await withIcloudLoginHelp('加载 iCloud 隐私邮箱列表', async () => { + const { serviceUrl } = await resolveIcloudPremiumMailService(options); + const response = await icloudRequest('GET', `${serviceUrl}/v2/hme/list`); + const state = await getState(); + const aliases = normalizeIcloudAliasList(response, { + usedEmails: getEffectiveUsedEmails(state), + preservedEmails: getPreservedAliasMap(state), + }); + await setState({ + icloudAliasCache: normalizeIcloudAliasCacheList(aliases), + icloudAliasCacheAt: Date.now(), + }); + return aliases; }); - }); + } catch (err) { + const message = getErrorMessage(err); + const transientContextError = err?.code === 'ICLOUD_TRANSIENT_CONTEXT' + || message.includes('网络/上下文波动'); + if (!transientContextError) { + throw err; + } + const state = await getState(); + const freshCachedAliases = getIcloudAliasCacheFromState(state); + if (freshCachedAliases.length) { + await addLog(`iCloud:加载别名失败,已回退最近缓存(${freshCachedAliases.length} 条)。`, 'warn'); + return freshCachedAliases; + } + + const staleCachedAliases = getIcloudAliasCacheFromState(state, { maxAgeMs: 0 }); + if (staleCachedAliases.length) { + await addLog(`iCloud:加载别名失败,已回退历史缓存(${staleCachedAliases.length} 条)。`, 'warn'); + return staleCachedAliases; + } + + const localFallbackAliases = buildIcloudAliasFallbackFromLocalState(state); + if (localFallbackAliases.length) { + await addLog(`iCloud:加载别名失败,已回退本地别名记录(${localFallbackAliases.length} 条)。`, 'warn'); + return localFallbackAliases; + } + + throw err; + } } async function deleteIcloudAlias(payload) { @@ -3982,7 +5000,18 @@ async function deleteIcloudAlias(payload) { throw new Error(`缺少 ${alias.email} 的 anonymousId,请先刷新 iCloud 别名列表。`); } - const { serviceUrl } = await resolveIcloudPremiumMailService(); + let serviceUrl = ''; + try { + ({ serviceUrl } = await resolveIcloudPremiumMailService()); + } catch (resolveErr) { + const canFallbackToCachedService = isIcloudTransientContextError(resolveErr) + && Boolean(lastResolvedIcloudServiceUrl); + if (!canFallbackToCachedService) { + throw resolveErr; + } + serviceUrl = lastResolvedIcloudServiceUrl; + await addLog(`iCloud:会话校验暂时不可用,已回退最近可用服务节点 ${new URL(serviceUrl).host} 继续删除。`, 'warn'); + } try { const directDelete = await icloudRequest('POST', `${serviceUrl}/v1/hme/delete`, { @@ -4050,12 +5079,19 @@ async function fetchIcloudHideMyEmail(options = {}) { return withIcloudLoginHelp('获取 iCloud 隐私邮箱', async () => { throwIfStopped(); const generateNew = Boolean(options?.generateNew); - await addLog('iCloud:正在校验当前浏览器登录状态...', 'info'); + await addLog('iCloud:正在加载别名列表并校验当前浏览器登录状态...', 'info'); const { serviceUrl, setupUrl } = await resolveIcloudPremiumMailService(); await addLog(`iCloud:已通过 ${new URL(setupUrl).host} 验证会话`, 'ok'); + await addLog(`iCloud:当前 Hide My Email 服务节点 ${new URL(serviceUrl).host}`, 'info'); - const existingAliases = await listIcloudAliases().catch(() => []); + let activeServiceUrl = serviceUrl; + const existingAliases = await listIcloudAliases(); + const existingAliasEmailSet = new Set( + existingAliases + .map((aliasItem) => String(aliasItem?.email || '').trim().toLowerCase()) + .filter(Boolean) + ); if (!generateNew) { const reusableAlias = pickReusableIcloudAlias(existingAliases); @@ -4070,45 +5106,142 @@ async function fetchIcloudHideMyEmail(options = {}) { } await addLog('iCloud:没有可复用别名,开始生成新的 Hide My Email 地址...', 'warn'); + await addLog(`iCloud:正在向 ${new URL(activeServiceUrl).host} 请求新的 Hide My Email 候选地址...`, 'info'); - const generated = await icloudRequest('POST', `${serviceUrl}/v1/hme/generate`); - if (!generated?.success || !generated?.result?.hme) { - throw new Error(generated?.error?.errorMessage || 'iCloud 隐私邮箱生成失败。'); - } - - let alias = ''; try { - const reserved = await icloudRequest('POST', `${serviceUrl}/v1/hme/reserve`, { - data: { - hme: generated.result.hme, - label: getIcloudAliasLabel(), - note: 'Generated through Multi-Page Automation', - }, - }); - - if (!reserved?.success || !reserved?.result?.hme?.hme) { - throw new Error(reserved?.error?.errorMessage || 'iCloud 隐私邮箱保留失败。'); + let generated = null; + try { + generated = await icloudRequest('POST', `${activeServiceUrl}/v1/hme/generate`, { + timeoutMs: ICLOUD_REQUEST_TIMEOUT_MS, + maxAttempts: ICLOUD_WRITE_MAX_ATTEMPTS, + retryLabel: '生成 Hide My Email 地址', + logRetries: true, + }); + } catch (err) { + if (!isIcloudRetryableError(err)) { + throw err; + } + await addLog('iCloud:生成候选别名失败,正在刷新服务节点后再试一次...', 'warn'); + const refreshedService = await resolveIcloudPremiumMailService(); + activeServiceUrl = refreshedService.serviceUrl; + generated = await icloudRequest('POST', `${activeServiceUrl}/v1/hme/generate`, { + timeoutMs: ICLOUD_REQUEST_TIMEOUT_MS, + maxAttempts: ICLOUD_WRITE_MAX_ATTEMPTS, + retryLabel: '生成 Hide My Email 地址', + logRetries: true, + }); } - alias = String(reserved.result.hme.hme || '').trim().toLowerCase(); - } catch (reserveError) { - const reserveFailedAlias = String(generated?.result?.hme || '').trim().toLowerCase(); - if (!reserveFailedAlias) { - throw reserveError; + if (!generated?.success || !generated?.result?.hme) { + throw new Error(generated?.error?.errorMessage || 'iCloud 隐私邮箱生成失败。'); } - const aliases = await listIcloudAliases().catch(() => []); - const aliasFromList = findIcloudAliasByEmail(aliases, reserveFailedAlias); - if (!aliasFromList?.email) { - throw reserveError; + + const generatedHmeRaw = generated.result.hme; + const generatedAlias = String( + (typeof generatedHmeRaw === 'string' + ? generatedHmeRaw + : generatedHmeRaw?.hme + || generatedHmeRaw?.email + || generatedHmeRaw?.alias + || generatedHmeRaw?.address + || '') + ).trim().toLowerCase(); + if (!generatedAlias) { + throw new Error('iCloud 隐私邮箱生成失败:未返回可用别名。'); } - alias = String(aliasFromList.email || '').trim().toLowerCase(); - await addLog(`iCloud:保留接口报错,但已在列表确认别名 ${alias},继续使用该地址。`, 'warn'); + await addLog(`iCloud:已生成候选别名 ${generatedAlias},正在保留...`, 'info'); + + const reserveData = { + ...(generatedHmeRaw && typeof generatedHmeRaw === 'object' && !Array.isArray(generatedHmeRaw) + ? generatedHmeRaw + : {}), + hme: generatedAlias, + label: getIcloudAliasLabel(), + note: 'Generated through Multi-Page Automation', + }; + + let alias = ''; + try { + const reserved = await icloudRequest('POST', `${activeServiceUrl}/v1/hme/reserve`, { + data: reserveData, + timeoutMs: ICLOUD_REQUEST_TIMEOUT_MS, + maxAttempts: 1, + }); + + if (!reserved?.success || !reserved?.result?.hme?.hme) { + throw new Error(reserved?.error?.errorMessage || 'iCloud 隐私邮箱保留失败。'); + } + + alias = String(reserved.result.hme.hme || '').trim().toLowerCase(); + } catch (reserveErr) { + const reserveErrMessage = getErrorMessage(reserveErr); + const shouldTryListFallback = isIcloudRetryableError(reserveErr) + || /\bstatus (?:401|403|409)\b/i.test(reserveErrMessage) + || /failed to fetch/i.test(reserveErrMessage); + if (!shouldTryListFallback) { + throw reserveErr; + } + + await addLog('iCloud:保留别名返回鉴权/网络异常,正在回查别名列表确认是否已创建...', 'warn'); + const { aliases: aliasesAfterReserveFailure, serviceUrl: refreshedListServiceUrl } = await loadNormalizedIcloudAliases({ + serviceUrl: activeServiceUrl, + silent: true, + }); + activeServiceUrl = refreshedListServiceUrl || activeServiceUrl; + + let recoveredAlias = findIcloudAliasByEmail(aliasesAfterReserveFailure, generatedAlias); + if (!recoveredAlias) { + recoveredAlias = aliasesAfterReserveFailure.find( + (aliasItem) => !existingAliasEmailSet.has(String(aliasItem?.email || '').trim().toLowerCase()) + ) || null; + } + + if (recoveredAlias?.email) { + alias = String(recoveredAlias.email || '').trim().toLowerCase(); + await addLog(`iCloud:保留请求异常,但已在列表确认别名 ${alias},继续使用。`, 'warn'); + } else if (isIcloudRetryableError(reserveErr)) { + await addLog(`iCloud:列表中尚未出现 ${generatedAlias},正在刷新服务节点后重试保留一次...`, 'warn'); + const refreshedService = await resolveIcloudPremiumMailService(); + activeServiceUrl = refreshedService.serviceUrl; + const reservedRetry = await icloudRequest('POST', `${activeServiceUrl}/v1/hme/reserve`, { + data: reserveData, + timeoutMs: ICLOUD_REQUEST_TIMEOUT_MS, + maxAttempts: 1, + }); + if (!reservedRetry?.success || !reservedRetry?.result?.hme?.hme) { + throw new Error(reservedRetry?.error?.errorMessage || 'iCloud 隐私邮箱保留失败。'); + } + alias = String(reservedRetry.result.hme.hme || '').trim().toLowerCase(); + } else { + alias = generatedAlias; + await addLog(`iCloud:保留请求异常,已回退使用生成别名 ${alias}。`, 'warn'); + } + } + + await setEmailState(alias); + await addLog(`iCloud:已创建并保留新别名 ${alias}`, 'ok'); + broadcastIcloudAliasesChanged({ reason: 'created', email: alias }); + return alias; + } catch (err) { + if (!shouldStopIcloudAutoFetchRetries(err)) { + throw err; + } + + const reusableAlias = pickReusableIcloudAlias(existingAliases); + if (reusableAlias) { + await setEmailState(reusableAlias.email); + await addLog( + `iCloud:当前网络/上下文波动,暂无法创建新别名,已临时回退复用 ${reusableAlias.email}。`, + 'warn' + ); + broadcastIcloudAliasesChanged({ reason: 'selected', email: reusableAlias.email }); + return reusableAlias.email; + } + + throw new Error( + `iCloud 当前无法创建新别名:${getErrorMessage(err)}。请先确认 iCloud 页面已登录且网络可访问,再重试。` + ); } - - await setEmailState(alias); - await addLog(`iCloud:已创建并保留新别名 ${alias}`, 'ok'); - broadcastIcloudAliasesChanged({ reason: 'created', email: alias }); - return alias; }); } @@ -4156,11 +5289,22 @@ async function finalizeIcloudAliasAfterSuccessfulFlow(state) { await addLog(`iCloud:流程成功后已自动删除 ${email}。`, 'ok'); return { handled: true, deleted: true }; } catch (err) { - await addLog(`iCloud:自动删除 ${email} 失败:${getErrorMessage(err)}`, 'warn'); + if (isIcloudTransientContextError(err)) { + await addLog(`iCloud:自动删除 ${email} 暂时跳过(网络/上下文波动),可稍后手动删除。`, 'info'); + } else { + await addLog(`iCloud:自动删除 ${email} 失败:${getErrorMessage(err)}`, 'warn'); + } return { handled: true, deleted: false }; } } +async function finalizePhoneActivationAfterSuccessfulFlow(state) { + if (typeof phoneVerificationHelpers?.finalizePendingPhoneActivationConfirmation !== 'function') { + return null; + } + return phoneVerificationHelpers.finalizePendingPhoneActivationConfirmation(state); +} + // ============================================================ // Tab Registry // ============================================================ @@ -4395,6 +5539,21 @@ async function waitForTabUrlMatch(tabId, matcher, options = {}) { return tabRuntime.waitForTabUrlMatch(tabId, matcher, options); } +async function waitForTabUrlMatchUntilStopped(tabId, matcher, options = {}) { + const retryDelayMs = Math.max(100, Math.floor(Number(options.retryDelayMs) || 300)); + while (true) { + throwIfStopped(); + const tab = await chrome.tabs.get(tabId).catch(() => null); + if (!tab) { + throw new Error('目标标签页已关闭,无法继续等待页面跳转。'); + } + if (typeof matcher === 'function' && matcher(tab.url || '', tab)) { + return tab; + } + await sleepWithStop(retryDelayMs); + } +} + async function waitForTabComplete(tabId, options = {}) { return tabRuntime.waitForTabComplete(tabId, options); } @@ -4403,10 +5562,94 @@ async function waitForTabStableComplete(tabId, options = {}) { return tabRuntime.waitForTabStableComplete(tabId, options); } +async function waitForTabCompleteUntilStopped(tabId, options = {}) { + const retryDelayMs = Math.max(100, Math.floor(Number(options.retryDelayMs) || 300)); + while (true) { + throwIfStopped(); + const tab = await chrome.tabs.get(tabId).catch(() => null); + if (!tab) { + throw new Error('目标标签页已关闭,无法继续等待页面加载完成。'); + } + if (tab.status === 'complete') { + return tab; + } + await sleepWithStop(retryDelayMs); + } +} + async function ensureContentScriptReadyOnTab(source, tabId, options = {}) { return tabRuntime.ensureContentScriptReadyOnTab(source, tabId, options); } +function isContentScriptReadyPong(source, pong) { + if (!pong?.ok) return false; + if (pong.source && pong.source !== source) return false; + if (source === 'plus-checkout') { + return Boolean(pong.plusCheckoutReady); + } + return true; +} + +function isUnrecoverableContentScriptInjectError(error) { + return /Could not load file/i.test(String(error?.message || error || '')); +} + +async function ensureContentScriptReadyOnTabUntilStopped(source, tabId, options = {}) { + const { + inject = null, + injectSource = null, + retryDelayMs = 700, + logMessage = '', + } = options; + let logged = false; + + while (true) { + throwIfStopped(); + const pong = await pingContentScriptOnTab(tabId); + if (isContentScriptReadyPong(source, pong)) { + await registerTab(source, tabId); + return; + } + + if (!inject || !inject.length) { + throw new Error(`${getSourceLabel(source)} 内容脚本未就绪,且未提供可用的注入文件。`); + } + + try { + if (injectSource) { + await chrome.scripting.executeScript({ + target: { tabId }, + func: (injectedSource) => { + window.__MULTIPAGE_SOURCE = injectedSource; + }, + args: [injectSource], + }); + } + await chrome.scripting.executeScript({ + target: { tabId }, + files: inject, + }); + } catch (error) { + console.warn(LOG_PREFIX, `[ensureContentScriptReadyOnTabUntilStopped] inject failed for ${source}:`, error?.message || error); + if (isUnrecoverableContentScriptInjectError(error)) { + throw new Error(`${getSourceLabel(source)} 内容脚本文件加载失败:${error?.message || error}。请在扩展管理页重新加载当前扩展,确认文件已包含在已加载的扩展目录中。`); + } + } + + const pongAfterInject = await pingContentScriptOnTab(tabId); + if (isContentScriptReadyPong(source, pongAfterInject)) { + await registerTab(source, tabId); + return; + } + + if (logMessage && !logged) { + logged = true; + await addLog(logMessage, 'warn'); + } + await sleepWithStop(retryDelayMs); + } +} + // ============================================================ // Command Queue (for content scripts not yet ready) // ============================================================ @@ -4429,6 +5672,21 @@ function sendTabMessageWithTimeout(tabId, source, message, responseTimeoutMs = g return tabRuntime.sendTabMessageWithTimeout(tabId, source, message, responseTimeoutMs); } +async function sendTabMessageUntilStopped(tabId, source, message, options = {}) { + const retryDelayMs = Math.max(100, Math.floor(Number(options.retryDelayMs) || 300)); + while (true) { + throwIfStopped(); + try { + return await chrome.tabs.sendMessage(tabId, message); + } catch (error) { + if (!isRetryableContentScriptTransportError(error)) { + throw error; + } + await sleepWithStop(retryDelayMs); + } + } +} + function queueCommand(source, message, timeout = 15000) { return tabRuntime.queueCommand(source, message, timeout); } @@ -4465,28 +5723,6 @@ async function sendToMailContentScriptResilient(mail, message, options = {}) { return tabRuntime.sendToMailContentScriptResilient(mail, message, options); } -function throwIfStepExecutionStopped(error = null) { - throwIfStopped(error); -} - -async function waitForTabCompleteUntilStopped(tabId, options = {}) { - return waitForTabComplete(tabId, options); -} - -async function waitForTabUrlMatchUntilStopped(tabId, matcher, options = {}) { - return waitForTabUrlMatch(tabId, matcher, options); -} - -async function ensureContentScriptReadyOnTabUntilStopped(source, tabId, options = {}) { - throwIfStepExecutionStopped(); - return ensureContentScriptReadyOnTab(source, tabId, options); -} - -async function sendTabMessageUntilStopped(tabId, source, message, options = {}) { - throwIfStepExecutionStopped(); - return tabRuntime.sendTabMessage(tabId, source, message, options); -} - // ============================================================ // Logging // ============================================================ @@ -4544,6 +5780,8 @@ function getSourceLabel(source) { 'hotmail-api': 'Hotmail(API对接/本地助手)', 'luckmail-api': 'LuckMail(API 购邮)', 'cloudflare-temp-email': 'Cloudflare Temp Email', + 'plus-checkout': 'Plus Checkout', + 'paypal-flow': 'PayPal 授权页', }; return labels[source] || source || '未知来源'; } @@ -4706,7 +5944,6 @@ function getLoginAuthStateLabel(state) { if (typeof loggingStatus !== 'undefined' && loggingStatus?.getLoginAuthStateLabel) { return loggingStatus.getLoginAuthStateLabel(state); } - state = state === 'oauth_consent_page' ? 'unknown' : state; switch (state) { case 'verification_page': return '登录验证码页'; case 'password_page': return '密码页'; @@ -4749,38 +5986,46 @@ function isStepDoneStatus(status) { return status === 'completed' || status === 'manual_completed' || status === 'skipped'; } -function getFirstUnfinishedStep(statuses = {}) { - if (typeof loggingStatus !== 'undefined' && loggingStatus?.getFirstUnfinishedStep) { - return loggingStatus.getFirstUnfinishedStep(statuses); - } - const stepIds = Object.keys({ ...DEFAULT_STATE.stepStatuses, ...statuses }) - .map((step) => Number(step)) - .filter(Number.isFinite) - .sort((left, right) => left - right); - for (const step of stepIds) { +function getFirstUnfinishedStep(statuses = {}, stateOverride = null) { + const state = stateOverride || {}; + const activeStepIds = typeof getStepIdsForState === 'function' + ? getStepIdsForState(state) + : (typeof STEP_IDS !== 'undefined' && Array.isArray(STEP_IDS) && STEP_IDS.length + ? STEP_IDS + : Array.from({ length: typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10 }, (_, index) => index + 1)); + for (const step of activeStepIds) { if (!isStepDoneStatus(statuses[step] || 'pending')) return step; } return null; } -function hasSavedProgress(statuses = {}) { - if (typeof loggingStatus !== 'undefined' && loggingStatus?.hasSavedProgress) { - return loggingStatus.hasSavedProgress(statuses); - } - return Object.values({ ...DEFAULT_STATE.stepStatuses, ...statuses }).some((status) => status !== 'pending'); +function hasSavedProgress(statuses = {}, stateOverride = null) { + const state = stateOverride || {}; + const merged = { ...DEFAULT_STATE.stepStatuses, ...statuses }; + const activeStepIds = typeof getStepIdsForState === 'function' + ? getStepIdsForState(state) + : (typeof STEP_IDS !== 'undefined' && Array.isArray(STEP_IDS) && STEP_IDS.length + ? STEP_IDS + : Array.from({ length: typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10 }, (_, index) => index + 1)); + return activeStepIds.some((step) => (merged[step] || 'pending') !== 'pending'); } function getDownstreamStateResets(step, state = {}) { - const stepKey = typeof getStepExecutionKeyForState === 'function' - ? getStepExecutionKeyForState(step, state) - : String( - typeof getStepDefinitionForState === 'function' - ? (getStepDefinitionForState(step, state)?.key || '') - : '' - ).trim(); + const stepKey = getStepExecutionKeyForState(step, state); + const plusRuntimeResets = { + plusCheckoutTabId: null, + plusCheckoutUrl: null, + plusCheckoutCountry: 'DE', + plusCheckoutCurrency: 'EUR', + plusBillingCountryText: '', + plusBillingAddress: null, + plusPaypalApprovedAt: null, + plusReturnUrl: '', + }; if (step <= 1) { return { + ...plusRuntimeResets, oauthUrl: null, cpaOAuthState: null, cpaManagementOrigin: null, @@ -4797,6 +6042,7 @@ function getDownstreamStateResets(step, state = {}) { loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, oauthFlowDeadlineSourceUrl: null, + pendingPhoneActivationConfirmation: null, lastSignupCode: null, lastLoginCode: null, localhostUrl: null, @@ -4804,12 +6050,14 @@ function getDownstreamStateResets(step, state = {}) { } if (step === 2) { return { + ...plusRuntimeResets, password: null, lastEmailTimestamp: null, signupVerificationRequestedAt: null, loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, oauthFlowDeadlineSourceUrl: null, + pendingPhoneActivationConfirmation: null, lastSignupCode: null, lastLoginCode: null, localhostUrl: null, @@ -4817,11 +6065,13 @@ function getDownstreamStateResets(step, state = {}) { } if (step === 3 || step === 4) { return { + ...plusRuntimeResets, lastEmailTimestamp: null, signupVerificationRequestedAt: null, loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, oauthFlowDeadlineSourceUrl: null, + pendingPhoneActivationConfirmation: null, lastSignupCode: null, lastLoginCode: null, localhostUrl: null, @@ -4829,15 +6079,45 @@ function getDownstreamStateResets(step, state = {}) { } if (step === 5 || step === 6 || step === 7 || step === 8) { return { + ...(step <= 6 ? plusRuntimeResets : {}), + ...(step === 7 ? { + plusBillingCountryText: '', + plusBillingAddress: null, + plusPaypalApprovedAt: null, + plusReturnUrl: '', + } : {}), + ...(step === 8 ? { + plusPaypalApprovedAt: null, + plusReturnUrl: '', + } : {}), lastLoginCode: null, loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, oauthFlowDeadlineSourceUrl: null, + pendingPhoneActivationConfirmation: null, localhostUrl: null, }; } if (step === 9) { return { + pendingPhoneActivationConfirmation: null, + plusReturnUrl: '', + localhostUrl: null, + }; + } + if (stepKey === 'oauth-login' || stepKey === 'fetch-login-code') { + return { + lastLoginCode: null, + loginVerificationRequestedAt: null, + oauthFlowDeadlineAt: null, + oauthFlowDeadlineSourceUrl: null, + pendingPhoneActivationConfirmation: null, + localhostUrl: null, + }; + } + if (stepKey === 'confirm-oauth') { + return { + pendingPhoneActivationConfirmation: null, localhostUrl: null, }; } @@ -4848,10 +6128,17 @@ async function invalidateDownstreamAfterStepRestart(step, options = {}) { const { logLabel = `步骤 ${step} 重新执行` } = options; const state = await getState(); const statuses = { ...(state.stepStatuses || {}) }; - const lastStepId = getLastStepIdForState(state); const changedSteps = []; - for (let downstream = step + 1; downstream <= lastStepId; downstream++) { + const activeStepIds = typeof getStepIdsForState === 'function' + ? getStepIdsForState(state) + : (typeof STEP_IDS !== 'undefined' && Array.isArray(STEP_IDS) && STEP_IDS.length + ? STEP_IDS + : Array.from({ length: typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10 }, (_, index) => index + 1)); + for (const downstream of activeStepIds) { + if (downstream <= step) { + continue; + } if (statuses[downstream] !== 'pending') { statuses[downstream] = 'pending'; changedSteps.push(downstream); @@ -4880,22 +6167,26 @@ function clearStopRequest() { stopRequested = false; } -function getRunningSteps(statuses = {}) { - if (typeof loggingStatus !== 'undefined' && loggingStatus?.getRunningSteps) { - return loggingStatus.getRunningSteps(statuses); - } - return Object.entries({ ...DEFAULT_STATE.stepStatuses, ...statuses }) - .filter(([, status]) => status === 'running') - .map(([step]) => Number(step)) +function getRunningSteps(statuses = {}, stateOverride = null) { + const state = stateOverride || {}; + const merged = { ...DEFAULT_STATE.stepStatuses, ...statuses }; + const activeStepIds = typeof getStepIdsForState === 'function' + ? getStepIdsForState(state) + : (typeof STEP_IDS !== 'undefined' && Array.isArray(STEP_IDS) && STEP_IDS.length + ? STEP_IDS + : Array.from({ length: typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10 }, (_, index) => index + 1)); + return activeStepIds + .filter((step) => merged[step] === 'running') .sort((a, b) => a - b); } function inferStoppedRecordStep(state = {}) { const statuses = { ...DEFAULT_STATE.stepStatuses, ...(state?.stepStatuses || {}) }; - const stepIds = Object.keys(statuses) - .map((step) => Number(step)) - .filter(Number.isFinite) - .sort((left, right) => left - right); + const stepIds = typeof getStepIdsForState === 'function' + ? getStepIdsForState(state) + : (typeof STEP_IDS !== 'undefined' && Array.isArray(STEP_IDS) && STEP_IDS.length + ? STEP_IDS + : Array.from({ length: typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10 }, (_, index) => index + 1)); const runningSteps = stepIds.filter((step) => statuses[step] === 'running'); if (runningSteps.length) { @@ -5396,9 +6687,13 @@ async function ensureManualInteractionAllowed(actionLabel) { async function skipStep(step) { const state = await ensureManualInteractionAllowed('跳过步骤'); - const stepIds = getStepIdsForState(state); + const activeStepIds = typeof getStepIdsForState === 'function' + ? getStepIdsForState(state) + : (typeof STEP_IDS !== 'undefined' && Array.isArray(STEP_IDS) && STEP_IDS.length + ? STEP_IDS + : Array.from({ length: typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10 }, (_, index) => index + 1)); - if (!Number.isInteger(step) || !stepIds.includes(step)) { + if (!Number.isInteger(step) || !activeStepIds.includes(step)) { throw new Error(`无效步骤:${step}`); } @@ -5411,10 +6706,12 @@ async function skipStep(step) { throw new Error(`步骤 ${step} 已完成,无需再跳过。`); } - if (step > 1) { - const prevStatus = statuses[step - 1]; + const currentIndex = activeStepIds.indexOf(step); + if (currentIndex > 0) { + const prevStep = activeStepIds[currentIndex - 1]; + const prevStatus = statuses[prevStep]; if (!isStepDoneStatus(prevStatus)) { - throw new Error(`请先完成步骤 ${step - 1},再跳过步骤 ${step}。`); + throw new Error(`请先完成步骤 ${prevStep},再跳过步骤 ${step}。`); } } @@ -5673,6 +6970,7 @@ async function handleStepData(step, payload) { if ((shouldUseCustomRegistrationEmail(latestState) || shouldClearCustomPoolEmail) && latestState.email) { await setEmailStateSilently(null); } + await finalizePhoneActivationAfterSuccessfulFlow(latestState); break; } } @@ -5687,7 +6985,28 @@ const stepWaiters = new Map(); let resumeWaiter = null; const AUTO_RUN_SIGNAL_COMPLETION_TIMEOUT_MS = 120000; const AUTO_RUN_BACKGROUND_COMPLETED_STEPS = new Set([1, 2, 4, 6, 7, 8, 9]); -const STEP_COMPLETION_SIGNAL_STEPS = new Set([3, 5, 10]); +const STEP_COMPLETION_SIGNAL_STEPS = new Set([3, 5, 10, 12]); +const AUTO_RUN_BACKGROUND_COMPLETED_STEP_KEYS = new Set([ + 'open-chatgpt', + 'submit-signup-email', + 'fetch-signup-code', + 'clear-login-cookies', + 'plus-checkout-create', + 'plus-checkout-billing', + 'paypal-approve', + 'plus-checkout-return', + 'oauth-login', + 'fetch-login-code', + 'confirm-oauth', +]); +const STEP_COMPLETION_SIGNAL_STEP_KEYS = new Set([ + 'fill-password', + 'fill-profile', + 'platform-verify', +]); +const AUTO_RUN_PRE_EXECUTION_DELAYS_BY_STEP_KEY = new Map([ + ['plus-checkout-create', 20000], +]); function waitForStepComplete(step, timeoutMs = 120000) { throwIfStopped(); @@ -5734,10 +7053,37 @@ function waitForStepComplete(step, timeoutMs = 120000) { return waiter.promise; } -function doesStepUseCompletionSignal(step) { +function getStepExecutionKeyForState(step, state = {}) { + if (typeof getStepDefinitionForState !== 'function') { + return ''; + } + return String(getStepDefinitionForState(step, state)?.key || '').trim(); +} + +function doesStepUseBackgroundCompletion(step, state = {}) { + const stepKey = getStepExecutionKeyForState(step, state); + if (stepKey) { + return AUTO_RUN_BACKGROUND_COMPLETED_STEP_KEYS.has(stepKey); + } + return AUTO_RUN_BACKGROUND_COMPLETED_STEPS.has(step); +} + +function doesStepUseCompletionSignal(step, state = {}) { + const stepKey = getStepExecutionKeyForState(step, state); + if (stepKey) { + return STEP_COMPLETION_SIGNAL_STEP_KEYS.has(stepKey); + } return STEP_COMPLETION_SIGNAL_STEPS.has(step); } +function getAutoRunPreExecutionDelayMs(step, state = {}) { + const stepKey = getStepExecutionKeyForState(step, state); + if (stepKey) { + return AUTO_RUN_PRE_EXECUTION_DELAYS_BY_STEP_KEY.get(stepKey) || 0; + } + return 0; +} + function notifyStepComplete(step, payload) { const waiter = stepWaiters.get(step); console.log(LOG_PREFIX, `[notifyStepComplete] step ${step}, hasWaiter=${Boolean(waiter)}`); @@ -5750,6 +7096,19 @@ function notifyStepError(step, error) { if (waiter) waiter.reject(new Error(error)); } +async function runCompletedStepSideEffects(step, payload, completionState, lastStepId) { + await handleStepData(step, payload); + if (step === lastStepId) { + await appendAndBroadcastAccountRunRecord('success', completionState); + } +} + +async function reportCompletedStepSideEffectError(step, error) { + const message = getErrorMessage(error); + console.warn(LOG_PREFIX, `[completeStepFromBackground] step ${step} post-completion side effect failed:`, error); + await addLog(`步骤 ${step} 已完成,但完成后的收尾处理失败:${message}`, 'warn'); +} + async function completeStepFromBackground(step, payload = {}) { if (stopRequested) { await setStepStatus(step, 'stopped'); @@ -5759,14 +7118,21 @@ async function completeStepFromBackground(step, payload = {}) { } const latestState = await getState(); - const completionLastStepId = getLastStepIdForState(latestState); - const completionState = step === completionLastStepId ? latestState : null; + const lastStepId = typeof getLastStepIdForState === 'function' + ? getLastStepIdForState(latestState) + : (typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10); + const completionState = step === lastStepId ? latestState : null; await setStepStatus(step, 'completed'); await addLog(`步骤 ${step} 已完成`, 'ok'); - await handleStepData(step, payload); - if (step === completionLastStepId) { - await appendAndBroadcastAccountRunRecord('success', completionState); + + if (step === lastStepId) { + notifyStepComplete(step, payload); + void runCompletedStepSideEffects(step, payload, completionState, lastStepId) + .catch((error) => reportCompletedStepSideEffectError(step, error)); + return; } + + await runCompletedStepSideEffects(step, payload, completionState, lastStepId); notifyStepComplete(step, payload); } @@ -5843,7 +7209,7 @@ async function executeStepViaCompletionSignal(step, timeoutMs = AUTO_RUN_SIGNAL_ async function waitForRunningStepsToFinish(payload = {}) { let currentState = await getState(); - let runningSteps = getRunningSteps(currentState.stepStatuses); + let runningSteps = getRunningSteps(currentState.stepStatuses, currentState); if (!runningSteps.length) { return currentState; } @@ -5854,7 +7220,7 @@ async function waitForRunningStepsToFinish(payload = {}) { while (runningSteps.length) { await sleepWithStop(250); currentState = await getState(); - runningSteps = getRunningSteps(currentState.stepStatuses); + runningSteps = getRunningSteps(currentState.stepStatuses, currentState); } await addLog('自动继续:当前运行步骤已结束,准备按最新进度继续自动流程...', 'info'); @@ -5862,15 +7228,27 @@ async function waitForRunningStepsToFinish(payload = {}) { } const AUTH_CHAIN_STEP_IDS = new Set([7, 8, 9, 10, 11, 12, 13]); +const AUTH_CHAIN_STEP_KEYS = new Set([ + 'oauth-login', + 'fetch-login-code', + 'confirm-oauth', + 'platform-verify', +]); let activeTopLevelAuthChainExecution = null; -function isAuthChainStep(step) { +function isAuthChainStep(step, state = {}) { + const stepKey = typeof getStepDefinitionForState === 'function' + ? String(getStepDefinitionForState(step, state)?.key || '').trim() + : ''; + if (stepKey && typeof AUTH_CHAIN_STEP_KEYS !== 'undefined') { + return AUTH_CHAIN_STEP_KEYS.has(stepKey); + } return AUTH_CHAIN_STEP_IDS.has(Number(step)); } -async function acquireTopLevelAuthChainExecution(step) { +async function acquireTopLevelAuthChainExecution(step, state = {}) { const normalizedStep = Number(step); - if (!isAuthChainStep(normalizedStep)) { + if (!isAuthChainStep(normalizedStep, state)) { return { joined: false, release() {}, @@ -5916,7 +7294,7 @@ async function acquireTopLevelAuthChainExecution(step) { async function markRunningStepsStopped() { const state = await getState(); - const runningSteps = getRunningSteps(state.stepStatuses); + const runningSteps = getRunningSteps(state.stepStatuses, state); for (const step of runningSteps) { await setStepStatus(step, 'stopped'); @@ -5926,7 +7304,7 @@ async function markRunningStepsStopped() { async function requestStop(options = {}) { const { logMessage = '已收到停止请求,正在取消当前操作...' } = options; const state = await getState(); - const runningSteps = getRunningSteps(state.stepStatuses); + const runningSteps = getRunningSteps(state.stepStatuses, state); const inferredStopStep = inferStoppedRecordStep(state); const timerPlan = getPendingAutoRunTimerPlan(state); @@ -5969,6 +7347,7 @@ async function requestStop(options = {}) { stopRequested = true; clearCurrentAutoRunSessionId(); cancelPendingCommands(); + abortActiveIcloudRequests(); cleanupStep8NavigationListeners(); rejectPendingStep8(new Error(STOP_ERROR_MESSAGE)); @@ -6010,7 +7389,8 @@ async function requestStop(options = {}) { async function executeStep(step, options = {}) { const { deferRetryableTransportError = false } = options; console.log(LOG_PREFIX, `Executing step ${step}`); - const authChainClaim = await acquireTopLevelAuthChainExecution(step); + let state = await getState(); + const authChainClaim = await acquireTopLevelAuthChainExecution(step, state); if (authChainClaim.joined) { return; } @@ -6022,7 +7402,7 @@ async function executeStep(step, options = {}) { await addLog(`步骤 ${step} 开始执行`); await humanStepDelay(); - const state = await getState(); + state = await getState(); // Set flow start time on first step if (step === 1 && !state.flowStartTime) { @@ -6030,14 +7410,21 @@ async function executeStep(step, options = {}) { } const activeStepRegistry = getStepRegistryForState(state); - await activeStepRegistry.executeStep(step, { ...state, visibleStep: step }); + if (!activeStepRegistry?.getStepDefinition?.(step)) { + throw new Error(`当前模式下不存在步骤:${step}`); + } + await activeStepRegistry.executeStep(step, { + ...state, + visibleStep: Number(step), + stepDefinition: getStepDefinitionForState(step, state), + }); } catch (err) { executionError = err; - const state = await getState(); + const errorState = await getState(); if (isStopError(err)) { await setStepStatus(step, 'stopped'); await addLog(`步骤 ${step} 已被用户停止`, 'warn'); - await appendManualAccountRunRecordIfNeeded(`step${step}_stopped`, state, getErrorMessage(err)); + await appendManualAccountRunRecordIfNeeded(`step${step}_stopped`, errorState, getErrorMessage(err)); throw err; } if (isTerminalSecurityBlockedError(err)) { @@ -6048,10 +7435,10 @@ async function executeStep(step, options = {}) { await handleBrowserSwitchRequired(err); throw new Error(STOP_ERROR_MESSAGE); } - if (!(deferRetryableTransportError && doesStepUseCompletionSignal(step) && isRetryableContentScriptTransportError(err))) { + if (!(deferRetryableTransportError && doesStepUseCompletionSignal(step, errorState) && isRetryableContentScriptTransportError(err))) { await setStepStatus(step, 'failed'); await addLog(`步骤 ${step} 失败:${err.message}`, 'error'); - await appendManualAccountRunRecordIfNeeded(`step${step}_failed`, state, getErrorMessage(err)); + await appendManualAccountRunRecordIfNeeded(`step${step}_failed`, errorState, getErrorMessage(err)); } else { console.warn( LOG_PREFIX, @@ -6081,12 +7468,23 @@ async function executeStepAndWait(step, delayAfter = 2000) { await sleepWithStop(delaySeconds * 1000); } - if (AUTO_RUN_BACKGROUND_COMPLETED_STEPS.has(step)) { + let executionState = await getState(); + const preExecutionDelayMs = getAutoRunPreExecutionDelayMs(step, executionState); + if (preExecutionDelayMs > 0) { + await addLog( + `自动运行:步骤 ${step} 执行前固定等待 ${Math.round(preExecutionDelayMs / 1000)} 秒,确保 Plus Checkout 创建前页面稳定。`, + 'info' + ); + await sleepWithStop(preExecutionDelayMs); + executionState = await getState(); + } + + if (doesStepUseBackgroundCompletion(step, executionState)) { await addLog(`自动运行:步骤 ${step} 由后台流程负责收尾,执行函数返回后将直接进入下一步。`, 'info'); await executeStep(step); const latestState = await getState(); await addLog(`自动运行:步骤 ${step} 已执行返回,当前状态为 ${latestState.stepStatuses?.[step] || 'pending'},准备继续后续步骤。`, 'info'); - } else if (doesStepUseCompletionSignal(step)) { + } else if (doesStepUseCompletionSignal(step, executionState)) { await addLog(`自动运行:步骤 ${step} 已发起,正在等待完成信号(超时 ${AUTO_RUN_SIGNAL_COMPLETION_TIMEOUT_MS / 1000} 秒)。`, 'info'); await executeStepViaCompletionSignal(step, AUTO_RUN_SIGNAL_COMPLETION_TIMEOUT_MS); await addLog(`自动运行:步骤 ${step} 已收到完成信号,准备继续后续步骤。`, 'info'); @@ -6224,6 +7622,39 @@ function isMail2925PoolExhaustedPauseError(error) { return /^MAIL2925_POOL_EXHAUSTED_PAUSE::/.test(message); } +const payPalAccountStore = self.MultiPageBackgroundPayPalAccountStore?.createPayPalAccountStore({ + broadcastDataUpdate, + findPayPalAccount, + getState, + normalizePayPalAccount, + normalizePayPalAccounts, + setPersistentSettings, + setState, + upsertPayPalAccountInList, +}); + +async function syncPayPalAccounts(accounts) { + return payPalAccountStore?.syncPayPalAccounts?.(accounts) || []; +} + +async function upsertPayPalAccount(input = {}) { + if (!payPalAccountStore?.upsertPayPalAccount) { + throw new Error('PayPal 账号存储能力尚未接入。'); + } + return payPalAccountStore.upsertPayPalAccount(input); +} + +async function setCurrentPayPalAccount(accountId) { + if (!payPalAccountStore?.setCurrentPayPalAccount) { + throw new Error('PayPal 账号选择能力尚未接入。'); + } + return payPalAccountStore.setCurrentPayPalAccount(accountId); +} + +function getCurrentPayPalAccount(state = null) { + return payPalAccountStore?.getCurrentPayPalAccount?.(state || {}) || null; +} + const generatedEmailHelpers = self.MultiPageGeneratedEmailHelpers?.createGeneratedEmailHelpers({ addLog, buildGeneratedAliasEmail, @@ -6460,6 +7891,7 @@ const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoR cancelPendingCommands, clearStopRequest: () => clearStopRequest(), createAutoRunSessionId: () => createAutoRunSessionId(), + ensureHotmailMailboxReadyForAutoRunRound: (...args) => ensureHotmailMailboxReadyForAutoRunRound(...args), getAutoRunStatusPayload, getErrorMessage, getFirstUnfinishedStep, @@ -6521,6 +7953,42 @@ async function resumeAutoRunIfWaitingForEmail(options = {}) { return false; } +function shouldStopIcloudAutoFetchRetries(error) { + if (!error) { + return false; + } + + if (error.code === 'ICLOUD_TRANSIENT_CONTEXT') { + return true; + } + + const message = getErrorMessage(error).toLowerCase(); + if (message.includes('请先在新打开的 icloud 页面中完成登录')) { + return true; + } + return message.includes('网络/上下文波动') + || message.includes('could not validate icloud session') + || message.includes('status 421') + || message.includes('failed to fetch') + || message.includes('network request failed') + || message.includes('networkerror') + || message.includes('cors') + || message.includes('address space') + || message.includes('timed out') + || message.includes('timeout'); +} + +function shouldStopEmailAutoFetchRetries(generator, error) { + if (generator === 'icloud' && shouldStopIcloudAutoFetchRetries(error)) { + return true; + } + const message = String(error?.message || ''); + if (generator === 'cloudflare' && /域名/.test(message)) { + return true; + } + return generator === CLOUDFLARE_TEMP_EMAIL_GENERATOR && /(服务地址|Admin Auth|域名)/.test(message); +} + async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) { const currentState = await getState(); if (isHotmailProvider(currentState)) { @@ -6606,7 +8074,9 @@ async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) { const generator = normalizeEmailGenerator(currentState.emailGenerator); const generatorLabel = getEmailGeneratorLabel(generator); let lastError = null; + let attemptedFetches = 0; for (let attempt = 1; attempt <= EMAIL_FETCH_MAX_ATTEMPTS; attempt++) { + attemptedFetches = attempt; try { if (attempt > 1) { await addLog(`${generatorLabel}:正在进行第 ${attempt}/${EMAIL_FETCH_MAX_ATTEMPTS} 次自动获取重试...`, 'warn'); @@ -6623,16 +8093,17 @@ async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) { } catch (err) { lastError = err; await addLog(`${generatorLabel}自动获取失败(${attempt}/${EMAIL_FETCH_MAX_ATTEMPTS}):${err.message}`, 'warn'); - if ( - (generator === 'cloudflare' && /域名/.test(String(err.message || ''))) - || (generator === CLOUDFLARE_TEMP_EMAIL_GENERATOR && /(服务地址|Admin Auth|域名)/.test(String(err.message || ''))) - ) { + if (generator === 'icloud' && shouldStopIcloudAutoFetchRetries(err)) { + await addLog('iCloud:检测到会话/网络异常,本轮将停止重复重试。请先确认 iCloud 页面已登录,再点击“我已登录”或手动粘贴邮箱继续。', 'warn'); + } + if (shouldStopEmailAutoFetchRetries(generator, err)) { break; } } } - await addLog(`${generatorLabel}自动获取已连续失败 ${EMAIL_FETCH_MAX_ATTEMPTS} 次:${lastError?.message || '未知错误'}`, 'error'); + const totalAttempts = Math.max(1, attemptedFetches); + await addLog(`${generatorLabel}自动获取已连续失败 ${totalAttempts} 次:${lastError?.message || '未知错误'}`, 'error'); await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮已暂停:请先自动获取邮箱或手动粘贴邮箱,然后继续 ===`, 'warn'); await broadcastAutoRunStatus('waiting_email', { currentRun: targetRun, @@ -6755,7 +8226,9 @@ async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) { const generator = normalizeEmailGenerator(currentState.emailGenerator); const generatorLabel = getEmailGeneratorLabel(generator); let lastError = null; + let attemptedFetches = 0; for (let attempt = 1; attempt <= EMAIL_FETCH_MAX_ATTEMPTS; attempt++) { + attemptedFetches = attempt; try { if (attempt > 1) { await addLog(`${generatorLabel}:正在进行第 ${attempt}/${EMAIL_FETCH_MAX_ATTEMPTS} 次自动获取重试...`, 'warn'); @@ -6772,16 +8245,17 @@ async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) { } catch (err) { lastError = err; await addLog(`${generatorLabel}自动获取失败(${attempt}/${EMAIL_FETCH_MAX_ATTEMPTS}):${err.message}`, 'warn'); - if ( - (generator === 'cloudflare' && /域名/.test(String(err.message || ''))) - || (generator === CLOUDFLARE_TEMP_EMAIL_GENERATOR && /(服务地址|Admin Auth|域名)/.test(String(err.message || ''))) - ) { + if (generator === 'icloud' && shouldStopIcloudAutoFetchRetries(err)) { + await addLog('iCloud:检测到会话/网络异常,本轮将停止重复重试。请先确认 iCloud 页面已登录,再点击“我已登录”或手动粘贴邮箱继续。', 'warn'); + } + if (shouldStopEmailAutoFetchRetries(generator, err)) { break; } } } - await addLog(`${generatorLabel}自动获取已连续失败 ${EMAIL_FETCH_MAX_ATTEMPTS} 次:${lastError?.message || '未知错误'}`, 'error'); + const totalAttempts = Math.max(1, attemptedFetches); + await addLog(`${generatorLabel}自动获取已连续失败 ${totalAttempts} 次:${lastError?.message || '未知错误'}`, 'error'); await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮已暂停:请先自动获取邮箱或手动粘贴邮箱,然后继续 ===`, 'warn'); await broadcastAutoRunStatus('waiting_email', { currentRun: targetRun, @@ -6847,11 +8321,13 @@ async function runAutoSequenceFromStep(startStep, context = {}) { let restartFromStep1WithCurrentEmail = false; let step = Math.max(currentStartStep, 4); - while (true) { + while (step <= (typeof getLastStepIdForState === 'function' + ? getLastStepIdForState(await getState()) + : (typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10))) { const latestState = await getState(); - const flowLastStepId = getLastStepIdForState(latestState); - if (step > flowLastStepId) { - break; + if (typeof getStepDefinitionForState === 'function' && !getStepDefinitionForState(step, latestState)) { + step += 1; + continue; } const currentStatus = latestState.stepStatuses?.[step] || 'pending'; if (isStepDoneStatus(currentStatus)) { @@ -6902,6 +8378,11 @@ async function runAutoSequenceFromStep(startStep, context = {}) { const restartDecision = await getPostStep6AutoRestartDecision(step, err); if (restartDecision.shouldRestart) { postStep7RestartCount += 1; + const restartStep = restartDecision.restartStep + || (typeof getAuthChainStartStepId === 'function' + ? getAuthChainStartStepId(await getState()) + : FINAL_OAUTH_CHAIN_START_STEP); + const resetAfterStep = Math.max(1, restartStep - 1); const authState = restartDecision.authState; const authStateLabel = authState?.state ? getLoginAuthStateLabel(authState.state) : '未知页面'; const authStateSuffix = authState?.url @@ -6910,19 +8391,22 @@ async function runAutoSequenceFromStep(startStep, context = {}) { ? `当前认证页:${authStateLabel}` : '未获取到认证页状态'; await addLog( - `步骤 ${step}:检测到报错且当前未进入 add-phone,正在回到步骤 7 重新开始授权流程(第 ${postStep7RestartCount} 次重开)。${authStateSuffix};原因:${restartDecision.errorMessage || '未知错误'}`, + `步骤 ${step}:检测到报错且当前未进入 add-phone,正在回到步骤 ${restartStep} 重新开始授权流程(第 ${postStep7RestartCount} 次重开)。${authStateSuffix};原因:${restartDecision.errorMessage || '未知错误'}`, 'warn' ); - await invalidateDownstreamAfterStepRestart(6, { - logLabel: `步骤 ${step} 报错后准备回到步骤 7 重试(第 ${postStep7RestartCount} 次重开)`, + await invalidateDownstreamAfterStepRestart(resetAfterStep, { + logLabel: `步骤 ${step} 报错后准备回到步骤 ${restartStep} 重试(第 ${postStep7RestartCount} 次重开)`, }); - step = 7; + step = restartStep; continue; } if (restartDecision.blockedByAddPhone) { const addPhoneUrl = restartDecision.authState?.url || 'https://auth.openai.com/add-phone'; - await addLog(`步骤 ${step}:检测到认证流程进入 add-phone(${addPhoneUrl}),停止自动回到步骤 7 重开。`, 'warn'); + const authChainStartStep = typeof getAuthChainStartStepId === 'function' + ? getAuthChainStartStepId(await getState()) + : FINAL_OAUTH_CHAIN_START_STEP; + await addLog(`步骤 ${step}:检测到认证流程进入 add-phone(${addPhoneUrl}),停止自动回到步骤 ${authChainStartStep} 重开。`, 'warn'); } throw err; } @@ -7204,54 +8688,6 @@ const step6Executor = self.MultiPageBackgroundStep6?.createStep6Executor({ completeStepFromBackground, runPreStep6CookieCleanup, }); -const plusCheckoutCreateExecutor = self.MultiPageBackgroundPlusCheckoutCreate?.createPlusCheckoutCreateExecutor({ - addLog, - chrome, - completeStepFromBackground, - ensureContentScriptReadyOnTabUntilStopped, - registerTab, - sendTabMessageUntilStopped, - setState, - sleepWithStop, - waitForTabCompleteUntilStopped, -}); -const plusCheckoutBillingExecutor = self.MultiPageBackgroundPlusCheckoutBilling?.createPlusCheckoutBillingExecutor({ - addLog, - chrome, - completeStepFromBackground, - ensureContentScriptReadyOnTabUntilStopped, - fetch, - generateRandomName, - getAddressSeedForCountry, - getTabId, - isTabAlive, - setState, - sleepWithStop, - waitForTabCompleteUntilStopped, - waitForTabUrlMatchUntilStopped, -}); -const payPalApproveExecutor = self.MultiPageBackgroundPayPalApprove?.createPayPalApproveExecutor({ - addLog, - chrome, - completeStepFromBackground, - ensureContentScriptReadyOnTabUntilStopped, - getTabId, - isTabAlive, - sendTabMessageUntilStopped, - setState, - sleepWithStop, - waitForTabCompleteUntilStopped, - waitForTabUrlMatchUntilStopped, -}); -const plusReturnConfirmExecutor = self.MultiPageBackgroundPlusReturnConfirm?.createPlusReturnConfirmExecutor({ - addLog, - completeStepFromBackground, - getTabId, - isTabAlive, - setState, - sleepWithStop, - waitForTabUrlMatchUntilStopped, -}); const step7Executor = self.MultiPageBackgroundStep7?.createStep7Executor({ addLog, completeStepFromBackground, @@ -7298,6 +8734,55 @@ const step8Executor = self.MultiPageBackgroundStep8?.createStep8Executor({ STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS, throwIfStopped, }); +const plusCheckoutCreateExecutor = self.MultiPageBackgroundPlusCheckoutCreate?.createPlusCheckoutCreateExecutor({ + addLog, + chrome, + completeStepFromBackground, + ensureContentScriptReadyOnTabUntilStopped, + registerTab, + sendTabMessageUntilStopped, + setState, + sleepWithStop, + waitForTabCompleteUntilStopped, +}); +const plusCheckoutBillingExecutor = self.MultiPageBackgroundPlusCheckoutBilling?.createPlusCheckoutBillingExecutor({ + addLog, + chrome, + completeStepFromBackground, + ensureContentScriptReadyOnTabUntilStopped, + fetch: typeof fetch === 'function' ? fetch.bind(globalThis) : null, + generateRandomName, + getAddressSeedForCountry: self.MultiPageAddressSources?.getAddressSeedForCountry, + getTabId, + isTabAlive, + sendTabMessageUntilStopped, + setState, + sleepWithStop, + waitForTabCompleteUntilStopped, + waitForTabUrlMatchUntilStopped, +}); +const payPalApproveExecutor = self.MultiPageBackgroundPayPalApprove?.createPayPalApproveExecutor({ + addLog, + chrome, + completeStepFromBackground, + ensureContentScriptReadyOnTabUntilStopped, + getTabId, + isTabAlive, + sendTabMessageUntilStopped, + setState, + sleepWithStop, + waitForTabCompleteUntilStopped, + waitForTabUrlMatchUntilStopped, +}); +const plusReturnConfirmExecutor = self.MultiPageBackgroundPlusReturnConfirm?.createPlusReturnConfirmExecutor({ + addLog, + completeStepFromBackground, + getTabId, + isTabAlive, + setState, + sleepWithStop, + waitForTabUrlMatchUntilStopped, +}); const step10Executor = self.MultiPageBackgroundStep10?.createStep10Executor({ addLog, chrome, @@ -7324,8 +8809,8 @@ const stepExecutorsByKey = { 'fetch-signup-code': (state) => step4Executor.executeStep4(state), 'fill-profile': (state) => step5Executor.executeStep5(state), 'clear-login-cookies': () => step6Executor.executeStep6(), - 'plus-checkout-create': () => plusCheckoutCreateExecutor.executePlusCheckoutCreate(), - 'plus-checkout-billing': () => plusCheckoutBillingExecutor.executePlusCheckoutBilling(), + 'plus-checkout-create': (state) => plusCheckoutCreateExecutor.executePlusCheckoutCreate(state), + 'plus-checkout-billing': (state) => plusCheckoutBillingExecutor.executePlusCheckoutBilling(state), 'paypal-approve': (state) => payPalApproveExecutor.executePayPalApprove(state), 'plus-checkout-return': (state) => plusReturnConfirmExecutor.executePlusReturnConfirm(state), 'oauth-login': (state) => step7Executor.executeStep7(state), @@ -7355,6 +8840,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter deleteHotmailAccounts, deleteIcloudAlias, deleteUsedIcloudAliases, + findPayPalAccount, disableUsedLuckmailPurchases, doesStepUseCompletionSignal, ensureMail2925MailboxSession, @@ -7363,6 +8849,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter executeStepViaCompletionSignal, exportSettingsBundle, fetchGeneratedEmail, + finalizePhoneActivationAfterSuccessfulFlow, finalizeStep3Completion: async () => { const currentState = await getState(); const signupTabId = await getTabId('signup-page'); @@ -7399,9 +8886,11 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter listIcloudAliases, listLuckmailPurchasesForManagement, refreshIpProxyPool, + getCurrentPayPalAccount, getCurrentMail2925Account, normalizeHotmailAccounts, normalizeMail2925Accounts, + normalizePayPalAccounts, normalizeRunCount, AUTO_RUN_TIMER_KIND_SCHEDULED_START, notifyStepComplete, @@ -7417,6 +8906,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter selectLuckmailPurchase, switchIpProxy, changeIpProxyExit, + setCurrentPayPalAccount, setCurrentHotmailAccount, setCurrentMail2925Account, setContributionMode, @@ -7436,23 +8926,32 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter startAutoRunLoop, pollContributionStatus: (...args) => contributionOAuthManager?.pollContributionStatus?.(...args), syncHotmailAccounts, + syncPayPalAccounts, deleteMail2925Account, deleteMail2925Accounts, testHotmailAccountMailAccess, + upsertPayPalAccount, upsertMail2925Account, upsertHotmailAccount, verifyHotmailAccount, }); -function getStepRegistryForState(state = {}) { - const stepDefinitions = getStepDefinitionsForState(state); + +function buildStepRegistry(definitions = []) { return self.MultiPageBackgroundStepRegistry?.createStepRegistry( - stepDefinitions.map((definition) => ({ + definitions.map((definition) => ({ ...definition, execute: stepExecutorsByKey[definition.key], })) ); } +const normalStepRegistry = buildStepRegistry(NORMAL_STEP_DEFINITIONS); +const plusStepRegistry = buildStepRegistry(PLUS_STEP_DEFINITIONS); + +function getStepRegistryForState(state = {}) { + return isPlusModeState(state) ? plusStepRegistry : normalStepRegistry; +} + async function requestOAuthUrlFromPanel(state, options = {}) { return panelBridge.requestOAuthUrlFromPanel(state, options); } @@ -7526,30 +9025,20 @@ function getMailConfig(state) { return { provider: HOTMAIL_PROVIDER, label: 'Hotmail(API对接/本地助手)' }; } if (provider === ICLOUD_PROVIDER) { - const resolveForwardMailConfig = typeof getIcloudForwardMailConfig === 'function' - ? getIcloudForwardMailConfig - : (typeof getSharedIcloudForwardMailConfig === 'function' ? getSharedIcloudForwardMailConfig : null); const configuredHost = getConfiguredIcloudHostPreference(state) || normalizeIcloudHost(state?.preferredIcloudHost) || 'icloud.com'; - const targetMailboxType = typeof normalizeIcloudTargetMailboxType === 'function' - ? normalizeIcloudTargetMailboxType(state?.icloudTargetMailboxType) - : 'icloud-inbox'; - if (targetMailboxType === 'forward-mailbox' && typeof resolveForwardMailConfig === 'function') { - const normalizeForwardProvider = typeof normalizeIcloudForwardMailProvider === 'function' - ? normalizeIcloudForwardMailProvider - : (value = '') => String(value || '').trim().toLowerCase() === 'gmail' ? 'gmail' : 'qq'; - const forwardProvider = normalizeForwardProvider(state?.icloudForwardMailProvider); - const forwardConfig = resolveForwardMailConfig(forwardProvider); - if (forwardConfig && !forwardConfig.error) { - return { - ...forwardConfig, - label: `iCloud 转发(${forwardConfig.label})`, - icloudForwarding: true, - }; - } + const targetMailboxType = normalizeIcloudTargetMailboxType(state?.icloudTargetMailboxType); + const useForwardMailbox = targetMailboxType === 'forward-mailbox'; + if (useForwardMailbox) { + const forwardProvider = normalizeIcloudForwardMailProvider(state?.icloudForwardMailProvider); + const forwardConfig = getSharedIcloudForwardMailConfig(forwardProvider); + return { + ...forwardConfig, + label: `iCloud 转发(${forwardConfig.label})`, + icloudForwarding: true, + }; } - const loginUrl = getIcloudLoginUrlForHost(configuredHost) || 'https://www.icloud.com/'; const mailUrl = getIcloudMailUrlForHost(configuredHost) || loginUrl; return { @@ -7799,12 +9288,13 @@ async function runPreStep6CookieCleanup() { // Step 7: Login and ensure the auth page reaches the login verification page // ============================================================ -async function refreshOAuthUrlBeforeStep6(state) { +async function refreshOAuthUrlBeforeStep6(state, options = {}) { + const visibleStep = Number(options.visibleStep) || Number(state?.visibleStep) || 7; if (state?.contributionModeExpected && !state?.contributionMode) { - throw new Error('步骤 7:当前自动流程预期使用贡献模式,但运行态 contributionMode 已丢失,已阻止回退到普通 CPA / SUB2API / Codex2API 链路。请重新进入贡献模式后再点击自动。'); + throw new Error(`步骤 ${visibleStep}:当前自动流程预期使用贡献模式,但运行态 contributionMode 已丢失,已阻止回退到普通 CPA / SUB2API / Codex2API 链路。请重新进入贡献模式后再点击自动。`); } if (state?.contributionMode && contributionOAuthManager?.startContributionFlow) { - await addLog('步骤 7:contributionMode=true,走公开贡献接口,正在申请 OAuth 登录地址...', 'info'); + await addLog(`步骤 ${visibleStep}:contributionMode=true,走公开贡献接口,正在申请 OAuth 登录地址...`, 'info'); const contributionState = await contributionOAuthManager.startContributionFlow({ nickname: state.contributionNickname || '', openAuthTab: false, @@ -7817,9 +9307,9 @@ async function refreshOAuthUrlBeforeStep6(state) { await handleStepData(1, { oauthUrl }); return oauthUrl; } - await addLog(`步骤 7:contributionMode=false,走普通 CPA / SUB2API / Codex2API 链路(当前面板:${getPanelModeLabel(state)}),正在刷新 OAuth 登录地址...`, 'info'); + await addLog(`步骤 ${visibleStep}:contributionMode=false,走普通 CPA / SUB2API / Codex2API 链路(当前面板:${getPanelModeLabel(state)}),正在刷新 OAuth 登录地址...`, 'info'); console.log(LOG_PREFIX, '[refreshOAuthUrlBeforeStep6] requesting fresh OAuth directly from panel'); - const refreshResult = await requestOAuthUrlFromPanel(state, { logLabel: '步骤 7' }); + const refreshResult = await requestOAuthUrlFromPanel(state, { logLabel: `步骤 ${visibleStep}` }); await handleStepData(1, refreshResult); if (!refreshResult?.oauthUrl) { @@ -7829,9 +9319,12 @@ async function refreshOAuthUrlBeforeStep6(state) { return refreshResult.oauthUrl; } -function buildOAuthFlowTimeoutError(step, actionLabel = '后续授权流程') { +function buildOAuthFlowTimeoutError(step, actionLabel = '后续授权流程', state = {}) { + const restartStep = typeof getAuthChainStartStepId === 'function' + ? getAuthChainStartStepId(state) + : FINAL_OAUTH_CHAIN_START_STEP; return new Error( - `步骤 ${step}:从拿到 OAuth 登录地址开始,${Math.round(OAUTH_FLOW_TIMEOUT_MS / 60000)} 分钟内未完成${actionLabel},结束当前链路,准备从步骤 7 重新开始。` + `步骤 ${step}:从拿到 OAuth 登录地址开始,${Math.round(OAUTH_FLOW_TIMEOUT_MS / 60000)} 分钟内未完成${actionLabel},结束当前链路,准备从步骤 ${restartStep} 重新开始。` ); } @@ -7882,7 +9375,7 @@ async function getOAuthFlowRemainingMs(options = {}) { const remainingMs = deadlineAt - Date.now(); if (remainingMs <= 0) { - throw buildOAuthFlowTimeoutError(step, actionLabel); + throw buildOAuthFlowTimeoutError(step, actionLabel, state); } return remainingMs; @@ -7898,9 +9391,11 @@ async function getOAuthFlowStepTimeoutMs(defaultTimeoutMs, options = {}) { const budgetMs = remainingMs - reserveMs; if (budgetMs <= 0) { + const stateForError = options.state || await getState(); throw buildOAuthFlowTimeoutError( Number(options.step) || 7, - String(options.actionLabel || '后续授权流程').trim() || '后续授权流程' + String(options.actionLabel || '后续授权流程').trim() || '后续授权流程', + stateForError ); } @@ -7983,6 +9478,7 @@ async function getPostStep6AutoRestartDecision(step, error) { shouldRestart: false, blockedByAddPhone: false, forcedByPhoneVerificationTimeout: false, + restartStep: authChainStartStep, errorMessage, authState: null, }; @@ -7993,6 +9489,7 @@ async function getPostStep6AutoRestartDecision(step, error) { shouldRestart: true, blockedByAddPhone: false, forcedByPhoneVerificationTimeout: true, + restartStep: authChainStartStep, errorMessage, authState: null, }; @@ -8003,6 +9500,7 @@ async function getPostStep6AutoRestartDecision(step, error) { shouldRestart: false, blockedByAddPhone: true, forcedByPhoneVerificationTimeout: false, + restartStep: authChainStartStep, errorMessage, authState: null, }; @@ -8026,6 +9524,7 @@ async function getPostStep6AutoRestartDecision(step, error) { shouldRestart: false, blockedByAddPhone: true, forcedByPhoneVerificationTimeout: false, + restartStep: authChainStartStep, errorMessage, authState, }; @@ -8151,17 +9650,17 @@ async function ensureStep8VerificationPageReady(options = {}) { } const urlPart = pageState.url ? ` URL: ${pageState.url}` : ''; - throw new Error(`STEP8_RESTART_STEP7::步骤 8:当前认证页进入登录超时报错页,请回到步骤 7 重新开始。${urlPart}`.trim()); + throw new Error(`STEP8_RESTART_STEP7::步骤 ${visibleStep}:当前认证页进入登录超时报错页,请回到步骤 ${authLoginStep} 重新开始。${urlPart}`.trim()); } if (pageState.state === 'add_phone_page' || pageState.state === 'phone_verification_page') { const urlPart = pageState.url ? ` URL: ${pageState.url}` : ''; - throw new Error(`步骤 8:当前认证页进入手机号页面,当前流程无法继续自动授权。${urlPart}`.trim()); + throw new Error(`步骤 ${visibleStep}:当前认证页进入手机号页面,当前流程无法继续自动授权。${urlPart}`.trim()); } const stateLabel = getLoginAuthStateLabel(pageState.state); const urlPart = pageState.url ? ` URL: ${pageState.url}` : ''; - throw new Error(`当前未进入登录验证码页面,请先重新完成步骤 7。当前状态:${stateLabel}.${urlPart}`.trim()); + throw new Error(`当前未进入登录验证码页面,请先重新完成步骤 ${authLoginStep}。当前状态:${stateLabel}.${urlPart}`.trim()); } async function rerunStep7ForStep8Recovery(options = {}) { @@ -8172,27 +9671,33 @@ async function rerunStep7ForStep8Recovery(options = {}) { throwIfStopped(); const initialState = await getState(); + const authLoginStep = typeof getAuthChainStartStepId === 'function' + ? getAuthChainStartStepId(initialState) + : FINAL_OAUTH_CHAIN_START_STEP; await addLog(logMessage, 'warn'); - await setStepStatus(7, 'running'); - await addLog('步骤 7 开始执行'); + await setStepStatus(authLoginStep, 'running'); + await addLog(`步骤 ${authLoginStep} 开始执行`); try { - await step7Executor.executeStep7(initialState); + await step7Executor.executeStep7({ + ...initialState, + visibleStep: authLoginStep, + }); } catch (err) { const latestState = await getState(); if (isStopError(err)) { - await setStepStatus(7, 'stopped'); - await addLog('步骤 7 已被用户停止', 'warn'); - await appendManualAccountRunRecordIfNeeded('step7_stopped', latestState, getErrorMessage(err)); + await setStepStatus(authLoginStep, 'stopped'); + await addLog(`步骤 ${authLoginStep} 已被用户停止`, 'warn'); + await appendManualAccountRunRecordIfNeeded(`step${authLoginStep}_stopped`, latestState, getErrorMessage(err)); throw err; } if (isTerminalSecurityBlockedError(err)) { await handleCloudflareSecurityBlocked(err); throw new Error(STOP_ERROR_MESSAGE); } - await setStepStatus(7, 'failed'); - await addLog(`步骤 7 失败:${getErrorMessage(err)}`, 'error'); - await appendManualAccountRunRecordIfNeeded('step7_failed', latestState, getErrorMessage(err)); + await setStepStatus(authLoginStep, 'failed'); + await addLog(`步骤 ${authLoginStep} 失败:${getErrorMessage(err)}`, 'error'); + await appendManualAccountRunRecordIfNeeded(`step${authLoginStep}_failed`, latestState, getErrorMessage(err)); throw err; } @@ -8664,8 +10169,8 @@ async function executeContributionStep10(state) { 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'); + if (status === 'auto_approved') { + await addLog(`步骤 10:贡献流程已结束,最终状态:${latestState.contributionStatusMessage || status}`, 'ok'); await completeStepFromBackground(10, { contributionStatus: status, contributionStatusMessage: latestState.contributionStatusMessage || '', diff --git a/background/auto-run-controller.js b/background/auto-run-controller.js index 37705a9..25270f2 100644 --- a/background/auto-run-controller.js +++ b/background/auto-run-controller.js @@ -14,6 +14,7 @@ cancelPendingCommands, clearStopRequest, createAutoRunSessionId, + ensureHotmailMailboxReadyForAutoRunRound, getAutoRunStatusPayload, getErrorMessage, getFirstUnfinishedStep, @@ -391,6 +392,7 @@ inbucketMailbox: prevState.inbucketMailbox, cloudflareDomain: prevState.cloudflareDomain, cloudflareDomains: prevState.cloudflareDomains, + reusablePhoneActivation: prevState.reusablePhoneActivation, autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), autoRunSessionId: sessionId, tabRegistry: {}, @@ -439,6 +441,15 @@ sessionId, }); + if (!useExistingProgress && startStep === 1 && typeof ensureHotmailMailboxReadyForAutoRunRound === 'function') { + await ensureHotmailMailboxReadyForAutoRunRound({ + targetRun, + totalRuns, + attemptRun, + sessionId, + }); + } + await runAutoSequenceFromStep(startStep, { targetRun, totalRuns, diff --git a/background/mail-2925-session.js b/background/mail-2925-session.js index 502362a..e80dc4c 100644 --- a/background/mail-2925-session.js +++ b/background/mail-2925-session.js @@ -537,6 +537,16 @@ } } + if (typeof ensureContentScriptReadyOnTab === 'function') { + await ensureContentScriptReadyOnTab(MAIL2925_SOURCE, tabId, { + inject: MAIL2925_INJECT, + injectSource: MAIL2925_INJECT_SOURCE, + timeoutMs: 20000, + retryDelayMs: 800, + logMessage: '步骤 0:2925 登录页内容脚本未就绪,正在等待页面稳定后继续登录...', + }); + } + if (!forceRelogin && !isMail2925LoginUrl(openedUrl) && !normalizedExpectedMailboxEmail) { await addLog('2925:当前邮箱页未跳转到登录页,将直接复用已登录会话。', 'info'); return buildSuccessPayload(); @@ -553,16 +563,6 @@ }); } - if (typeof ensureContentScriptReadyOnTab === 'function') { - await ensureContentScriptReadyOnTab(MAIL2925_SOURCE, tabId, { - inject: MAIL2925_INJECT, - injectSource: MAIL2925_INJECT_SOURCE, - timeoutMs: 20000, - retryDelayMs: 800, - logMessage: '步骤 0:2925 登录页内容脚本未就绪,正在等待页面稳定后继续登录...', - }); - } - if (forceRelogin && typeof sleepWithStop === 'function') { await addLog('2925:登录页已打开,等待 3 秒后开始检查输入框并执行登录...', 'info'); await sleepWithStop(3000); diff --git a/background/message-router.js b/background/message-router.js index 2b7cfe0..7ce7a6e 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -32,11 +32,14 @@ executeStepViaCompletionSignal, exportSettingsBundle, fetchGeneratedEmail, + finalizePhoneActivationAfterSuccessfulFlow, finalizeStep3Completion, finalizeIcloudAliasAfterSuccessfulFlow, findHotmailAccount, + findPayPalAccount, flushCommand, getCurrentLuckmailPurchase, + getCurrentPayPalAccount, getCurrentMail2925Account, getPendingAutoRunTimerPlan, getSourceLabel, @@ -62,6 +65,7 @@ refreshIpProxyPool, normalizeHotmailAccounts, normalizeMail2925Accounts, + normalizePayPalAccounts, normalizeRunCount, AUTO_RUN_TIMER_KIND_SCHEDULED_START, notifyStepComplete, @@ -79,6 +83,7 @@ selectLuckmailPurchase, switchIpProxy, changeIpProxyExit, + setCurrentPayPalAccount, setCurrentMail2925Account, setCurrentHotmailAccount, setContributionMode, @@ -99,7 +104,9 @@ deleteMail2925Account, deleteMail2925Accounts, syncHotmailAccounts, + syncPayPalAccounts, testHotmailAccountMailAccess, + upsertPayPalAccount, upsertMail2925Account, upsertHotmailAccount, verifyHotmailAccount, @@ -135,20 +142,6 @@ } } -<<<<<<< HEAD - function resolveLastStepIdForState(state = {}) { - if (typeof getLastStepIdForState === 'function') { - const fromResolver = Number(getLastStepIdForState(state)); - if (Number.isFinite(fromResolver) && fromResolver > 0) { - return fromResolver; - } - } - const stepIds = typeof getStepIdsForState === 'function' - ? (getStepIdsForState(state) || []) - : Object.keys(state?.stepStatuses || {}).map((step) => Number(step)).filter(Number.isFinite); - const sorted = stepIds.slice().sort((left, right) => left - right); - return Math.max(10, sorted[sorted.length - 1] || 0); -======= function getStepKeyForState(step, state = {}) { if (typeof getStepDefinitionForState === 'function') { return String(getStepDefinitionForState(step, state)?.key || '').trim(); @@ -220,7 +213,6 @@ if (typeof finalizePhoneActivationAfterSuccessfulFlow === 'function') { await finalizePhoneActivationAfterSuccessfulFlow(latestState); } ->>>>>>> 705c749 (fix(flow): restore dynamic router handling and downstream reset safety) } async function handleStepData(step, payload) { @@ -800,6 +792,16 @@ return { ok: true, account }; } + case 'UPSERT_PAYPAL_ACCOUNT': { + const account = await upsertPayPalAccount(message.payload || {}); + return { ok: true, account }; + } + + case 'SELECT_PAYPAL_ACCOUNT': { + const account = await setCurrentPayPalAccount(String(message.payload?.accountId || '')); + return { ok: true, account }; + } + case 'DELETE_HOTMAIL_ACCOUNT': { await deleteHotmailAccount(String(message.payload?.accountId || '')); return { ok: true }; diff --git a/background/paypal-account-store.js b/background/paypal-account-store.js new file mode 100644 index 0000000..6d6c889 --- /dev/null +++ b/background/paypal-account-store.js @@ -0,0 +1,110 @@ +(function attachBackgroundPayPalAccountStore(root, factory) { + root.MultiPageBackgroundPayPalAccountStore = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundPayPalAccountStoreModule() { + function createPayPalAccountStore(deps = {}) { + const { + broadcastDataUpdate, + findPayPalAccount, + getState, + normalizePayPalAccount, + normalizePayPalAccounts, + setPersistentSettings, + setState, + upsertPayPalAccountInList, + } = deps; + + async function syncSelectedPayPalAccountState(account = null) { + const updates = account + ? { + currentPayPalAccountId: account.id, + paypalEmail: String(account.email || '').trim(), + paypalPassword: String(account.password || ''), + } + : { + currentPayPalAccountId: '', + paypalEmail: '', + paypalPassword: '', + }; + + await setPersistentSettings(updates); + await setState({ + currentPayPalAccountId: updates.currentPayPalAccountId || null, + paypalEmail: updates.paypalEmail, + paypalPassword: updates.paypalPassword, + }); + broadcastDataUpdate({ + currentPayPalAccountId: updates.currentPayPalAccountId || null, + paypalEmail: updates.paypalEmail, + paypalPassword: updates.paypalPassword, + }); + } + + async function syncPayPalAccounts(accounts) { + const normalized = normalizePayPalAccounts(accounts); + await setPersistentSettings({ paypalAccounts: normalized }); + await setState({ paypalAccounts: normalized }); + broadcastDataUpdate({ paypalAccounts: normalized }); + + const state = await getState(); + if (state.currentPayPalAccountId && !findPayPalAccount(normalized, state.currentPayPalAccountId)) { + await syncSelectedPayPalAccountState(null); + } + return normalized; + } + + function getCurrentPayPalAccount(state = {}) { + return findPayPalAccount(state.paypalAccounts, state.currentPayPalAccountId) || null; + } + + async function upsertPayPalAccount(input = {}) { + const state = await getState(); + const accounts = normalizePayPalAccounts(state.paypalAccounts); + const normalizedEmail = String(input?.email || '').trim().toLowerCase(); + const existing = input?.id + ? findPayPalAccount(accounts, input.id) + : accounts.find((account) => account.email === normalizedEmail) || null; + const normalized = normalizePayPalAccount({ + ...(existing || {}), + ...input, + id: input?.id || existing?.id || crypto.randomUUID(), + createdAt: existing?.createdAt || Date.now(), + updatedAt: Date.now(), + }); + + const nextAccounts = typeof upsertPayPalAccountInList === 'function' + ? upsertPayPalAccountInList(accounts, normalized) + : accounts.concat(normalized); + + await syncPayPalAccounts(nextAccounts); + + if (state.currentPayPalAccountId === normalized.id) { + await syncSelectedPayPalAccountState(normalized); + } + + return normalized; + } + + async function setCurrentPayPalAccount(accountId) { + const state = await getState(); + const accounts = normalizePayPalAccounts(state.paypalAccounts); + const account = findPayPalAccount(accounts, accountId); + if (!account) { + throw new Error('未找到对应的 PayPal 账号。'); + } + + await syncSelectedPayPalAccountState(account); + return account; + } + + return { + getCurrentPayPalAccount, + setCurrentPayPalAccount, + syncPayPalAccounts, + upsertPayPalAccount, + }; + } + + return { + createPayPalAccountStore, + }; +}); diff --git a/background/phone-verification-flow.js b/background/phone-verification-flow.js index ca16eae..e8b7eb8 100644 --- a/background/phone-verification-flow.js +++ b/background/phone-verification-flow.js @@ -21,13 +21,13 @@ const PHONE_ACTIVATION_STATE_KEY = 'currentPhoneActivation'; const REUSABLE_PHONE_ACTIVATION_STATE_KEY = 'reusablePhoneActivation'; + const PENDING_PHONE_ACTIVATION_CONFIRMATION_STATE_KEY = 'pendingPhoneActivationConfirmation'; const DEFAULT_PHONE_POLL_INTERVAL_MS = 5000; const DEFAULT_PHONE_POLL_TIMEOUT_MS = 180000; const DEFAULT_PHONE_REQUEST_TIMEOUT_MS = 20000; const DEFAULT_PHONE_SUBMIT_ATTEMPTS = 3; const DEFAULT_PHONE_CODE_WAIT_WINDOW_MS = 60000; const DEFAULT_PHONE_NUMBER_MAX_USES = 3; - const DEFAULT_PHONE_PRICE_LOOKUP_ATTEMPTS = 3; const PHONE_CODE_TIMEOUT_ERROR_PREFIX = 'PHONE_CODE_TIMEOUT::'; const PHONE_RESTART_STEP7_ERROR_PREFIX = 'PHONE_RESTART_STEP7::'; @@ -47,6 +47,18 @@ return String(value || '').trim(); } + function normalizeManualHeroSmsMaxPrice(value) { + const trimmed = String(value ?? '').trim(); + if (!trimmed) { + return null; + } + const price = Number(trimmed); + if (!Number.isFinite(price) || price <= 0) { + return null; + } + return String(price); + } + function normalizeUseCount(value) { return Math.max(0, Math.floor(Number(value) || 0)); } @@ -236,13 +248,19 @@ } } - function resolvePhoneConfig(state = {}) { + function resolvePhoneConfig(state = {}, options = {}) { const apiKey = normalizeApiKey(state.heroSmsApiKey); if (!apiKey) { throw new Error('HeroSMS API key is missing. Save it in the side panel before running the phone flow.'); } + const requireMaxPrice = Boolean(options.requireMaxPrice); + const maxPrice = normalizeManualHeroSmsMaxPrice(state.heroSmsMaxPrice); + if (requireMaxPrice && !maxPrice) { + throw new Error('HeroSMS maxPrice is missing. Fill it in below the country selector before running the phone flow.'); + } return { apiKey, + ...(maxPrice ? { maxPrice } : {}), baseUrl: normalizeUrl(state.heroSmsBaseUrl, DEFAULT_HERO_SMS_BASE_URL), }; } @@ -289,91 +307,10 @@ return activation?.statusAction === 'getStatusV2' ? 'getStatusV2' : 'getStatus'; } - function normalizeHeroSmsPrice(value) { - const price = Number(value); - if (!Number.isFinite(price) || price < 0) { - return null; - } - return price; - } - - function collectHeroSmsPriceCandidates(payload, candidates = []) { - if (Array.isArray(payload)) { - payload.forEach((entry) => collectHeroSmsPriceCandidates(entry, candidates)); - return candidates; - } - if (!payload || typeof payload !== 'object') { - return candidates; - } - - const cost = normalizeHeroSmsPrice(payload.cost); - if (cost !== null) { - const count = Number(payload.count); - const physicalCount = Number(payload.physicalCount); - const hasCount = Number.isFinite(count); - const hasPhysicalCount = Number.isFinite(physicalCount); - if ((!hasCount && !hasPhysicalCount) || count > 0 || physicalCount > 0) { - candidates.push(cost); - } - } - - Object.values(payload).forEach((value) => collectHeroSmsPriceCandidates(value, candidates)); - return candidates; - } - - function findLowestHeroSmsPrice(payload) { - const candidates = collectHeroSmsPriceCandidates(payload, []); - if (!candidates.length) { - return null; - } - return Math.min(...candidates); - } - function isHeroSmsNoNumbersPayload(payload) { return /\bNO_NUMBERS\b/i.test(describeHeroSmsPayload(payload)); } - function extractHeroSmsWrongMaxPrice(payload) { - if (payload && typeof payload === 'object') { - const title = String(payload.title || '').trim(); - const minPrice = normalizeHeroSmsPrice(payload.info?.min); - if (/^WRONG_MAX_PRICE$/i.test(title) && minPrice !== null) { - return minPrice; - } - } - - const text = describeHeroSmsPayload(payload); - const match = text.match(/\bWRONG_MAX_PRICE:(\d+(?:\.\d+)?)\b/i); - if (!match) { - return null; - } - return normalizeHeroSmsPrice(match[1]); - } - - function isNetworkFetchFailure(error) { - const message = String(error?.message || '').trim(); - return /failed to fetch|networkerror|load failed/i.test(message); - } - - async function resolveCheapestPhoneActivationPrice(config, countryConfig) { - for (let attempt = 1; attempt <= DEFAULT_PHONE_PRICE_LOOKUP_ATTEMPTS; attempt += 1) { - try { - const payload = await fetchHeroSmsPayload(config, { - action: 'getPrices', - service: HERO_SMS_SERVICE_CODE, - country: countryConfig.id, - }, 'HeroSMS getPrices'); - const price = findLowestHeroSmsPrice(payload); - if (price !== null) { - return price; - } - } catch (_) { - // Best-effort lookup only. - } - } - return null; - } - async function fetchPhoneActivationPayload(config, countryConfig, action, options = {}) { const query = { action, @@ -387,49 +324,10 @@ return fetchHeroSmsPayload(config, query, `HeroSMS ${action}`); } - async function requestPhoneActivationWithPrice(config, countryConfig, action, maxPrice) { - let nextMaxPrice = maxPrice; - let retriedWithUpdatedPrice = false; - let retriedWithoutPrice = false; - - while (true) { - try { - return await fetchPhoneActivationPayload(config, countryConfig, action, { - maxPrice: nextMaxPrice, - }); - } catch (error) { - const updatedMaxPrice = extractHeroSmsWrongMaxPrice(error?.payload || error?.message); - if ( - nextMaxPrice !== null - && nextMaxPrice !== undefined - && !retriedWithUpdatedPrice - && updatedMaxPrice !== null - ) { - nextMaxPrice = updatedMaxPrice; - retriedWithUpdatedPrice = true; - continue; - } - - if ( - nextMaxPrice !== null - && nextMaxPrice !== undefined - && !retriedWithoutPrice - && isNetworkFetchFailure(error) - ) { - nextMaxPrice = null; - retriedWithoutPrice = true; - continue; - } - - throw error; - } - } - } - async function requestPhoneActivation(state = {}) { - const config = resolvePhoneConfig(state); + const config = resolvePhoneConfig(state, { requireMaxPrice: true }); const countryConfig = resolveCountryConfig(state); - const maxPrice = await resolveCheapestPhoneActivationPrice(config, countryConfig); + const maxPrice = config.maxPrice; const buildFallbackActivation = (requestAction) => ({ countryId: countryConfig.id, ...(requestAction === 'getNumberV2' ? { statusAction: 'getStatusV2' } : {}), @@ -438,19 +336,19 @@ let payload; try { - payload = await requestPhoneActivationWithPrice(config, countryConfig, requestAction, maxPrice); + payload = await fetchPhoneActivationPayload(config, countryConfig, requestAction, { maxPrice }); } catch (error) { if (!isHeroSmsNoNumbersPayload(error?.payload || error?.message)) { throw error; } requestAction = 'getNumberV2'; - payload = await requestPhoneActivationWithPrice(config, countryConfig, requestAction, maxPrice); + payload = await fetchPhoneActivationPayload(config, countryConfig, requestAction, { maxPrice }); } let activation = parseActivationPayload(payload, buildFallbackActivation(requestAction)); if (!activation && requestAction === 'getNumber' && isHeroSmsNoNumbersPayload(payload)) { requestAction = 'getNumberV2'; - payload = await requestPhoneActivationWithPrice(config, countryConfig, requestAction, maxPrice); + payload = await fetchPhoneActivationPayload(config, countryConfig, requestAction, { maxPrice }); activation = parseActivationPayload(payload, buildFallbackActivation(requestAction)); } @@ -496,7 +394,7 @@ } async function completePhoneActivation(state = {}, activation) { - await setPhoneActivationStatus(state, activation, 6, 'HeroSMS setStatus(6)'); + await setPhoneActivationStatus(state, activation, 3, 'HeroSMS setStatus(3)'); } async function cancelPhoneActivation(state = {}, activation) { @@ -738,6 +636,18 @@ await persistReusableActivation(null); } + function incrementActivationUseCount(activation) { + const normalizedActivation = normalizeActivation(activation); + if (!normalizedActivation) { + return null; + } + + return { + ...normalizedActivation, + successfulUses: Math.min(normalizedActivation.successfulUses + 1, normalizedActivation.maxUses), + }; + } + async function acquirePhoneActivation(state = {}) { const countryConfig = resolveCountryConfig(state); const reusableActivation = normalizeActivation(state[REUSABLE_PHONE_ACTIVATION_STATE_KEY]); @@ -769,28 +679,53 @@ return activation; } - async function markActivationReusableAfterSuccess(activation) { + async function syncReusableActivationAfterUse(activation) { const normalizedActivation = normalizeActivation(activation); if (!normalizedActivation) { await clearReusableActivation(); return; } - const successfulUses = normalizedActivation.successfulUses + 1; - if (successfulUses >= normalizedActivation.maxUses) { + if (normalizedActivation.successfulUses >= normalizedActivation.maxUses) { await clearReusableActivation(); return; } - await persistReusableActivation({ - ...normalizedActivation, - successfulUses, + await persistReusableActivation(normalizedActivation); + } + + async function persistPendingPhoneActivationConfirmation(activation) { + await setState({ + [PENDING_PHONE_ACTIVATION_CONFIRMATION_STATE_KEY]: activation || null, }); } + async function clearPendingPhoneActivationConfirmation() { + await persistPendingPhoneActivationConfirmation(null); + } + + async function finalizePendingPhoneActivationConfirmation(stateOverride = null) { + const state = stateOverride || await getState(); + const pendingActivation = normalizeActivation(state[PENDING_PHONE_ACTIVATION_CONFIRMATION_STATE_KEY]); + if (!pendingActivation) { + return null; + } + + const committedActivation = incrementActivationUseCount(pendingActivation); + if (!committedActivation) { + await clearPendingPhoneActivationConfirmation(); + await clearReusableActivation(); + return null; + } + + await syncReusableActivationAfterUse(committedActivation); + await clearPendingPhoneActivationConfirmation(); + return committedActivation; + } + async function waitForPhoneCodeOrRotateNumber(tabId, state, activation) { - const normalizedActivation = normalizeActivation(activation); - if (!normalizedActivation) { + let currentActivation = normalizeActivation(activation); + if (!currentActivation) { throw new Error('Phone activation is missing.'); } @@ -799,11 +734,11 @@ for (let windowIndex = 1; windowIndex <= 2; windowIndex += 1) { await addLog( - `Step 9: waiting up to 60 seconds for SMS on ${normalizedActivation.phoneNumber} (${windowIndex}/2).`, + `Step 9: waiting up to 60 seconds for SMS on ${currentActivation.phoneNumber} (${windowIndex}/2).`, 'info' ); try { - const code = await pollPhoneActivationCode(state, normalizedActivation, { + const code = await pollPhoneActivationCode(state, currentActivation, { actionLabel: windowIndex === 1 ? 'poll phone verification code from HeroSMS' : 'poll resent phone verification code from HeroSMS', @@ -820,7 +755,7 @@ lastLoggedStatus = statusText; lastLoggedPollCount = pollCount; await addLog( - `Step 9: HeroSMS status for ${normalizedActivation.phoneNumber}: ${statusText} (${Math.ceil(elapsedMs / 1000)}s elapsed).`, + `Step 9: HeroSMS status for ${currentActivation.phoneNumber}: ${statusText} (${Math.ceil(elapsedMs / 1000)}s elapsed).`, 'info' ); }, @@ -836,10 +771,10 @@ if (windowIndex === 1) { await addLog( - `Step 9: no SMS arrived for ${normalizedActivation.phoneNumber} within 60 seconds, requesting another SMS.`, + `Step 9: no SMS arrived for ${currentActivation.phoneNumber} within 60 seconds, requesting another SMS.`, 'warn' ); - await requestAdditionalPhoneSms(state, normalizedActivation); + await requestAdditionalPhoneSms(state, currentActivation); try { await resendPhoneVerificationCode(tabId); await addLog('Step 9: clicked "Resend text message" on the phone verification page.', 'info'); @@ -850,10 +785,10 @@ } await addLog( - `Step 9: still no SMS for ${normalizedActivation.phoneNumber} 60 seconds after resend, restarting from step 7 with a new number.`, + `Step 9: still no SMS for ${currentActivation.phoneNumber} 60 seconds after resend, restarting from step 7 with a new number.`, 'warn' ); - throw buildPhoneRestartStep7Error(normalizedActivation.phoneNumber); + throw buildPhoneRestartStep7Error(currentActivation.phoneNumber); } } @@ -875,6 +810,9 @@ } if (pageState?.addPhonePage) { + if (normalizeActivation(state[PENDING_PHONE_ACTIVATION_CONFIRMATION_STATE_KEY])) { + await clearPendingPhoneActivationConfirmation(); + } if (activation) { await cancelPhoneActivation(state, activation); await clearCurrentActivation(); @@ -965,8 +903,18 @@ continue; } - await completePhoneActivation(state, activation); - await markActivationReusableAfterSuccess(activation); + try { + await completePhoneActivation(state, activation); + await persistReusableActivation(activation); + await persistPendingPhoneActivationConfirmation(activation); + } catch (activationStatusError) { + await clearReusableActivation(); + await clearPendingPhoneActivationConfirmation(); + await addLog( + `Step 9: phone verification succeeded, but HeroSMS setStatus(3) failed. The next flow will request a new number. ${activationStatusError.message}`, + 'warn' + ); + } shouldCancelActivation = false; await clearCurrentActivation(); await addLog('Step 9: phone verification finished, waiting for OAuth consent.', 'ok'); @@ -988,6 +936,7 @@ return { completePhoneVerificationFlow, + finalizePendingPhoneActivationConfirmation, normalizeActivation, pollPhoneActivationCode, reactivatePhoneActivation, diff --git a/background/steps/fetch-login-code.js b/background/steps/fetch-login-code.js index 0af5cd4..9f59517 100644 --- a/background/steps/fetch-login-code.js +++ b/background/steps/fetch-login-code.js @@ -27,6 +27,7 @@ reuseOrCreateTab, setState, shouldUseCustomRegistrationEmail, + sleepWithStop, STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS, STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS, throwIfStopped, @@ -167,10 +168,26 @@ }); } - async function runStep8Attempt(state) { + function getStep8ResendIntervalMs(state = {}) { + const mail = getMailConfig(state); + if (mail?.provider === LUCKMAIL_PROVIDER) { + return 15000; + } + if (mail?.provider === HOTMAIL_PROVIDER || mail?.provider === '2925') { + return 0; + } + return Math.max(0, Number(STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS) || 0); + } + + async function runStep8Attempt(state, runtime = {}) { const visibleStep = getVisibleStep(state, 8); const mail = getMailConfig(state); if (mail.error) throw new Error(mail.error); + const stateLastResendAt = Number(state?.loginVerificationRequestedAt) || 0; + let latestResendAt = Math.max(0, Number(runtime?.stickyLastResendAt) || 0, stateLastResendAt); + const notifyResendRequestedAt = typeof runtime?.onResendRequestedAt === 'function' + ? runtime.onResendRequestedAt + : null; const stepStartedAt = Date.now(); const verificationFilterAfterTimestamp = mail.provider === '2925' @@ -267,6 +284,16 @@ disableTimeBudgetCap: mail.provider === '2925', getRemainingTimeMs: getStep8RemainingTimeResolver(state?.oauthUrl || '', visibleStep), requestFreshCodeFirst: false, + lastResendAt: latestResendAt, + onResendRequestedAt: async (requestedAt) => { + const numericRequestedAt = Number(requestedAt) || 0; + if (numericRequestedAt > 0) { + latestResendAt = Math.max(latestResendAt, numericRequestedAt); + } + if (notifyResendRequestedAt) { + await notifyResendRequestedAt(latestResendAt); + } + }, targetEmail: fixedTargetEmail, resendIntervalMs: mail.provider === LUCKMAIL_PROVIDER ? 15000 @@ -274,6 +301,9 @@ ? 0 : STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS), }); + return { + lastResendAt: latestResendAt, + }; } function isStep8RestartStep7Error(error) { @@ -285,10 +315,22 @@ let currentState = state; let mailPollingAttempt = 1; let lastMailPollingError = null; + let stickyLastResendAt = Number(state?.loginVerificationRequestedAt) || 0; while (true) { try { - await runStep8Attempt(currentState); + const result = await runStep8Attempt(currentState, { + stickyLastResendAt, + onResendRequestedAt: async (requestedAt) => { + const numericRequestedAt = Number(requestedAt) || 0; + if (numericRequestedAt > 0) { + stickyLastResendAt = Math.max(stickyLastResendAt, numericRequestedAt); + } + }, + }); + if (Number(result?.lastResendAt) > 0) { + stickyLastResendAt = Math.max(stickyLastResendAt, Number(result.lastResendAt) || 0); + } return; } catch (err) { const visibleStep = getVisibleStep(currentState, 8); @@ -323,7 +365,29 @@ `步骤 ${visibleStep}:认证页仍保持在验证码页,将在当前链路直接重试(${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS}),不回到步骤 ${authLoginStep}。`, 'warn' ); - currentState = await getState(); + const latestState = await getState(); + const latestStateResendAt = Number(latestState?.loginVerificationRequestedAt) || 0; + if (latestStateResendAt > 0) { + stickyLastResendAt = Math.max(stickyLastResendAt, latestStateResendAt); + } + currentState = latestState; + if (stickyLastResendAt > 0 && (!latestStateResendAt || latestStateResendAt < stickyLastResendAt)) { + currentState = { + ...latestState, + loginVerificationRequestedAt: stickyLastResendAt, + }; + } + const resendIntervalMs = getStep8ResendIntervalMs(currentState); + const remainingBeforeRetryMs = stickyLastResendAt > 0 && resendIntervalMs > 0 + ? Math.max(0, resendIntervalMs - (Date.now() - stickyLastResendAt)) + : 0; + if (remainingBeforeRetryMs > 0 && typeof sleepWithStop === 'function') { + await addLog( + `步骤 ${visibleStep}:上轮已触发重发验证码,为避免重复重发,先等待 ${Math.ceil(remainingBeforeRetryMs / 1000)} 秒后继续当前链路重试。`, + 'info' + ); + await sleepWithStop(Math.min(remainingBeforeRetryMs, 3000)); + } continue; } await addLog( diff --git a/background/steps/paypal-approve.js b/background/steps/paypal-approve.js index d69540d..3d1e390 100644 --- a/background/steps/paypal-approve.js +++ b/background/steps/paypal-approve.js @@ -99,17 +99,30 @@ return result || {}; } + function resolvePayPalCredentials(state = {}) { + const currentId = String(state?.currentPayPalAccountId || '').trim(); + const accounts = Array.isArray(state?.paypalAccounts) ? state.paypalAccounts : []; + const selectedAccount = currentId + ? accounts.find((account) => String(account?.id || '').trim() === currentId) || null + : null; + return { + email: String(selectedAccount?.email || state?.paypalEmail || '').trim(), + password: String(selectedAccount?.password || state?.paypalPassword || ''), + }; + } + async function submitLogin(tabId, state = {}) { - if (!state.paypalPassword) { - throw new Error('步骤 8:未配置 PayPal 密码,请先在侧边栏填写。'); + const credentials = resolvePayPalCredentials(state); + if (!credentials.password) { + throw new Error('步骤 8:未配置可用的 PayPal 账号,请先在侧边栏添加并选择账号。'); } await addLog('步骤 8:正在填写 PayPal 登录信息并提交...', 'info'); const result = await sendTabMessageUntilStopped(tabId, PAYPAL_SOURCE, { type: 'PAYPAL_SUBMIT_LOGIN', source: 'background', payload: { - email: state.paypalEmail || '', - password: state.paypalPassword || '', + email: credentials.email, + password: credentials.password, }, }); if (result?.error) { diff --git a/background/verification-flow.js b/background/verification-flow.js index dcade7a..f41d9fa 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -920,8 +920,18 @@ const maxSubmitAttempts = mail.provider === LUCKMAIL_PROVIDER ? 3 : 15; const resendIntervalMs = Math.max(0, Number(options.resendIntervalMs) || 0); let lastResendAt = Number(options.lastResendAt) || 0; + const externalOnResendRequestedAt = typeof options.onResendRequestedAt === 'function' + ? options.onResendRequestedAt + : null; - const updateFilterAfterTimestampForVerificationStep = async (_requestedAt) => { + const updateFilterAfterTimestampForVerificationStep = async (requestedAt) => { + if (externalOnResendRequestedAt) { + try { + await externalOnResendRequestedAt(requestedAt); + } catch (_) { + // Keep resend flow best-effort; state sync callback failures should not break verification. + } + } return nextFilterAfterTimestamp; }; diff --git a/content/mail-2925.js b/content/mail-2925.js index 1e698c4..d2ba01a 100644 --- a/content/mail-2925.js +++ b/content/mail-2925.js @@ -699,10 +699,29 @@ function extractEmails(text = '') { return [...new Set(matches.map((item) => item.toLowerCase()))]; } +function extractForwardedTargetEmails(text = '') { + const normalizedText = String(text || '').toLowerCase(); + const matches = normalizedText.match(/bounce\+[a-z0-9._%+-]*-([a-z0-9._%+-]+)=([a-z0-9.-]+\.[a-z]{2,})@(?:tm\d*\.openai\.com|em\d+\.tm\.openai\.com)/gi) || []; + const decoded = matches + .map((candidate) => { + const match = String(candidate || '').match(/bounce\+[a-z0-9._%+-]*-([a-z0-9._%+-]+)=([a-z0-9.-]+\.[a-z]{2,})@/i); + if (!match) { + return ''; + } + return `${match[1].toLowerCase()}@${match[2].toLowerCase()}`; + }) + .filter(Boolean); + return [...new Set(decoded)]; +} + function emailMatchesTarget(candidate, targetEmail) { const normalizedCandidate = String(candidate || '').trim().toLowerCase(); const normalizedTarget = String(targetEmail || '').trim().toLowerCase(); - return Boolean(normalizedCandidate && normalizedTarget && normalizedCandidate === normalizedTarget); + if (!normalizedCandidate || !normalizedTarget) { + return false; + } + + return normalizedCandidate === normalizedTarget; } function getTargetEmailMatchState(text, targetEmail) { @@ -717,12 +736,30 @@ function getTargetEmailMatchState(text, targetEmail) { } const extractedEmails = extractEmails(normalizedText); + const forwardedTargetEmails = extractForwardedTargetEmails(normalizedText); if (!extractedEmails.length) { + return forwardedTargetEmails.length + ? { + matches: forwardedTargetEmails.some((candidate) => emailMatchesTarget(candidate, normalizedTarget)), + hasExplicitEmail: true, + } + : { matches: true, hasExplicitEmail: false }; + } + + const targetDomain = normalizedTarget.includes('@') + ? normalizedTarget.split('@').pop() + : ''; + const comparableEmails = [...new Set( + (targetDomain + ? [...extractedEmails, ...forwardedTargetEmails].filter((candidate) => String(candidate || '').trim().toLowerCase().endsWith(`@${targetDomain}`)) + : [...extractedEmails, ...forwardedTargetEmails]) + )]; + if (!comparableEmails.length) { return { matches: true, hasExplicitEmail: false }; } return { - matches: extractedEmails.some((candidate) => emailMatchesTarget(candidate, normalizedTarget)), + matches: comparableEmails.some((candidate) => emailMatchesTarget(candidate, normalizedTarget)), hasExplicitEmail: true, }; } @@ -782,6 +819,17 @@ function parseMailItemTimestamp(item) { return date.getTime(); } + match = timeText.match(/(\d{1,2})月(\d{1,2})日(?:\s*(\d{1,2}):(\d{2}))?/); + if (match) { + date.setMonth(Number(match[1]) - 1, Number(match[2])); + if (match[3] && match[4]) { + date.setHours(Number(match[3]), Number(match[4]), 0, 0); + } else { + date.setHours(0, 0, 0, 0); + } + return date.getTime(); + } + match = timeText.match(/(\d{4})-(\d{1,2})-(\d{1,2})\s*(\d{1,2}):(\d{2})/); if (match) { return new Date( diff --git a/content/paypal-flow.js b/content/paypal-flow.js index 9f34e51..7a505e6 100644 --- a/content/paypal-flow.js +++ b/content/paypal-flow.js @@ -227,6 +227,18 @@ function getPayPalLoginPhase(emailInput, passwordInput) { return ''; } +function refillPayPalEmailInput(emailInput, email) { + if (!emailInput) return; + if (typeof emailInput.focus === 'function') { + emailInput.focus(); + } + fillInput(emailInput, ''); + fillInput(emailInput, email); + if (typeof emailInput.blur === 'function') { + emailInput.blur(); + } +} + async function submitPayPalLogin(payload = {}) { await waitForDocumentComplete(); @@ -241,9 +253,7 @@ async function submitPayPalLogin(payload = {}) { const emailNextButton = findEmailNextButton(); if (emailInput && emailNextButton && isEnabledControl(emailNextButton) && (!passwordInput || !findPasswordLoginButton())) { - if (normalizeText(emailInput.value || '') !== email) { - fillInput(emailInput, email); - } + refillPayPalEmailInput(emailInput, email); simulateClick(emailNextButton); return { submitted: false, @@ -253,9 +263,7 @@ async function submitPayPalLogin(payload = {}) { } if (!passwordInput && emailInput && email) { - if (normalizeText(emailInput.value || '') !== email) { - fillInput(emailInput, email); - } + refillPayPalEmailInput(emailInput, email); const nextButton = await waitUntil(() => { const button = findEmailNextButton() || findLoginNextButton(); return button && isEnabledControl(button) ? button : null; @@ -272,8 +280,8 @@ async function submitPayPalLogin(payload = {}) { }; } else if (!passwordInput && emailInput && !email) { throw new Error('PayPal 账号为空,请先在侧边栏配置。'); - } else if (emailInput && email && normalizeText(emailInput.value || '') !== email) { - fillInput(emailInput, email); + } else if (emailInput && email) { + refillPayPalEmailInput(emailInput, email); } passwordInput = passwordInput || await waitUntil(() => findPasswordInput(), { diff --git a/docs/使用教程.md b/docs/使用教程.md index 4926491..f9721a6 100644 --- a/docs/使用教程.md +++ b/docs/使用教程.md @@ -1,6 +1,6 @@ # Codex 注册扩展相关项目、更新、邮箱、PayPal 与 Clash Verge 配置教程 -本教程用于说明相关项目地址、扩展更新方式、`Cloudflare Temp Email`、`iCloud 隐私邮箱` 与 `QQ 邮箱` 的使用方法、`PayPal` 注册绑卡流程,以及 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` 配置方法。 +本教程用于说明相关项目地址、扩展更新方式、`Cloudflare Temp Email`、`iCloud 隐私邮箱` 与 `QQ 邮箱` 的使用方法、`PayPal` 注册绑卡流程、扩展内置动态 IP 代理、节点检测与纯净度检查,以及 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` 配置方法。 ## 适用场景 @@ -9,7 +9,10 @@ - 需要把 `Cloudflare Temp Email` 用作 `邮箱生成` 或 `邮箱服务` - 需要使用 `iCloud+` 的 `隐藏邮件地址` 作为隐私邮箱 - 需要临时切换 `QQ 邮箱` 地址继续使用 +- 需要使用 `网易邮箱`、`网易邮箱大师` 注册多个邮箱或替身邮箱 - 需要注册并使用 `PayPal` 个人账户 +- 需要在扩展内使用 `711Proxy` 动态 IP,并在自动流程中按轮次切换出口 +- 需要检查当前节点的出口 IP、纯净度、泄露情况和访问速度 - 需要在 `Clash Verge` 中启用 `🔁 非港轮询` ## 准备内容 @@ -21,8 +24,10 @@ - 一个已开通 `iCloud+` 的 Apple ID - 一个用于接收转发邮件的邮箱 - 一个可正常登录的 `QQ 邮箱` +- 手机端已安装 `网易邮箱` 或 `网易邮箱大师` - 一个可正常接收短信的手机号 - 一张可在线支付的借记卡或信用卡 +- 如需使用扩展内置动态 IP,需准备 `711Proxy` 的 Host、Port、代理账号和代理密码 - 如需部署 `cpa`,部署环境必须可以访问 `OpenAI` - 已安装 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev),并已导入可用订阅 @@ -163,7 +168,7 @@ `iCloud 邮件` 主地址创建说明:[Create a primary email address for iCloud Mail](https://support.apple.com/is-is/guide/icloud/mmdd8d1c5c/icloud) `隐藏邮件地址` 创建与转发说明:[Create and edit Hide My Email addresses on iCloud.com](https://support.apple.com/lv-lv/guide/icloud/mm1a876f7aed/icloud) -### 第五部分:`QQ 邮箱`切换邮箱使用教程 +### 第五部分:邮箱方法一:`QQ 邮箱`切换邮箱使用教程 1. 登录 `QQ 邮箱` 先登录你当前正在使用的 `QQ 邮箱` 账号。 @@ -182,7 +187,41 @@ 这两个邮箱地址使用完成后,可以直接删除。 删除后再次创建新的英文邮箱和 `Foxmail` 邮箱,即可重复注册使用。 -### 第六部分:`PayPal` 注册与绑卡使用教程 +### 第六部分:邮箱方法二:`网易邮箱` 注册与替身邮箱使用教程 + +1. 安装手机端 App + 在手机上下载安装 `网易邮箱` 或 `网易邮箱大师`。 + 打开后,先使用一个手机号登录。 + +2. 进入注册邮箱入口 + 点击右下角的 `我`。 + 找到并点击 `注册邮箱`。 + +3. 选择 `网易免费邮箱` + 在注册邮箱页面中选择 `网易免费邮箱`。 + 进入后,可以使用同一个手机号分别注册 `163`、`126` 和 `yeah` 邮箱。 + +4. 控制注册节奏 + 同一个手机号理论上可以注册多个网易邮箱账号。 + `163`、`126` 和 `yeah` 加起来大约可以注册 `15` 个。 + 不要短时间连续注册,连续注册太快容易触发频繁提示或限制。 + 更稳妥的做法是注册一个后暂停一会儿,或者换一个网络环境再继续。 + +5. 添加 `替身邮箱` + 注册完成后,回到 `注册邮箱` 入口附近。 + 点击旁边的 `替身邮箱`。 + 进入后,每个网易邮箱账号可以添加 `2` 个替身邮箱。 + +6. 计算可用邮箱数量 + 如果一个手机号注册了约 `15` 个网易邮箱账号,每个账号再添加 `2` 个替身邮箱,那么总共大约可以得到 `45` 个可用邮箱地址。 + 这些邮箱地址后续可以用于其他需要独立邮箱的注册或接收邮件场景。 + +7. 注意域名和注册环境 + `163`、`126` 和 `yeah` 这类网易邮箱域名通常比较常见。 + 只要网络环境不要太差,并且不要连续高频注册,一般不容易马上触发额外手机号验证。 + 建议注册一个后换节奏再继续,不要一口气连续创建。 + +### 第七部分:`PayPal` 注册与绑卡使用教程 1. 打开注册页面 打开 [https://www.paypal.com/signin](https://www.paypal.com/signin)。 @@ -234,14 +273,14 @@ 常见情况是上传身份证件。 `PayPal` 官方帮助中心说明,通常会在 `2 个工作日` 内审核,但某些情况可能更久。 -### 第七部分:0元试用 ChatGPT Plus 教程 +### 第八部分:0元试用 ChatGPT Plus 教程 本部分说明如何在已登录 ChatGPT 的状态下,通过脚本快速生成 Plus 支付链接,然后选择 PayPal 支付完成0元试用订阅。 #### 准备工作 1. 已有一个登录状态的 ChatGPT 账户。 -2. 一个可用的 PayPal 账户(参考第六部分进行注册和绑卡)。 +2. 一个可用的 PayPal 账户(参考第七部分进行注册和绑卡)。 3. 能够接收生成的账单地址的真实地址或虚拟地址。 4. Chrome 浏览器(推荐使用地址补全功能)。 @@ -389,7 +428,147 @@ - **PayPal 登录后页面无法继续跳转** 稍等片刻让页面加载完毕。如果长时间未响应,检查浏览器是否有弹窗被隐藏,或尝试刷新页面。 -### 第八部分:配置 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` +### 第九部分:扩展内置动态 IP 代理使用教程 + +本部分说明扩展侧边栏里的 `IP代理` 功能。 +它和 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 这类系统代理不一样:扩展会通过浏览器代理 API 给当前浏览器设置 PAC 代理,并自动回填代理鉴权。当前首版只开放 `711Proxy` 的账号密码模式,`API 拉取` 和多账号列表入口暂未开放。 + +#### 一、什么时候需要打开 `IP代理` + +如果你已经在 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 或其他客户端里稳定使用节点,可以继续使用外部代理。 +如果你希望在扩展里直接控制动态 IP、检查当前出口、按成功轮次自动切换,或者不想依赖系统全局代理,就使用这里的 `IP代理`。 + +开启 `IP代理` 后,扩展会接管浏览器代理。 +关闭 `IP代理` 后,浏览器会恢复默认代理或外部全局代理。 + +#### 二、注册或登录 `711Proxy` + +在侧边栏打开 `IP代理` 后,`代理服务` 当前固定为 `711Proxy(首版)`。 +点击旁边的 `注册` 按钮,按页面提示完成注册或登录。 + +在网站中完成注册、登录和套餐准备后,进入代理信息页面,找到可用于账号密码代理的 `Host`、`Port`、`Username` 和 `Password`。 + +#### 三、填写固定账号代理 + +1. 在扩展侧边栏打开 `IP代理` 开关。 +2. `代理模式` 保持 `账号密码`。 +3. 填写 `代理 Host`,例如 `global.rotgb.711proxy.com`。 +4. 填写 `代理 Port`,例如 `10000`。 +5. `代理协议` 一般保持 `HTTP`,除非你的代理信息明确要求 `HTTPS`、`SOCKS5` 或 `SOCKS4`。 +6. 填写 `代理账号` 和 `代理密码`。 +7. 按需填写 `地区参数`,例如 `US`、`DE`、`HK`。 +8. 按需填写 `会话(session)` 和 `时长(life)`。 + +`地区参数` 只接受两个字母的国家或地区代码。 +`会话(session)` 会被追加或写回到用户名里的 `session-xxxx`。 +`时长(life)` 会被追加或写回到用户名里的 `sessTime-xx`,711Proxy 的有效范围按 `5-180` 分钟使用更稳。 + +如果你的 `711Proxy` 用户名里已经带了 `region-US`、`session-xxxx` 或 `sessTime-30`,扩展会优先识别用户名里的值。 +如果你在表单里修改地区、session 或时长,扩展会把它们同步回最终生效的代理用户名。 + +#### 四、同步、下一条、Change 和检测出口 + +填写或修改代理信息后,不要只保存后就直接开跑。建议按下面顺序操作: + +1. 点击 `同步` + 扩展会保存当前配置,生成代理规则,应用到浏览器,并自动做一次出口检测。 + 这是修改 Host、Port、账号、密码、地区、session 或时长后最常用的按钮。 + +2. 查看 `代理状态` + 状态卡会显示当前代理、当前出口 IP、出口地区和诊断详情。 + 如果显示 `当前代理` 和 `当前出口`,说明扩展已经能看到代理出口。 + +3. 点击 `检测出口` + 它只重新检测当前出口,不主动更换节点。 + 如果你怀疑检测结果没刷新,或者刚刚切换过外部网络,可以点它复测。 + +4. 点击 `检查IP` + 它会打开 `https://ipinfo.io/what-is-my-ip`,用于人工确认网页看到的公网 IP。 + +5. 点击 `下一条` + 当前首版主要是固定账号模式。如果只有一条可用代理,`下一条` 会重绑当前节点并复测,不保证一定换出口。 + 如果后续开放多账号列表或 API 池,`下一条` 才会真正切到池里的下一条。 + +6. 点击 `Change` + `Change` 只支持 `711Proxy + 账号密码模式 + 用户名包含 session` 的场景。 + 它会保持当前 session,强制重绑代理链路并刷新出口,适合想换出口但不想改整条账号配置时使用。 + +#### 五、任务切换阈值 + +`任务切换阈值` 表示自动流程成功多少轮后尝试自动切换一次代理。 +默认是 `20` 轮,可填写 `1-500`。 + +需要注意: + +- 当前只有一条固定账号时,即使命中阈值,也会跳过自动切换。 +- 如果后续有多条可切换代理,命中阈值后会自动执行 `下一条`。 +- 这个阈值只影响自动流程成功后的切换节奏,不会替代你手动点击 `同步` 或 `检测出口`。 + +#### 六、状态卡怎么看 + +常见状态含义如下: + +- `未开启`:扩展没有接管浏览器代理,继续使用默认网络或外部全局代理。 +- `当前代理:host:port;当前出口:x.x.x.x`:代理已经应用,并检测到出口 IP。 +- `已启用,但账号模式没有可用代理`:Host/Port 为空或格式不完整,需要先补齐代理信息。 +- `连通性失败`:代理应用了,但没有检测到可用出口,优先检查 Host、Port、协议、账号和密码。 +- `地区校验未通过`:检测到的出口地区和你填写的地区参数不一致。711Proxy 场景下扩展会保留接管并给出警告,你需要决定是否继续使用这个出口。 +- `当前链路未收到 407 代理鉴权挑战`:代理服务没有按浏览器扩展代理 API 预期返回标准鉴权挑战,可能导致账号密码无法自动回填。可以尝试 `Change`、关闭再开启 `IP代理`、换 session,或者更换代理节点。 + +如果代理不可用,扩展会启用目标站点阻断保护,避免在代理失效时继续直连访问目标站点。修好代理后重新点击 `同步` 或 `检测出口` 即可恢复。 + +#### 七、推荐使用顺序 + +首次配置建议按这个顺序: + +1. 点击 `注册` 打开 `711Proxy`,准备代理账号信息。 +2. 打开扩展侧边栏的 `IP代理`。 +3. 填写 Host、Port、协议、账号、密码。 +4. 填写地区参数,例如 `US`。 +5. 填写 session 和时长,例如 session 为随机前缀、时长为 `30`。 +6. 点击 `同步`。 +7. 确认 `代理状态` 里有出口 IP。 +8. 点击 `检查IP`,人工确认网页检测结果。 +9. 再继续执行注册、登录或自动流程。 + +如果后续要换出口,优先尝试 `Change`。 +如果 `Change` 不可用,再调整 session 或代理账号后点击 `同步`。 + +### 第十部分:节点检测与纯净度检查网站 + +本部分用于检查当前代理节点的出口 IP、归属地、风险标签、泄露情况和网站访问速度。 +建议每次切换节点后都重新打开这些网站检查一次。 + +1. 使用 [IP111](https://ip111.cn/) 检查出口 IP + `IP111` 会展示当前访问网站时识别到的 IP 信息。 + 它适合用来快速确认当前节点是否已经生效,以及国内外访问看到的出口 IP 是否一致。 + 如果你切换节点后页面仍显示原来的 IP,说明代理可能没有切换成功,或者浏览器还有缓存/分流规则没有生效。 + +2. 使用 [IPData](https://ipdata.co/) 查看 IP 资料和风险信息 + `IPData` 偏向 IP 数据查询。 + 它可以查看 IP 的国家、地区、运营商、组织、ASN、时区、货币等基础信息。 + 它也会展示威胁和风险相关信息,例如是否像代理、VPN、Tor、数据中心 IP、匿名 IP,或者是否存在滥用记录。 + 适合用来判断节点的基础身份和风险标签。 + +3. 使用 [IPPure](https://ippure.com/) 检查 IP 纯净度 + `IPPure` 更偏向一键检测 IP 纯净度。 + 它会显示 IP 位置、风险检测、代理/VPN/黑名单等信息。 + 页面中还包含浏览器指纹、出口地图、`WebRTC` 泄露、`DNS` 泄露等检测项。 + 适合用来判断节点是否干净,以及浏览器是否暴露了真实网络环境。 + +4. 使用 [TCPTest 网站测速](https://www.tcptest.cn/?cckey=e62f31db) 检查访问速度 + `TCPTest` 主要用于网站测速和连通性检测。 + 它可以测试 `Ping`、`TCPing`、`HTTP` 速度、`DNS` 解析、路由追踪和 `MTR`。 + 如果某个网站访问慢、打不开,或者想比较不同节点访问同一网站的速度,可以用它测试连接耗时、状态码、解析耗时和线路表现。 + 这类测速结果不等于 IP 纯净度,但可以帮助判断节点访问目标网站是否稳定。 + +5. 推荐检查顺序 + 先打开 [IP111](https://ip111.cn/) 确认出口 IP 是否切换成功。 + 再打开 [IPData](https://ipdata.co/) 查看 IP 归属、ASN 和风险标签。 + 接着打开 [IPPure](https://ippure.com/) 检查纯净度、黑名单、代理识别和泄露情况。 + 最后用 [TCPTest 网站测速](https://www.tcptest.cn/?cckey=e62f31db) 测试目标网站的访问速度和连通性。 + +### 第十一部分:配置 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` #### 第一步:添加扩展脚本 @@ -497,6 +676,48 @@ function main(config, profileName) { 4. 确认右上角的 `代理模式`(`Mode`)已经设置为 `规则模式`(`Rule`)。 5. 确认左侧 `设置` 中的 `系统代理`(`System Proxy`)已经开启。 ![2026 04 25 003703](https://apikey.qzz.io/content-assets/library/2026/04/20260424-163745--2026-04-25-003703--21e892504b82.png) + +### 第十二部分:订阅节点与自建推荐 + +如果你还没有订阅节点或想要寻找稳定、便宜的科学上网方式,可以参考以下三种方案获取。 + +#### 方法一:获取日常免费节点(85LA) + +1. 打开 [85LA 科学上网免费节点](https://www.85la.com/internet-access)。 +2. 网站每天会更新包含数百个测试通过的高速节点订阅,支持 `v2ray`、`Clash`、`SING-BOX` 等主流客户端。 +3. 复制最新日期的订阅链接,直接导入你的客户端即可免费使用。 + +#### 方法二:选择限时免费与高性价比机场推荐(二毛博客) + +1. 打开 [2026年翻墙机场推荐评测](https://www.ermao.net/posts/vpn/)。 +2. 该页面整理了数十家机场的短期与长期流量包价格,其中像 `ssone`、`flybit` 等很多机场在注册后均赠送限时免费试用(如送 1 天 1G/2G 流量等)。 +3. 可以根据网页中的评测挑选符合你需求与预算的低价机场来保障长期稳定。 + +#### 方法三:十分钟自建 Cloudflare 永久免费节点 + +如果你不想花钱,且希望获得一个长期稳定的私人免费节点,可以利用 `Cloudflare Pages` 快速搭建。 + +1. **第一步:注册免费域名** + 访问诸如 [DNS.HE](https://www.dnshe.com/) 或 [DigitalPlat](https://digitalplat.org/) 注册,并获取一个永久免费的域名(例如结尾是 `ccwu.cc` 或 `us.ci` 的域名)。 + +2. **第二步:托管域名至 Cloudflare** + 登录 [Cloudflare 后台](https://www.cloudflare.com/),将刚刚申请好的免费域名添加进去进行托管,并根据提示修改域名的 NS 记录直到激活显示。 + +3. **第三步:创建 KV 命名空间** + 在 Cloudflare 后台侧边栏找到 `存储和数据库` -> `Worker KV`,点击 `创建命名空间`,可以随意命名(主要是为了稍后绑定缓存数据)。 + +4. **第四步:部署 Pages 节点服务** + - 展开侧边栏 `Workers 和 Pages`,点击 `创建`,然后选择 `Pages` 标签页中的 `上传资产`。 + - 下载由 `cmliu` 开源的 [EdgeTunnel 资源库](https://github.com/cmliu/edgetunnel/archive/refs/heads/main.zip) 原程序压缩包(`main.zip`)。 + - 将 `main.zip` 上传并部署。 + - 部署完成后点击 `继续处理站点`,进入项目的 `设置` -> `环境变量` -> `添加变量`,变量名填入 `ADMIN`,值设为你想要的后台登录管理员密码,点击 `保存`。 + - 在同一个 `设置` 页中选择 `绑定` -> `添加` -> `KV 命名空间`,变量名称统一填写 `KV`,并选中你在第三步创建好的那个命名空间进行保存。 + - 返回 `部署` 选项卡重新上传 `main.zip` 进行覆盖部署即可使得配置生效。 + - 返回项目的 `自定义域` 标签卡,添加一条你的免费域名(如 `node.ccwu.cc`),并按要求去 DNS 面板配置 CNAME 指向分配的 `pages.dev` 后激活。 + +5. **第五步:获取订阅与后台管理** + 直接在浏览器访问你的自定义域名并加上后台路径(如 `https://node.ccwu.cc/admin`),通过第四步设置的密码登录后台。在后台即可复制你的专属 VLESS / Trojan 节点订阅地址,也可导入到各路客户端中直接使用。 + ## 常见问题 ### 为什么第十步显示认证成功,但没有认证文件? @@ -511,6 +732,16 @@ function main(config, profileName) { 可以。你在 `账号管理` 中创建英文邮箱和 `Foxmail` 邮箱,使用后直接删除,再重复创建即可继续使用。 +### `网易邮箱` 一个手机号大约可以注册多少个邮箱? + +按目前使用经验,同一个手机号可以分别注册 `163`、`126` 和 `yeah` 邮箱,加起来大约 `15` 个账号。 +每个账号还可以添加 `2` 个 `替身邮箱`,所以总共大约可以得到 `45` 个可用邮箱地址。 + +### `网易邮箱` 注册时提示频繁怎么办? + +先停止连续注册,隔一段时间再继续。 +短时间连续注册容易触发频繁提示或限制,建议注册一个后暂停一会儿,或者换一个网络环境再继续。 + ### `iCloud 隐私邮箱` 插件刷新后没有邮箱怎么办? 先确认你已经在网页中登录 `iCloud`,并且已经在 `隐藏邮件地址` 里手动创建过隐私邮箱。 @@ -538,6 +769,35 @@ function main(config, profileName) { 请先确认脚本已经完整粘贴并保存,然后回到 `代理` 页面重新查看。若仍未出现,请继续确认当前订阅可用、`代理模式` 为 `规则模式`,并且 `系统代理` 已开启。 +### 扩展 `IP代理` 开启后,为什么状态还是没有出口 IP? + +先确认 Host、Port、协议、代理账号和代理密码都已填写正确。 +修改这些字段后,需要点击 `同步` 才会重新应用代理并检测出口。 +如果诊断提示没有收到 `407` 代理鉴权挑战,可以尝试点击 `Change`、关闭再开启 `IP代理`、更换 session,或者换一条代理节点。 + +### `Change` 按钮为什么点不了? + +`Change` 只在 `711Proxy` 的账号密码模式下可用,并且当前生效用户名必须包含 `session-xxxx` 这类 session 参数。 +如果没有 session,请先在 `会话(session)` 中填写一个随机前缀,再点击 `同步`。 + +### `IP代理` 和 Clash Verge 应该同时开吗? + +通常二选一即可。 +如果开启扩展内置 `IP代理`,浏览器会优先使用扩展设置的 PAC 代理;如果关闭它,浏览器会回到默认网络或外部全局代理。 +排查问题时建议先只保留一种代理方式,确认出口 IP 稳定后再继续流程。 + +### 网站测速结果好,是否代表节点纯净? + +不代表。 +`TCPTest` 主要看连接速度、解析耗时、状态码和线路稳定性。 +节点纯净度还需要结合 [IPData](https://ipdata.co/) 和 [IPPure](https://ippure.com/) 的风险标签、代理识别、黑名单、`DNS` 泄露和 `WebRTC` 泄露结果一起判断。 + +### 切换节点后为什么检测网站结果没变? + +先确认 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 已经切换到目标节点或目标策略组。 +然后刷新检测网站,必要时关闭浏览器重新打开。 +如果 [IP111](https://ip111.cn/) 仍显示旧 IP,说明当前浏览器流量可能没有走新节点,或者分流规则没有命中。 + ## 注意事项 - 不论使用哪种方式更新扩展,更新完成后都必须在浏览器的 `扩展程序管理` 页面重新加载一次该扩展。 @@ -548,9 +808,17 @@ function main(config, profileName) { - 如果条件允许,建议使用非日常主力 Apple ID 配置 `iCloud 隐私邮箱`,避免影响平时常用账号。 - 如需部署 `cpa`,请先确认部署环境可以访问 `OpenAI`,否则可能出现第十步认证成功但没有认证文件的问题。 - `QQ 邮箱`切换邮箱时,建议在使用完成后及时删除,再重新创建,避免混淆当前正在使用的邮箱地址。 +- `网易邮箱` 注册时不要短时间连续创建多个账号,否则容易触发频繁提示或限制。 +- `网易邮箱` 的 `替身邮箱` 是在单个邮箱账号下添加的,注册完成主邮箱后再进入 `替身邮箱` 页面添加。 - 中国大陆居民注册 `PayPal` 个人账户时,姓名请按身份证上的中文姓名填写,不要使用拼音。 - `PayPal` 官方说明,绑定新卡时可能会向发卡行发送最高 `1 USD` 或等值货币的临时授权验证,所以完全没有余额的卡也可能因为授权失败而无法绑定。 - 为了控制风险,更稳妥的做法是使用单独管理、余额较低的借记卡,并在绑卡后及时检查 `钱包` 页面和右上角通知。 +- 使用扩展内置 `IP代理` 时,修改 Host、Port、账号、密码、地区、session 或时长后,请点击 `同步` 让新配置真正生效。 +- `711Proxy` 的 `Change` 需要用户名中包含 session;没有 session 时请先填写 `会话(session)` 并同步。 +- 扩展 `IP代理` 报连通性失败时,不要直接继续自动流程,先用 `检测出口` 和 `检查IP` 确认网页看到的出口 IP。 +- 检查节点时,先确认出口 IP,再看风险标签和泄露检测,最后再看网站测速。 +- [TCPTest 网站测速](https://www.tcptest.cn/?cckey=e62f31db) 主要用于判断访问速度和连通性,不要单独用它判断节点纯净度。 +- [IPPure](https://ippure.com/) 如果提示 `WebRTC` 或 `DNS` 泄露,请优先检查浏览器和代理客户端的相关设置。 - 使用 `git pull` 更新扩展是最方便、最推荐的方式。 - 在 `Clash Verge` 中粘贴脚本时,请先清空旧内容,再完整粘贴新代码并保存。 diff --git a/manifest.json b/manifest.json index f1e2895..b6cb432 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, "name": "codex-oauth-automation-extension", - "version": "1.4", - "version_name": "Ultra1.4", + "version": "2.0", + "version_name": "Ultra2.0", "description": "用于自动执行多步骤 OAuth 注册流程", "permissions": [ "sidePanel", diff --git a/paypal-utils.js b/paypal-utils.js new file mode 100644 index 0000000..b949bc7 --- /dev/null +++ b/paypal-utils.js @@ -0,0 +1,56 @@ +(function attachPayPalUtils(root, factory) { + root.PayPalUtils = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createPayPalUtils() { + function normalizePayPalAccount(account = {}) { + const normalizedEmail = String(account.email || '').trim().toLowerCase(); + const now = Date.now(); + return { + id: String(account.id || crypto.randomUUID()), + email: normalizedEmail, + password: String(account.password || ''), + createdAt: Number.isFinite(Number(account.createdAt)) ? Number(account.createdAt) : now, + updatedAt: Number.isFinite(Number(account.updatedAt)) ? Number(account.updatedAt) : now, + lastUsedAt: Number.isFinite(Number(account.lastUsedAt)) ? Number(account.lastUsedAt) : 0, + }; + } + + function normalizePayPalAccounts(accounts) { + if (!Array.isArray(accounts)) return []; + + const deduped = new Map(); + for (const account of accounts) { + const normalized = normalizePayPalAccount(account); + if (!normalized.email) continue; + deduped.set(normalized.id, normalized); + } + return [...deduped.values()]; + } + + function findPayPalAccount(accounts = [], accountId = '') { + const normalizedId = String(accountId || '').trim(); + if (!normalizedId) return null; + return normalizePayPalAccounts(accounts).find((account) => account.id === normalizedId) || null; + } + + function upsertPayPalAccountInList(accounts = [], nextAccount = null) { + if (!nextAccount) { + return normalizePayPalAccounts(accounts); + } + + const normalizedNext = normalizePayPalAccount(nextAccount); + const list = normalizePayPalAccounts(accounts); + const existingIndex = list.findIndex((account) => account.id === normalizedNext.id); + if (existingIndex >= 0) { + list[existingIndex] = normalizedNext; + return list; + } + return [...list, normalizedNext]; + } + + return { + findPayPalAccount, + normalizePayPalAccount, + normalizePayPalAccounts, + upsertPayPalAccountInList, + }; +}); diff --git a/scripts/hotmail_helper.py b/scripts/hotmail_helper.py index 0a21d10..768b480 100644 --- a/scripts/hotmail_helper.py +++ b/scripts/hotmail_helper.py @@ -76,8 +76,11 @@ def json_response(handler, status, payload): handler.send_header("Access-Control-Allow-Origin", "*") handler.send_header("Access-Control-Allow-Headers", "Content-Type") handler.send_header("Access-Control-Allow-Methods", "POST, OPTIONS") - handler.end_headers() - handler.wfile.write(body) + try: + handler.end_headers() + handler.wfile.write(body) + except (BrokenPipeError, ConnectionResetError) as exc: + log_info(f"response aborted by client status={status} detail={compact_text(exc)}") def read_json_payload(handler): @@ -120,6 +123,87 @@ def log_info(message): print(f"[HotmailHelper] {message}", flush=True) +def is_openai_sender(address): + sender = str(address or "").strip().lower() + return "openai" in sender + + +def get_message_body_content(message): + body = message.get("body") or {} + if not isinstance(body, dict): + return "" + return str(body.get("content") or "").strip() + + +def log_openai_messages(messages, transport=""): + for message in messages or []: + sender_info = message.get("from", {}).get("emailAddress", {}) or {} + sender = str(sender_info.get("address") or "").strip() + if not is_openai_sender(sender): + continue + + sender_name = str(sender_info.get("name") or "").strip() + mailbox = str(message.get("mailbox") or "").strip() or "INBOX" + subject = str(message.get("subject") or "").strip() + transport_label = str(transport or "").strip() or "unknown" + base = ( + f"transport={transport_label} mailbox={mailbox} sender={sender} " + f"senderName={sender_name or '-'} subject={subject}" + ) + + log_info(f"openai mail received {base}") + + body_content = get_message_body_content(message) + if body_content: + log_info(f"openai mail full body start {base}") + print(body_content, flush=True) + log_info("openai mail full body end") + continue + + preview = str(message.get("bodyPreview") or "").strip() + if preview: + log_info(f"openai mail preview {base} preview={compact_text(preview, 1000)}") + + +def get_proxy_debug_context(): + names = ["all_proxy", "http_proxy", "https_proxy", "ALL_PROXY", "HTTP_PROXY", "HTTPS_PROXY"] + parts = [] + for name in names: + value = str(os.environ.get(name) or "").strip() + if value: + parts.append(f"{name}={value}") + return ",".join(parts) if parts else "direct" + + +def classify_token_refresh_failure(result): + detail = str(result.get("error") or "").strip().lower() + if "invalid_grant" in detail or "aadsts70000" in detail: + return "invalid_grant" + if "proxy authentication required" in detail: + return "proxy_auth_failed" + if "connection refused" in detail: + return "proxy_connect_failed" if get_proxy_debug_context() != "direct" else "connection_refused" + if "eof occurred in violation of protocol" in detail or "wrong version number" in detail: + return "proxy_tls_failed" if get_proxy_debug_context() != "direct" else "tls_failed" + if "timed out" in detail or "timeout" in detail: + return "network_timeout" + return "request_failed" + + +def log_token_refresh_failure_diagnosis(result): + category = classify_token_refresh_failure(result) + message = ( + "token refresh diagnosis " + f"endpoint={result['endpoint']} " + f"category={category}" + ) + if category.startswith("proxy_"): + message += f" proxy={get_proxy_debug_context()}" + elif category == "invalid_grant": + message += " hint=refresh_token_or_scope_invalid" + log_info(message) + + def append_account_log(email_addr, password, status, recorded_at="", reason=""): normalized_email = str(email_addr or "").strip() normalized_password = str(password or "").strip() @@ -320,6 +404,7 @@ def refresh_access_token(client_id, refresh_token, strategy_names=None): f"elapsedMs={result['elapsed_ms']} " f"detail={result['error']}" ) + log_token_refresh_failure_diagnosis(result) details = " | ".join( f"{item['endpoint']}({item['status']}): {item['error']}" @@ -438,6 +523,9 @@ def normalize_message(message_id, raw_bytes, mailbox): } }, "bodyPreview": body[:500], + "body": { + "content": body, + }, "receivedDateTime": to_iso_string(timestamp_ms), "receivedTimestamp": timestamp_ms, } @@ -532,12 +620,12 @@ def normalize_outlook_message(message, mailbox): def fetch_graph_messages(access_token, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT): mailbox_id = normalize_mailbox_id(mailbox) - url = ( - f"{GRAPH_API_ORIGIN}/v1.0/me/mailFolders/{mailbox_id}/messages" - f"?$top={max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30))}" - f"&$select=id,internetMessageId,subject,from,bodyPreview,receivedDateTime" - f"&$orderby=receivedDateTime desc" - ) + query = urlencode({ + "$top": max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30)), + "$select": "id,internetMessageId,subject,from,bodyPreview,receivedDateTime", + "$orderby": "receivedDateTime desc", + }) + url = f"{GRAPH_API_ORIGIN}/v1.0/me/mailFolders/{mailbox_id}/messages?{query}" try: _, payload = get_json(url, headers={ "Accept": "application/json", @@ -555,12 +643,12 @@ def fetch_graph_messages(access_token, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT) def fetch_outlook_api_messages(access_token, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT): mailbox_id = normalize_mailbox_id(mailbox) - url = ( - f"{OUTLOOK_API_ORIGIN}/api/v2.0/me/mailfolders/{mailbox_id}/messages" - f"?$top={max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30))}" - f"&$select=Id,Subject,From,BodyPreview,ReceivedDateTime" - f"&$orderby=ReceivedDateTime desc" - ) + query = urlencode({ + "$top": max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30)), + "$select": "Id,Subject,From,BodyPreview,ReceivedDateTime", + "$orderby": "ReceivedDateTime desc", + }) + url = f"{OUTLOOK_API_ORIGIN}/api/v2.0/me/mailfolders/{mailbox_id}/messages?{query}" try: _, payload = get_json(url, headers={ "Accept": "application/json", @@ -637,6 +725,7 @@ def collect_messages(email_addr, client_id, refresh_token, mailboxes, top): try: log_info(f"message collection start transport={transport_name}") result = collector(email_addr, client_id, refresh_token, mailboxes, top) + log_openai_messages(result.get("messages") or [], transport=transport_name) log_info( f"message collection success transport={transport_name} " f"tokenEndpoint={result['token_payload'].get('token_endpoint', '')}" @@ -679,6 +768,10 @@ def select_latest_code(messages, sender_filters, subject_filters, exclude_codes, preview = str(message.get("bodyPreview", "")) combined = " ".join([sender, subject.lower(), preview.lower()]) code = extract_code(" ".join([subject, preview, sender])) + if not code: + body_content = get_message_body_content(message) + if body_content: + code = extract_code(" ".join([subject, body_content, sender])) if not code or code in excluded: return None diff --git a/sidepanel/form-dialog.js b/sidepanel/form-dialog.js new file mode 100644 index 0000000..0e44133 --- /dev/null +++ b/sidepanel/form-dialog.js @@ -0,0 +1,269 @@ +(function attachSidepanelFormDialog(globalScope) { + const FORM_EYE_OPEN_ICON = ''; + const FORM_EYE_CLOSED_ICON = ''; + + function syncPasswordToggleButton(button, input, labels) { + if (!button || !input) return; + const isHidden = input.type === 'password'; + button.innerHTML = isHidden ? FORM_EYE_OPEN_ICON : FORM_EYE_CLOSED_ICON; + button.setAttribute('aria-label', isHidden ? labels.show : labels.hide); + button.title = isHidden ? labels.show : labels.hide; + } + + function createFormDialog(context = {}) { + const { + overlay = null, + titleNode = null, + closeButton = null, + messageNode = null, + alertNode = null, + fieldsContainer = null, + cancelButton = null, + confirmButton = null, + documentRef = globalScope.document, + } = context; + + let resolver = null; + let currentConfig = null; + let currentInputs = []; + + function setHidden(node, hidden) { + if (!node) return; + node.hidden = Boolean(hidden); + } + + function resetAlert() { + if (!alertNode) return; + alertNode.textContent = ''; + alertNode.className = 'modal-alert modal-form-alert'; + alertNode.hidden = true; + } + + function setAlert(message = '', tone = 'danger') { + if (!alertNode) return; + const text = String(message || '').trim(); + if (!text) { + resetAlert(); + return; + } + alertNode.textContent = text; + alertNode.className = `modal-alert modal-form-alert${tone === 'danger' ? ' is-danger' : ''}`; + alertNode.hidden = false; + } + + function close(result = null) { + if (resolver) { + resolver(result); + resolver = null; + } + currentConfig = null; + currentInputs = []; + resetAlert(); + if (fieldsContainer) { + fieldsContainer.innerHTML = ''; + } + if (overlay) { + overlay.hidden = true; + } + } + + function buildFieldNode(field, values) { + const wrapper = documentRef.createElement('div'); + wrapper.className = 'modal-form-row'; + + const label = documentRef.createElement('label'); + label.className = 'modal-form-label'; + const labelText = String(field.label || field.key || '').trim(); + label.textContent = labelText; + wrapper.appendChild(label); + + let input = null; + if (field.type === 'textarea') { + input = documentRef.createElement('textarea'); + input.className = 'data-textarea'; + } else if (field.type === 'select') { + input = documentRef.createElement('select'); + input.className = 'data-select'; + const options = Array.isArray(field.options) ? field.options : []; + options.forEach((option) => { + const optionNode = documentRef.createElement('option'); + optionNode.value = String(option?.value || ''); + optionNode.textContent = String(option?.label || option?.value || ''); + input.appendChild(optionNode); + }); + } else { + input = documentRef.createElement('input'); + input.type = field.type === 'password' ? 'password' : 'text'; + input.className = field.type === 'password' + ? 'data-input data-input-with-icon' + : 'data-input'; + } + + const normalizedValue = Object.prototype.hasOwnProperty.call(values, field.key) + ? values[field.key] + : field.value; + if (normalizedValue !== undefined && normalizedValue !== null) { + input.value = String(normalizedValue); + } + if (field.placeholder) { + input.placeholder = String(field.placeholder); + } + if (field.autocomplete) { + input.autocomplete = String(field.autocomplete); + } + if (field.inputMode) { + input.inputMode = String(field.inputMode); + } + if (field.rows && field.type === 'textarea') { + input.rows = Number(field.rows) || 3; + } + input.dataset.fieldKey = String(field.key || ''); + label.htmlFor = field.key; + input.id = field.key; + if (field.type === 'password') { + const inputShell = documentRef.createElement('div'); + inputShell.className = 'input-with-icon'; + inputShell.appendChild(input); + + const toggleButton = documentRef.createElement('button'); + toggleButton.className = 'input-icon-btn'; + toggleButton.type = 'button'; + const labels = { + show: String(field.showPasswordLabel || `\u663e\u793a${labelText || '\u5bc6\u7801'}`), + hide: String(field.hidePasswordLabel || `\u9690\u85cf${labelText || '\u5bc6\u7801'}`), + }; + syncPasswordToggleButton(toggleButton, input, labels); + toggleButton.addEventListener('click', () => { + input.type = input.type === 'password' ? 'text' : 'password'; + syncPasswordToggleButton(toggleButton, input, labels); + }); + + inputShell.appendChild(toggleButton); + wrapper.appendChild(inputShell); + } else { + wrapper.appendChild(input); + } + + if (field.type !== 'textarea') { + input.addEventListener('keydown', (event) => { + if (event.key !== 'Enter') { + return; + } + event.preventDefault(); + void handleConfirm(); + }); + } + + currentInputs.push({ field, input }); + return wrapper; + } + + function collectValues() { + return currentInputs.reduce((result, item) => { + result[item.field.key] = item.input.value; + return result; + }, {}); + } + + async function handleConfirm() { + if (!currentConfig) { + close(null); + return; + } + + const values = collectValues(); + resetAlert(); + + for (const item of currentInputs) { + const { field, input } = item; + const rawValue = values[field.key]; + const textValue = String(rawValue || '').trim(); + if (field.required && !textValue) { + setAlert(field.requiredMessage || `${field.label || field.key}不能为空。`); + input.focus?.(); + return; + } + if (typeof field.validate === 'function') { + const validationMessage = await field.validate(rawValue, values); + if (validationMessage) { + setAlert(validationMessage); + input.focus?.(); + return; + } + } + } + + close(values); + } + + function bindEvents() { + overlay?.addEventListener('click', (event) => { + if (event.target === overlay) { + close(null); + } + }); + closeButton?.addEventListener('click', () => close(null)); + cancelButton?.addEventListener('click', () => close(null)); + confirmButton?.addEventListener('click', () => { + void handleConfirm(); + }); + } + + async function open(config = {}) { + if (!overlay || !titleNode || !fieldsContainer || !confirmButton) { + return null; + } + if (resolver) { + close(null); + } + + currentConfig = config || {}; + currentInputs = []; + titleNode.textContent = String(currentConfig.title || '填写表单'); + if (messageNode) { + const message = String(currentConfig.message || '').trim(); + messageNode.textContent = message; + setHidden(messageNode, !message); + } + resetAlert(); + if (currentConfig.alert?.text) { + setAlert(currentConfig.alert.text, currentConfig.alert.tone || 'danger'); + } + + confirmButton.textContent = String(currentConfig.confirmLabel || '确认'); + confirmButton.className = `btn ${currentConfig.confirmVariant || 'btn-primary'} btn-sm`; + fieldsContainer.innerHTML = ''; + + const initialValues = currentConfig.initialValues && typeof currentConfig.initialValues === 'object' + ? currentConfig.initialValues + : {}; + const fields = Array.isArray(currentConfig.fields) ? currentConfig.fields : []; + fields.forEach((field) => { + fieldsContainer.appendChild(buildFieldNode(field, initialValues)); + }); + + overlay.hidden = false; + const firstInput = currentInputs[0]?.input || null; + if (firstInput && typeof globalScope.requestAnimationFrame === 'function') { + globalScope.requestAnimationFrame(() => firstInput.focus?.()); + } else { + firstInput?.focus?.(); + } + + return new Promise((resolve) => { + resolver = resolve; + }); + } + + bindEvents(); + + return { + close, + open, + }; + } + + globalScope.SidepanelFormDialog = { + createFormDialog, + }; +})(window); diff --git a/sidepanel/ip-proxy-panel.js b/sidepanel/ip-proxy-panel.js index fe6e115..947a9de 100644 --- a/sidepanel/ip-proxy-panel.js +++ b/sidepanel/ip-proxy-panel.js @@ -14,6 +14,47 @@ const ipProxyActionState = { busy: false, action: '', }; +const IP_PROXY_SECTION_EXPANDED_STORAGE_KEY = 'multipage-ip-proxy-section-expanded'; +let ipProxySectionExpanded = false; + +function readIpProxySectionExpanded() { + try { + return globalThis.localStorage?.getItem(IP_PROXY_SECTION_EXPANDED_STORAGE_KEY) === '1'; + } catch (err) { + return false; + } +} + +function persistIpProxySectionExpanded(expanded) { + try { + if (expanded) { + globalThis.localStorage?.setItem(IP_PROXY_SECTION_EXPANDED_STORAGE_KEY, '1'); + } else { + globalThis.localStorage?.removeItem(IP_PROXY_SECTION_EXPANDED_STORAGE_KEY); + } + } catch (err) { + // Ignore storage errors; the in-memory collapsed state is still enough for this session. + } +} + +function setIpProxySectionExpanded(expanded) { + ipProxySectionExpanded = Boolean(expanded); + persistIpProxySectionExpanded(ipProxySectionExpanded); + if (typeof updateIpProxyUI === 'function') { + updateIpProxyUI(latestState); + } +} + +function toggleIpProxySectionExpanded() { + setIpProxySectionExpanded(!ipProxySectionExpanded); +} + +function initIpProxySectionExpandedState() { + ipProxySectionExpanded = readIpProxySectionExpanded(); + if (typeof updateIpProxyUI === 'function') { + updateIpProxyUI(latestState); + } +} function normalizeIpProxyActionType(value = '') { const normalized = String(value || '').trim().toLowerCase(); @@ -1158,6 +1199,7 @@ function setIpProxyEnabledInlineStatus(state = {}, enabled = getSelectedIpProxyE function updateIpProxyUI(state = latestState) { const enabled = getSelectedIpProxyEnabled(); + const showSettings = enabled && ipProxySectionExpanded; const mode = getSelectedIpProxyMode(); const service = normalizeIpProxyService(selectIpProxyService?.value || state?.ipProxyService || DEFAULT_IP_PROXY_SERVICE); const apiModeAvailable = isIpProxyApiModeAvailable(); @@ -1172,64 +1214,70 @@ function updateIpProxyUI(state = latestState) { const busyAction = normalizeIpProxyActionType(actionState.action); const runtimeState = state || latestState || {}; - setIpProxyEnabledInlineStatus(runtimeState, enabled); - if (rowIpProxyEnabled) { rowIpProxyEnabled.style.display = ''; } + if (btnToggleIpProxySection) { + btnToggleIpProxySection.disabled = !enabled; + btnToggleIpProxySection.textContent = showSettings ? '收起设置' : '展开设置'; + btnToggleIpProxySection.title = enabled + ? (showSettings ? '收起 IP 代理设置' : '展开 IP 代理设置') + : '开启 IP 代理后可展开设置'; + btnToggleIpProxySection.setAttribute('aria-expanded', String(showSettings)); + } if (rowIpProxyFold) { - rowIpProxyFold.style.display = enabled ? '' : 'none'; + rowIpProxyFold.style.display = showSettings ? '' : 'none'; } if (rowIpProxyService) { - rowIpProxyService.style.display = enabled ? '' : 'none'; + rowIpProxyService.style.display = showSettings ? '' : 'none'; } if (rowIpProxyMode) { - rowIpProxyMode.style.display = enabled ? '' : 'none'; + rowIpProxyMode.style.display = showSettings ? '' : 'none'; } if (rowIpProxyLayout) { - rowIpProxyLayout.style.display = enabled ? '' : 'none'; + rowIpProxyLayout.style.display = showSettings ? '' : 'none'; } if (rowIpProxyApiUrl) { - rowIpProxyApiUrl.style.display = enabled && apiModeAvailable && isApiMode ? '' : 'none'; + rowIpProxyApiUrl.style.display = showSettings && apiModeAvailable && isApiMode ? '' : 'none'; } if (rowIpProxyAccountList) { - rowIpProxyAccountList.style.display = enabled && isAccountMode && accountListAvailable ? '' : 'none'; + rowIpProxyAccountList.style.display = showSettings && isAccountMode && accountListAvailable ? '' : 'none'; } if (rowIpProxyAccountSessionPrefix) { - rowIpProxyAccountSessionPrefix.style.display = enabled && showSessionOptions ? '' : 'none'; + rowIpProxyAccountSessionPrefix.style.display = showSettings && showSessionOptions ? '' : 'none'; } if (rowIpProxyAccountLifeMinutes) { - rowIpProxyAccountLifeMinutes.style.display = enabled && showSessionOptions ? '' : 'none'; + rowIpProxyAccountLifeMinutes.style.display = showSettings && showSessionOptions ? '' : 'none'; } if (rowIpProxyPoolTargetCount) { - rowIpProxyPoolTargetCount.style.display = enabled ? '' : 'none'; + rowIpProxyPoolTargetCount.style.display = showSettings ? '' : 'none'; } if (rowIpProxyHost) { - rowIpProxyHost.style.display = enabled && isAccountMode ? '' : 'none'; + rowIpProxyHost.style.display = showSettings && isAccountMode ? '' : 'none'; } if (rowIpProxyPort) { - rowIpProxyPort.style.display = enabled && isAccountMode ? '' : 'none'; + rowIpProxyPort.style.display = showSettings && isAccountMode ? '' : 'none'; } if (rowIpProxyProtocol) { - rowIpProxyProtocol.style.display = enabled ? '' : 'none'; + rowIpProxyProtocol.style.display = showSettings ? '' : 'none'; } if (rowIpProxyUsername) { - rowIpProxyUsername.style.display = enabled && isAccountMode ? '' : 'none'; + rowIpProxyUsername.style.display = showSettings && isAccountMode ? '' : 'none'; } if (rowIpProxyPassword) { - rowIpProxyPassword.style.display = enabled && isAccountMode ? '' : 'none'; + rowIpProxyPassword.style.display = showSettings && isAccountMode ? '' : 'none'; } if (rowIpProxyRegion) { - rowIpProxyRegion.style.display = enabled && isAccountMode ? '' : 'none'; + rowIpProxyRegion.style.display = showSettings && isAccountMode ? '' : 'none'; } if (rowIpProxyActions) { - rowIpProxyActions.style.display = enabled ? '' : 'none'; + rowIpProxyActions.style.display = showSettings ? '' : 'none'; } if (ipProxyActionButtons) { - ipProxyActionButtons.style.display = enabled ? '' : 'none'; + ipProxyActionButtons.style.display = showSettings ? '' : 'none'; } if (rowIpProxyRuntimeStatus) { - rowIpProxyRuntimeStatus.style.display = enabled ? '' : 'none'; + rowIpProxyRuntimeStatus.style.display = showSettings ? '' : 'none'; } if (ipProxyLayout) { ipProxyLayout.classList.toggle('is-account-only', !apiModeAvailable); @@ -1256,7 +1304,7 @@ function updateIpProxyUI(state = latestState) { ipProxyApiPanel.classList.toggle('is-disabled', !apiModeAvailable); ipProxyApiPanel.setAttribute('aria-disabled', String(!apiModeAvailable)); ipProxyApiPanel.hidden = !apiModeAvailable; - ipProxyApiPanel.style.display = enabled && apiModeAvailable ? '' : 'none'; + ipProxyApiPanel.style.display = showSettings && apiModeAvailable ? '' : 'none'; } if (inputIpProxyApiUrl) { inputIpProxyApiUrl.disabled = !enabled || !apiModeAvailable; diff --git a/sidepanel/paypal-manager.js b/sidepanel/paypal-manager.js new file mode 100644 index 0000000..97c1be0 --- /dev/null +++ b/sidepanel/paypal-manager.js @@ -0,0 +1,191 @@ +(function attachSidepanelPayPalManager(globalScope) { + function createPayPalManager(context = {}) { + const { + state, + dom, + helpers, + runtime, + paypalUtils = {}, + } = context; + + let actionInFlight = false; + + function getPayPalAccounts(currentState = state.getLatestState()) { + return helpers.getPayPalAccounts(currentState); + } + + function getCurrentPayPalAccountId(currentState = state.getLatestState()) { + return String(currentState?.currentPayPalAccountId || '').trim(); + } + + function buildSelectOptions(accounts = []) { + if (!accounts.length) { + return ''; + } + return accounts.map((account) => ( + `` + )).join(''); + } + + function applyPayPalAccountMutation(account) { + if (!account?.id) return; + const latestState = state.getLatestState(); + const nextAccounts = typeof paypalUtils.upsertPayPalAccountInList === 'function' + ? paypalUtils.upsertPayPalAccountInList(getPayPalAccounts(latestState), account) + : [...getPayPalAccounts(latestState), account]; + state.syncLatestState({ paypalAccounts: nextAccounts }); + renderPayPalAccounts(); + } + + function renderPayPalAccounts() { + if (!dom.selectPayPalAccount) return; + + const latestState = state.getLatestState(); + const accounts = getPayPalAccounts(latestState); + const currentId = getCurrentPayPalAccountId(latestState); + + dom.selectPayPalAccount.innerHTML = buildSelectOptions(accounts); + dom.selectPayPalAccount.disabled = accounts.length === 0; + dom.selectPayPalAccount.value = accounts.some((account) => account.id === currentId) ? currentId : ''; + } + + async function syncSelectedPayPalAccount(options = {}) { + const { silent = false } = options; + const accountId = String(dom.selectPayPalAccount?.value || '').trim(); + if (!accountId) { + state.syncLatestState({ + currentPayPalAccountId: null, + paypalEmail: '', + paypalPassword: '', + }); + renderPayPalAccounts(); + return null; + } + + const response = await runtime.sendMessage({ + type: 'SELECT_PAYPAL_ACCOUNT', + source: 'sidepanel', + payload: { accountId }, + }); + if (response?.error) { + throw new Error(response.error); + } + + state.syncLatestState({ + currentPayPalAccountId: response.account?.id || accountId, + paypalEmail: String(response.account?.email || '').trim(), + paypalPassword: String(response.account?.password || ''), + }); + renderPayPalAccounts(); + if (!silent) { + helpers.showToast(`已切换当前 PayPal 账号为 ${response.account?.email || accountId}`, 'success', 1800); + } + return response.account || null; + } + + async function openPayPalAccountDialog() { + if (typeof helpers.openFormDialog !== 'function') { + throw new Error('表单弹窗能力未加载,请刷新扩展后重试。'); + } + return helpers.openFormDialog({ + title: '添加 PayPal 账号', + confirmLabel: '保存账号', + confirmVariant: 'btn-primary', + fields: [ + { + key: 'email', + label: 'PayPal 账号', + type: 'text', + placeholder: '请输入 PayPal 登录邮箱', + autocomplete: 'username', + required: true, + requiredMessage: '请先填写 PayPal 账号。', + validate: (value) => { + const normalized = String(value || '').trim(); + if (!normalized.includes('@')) { + return 'PayPal 账号需填写邮箱格式。'; + } + return ''; + }, + }, + { + key: 'password', + label: 'PayPal 密码', + type: 'password', + placeholder: '请输入 PayPal 登录密码', + autocomplete: 'current-password', + required: true, + requiredMessage: '请先填写 PayPal 密码。', + }, + ], + }); + } + + async function handleAddPayPalAccount() { + if (actionInFlight) return; + + const formValues = await openPayPalAccountDialog(); + if (!formValues) { + return; + } + + actionInFlight = true; + if (dom.btnAddPayPalAccount) { + dom.btnAddPayPalAccount.disabled = true; + } + + try { + const response = await runtime.sendMessage({ + type: 'UPSERT_PAYPAL_ACCOUNT', + source: 'sidepanel', + payload: { + email: String(formValues.email || '').trim(), + password: String(formValues.password || ''), + }, + }); + if (response?.error) { + throw new Error(response.error); + } + + applyPayPalAccountMutation(response.account); + if (response.account?.id) { + state.syncLatestState({ currentPayPalAccountId: response.account.id }); + renderPayPalAccounts(); + dom.selectPayPalAccount.value = response.account.id; + await syncSelectedPayPalAccount({ silent: true }); + } + helpers.showToast(`已保存 PayPal 账号 ${response.account?.email || ''}`, 'success', 2200); + } catch (error) { + helpers.showToast(`保存 PayPal 账号失败:${error.message}`, 'error'); + throw error; + } finally { + actionInFlight = false; + if (dom.btnAddPayPalAccount) { + dom.btnAddPayPalAccount.disabled = false; + } + } + } + + function bindPayPalEvents() { + dom.btnAddPayPalAccount?.addEventListener('click', () => { + void handleAddPayPalAccount(); + }); + dom.selectPayPalAccount?.addEventListener('change', () => { + void syncSelectedPayPalAccount().catch((error) => { + helpers.showToast(error.message, 'error'); + renderPayPalAccounts(); + }); + }); + } + + return { + bindPayPalEvents, + renderPayPalAccounts, + syncSelectedPayPalAccount, + }; + } + + globalScope.SidepanelPayPalManager = { + createPayPalManager, + }; +})(window); diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index df0dce6..1c8bd16 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -785,6 +785,37 @@ header { display: block; } +.ip-proxy-card { + margin-top: 10px; +} + +.ip-proxy-header-actions { + flex: 0 0 auto; + align-items: center; +} + +#btn-toggle-ip-proxy-section { + white-space: nowrap; +} + +.section-collapse-body { + display: flex; + flex-direction: column; + gap: 9px; +} + +.section-collapse-body[hidden] { + display: none; +} + +#btn-toggle-hotmail-section { + white-space: nowrap; +} + +#btn-toggle-cloudflare-temp-email-section { + white-space: nowrap; +} + .ip-proxy-fold { width: 100%; border: none; @@ -858,11 +889,6 @@ header { } } -.ip-proxy-enabled-inline { - justify-content: flex-end; - gap: 10px; -} - .ip-proxy-actions-inline { flex-wrap: wrap; align-items: center; @@ -888,42 +914,6 @@ header { color: var(--text-secondary); } -.ip-proxy-enabled-status { - margin-left: auto; - display: inline-flex; - align-items: center; - justify-content: flex-end; - gap: 6px; - min-width: 92px; - font-size: 12px; - font-weight: 600; - color: var(--text-muted); - text-align: right; - white-space: nowrap; -} - -.ip-proxy-enabled-status-dot { - width: 7px; - height: 7px; - border-radius: 999px; - background: var(--text-muted); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--text-muted) 25%, transparent); - flex-shrink: 0; -} - -.ip-proxy-enabled-status.is-on { - color: var(--green); -} - -.ip-proxy-enabled-status.is-on .ip-proxy-enabled-status-dot { - background: var(--green); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--green) 30%, transparent); -} - -.ip-proxy-enabled-status.is-off { - color: var(--text-muted); -} - .ip-proxy-runtime-status { display: inline-flex; align-items: flex-start; @@ -2722,6 +2712,40 @@ header { cursor: pointer; } +.modal-form-fields { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 14px; +} + +.modal-form-row { + display: flex; + flex-direction: column; + gap: 6px; +} + +.modal-form-label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); +} + +.modal-form-row .data-input, +.modal-form-row .data-select, +.modal-form-row .data-textarea { + width: 100%; +} + +.modal-form-row .input-with-icon { + width: 100%; +} + +.modal-form-alert { + margin-top: -4px; + margin-bottom: 14px; +} + .modal-actions { display: flex; justify-content: flex-end; diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 7db0a30..6fa6a12 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -184,7 +184,13 @@ -
- IP代理 -
- - - - 未开启 - -
-
- - +
+
+
+ + 用于浏览器代理接管与出口切换 +
+
+ + +
+
+ +