diff --git a/README.md b/README.md index ca02eb3..b120186 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,7 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。 - 手动输入:使用你自定义的密码 - 可通过输入框右侧的眼睛图标切换显示 - 配置会自动保存,也可以点击右侧 `保存` 按钮手动保存一次 +- 右上角 `配置` 按钮支持导出当前配置到 JSON 文件,也支持从 JSON 文件覆盖导入配置 扩展会把本轮实际使用的密码同步回侧边栏,便于查看和复制。 @@ -369,6 +370,9 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。 - 步骤 9 会拒绝任何不是真实 `/auth/callback`,或缺少 `code` / `state` 的本地回调地址 - 成功后的清理只会针对 `/auth` 这一类真实回调标签页,不会再泛化清理任意 localhost 路径 +- 侧边栏可切换“本地 CPA”策略,默认是 `全部回调` +- 选择 `全部回调` 时,即使 CPA 部署在本地,也会执行步骤 9 +- 选择 `跳过第9步` 时,仅当本地 CPA 且步骤 8 已拿到回调地址时,才会直接跳过步骤 9 回到 CPA 面板: diff --git a/background.js b/background.js index f1adbdb..1902e12 100644 --- a/background.js +++ b/background.js @@ -39,6 +39,7 @@ const DEFAULT_SUB2API_REDIRECT_URI = 'http://localhost:1455/auth/callback'; const AUTO_RUN_ALARM_NAME = 'scheduled-auto-run'; const AUTO_RUN_DELAY_MIN_MINUTES = 1; const AUTO_RUN_DELAY_MAX_MINUTES = 1440; +const DEFAULT_LOCAL_CPA_STEP9_MODE = 'submit'; initializeSessionStorageAccess(); @@ -50,6 +51,7 @@ const PERSISTED_SETTING_DEFAULTS = { panelMode: 'cpa', // Step 1 / Step 9 的来源模式:cpa | sub2api。 vpsUrl: '', // VPS 面板地址,可手动填写。 vpsPassword: '', // VPS 面板登录密码,可手动填写。 + localCpaStep9Mode: DEFAULT_LOCAL_CPA_STEP9_MODE, // 本地 CPA 的第 9 步策略:submit | bypass。 sub2apiUrl: DEFAULT_SUB2API_URL, // SUB2API 管理后台地址。 sub2apiEmail: '', // SUB2API 登录邮箱。 sub2apiPassword: '', // SUB2API 登录密码。 @@ -68,6 +70,8 @@ const PERSISTED_SETTING_DEFAULTS = { }; const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS); +const SETTINGS_EXPORT_SCHEMA_VERSION = 1; +const SETTINGS_EXPORT_FILENAME_PREFIX = 'multipage-settings'; const DEFAULT_STATE = { currentStep: 0, // 当前流程执行到的步骤编号。 @@ -139,6 +143,30 @@ function normalizeEmailGenerator(value = '') { return String(value || '').trim().toLowerCase() === 'cloudflare' ? 'cloudflare' : 'duck'; } +function normalizePanelMode(value = '') { + return String(value || '').trim().toLowerCase() === 'sub2api' ? 'sub2api' : 'cpa'; +} + +function normalizeMailProvider(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + switch (normalized) { + case HOTMAIL_PROVIDER: + case '163': + case '163-vip': + case 'qq': + case 'inbucket': + return normalized; + default: + return PERSISTED_SETTING_DEFAULTS.mailProvider; + } +} + +function normalizeLocalCpaStep9Mode(value = '') { + return String(value || '').trim().toLowerCase() === 'bypass' + ? 'bypass' + : DEFAULT_LOCAL_CPA_STEP9_MODE; +} + function normalizeCloudflareDomain(rawValue = '') { let value = String(rawValue || '').trim().toLowerCase(); if (!value) return ''; @@ -149,17 +177,99 @@ function normalizeCloudflareDomain(rawValue = '') { return value; } +function normalizeCloudflareDomains(values) { + const normalizedDomains = []; + const seen = new Set(); + + for (const value of Array.isArray(values) ? values : []) { + const normalized = normalizeCloudflareDomain(value); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + normalizedDomains.push(normalized); + } + + return normalizedDomains; +} + +function normalizePersistentSettingValue(key, value) { + switch (key) { + case 'panelMode': + return normalizePanelMode(value); + case 'vpsUrl': + return String(value || '').trim(); + case 'vpsPassword': + return String(value || ''); + case 'localCpaStep9Mode': + return normalizeLocalCpaStep9Mode(value); + case 'sub2apiUrl': + return String(value || '').trim(); + case 'sub2apiEmail': + return String(value || '').trim(); + case 'sub2apiPassword': + return String(value || ''); + case 'sub2apiGroupName': + return String(value || '').trim(); + case 'customPassword': + return String(value || ''); + case 'autoRunSkipFailures': + case 'autoRunDelayEnabled': + return Boolean(value); + case 'autoRunDelayMinutes': + return normalizeAutoRunDelayMinutes(value); + case 'mailProvider': + return normalizeMailProvider(value); + case 'emailGenerator': + return normalizeEmailGenerator(value); + case 'inbucketHost': + return String(value || '').trim(); + case 'inbucketMailbox': + return String(value || '').trim(); + case 'cloudflareDomain': + return normalizeCloudflareDomain(value); + case 'cloudflareDomains': + return normalizeCloudflareDomains(value); + case 'hotmailAccounts': + return normalizeHotmailAccounts(value); + default: + return value; + } +} + +function buildPersistentSettingsPayload(input = {}, options = {}) { + const { fillDefaults = false, requireKnownKeys = false } = options; + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('\u914d\u7f6e\u5185\u5bb9\u683c\u5f0f\u65e0\u6548\u3002'); + } + + const payload = {}; + let matchedKeyCount = 0; + for (const key of PERSISTED_SETTING_KEYS) { + if (input[key] !== undefined) { + payload[key] = normalizePersistentSettingValue(key, input[key]); + matchedKeyCount += 1; + } else if (fillDefaults) { + payload[key] = normalizePersistentSettingValue(key, PERSISTED_SETTING_DEFAULTS[key]); + } + } + + if (requireKnownKeys && matchedKeyCount === 0) { + throw new Error('\u914d\u7f6e\u6587\u4ef6\u4e2d\u6ca1\u6709\u53ef\u8bc6\u522b\u7684\u914d\u7f6e\u5185\u5bb9\u3002'); + } + + if (payload.cloudflareDomains) { + const domains = normalizeCloudflareDomains(payload.cloudflareDomains); + if (payload.cloudflareDomain && !domains.includes(payload.cloudflareDomain)) { + domains.unshift(payload.cloudflareDomain); + } + payload.cloudflareDomains = domains; + } + + return payload; +} + async function getPersistedSettings() { const stored = await chrome.storage.local.get(PERSISTED_SETTING_KEYS); - return { - ...PERSISTED_SETTING_DEFAULTS, - ...stored, - autoRunSkipFailures: Boolean(stored.autoRunSkipFailures ?? PERSISTED_SETTING_DEFAULTS.autoRunSkipFailures), - autoRunDelayEnabled: Boolean(stored.autoRunDelayEnabled ?? PERSISTED_SETTING_DEFAULTS.autoRunDelayEnabled), - autoRunDelayMinutes: normalizeAutoRunDelayMinutes(stored.autoRunDelayMinutes ?? PERSISTED_SETTING_DEFAULTS.autoRunDelayMinutes), - emailGenerator: normalizeEmailGenerator(stored.emailGenerator ?? PERSISTED_SETTING_DEFAULTS.emailGenerator), - hotmailAccounts: normalizeHotmailAccounts(stored.hotmailAccounts), - }; + return buildPersistentSettingsPayload(stored, { fillDefaults: true }); } async function getState() { @@ -191,26 +301,75 @@ async function setState(updates) { } async function setPersistentSettings(updates) { - const persistedUpdates = {}; - for (const key of PERSISTED_SETTING_KEYS) { - if (updates[key] !== undefined) { - if (key === 'autoRunSkipFailures' || key === 'autoRunDelayEnabled') { - persistedUpdates[key] = Boolean(updates[key]); - } else if (key === 'autoRunDelayMinutes') { - persistedUpdates[key] = normalizeAutoRunDelayMinutes(updates[key]); - } else if (key === 'hotmailAccounts') { - persistedUpdates[key] = normalizeHotmailAccounts(updates[key]); - } else { - persistedUpdates[key] = updates[key]; - } - } - } + const persistedUpdates = buildPersistentSettingsPayload(updates); if (Object.keys(persistedUpdates).length > 0) { await chrome.storage.local.set(persistedUpdates); } } +function buildSettingsExportFilename(date = new Date()) { + const pad = (value) => String(value).padStart(2, '0'); + return `${SETTINGS_EXPORT_FILENAME_PREFIX}-${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}.json`; +} + +async function exportSettingsBundle() { + const settings = await getPersistedSettings(); + const bundle = { + schemaVersion: SETTINGS_EXPORT_SCHEMA_VERSION, + exportedAt: new Date().toISOString(), + extensionVersion: chrome.runtime.getManifest().version, + settings, + }; + + return { + fileName: buildSettingsExportFilename(), + fileContent: JSON.stringify(bundle, null, 2), + }; +} + +async function importSettingsBundle(configBundle) { + const state = await ensureManualInteractionAllowed('\u5bfc\u5165\u914d\u7f6e'); + if (Object.values(state.stepStatuses || {}).some((status) => status === 'running')) { + throw new Error('\u5f53\u524d\u6709\u6b65\u9aa4\u6b63\u5728\u6267\u884c\uff0c\u65e0\u6cd5\u5bfc\u5165\u914d\u7f6e\u3002'); + } + if (!configBundle || typeof configBundle !== 'object' || Array.isArray(configBundle)) { + throw new Error('\u914d\u7f6e\u6587\u4ef6\u5185\u5bb9\u65e0\u6548\u3002'); + } + + const schemaVersion = Number(configBundle.schemaVersion); + if (schemaVersion !== SETTINGS_EXPORT_SCHEMA_VERSION) { + throw new Error(`\u4ec5\u652f\u6301\u5bfc\u5165 schemaVersion=${SETTINGS_EXPORT_SCHEMA_VERSION} \u7684\u914d\u7f6e\u6587\u4ef6\u3002`); + } + if (!configBundle.settings || typeof configBundle.settings !== 'object' || Array.isArray(configBundle.settings)) { + throw new Error('\u914d\u7f6e\u6587\u4ef6\u7f3a\u5c11 settings \u914d\u7f6e\u6bb5\u3002'); + } + + const importedSettings = buildPersistentSettingsPayload(configBundle.settings, { + fillDefaults: true, + requireKnownKeys: true, + }); + + await setPersistentSettings(importedSettings); + + const sessionUpdates = { + ...importedSettings, + currentHotmailAccountId: null, + }; + if (importedSettings.mailProvider === HOTMAIL_PROVIDER) { + sessionUpdates.email = null; + } + + await setState(sessionUpdates); + broadcastDataUpdate({ + ...importedSettings, + currentHotmailAccountId: null, + ...(sessionUpdates.email !== undefined ? { email: sessionUpdates.email } : {}), + }); + + return getState(); +} + function broadcastDataUpdate(payload) { chrome.runtime.sendMessage({ type: 'DATA_UPDATED', @@ -218,9 +377,13 @@ function broadcastDataUpdate(payload) { }).catch(() => { }); } -async function setEmailState(email) { +async function setEmailStateSilently(email) { await setState({ email }); broadcastDataUpdate({ email }); +} + +async function setEmailState(email) { + await setEmailStateSilently(email); if (email) { await resumeAutoRunIfWaitingForEmail(); } @@ -983,7 +1146,9 @@ function isLocalCpaUrl(rawUrl) { } function shouldBypassStep9ForLocalCpa(state) { - return Boolean(state?.localhostUrl) && isLocalCpaUrl(state?.vpsUrl); + return normalizeLocalCpaStep9Mode(state?.localCpaStep9Mode) === 'bypass' + && Boolean(state?.localhostUrl) + && isLocalCpaUrl(state?.vpsUrl); } function matchesSourceUrlFamily(source, candidateUrl, referenceUrl) { @@ -1881,6 +2046,13 @@ function clearStopRequest() { stopRequested = false; } +function getRunningSteps(statuses = {}) { + return Object.entries({ ...DEFAULT_STATE.stepStatuses, ...statuses }) + .filter(([, status]) => status === 'running') + .map(([step]) => Number(step)) + .sort((a, b) => a - b); +} + function getAutoRunStatusPayload(phase, payload = {}) { const currentRun = payload.currentRun ?? autoRunCurrentRun; const totalRuns = payload.totalRuns ?? autoRunTotalRuns; @@ -1889,7 +2061,7 @@ function getAutoRunStatusPayload(phase, payload = {}) { ? (payload.scheduledAt ?? payload.scheduledAutoRunAt ?? null) : null; const scheduledAt = rawScheduledAt === null ? null : Number(rawScheduledAt); - const autoRunning = phase === 'scheduled' || phase === 'running' || phase === 'waiting_email' || phase === 'retrying'; + const autoRunning = phase === 'scheduled' || phase === 'running' || phase === 'waiting_step' || phase === 'waiting_email' || phase === 'retrying'; return { autoRunning, @@ -1924,7 +2096,7 @@ async function broadcastAutoRunStatus(phase, payload = {}, extraState = {}) { } function isAutoRunLockedState(state) { - return Boolean(state.autoRunning) && (state.autoRunPhase === 'running' || state.autoRunPhase === 'retrying'); + return Boolean(state.autoRunning) && (state.autoRunPhase === 'running' || state.autoRunPhase === 'waiting_step' || state.autoRunPhase === 'retrying'); } function isAutoRunPausedState(state) { @@ -2211,6 +2383,7 @@ async function humanStepDelay(min = HUMAN_STEP_DELAY_MIN, max = HUMAN_STEP_DELAY } async function clickWithDebugger(tabId, rect) { + throwIfStopped(); if (!tabId) { throw new Error('未找到用于调试点击的认证页面标签页。'); } @@ -2229,10 +2402,12 @@ async function clickWithDebugger(tabId, rect) { } try { + throwIfStopped(); const x = Math.round(rect.centerX); const y = Math.round(rect.centerY); await chrome.debugger.sendCommand(target, 'Page.bringToFront'); + throwIfStopped(); await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, @@ -2241,6 +2416,7 @@ async function clickWithDebugger(tabId, rect) { buttons: 0, clickCount: 0, }); + throwIfStopped(); await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', { type: 'mousePressed', x, @@ -2249,6 +2425,7 @@ async function clickWithDebugger(tabId, rect) { buttons: 1, clickCount: 1, }); + throwIfStopped(); await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x, @@ -2430,31 +2607,21 @@ async function handleMessage(message, sender) { } case 'SAVE_SETTING': { - const updates = {}; - if (message.payload.panelMode !== undefined) updates.panelMode = message.payload.panelMode; - if (message.payload.vpsUrl !== undefined) updates.vpsUrl = message.payload.vpsUrl; - if (message.payload.vpsPassword !== undefined) updates.vpsPassword = message.payload.vpsPassword; - if (message.payload.sub2apiUrl !== undefined) updates.sub2apiUrl = message.payload.sub2apiUrl; - if (message.payload.sub2apiEmail !== undefined) updates.sub2apiEmail = message.payload.sub2apiEmail; - if (message.payload.sub2apiPassword !== undefined) updates.sub2apiPassword = message.payload.sub2apiPassword; - if (message.payload.sub2apiGroupName !== undefined) updates.sub2apiGroupName = message.payload.sub2apiGroupName; - if (message.payload.customPassword !== undefined) updates.customPassword = message.payload.customPassword; - if (message.payload.autoRunSkipFailures !== undefined) updates.autoRunSkipFailures = Boolean(message.payload.autoRunSkipFailures); - if (message.payload.autoRunDelayEnabled !== undefined) updates.autoRunDelayEnabled = Boolean(message.payload.autoRunDelayEnabled); - if (message.payload.autoRunDelayMinutes !== undefined) updates.autoRunDelayMinutes = normalizeAutoRunDelayMinutes(message.payload.autoRunDelayMinutes); - if (message.payload.mailProvider !== undefined) updates.mailProvider = message.payload.mailProvider; - if (message.payload.emailGenerator !== undefined) updates.emailGenerator = normalizeEmailGenerator(message.payload.emailGenerator); - if (message.payload.inbucketHost !== undefined) updates.inbucketHost = message.payload.inbucketHost; - if (message.payload.inbucketMailbox !== undefined) updates.inbucketMailbox = message.payload.inbucketMailbox; - if (message.payload.cloudflareDomain !== undefined) updates.cloudflareDomain = normalizeCloudflareDomain(message.payload.cloudflareDomain); - if (message.payload.cloudflareDomains !== undefined) updates.cloudflareDomains = Array.isArray(message.payload.cloudflareDomains) - ? message.payload.cloudflareDomains.map(domain => normalizeCloudflareDomain(domain)).filter(Boolean) - : []; + const updates = buildPersistentSettingsPayload(message.payload || {}); await setPersistentSettings(updates); await setState(updates); return { ok: true }; } + case 'EXPORT_SETTINGS': { + return { ok: true, ...(await exportSettingsBundle()) }; + } + + case 'IMPORT_SETTINGS': { + const state = await importSettingsBundle(message.payload?.config || null); + return { ok: true, state }; + } + case 'UPSERT_HOTMAIL_ACCOUNT': { const account = await upsertHotmailAccount(message.payload || {}); return { ok: true, account }; @@ -2513,6 +2680,16 @@ async function handleMessage(message, sender) { } // Side panel data updates + case 'SET_EMAIL_STATE': { + const state = await getState(); + if (isAutoRunLockedState(state)) { + throw new Error('自动流程运行中,当前不能手动修改邮箱。'); + } + const email = String(message.payload?.email || '').trim() || null; + await setEmailStateSilently(email); + return { ok: true, email }; + } + case 'SAVE_EMAIL': { const state = await getState(); if (isAutoRunLockedState(state)) { @@ -2688,11 +2865,29 @@ async function completeStepFromBackground(step, payload = {}) { notifyStepComplete(step, payload); } +async function waitForRunningStepsToFinish(payload = {}) { + let currentState = await getState(); + let runningSteps = getRunningSteps(currentState.stepStatuses); + if (!runningSteps.length) { + return currentState; + } + + await addLog(`自动继续:检测到步骤 ${runningSteps.join(', ')} 正在运行,等待完成后再继续自动流程...`, 'info'); + await broadcastAutoRunStatus('waiting_step', payload); + + while (runningSteps.length) { + await sleepWithStop(250); + currentState = await getState(); + runningSteps = getRunningSteps(currentState.stepStatuses); + } + + await addLog('自动继续:当前运行步骤已结束,准备按最新进度继续自动流程...', 'info'); + return currentState; +} + async function markRunningStepsStopped() { const state = await getState(); - const runningSteps = Object.entries(state.stepStatuses || {}) - .filter(([, status]) => status === 'running') - .map(([step]) => Number(step)); + const runningSteps = getRunningSteps(state.stepStatuses); for (const step of runningSteps) { await setStepStatus(step, 'stopped'); @@ -3083,10 +3278,14 @@ async function autoRunLoop(totalRuns, options = {}) { let attemptRuns = Math.max(0, resumeAttemptRunsProcessed); let forceFreshTabsNextRun = false; let continueCurrentOnFirstAttempt = initialMode === 'continue'; + const initialState = await getState(); + const initialPhase = continueCurrentOnFirstAttempt && getRunningSteps(initialState.stepStatuses).length + ? 'waiting_step' + : 'running'; await setState({ autoRunSkipFailures, - ...getAutoRunStatusPayload('running', { + ...getAutoRunStatusPayload(initialPhase, { currentRun: resumeCurrentRun, totalRuns, attemptRun: resumeAttemptRunsProcessed, @@ -3102,7 +3301,14 @@ async function autoRunLoop(totalRuns, options = {}) { let useExistingProgress = false; if (continueCurrentOnFirstAttempt) { - const currentState = await getState(); + let currentState = await getState(); + if (getRunningSteps(currentState.stepStatuses).length) { + currentState = await waitForRunningStepsToFinish({ + currentRun: targetRun, + totalRuns, + attemptRun: attemptRuns, + }); + } const resumeStep = getFirstUnfinishedStep(currentState.stepStatuses); if (resumeStep && hasSavedProgress(currentState.stepStatuses)) { startStep = resumeStep; @@ -4415,7 +4621,7 @@ async function executeCpaStep9(state) { } if (shouldBypassStep9ForLocalCpa(state)) { - await addLog('步骤 9:检测到本地 CPA,步骤 8 完成后已自动添加,无需重复提交回调地址。', 'info'); + await addLog('步骤 9:检测到本地 CPA,且当前策略为“跳过第9步”,本轮不再重复提交回调地址。', 'info'); await completeStepFromBackground(9, { localhostUrl: state.localhostUrl, verifiedStatus: 'local-auto', diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index a300720..b627d9d 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -142,6 +142,67 @@ header { [data-theme="dark"] .theme-toggle .icon-moon { display: none; } [data-theme="dark"] .theme-toggle .icon-sun { display: block; } +.header-menu { + position: relative; + display: flex; + align-items: center; +} + +.header-config-btn { + min-width: 0; + padding-inline: 10px; +} + +.header-config-btn[aria-expanded="true"] { + background: var(--bg-hover); + color: var(--text-primary); +} + +.header-dropdown { + position: absolute; + top: calc(100% + 6px); + right: 0; + min-width: 120px; + padding: 6px; + display: flex; + flex-direction: column; + gap: 4px; + background: var(--bg-base); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-md); + z-index: 1300; +} + +.header-dropdown[hidden] { + display: none !important; +} + +.header-dropdown-item { + width: 100%; + padding: 8px 10px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-secondary); + font: inherit; + font-size: 12px; + font-weight: 600; + text-align: left; + cursor: pointer; + transition: background var(--transition), color var(--transition); +} + +.header-dropdown-item:hover:not(:disabled) { + background: var(--bg-hover); + color: var(--text-primary); +} + +.header-dropdown-item:disabled { + color: var(--text-muted); + cursor: not-allowed; +} + /* ============================================================ Buttons ============================================================ */ @@ -238,6 +299,20 @@ header { box-shadow: var(--shadow-sm); } +.data-card.is-locked { + position: relative; + overflow: hidden; +} + +.data-card.is-locked::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background: color-mix(in srgb, var(--bg-base) 18%, transparent); + pointer-events: none; +} + .section-mini-header { display: flex; align-items: center; @@ -283,6 +358,10 @@ header { min-width: 0; } +.data-value-actions { + justify-content: space-between; +} + .input-with-icon { position: relative; flex: 1; @@ -307,6 +386,10 @@ header { min-width: 0; } +.data-value-fill { + flex: 1; +} + .data-value.has-value { color: var(--text-primary); } .mono { @@ -378,6 +461,41 @@ header { background: var(--bg-hover); } +.choice-group { + flex: 1; + min-width: 0; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.choice-btn { + flex: 1; + min-width: 0; + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-base); + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: border-color var(--transition), background var(--transition), color var(--transition), box-shadow var(--transition); +} + +.choice-btn:hover { + border-color: var(--blue); + color: var(--blue); + background: var(--blue-soft); +} + +.choice-btn.is-active { + border-color: var(--blue); + color: var(--blue); + background: var(--blue-soft); + box-shadow: 0 0 0 1px var(--blue-glow); +} + #btn-fetch-email, #btn-save-settings { padding-inline: 10px; diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index e8a9e2c..af607f4 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -58,11 +58,19 @@ +
+ + +
-
+
来源 +
+ + +
+
+
+ 本地 CPA +
+ + +
codex密码 -
-
- + -
-
@@ -179,7 +195,10 @@
回调 - 等待中... +
+ 等待中... + +
+ diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 6efc3d9..918c507 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -11,6 +11,7 @@ const STATUS_ICONS = { }; const logArea = document.getElementById('log-area'); +const settingsCard = document.getElementById('settings-card'); const displayOauthUrl = document.getElementById('display-oauth-url'); const displayLocalhostUrl = document.getElementById('display-localhost-url'); const displayStatus = document.getElementById('display-status'); @@ -18,6 +19,7 @@ const statusBar = document.getElementById('status-bar'); const inputEmail = document.getElementById('input-email'); const inputPassword = document.getElementById('input-password'); const btnToggleVpsUrl = document.getElementById('btn-toggle-vps-url'); +const btnToggleVpsPassword = document.getElementById('btn-toggle-vps-password'); const btnFetchEmail = document.getElementById('btn-fetch-email'); const btnTogglePassword = document.getElementById('btn-toggle-password'); const btnSaveSettings = document.getElementById('btn-save-settings'); @@ -33,11 +35,19 @@ const autoScheduleMeta = document.getElementById('auto-schedule-meta'); const btnAutoRunNow = document.getElementById('btn-auto-run-now'); const btnAutoCancelSchedule = document.getElementById('btn-auto-cancel-schedule'); const btnClearLog = document.getElementById('btn-clear-log'); +const configMenuShell = document.getElementById('config-menu-shell'); +const btnConfigMenu = document.getElementById('btn-config-menu'); +const configMenu = document.getElementById('config-menu'); +const btnExportSettings = document.getElementById('btn-export-settings'); +const btnImportSettings = document.getElementById('btn-import-settings'); +const inputImportSettingsFile = document.getElementById('input-import-settings-file'); const selectPanelMode = document.getElementById('select-panel-mode'); const rowVpsUrl = document.getElementById('row-vps-url'); const inputVpsUrl = document.getElementById('input-vps-url'); const rowVpsPassword = document.getElementById('row-vps-password'); const inputVpsPassword = document.getElementById('input-vps-password'); +const rowLocalCpaStep9Mode = document.getElementById('row-local-cpa-step9-mode'); +const localCpaStep9ModeButtons = Array.from(document.querySelectorAll('[data-local-cpa-step9-mode]')); const rowSub2ApiUrl = document.getElementById('row-sub2api-url'); const inputSub2ApiUrl = document.getElementById('input-sub2api-url'); const rowSub2ApiEmail = document.getElementById('row-sub2api-email'); @@ -96,6 +106,7 @@ const SKIPPABLE_STEPS = new Set([1, 2, 3, 4, 5, 6, 7, 8, 9]); const AUTO_DELAY_MIN_MINUTES = 1; const AUTO_DELAY_MAX_MINUTES = 1440; const AUTO_DELAY_DEFAULT_MINUTES = 30; +const DEFAULT_LOCAL_CPA_STEP9_MODE = 'submit'; let latestState = null; let currentAutoRun = { @@ -115,6 +126,8 @@ let currentModalActions = []; let scheduledCountdownTimer = null; let hotmailActionInFlight = false; let hotmailListExpanded = false; +let configMenuOpen = false; +let configActionInFlight = false; const EYE_OPEN_ICON = ''; const EYE_CLOSED_ICON = ''; @@ -225,10 +238,14 @@ function openActionModal({ title, message, actions }) { }); } -function openAutoStartChoiceDialog(startStep) { +function openAutoStartChoiceDialog(startStep, options = {}) { + const runningStep = Number.isInteger(options.runningStep) ? options.runningStep : null; + const continueMessage = runningStep + ? `继续当前会先等待步骤 ${runningStep} 完成,再按最新进度自动执行。` + : `继续当前会从步骤 ${startStep} 开始自动执行。`; return openActionModal({ title: '启动自动', - message: `检测到当前已有流程进度。继续当前会从步骤 ${startStep} 开始自动执行,重新开始会清空当前流程进度并从步骤 1 新开一轮。`, + message: `检测到当前已有流程进度。${continueMessage}重新开始会清空当前流程进度并从步骤 1 新开一轮。`, actions: [ { id: null, label: '取消', variant: 'btn-ghost' }, { id: 'restart', label: '重新开始', variant: 'btn-outline' }, @@ -249,6 +266,71 @@ async function openConfirmModal({ title, message, confirmLabel = '确认', confi return choice === 'confirm'; } +function updateConfigMenuControls() { + const disabled = configActionInFlight || settingsSaveInFlight; + const importLocked = disabled + || currentAutoRun.autoRunning + || Object.values(getStepStatuses()).some((status) => status === 'running'); + if (btnConfigMenu) { + btnConfigMenu.disabled = disabled; + btnConfigMenu.setAttribute('aria-expanded', String(configMenuOpen)); + } + if (configMenu) { + configMenu.hidden = !configMenuOpen; + } + if (btnExportSettings) { + btnExportSettings.disabled = disabled; + } + if (btnImportSettings) { + btnImportSettings.disabled = importLocked; + } +} + +function closeConfigMenu() { + configMenuOpen = false; + updateConfigMenuControls(); +} + +function openConfigMenu() { + configMenuOpen = true; + updateConfigMenuControls(); +} + +function toggleConfigMenu() { + configMenuOpen ? closeConfigMenu() : openConfigMenu(); +} + +async function waitForSettingsSaveIdle() { + while (settingsSaveInFlight) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +async function flushPendingSettingsBeforeExport() { + clearTimeout(settingsAutoSaveTimer); + await waitForSettingsSaveIdle(); + if (settingsDirty) { + await saveSettings({ silent: true }); + } +} + +async function settlePendingSettingsBeforeImport() { + clearTimeout(settingsAutoSaveTimer); + await waitForSettingsSaveIdle(); +} + +function downloadTextFile(content, fileName, mimeType = 'application/json;charset=utf-8') { + const blob = new Blob([content], { type: mimeType }); + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = objectUrl; + anchor.download = fileName; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(objectUrl), 0); +} + function isDoneStatus(status) { return status === 'completed' || status === 'manual_completed' || status === 'skipped'; } @@ -267,6 +349,14 @@ function getFirstUnfinishedStep(state = latestState) { return null; } +function getRunningSteps(state = latestState) { + const statuses = getStepStatuses(state); + return Object.entries(statuses) + .filter(([, status]) => status === 'running') + .map(([step]) => Number(step)) + .sort((a, b) => a - b); +} + function hasSavedProgress(state = latestState) { const statuses = getStepStatuses(state); return Object.values(statuses).some((status) => status !== 'pending'); @@ -293,7 +383,7 @@ function syncAutoRunState(source = {}) { const autoRunning = source.autoRunning !== undefined ? Boolean(source.autoRunning) : (source.autoRunPhase !== undefined || source.phase !== undefined - ? ['scheduled', 'running', 'waiting_email', 'retrying'].includes(phase) + ? ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying'].includes(phase) : currentAutoRun.autoRunning); currentAutoRun = { @@ -307,13 +397,17 @@ function syncAutoRunState(source = {}) { } function isAutoRunLockedPhase() { - return currentAutoRun.phase === 'running' || currentAutoRun.phase === 'retrying'; + return currentAutoRun.phase === 'running' || currentAutoRun.phase === 'waiting_step' || currentAutoRun.phase === 'retrying'; } function isAutoRunPausedPhase() { return currentAutoRun.phase === 'waiting_email'; } +function isAutoRunWaitingStepPhase() { + return currentAutoRun.phase === 'waiting_step'; +} + function isAutoRunScheduledPhase() { return currentAutoRun.phase === 'scheduled'; } @@ -492,6 +586,7 @@ function collectSettingsPayload() { panelMode: selectPanelMode.value, vpsUrl: inputVpsUrl.value.trim(), vpsPassword: inputVpsPassword.value, + localCpaStep9Mode: getSelectedLocalCpaStep9Mode(), sub2apiUrl: inputSub2ApiUrl.value.trim(), sub2apiEmail: inputSub2ApiEmail.value.trim(), sub2apiPassword: inputSub2ApiPassword.value, @@ -509,6 +604,68 @@ function collectSettingsPayload() { }; } +function normalizeLocalCpaStep9Mode(value = '') { + return String(value || '').trim().toLowerCase() === 'bypass' + ? 'bypass' + : DEFAULT_LOCAL_CPA_STEP9_MODE; +} + +function getSelectedLocalCpaStep9Mode() { + const activeButton = localCpaStep9ModeButtons.find((button) => button.classList.contains('is-active')); + return normalizeLocalCpaStep9Mode(activeButton?.dataset.localCpaStep9Mode); +} + +function setLocalCpaStep9Mode(mode) { + const resolvedMode = normalizeLocalCpaStep9Mode(mode); + localCpaStep9ModeButtons.forEach((button) => { + const active = button.dataset.localCpaStep9Mode === resolvedMode; + button.classList.toggle('is-active', active); + button.setAttribute('aria-pressed', String(active)); + }); +} + +function setSettingsCardLocked(locked) { + if (!settingsCard) { + return; + } + settingsCard.classList.toggle('is-locked', locked); + settingsCard.toggleAttribute('inert', locked); +} + +async function setRuntimeEmailState(email) { + const normalizedEmail = String(email || '').trim() || null; + const response = await chrome.runtime.sendMessage({ + type: 'SET_EMAIL_STATE', + source: 'sidepanel', + payload: { email: normalizedEmail }, + }); + + if (response?.error) { + throw new Error(response.error); + } + + return normalizedEmail; +} + +async function clearRegistrationEmail(options = {}) { + const { silent = false } = options; + if (!inputEmail.value.trim() && !latestState?.email) { + return; + } + + inputEmail.value = ''; + syncLatestState({ email: null }); + + try { + await setRuntimeEmailState(null); + } catch (err) { + if (!silent) { + showToast(`清空邮箱失败:${err.message}`, 'error'); + } + throw err; + } +} + function markSettingsDirty(isDirty = true) { settingsDirty = isDirty; updateSaveButtonState(); @@ -516,6 +673,7 @@ function markSettingsDirty(isDirty = true) { function updateSaveButtonState() { btnSaveSettings.disabled = settingsSaveInFlight || !settingsDirty; + updateConfigMenuControls(); btnSaveSettings.textContent = settingsSaveInFlight ? '保存中' : '保存'; } @@ -575,6 +733,9 @@ function applyAutoRunStatus(payload = currentAutoRun) { const locked = isAutoRunLockedPhase(); const paused = isAutoRunPausedPhase(); const scheduled = isAutoRunScheduledPhase(); + const settingsCardLocked = scheduled || locked; + + setSettingsCardLocked(settingsCardLocked); inputRunCount.disabled = currentAutoRun.autoRunning; btnAutoRun.disabled = currentAutoRun.autoRunning; @@ -591,6 +752,10 @@ function applyAutoRunStatus(payload = currentAutoRun) { autoContinueBar.style.display = 'none'; btnAutoRun.innerHTML = `已计划${runLabel}`; break; + case 'waiting_step': + autoContinueBar.style.display = 'none'; + btnAutoRun.innerHTML = `等待中${runLabel}`; + break; case 'waiting_email': autoContinueBar.style.display = 'flex'; btnAutoRun.innerHTML = `已暂停${runLabel}`; @@ -616,6 +781,7 @@ function applyAutoRunStatus(payload = currentAutoRun) { updateAutoDelayInputState(); syncScheduledCountdownTicker(); updateStopButtonState(scheduled || paused || locked || Object.values(getStepStatuses()).some(status => status === 'running')); + updateConfigMenuControls(); } function initializeManualStepActions() { @@ -653,11 +819,45 @@ function initializeManualStepActions() { // State Restore on load // ============================================================ +function applySettingsState(state) { + syncLatestState(state); + syncAutoRunState(state); + + inputEmail.value = state?.email || ''; + syncPasswordField(state || {}); + inputVpsUrl.value = state?.vpsUrl || ''; + inputVpsPassword.value = state?.vpsPassword || ''; + setLocalCpaStep9Mode(state?.localCpaStep9Mode); + selectPanelMode.value = state?.panelMode || 'cpa'; + inputSub2ApiUrl.value = state?.sub2apiUrl || ''; + inputSub2ApiEmail.value = state?.sub2apiEmail || ''; + inputSub2ApiPassword.value = state?.sub2apiPassword || ''; + inputSub2ApiGroup.value = state?.sub2apiGroupName || ''; + selectMailProvider.value = state?.mailProvider || '163'; + selectEmailGenerator.value = state?.emailGenerator || 'duck'; + inputInbucketHost.value = state?.inbucketHost || ''; + inputInbucketMailbox.value = state?.inbucketMailbox || ''; + renderCloudflareDomainOptions(state?.cloudflareDomain || ''); + setCloudflareDomainEditMode(false, { clearInput: true }); + inputAutoSkipFailures.checked = Boolean(state?.autoRunSkipFailures); + inputAutoDelayEnabled.checked = Boolean(state?.autoRunDelayEnabled); + inputAutoDelayMinutes.value = String(normalizeAutoDelayMinutes(state?.autoRunDelayMinutes)); + if (state?.autoRunTotalRuns) { + inputRunCount.value = String(state.autoRunTotalRuns); + } + + applyAutoRunStatus(state); + markSettingsDirty(false); + updateAutoDelayInputState(); + updatePanelModeUI(); + updateMailProviderUI(); + updateButtonStates(); +} + async function restoreState() { try { const state = await chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' }); - syncLatestState(state); - syncAutoRunState(state); + applySettingsState(state); if (state.oauthUrl) { displayOauthUrl.textContent = state.oauthUrl; @@ -667,52 +867,6 @@ async function restoreState() { displayLocalhostUrl.textContent = state.localhostUrl; displayLocalhostUrl.classList.add('has-value'); } - if (state.email) { - inputEmail.value = state.email; - } - syncPasswordField(state); - if (state.vpsUrl) { - inputVpsUrl.value = state.vpsUrl; - } - if (state.vpsPassword) { - inputVpsPassword.value = state.vpsPassword; - } - if (state.panelMode) { - selectPanelMode.value = state.panelMode; - } - if (state.sub2apiUrl) { - inputSub2ApiUrl.value = state.sub2apiUrl; - } - if (state.sub2apiEmail) { - inputSub2ApiEmail.value = state.sub2apiEmail; - } - if (state.sub2apiPassword) { - inputSub2ApiPassword.value = state.sub2apiPassword; - } - if (state.sub2apiGroupName) { - inputSub2ApiGroup.value = state.sub2apiGroupName; - } - if (state.mailProvider) { - selectMailProvider.value = state.mailProvider; - } - if (state.emailGenerator) { - selectEmailGenerator.value = state.emailGenerator; - } - if (state.inbucketHost) { - inputInbucketHost.value = state.inbucketHost; - } - if (state.inbucketMailbox) { - inputInbucketMailbox.value = state.inbucketMailbox; - } - renderCloudflareDomainOptions(state.cloudflareDomain || ''); - setCloudflareDomainEditMode(false, { clearInput: true }); - inputAutoSkipFailures.checked = Boolean(state.autoRunSkipFailures); - inputAutoDelayEnabled.checked = Boolean(state.autoRunDelayEnabled); - inputAutoDelayMinutes.value = String(normalizeAutoDelayMinutes(state.autoRunDelayMinutes)); - if (state.autoRunTotalRuns) { - inputRunCount.value = String(state.autoRunTotalRuns); - } - if (state.stepStatuses) { for (const [step, status] of Object.entries(state.stepStatuses)) { updateStepUI(Number(step), status); @@ -725,14 +879,8 @@ async function restoreState() { } } - applyAutoRunStatus(state); - markSettingsDirty(false); - updateAutoDelayInputState(); updateStatusDisplay(latestState); updateProgressCounter(); - updatePanelModeUI(); - updateMailProviderUI(); - updateButtonStates(); } catch (err) { console.error('Failed to restore state:', err); } @@ -1051,6 +1199,7 @@ function updatePanelModeUI() { const useSub2Api = selectPanelMode.value === 'sub2api'; rowVpsUrl.style.display = useSub2Api ? 'none' : ''; rowVpsPassword.style.display = useSub2Api ? 'none' : ''; + rowLocalCpaStep9Mode.style.display = useSub2Api ? 'none' : ''; rowSub2ApiUrl.style.display = useSub2Api ? '' : 'none'; rowSub2ApiEmail.style.display = useSub2Api ? '' : 'none'; rowSub2ApiPassword.style.display = useSub2Api ? '' : 'none'; @@ -1084,6 +1233,7 @@ function updateStepUI(step, status) { updateButtonStates(); updateProgressCounter(); + updateConfigMenuControls(); } function updateProgressCounter() { @@ -1166,6 +1316,15 @@ function updateStatusDisplay(state) { return; } + if (isAutoRunWaitingStepPhase()) { + const runningSteps = getRunningSteps(state); + displayStatus.textContent = runningSteps.length + ? `自动等待步骤 ${runningSteps.join(', ')} 完成后继续${getAutoRunLabel()}` + : `自动正在按最新进度准备继续${getAutoRunLabel()}`; + statusBar.classList.add('running'); + return; + } + const running = Object.entries(state.stepStatuses).find(([, s]) => s === 'running'); if (running) { displayStatus.textContent = `步骤 ${running[0]} 运行中...`; @@ -1295,6 +1454,93 @@ async function copyTextToClipboard(text) { await navigator.clipboard.writeText(value); } +async function exportSettingsFile() { + closeConfigMenu(); + configActionInFlight = true; + updateConfigMenuControls(); + + try { + await flushPendingSettingsBeforeExport(); + const response = await chrome.runtime.sendMessage({ + type: 'EXPORT_SETTINGS', + source: 'sidepanel', + payload: {}, + }); + + if (response?.error) { + throw new Error(response.error); + } + if (!response?.fileContent || !response?.fileName) { + throw new Error('\u672a\u751f\u6210\u53ef\u4e0b\u8f7d\u7684\u914d\u7f6e\u6587\u4ef6\u3002'); + } + + downloadTextFile(response.fileContent, response.fileName); + showToast('\u914d\u7f6e\u5df2\u5bfc\u51fa\uff1a' + response.fileName, 'success', 2200); + } catch (err) { + showToast('\u5bfc\u51fa\u914d\u7f6e\u5931\u8d25\uff1a' + err.message, 'error'); + } finally { + configActionInFlight = false; + updateConfigMenuControls(); + } +} + +async function importSettingsFromFile(file) { + if (!file) return; + + configActionInFlight = true; + closeConfigMenu(); + updateConfigMenuControls(); + + try { + await settlePendingSettingsBeforeImport(); + const rawText = await file.text(); + + let parsedConfig = null; + try { + parsedConfig = JSON.parse(rawText); + } catch { + throw new Error('\u914d\u7f6e\u6587\u4ef6\u4e0d\u662f\u6709\u6548\u7684 JSON\u3002'); + } + + const confirmed = await openConfirmModal({ + title: '\u5bfc\u5165\u914d\u7f6e', + message: '\u786e\u8ba4\u5bfc\u5165\u914d\u7f6e\u6587\u4ef6 "' + file.name + '" \u5417\uff1f\u5bfc\u5165\u540e\u4f1a\u8986\u76d6\u5f53\u524d\u914d\u7f6e\u3002', + confirmLabel: '\u786e\u8ba4\u8986\u76d6\u5bfc\u5165', + confirmVariant: 'btn-danger', + }); + if (!confirmed) { + return; + } + + const response = await chrome.runtime.sendMessage({ + type: 'IMPORT_SETTINGS', + source: 'sidepanel', + payload: { + config: parsedConfig, + }, + }); + + if (response?.error) { + throw new Error(response.error); + } + if (!response?.state) { + throw new Error('\u5bfc\u5165\u540e\u672a\u8fd4\u56de\u6700\u65b0\u914d\u7f6e\u72b6\u6001\u3002'); + } + + applySettingsState(response.state); + updateStatusDisplay(latestState); + showToast('\u914d\u7f6e\u5df2\u5bfc\u5165\uff0c\u5f53\u524d\u914d\u7f6e\u5df2\u8986\u76d6\u3002', 'success', 2200); + } catch (err) { + showToast('\u5bfc\u5165\u914d\u7f6e\u5931\u8d25\uff1a' + err.message, 'error'); + } finally { + configActionInFlight = false; + updateConfigMenuControls(); + if (inputImportSettingsFile) { + inputImportSettingsFile.value = ''; + } + } +} + async function deleteHotmailAccountsByMode(mode) { const isUsedMode = mode === 'used'; const targetAccounts = getHotmailAccountsByUsage(isUsedMode ? 'used' : 'all'); @@ -1362,6 +1608,13 @@ function syncVpsUrlToggleLabel() { }); } +function syncVpsPasswordToggleLabel() { + syncToggleButtonLabel(btnToggleVpsPassword, inputVpsPassword, { + show: '显示管理密钥', + hide: '隐藏管理密钥', + }); +} + async function maybeTakeoverAutoRun(actionLabel) { if (!isAutoRunPausedPhase()) { return true; @@ -1698,6 +1951,23 @@ btnToggleVpsUrl.addEventListener('click', () => { syncVpsUrlToggleLabel(); }); +btnToggleVpsPassword.addEventListener('click', () => { + inputVpsPassword.type = inputVpsPassword.type === 'password' ? 'text' : 'password'; + syncVpsPasswordToggleLabel(); +}); + +localCpaStep9ModeButtons.forEach((button) => { + button.addEventListener('click', () => { + const nextMode = button.dataset.localCpaStep9Mode; + if (getSelectedLocalCpaStep9Mode() === normalizeLocalCpaStep9Mode(nextMode)) { + return; + } + setLocalCpaStep9Mode(nextMode); + markSettingsDirty(true); + saveSettings({ silent: true }).catch(() => { }); + }); +}); + btnSaveSettings.addEventListener('click', async () => { if (!settingsDirty) { showToast('配置已是最新', 'info', 1400); @@ -1712,6 +1982,38 @@ btnStop.addEventListener('click', async () => { showToast(isAutoRunScheduledPhase() ? '正在取消倒计时计划...' : '正在停止当前流程...', 'warn', 2000); }); +btnConfigMenu?.addEventListener('click', (event) => { + event.stopPropagation(); + toggleConfigMenu(); +}); + +configMenu?.addEventListener('click', (event) => { + event.stopPropagation(); +}); + +btnExportSettings?.addEventListener('click', async () => { + if (configActionInFlight || settingsSaveInFlight) { + return; + } + await exportSettingsFile(); +}); + +btnImportSettings?.addEventListener('click', async () => { + if (configActionInFlight || settingsSaveInFlight) { + return; + } + closeConfigMenu(); + if (inputImportSettingsFile) { + inputImportSettingsFile.value = ''; + inputImportSettingsFile.click(); + } +}); + +inputImportSettingsFile?.addEventListener('change', async () => { + const file = inputImportSettingsFile.files?.[0] || null; + await importSettingsFromFile(file); +}); + autoStartModal?.addEventListener('click', (event) => { if (event.target === autoStartModal) { resolveModalChoice(null); @@ -1727,7 +2029,8 @@ btnAutoRun.addEventListener('click', async () => { if (shouldOfferAutoModeChoice()) { const startStep = getFirstUnfinishedStep(); - const choice = await openAutoStartChoiceDialog(startStep); + const runningStep = getRunningSteps()[0] ?? null; + const choice = await openAutoStartChoiceDialog(startStep, { runningStep }); if (!choice) { return; } @@ -1847,8 +2150,18 @@ inputEmail.addEventListener('change', async () => { return; } const email = inputEmail.value.trim(); - if (email) { - await chrome.runtime.sendMessage({ type: 'SAVE_EMAIL', source: 'sidepanel', payload: { email } }); + inputEmail.value = email; + try { + if (email) { + const response = await chrome.runtime.sendMessage({ type: 'SAVE_EMAIL', source: 'sidepanel', payload: { email } }); + if (response?.error) { + throw new Error(response.error); + } + } else { + await setRuntimeEmailState(null); + } + } catch (err) { + showToast(err.message, 'error'); } }); inputEmail.addEventListener('input', updateButtonStates); @@ -1885,6 +2198,7 @@ selectMailProvider.addEventListener('change', () => { selectEmailGenerator.addEventListener('change', () => { updateMailProviderUI(); + clearRegistrationEmail({ silent: true }).catch(() => { }); markSettingsDirty(true); saveSettings({ silent: true }).catch(() => { }); }); @@ -2072,6 +2386,9 @@ chrome.runtime.onMessage.addListener((message) => { if (message.payload.password !== undefined) { inputPassword.value = message.payload.password || ''; } + if (message.payload.localCpaStep9Mode !== undefined) { + setLocalCpaStep9Mode(message.payload.localCpaStep9Mode); + } if (message.payload.oauthUrl !== undefined) { displayOauthUrl.textContent = message.payload.oauthUrl || '等待中...'; displayOauthUrl.classList.toggle('has-value', Boolean(message.payload.oauthUrl)); @@ -2099,7 +2416,7 @@ chrome.runtime.onMessage.addListener((message) => { case 'AUTO_RUN_STATUS': { syncLatestState({ - autoRunning: ['scheduled', 'running', 'waiting_email', 'retrying'].includes(message.payload.phase), + autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying'].includes(message.payload.phase), autoRunPhase: message.payload.phase, autoRunCurrentRun: message.payload.currentRun, autoRunTotalRuns: message.payload.totalRuns, @@ -2139,6 +2456,22 @@ btnTheme.addEventListener('click', () => { setTheme(current === 'dark' ? 'light' : 'dark'); }); +document.addEventListener('click', (event) => { + if (!configMenuOpen) { + return; + } + if (configMenuShell?.contains(event.target)) { + return; + } + closeConfigMenu(); +}); + +document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && configMenuOpen) { + closeConfigMenu(); + } +}); + // ============================================================ // Init // ============================================================ @@ -2147,9 +2480,12 @@ initializeManualStepActions(); initTheme(); initHotmailListExpandedState(); updateSaveButtonState(); +updateConfigMenuControls(); +setLocalCpaStep9Mode(DEFAULT_LOCAL_CPA_STEP9_MODE); restoreState().then(() => { syncPasswordToggleLabel(); syncVpsUrlToggleLabel(); + syncVpsPasswordToggleLabel(); updatePanelModeUI(); updateButtonStates(); updateStatusDisplay(latestState); diff --git a/tests/step9-cpa-mode.test.js b/tests/step9-cpa-mode.test.js index 0c2d8e7..7645bca 100644 --- a/tests/step9-cpa-mode.test.js +++ b/tests/step9-cpa-mode.test.js @@ -28,29 +28,42 @@ function extractFunction(name) { } const bundle = [ + 'const DEFAULT_LOCAL_CPA_STEP9_MODE = "submit";', extractFunction('parseUrlSafely'), extractFunction('isLocalCpaUrl'), + extractFunction('normalizeLocalCpaStep9Mode'), extractFunction('shouldBypassStep9ForLocalCpa'), ].join('\n'); -const api = new Function(`${bundle}; return { isLocalCpaUrl, shouldBypassStep9ForLocalCpa };`)(); +const api = new Function(`${bundle}; return { isLocalCpaUrl, normalizeLocalCpaStep9Mode, shouldBypassStep9ForLocalCpa };`)(); assert.strictEqual(api.isLocalCpaUrl('http://127.0.0.1:8317/management.html#/oauth'), true, '127.0.0.1 应视为本地 CPA'); assert.strictEqual(api.isLocalCpaUrl('http://localhost:1455/management.html#/oauth'), true, 'localhost 应视为本地 CPA'); assert.strictEqual(api.isLocalCpaUrl('https://example.com/management.html#/oauth'), false, '远程域名不应视为本地 CPA'); assert.strictEqual(api.isLocalCpaUrl('notaurl'), false, '非法 URL 不应视为本地 CPA'); +assert.strictEqual(api.normalizeLocalCpaStep9Mode('submit'), 'submit', 'submit 应保持为 submit'); +assert.strictEqual(api.normalizeLocalCpaStep9Mode('bypass'), 'bypass', 'bypass 应保持为 bypass'); +assert.strictEqual(api.normalizeLocalCpaStep9Mode('other'), 'submit', '未知模式应回退为 submit'); assert.strictEqual(api.shouldBypassStep9ForLocalCpa({ vpsUrl: 'http://127.0.0.1:8317/management.html#/oauth', localhostUrl: 'http://127.0.0.1:8317/codex/callback?code=abc&state=xyz', -}), true, '本地 CPA 且已有 callback 时应跳过远程提交流程'); +}), false, '默认模式下,本地 CPA 也应执行步骤 9'); assert.strictEqual(api.shouldBypassStep9ForLocalCpa({ + localCpaStep9Mode: 'bypass', + vpsUrl: 'http://127.0.0.1:8317/management.html#/oauth', + localhostUrl: 'http://127.0.0.1:8317/codex/callback?code=abc&state=xyz', +}), true, '切换为 bypass 后,本地 CPA 且已有 callback 时应跳过步骤 9'); + +assert.strictEqual(api.shouldBypassStep9ForLocalCpa({ + localCpaStep9Mode: 'bypass', vpsUrl: 'https://example.com/management.html#/oauth', localhostUrl: 'http://127.0.0.1:8317/codex/callback?code=abc&state=xyz', }), false, '远程 CPA 不应跳过步骤 9'); assert.strictEqual(api.shouldBypassStep9ForLocalCpa({ + localCpaStep9Mode: 'bypass', vpsUrl: 'http://127.0.0.1:8317/management.html#/oauth', localhostUrl: '', }), false, '没有 callback 时不应跳过步骤 9');