From 14e3d6271d8179483d4c74f00b4c082538391f15 Mon Sep 17 00:00:00 2001 From: Q3CC Date: Tue, 14 Apr 2026 23:16:59 +0800 Subject: [PATCH 1/3] feat: integrate iCloud hide my email generation into OAuth flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 合并 PR #72 的核心改动:新增 iCloud Hide My Email 生成、alias 管理与成功后自动标记/删除流程 - 本地补充修复:解决与 Cloudflare Temp Email 分支差异的冲突,并补齐合并后的测试常量 - 影响范围:background 邮箱生成与 Step 9 收尾、sidepanel alias 管理 UI、manifest/rules 与新增 iCloud 测试 --- background.js | 576 ++++++++++++++++++- icloud-utils.js | 177 ++++++ manifest.json | 12 + rules.json | 50 ++ sidepanel/sidepanel.css | 157 ++++++ sidepanel/sidepanel.html | 61 ++ sidepanel/sidepanel.js | 580 +++++++++++++++++++- tests/auto-run-fresh-attempt-reset.test.js | 14 + tests/background-icloud.test.js | 265 +++++++++ tests/icloud-utils.test.js | 121 ++++ tests/step9-localhost-cleanup-scope.test.js | 5 + 11 files changed, 2007 insertions(+), 11 deletions(-) create mode 100644 icloud-utils.js create mode 100644 rules.json create mode 100644 tests/background-icloud.test.js create mode 100644 tests/icloud-utils.test.js diff --git a/background.js b/background.js index 289b2ff..1585fc1 100644 --- a/background.js +++ b/background.js @@ -1,6 +1,6 @@ // background.js — Service Worker: orchestration, state, tab management, message routing -importScripts('data/names.js', 'hotmail-utils.js', 'cloudflare-temp-email-utils.js', 'content/activation-utils.js'); +importScripts('data/names.js', 'hotmail-utils.js', 'cloudflare-temp-email-utils.js', 'icloud-utils.js', 'content/activation-utils.js'); const { buildHotmailMailApiLatestUrl, @@ -28,12 +28,32 @@ const { normalizeCloudflareTempEmailDomains, normalizeCloudflareTempEmailMailApiMessages, } = self.CloudflareTempEmailUtils; +const { + findIcloudAliasByEmail, + getConfiguredIcloudHostPreference, + getIcloudHostHintFromMessage, + getIcloudLoginUrlForHost, + getIcloudSetupUrlForHost, + normalizeBooleanMap, + normalizeIcloudAliasList, + normalizeIcloudHost, + pickReusableIcloudAlias, + toNormalizedEmailSet, +} = self.IcloudUtils; const { isRecoverableStep9AuthFailure, } = self.MultiPageActivationUtils; const LOG_PREFIX = '[MultiPage:bg]'; const DUCK_AUTOFILL_URL = 'https://duckduckgo.com/email/settings/autofill'; +const ICLOUD_SETUP_URLS = [ + 'https://setup.icloud.com.cn/setup/ws/1', + 'https://setup.icloud.com/setup/ws/1', +]; +const ICLOUD_LOGIN_URLS = [ + 'https://www.icloud.com.cn/', + 'https://www.icloud.com/', +]; const HOTMAIL_PROVIDER = 'hotmail-api'; const CLOUDFLARE_TEMP_EMAIL_PROVIDER = 'cloudflare-temp-email'; const CLOUDFLARE_TEMP_EMAIL_GENERATOR = 'cloudflare-temp-email'; @@ -62,6 +82,7 @@ const DEFAULT_HOTMAIL_REMOTE_BASE_URL = ''; const DEFAULT_HOTMAIL_LOCAL_BASE_URL = 'http://127.0.0.1:17373'; const HOTMAIL_LOCAL_HELPER_TIMEOUT_MS = 45000; const DISPLAY_TIMEZONE = 'Asia/Shanghai'; +const PERSISTENT_ALIAS_STATE_KEYS = ['manualAliasUsage', 'preservedAliases']; initializeSessionStorageAccess(); @@ -86,6 +107,8 @@ const PERSISTED_SETTING_DEFAULTS = { autoStepDelaySeconds: null, mailProvider: '163', emailGenerator: 'duck', + autoDeleteUsedIcloudAlias: false, + icloudHostPreference: 'auto', emailPrefix: '', inbucketHost: '', inbucketMailbox: '', @@ -116,6 +139,8 @@ const DEFAULT_STATE = { email: null, // 运行时邮箱,由程序自动获取并写入,不能手动预填。 password: null, // 运行时实际密码,由 customPassword 或程序自动生成后写入。 accounts: [], // 已生成账号记录:{ email, password, createdAt }。 + manualAliasUsage: {}, + preservedAliases: {}, lastEmailTimestamp: null, // 最近一次获取到邮箱数据的运行时时间戳。 lastSignupCode: null, // 注册验证码,运行时由程序自动读取并写入。 lastLoginCode: null, // 登录验证码,运行时由程序自动读取并写入。 @@ -143,6 +168,7 @@ const DEFAULT_STATE = { signupVerificationRequestedAt: null, loginVerificationRequestedAt: null, currentHotmailAccountId: null, + preferredIcloudHost: '', }; function normalizeAutoRunDelayMinutes(value) { @@ -236,6 +262,9 @@ function normalizeEmailGenerator(value = '') { if (normalized === 'custom' || normalized === 'manual') { return 'custom'; } + if (normalized === 'icloud') { + return 'icloud'; + } if (normalized === 'cloudflare') return 'cloudflare'; if (normalized === CLOUDFLARE_TEMP_EMAIL_GENERATOR) return CLOUDFLARE_TEMP_EMAIL_GENERATOR; return 'duck'; @@ -391,6 +420,10 @@ function normalizePersistentSettingValue(key, value) { return normalizeMailProvider(value); case 'emailGenerator': return normalizeEmailGenerator(value); + case 'autoDeleteUsedIcloudAlias': + return Boolean(value); + case 'icloudHostPreference': + return normalizeIcloudHost(value) || 'auto'; case 'emailPrefix': return String(value || '').trim(); case 'inbucketHost': @@ -475,12 +508,29 @@ async function getPersistedSettings() { return buildPersistentSettingsPayload(stored, { fillDefaults: true }); } +async function getPersistedAliasState() { + try { + const stored = await chrome.storage.local.get(PERSISTENT_ALIAS_STATE_KEYS); + return { + manualAliasUsage: normalizeBooleanMap(stored.manualAliasUsage), + preservedAliases: normalizeBooleanMap(stored.preservedAliases), + }; + } catch (err) { + console.warn(LOG_PREFIX, 'Failed to read persisted iCloud alias state:', err?.message || err); + return { + manualAliasUsage: {}, + preservedAliases: {}, + }; + } +} + async function getState() { - const [state, persistedSettings] = await Promise.all([ + const [state, persistedSettings, persistedAliasState] = await Promise.all([ chrome.storage.session.get(null), getPersistedSettings(), + getPersistedAliasState(), ]); - return { ...DEFAULT_STATE, ...persistedSettings, ...state }; + return { ...DEFAULT_STATE, ...persistedSettings, ...persistedAliasState, ...state }; } async function initializeSessionStorageAccess() { @@ -500,6 +550,16 @@ async function setState(updates) { console.log(LOG_PREFIX, 'storage.set:', JSON.stringify(updates).slice(0, 200)); if (Object.keys(updates || {}).length > 0) { await chrome.storage.session.set(updates); + const persistentAliasUpdates = {}; + if (Object.prototype.hasOwnProperty.call(updates, 'manualAliasUsage')) { + persistentAliasUpdates.manualAliasUsage = normalizeBooleanMap(updates.manualAliasUsage); + } + if (Object.prototype.hasOwnProperty.call(updates, 'preservedAliases')) { + persistentAliasUpdates.preservedAliases = normalizeBooleanMap(updates.preservedAliases); + } + if (Object.keys(persistentAliasUpdates).length > 0) { + await chrome.storage.local.set(persistentAliasUpdates); + } } } @@ -578,6 +638,13 @@ function broadcastDataUpdate(payload) { }).catch(() => { }); } +function broadcastIcloudAliasesChanged(payload = {}) { + chrome.runtime.sendMessage({ + type: 'ICLOUD_ALIASES_CHANGED', + payload, + }).catch(() => { }); +} + async function setEmailStateSilently(email) { await setState({ email }); broadcastDataUpdate({ email }); @@ -595,28 +662,84 @@ async function setPasswordState(password) { broadcastDataUpdate({ password }); } +function getManualAliasUsageMap(state) { + return normalizeBooleanMap(state?.manualAliasUsage); +} + +function getPreservedAliasMap(state) { + return normalizeBooleanMap(state?.preservedAliases); +} + +function isAliasPreserved(state, email) { + const normalizedEmail = String(email || '').trim().toLowerCase(); + if (!normalizedEmail) return false; + return Boolean(getPreservedAliasMap(state)[normalizedEmail]); +} + +function getEffectiveUsedEmails(state) { + return toNormalizedEmailSet(getManualAliasUsageMap(state)); +} + +async function setIcloudAliasUsedState(payload = {}, options = {}) { + const email = String(payload.email || '').trim().toLowerCase(); + if (!email) { + throw new Error('未提供 iCloud 隐私邮箱地址。'); + } + + const used = Boolean(payload.used); + const state = await getState(); + const manualAliasUsage = getManualAliasUsageMap(state); + manualAliasUsage[email] = used; + await setState({ manualAliasUsage }); + if (!options.silentLog) { + await addLog(`iCloud:已将 ${email} 标记为${used ? '已用' : '未用'}`, 'ok'); + } + broadcastIcloudAliasesChanged({ reason: 'used-updated', email, used }); + return { email, used }; +} + +async function setIcloudAliasPreservedState(payload = {}) { + const email = String(payload.email || '').trim().toLowerCase(); + if (!email) { + throw new Error('未提供 iCloud 隐私邮箱地址。'); + } + + const preserved = Boolean(payload.preserved); + const state = await getState(); + const preservedAliases = getPreservedAliasMap(state); + preservedAliases[email] = preserved; + await setState({ preservedAliases }); + await addLog(`iCloud:已将 ${email} ${preserved ? '设为保留' : '取消保留'}`, 'ok'); + broadcastIcloudAliasesChanged({ reason: 'preserved-updated', email, preserved }); + return { email, preserved }; +} + async function resetState() { console.log(LOG_PREFIX, 'Resetting all state'); // Preserve settings and persistent data across resets - const [prev, persistedSettings] = await Promise.all([ + const [prev, persistedSettings, persistedAliasState] = await Promise.all([ chrome.storage.session.get([ 'seenCodes', 'seenInbucketMailIds', 'accounts', 'tabRegistry', 'sourceLastUrls', + 'preferredIcloudHost', ]), getPersistedSettings(), + getPersistedAliasState(), ]); await chrome.storage.session.clear(); await chrome.storage.session.set({ ...DEFAULT_STATE, ...persistedSettings, + ...persistedAliasState, seenCodes: prev.seenCodes || [], seenInbucketMailIds: prev.seenInbucketMailIds || [], accounts: prev.accounts || [], tabRegistry: prev.tabRegistry || {}, sourceLastUrls: prev.sourceLastUrls || {}, + preferredIcloudHost: prev.preferredIcloudHost || '', }); } @@ -1460,6 +1583,408 @@ async function pollCloudflareTempEmailVerificationCode(step, state, pollPayload throw lastError || new Error(`步骤 ${step}:未在 Cloudflare Temp Email 中找到新的匹配验证码。`); } +async function getOpenIcloudHostPreference() { + try { + const tabs = await chrome.tabs.query({ + url: [ + 'https://www.icloud.com/*', + 'https://www.icloud.com.cn/*', + ], + }); + + const activeTab = tabs.find((tab) => tab.active); + const candidates = activeTab ? [activeTab, ...tabs.filter((tab) => tab.id !== activeTab.id)] : tabs; + for (const tab of candidates) { + try { + const host = normalizeIcloudHost(new URL(tab.url).host); + if (host) return host; + } catch {} + } + } catch {} + + return ''; +} + +async function getPreferredIcloudLoginUrl(error = null, state = null) { + const currentState = state || await getState(); + const configuredHost = getConfiguredIcloudHostPreference(currentState); + if (configuredHost) { + return getIcloudLoginUrlForHost(configuredHost); + } + + const messageHint = getIcloudHostHintFromMessage(getErrorMessage(error)); + if (messageHint) { + return getIcloudLoginUrlForHost(messageHint); + } + + const savedHost = normalizeIcloudHost(currentState?.preferredIcloudHost); + if (savedHost) { + return getIcloudLoginUrlForHost(savedHost); + } + + const openHost = await getOpenIcloudHostPreference(); + if (openHost) { + return getIcloudLoginUrlForHost(openHost); + } + + return ICLOUD_LOGIN_URLS[0]; +} + +async function getPreferredIcloudSetupUrls(state = null, error = null) { + const preferredLoginUrl = await getPreferredIcloudLoginUrl(error, state); + const preferredHost = normalizeIcloudHost(new URL(preferredLoginUrl).host); + const preferredSetupUrl = getIcloudSetupUrlForHost(preferredHost); + if (!preferredSetupUrl) { + return [...ICLOUD_SETUP_URLS]; + } + return [ + preferredSetupUrl, + ...ICLOUD_SETUP_URLS.filter((url) => url !== preferredSetupUrl), + ]; +} + +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); +} + +let lastIcloudLoginPromptAt = 0; + +async function openIcloudLoginPage(preferredUrl) { + const tabs = await chrome.tabs.query({ + url: [ + 'https://www.icloud.com/*', + 'https://www.icloud.com.cn/*', + ], + }); + const preferredHost = new URL(preferredUrl).host; + const existing = tabs.find((tab) => { + try { + return new URL(tab.url).host === preferredHost; + } catch { + return false; + } + }); + + 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; + } + + const created = await chrome.tabs.create({ url: preferredUrl, active: true }); + return created.id; +} + +async function promptIcloudLogin(error, actionLabel = 'iCloud 操作') { + const now = Date.now(); + const preferredUrl = await getPreferredIcloudLoginUrl(error); + const originalError = getErrorMessage(error); + + chrome.runtime.sendMessage({ + type: 'ICLOUD_LOGIN_REQUIRED', + payload: { + actionLabel, + loginUrl: preferredUrl, + message: '需要先登录 iCloud,我已经为你打开登录页。', + detail: originalError, + }, + }).catch(() => { }); + + if (now - lastIcloudLoginPromptAt < 15000) { + return; + } + lastIcloudLoginPromptAt = now; + + await addLog(`iCloud:${actionLabel}时需要登录,正在打开 ${new URL(preferredUrl).host} ...`, 'warn'); + + try { + await openIcloudLoginPage(preferredUrl); + } catch (tabErr) { + await addLog(`iCloud:自动打开登录页失败:${getErrorMessage(tabErr)}`, 'warn'); + } +} + +async function withIcloudLoginHelp(actionLabel, action) { + try { + return await action(); + } catch (err) { + if (isIcloudLoginRequiredError(err)) { + await promptIcloudLogin(err, actionLabel); + throw new Error('请先在新打开的 iCloud 页面中完成登录,再回来点击“我已登录”。'); + } + throw err; + } +} + +async function icloudRequest(method, url, options = {}) { + const { data } = options; + let response; + try { + response = await fetch(url, { + method, + credentials: 'include', + headers: data !== undefined ? { 'Content-Type': 'application/json' } : undefined, + body: data !== undefined ? JSON.stringify(data) : undefined, + }); + } catch (err) { + throw new Error(`iCloud 请求失败:${method} ${url},${err.message}`); + } + + if (!response.ok) { + throw new Error(`iCloud 请求失败:${method} ${url},status ${response.status}`); + } + + try { + return await response.json(); + } catch (err) { + throw new Error(`iCloud 返回的 JSON 无法解析:${method} ${url},${err.message}`); + } +} + +async function validateIcloudSession(setupUrl) { + const data = await icloudRequest('POST', `${setupUrl}/validate`); + if (!data?.webservices?.premiummailsettings?.url) { + throw new Error('Could not validate iCloud session. Hide My Email service was unavailable.'); + } + return data; +} + +async function resolveIcloudPremiumMailService() { + const errors = []; + const state = await getState(); + const setupUrls = await getPreferredIcloudSetupUrls(state); + + for (const setupUrl of setupUrls) { + try { + const data = await validateIcloudSession(setupUrl); + const preferredIcloudHost = normalizeIcloudHost(new URL(setupUrl).host); + if (preferredIcloudHost && preferredIcloudHost !== normalizeIcloudHost(state.preferredIcloudHost)) { + await setState({ preferredIcloudHost }); + } + return { + setupUrl, + serviceUrl: String(data.webservices.premiummailsettings.url || '').replace(/\/$/, ''), + }; + } catch (err) { + errors.push(`${new URL(setupUrl).host}: ${getErrorMessage(err)}`); + } + } + + throw new Error(errors.length + ? `Could not validate iCloud session. ${errors.join(' | ')}` + : 'Could not validate iCloud session. 请先在当前浏览器登录 icloud.com.cn 或 icloud.com。'); +} + +function getIcloudAliasLabel() { + const now = new Date(); + const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + return `MultiPage ${dateStr}`; +} + +async function checkIcloudSession() { + return withIcloudLoginHelp('检查 iCloud 会话', async () => { + const { setupUrl } = await resolveIcloudPremiumMailService(); + await addLog(`iCloud:会话校验通过(${new URL(setupUrl).host})`, 'ok'); + return { ok: true, setupUrl }; + }); +} + +async function listIcloudAliases() { + return withIcloudLoginHelp('加载 iCloud 隐私邮箱列表', async () => { + const { serviceUrl } = await resolveIcloudPremiumMailService(); + const response = await icloudRequest('GET', `${serviceUrl}/v2/hme/list`); + const state = await getState(); + return normalizeIcloudAliasList(response, { + usedEmails: getEffectiveUsedEmails(state), + preservedEmails: getPreservedAliasMap(state), + }); + }); +} + +async function deleteIcloudAlias(payload) { + return withIcloudLoginHelp('删除 iCloud 隐私邮箱', async () => { + const alias = typeof payload === 'string' + ? { email: String(payload).trim().toLowerCase(), anonymousId: '' } + : { + email: String(payload?.email || '').trim().toLowerCase(), + anonymousId: String(payload?.anonymousId || '').trim(), + }; + + if (!alias.email) { + throw new Error('未提供需要删除的 iCloud 隐私邮箱。'); + } + if (!alias.anonymousId) { + throw new Error(`缺少 ${alias.email} 的 anonymousId,请先刷新 iCloud 别名列表。`); + } + + const { serviceUrl } = await resolveIcloudPremiumMailService(); + + try { + const directDelete = await icloudRequest('POST', `${serviceUrl}/v1/hme/delete`, { + data: { anonymousId: alias.anonymousId }, + }); + if (directDelete?.success === false) { + throw new Error(directDelete?.error?.errorMessage || 'delete failed'); + } + } catch (err) { + await addLog(`iCloud:直接删除 ${alias.email} 失败,尝试先停用再删除...`, 'warn'); + + const deactivated = await icloudRequest('POST', `${serviceUrl}/v1/hme/deactivate`, { + data: { anonymousId: alias.anonymousId }, + }); + if (deactivated?.success === false) { + throw new Error(deactivated?.error?.errorMessage || `停用 ${alias.email} 失败`); + } + + const deleted = await icloudRequest('POST', `${serviceUrl}/v1/hme/delete`, { + data: { anonymousId: alias.anonymousId }, + }); + if (deleted?.success === false) { + throw new Error(deleted?.error?.errorMessage || `删除 ${alias.email} 失败`); + } + } + + const state = await getState(); + const manualAliasUsage = getManualAliasUsageMap(state); + const preservedAliases = getPreservedAliasMap(state); + delete manualAliasUsage[alias.email]; + delete preservedAliases[alias.email]; + await setState({ manualAliasUsage, preservedAliases }); + + await addLog(`iCloud:已删除 ${alias.email}`, 'ok'); + broadcastIcloudAliasesChanged({ reason: 'deleted', email: alias.email }); + return { email: alias.email }; + }); +} + +async function deleteUsedIcloudAliases() { + const aliases = await listIcloudAliases(); + const usedAliases = aliases.filter((alias) => alias.used); + if (!usedAliases.length) { + return { deleted: [], skipped: [] }; + } + + const deleted = []; + const skipped = []; + for (const alias of usedAliases) { + if (alias.preserved) { + skipped.push({ email: alias.email, error: 'preserved' }); + continue; + } + try { + await deleteIcloudAlias(alias); + deleted.push(alias.email); + } catch (err) { + skipped.push({ email: alias.email, error: getErrorMessage(err) }); + } + } + return { deleted, skipped }; +} + +async function fetchIcloudHideMyEmail() { + return withIcloudLoginHelp('获取 iCloud 隐私邮箱', async () => { + throwIfStopped(); + await addLog('iCloud:正在校验当前浏览器登录状态...', 'info'); + + const { serviceUrl, setupUrl } = await resolveIcloudPremiumMailService(); + await addLog(`iCloud:已通过 ${new URL(setupUrl).host} 验证会话`, 'ok'); + + const existingAliasesResponse = await icloudRequest('GET', `${serviceUrl}/v2/hme/list`); + const state = await getState(); + const existingAliases = normalizeIcloudAliasList(existingAliasesResponse, { + usedEmails: getEffectiveUsedEmails(state), + preservedEmails: getPreservedAliasMap(state), + }); + + const reusableAlias = pickReusableIcloudAlias(existingAliases); + if (reusableAlias) { + await setEmailState(reusableAlias.email); + await addLog(`iCloud:复用未使用别名 ${reusableAlias.email}`, 'ok'); + broadcastIcloudAliasesChanged({ reason: 'selected', email: reusableAlias.email }); + return reusableAlias.email; + } + + await addLog('iCloud:没有可复用别名,开始生成新的 Hide My Email 地址...', 'warn'); + + const generated = await icloudRequest('POST', `${serviceUrl}/v1/hme/generate`); + if (!generated?.success || !generated?.result?.hme) { + throw new Error(generated?.error?.errorMessage || 'iCloud 隐私邮箱生成失败。'); + } + + 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 隐私邮箱保留失败。'); + } + + const alias = String(reserved.result.hme.hme || '').trim().toLowerCase(); + await setEmailState(alias); + await addLog(`iCloud:已创建并保留新别名 ${alias}`, 'ok'); + broadcastIcloudAliasesChanged({ reason: 'created', email: alias }); + return alias; + }); +} + +async function finalizeIcloudAliasAfterSuccessfulFlow(state) { + const email = String(state?.email || '').trim().toLowerCase(); + if (!email) { + return { handled: false, deleted: false }; + } + + const knownIcloudAlias = normalizeEmailGenerator(state?.emailGenerator) === 'icloud' + || Object.prototype.hasOwnProperty.call(getManualAliasUsageMap(state), email) + || Object.prototype.hasOwnProperty.call(getPreservedAliasMap(state), email); + if (!knownIcloudAlias) { + return { handled: false, deleted: false }; + } + + await setIcloudAliasUsedState({ email, used: true }, { silentLog: true }); + await addLog(`iCloud:流程成功后已标记 ${email} 为已用。`, 'ok'); + + if (!state.autoDeleteUsedIcloudAlias) { + return { handled: true, deleted: false }; + } + + if (isAliasPreserved(state, email)) { + await addLog(`iCloud:${email} 已被标记为保留,跳过自动删除。`, 'info'); + return { handled: true, deleted: false }; + } + + try { + const aliases = await listIcloudAliases(); + const alias = findIcloudAliasByEmail(aliases, email); + if (!alias) { + await addLog(`iCloud:自动删除跳过,列表中未找到 ${email}。`, 'warn'); + return { handled: true, deleted: false }; + } + if (alias.preserved) { + await addLog(`iCloud:${email} 在最新别名列表中已是保留状态,跳过自动删除。`, 'info'); + return { handled: true, deleted: false }; + } + if (!alias.anonymousId) { + await addLog(`iCloud:自动删除跳过,${email} 缺少 anonymousId,请先刷新列表后重试。`, 'warn'); + return { handled: true, deleted: false }; + } + await deleteIcloudAlias(alias); + await addLog(`iCloud:流程成功后已自动删除 ${email}。`, 'ok'); + return { handled: true, deleted: true }; + } catch (err) { + await addLog(`iCloud:自动删除 ${email} 失败:${getErrorMessage(err)}`, 'warn'); + return { handled: true, deleted: false }; + } +} + // ============================================================ // Tab Registry // ============================================================ @@ -1699,6 +2224,7 @@ async function closeTabsByUrlPrefix(prefix, options = {}) { .filter((tab) => typeof tab.url === 'string' && !excludedUrls.has(tab.url)) .filter((tab) => !(excludeLocalhostCallbacks && isLocalhostOAuthCallbackUrl(tab.url))) .filter((tab) => typeof tab.url === 'string' && tab.url.startsWith(prefix)) + .filter((tab) => !isLocalhostOAuthCallbackUrl(tab.url)) .map((tab) => tab.id); if (!matchedIds.length) return 0; @@ -3222,6 +3748,41 @@ async function handleMessage(message, sender) { return { ok: true, email }; } + case 'CHECK_ICLOUD_SESSION': { + clearStopRequest(); + return await checkIcloudSession(); + } + + case 'LIST_ICLOUD_ALIASES': { + clearStopRequest(); + const aliases = await listIcloudAliases(); + return { ok: true, aliases }; + } + + case 'SET_ICLOUD_ALIAS_USED_STATE': { + clearStopRequest(); + const result = await setIcloudAliasUsedState(message.payload || {}); + return { ok: true, ...result }; + } + + case 'SET_ICLOUD_ALIAS_PRESERVED_STATE': { + clearStopRequest(); + const result = await setIcloudAliasPreservedState(message.payload || {}); + return { ok: true, ...result }; + } + + case 'DELETE_ICLOUD_ALIAS': { + clearStopRequest(); + const result = await deleteIcloudAlias(message.payload || {}); + return { ok: true, ...result }; + } + + case 'DELETE_USED_ICLOUD_ALIASES': { + clearStopRequest(); + const result = await deleteUsedIcloudAliases(); + return { ok: true, ...result }; + } + case 'STOP_FLOW': { await requestStop(); return { ok: true }; @@ -3308,6 +3869,7 @@ async function handleStepData(step, payload) { excludeLocalhostCallbacks: true, }); } + await finalizeIcloudAliasAfterSuccessfulFlow(latestState); if (shouldUseCustomRegistrationEmail(latestState) && latestState.email) { await setEmailStateSilently(null); } @@ -3599,6 +4161,9 @@ function getEmailGeneratorLabel(generator) { if (generator === 'custom') { return '自定义邮箱'; } + if (generator === 'icloud') { + return 'iCloud 隐私邮箱'; + } if (generator === 'cloudflare') return 'Cloudflare 邮箱'; if (generator === CLOUDFLARE_TEMP_EMAIL_GENERATOR) return 'Cloudflare Temp Email'; return 'Duck 邮箱'; @@ -3773,6 +4338,9 @@ async function fetchGeneratedEmail(state, options = {}) { if (generator === 'custom') { throw new Error('当前邮箱生成方式为自定义邮箱,请直接填写注册邮箱。'); } + if (generator === 'icloud') { + return fetchIcloudHideMyEmail(); + } if (generator === 'cloudflare') { return fetchCloudflareEmail(currentState, options); } diff --git a/icloud-utils.js b/icloud-utils.js new file mode 100644 index 0000000..82c7cc1 --- /dev/null +++ b/icloud-utils.js @@ -0,0 +1,177 @@ +(function icloudUtilsModule(root, factory) { + if (typeof module !== 'undefined' && module.exports) { + module.exports = factory(); + return; + } + + root.IcloudUtils = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createIcloudUtils() { + function normalizeIcloudHost(rawHost) { + const host = String(rawHost || '').trim().toLowerCase(); + if (!host) return ''; + if (host === 'icloud.com' || host === 'www.icloud.com' || host === 'setup.icloud.com') return 'icloud.com'; + if (host === 'icloud.com.cn' || host === 'www.icloud.com.cn' || host === 'setup.icloud.com.cn') return 'icloud.com.cn'; + return ''; + } + + function getConfiguredIcloudHostPreference(stateOrValue = '') { + const preference = typeof stateOrValue === 'object' + ? String(stateOrValue?.icloudHostPreference || '').trim().toLowerCase() + : String(stateOrValue || '').trim().toLowerCase(); + if (!preference || preference === 'auto') return ''; + return normalizeIcloudHost(preference); + } + + function getIcloudLoginUrlForHost(host) { + const normalizedHost = normalizeIcloudHost(host); + if (normalizedHost === 'icloud.com') return 'https://www.icloud.com/'; + if (normalizedHost === 'icloud.com.cn') return 'https://www.icloud.com.cn/'; + return ''; + } + + function getIcloudSetupUrlForHost(host) { + const normalizedHost = normalizeIcloudHost(host); + if (normalizedHost === 'icloud.com') return 'https://setup.icloud.com/setup/ws/1'; + if (normalizedHost === 'icloud.com.cn') return 'https://setup.icloud.com.cn/setup/ws/1'; + return ''; + } + + function getIcloudHostHintFromMessage(message) { + const lower = String(message || '').toLowerCase(); + if (lower.includes('setup.icloud.com.cn') || lower.includes('www.icloud.com.cn') || lower.includes('icloud.com.cn')) { + return 'icloud.com.cn'; + } + if (lower.includes('setup.icloud.com') || lower.includes('www.icloud.com') || lower.includes('icloud.com')) { + return 'icloud.com'; + } + return ''; + } + + function normalizeBooleanMap(rawValue = {}) { + if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) { + return {}; + } + + return Object.entries(rawValue).reduce((result, [key, value]) => { + const normalizedKey = String(key || '').trim().toLowerCase(); + if (!normalizedKey) { + return result; + } + result[normalizedKey] = Boolean(value); + return result; + }, {}); + } + + function toNormalizedEmailSet(values = []) { + if (values instanceof Set) { + return new Set(Array.from(values, (item) => String(item || '').trim().toLowerCase()).filter(Boolean)); + } + if (Array.isArray(values)) { + return new Set(values.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)); + } + if (values && typeof values === 'object') { + const normalizedMap = normalizeBooleanMap(values); + return new Set(Object.entries(normalizedMap) + .filter(([, value]) => value) + .map(([email]) => email)); + } + return new Set(); + } + + function findIcloudAliasArray(node, depth = 0) { + if (!node || depth > 4) return null; + if (Array.isArray(node)) { + return node.some((item) => item && typeof item === 'object') ? node : null; + } + if (typeof node !== 'object') return null; + + const priorityKeys = ['hmeEmails', 'hmeEmailList', 'hmeList', 'hmes', 'aliases', 'items']; + for (const key of priorityKeys) { + if (Array.isArray(node[key])) return node[key]; + } + + for (const value of Object.values(node)) { + const nested = findIcloudAliasArray(value, depth + 1); + if (nested) return nested; + } + + return null; + } + + function normalizeIcloudAliasRecord(raw, options = {}) { + const usedEmails = toNormalizedEmailSet(options.usedEmails); + const preservedEmails = toNormalizedEmailSet(options.preservedEmails); + const anonymousId = String(raw?.anonymousId || raw?.id || '').trim(); + const email = String( + raw?.hme + || raw?.email + || raw?.alias + || raw?.address + || raw?.metaData?.hme + || '' + ).trim().toLowerCase(); + + if (!email || !email.includes('@')) return null; + + const label = String(raw?.label || raw?.metaData?.label || '').trim(); + const note = String(raw?.note || raw?.metaData?.note || '').trim(); + const state = String(raw?.state || raw?.status || '').trim().toLowerCase(); + const createdAt = raw?.createTimestamp + || raw?.createTime + || raw?.createdAt + || raw?.createdDate + || null; + + return { + anonymousId, + email, + label, + note, + state, + active: raw?.active !== false && raw?.isActive !== false && state !== 'inactive' && state !== 'deleted', + used: usedEmails.has(email), + preserved: preservedEmails.has(email), + createdAt, + }; + } + + function normalizeIcloudAliasList(response, options = {}) { + const aliases = findIcloudAliasArray(response); + if (!aliases) return []; + + return aliases + .map((alias) => normalizeIcloudAliasRecord(alias, options)) + .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 pickReusableIcloudAlias(aliases = []) { + return (Array.isArray(aliases) ? aliases : []).find((alias) => alias?.active && !alias?.used) || null; + } + + function findIcloudAliasByEmail(aliases = [], email = '') { + const normalizedEmail = String(email || '').trim().toLowerCase(); + if (!normalizedEmail) return null; + return (Array.isArray(aliases) ? aliases : []) + .find((alias) => String(alias?.email || '').trim().toLowerCase() === normalizedEmail) || null; + } + + return { + findIcloudAliasArray, + findIcloudAliasByEmail, + getConfiguredIcloudHostPreference, + getIcloudHostHintFromMessage, + getIcloudLoginUrlForHost, + getIcloudSetupUrlForHost, + normalizeBooleanMap, + normalizeIcloudAliasList, + normalizeIcloudAliasRecord, + normalizeIcloudHost, + pickReusableIcloudAlias, + toNormalizedEmailSet, + }; +}); diff --git a/manifest.json b/manifest.json index 079d4ad..53a06b4 100644 --- a/manifest.json +++ b/manifest.json @@ -8,17 +8,29 @@ "alarms", "tabs", "webNavigation", + "declarativeNetRequest", "debugger", "storage", "scripting", "activeTab" ], "host_permissions": [ + "https://*.icloud.com/*", + "https://*.icloud.com.cn/*", "" ], "background": { "service_worker": "background.js" }, + "declarative_net_request": { + "rule_resources": [ + { + "id": "icloud_headers", + "enabled": true, + "path": "rules.json" + } + ] + }, "side_panel": { "default_path": "sidepanel/sidepanel.html" }, diff --git a/rules.json b/rules.json new file mode 100644 index 0000000..3bb7919 --- /dev/null +++ b/rules.json @@ -0,0 +1,50 @@ +[ + { + "id": 1, + "priority": 1, + "action": { + "type": "modifyHeaders", + "requestHeaders": [ + { + "header": "Origin", + "operation": "set", + "value": "https://www.icloud.com" + }, + { + "header": "Referer", + "operation": "set", + "value": "https://www.icloud.com/" + } + ] + }, + "condition": { + "urlFilter": "|https://*.icloud.com/*", + "resourceTypes": ["xmlhttprequest"], + "excludedInitiatorDomains": ["apple.com", "icloud.com"] + } + }, + { + "id": 2, + "priority": 1, + "action": { + "type": "modifyHeaders", + "requestHeaders": [ + { + "header": "Origin", + "operation": "set", + "value": "https://www.icloud.com.cn" + }, + { + "header": "Referer", + "operation": "set", + "value": "https://www.icloud.com.cn/" + } + ] + }, + "condition": { + "urlFilter": "|https://*.icloud.com.cn/*", + "resourceTypes": ["xmlhttprequest"], + "excludedInitiatorDomains": ["apple.com.cn", "icloud.com.cn"] + } + } +] diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index e6bbe94..184e028 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -877,6 +877,163 @@ header { text-align: center; } +.icloud-card { + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-base) 84%, transparent); + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.icloud-summary { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.6; +} + +.icloud-login-help { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + border: 1px solid color-mix(in srgb, var(--orange) 26%, var(--border)); + background: color-mix(in srgb, var(--orange-soft) 62%, var(--bg-base)); + border-radius: var(--radius-sm); + padding: 10px; +} + +.icloud-login-help-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.icloud-login-help-title { + color: var(--text-primary); + font-weight: 700; + font-size: 13px; +} + +.icloud-login-help-text { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; +} + +.icloud-toolbar { + display: flex; + gap: 8px; + align-items: center; +} + +.icloud-search { + flex: 1; +} + +.icloud-filter { + flex: 0 0 130px; +} + +.icloud-bulkbar { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; +} + +.icloud-select-all { + flex: 0 0 auto; +} + +.icloud-bulk-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.icloud-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 360px; + overflow: auto; + padding-right: 4px; +} + +.icloud-item { + border: 1px solid var(--border); + background: var(--bg-base); + border-radius: var(--radius-sm); + padding: 10px; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 10px; + align-items: flex-start; +} + +.icloud-item-check { + margin-top: 4px; +} + +.icloud-item-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.icloud-item-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.icloud-item-email { + font-weight: 600; + color: var(--text-primary); + word-break: break-all; +} + +.icloud-item-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.icloud-tag { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + background: var(--bg-surface); + color: var(--text-secondary); +} + +.icloud-tag.used { + background: var(--orange-soft); + color: var(--orange); +} + +.icloud-tag.active { + background: var(--green-soft); + color: var(--green); +} + +.icloud-empty { + color: var(--text-muted); + font-size: 12px; + border: 1px dashed var(--border); + border-radius: var(--radius-sm); + padding: 12px; + text-align: center; +} + .data-select { flex: 1; padding: 7px 10px; diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 45ba375..f132b06 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -169,6 +169,7 @@ @@ -339,6 +340,66 @@
+
就绪 diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index e9d0405..2b6773f 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -79,6 +79,26 @@ const selectTempEmailDomain = document.getElementById('select-temp-email-domain' const inputTempEmailDomain = document.getElementById('input-temp-email-domain'); const btnTempEmailDomainMode = document.getElementById('btn-temp-email-domain-mode'); const hotmailSection = document.getElementById('hotmail-section'); +const icloudSection = document.getElementById('icloud-section'); +const icloudSummary = document.getElementById('icloud-summary'); +const icloudList = document.getElementById('icloud-list'); +const icloudLoginHelp = document.getElementById('icloud-login-help'); +const icloudLoginHelpTitle = document.getElementById('icloud-login-help-title'); +const icloudLoginHelpText = document.getElementById('icloud-login-help-text'); +const btnIcloudLoginDone = document.getElementById('btn-icloud-login-done'); +const btnIcloudRefresh = document.getElementById('btn-icloud-refresh'); +const btnIcloudDeleteUsed = document.getElementById('btn-icloud-delete-used'); +const selectIcloudHostPreference = document.getElementById('select-icloud-host-preference'); +const checkboxAutoDeleteIcloud = document.getElementById('checkbox-auto-delete-icloud'); +const inputIcloudSearch = document.getElementById('input-icloud-search'); +const selectIcloudFilter = document.getElementById('select-icloud-filter'); +const checkboxIcloudSelectAll = document.getElementById('checkbox-icloud-select-all'); +const icloudSelectionSummary = document.getElementById('icloud-selection-summary'); +const btnIcloudBulkUsed = document.getElementById('btn-icloud-bulk-used'); +const btnIcloudBulkUnused = document.getElementById('btn-icloud-bulk-unused'); +const btnIcloudBulkPreserve = document.getElementById('btn-icloud-bulk-preserve'); +const btnIcloudBulkUnpreserve = document.getElementById('btn-icloud-bulk-unpreserve'); +const btnIcloudBulkDelete = document.getElementById('btn-icloud-bulk-delete'); const rowHotmailServiceMode = document.getElementById('row-hotmail-service-mode'); const hotmailServiceModeButtons = Array.from(document.querySelectorAll('[data-hotmail-service-mode]')); const rowHotmailRemoteBaseUrl = document.getElementById('row-hotmail-remote-base-url'); @@ -174,6 +194,11 @@ let settingsSaveInFlight = false; let settingsAutoSaveTimer = null; let cloudflareDomainEditMode = false; let cloudflareTempEmailDomainEditMode = false; +let icloudRefreshQueued = false; +let lastRenderedIcloudAliases = []; +let icloudSelectedEmails = new Set(); +let icloudSearchTerm = ''; +let icloudFilterMode = 'all'; let modalChoiceResolver = null; let currentModalActions = []; let modalResultBuilder = null; @@ -1032,6 +1057,8 @@ function collectSettingsPayload() { customPassword: inputPassword.value, mailProvider: selectMailProvider.value, emailGenerator: selectEmailGenerator.value, + autoDeleteUsedIcloudAlias: checkboxAutoDeleteIcloud?.checked, + icloudHostPreference: selectIcloudHostPreference?.value || 'auto', emailPrefix: inputEmailPrefix.value.trim(), inbucketHost: inputInbucketHost.value.trim(), inbucketMailbox: inputInbucketMailbox.value.trim(), @@ -1323,10 +1350,26 @@ function applySettingsState(state) { ? 'custom' : '163'); selectMailProvider.value = restoredMailProvider; - const restoredEmailGenerator = String(state?.emailGenerator || '').trim().toLowerCase(); - selectEmailGenerator.value = ['cloudflare', 'cloudflare-temp-email'].includes(restoredEmailGenerator) - ? restoredEmailGenerator - : 'duck'; + { + const restoredGenerator = String(state?.emailGenerator || '').trim().toLowerCase(); + if (restoredGenerator === 'icloud') { + selectEmailGenerator.value = 'icloud'; + } else if (restoredGenerator === 'cloudflare') { + selectEmailGenerator.value = 'cloudflare'; + } else if (restoredGenerator === 'cloudflare-temp-email') { + selectEmailGenerator.value = 'cloudflare-temp-email'; + } else { + selectEmailGenerator.value = 'duck'; + } + } + if (selectIcloudHostPreference) { + selectIcloudHostPreference.value = String(state?.icloudHostPreference || '').trim().toLowerCase() === 'icloud.com' + ? 'icloud.com' + : (String(state?.icloudHostPreference || '').trim().toLowerCase() === 'icloud.com.cn' ? 'icloud.com.cn' : 'auto'); + } + if (checkboxAutoDeleteIcloud) { + checkboxAutoDeleteIcloud.checked = Boolean(state?.autoDeleteUsedIcloudAlias); + } inputEmailPrefix.value = state?.emailPrefix || ''; inputInbucketHost.value = state?.inbucketHost || ''; inputInbucketMailbox.value = state?.inbucketMailbox || ''; @@ -1362,6 +1405,9 @@ async function restoreState() { try { const state = await chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' }); applySettingsState(state); + if (getSelectedEmailGenerator() === 'icloud' && icloudSection?.style.display !== 'none') { + refreshIcloudAliases({ silent: true }).catch(() => { }); + } if (state.oauthUrl) { displayOauthUrl.textContent = state.oauthUrl; @@ -1609,6 +1655,9 @@ function getSelectedEmailGenerator() { if (generator === 'custom' || generator === 'manual') { return 'custom'; } + if (generator === 'icloud') { + return 'icloud'; + } if (generator === 'cloudflare') return 'cloudflare'; if (generator === 'cloudflare-temp-email') return 'cloudflare-temp-email'; return 'duck'; @@ -1618,6 +1667,14 @@ function getEmailGeneratorUiCopy() { if (getSelectedEmailGenerator() === 'custom') { return getCustomMailProviderUiCopy(); } + if (getSelectedEmailGenerator() === 'icloud') { + return { + buttonLabel: '获取', + placeholder: '点击获取 iCloud 隐私邮箱,或手动粘贴邮箱', + successVerb: '获取', + label: 'iCloud 隐私邮箱', + }; + } if (getSelectedEmailGenerator() === 'cloudflare') { return { buttonLabel: '生成', @@ -1946,14 +2003,26 @@ function updateMailProviderUI() { const hotmailServiceMode = getSelectedHotmailServiceMode(); rowInbucketHost.style.display = useInbucket ? '' : 'none'; rowInbucketMailbox.style.display = useInbucket ? '' : 'none'; - const useCloudflare = selectEmailGenerator.value === 'cloudflare'; - const useCloudflareTempEmailGenerator = selectEmailGenerator.value === 'cloudflare-temp-email'; + const selectedGenerator = getSelectedEmailGenerator(); + const useCloudflare = selectedGenerator === 'cloudflare'; + const useIcloud = selectedGenerator === 'icloud'; + const useCloudflareTempEmailGenerator = selectedGenerator === 'cloudflare-temp-email'; const showCloudflareDomain = useEmailGenerator && useCloudflare; const showCloudflareTempEmailSettings = useCloudflareTempEmailProvider || (useEmailGenerator && useCloudflareTempEmailGenerator); const showCloudflareTempEmailDomain = useEmailGenerator && useCloudflareTempEmailGenerator; if (rowEmailGenerator) { rowEmailGenerator.style.display = useEmailGenerator ? '' : 'none'; } + if (icloudSection) { + const showIcloudSection = useEmailGenerator && useIcloud; + icloudSection.style.display = showIcloudSection ? '' : 'none'; + if (showIcloudSection && !lastRenderedIcloudAliases.length) { + queueIcloudAliasRefresh(); + } + if (!showIcloudSection) { + hideIcloudLoginHelp(); + } + } rowCfDomain.style.display = showCloudflareDomain ? '' : 'none'; const { domains } = getCloudflareDomainsFromState(); if (showCloudflareDomain) { @@ -2002,7 +2071,7 @@ function updateMailProviderUI() { ? '请先校验并选择一个 Hotmail 账号' : (useGeneratedAlias ? '步骤 3 会自动生成邮箱,无需手动获取' - : (useCustomEmail ? '请先填写自定义注册邮箱,成功一轮后会自动清空' : '先自动获取邮箱,或手动粘贴邮箱后再继续')); + : (useCustomEmail ? '请先填写自定义注册邮箱,成功一轮后会自动清空' : `先自动获取${uiCopy.label},或手动粘贴邮箱后再继续`)); } if (useHotmail) { inputEmail.value = getCurrentHotmailEmail(); @@ -2166,6 +2235,11 @@ function updateButtonStates() { }); btnReset.disabled = anyRunning || autoScheduled || isAutoRunPausedPhase() || autoLocked; + const disableIcloudControls = anyRunning || autoScheduled || autoLocked; + if (btnIcloudRefresh) btnIcloudRefresh.disabled = disableIcloudControls; + if (btnIcloudDeleteUsed) btnIcloudDeleteUsed.disabled = disableIcloudControls || !(lastRenderedIcloudAliases.some((alias) => alias.used && !alias.preserved)); + if (selectIcloudHostPreference) selectIcloudHostPreference.disabled = disableIcloudControls; + if (checkboxAutoDeleteIcloud) checkboxAutoDeleteIcloud.disabled = disableIcloudControls; updateStopButtonState(anyRunning || autoScheduled || isAutoRunPausedPhase() || autoLocked); } @@ -2288,6 +2362,375 @@ function escapeHtml(text) { return div.innerHTML; } +function normalizeIcloudSearchText(value) { + return String(value || '').trim().toLowerCase(); +} + +function getFilteredIcloudAliases(aliases = lastRenderedIcloudAliases) { + const searchTerm = normalizeIcloudSearchText(icloudSearchTerm); + return (Array.isArray(aliases) ? aliases : []).filter((alias) => { + const matchesFilter = (() => { + switch (icloudFilterMode) { + case 'active': return Boolean(alias.active); + case 'used': return Boolean(alias.used); + case 'unused': return !alias.used; + case 'preserved': return Boolean(alias.preserved); + default: return true; + } + })(); + + if (!matchesFilter) return false; + if (!searchTerm) return true; + + const haystack = [ + alias.email, + alias.label, + alias.note, + alias.used ? '已用 used' : '未用 unused', + alias.active ? '可用 active' : '不可用 inactive', + alias.preserved ? '保留 preserved' : '', + ].join(' ').toLowerCase(); + + return haystack.includes(searchTerm); + }); +} + +function pruneIcloudSelection(aliases = lastRenderedIcloudAliases) { + const existing = new Set((Array.isArray(aliases) ? aliases : []).map((alias) => alias.email)); + icloudSelectedEmails = new Set([...icloudSelectedEmails].filter((email) => existing.has(email))); +} + +function updateIcloudBulkUI(visibleAliases = getFilteredIcloudAliases()) { + if (!checkboxIcloudSelectAll || !icloudSelectionSummary) { + return; + } + + const visibleEmails = visibleAliases.map((alias) => alias.email); + const selectedVisibleCount = visibleEmails.filter((email) => icloudSelectedEmails.has(email)).length; + const hasVisible = visibleEmails.length > 0; + + checkboxIcloudSelectAll.checked = hasVisible && selectedVisibleCount === visibleEmails.length; + checkboxIcloudSelectAll.indeterminate = selectedVisibleCount > 0 && selectedVisibleCount < visibleEmails.length; + checkboxIcloudSelectAll.disabled = !hasVisible; + icloudSelectionSummary.textContent = `已选 ${icloudSelectedEmails.size} 个(当前显示 ${visibleEmails.length} 个)`; + + const hasSelection = icloudSelectedEmails.size > 0; + if (btnIcloudBulkUsed) btnIcloudBulkUsed.disabled = !hasSelection; + if (btnIcloudBulkUnused) btnIcloudBulkUnused.disabled = !hasSelection; + if (btnIcloudBulkPreserve) btnIcloudBulkPreserve.disabled = !hasSelection; + if (btnIcloudBulkUnpreserve) btnIcloudBulkUnpreserve.disabled = !hasSelection; + if (btnIcloudBulkDelete) btnIcloudBulkDelete.disabled = !hasSelection; +} + +function setIcloudLoadingState(loading, summary = '') { + if (btnIcloudRefresh) btnIcloudRefresh.disabled = loading; + if (btnIcloudDeleteUsed) btnIcloudDeleteUsed.disabled = loading; + if (btnIcloudLoginDone) btnIcloudLoginDone.disabled = loading; + if (inputIcloudSearch) inputIcloudSearch.disabled = loading; + if (selectIcloudFilter) selectIcloudFilter.disabled = loading; + if (checkboxIcloudSelectAll) checkboxIcloudSelectAll.disabled = loading || getFilteredIcloudAliases().length === 0; + if (btnIcloudBulkUsed) btnIcloudBulkUsed.disabled = loading || icloudSelectedEmails.size === 0; + if (btnIcloudBulkUnused) btnIcloudBulkUnused.disabled = loading || icloudSelectedEmails.size === 0; + if (btnIcloudBulkPreserve) btnIcloudBulkPreserve.disabled = loading || icloudSelectedEmails.size === 0; + if (btnIcloudBulkUnpreserve) btnIcloudBulkUnpreserve.disabled = loading || icloudSelectedEmails.size === 0; + if (btnIcloudBulkDelete) btnIcloudBulkDelete.disabled = loading || icloudSelectedEmails.size === 0; + if (summary && icloudSummary) icloudSummary.textContent = summary; +} + +function showIcloudLoginHelp(payload = {}) { + if (!icloudLoginHelp) return; + const loginUrl = String(payload.loginUrl || '').trim(); + const host = loginUrl ? new URL(loginUrl).host : 'icloud.com.cn / icloud.com'; + if (icloudLoginHelpTitle) icloudLoginHelpTitle.textContent = '需要登录 iCloud'; + if (icloudLoginHelpText) icloudLoginHelpText.textContent = `我已经为你打开 ${host}。请在那个页面完成登录,然后回到这里点击“我已登录”。`; + icloudLoginHelp.style.display = 'flex'; +} + +function hideIcloudLoginHelp() { + if (icloudLoginHelp) { + icloudLoginHelp.style.display = 'none'; + } +} + +function renderIcloudAliases(aliases = []) { + if (!icloudList || !icloudSummary) return; + + lastRenderedIcloudAliases = Array.isArray(aliases) ? aliases : []; + pruneIcloudSelection(lastRenderedIcloudAliases); + icloudList.innerHTML = ''; + + if (!aliases.length) { + icloudSelectedEmails.clear(); + icloudList.innerHTML = '
未找到 iCloud Hide My Email 别名。
'; + icloudSummary.textContent = '加载你的 iCloud Hide My Email 别名以便在这里管理。'; + if (btnIcloudDeleteUsed) btnIcloudDeleteUsed.disabled = true; + updateIcloudBulkUI([]); + return; + } + + const usedCount = aliases.filter((alias) => alias.used).length; + const deletableUsedCount = aliases.filter((alias) => alias.used && !alias.preserved).length; + icloudSummary.textContent = `已加载 ${aliases.length} 个别名,其中 ${usedCount} 个已标记为已用。`; + if (btnIcloudDeleteUsed) btnIcloudDeleteUsed.disabled = deletableUsedCount === 0; + + const visibleAliases = getFilteredIcloudAliases(aliases); + if (!visibleAliases.length) { + icloudList.innerHTML = '
没有匹配当前筛选条件的别名。
'; + updateIcloudBulkUI([]); + return; + } + + for (const alias of visibleAliases) { + const item = document.createElement('div'); + item.className = 'icloud-item'; + item.innerHTML = ` + +
+ +
+ ${alias.used ? '已用' : ''} + ${!alias.used && alias.active ? '可用' : ''} + ${alias.preserved ? '保留' : ''} + ${alias.label ? `${escapeHtml(alias.label)}` : ''} + ${alias.note ? `${escapeHtml(alias.note)}` : ''} +
+
+
+ + + +
+ `; + + item.querySelector('[data-action="select"]').addEventListener('change', (event) => { + if (event.target.checked) { + icloudSelectedEmails.add(alias.email); + } else { + icloudSelectedEmails.delete(alias.email); + } + updateIcloudBulkUI(visibleAliases); + }); + item.querySelector('[data-action="toggle-used"]').addEventListener('click', async () => { + await setSingleIcloudAliasUsedState(alias, !alias.used); + }); + item.querySelector('[data-action="toggle-preserved"]').addEventListener('click', async () => { + await setSingleIcloudAliasPreservedState(alias, !alias.preserved); + }); + item.querySelector('[data-action="delete"]').addEventListener('click', async () => { + await deleteSingleIcloudAlias(alias); + }); + icloudList.appendChild(item); + } + + updateIcloudBulkUI(visibleAliases); +} + +async function refreshIcloudAliases(options = {}) { + const { silent = false } = options; + if (!icloudSection || icloudSection.style.display === 'none') { + return; + } + + if (!silent) setIcloudLoadingState(true, '正在加载 iCloud 别名...'); + try { + const response = await chrome.runtime.sendMessage({ + type: 'LIST_ICLOUD_ALIASES', + source: 'sidepanel', + payload: {}, + }); + if (response?.error) throw new Error(response.error); + hideIcloudLoginHelp(); + renderIcloudAliases(response?.aliases || []); + } catch (err) { + icloudSelectedEmails.clear(); + if (icloudList) { + icloudList.innerHTML = '
无法加载 iCloud 别名。
'; + } + if (icloudSummary) { + icloudSummary.textContent = err.message; + } + updateIcloudBulkUI([]); + if (!silent) showToast(`iCloud 别名加载失败:${err.message}`, 'error'); + } finally { + setIcloudLoadingState(false); + } +} + +function queueIcloudAliasRefresh() { + if (icloudRefreshQueued) return; + icloudRefreshQueued = true; + setTimeout(async () => { + icloudRefreshQueued = false; + await refreshIcloudAliases({ silent: true }); + }, 150); +} + +async function deleteSingleIcloudAlias(alias) { + const confirmed = await openConfirmModal({ + title: '删除 iCloud 别名', + message: `确认删除 ${alias.email} 吗?此操作不可撤销。`, + confirmLabel: '确认删除', + confirmVariant: 'btn-danger', + }); + if (!confirmed) { + return; + } + + setIcloudLoadingState(true, `正在删除 ${alias.email} ...`); + try { + const response = await chrome.runtime.sendMessage({ + type: 'DELETE_ICLOUD_ALIAS', + source: 'sidepanel', + payload: { email: alias.email, anonymousId: alias.anonymousId }, + }); + if (response?.error) throw new Error(response.error); + showToast(`已删除 ${alias.email}`, 'success', 2200); + await refreshIcloudAliases({ silent: true }); + } catch (err) { + if (icloudSummary) icloudSummary.textContent = err.message; + showToast(`删除 iCloud 别名失败:${err.message}`, 'error'); + } finally { + setIcloudLoadingState(false); + } +} + +async function setSingleIcloudAliasUsedState(alias, used) { + setIcloudLoadingState(true, `正在更新 ${alias.email} 的使用状态...`); + try { + const response = await chrome.runtime.sendMessage({ + type: 'SET_ICLOUD_ALIAS_USED_STATE', + source: 'sidepanel', + payload: { email: alias.email, used }, + }); + if (response?.error) throw new Error(response.error); + showToast(`${alias.email} 已${used ? '标记为已用' : '恢复为未用'}`, 'success', 2200); + await refreshIcloudAliases({ silent: true }); + } catch (err) { + if (icloudSummary) icloudSummary.textContent = err.message; + showToast(`更新 iCloud 使用状态失败:${err.message}`, 'error'); + } finally { + setIcloudLoadingState(false); + } +} + +async function setSingleIcloudAliasPreservedState(alias, preserved) { + setIcloudLoadingState(true, `正在更新 ${alias.email} 的保留状态...`); + try { + const response = await chrome.runtime.sendMessage({ + type: 'SET_ICLOUD_ALIAS_PRESERVED_STATE', + source: 'sidepanel', + payload: { email: alias.email, preserved }, + }); + if (response?.error) throw new Error(response.error); + showToast(`${alias.email} 已${preserved ? '设为保留' : '取消保留'}`, 'success', 2200); + await refreshIcloudAliases({ silent: true }); + } catch (err) { + if (icloudSummary) icloudSummary.textContent = err.message; + showToast(`更新 iCloud 保留状态失败:${err.message}`, 'error'); + } finally { + setIcloudLoadingState(false); + } +} + +async function runBulkIcloudAction(action) { + const selectedAliases = lastRenderedIcloudAliases.filter((alias) => icloudSelectedEmails.has(alias.email)); + if (!selectedAliases.length) { + updateIcloudBulkUI(); + return; + } + + if (action === 'delete') { + const confirmed = await openConfirmModal({ + title: '批量删除 iCloud 别名', + message: `确认删除选中的 ${selectedAliases.length} 个 iCloud 别名吗?此操作不可撤销。`, + confirmLabel: '确认删除', + confirmVariant: 'btn-danger', + }); + if (!confirmed) { + return; + } + } + + const actionLabelMap = { + used: '标记已用', + unused: '标记未用', + preserve: '保留', + unpreserve: '取消保留', + delete: '删除', + }; + setIcloudLoadingState(true, `正在批量${actionLabelMap[action] || '处理'} iCloud 别名...`); + + try { + for (const alias of selectedAliases) { + let response = null; + if (action === 'used' || action === 'unused') { + response = await chrome.runtime.sendMessage({ + type: 'SET_ICLOUD_ALIAS_USED_STATE', + source: 'sidepanel', + payload: { email: alias.email, used: action === 'used' }, + }); + } else if (action === 'preserve' || action === 'unpreserve') { + response = await chrome.runtime.sendMessage({ + type: 'SET_ICLOUD_ALIAS_PRESERVED_STATE', + source: 'sidepanel', + payload: { email: alias.email, preserved: action === 'preserve' }, + }); + } else if (action === 'delete') { + response = await chrome.runtime.sendMessage({ + type: 'DELETE_ICLOUD_ALIAS', + source: 'sidepanel', + payload: { email: alias.email, anonymousId: alias.anonymousId }, + }); + icloudSelectedEmails.delete(alias.email); + } + + if (response?.error) { + throw new Error(response.error); + } + } + + showToast(`已批量${actionLabelMap[action] || '处理'} ${selectedAliases.length} 个 iCloud 别名`, 'success', 2400); + await refreshIcloudAliases({ silent: true }); + } catch (err) { + if (icloudSummary) icloudSummary.textContent = err.message; + showToast(`批量处理 iCloud 别名失败:${err.message}`, 'error'); + } finally { + setIcloudLoadingState(false); + updateIcloudBulkUI(); + } +} + +async function deleteUsedIcloudAliases() { + const confirmed = await openConfirmModal({ + title: '删除已用 iCloud 别名', + message: '确认删除所有未保留的已用 iCloud 别名吗?此操作不可撤销。', + confirmLabel: '确认删除', + confirmVariant: 'btn-danger', + }); + if (!confirmed) { + return; + } + + setIcloudLoadingState(true, '正在删除已用 iCloud 别名...'); + try { + const response = await chrome.runtime.sendMessage({ + type: 'DELETE_USED_ICLOUD_ALIASES', + source: 'sidepanel', + payload: {}, + }); + if (response?.error) throw new Error(response.error); + const deleted = response?.deleted || []; + const skipped = response?.skipped || []; + showToast(`已删除 ${deleted.length} 个已用别名,跳过 ${skipped.length} 个`, skipped.length ? 'warn' : 'success', 2800); + await refreshIcloudAliases({ silent: true }); + } catch (err) { + if (icloudSummary) icloudSummary.textContent = err.message; + showToast(`删除已用 iCloud 别名失败:${err.message}`, 'error'); + } finally { + setIcloudLoadingState(false); + } +} + async function fetchGeneratedEmail(options = {}) { const { showFailureToast = true } = options; const uiCopy = getEmailGeneratorUiCopy(); @@ -2316,6 +2759,9 @@ async function fetchGeneratedEmail(options = {}) { } inputEmail.value = response.email; + if (getSelectedEmailGenerator() === 'icloud') { + queueIcloudAliasRefresh(); + } showToast(`已${uiCopy.successVerb} ${uiCopy.label}:${response.email}`, 'success', 2500); return response.email; } catch (err) { @@ -2628,6 +3074,75 @@ btnFetchEmail.addEventListener('click', async () => { await fetchGeneratedEmail().catch(() => { }); }); +btnIcloudRefresh?.addEventListener('click', async () => { + await refreshIcloudAliases(); +}); + +btnIcloudDeleteUsed?.addEventListener('click', async () => { + await deleteUsedIcloudAliases(); +}); + +inputIcloudSearch?.addEventListener('input', () => { + icloudSearchTerm = inputIcloudSearch.value || ''; + renderIcloudAliases(lastRenderedIcloudAliases); +}); + +selectIcloudFilter?.addEventListener('change', () => { + icloudFilterMode = selectIcloudFilter.value || 'all'; + renderIcloudAliases(lastRenderedIcloudAliases); +}); + +checkboxIcloudSelectAll?.addEventListener('change', () => { + const visibleAliases = getFilteredIcloudAliases(); + if (checkboxIcloudSelectAll.checked) { + visibleAliases.forEach((alias) => icloudSelectedEmails.add(alias.email)); + } else { + visibleAliases.forEach((alias) => icloudSelectedEmails.delete(alias.email)); + } + renderIcloudAliases(lastRenderedIcloudAliases); +}); + +btnIcloudBulkUsed?.addEventListener('click', async () => { + await runBulkIcloudAction('used'); +}); + +btnIcloudBulkUnused?.addEventListener('click', async () => { + await runBulkIcloudAction('unused'); +}); + +btnIcloudBulkPreserve?.addEventListener('click', async () => { + await runBulkIcloudAction('preserve'); +}); + +btnIcloudBulkUnpreserve?.addEventListener('click', async () => { + await runBulkIcloudAction('unpreserve'); +}); + +btnIcloudBulkDelete?.addEventListener('click', async () => { + await runBulkIcloudAction('delete'); +}); + +btnIcloudLoginDone?.addEventListener('click', async () => { + btnIcloudLoginDone.disabled = true; + try { + const response = await chrome.runtime.sendMessage({ + type: 'CHECK_ICLOUD_SESSION', + source: 'sidepanel', + payload: {}, + }); + if (response?.error) { + throw new Error(response.error); + } + hideIcloudLoginHelp(); + showToast('iCloud 会话已恢复,别名列表已刷新。', 'success', 2600); + await refreshIcloudAliases({ silent: true }); + } catch (err) { + showToast(`看起来还没有登录完成:${err.message}`, 'warn', 4200); + } finally { + btnIcloudLoginDone.disabled = false; + } +}); + btnToggleHotmailList?.addEventListener('click', () => { setHotmailListExpanded(!hotmailListExpanded); }); @@ -3106,6 +3621,15 @@ btnReset.addEventListener('click', async () => { displayStatus.textContent = '就绪'; statusBar.className = 'status-bar'; logArea.innerHTML = ''; + icloudSelectedEmails.clear(); + lastRenderedIcloudAliases = []; + if (icloudList) { + icloudList.innerHTML = ''; + } + if (icloudSummary) { + icloudSummary.textContent = '加载你的 iCloud Hide My Email 别名以便在这里管理。'; + } + updateIcloudBulkUI([]); document.querySelectorAll('.step-row').forEach(row => row.className = 'step-row'); document.querySelectorAll('.step-status').forEach(el => el.textContent = ''); setDefaultAutoRunButton(); @@ -3202,6 +3726,19 @@ selectEmailGenerator.addEventListener('change', () => { saveSettings({ silent: true }).catch(() => { }); }); +selectIcloudHostPreference?.addEventListener('change', () => { + markSettingsDirty(true); + saveSettings({ silent: true }).catch(() => { }); + if (getSelectedEmailGenerator() === 'icloud') { + queueIcloudAliasRefresh(); + } +}); + +checkboxAutoDeleteIcloud?.addEventListener('change', () => { + markSettingsDirty(true); + saveSettings({ silent: true }).catch(() => { }); +}); + selectPanelMode.addEventListener('change', () => { updatePanelModeUI(); markSettingsDirty(true); @@ -3502,6 +4039,11 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { displayStatus.textContent = '就绪'; statusBar.className = 'status-bar'; logArea.innerHTML = ''; + icloudSelectedEmails.clear(); + lastRenderedIcloudAliases = []; + if (icloudList) icloudList.innerHTML = ''; + if (icloudSummary) icloudSummary.textContent = '加载你的 iCloud Hide My Email 别名以便在这里管理。'; + updateIcloudBulkUI([]); document.querySelectorAll('.step-row').forEach(row => row.className = 'step-row'); document.querySelectorAll('.step-status').forEach(el => el.textContent = ''); syncAutoRunState({ @@ -3559,6 +4101,15 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { inputEmail.value = getCurrentHotmailEmail(); } } + if (message.payload.autoDeleteUsedIcloudAlias !== undefined && checkboxAutoDeleteIcloud) { + checkboxAutoDeleteIcloud.checked = Boolean(message.payload.autoDeleteUsedIcloudAlias); + } + if (message.payload.icloudHostPreference !== undefined && selectIcloudHostPreference) { + const hostPreference = String(message.payload.icloudHostPreference || '').trim().toLowerCase(); + selectIcloudHostPreference.value = hostPreference === 'icloud.com' + ? 'icloud.com' + : (hostPreference === 'icloud.com.cn' ? 'icloud.com.cn' : 'auto'); + } if (message.payload.autoRunSkipFailures !== undefined) { inputAutoSkipFailures.checked = Boolean(message.payload.autoRunSkipFailures); updateFallbackThreadIntervalInputState(); @@ -3582,6 +4133,21 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { break; } + case 'ICLOUD_LOGIN_REQUIRED': { + const loginMessage = '需要登录 iCloud,我已经为你打开登录页。'; + showToast(loginMessage, 'warn', 5000); + if (icloudSummary) { + icloudSummary.textContent = loginMessage; + } + showIcloudLoginHelp(message.payload || {}); + break; + } + + case 'ICLOUD_ALIASES_CHANGED': { + queueIcloudAliasRefresh(); + break; + } + case 'AUTO_RUN_STATUS': { syncLatestState({ autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(message.payload.phase), diff --git a/tests/auto-run-fresh-attempt-reset.test.js b/tests/auto-run-fresh-attempt-reset.test.js index 3c8622a..91533ea 100644 --- a/tests/auto-run-fresh-attempt-reset.test.js +++ b/tests/auto-run-fresh-attempt-reset.test.js @@ -189,6 +189,20 @@ function cancelPendingCommands() {} function normalizeAutoRunFallbackThreadIntervalMinutes(value) { return Math.max(0, Math.floor(Number(value) || 0)); } +function buildAutoRunRoundSummaries(totalRuns, rawSummaries = []) { + return Array.from({ length: totalRuns }, (_, index) => ({ + round: index + 1, + status: rawSummaries[index]?.status || 'pending', + attempts: rawSummaries[index]?.attempts || 0, + failureReasons: [...(rawSummaries[index]?.failureReasons || [])], + finalFailureReason: rawSummaries[index]?.finalFailureReason || '', + })); +} +function serializeAutoRunRoundSummaries(totalRuns, roundSummaries = []) { + return buildAutoRunRoundSummaries(totalRuns, roundSummaries); +} +async function logAutoRunFinalSummary() {} +async function waitBetweenAutoRunRounds() {} const chrome = { runtime: { diff --git a/tests/background-icloud.test.js b/tests/background-icloud.test.js new file mode 100644 index 0000000..c7f26db --- /dev/null +++ b/tests/background-icloud.test.js @@ -0,0 +1,265 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); + +const source = fs.readFileSync('background.js', 'utf8'); + +function extractFunction(name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map((marker) => source.indexOf(marker)) + .find((index) => index >= 0); + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let i = start; i < source.length; i += 1) { + const ch = source[i]; + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (ch === '{' && signatureEnded) { + braceStart = i; + break; + } + } + if (braceStart < 0) { + throw new Error(`missing body for function ${name}`); + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end += 1) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +const bundle = [ + extractFunction('normalizeEmailGenerator'), + extractFunction('getEmailGeneratorLabel'), + extractFunction('normalizePersistentSettingValue'), + extractFunction('finalizeIcloudAliasAfterSuccessfulFlow'), +].join('\n'); + +function createApi(overrides = {}) { + return new Function('overrides', ` +const HOTMAIL_PROVIDER = 'hotmail-api'; +const HOTMAIL_SERVICE_MODE_LOCAL = 'local'; +const CLOUDFLARE_TEMP_EMAIL_GENERATOR = 'cloudflare-temp-email'; +const DEFAULT_LOCAL_CPA_STEP9_MODE = 'submit'; +const DEFAULT_HOTMAIL_REMOTE_BASE_URL = ''; +const DEFAULT_HOTMAIL_LOCAL_BASE_URL = 'http://127.0.0.1:17373'; +const PERSISTED_SETTING_DEFAULTS = { + mailProvider: '163', + autoStepDelaySeconds: null, +}; + +const calls = { + setUsed: [], + logs: [], + deletes: [], + listCalls: 0, +}; + +function normalizeIcloudHost(value) { + const normalized = String(value || '').trim().toLowerCase(); + return ['icloud.com', 'icloud.com.cn'].includes(normalized) ? normalized : ''; +} +function normalizePanelMode(value = '') { + return String(value || '').trim().toLowerCase() === 'sub2api' ? 'sub2api' : 'cpa'; +} +function normalizeMailProvider(value = '') { + return String(value || '').trim().toLowerCase() || '163'; +} +function normalizeAutoRunFallbackThreadIntervalMinutes(value) { + return Math.max(0, Math.floor(Number(value) || 0)); +} +function normalizeAutoRunDelayMinutes(value) { + return Math.max(1, Math.floor(Number(value) || 30)); +} +function normalizeAutoStepDelaySeconds(value, fallback = null) { + const numeric = Number(value); + return Number.isFinite(numeric) ? Math.max(0, Math.floor(numeric)) : fallback; +} +function normalizeHotmailServiceMode() { + return HOTMAIL_SERVICE_MODE_LOCAL; +} +function normalizeHotmailRemoteBaseUrl(value = '') { + return String(value || '').trim() || DEFAULT_HOTMAIL_REMOTE_BASE_URL; +} +function normalizeHotmailLocalBaseUrl(value = '') { + return String(value || '').trim() || DEFAULT_HOTMAIL_LOCAL_BASE_URL; +} +function normalizeCloudflareDomain(value = '') { + return String(value || '').trim().toLowerCase(); +} +function normalizeCloudflareDomains(values = []) { + return Array.isArray(values) ? values : []; +} +function normalizeHotmailAccounts(values = []) { + return Array.isArray(values) ? values : []; +} +function getManualAliasUsageMap(state) { + return { ...(state?.manualAliasUsage || {}) }; +} +function getPreservedAliasMap(state) { + return { ...(state?.preservedAliases || {}) }; +} +function isAliasPreserved(state, email) { + return Boolean(getPreservedAliasMap(state)[String(email || '').trim().toLowerCase()]); +} +async function setIcloudAliasUsedState(payload, options = {}) { + calls.setUsed.push({ payload, options }); +} +async function addLog(message, level = 'info') { + calls.logs.push({ message, level }); +} +async function deleteIcloudAlias(alias) { + calls.deletes.push(alias); +} +async function listIcloudAliases() { + calls.listCalls += 1; + return overrides.listIcloudAliases ? overrides.listIcloudAliases() : []; +} +function findIcloudAliasByEmail(aliases, email) { + return (aliases || []).find((alias) => String(alias.email || '').toLowerCase() === String(email || '').toLowerCase()) || null; +} +function getErrorMessage(error) { + return String(typeof error === 'string' ? error : error?.message || ''); +} + +${bundle} + +return { + calls, + normalizeEmailGenerator, + getEmailGeneratorLabel, + normalizePersistentSettingValue, + finalizeIcloudAliasAfterSuccessfulFlow, +}; +`)(overrides); +} + +test('normalizeEmailGenerator and label support icloud', () => { + const api = createApi(); + assert.equal(api.normalizeEmailGenerator('icloud'), 'icloud'); + assert.equal(api.getEmailGeneratorLabel('icloud'), 'iCloud 隐私邮箱'); +}); + +test('normalizePersistentSettingValue handles icloud settings', () => { + const api = createApi(); + assert.equal(api.normalizePersistentSettingValue('icloudHostPreference', 'icloud.com'), 'icloud.com'); + assert.equal(api.normalizePersistentSettingValue('icloudHostPreference', 'bad-host'), 'auto'); + assert.equal(api.normalizePersistentSettingValue('autoDeleteUsedIcloudAlias', 1), true); +}); + +test('finalizeIcloudAliasAfterSuccessfulFlow marks icloud aliases as used without deleting when auto-delete is off', async () => { + const api = createApi(); + const result = await api.finalizeIcloudAliasAfterSuccessfulFlow({ + email: 'alias@icloud.com', + emailGenerator: 'icloud', + autoDeleteUsedIcloudAlias: false, + manualAliasUsage: {}, + preservedAliases: {}, + }); + + assert.deepEqual(result, { handled: true, deleted: false }); + assert.equal(api.calls.setUsed.length, 1); + assert.equal(api.calls.listCalls, 0); + assert.equal(api.calls.deletes.length, 0); +}); + +test('finalizeIcloudAliasAfterSuccessfulFlow skips deleting preserved aliases', async () => { + const api = createApi(); + const result = await api.finalizeIcloudAliasAfterSuccessfulFlow({ + email: 'alias@icloud.com', + emailGenerator: 'icloud', + autoDeleteUsedIcloudAlias: true, + manualAliasUsage: {}, + preservedAliases: { 'alias@icloud.com': true }, + }); + + assert.deepEqual(result, { handled: true, deleted: false }); + assert.equal(api.calls.setUsed.length, 1); + assert.equal(api.calls.listCalls, 0); + assert.equal(api.calls.deletes.length, 0); +}); + +test('finalizeIcloudAliasAfterSuccessfulFlow skips deleting aliases that are preserved in the latest alias list', async () => { + const api = createApi({ + listIcloudAliases() { + return [ + { email: 'alias@icloud.com', anonymousId: 'anon-1', preserved: true }, + ]; + }, + }); + + const result = await api.finalizeIcloudAliasAfterSuccessfulFlow({ + email: 'alias@icloud.com', + emailGenerator: 'icloud', + autoDeleteUsedIcloudAlias: true, + manualAliasUsage: {}, + preservedAliases: {}, + }); + + assert.deepEqual(result, { handled: true, deleted: false }); + assert.equal(api.calls.setUsed.length, 1); + assert.equal(api.calls.listCalls, 1); + assert.equal(api.calls.deletes.length, 0); +}); + +test('finalizeIcloudAliasAfterSuccessfulFlow deletes alias when auto-delete is enabled and alias exists', async () => { + const api = createApi({ + listIcloudAliases() { + return [ + { email: 'alias@icloud.com', anonymousId: 'anon-1', preserved: false }, + ]; + }, + }); + + const result = await api.finalizeIcloudAliasAfterSuccessfulFlow({ + email: 'alias@icloud.com', + emailGenerator: 'icloud', + autoDeleteUsedIcloudAlias: true, + manualAliasUsage: {}, + preservedAliases: {}, + }); + + assert.deepEqual(result, { handled: true, deleted: true }); + assert.equal(api.calls.setUsed.length, 1); + assert.equal(api.calls.listCalls, 1); + assert.deepEqual(api.calls.deletes, [ + { email: 'alias@icloud.com', anonymousId: 'anon-1', preserved: false }, + ]); +}); + +test('finalizeIcloudAliasAfterSuccessfulFlow ignores non-icloud flows', async () => { + const api = createApi(); + const result = await api.finalizeIcloudAliasAfterSuccessfulFlow({ + email: 'plain@example.com', + emailGenerator: 'duck', + autoDeleteUsedIcloudAlias: true, + manualAliasUsage: {}, + preservedAliases: {}, + }); + + assert.deepEqual(result, { handled: false, deleted: false }); + assert.equal(api.calls.setUsed.length, 0); +}); diff --git a/tests/icloud-utils.test.js b/tests/icloud-utils.test.js new file mode 100644 index 0000000..5d8fa68 --- /dev/null +++ b/tests/icloud-utils.test.js @@ -0,0 +1,121 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + findIcloudAliasArray, + findIcloudAliasByEmail, + getConfiguredIcloudHostPreference, + getIcloudHostHintFromMessage, + getIcloudLoginUrlForHost, + getIcloudSetupUrlForHost, + normalizeBooleanMap, + normalizeIcloudAliasList, + normalizeIcloudAliasRecord, + normalizeIcloudHost, + pickReusableIcloudAlias, + toNormalizedEmailSet, +} = require('../icloud-utils.js'); + +test('normalizeIcloudHost and host preference helpers resolve supported hosts', () => { + assert.equal(normalizeIcloudHost('www.icloud.com'), 'icloud.com'); + assert.equal(normalizeIcloudHost('setup.icloud.com.cn'), 'icloud.com.cn'); + assert.equal(normalizeIcloudHost('example.com'), ''); + + assert.equal(getConfiguredIcloudHostPreference({ icloudHostPreference: 'icloud.com' }), 'icloud.com'); + assert.equal(getConfiguredIcloudHostPreference({ icloudHostPreference: 'auto' }), ''); + assert.equal(getIcloudLoginUrlForHost('icloud.com.cn'), 'https://www.icloud.com.cn/'); + assert.equal(getIcloudSetupUrlForHost('icloud.com'), 'https://setup.icloud.com/setup/ws/1'); +}); + +test('getIcloudHostHintFromMessage can infer host from error text', () => { + assert.equal(getIcloudHostHintFromMessage('status 401 from setup.icloud.com.cn/setup/ws/1'), 'icloud.com.cn'); + assert.equal(getIcloudHostHintFromMessage('request failed at https://www.icloud.com/'), 'icloud.com'); + assert.equal(getIcloudHostHintFromMessage('unknown host'), ''); +}); + +test('findIcloudAliasArray finds nested alias collections', () => { + const payload = { + result: { + data: { + items: [ + { hme: 'first@icloud.com', anonymousId: 'a1' }, + { hme: 'second@icloud.com', anonymousId: 'a2' }, + ], + }, + }, + }; + + assert.deepEqual(findIcloudAliasArray(payload), payload.result.data.items); +}); + +test('normalizeIcloudAliasRecord merges used and preserved state', () => { + const alias = normalizeIcloudAliasRecord({ + anonymousId: 'alias-1', + hme: 'Demo@iCloud.com', + label: 'Test', + note: 'Created by test', + state: 'active', + }, { + usedEmails: ['demo@icloud.com'], + preservedEmails: ['demo@icloud.com'], + }); + + assert.deepEqual(alias, { + anonymousId: 'alias-1', + email: 'demo@icloud.com', + label: 'Test', + note: 'Created by test', + state: 'active', + active: true, + used: true, + preserved: true, + createdAt: null, + }); +}); + +test('normalizeIcloudAliasList orders active unused aliases before used aliases', () => { + const aliases = normalizeIcloudAliasList({ + hmeEmails: [ + { hme: 'used@icloud.com', anonymousId: 'u1', active: true }, + { hme: 'fresh@icloud.com', anonymousId: 'f1', active: true }, + { hme: 'inactive@icloud.com', anonymousId: 'i1', active: false }, + ], + }, { + usedEmails: ['used@icloud.com'], + preservedEmails: ['inactive@icloud.com'], + }); + + assert.deepEqual(aliases.map((alias) => alias.email), [ + 'fresh@icloud.com', + 'used@icloud.com', + 'inactive@icloud.com', + ]); + assert.equal(aliases[2].preserved, true); +}); + +test('pickReusableIcloudAlias and findIcloudAliasByEmail select expected aliases', () => { + const aliases = normalizeIcloudAliasList({ + hmeEmails: [ + { hme: 'used@icloud.com', anonymousId: 'u1', active: true }, + { hme: 'fresh@icloud.com', anonymousId: 'f1', active: true }, + ], + }, { + usedEmails: ['used@icloud.com'], + }); + + assert.equal(pickReusableIcloudAlias(aliases)?.email, 'fresh@icloud.com'); + assert.equal(findIcloudAliasByEmail(aliases, 'FRESH@ICLOUD.COM')?.anonymousId, 'f1'); +}); + +test('normalizeBooleanMap and toNormalizedEmailSet normalize keys and truthy entries', () => { + const normalized = normalizeBooleanMap({ + ' Demo@icloud.com ': 1, + 'skip@icloud.com': 0, + }); + + assert.deepEqual(normalized, { + 'demo@icloud.com': true, + 'skip@icloud.com': false, + }); + assert.deepEqual([...toNormalizedEmailSet(normalized)], ['demo@icloud.com']); +}); diff --git a/tests/step9-localhost-cleanup-scope.test.js b/tests/step9-localhost-cleanup-scope.test.js index ffb9670..5779339 100644 --- a/tests/step9-localhost-cleanup-scope.test.js +++ b/tests/step9-localhost-cleanup-scope.test.js @@ -110,6 +110,11 @@ async function addLog(message) { logMessages.push(message); } +async function finalizeIcloudAliasAfterSuccessfulFlow() {} +function shouldUseCustomRegistrationEmail() { + return false; +} + ${bundle} return { From dce60afe497f5f87f3560f11a7c77c72f697a50c Mon Sep 17 00:00:00 2001 From: Q3CC Date: Tue, 14 Apr 2026 23:31:15 +0800 Subject: [PATCH 2/3] docs: refine PR review and merge workflow --- docs/仓库协作者AI分析PR与合并标准流程.md | 101 ++++++++++++++++------- 1 file changed, 69 insertions(+), 32 deletions(-) diff --git a/docs/仓库协作者AI分析PR与合并标准流程.md b/docs/仓库协作者AI分析PR与合并标准流程.md index 22abdf0..9bc4b48 100644 --- a/docs/仓库协作者AI分析PR与合并标准流程.md +++ b/docs/仓库协作者AI分析PR与合并标准流程.md @@ -26,6 +26,7 @@ 10. 默认不编译测试。处理完成后提醒用户自行测试。 11. 任何 GitHub 评论、回复、感谢留言发出后,AI 都必须立即再读一遍线上实际内容,确认正文不是乱码、不是 `?`、不是编码异常;如果发现异常,必须立刻修正后再继续后续流程。 12. 如果流程执行过程中,PR 的实时目标分支被别人改掉,或者不再是 `dev`,AI 必须停止当前合并流程,重新拉取信息后再决定下一步。 +13. 进入“阶段 2:分析与审查”时,AI 必须先给当前 PR 添加 `审查中` 标签,并在开始正式审查前确认该标签已经在线上生效。 ## 输入要求 @@ -53,7 +54,7 @@ AI 必须先执行下面这些动作,不能跳步: 2. 拉取 PR 元数据: ```powershell -gh pr view --repo --json number,title,body,author,baseRefName,headRefName,headRepository,headRepositoryOwner,changedFiles,additions,deletions,commits,files,isDraft,mergeStateStatus,mergeable,state,url +gh pr view --repo --json number,title,body,author,baseRefName,headRefName,headRepository,headRepositoryOwner,changedFiles,additions,deletions,commits,files,labels,isDraft,mergeStateStatus,mergeable,state,url ``` 3. 先根据实时 `baseRefName` 处理目标分支: @@ -107,27 +108,37 @@ git worktree add --detach $TempWorktree origin/pr- AI 分析时,必须完整执行下面这些要求: -1. 不能只看 PR 描述,必须结合真实 diff 和仓库现有代码上下文一起分析。 -2. 如果阶段 1 发生过 `master -> dev` 自动转向,分析时只能基于转向后的最新 diff,不得继续引用转向前的 diff 结论。 -3. 对于高风险文件,必须继续读取改动函数周边逻辑,确认消息流、状态流、配置项、回调流程、页面跳转流程是否一致。 -4. 必须分析并输出: +1. 审查一开始必须先执行: + +```powershell +gh pr edit --add-label "审查中" --repo +``` + +补充要求: + + - 打标后必须立刻重新读取一次实时 PR 元数据,确认 `labels` 中已经出现 `审查中`。 + - 如果仓库里没有 `审查中` 标签、当前账号无权限打标,或者命令执行失败,必须立刻告知用户,不能假装已经开始审查。 +2. 不能只看 PR 描述,必须结合真实 diff 和仓库现有代码上下文一起分析。 +3. 如果阶段 1 发生过 `master -> dev` 自动转向,分析时只能基于转向后的最新 diff,不得继续引用转向前的 diff 结论。 +4. 对于高风险文件,必须继续读取改动函数周边逻辑,确认消息流、状态流、配置项、回调流程、页面跳转流程是否一致。 +5. 必须分析并输出: - 这个 PR 改了什么 - 这些改动是否合理 - 是否存在 bug / 风险 / 逻辑冲突 - PR 当前是否可直接合并,例如 `mergeable`、`mergeStateStatus` -5. 如果发现问题,结论必须按严重级别排序,优先写真正会影响功能、合并或后续维护的问题。 -6. 如果没有发现明确问题,也要说明剩余风险,例如: +6. 如果发现问题,结论必须按严重级别排序,优先写真正会影响功能、合并或后续维护的问题。 +7. 如果没有发现明确问题,也要说明剩余风险,例如: - 未运行测试 - 需要人工验证真实业务接口 - 当前仅完成静态分析 -7. 在准备进入后续合并步骤前,必须再拉取一次实时 PR 元数据,确认: +8. 在准备进入后续合并步骤前,必须再拉取一次实时 PR 元数据,确认: - PR 仍然是打开状态 - PR 不是 draft - `baseRefName` 仍然是 `dev` ## 分析后评论规则 -### 有问题时 +### 有问题 / 审核不通过时 如果发现需要作者或维护者注意的问题,AI 必须自动发 PR 评论,并且评论格式必须固定如下: @@ -144,6 +155,10 @@ AI 分析时,必须完整执行下面这些要求: 3. 正文内容要直接写问题,不要再套娃解释“下面是分析结果”。 4. 评论内容必须基于实际检查到的问题,不能编。 5. 评论发出后,必须立刻回读该评论的线上正文,确认不是乱码;如果有乱码,必须先修正评论,再继续后续动作。 +6. 如果问题能够明确定位到某个改动文件、代码块或行号,AI 应优先在对应文件的代码 diff / 代码附件上追加行级评论,直接指出问题和期望修改方式。 +7. 如果结论是“当前不能通过审查”,AI 必须明确写出“需要修改后再继续”,不能只写模糊提醒。 +8. 如果当前环境支持正式 PR review,且问题足以阻止合并,优先使用带“要求更改(Request changes)”语义的审查,而不是只留普通闲聊式评论。 +9. 行级评论是对总评论的补充,不得用零散行级评论替代总评论结论。 ### 没问题时 @@ -158,49 +173,52 @@ AI 分析时,必须完整执行下面这些要求: 如果 PR 有问题,但用户明确要求: - 不等待 PR 作者修复 -- 由当前 AI 直接本地处理冲突和问题 +- 由当前 AI 直接修改 PR 分支代码,处理冲突和问题 - 处理完成后继续合并 则必须进入下面的流程。 -### 阶段 3:临时工作区合并 +### 阶段 3:临时工作区修复 PR 分支 禁止在用户当前工作区直接乱合并。 -必须先创建临时 `worktree`,再在临时目录内处理: +必须先创建临时 `worktree`,再在临时目录内处理 PR 分支: ```powershell git fetch origin dev git fetch origin pull//head:refs/remotes/origin/pr- -git worktree add -b pr--merge origin/dev +git worktree add -b pr--fix origin/pr- ``` -进入临时目录后再执行: +进入临时目录后,先把最新 `dev` 合到 PR 分支里,显式暴露冲突: ```powershell -git merge --no-ff --no-commit origin/pr- +git merge --no-ff --no-commit origin/dev ``` 处理规则: 1. 如果阶段 1 发生过 `master -> dev` 自动转向,本阶段必须以 `dev` 为唯一合并基准,不允许再回到 `master` 做本地吸收。 -2. 如果出现冲突,先解决冲突,再继续检查相关联逻辑。 +2. 如果出现冲突,先在 PR 分支上解决冲突,再继续检查相关联逻辑。 3. 不能只消掉冲突标记就结束,必须继续看是否有设计冲突、状态字段不一致、调用链断裂、配置项名不一致、回调逻辑互相打架的问题。 -4. 如果 PR 本身逻辑有 bug,而用户又明确要求继续合并,AI 需要直接在本地修正。 -5. 修正时要清理无用旧代码,避免留下史山。 -6. 如果改了 SQL,按仓库规则同步本地 MySQL `xzs`。 +4. 如果 PR 本身逻辑有 bug,而用户又明确要求继续合并,AI 需要直接修改 PR 分支代码并修正。 +5. 修正完成后,必须把修改提交回 PR 分支并推送到远端;如果没有权限推送到 PR 来源分支,必须立刻停止并明确告知用户,不能假装 PR 已修好。 +6. 推送完成后,必须重新拉取一次实时 PR 元数据与最新 diff,确认线上 PR 已包含本次修复,再决定是否继续合并。 +7. 修正时要清理无用旧代码,避免留下史山。 +8. 如果改了 SQL,按仓库规则同步本地 MySQL `xzs`。 -### 阶段 4:本地修复后的自检清单 +### 阶段 4:PR 修复推送后的自检清单 -完成临时合并与本地修复后,必须自检下面这些项目: +完成 PR 分支修复并推送后,必须自检下面这些项目: 1. `git status` 中不能再有未处理冲突。 2. 相关文件中不能残留冲突标记。 3. 新旧逻辑之间不能出现字段名、消息名、步骤编号、状态名不一致。 4. 需要检查与本次功能直接相关的上下游代码,不能只改当前冲突文件。 -5. 要重新查看最终 diff,确认本地修复没有引入明显回归。 +5. 要重新查看最终线上 diff,确认修复后的 PR 没有引入明显回归。 6. 要重新拉取一次实时 PR 元数据,确认该 PR 的 `baseRefName` 仍然是 `dev`;如果不是,停止后续合并并先反馈用户。 -7. 默认不编译测试,最后提醒用户自行测试。 +7. 要确认 PR 当前已经回到可继续处理的状态,例如不再是 `draft`,且 `mergeable` / `mergeStateStatus` 没有出现新的阻塞。 +8. 默认不编译测试,最后提醒用户自行测试。 ## 合并提交信息规则 @@ -212,6 +230,8 @@ git merge --no-ff --no-commit origin/pr- 必须重新分析“最终合并结果到底做了什么”,然后重写提交信息。 +这些规则同样适用于最终执行 `gh pr merge` 时使用的标题与正文。 + ### 提交信息要求 1. 标题必须描述最终落地功能,不是描述 Git 动作。 @@ -243,9 +263,23 @@ feat: support SUB2API mode for OAuth generation and callback handling ## 最终合并与收尾 -如果本地已经解决该 PR 的内容,并且最终代码已经进入 `dev` 分支,则继续执行下面动作。 +如果 PR 分支上的冲突 / 问题已经修好,并且复检确认该 PR 可以继续合并到 `dev`,则继续执行下面动作。 -### 1. 感谢作者 +### 1. 合并 PR + +优先直接合并这个 PR,而不是只在本地偷偷吸收代码后手工关闭: + +```powershell +gh pr merge --merge --subject "" --body "<BODY>" --repo <OWNER/REPO> +``` + +要求: + +1. `--subject` 与 `--body` 必须遵守上面的“合并提交信息规则”。 +2. 合并前必须再次确认 PR 仍然是打开状态、目标分支仍然是 `dev`、线上 diff 已包含你刚刚推送的修复。 +3. 如果合并时发现新的冲突、状态检查阻塞或权限问题,必须停止并把真实阻塞原因反馈给用户。 + +### 2. 感谢作者 此时需要自动给 PR 留一条感谢评论。 @@ -263,7 +297,7 @@ feat: support SUB2API mode for OAuth generation and callback handling 感谢贡献这次改动,核心思路和主体实现已经吸收进 dev 分支了。我这边补了一下合并过程里的冲突和相关修正,后续如果你还有类似改进也欢迎继续提交。 ``` -### 2. 自动关闭 PR +### 3. 自动关闭 PR 如果 PR 还没有因为合并而自动关闭,则感谢评论发完后,自动关闭该 PR: @@ -273,7 +307,7 @@ gh pr close <PR_NUMBER> --repo <OWNER/REPO> 如果需要,先评论再关闭,不要把感谢遗漏掉。 -### 3. 评论编码复检 +### 4. 评论编码复检 无论是问题评论、感谢评论,还是其他直接发到 GitHub 的回复,只要消息已经发出,AI 必须执行一次“发送后复检”: @@ -291,11 +325,14 @@ AI 在对当前用户做最终反馈时,至少要说明: 3. 是否已经执行 `master -> dev` 自动转向 4. 是否发现重复的 `dev` PR 5. 是否发现问题 -6. 是否已经发了 PR 评论 -7. 是否已经本地合并并修复 -8. 是否已经关闭 PR -9. 如果改了代码但没跑测试,要明确提醒用户测试 +6. 是否已经给 PR 添加 `审查中` 标签 +7. 是否已经发了 PR 评论 / 行级代码评论 +8. 是否已经要求 PR 修改后再继续 +9. 是否已经修改 PR 分支并推回远端 +10. 是否已经完成 PR 合并 +11. 是否已经关闭 PR +12. 如果改了代码但没跑测试,要明确提醒用户测试 ## 给 AI 的一句话执行要求 -拿到本文件后,AI 必须按“先真实读取 PR 元数据,先校正目标分支,再拉取最新 diff 做分析,再按用户要求决定是否进入临时合并修复,最后重写提交信息并在完成后感谢作者、关闭 PR”的顺序执行,不能跳步,不能猜,不能偷懒。 +拿到本文件后,AI 必须按“先真实读取 PR 元数据,先校正目标分支,再拉取最新 diff,在正式审查开始时先给 PR 打上 `审查中` 标签并确认已生效;如果审核不通过,就在对应代码附件上评论并明确要求更改;如果用户要求继续处理冲突和问题,就先修好并推回 PR 分支,再按规则合并 PR,最后感谢作者、关闭 PR”的顺序执行,不能跳步,不能猜,不能偷懒。 From b1d5d2d8d41eee9eca05b5f35964e8dceb8d21e3 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Tue, 14 Apr 2026 23:50:15 +0800 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E7=AC=AC?= =?UTF-8?q?=E5=85=AD=E4=B8=83=E6=AD=A5=E7=99=BB=E5=BD=95=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E7=A0=81=E8=81=8C=E8=B4=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 16 +- background.js | 282 +++++++++------- content/signup-page.js | 711 ++++++++++++++++++++++++++++++++--------- 3 files changed, 754 insertions(+), 255 deletions(-) diff --git a/README.md b/README.md index 87d60c3..b6aca3f 100644 --- a/README.md +++ b/README.md @@ -492,13 +492,27 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。 在登录前会先重新获取一遍最新的 CPA OAuth 链接,再使用刚注册的账号登录。 +当前 Step 6 的完成标准不是“邮箱/密码已提交”,而是: + +- 认证页已经真正进入登录验证码页面 +- 如遇登录超时报错或登录页长时间停滞,会由后台刷新 OAuth 后重跑整个 Step 6 + 支持: - 邮箱 + 密码登录 -- 提交后进入验证码验证流程 +- 必要时切换到一次性验证码登录 +- 直到登录验证码页就绪才算步骤完成 ### Step 7: Get Login Code +Step 7 默认要求当前认证页已经处于登录验证码页。 + +它只负责: + +- 打开邮箱并轮询登录验证码 +- 填写并提交登录验证码 +- 验证码链路失败后按有限次数回退到 Step 6 + 与 Step 4 类似,但会使用稍微不同的关键词组合去找登录验证码邮件。 ### Step 8: Manual OAuth Confirm diff --git a/background.js b/background.js index 1585fc1..8160a76 100644 --- a/background.js +++ b/background.js @@ -2368,6 +2368,10 @@ function getContentScriptResponseTimeoutMs(message) { return 30000; } + if (message.type === 'EXECUTE_STEP' && Number(message.step) === 6) { + return 75000; + } + if (message.type === 'POLL_EMAIL') { const maxAttempts = Math.max(1, Number(message.payload?.maxAttempts) || 1); const intervalMs = Math.max(0, Number(message.payload?.intervalMs) || 0); @@ -2851,50 +2855,23 @@ function isVerificationMailPollingError(error) { return /未在 .*邮箱中找到新的匹配邮件|未在 Hotmail 收件箱中找到新的匹配验证码|邮箱轮询结束,但未获取到验证码|无法获取新的(?:注册|登录)验证码|页面未能重新就绪|页面通信异常|did not respond in \d+s/i.test(message); } -const STEP7_RESTART_FROM_STEP6_ERROR_CODE = 'STEP7_RESTART_FROM_STEP6'; -const STEP7_RESTART_FROM_STEP6_MARKER_PATTERN = /^STEP7_RESTART_FROM_STEP6::([^:]+)::(.*)$/; - -function createStep7RestartFromStep6Error(details = {}) { - const { reason = 'unknown', url = '' } = details || {}; - const reasonLabel = reason === 'login_timeout_error_page' - ? '检测到登录页超时报错' - : '步骤 7 请求回到步骤 6'; - const error = new Error(`步骤 7:${reasonLabel}。${url ? `URL: ${url}` : ''}`.trim()); - error.code = STEP7_RESTART_FROM_STEP6_ERROR_CODE; - error.restartReason = reason; - error.restartUrl = url; - return error; -} - -function parseStep7RestartFromStep6Marker(message) { - const normalized = getErrorMessage(message); - const match = normalized.match(STEP7_RESTART_FROM_STEP6_MARKER_PATTERN); - if (!match) { - return null; +function getLoginAuthStateLabel(state) { + switch (state) { + case 'verification_page': + return '登录验证码页'; + case 'password_page': + return '密码页'; + case 'email_page': + return '邮箱输入页'; + case 'login_timeout_error_page': + return '登录超时报错页'; + case 'oauth_consent_page': + return 'OAuth 授权页'; + case 'add_phone_page': + return '手机号页'; + default: + return '未知页面'; } - - return { - reason: match[1] || 'unknown', - url: match[2] || '', - }; -} - -function getStep7RestartFromStep6Error(result) { - if (result?.restartFromStep6) { - return createStep7RestartFromStep6Error(result); - } - - const parsed = parseStep7RestartFromStep6Marker(result?.error); - if (!parsed) { - return null; - } - - return createStep7RestartFromStep6Error(parsed); -} - -function isStep7RestartFromStep6Error(error) { - return error?.code === STEP7_RESTART_FROM_STEP6_ERROR_CODE - || Boolean(parseStep7RestartFromStep6Marker(error)); } function isRestartCurrentAttemptError(error) { @@ -3886,8 +3863,8 @@ async function handleStepData(step, payload) { const stepWaiters = new Map(); let resumeWaiter = null; const AUTO_RUN_SIGNAL_COMPLETION_TIMEOUT_MS = 120000; -const AUTO_RUN_BACKGROUND_COMPLETED_STEPS = new Set([4, 7, 8]); -const STEP_COMPLETION_SIGNAL_STEPS = new Set([1, 2, 3, 5, 6, 9]); +const AUTO_RUN_BACKGROUND_COMPLETED_STEPS = new Set([4, 6, 7, 8]); +const STEP_COMPLETION_SIGNAL_STEPS = new Set([1, 2, 3, 5, 9]); function waitForStepComplete(step, timeoutMs = 120000) { return new Promise((resolve, reject) => { @@ -5710,17 +5687,15 @@ async function requestVerificationCodeResend(step) { payload: {}, }); - if (step === 7) { - const restartError = getStep7RestartFromStep6Error(result); - if (restartError) { - throw restartError; - } - } - if (result && result.error) { throw new Error(result.error); } + const requestedAt = Date.now(); + if (step === 7) { + await setState({ loginVerificationRequestedAt: requestedAt }); + } + const currentState = await getState(); if (currentState.mailProvider === '2925') { const mailTabId = await getTabId('mail-2925'); @@ -5730,16 +5705,18 @@ async function requestVerificationCodeResend(step) { } } - return Date.now(); + return requestedAt; } async function pollFreshVerificationCode(step, state, mail, pollOverrides = {}) { + const { onResendRequestedAt, ...cleanPollOverrides } = pollOverrides; + if (mail.provider === HOTMAIL_PROVIDER) { const hotmailPollConfig = getHotmailVerificationPollConfig(step); return pollHotmailVerificationCode(step, state, { ...getVerificationPollPayload(step, state), ...hotmailPollConfig, - ...pollOverrides, + ...cleanPollOverrides, }); } if (mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER) { @@ -5763,17 +5740,23 @@ async function pollFreshVerificationCode(step, state, mail, pollOverrides = {}) } let lastError = null; - const filterAfterTimestamp = pollOverrides.filterAfterTimestamp ?? getVerificationPollPayload(step, state).filterAfterTimestamp; + let filterAfterTimestamp = cleanPollOverrides.filterAfterTimestamp ?? getVerificationPollPayload(step, state).filterAfterTimestamp; const maxRounds = pollOverrides.maxRounds || VERIFICATION_POLL_MAX_ROUNDS; for (let round = 1; round <= maxRounds; round++) { throwIfStopped(); if (round > 1) { - await requestVerificationCodeResend(step); + const requestedAt = await requestVerificationCodeResend(step); + if (typeof onResendRequestedAt === 'function') { + const nextFilterAfterTimestamp = await onResendRequestedAt(requestedAt); + if (nextFilterAfterTimestamp !== undefined) { + filterAfterTimestamp = nextFilterAfterTimestamp; + } + } } const payload = getVerificationPollPayload(step, state, { - ...pollOverrides, + ...cleanPollOverrides, filterAfterTimestamp, excludeCodes: [...rejectedCodes], }); @@ -5835,10 +5818,14 @@ async function pollFreshVerificationCodeWithResendInterval(step, state, mail, po maxRounds: _ignoredMaxRounds, resendIntervalMs: _ignoredResendIntervalMs, lastResendAt: _ignoredLastResendAt, + onResendRequestedAt: _ignoredOnResendRequestedAt, ...payloadOverrides } = pollOverrides; + const onResendRequestedAt = typeof pollOverrides.onResendRequestedAt === 'function' + ? pollOverrides.onResendRequestedAt + : null; let lastError = null; - const filterAfterTimestamp = payloadOverrides.filterAfterTimestamp ?? getVerificationPollPayload(step, state).filterAfterTimestamp; + let filterAfterTimestamp = payloadOverrides.filterAfterTimestamp ?? getVerificationPollPayload(step, state).filterAfterTimestamp; const maxRounds = pollOverrides.maxRounds || VERIFICATION_POLL_MAX_ROUNDS; const resendIntervalMs = Math.max(0, Number(pollOverrides.resendIntervalMs) || 0); let lastResendAt = Number(pollOverrides.lastResendAt) || 0; @@ -5847,6 +5834,12 @@ async function pollFreshVerificationCodeWithResendInterval(step, state, mail, po throwIfStopped(); if (round > 1) { lastResendAt = await requestVerificationCodeResend(step); + if (onResendRequestedAt) { + const nextFilterAfterTimestamp = await onResendRequestedAt(lastResendAt); + if (nextFilterAfterTimestamp !== undefined) { + filterAfterTimestamp = nextFilterAfterTimestamp; + } + } } while (true) { @@ -5938,13 +5931,6 @@ async function submitVerificationCode(step, code) { payload: { code }, }); - if (step === 7) { - const restartError = getStep7RestartFromStep6Error(result); - if (restartError) { - throw restartError; - } - } - if (result && result.error) { throw new Error(result.error); } @@ -5963,7 +5949,7 @@ async function resolveVerificationStep(step, state, mail, options = {}) { rejectedCodes.add(state[stateKey]); } - const nextFilterAfterTimestamp = options.filterAfterTimestamp ?? null; + let nextFilterAfterTimestamp = options.filterAfterTimestamp ?? null; const requestFreshCodeFirst = options.requestFreshCodeFirst !== undefined ? Boolean(options.requestFreshCodeFirst) : (hotmailPollConfig?.requestFreshCodeFirst ?? false); @@ -5971,12 +5957,30 @@ async function resolveVerificationStep(step, state, mail, options = {}) { const resendIntervalMs = Math.max(0, Number(options.resendIntervalMs) || 0); let lastResendAt = Number(options.lastResendAt) || 0; + const updateFilterAfterTimestampForStep7 = async (requestedAt) => { + if (step !== 7 || !requestedAt) { + return nextFilterAfterTimestamp; + } + + if (mail.provider === HOTMAIL_PROVIDER) { + nextFilterAfterTimestamp = getHotmailVerificationRequestTimestamp(7, { + ...state, + loginVerificationRequestedAt: requestedAt, + }); + } else { + nextFilterAfterTimestamp = Math.max(0, Number(requestedAt) - 60000); + } + + return nextFilterAfterTimestamp; + }; + if (requestFreshCodeFirst) { try { lastResendAt = await requestVerificationCodeResend(step); + await updateFilterAfterTimestampForStep7(lastResendAt); await addLog(`步骤 ${step}:已先请求一封新的${getVerificationCodeLabel(step)}验证码,再开始轮询邮箱。`, 'warn'); } catch (err) { - if (isStopError(err) || (step === 7 && isStep7RestartFromStep6Error(err))) { + if (isStopError(err)) { throw err; } await addLog(`步骤 ${step}:首次重新获取验证码失败:${err.message},将继续使用当前时间窗口轮询。`, 'warn'); @@ -5997,6 +6001,7 @@ async function resolveVerificationStep(step, state, mail, options = {}) { filterAfterTimestamp: nextFilterAfterTimestamp ?? undefined, resendIntervalMs, lastResendAt, + onResendRequestedAt: updateFilterAfterTimestampForStep7, }); lastResendAt = Number(result?.lastResendAt) || lastResendAt; @@ -6025,6 +6030,7 @@ async function resolveVerificationStep(step, state, mail, options = {}) { } lastResendAt = await requestVerificationCodeResend(step); + await updateFilterAfterTimestampForStep7(lastResendAt); await addLog(`步骤 ${step}:提交失败后已请求新验证码(${attempt + 1}/${maxSubmitAttempts})...`, 'warn'); continue; } @@ -6072,9 +6078,6 @@ async function executeStep4(state) { if (prepareResult && prepareResult.error) { throw new Error(prepareResult.error); } - if (prepareResult?.verificationRequestedAt) { - await setState({ loginVerificationRequestedAt: prepareResult.verificationRequestedAt }); - } if (prepareResult?.alreadyVerified) { await completeStepFromBackground(4, {}); return; @@ -6138,7 +6141,7 @@ async function executeStep5(state) { } // ============================================================ -// Step 6: Login ChatGPT (Background opens tab, chatgpt.js handles login) +// Step 6: Login and ensure the auth page reaches the login verification page // ============================================================ async function refreshOAuthUrlBeforeStep6(state) { @@ -6159,28 +6162,110 @@ async function refreshOAuthUrlBeforeStep6(state) { return latestState.oauthUrl; } +function isStep6SuccessResult(result) { + return result?.step6Outcome === 'success'; +} + +function isStep6RecoverableResult(result) { + return result?.step6Outcome === 'recoverable'; +} + +async function getLoginAuthStateFromContent() { + const result = await sendToContentScriptResilient( + 'signup-page', + { + type: 'GET_LOGIN_AUTH_STATE', + source: 'background', + payload: {}, + }, + { + timeoutMs: 15000, + retryDelayMs: 600, + logMessage: '步骤 7:认证页正在切换,等待页面重新就绪后继续确认验证码页状态...', + } + ); + + if (result?.error) { + throw new Error(result.error); + } + + return result || {}; +} + +async function ensureStep7VerificationPageReady() { + const pageState = await getLoginAuthStateFromContent(); + if (pageState.state === 'verification_page') { + return pageState; + } + + const stateLabel = getLoginAuthStateLabel(pageState.state); + const urlPart = pageState.url ? ` URL: ${pageState.url}` : ''; + throw new Error(`当前未进入登录验证码页面,请先重新完成步骤 6。当前状态:${stateLabel}.${urlPart}`.trim()); +} + async function executeStep6(state) { if (!state.email) { throw new Error('缺少邮箱地址,请先完成步骤 3。'); } + let attempt = 0; - const oauthUrl = await refreshOAuthUrlBeforeStep6(state); + while (true) { + throwIfStopped(); + attempt += 1; + const currentState = attempt === 1 ? state : await getState(); + const password = currentState.password || currentState.customPassword || ''; + const oauthUrl = await refreshOAuthUrlBeforeStep6(currentState); - await addLog('步骤 6:正在打开最新 OAuth 链接并登录...'); - // Reuse the signup-page tab — navigate it to the OAuth URL - await reuseOrCreateTab('signup-page', oauthUrl); + if (attempt === 1) { + await addLog('步骤 6:正在打开最新 OAuth 链接并登录...'); + } else { + await addLog(`步骤 6:上一轮登录未进入验证码页,正在重新发起第 ${attempt} 轮登录尝试...`, 'warn'); + } - // signup-page.js will inject (same auth.openai.com domain) and handle login - await sendToContentScript('signup-page', { - type: 'EXECUTE_STEP', - step: 6, - source: 'background', - payload: { email: state.email, password: state.password }, - }); + await reuseOrCreateTab('signup-page', oauthUrl); + + const result = await sendToContentScriptResilient( + 'signup-page', + { + type: 'EXECUTE_STEP', + step: 6, + source: 'background', + payload: { + email: currentState.email, + password, + }, + }, + { + timeoutMs: 180000, + retryDelayMs: 700, + logMessage: '步骤 6:认证页正在切换,等待页面重新就绪后继续登录...', + } + ); + + if (result?.error) { + throw new Error(result.error); + } + + if (isStep6SuccessResult(result)) { + await completeStepFromBackground(6, { + loginVerificationRequestedAt: result.loginVerificationRequestedAt || null, + }); + return; + } + + if (isStep6RecoverableResult(result)) { + const reasonMessage = result.message + || `当前停留在${getLoginAuthStateLabel(result.state)},准备重新执行步骤 6。`; + await addLog(`步骤 6:${reasonMessage}`, 'warn'); + continue; + } + + throw new Error('步骤 6:认证页未返回可识别的登录结果。'); + } } // ============================================================ -// Step 7: Get Login Verification Code (qq-mail.js polls, then fills in chatgpt.js) +// Step 7: Poll login verification mail and submit the login code // ============================================================ async function runStep7Attempt(state) { @@ -6199,22 +6284,8 @@ async function runStep7Attempt(state) { } throwIfStopped(); - await addLog('步骤 7:正在准备认证页,必要时切换到一次性验证码登录...'); - const prepareResult = await sendToContentScript('signup-page', { - type: 'PREPARE_LOGIN_CODE', - step: 7, - source: 'background', - payload: {}, - }); - - const restartError = getStep7RestartFromStep6Error(prepareResult); - if (restartError) { - throw restartError; - } - - if (prepareResult && prepareResult.error) { - throw new Error(prepareResult.error); - } + await ensureStep7VerificationPageReady(); + await addLog('步骤 7:登录验证码页面已就绪,开始获取验证码。', 'info'); if (shouldUseCustomRegistrationEmail(state)) { await confirmCustomVerificationStepBypass(7); @@ -6247,18 +6318,16 @@ async function runStep7Attempt(state) { } await resolveVerificationStep(7, state, mail, { - filterAfterTimestamp: mail.provider === HOTMAIL_PROVIDER ? undefined : stepStartedAt, - requestFreshCodeFirst: mail.provider === HOTMAIL_PROVIDER ? false : true, + filterAfterTimestamp: mail.provider === HOTMAIL_PROVIDER ? undefined : Math.max(0, stepStartedAt - 60000), + requestFreshCodeFirst: false, resendIntervalMs: mail.provider === HOTMAIL_PROVIDER ? 0 : STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS, }); } async function rerunStep6ForStep7Recovery() { const currentState = await getState(); - const waitForStep6 = waitForStepComplete(6, 120000); await addLog('步骤 7:正在回到步骤 6,重新发起登录验证码流程...', 'warn'); await executeStep6(currentState); - await waitForStep6; await sleepWithStop(3000); } @@ -6272,13 +6341,6 @@ async function executeStep7(state) { await runStep7Attempt(currentState); return; } catch (err) { - if (isStep7RestartFromStep6Error(err)) { - await addLog('步骤 7:检测到登录页超时报错,准备从步骤 6 重新开始...', 'warn'); - await rerunStep6ForStep7Recovery(); - currentState = await getState(); - continue; - } - if (!isVerificationMailPollingError(err)) { throw err; } diff --git a/content/signup-page.js b/content/signup-page.js index ea2de6b..6c56621 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -11,7 +11,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { || message.type === 'STEP8_FIND_AND_CLICK' || message.type === 'STEP8_GET_STATE' || message.type === 'STEP8_TRIGGER_CONTINUE' - || message.type === 'PREPARE_LOGIN_CODE' + || message.type === 'GET_LOGIN_AUTH_STATE' || message.type === 'PREPARE_SIGNUP_VERIFICATION' || message.type === 'RESEND_VERIFICATION_CODE' ) { @@ -52,10 +52,10 @@ async function handleCommand(message) { case 'FILL_CODE': // Step 4 = signup code, Step 7 = login code (same handler) return await fillVerificationCode(message.step, message.payload); + case 'GET_LOGIN_AUTH_STATE': + return serializeLoginAuthState(inspectLoginAuthState()); case 'PREPARE_SIGNUP_VERIFICATION': return await prepareSignupVerificationFlow(message.payload); - case 'PREPARE_LOGIN_CODE': - return await prepareLoginCodeFlow(); case 'RESEND_VERIFICATION_CODE': return await resendVerificationCode(message.step); case 'STEP8_FIND_AND_CLICK': @@ -176,86 +176,9 @@ function isEmailVerificationPage() { return /\/email-verification(?:[/?#]|$)/i.test(location.pathname || ''); } -async function prepareLoginCodeFlow(timeout = 15000) { - const readyTarget = getVerificationCodeTarget(); - if (readyTarget) { - log('步骤 7:验证码输入框已就绪。'); - return { ready: true, mode: readyTarget.type }; - } - - if (isEmailVerificationPage() && isVerificationPageStillVisible()) { - log('步骤 7:已进入邮箱验证码页面,正在等待验证码输入框或重发入口稳定。'); - return { ready: true, mode: 'verification_page' }; - } - - const initialRestartSignal = getStep7RestartFromStep6Signal(); - if (initialRestartSignal) { - log('步骤 7:检测到登录页超时报错,准备回到步骤 6 重新发起登录验证码流程...', 'warn'); - return initialRestartSignal; - } - - const start = Date.now(); - let switchClickCount = 0; - let lastSwitchAttemptAt = 0; - let loggedPasswordPage = false; - let loggedVerificationPage = false; - - while (Date.now() - start < timeout) { - throwIfStopped(); - - const target = getVerificationCodeTarget(); - if (target) { - log('步骤 7:验证码页面已就绪。'); - return { ready: true, mode: target.type }; - } - - if (isEmailVerificationPage() && isVerificationPageStillVisible()) { - if (!loggedVerificationPage) { - loggedVerificationPage = true; - log('步骤 7:页面已进入邮箱验证码流程,继续等待验证码输入框渲染...'); - } - await sleep(250); - continue; - } - - const restartSignal = getStep7RestartFromStep6Signal(); - if (restartSignal) { - log('步骤 7:检测到登录页超时报错,准备回到步骤 6 重新发起登录验证码流程...', 'warn'); - return restartSignal; - } - - const passwordInput = document.querySelector('input[type="password"]'); - const switchTrigger = findOneTimeCodeLoginTrigger(); - - if (switchTrigger && (switchClickCount === 0 || Date.now() - lastSwitchAttemptAt > 1500)) { - switchClickCount += 1; - lastSwitchAttemptAt = Date.now(); - loggedPasswordPage = false; - log('步骤 7:检测到密码页,正在切换到一次性验证码登录...'); - await humanPause(350, 900); - const verificationRequestedAt = Date.now(); - simulateClick(switchTrigger); - await sleep(1200); - return { ready: true, mode: 'verification_switch', verificationRequestedAt }; - } - - if (passwordInput && !loggedPasswordPage) { - loggedPasswordPage = true; - log('步骤 7:正在等待密码页上的一次性验证码登录入口...'); - } - - await sleep(200); - } - - throw new Error('无法切换到一次性验证码验证页面。URL: ' + location.href); -} - async function resendVerificationCode(step, timeout = 45000) { if (step === 7) { - const prepareResult = await prepareLoginCodeFlow(); - if (prepareResult?.restartFromStep6) { - return prepareResult; - } + await waitForLoginVerificationPageReady(); } const start = Date.now(); @@ -730,26 +653,248 @@ function getLoginTimeoutErrorPageState() { }); } -function isSignupPasswordErrorPage() { - return Boolean(getSignupPasswordTimeoutErrorPageState()); +function getLoginEmailInput() { + const input = document.querySelector( + 'input[type="email"], input[name="email"], input[name="username"], input[id*="email"], input[placeholder*="email" i], input[placeholder*="Email"]' + ); + return input && isVisibleElement(input) ? input : null; } -function buildStep7RestartFromStep6Marker(reason, url = location.href) { - return `STEP7_RESTART_FROM_STEP6::${reason || 'unknown'}::${url || ''}`; +function getLoginPasswordInput() { + const input = document.querySelector('input[type="password"]'); + return input && isVisibleElement(input) ? input : null; } -function getStep7RestartFromStep6Signal() { - const timeoutPage = getLoginTimeoutErrorPageState(); - if (!timeoutPage) { - return null; +function getLoginSubmitButton({ allowDisabled = false } = {}) { + const direct = document.querySelector('button[type="submit"], input[type="submit"]'); + if (direct && isVisibleElement(direct) && (allowDisabled || isActionEnabled(direct))) { + return direct; } - return { - error: buildStep7RestartFromStep6Marker('login_timeout_error_page', timeoutPage.url), - restartFromStep6: true, - reason: 'login_timeout_error_page', - url: timeoutPage.url, + const candidates = document.querySelectorAll( + 'button, a, [role="button"], [role="link"], input[type="button"], input[type="submit"]' + ); + return Array.from(candidates).find((el) => { + if (!isVisibleElement(el) || (!allowDisabled && !isActionEnabled(el))) return false; + const text = getActionText(el); + if (!text || ONE_TIME_CODE_LOGIN_PATTERN.test(text)) return false; + return /continue|next|submit|sign\s*in|log\s*in|继续|下一步|登录/i.test(text); + }) || null; +} + +function inspectLoginAuthState() { + const retryState = getLoginTimeoutErrorPageState(); + const verificationTarget = getVerificationCodeTarget(); + const passwordInput = getLoginPasswordInput(); + const emailInput = getLoginEmailInput(); + const switchTrigger = findOneTimeCodeLoginTrigger(); + const submitButton = getLoginSubmitButton({ allowDisabled: true }); + const verificationVisible = isVerificationPageStillVisible(); + const addPhonePage = isAddPhonePageReady(); + const consentReady = isStep8Ready(); + const oauthConsentPage = isOAuthConsentPage(); + const baseState = { + state: 'unknown', + url: location.href, + path: location.pathname || '', + retryButton: retryState?.retryButton || null, + retryEnabled: Boolean(retryState?.retryEnabled), + titleMatched: Boolean(retryState?.titleMatched), + detailMatched: Boolean(retryState?.detailMatched), + verificationTarget, + passwordInput, + emailInput, + submitButton, + switchTrigger, + verificationVisible, + addPhonePage, + oauthConsentPage, + consentReady, }; + + if (verificationTarget || verificationVisible) { + return { + ...baseState, + state: 'verification_page', + }; + } + + if (retryState) { + return { + ...baseState, + state: 'login_timeout_error_page', + }; + } + + if (addPhonePage) { + return { + ...baseState, + state: 'add_phone_page', + }; + } + + if (oauthConsentPage) { + return { + ...baseState, + state: 'oauth_consent_page', + }; + } + + if (passwordInput || switchTrigger) { + return { + ...baseState, + state: 'password_page', + }; + } + + if (emailInput) { + return { + ...baseState, + state: 'email_page', + }; + } + + return baseState; +} + +function serializeLoginAuthState(snapshot) { + return { + state: snapshot?.state || 'unknown', + url: snapshot?.url || location.href, + path: snapshot?.path || location.pathname || '', + retryEnabled: Boolean(snapshot?.retryEnabled), + titleMatched: Boolean(snapshot?.titleMatched), + detailMatched: Boolean(snapshot?.detailMatched), + hasVerificationTarget: Boolean(snapshot?.verificationTarget), + hasPasswordInput: Boolean(snapshot?.passwordInput), + hasEmailInput: Boolean(snapshot?.emailInput), + hasSubmitButton: Boolean(snapshot?.submitButton), + hasSwitchTrigger: Boolean(snapshot?.switchTrigger), + verificationVisible: Boolean(snapshot?.verificationVisible), + addPhonePage: Boolean(snapshot?.addPhonePage), + oauthConsentPage: Boolean(snapshot?.oauthConsentPage), + consentReady: Boolean(snapshot?.consentReady), + }; +} + +function getLoginAuthStateLabel(snapshot) { + switch (snapshot?.state) { + case 'verification_page': + return '登录验证码页'; + case 'password_page': + return '密码页'; + case 'email_page': + return '邮箱输入页'; + case 'login_timeout_error_page': + return '登录超时报错页'; + case 'oauth_consent_page': + return 'OAuth 授权页'; + case 'add_phone_page': + return '手机号页'; + default: + return '未知页面'; + } +} + +async function waitForKnownLoginAuthState(timeout = 15000) { + const start = Date.now(); + let snapshot = inspectLoginAuthState(); + + while (Date.now() - start < timeout) { + throwIfStopped(); + snapshot = inspectLoginAuthState(); + if (snapshot.state !== 'unknown') { + return snapshot; + } + await sleep(200); + } + + return snapshot; +} + +async function waitForLoginVerificationPageReady(timeout = 10000) { + const start = Date.now(); + let snapshot = inspectLoginAuthState(); + + while (Date.now() - start < timeout) { + throwIfStopped(); + snapshot = inspectLoginAuthState(); + if (snapshot.state === 'verification_page') { + return snapshot; + } + if (snapshot.state !== 'unknown') { + break; + } + await sleep(200); + } + + throw new Error( + `当前未进入登录验证码页面,请先重新完成步骤 6。当前状态:${getLoginAuthStateLabel(snapshot)}。URL: ${snapshot?.url || location.href}` + ); +} + +function createStep6SuccessResult(snapshot, options = {}) { + return { + step6Outcome: 'success', + state: snapshot?.state || 'verification_page', + url: snapshot?.url || location.href, + via: options.via || '', + loginVerificationRequestedAt: options.loginVerificationRequestedAt || null, + }; +} + +function createStep6RecoverableResult(reason, snapshot, options = {}) { + return { + step6Outcome: 'recoverable', + reason, + state: snapshot?.state || 'unknown', + url: snapshot?.url || location.href, + message: options.message || '', + loginVerificationRequestedAt: options.loginVerificationRequestedAt || null, + }; +} + +function throwForStep6FatalState(snapshot) { + switch (snapshot?.state) { + case 'oauth_consent_page': + throw new Error(`当前页面已进入 OAuth 授权页,未经过登录验证码页,无法完成步骤 6。URL: ${snapshot.url}`); + case 'add_phone_page': + throw new Error(`当前页面已进入手机号页面,未经过登录验证码页,无法完成步骤 6。URL: ${snapshot.url}`); + case 'unknown': + throw new Error(`无法识别当前登录页面状态。URL: ${snapshot?.url || location.href}`); + default: + return; + } +} + +async function triggerLoginSubmitAction(button, fallbackField) { + const form = button?.form || fallbackField?.form || button?.closest?.('form') || fallbackField?.closest?.('form') || null; + + await humanPause(400, 1100); + if (button && isActionEnabled(button)) { + simulateClick(button); + return; + } + + if (form && typeof form.requestSubmit === 'function') { + if (button && button.form === form) { + form.requestSubmit(button); + } else { + form.requestSubmit(); + } + return; + } + + if (button && typeof button.click === 'function') { + button.click(); + return; + } + + throw new Error('未找到可用的登录提交按钮。URL: ' + location.href); +} + +function isSignupPasswordErrorPage() { + return Boolean(getSignupPasswordTimeoutErrorPageState()); } function isSignupEmailAlreadyExistsPage() { @@ -922,10 +1067,7 @@ async function fillVerificationCode(step, payload) { log(`步骤 ${step}:正在填写验证码:${code}`); if (step === 7) { - const prepareResult = await prepareLoginCodeFlow(); - if (prepareResult?.restartFromStep6) { - return prepareResult; - } + await waitForLoginVerificationPageReady(); } // Find code input — could be a single input or multiple separate inputs @@ -986,63 +1128,344 @@ async function fillVerificationCode(step, payload) { // Step 6: Login with registered account (on OAuth auth page) // ============================================================ +async function waitForStep6EmailSubmitTransition(emailSubmittedAt, timeout = 12000) { + const start = Date.now(); + let snapshot = inspectLoginAuthState(); + + while (Date.now() - start < timeout) { + throwIfStopped(); + snapshot = inspectLoginAuthState(); + + if (snapshot.state === 'verification_page') { + return { + action: 'done', + result: createStep6SuccessResult(snapshot, { + via: 'email_submit', + loginVerificationRequestedAt: emailSubmittedAt, + }), + }; + } + + if (snapshot.state === 'password_page') { + return { action: 'password', snapshot }; + } + + if (snapshot.state === 'login_timeout_error_page') { + return { + action: 'recoverable', + result: createStep6RecoverableResult('login_timeout_error_page', snapshot, { + message: '提交邮箱后进入登录超时报错页。', + }), + }; + } + + if (snapshot.state === 'oauth_consent_page') { + throw new Error(`提交邮箱后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`); + } + + if (snapshot.state === 'add_phone_page') { + throw new Error(`提交邮箱后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`); + } + + await sleep(250); + } + + snapshot = inspectLoginAuthState(); + if (snapshot.state === 'verification_page') { + return { + action: 'done', + result: createStep6SuccessResult(snapshot, { + via: 'email_submit', + loginVerificationRequestedAt: emailSubmittedAt, + }), + }; + } + if (snapshot.state === 'password_page') { + return { action: 'password', snapshot }; + } + if (snapshot.state === 'login_timeout_error_page') { + return { + action: 'recoverable', + result: createStep6RecoverableResult('login_timeout_error_page', snapshot, { + message: '提交邮箱后进入登录超时报错页。', + }), + }; + } + if (snapshot.state === 'oauth_consent_page') { + throw new Error(`提交邮箱后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`); + } + if (snapshot.state === 'add_phone_page') { + throw new Error(`提交邮箱后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`); + } + + return { + action: 'recoverable', + result: createStep6RecoverableResult('email_submit_stalled', snapshot, { + message: '提交邮箱后长时间未进入密码页或登录验证码页。', + }), + }; +} + +async function waitForStep6PasswordSubmitTransition(passwordSubmittedAt, timeout = 10000) { + const start = Date.now(); + let snapshot = inspectLoginAuthState(); + + while (Date.now() - start < timeout) { + throwIfStopped(); + snapshot = inspectLoginAuthState(); + + if (snapshot.state === 'verification_page') { + return { + action: 'done', + result: createStep6SuccessResult(snapshot, { + via: 'password_submit', + loginVerificationRequestedAt: passwordSubmittedAt, + }), + }; + } + + if (snapshot.state === 'login_timeout_error_page') { + return { + action: 'recoverable', + result: createStep6RecoverableResult('login_timeout_error_page', snapshot, { + message: '提交密码后进入登录超时报错页。', + }), + }; + } + + if (snapshot.state === 'oauth_consent_page') { + throw new Error(`提交密码后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`); + } + + if (snapshot.state === 'add_phone_page') { + throw new Error(`提交密码后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`); + } + + await sleep(250); + } + + snapshot = inspectLoginAuthState(); + if (snapshot.state === 'verification_page') { + return { + action: 'done', + result: createStep6SuccessResult(snapshot, { + via: 'password_submit', + loginVerificationRequestedAt: passwordSubmittedAt, + }), + }; + } + if (snapshot.state === 'login_timeout_error_page') { + return { + action: 'recoverable', + result: createStep6RecoverableResult('login_timeout_error_page', snapshot, { + message: '提交密码后进入登录超时报错页。', + }), + }; + } + if (snapshot.state === 'oauth_consent_page') { + throw new Error(`提交密码后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`); + } + if (snapshot.state === 'add_phone_page') { + throw new Error(`提交密码后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`); + } + if (snapshot.state === 'password_page' && snapshot.switchTrigger) { + return { action: 'switch', snapshot }; + } + + return { + action: 'recoverable', + result: createStep6RecoverableResult('password_submit_stalled', snapshot, { + message: '提交密码后仍未进入登录验证码页。', + }), + }; +} + +async function waitForStep6SwitchTransition(loginVerificationRequestedAt, timeout = 10000) { + const start = Date.now(); + let snapshot = inspectLoginAuthState(); + + while (Date.now() - start < timeout) { + throwIfStopped(); + snapshot = inspectLoginAuthState(); + + if (snapshot.state === 'verification_page') { + return createStep6SuccessResult(snapshot, { + via: 'switch_to_one_time_code_login', + loginVerificationRequestedAt, + }); + } + + if (snapshot.state === 'login_timeout_error_page') { + return createStep6RecoverableResult('login_timeout_error_page', snapshot, { + message: '切换到一次性验证码登录后进入登录超时报错页。', + }); + } + + if (snapshot.state === 'oauth_consent_page') { + throw new Error(`切换到一次性验证码登录后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`); + } + + if (snapshot.state === 'add_phone_page') { + throw new Error(`切换到一次性验证码登录后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`); + } + + await sleep(250); + } + + snapshot = inspectLoginAuthState(); + if (snapshot.state === 'verification_page') { + return createStep6SuccessResult(snapshot, { + via: 'switch_to_one_time_code_login', + loginVerificationRequestedAt, + }); + } + if (snapshot.state === 'login_timeout_error_page') { + return createStep6RecoverableResult('login_timeout_error_page', snapshot, { + message: '切换到一次性验证码登录后进入登录超时报错页。', + }); + } + if (snapshot.state === 'oauth_consent_page') { + throw new Error(`切换到一次性验证码登录后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`); + } + if (snapshot.state === 'add_phone_page') { + throw new Error(`切换到一次性验证码登录后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`); + } + + return createStep6RecoverableResult('one_time_code_switch_stalled', snapshot, { + message: '点击一次性验证码登录后仍未进入登录验证码页。', + }); +} + +async function step6SwitchToOneTimeCodeLogin(snapshot) { + const switchTrigger = snapshot?.switchTrigger || findOneTimeCodeLoginTrigger(); + if (!switchTrigger || !isActionEnabled(switchTrigger)) { + return createStep6RecoverableResult('missing_one_time_code_trigger', inspectLoginAuthState(), { + message: '当前登录页没有可用的一次性验证码登录入口。', + }); + } + + log('步骤 6:已检测到一次性验证码登录入口,准备切换...'); + const loginVerificationRequestedAt = Date.now(); + await humanPause(350, 900); + simulateClick(switchTrigger); + log('步骤 6:已点击一次性验证码登录'); + await sleep(1200); + return waitForStep6SwitchTransition(loginVerificationRequestedAt); +} + +async function step6LoginFromPasswordPage(payload, snapshot) { + const currentSnapshot = snapshot || inspectLoginAuthState(); + + if (currentSnapshot.passwordInput) { + if (!payload.password) { + throw new Error('登录时缺少密码,步骤 6 无法继续。'); + } + + log('步骤 6:已进入密码页,准备填写密码...'); + await humanPause(550, 1450); + fillInput(currentSnapshot.passwordInput, payload.password); + log('步骤 6:已填写密码'); + + await sleep(500); + const passwordSubmittedAt = Date.now(); + await triggerLoginSubmitAction(currentSnapshot.submitButton, currentSnapshot.passwordInput); + log('步骤 6:已提交密码'); + + const transition = await waitForStep6PasswordSubmitTransition(passwordSubmittedAt); + if (transition.action === 'done') { + log('步骤 6:已进入登录验证码页面。', 'ok'); + return transition.result; + } + if (transition.action === 'recoverable') { + log(`步骤 6:${transition.result.message || '提交密码后仍未进入登录验证码页面,准备重新执行步骤 6。'}`, 'warn'); + return transition.result; + } + if (transition.action === 'switch') { + return step6SwitchToOneTimeCodeLogin(transition.snapshot); + } + + return createStep6RecoverableResult('password_submit_unknown', inspectLoginAuthState(), { + message: '提交密码后未得到可用的下一步状态。', + }); + } + + if (currentSnapshot.switchTrigger) { + return step6SwitchToOneTimeCodeLogin(currentSnapshot); + } + + return createStep6RecoverableResult('password_page_unactionable', currentSnapshot, { + message: '当前停留在登录页,但没有可提交密码的输入框,也没有一次性验证码登录入口。', + }); +} + +async function step6LoginFromEmailPage(payload, snapshot) { + const currentSnapshot = snapshot || inspectLoginAuthState(); + const emailInput = currentSnapshot.emailInput || getLoginEmailInput(); + if (!emailInput) { + throw new Error('在登录页未找到邮箱输入框。URL: ' + location.href); + } + + if ((emailInput.value || '').trim() !== payload.email) { + await humanPause(500, 1400); + fillInput(emailInput, payload.email); + log('步骤 6:已填写邮箱'); + } else { + log('步骤 6:邮箱已在输入框中,准备提交...'); + } + + await sleep(500); + const emailSubmittedAt = Date.now(); + await triggerLoginSubmitAction(currentSnapshot.submitButton, emailInput); + log('步骤 6:已提交邮箱'); + + const transition = await waitForStep6EmailSubmitTransition(emailSubmittedAt); + if (transition.action === 'done') { + log('步骤 6:已进入登录验证码页面。', 'ok'); + return transition.result; + } + if (transition.action === 'recoverable') { + log(`步骤 6:${transition.result.message || '提交邮箱后仍未进入目标页面,准备重新执行步骤 6。'}`, 'warn'); + return transition.result; + } + if (transition.action === 'password') { + return step6LoginFromPasswordPage(payload, transition.snapshot); + } + + return createStep6RecoverableResult('email_submit_unknown', inspectLoginAuthState(), { + message: '提交邮箱后未得到可用的下一步状态。', + }); +} + async function step6_login(payload) { - const { email, password } = payload; + const { email } = payload; if (!email) throw new Error('登录时缺少邮箱地址。'); log(`步骤 6:正在使用 ${email} 登录...`); - // Wait for email input on the auth page - let emailInput = null; - try { - emailInput = await waitForElement( - 'input[type="email"], input[name="email"], input[name="username"], input[id*="email"], input[placeholder*="email" i], input[placeholder*="Email"]', - 15000 - ); - } catch { - throw new Error('在登录页未找到邮箱输入框。URL: ' + location.href); + const snapshot = await waitForKnownLoginAuthState(15000); + + if (snapshot.state === 'verification_page') { + log('步骤 6:登录验证码页面已就绪。', 'ok'); + return createStep6SuccessResult(snapshot, { via: 'already_on_verification_page' }); } - await humanPause(500, 1400); - fillInput(emailInput, email); - log('步骤 6:邮箱已填写'); - - // Submit email - await sleep(500); - const submitBtn1 = document.querySelector('button[type="submit"]') - || await waitForElementByText('button', /continue|next|submit|继续|下一步/i, 5000).catch(() => null); - if (submitBtn1) { - await humanPause(400, 1100); - simulateClick(submitBtn1); - log('步骤 6:邮箱已提交'); + if (snapshot.state === 'login_timeout_error_page') { + log('步骤 6:检测到登录超时报错,准备重新执行步骤 6。', 'warn'); + return createStep6RecoverableResult('login_timeout_error_page', snapshot, { + message: '当前页面处于登录超时报错页。', + }); } - await sleep(2000); - - // Check for password field - const passwordInput = document.querySelector('input[type="password"]'); - if (passwordInput) { - log('步骤 6:已找到密码输入框,正在填写密码...'); - await humanPause(550, 1450); - fillInput(passwordInput, password); - - await sleep(500); - const submitBtn2 = document.querySelector('button[type="submit"]') - || await waitForElementByText('button', /continue|log\s*in|submit|sign\s*in|登录|继续/i, 5000).catch(() => null); - // Report complete BEFORE submit in case page navigates - reportComplete(6, { needsOTP: true }); - - if (submitBtn2) { - await humanPause(450, 1200); - simulateClick(submitBtn2); - log('步骤 6:密码已提交,可能还需要验证码(步骤 7)'); - } - return; + if (snapshot.state === 'email_page') { + return step6LoginFromEmailPage(payload, snapshot); } - // No password field — OTP flow - log('步骤 6:未发现密码输入框,可能进入验证码流程或自动跳转。'); - reportComplete(6, { needsOTP: true }); + if (snapshot.state === 'password_page') { + return step6LoginFromPasswordPage(payload, snapshot); + } + + throwForStep6FatalState(snapshot); + throw new Error(`无法识别当前登录页面状态。URL: ${snapshot?.url || location.href}`); } // ============================================================