From e732dd2a202eb0aa787b16b9e85d895729f8fbe4 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Wed, 22 Apr 2026 13:21:34 +0800 Subject: [PATCH 01/42] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E8=AF=8A=E6=96=AD=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E5=AF=86=E7=A0=81=E9=A1=B5=E5=92=8C=E5=8F=AF=E8=A7=81?= =?UTF-8?q?=E6=80=A7=E5=85=83=E6=95=B0=E6=8D=AE=E6=8D=95=E8=8E=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- content/signup-page.js | 230 +++++++++++++++++- docs/使用教程.md | 155 ++++++++++++ manifest.json | 2 +- sidepanel/sidepanel.html | 2 +- tests/signup-entry-diagnostics.test.js | 314 +++++++++++++++++++++++++ 5 files changed, 691 insertions(+), 12 deletions(-) create mode 100644 docs/使用教程.md diff --git a/content/signup-page.js b/content/signup-page.js index 6f68fba..53881b7 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -385,13 +385,82 @@ function getSignupEntryStateSummary(snapshot = inspectSignupEntryState()) { } function getSignupEntryDiagnostics() { + const view = typeof window !== 'undefined' ? window : globalThis; + const safeGetComputedStyle = (el) => { + if (!el || typeof view?.getComputedStyle !== 'function') { + return null; + } + try { + return view.getComputedStyle(el); + } catch { + return null; + } + }; + const buildRectSummary = (el) => { + const rect = typeof el?.getBoundingClientRect === 'function' + ? el.getBoundingClientRect() + : null; + return rect + ? { + width: Math.round(rect.width || 0), + height: Math.round(rect.height || 0), + } + : null; + }; + const buildVisibilityMeta = (el) => { + const style = safeGetComputedStyle(el); + return { + className: String(el?.className || '').slice(0, 200), + hidden: Boolean(el?.hidden), + ariaHidden: el?.getAttribute?.('aria-hidden') || '', + inert: typeof el?.hasAttribute === 'function' ? el.hasAttribute('inert') : false, + display: style?.display || '', + visibility: style?.visibility || '', + opacity: style?.opacity || '', + pointerEvents: style?.pointerEvents || '', + }; + }; + const findBlockingAncestor = (el) => { + let current = el?.parentElement || null; + while (current) { + const style = safeGetComputedStyle(current); + const rect = buildRectSummary(current); + const hidden = Boolean(current.hidden); + const ariaHidden = current.getAttribute?.('aria-hidden') || ''; + const inert = typeof current.hasAttribute === 'function' ? current.hasAttribute('inert') : false; + const blockedByStyle = Boolean( + style + && ( + style.display === 'none' + || style.visibility === 'hidden' + || style.opacity === '0' + || style.pointerEvents === 'none' + ) + ); + const blockedByRect = Boolean(rect && (rect.width === 0 || rect.height === 0)); + if (hidden || ariaHidden === 'true' || inert || blockedByStyle || blockedByRect) { + return { + tag: (current.tagName || '').toLowerCase(), + id: current.id || '', + className: String(current.className || '').slice(0, 200), + hidden, + ariaHidden, + inert, + display: style?.display || '', + visibility: style?.visibility || '', + opacity: style?.opacity || '', + pointerEvents: style?.pointerEvents || '', + rect, + }; + } + current = current.parentElement; + } + return null; + }; const actionCandidates = document.querySelectorAll( 'a, button, [role="button"], [role="link"], input[type="button"], input[type="submit"]' ); const allActions = Array.from(actionCandidates).map((el) => { - const rect = typeof el?.getBoundingClientRect === 'function' - ? el.getBoundingClientRect() - : null; const text = getActionText(el); return { tag: (el.tagName || '').toLowerCase(), @@ -399,12 +468,7 @@ function getSignupEntryDiagnostics() { text: text.slice(0, 80), visible: isVisibleElement(el), enabled: isActionEnabled(el), - rect: rect - ? { - width: Math.round(rect.width || 0), - height: Math.round(rect.height || 0), - } - : null, + rect: buildRectSummary(el), }; }); const visibleActions = Array.from(actionCandidates) @@ -417,7 +481,20 @@ function getSignupEntryDiagnostics() { enabled: isActionEnabled(el), })) .filter((item) => item.text); - const signupLikeActions = allActions + const signupLikeActions = Array.from(actionCandidates) + .map((el) => { + const text = getActionText(el); + return { + tag: (el.tagName || '').toLowerCase(), + type: el.getAttribute?.('type') || '', + text: text.slice(0, 80), + visible: isVisibleElement(el), + enabled: isActionEnabled(el), + rect: buildRectSummary(el), + ...buildVisibilityMeta(el), + blockingAncestor: findBlockingAncestor(el), + }; + }) .filter((item) => item.text && SIGNUP_ENTRY_TRIGGER_PATTERN.test(item.text)) .slice(0, 12); @@ -425,15 +502,133 @@ function getSignupEntryDiagnostics() { url: location.href, title: document.title || '', readyState: document.readyState || '', + viewport: { + innerWidth: Math.round(Number(view?.innerWidth) || 0), + innerHeight: Math.round(Number(view?.innerHeight) || 0), + outerWidth: Math.round(Number(view?.outerWidth) || 0), + outerHeight: Math.round(Number(view?.outerHeight) || 0), + devicePixelRatio: Number(view?.devicePixelRatio) || 0, + }, hasEmailInput: Boolean(getSignupEmailInput()), hasPasswordInput: Boolean(getSignupPasswordInput()), bodyContainsSignupText: SIGNUP_ENTRY_TRIGGER_PATTERN.test(getPageTextSnapshot()), + signupLikeActionCounts: { + total: signupLikeActions.length, + visible: signupLikeActions.filter((item) => item.visible).length, + hidden: signupLikeActions.filter((item) => !item.visible).length, + }, signupLikeActions, visibleActions, bodyTextPreview: getPageTextSnapshot().slice(0, 240), }; } +function getSignupPasswordDiagnostics() { + const view = typeof window !== 'undefined' ? window : globalThis; + const safeGetComputedStyle = (el) => { + if (!el || typeof view?.getComputedStyle !== 'function') { + return null; + } + try { + return view.getComputedStyle(el); + } catch { + return null; + } + }; + const buildRectSummary = (el) => { + const rect = typeof el?.getBoundingClientRect === 'function' + ? el.getBoundingClientRect() + : null; + return rect + ? { + width: Math.round(rect.width || 0), + height: Math.round(rect.height || 0), + } + : null; + }; + const buildInputSummary = (el) => { + const style = safeGetComputedStyle(el); + return { + tag: (el?.tagName || '').toLowerCase(), + type: el?.getAttribute?.('type') || el?.type || '', + name: el?.getAttribute?.('name') || el?.name || '', + id: el?.id || '', + autocomplete: el?.getAttribute?.('autocomplete') || '', + placeholder: String(el?.getAttribute?.('placeholder') || '').slice(0, 80), + visible: isVisibleElement(el), + enabled: isActionEnabled(el), + valueLength: String(el?.value || '').length, + rect: buildRectSummary(el), + className: String(el?.className || '').slice(0, 200), + display: style?.display || '', + visibility: style?.visibility || '', + opacity: style?.opacity || '', + pointerEvents: style?.pointerEvents || '', + formAction: el?.form?.action || '', + }; + }; + const buildActionSummary = (el) => { + const style = safeGetComputedStyle(el); + return { + tag: (el?.tagName || '').toLowerCase(), + type: el?.getAttribute?.('type') || el?.type || '', + role: el?.getAttribute?.('role') || '', + text: getActionText(el).slice(0, 120), + visible: isVisibleElement(el), + enabled: isActionEnabled(el), + rect: buildRectSummary(el), + className: String(el?.className || '').slice(0, 200), + display: style?.display || '', + visibility: style?.visibility || '', + opacity: style?.opacity || '', + pointerEvents: style?.pointerEvents || '', + dataDdActionName: el?.getAttribute?.('data-dd-action-name') || '', + formAction: el?.form?.action || '', + }; + }; + const passwordInputs = Array.from(document.querySelectorAll( + 'input[type="password"], input[name*="password" i], input[autocomplete="new-password"], input[autocomplete="current-password"]' + )) + .map(buildInputSummary) + .slice(0, 8); + const actionCandidates = Array.from(document.querySelectorAll( + 'button, a, [role="button"], [role="link"], input[type="button"], input[type="submit"]' + )) + .map(buildActionSummary) + .filter((item) => item.text) + .slice(0, 16); + const visibleActions = actionCandidates.filter((item) => item.visible).slice(0, 12); + const submitButton = getSignupPasswordSubmitButton({ allowDisabled: true }); + const oneTimeCodeTrigger = findOneTimeCodeLoginTrigger(); + const retryState = getSignupPasswordTimeoutErrorPageState(); + + return { + url: location.href, + title: document.title || '', + readyState: document.readyState || '', + displayedEmail: getSignupPasswordDisplayedEmail(), + hasVisiblePasswordInput: Boolean(getSignupPasswordInput()), + passwordInputCount: passwordInputs.length, + visiblePasswordInputCount: passwordInputs.filter((item) => item.visible).length, + passwordInputs, + submitButton: submitButton ? buildActionSummary(submitButton) : null, + oneTimeCodeTrigger: oneTimeCodeTrigger ? buildActionSummary(oneTimeCodeTrigger) : null, + retryPage: Boolean(retryState), + retryEnabled: Boolean(retryState?.retryEnabled), + userAlreadyExistsBlocked: Boolean(retryState?.userAlreadyExistsBlocked), + visibleActions, + bodyTextPreview: getPageTextSnapshot().slice(0, 240), + }; +} + +function logSignupPasswordDiagnostics(context, level = 'warn') { + try { + log(`${context}:密码页诊断快照:${JSON.stringify(getSignupPasswordDiagnostics())}`, level); + } catch (error) { + console.warn('[MultiPage:signup-page] failed to build signup password diagnostics:', error?.message || error); + } +} + async function waitForSignupEntryState(options = {}) { const { timeout = 15000, @@ -620,6 +815,10 @@ async function step3_fillEmailPassword(payload) { snapshot = inspectSignupEntryState(); } + if (snapshot.state !== 'password_page' || !snapshot.passwordInput) { + logSignupPasswordDiagnostics('步骤 3:未能识别可填写的密码输入框'); + } + if (snapshot.state !== 'password_page' || !snapshot.passwordInput) { throw new Error('在密码页未找到密码输入框。URL: ' + location.href); } @@ -635,6 +834,12 @@ async function step3_fillEmailPassword(payload) { || getSignupPasswordSubmitButton({ allowDisabled: true }) || await waitForElementByText('button', /continue|sign\s*up|submit|注册|创建|create/i, 5000).catch(() => null); + if (!submitBtn) { + logSignupPasswordDiagnostics('步骤 3:未找到可提交的密码页按钮'); + } else if (typeof findOneTimeCodeLoginTrigger === 'function' && findOneTimeCodeLoginTrigger()) { + logSignupPasswordDiagnostics('步骤 3:当前密码页同时存在一次性验证码入口', 'info'); + } + // Report complete BEFORE submit, because submit causes page navigation // which kills the content script connection const signupVerificationRequestedAt = submitBtn ? Date.now() : null; @@ -1640,6 +1845,7 @@ async function prepareSignupVerificationFlow(payload = {}, timeout = 30000) { const start = Date.now(); let recoveryRound = 0; const maxRecoveryRounds = 3; + let passwordPageDiagnosticsLogged = false; while (Date.now() - start < timeout && recoveryRound < maxRecoveryRounds) { throwIfStopped(); @@ -1678,6 +1884,10 @@ async function prepareSignupVerificationFlow(payload = {}, timeout = 30000) { } if (snapshot.state === 'password') { + if (!passwordPageDiagnosticsLogged) { + passwordPageDiagnosticsLogged = true; + logSignupPasswordDiagnostics(`${prepareLogLabel}:页面仍停留在密码页`); + } if (!password) { throw new Error('当前回到了密码页,但没有可用密码,无法自动重新提交。'); } diff --git a/docs/使用教程.md b/docs/使用教程.md new file mode 100644 index 0000000..90eda7b --- /dev/null +++ b/docs/使用教程.md @@ -0,0 +1,155 @@ +# Codex 注册扩展更新与 Clash Verge 非港轮询配置教程 + +本教程用于说明如何更新 `Codex 注册扩展`,以及如何在 `Clash Verge` 中配置 `🔁 非港轮询`。 + +## 适用场景 + +- 已经安装本扩展,想更新到最新版本 +- 想用更方便的方式长期同步扩展更新 +- 需要在 `Clash Verge` 中启用 `🔁 非港轮询` + +## 准备内容 + +- 已安装好的扩展文件夹 +- 可以打开浏览器的 `扩展程序管理` 页面 +- 如需使用 `git pull`,本地已安装 `git` +- 如需使用 `GitHub Desktop`,本地已安装 `GitHub Desktop` +- 已安装 `Clash Verge`,并已导入可用订阅 + +## 操作步骤 + +### 第一部分:更新扩展 + +1. 使用 `GitHub Desktop` 更新 + 先安装 `GitHub Desktop`。 + 把当前扩展仓库交给 `GitHub Desktop` 管理后,之后就可以随时更新。 + `GitHub Desktop` 的具体使用方法本教程不展开,需要时可直接询问豆包。 + +2. 使用 `git pull` 更新(最方便,最推荐) + 先在本地安装 `git`。 + 打开终端后执行 `cd 扩展文件夹路径` 进入当前扩展目录。 + 接着执行 `git pull` 拉取最新更新。 + 这是最方便、最推荐的更新方式。 + +3. 手动下载最新版本覆盖更新 + 点击左上角的版本号或 `更新` 按钮。 + 下载最新版本到本地后,直接覆盖现有扩展文件夹即可。 + +4. 更新完成后重新加载扩展 + 不论使用哪种更新方式,更新完成后都必须打开浏览器的 `扩展程序管理` 页面。 + 找到该扩展后,手动点击一次 `重新加载`。 + 这一步一定要做,否则浏览器可能仍在使用旧版本。 + +### 第二部分:配置 `Clash Verge` 的 `🔁 非港轮询` + +#### 第一步:添加扩展脚本 + +1. 打开 `Clash Verge`,进入左侧的 `订阅`(`Profiles`)界面。 +2. 在右上角或对应位置找到并双击打开 `全局扩展脚本`(`Merge/Script`)。 +3. 将里面的内容全部清空,替换为下方格式化好的代码。 +4. 点击保存,使用右上角保存按钮或按 `Ctrl+S`。 + +💻 格式化后的代码(如果遇到格式错误,则让豆包修复一下) + +```javascript +function uniqPrepend(arr, items) { + if (!Array.isArray(arr)) arr = []; + for (var i = items.length - 1; i >= 0; i--) { + var item = items[i]; + var exists = false; + for (var j = 0; j < arr.length; j++) { + if (arr[j] === item) { + exists = true; + break; + } + } + if (!exists) arr.unshift(item); + } + return arr; +} + +function upsertGroup(groups, group) { + for (var i = 0; i < groups.length; i++) { + if (groups[i] && groups[i].name === group.name) { + groups[i] = group; + return groups; + } + } + groups.unshift(group); + return groups; +} + +function main(config, profileName) { + if (!config) return config; + + if (!Array.isArray(config["proxy-groups"])) { + config["proxy-groups"] = []; + } + + var groups = config["proxy-groups"]; + var LB_NAME = "🔁 非港轮询"; + + var excludeRegex = + "(?i)(" + + "香港|hong[ -]?kong|\\bhk\\b|\\bhkg\\b|🇭🇰" + + "|剩余流量|套餐到期|下次重置剩余|重置剩余|到期时间|流量重置" + + "|traffic|expire|expiration|subscription|subscribe|reset|plan" + + ")"; + + groups = upsertGroup(groups, { + name: LB_NAME, + type: "load-balance", + strategy: "round-robin", + "include-all-proxies": true, + "exclude-filter": excludeRegex, + url: "https://www.gstatic.com/generate_204", + interval: 300, + lazy: true, + "expected-status": 204 + }); + + var injected = false; + var entryNameRegex = /节点选择|代理|Proxy|PROXY|默认|GLOBAL|全局|选择/i; + + for (var i = 0; i < groups.length; i++) { + var g = groups[i]; + if (!g || g.type !== "select") continue; + + if (entryNameRegex.test(g.name || "")) { + if (!Array.isArray(g.proxies)) g.proxies = []; + g.proxies = uniqPrepend(g.proxies, [LB_NAME]); + injected = true; + } + } + + if (!injected) { + for (var k = 0; k < groups.length; k++) { + var g2 = groups[k]; + if (g2 && g2.type === "select") { + if (!Array.isArray(g2.proxies)) g2.proxies = []; + g2.proxies = uniqPrepend(g2.proxies, [LB_NAME]); + break; + } + } + } + + config["proxy-groups"] = groups; + return config; +} +``` + +#### 第二步:应用设置 + +1. 切换到左侧的 `代理`(`Proxies`)界面,也就是首页。 +2. 在顶部的分类中,通常会看到 `节点选择`、`Proxy` 或 `当前节点` 之类的分组。 +3. 在对应下拉框中选择 `🔁 非港轮询`。 +4. 确认右上角的 `代理模式`(`Mode`)已经设置为 `规则模式`(`Rule`)。 +5. 确认左侧 `设置` 中的 `系统代理`(`System Proxy`)已经开启。 + + +## 注意事项 + +- 不论使用哪种方式更新扩展,更新完成后都必须在浏览器的 `扩展程序管理` 页面重新加载一次该扩展。 +- 如果你长期更新扩展,优先使用 `git pull`,这是最方便、最推荐的方式。 +- 使用手动覆盖更新时,请直接覆盖当前扩展文件夹,避免同时保留多份不同版本的目录。 +- 在 `Clash Verge` 中粘贴脚本时,请先清空旧内容,再完整粘贴新代码并保存。 diff --git a/manifest.json b/manifest.json index c58c1fa..ced3774 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "manifest_version": 3, - "name": "多页面自动化", + "name": "codex-oauth-automation-extension", "version": "5.8", "version_name": "Pro5.8", "description": "用于自动执行多步骤 OAuth 注册流程", diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 6b75d35..6b99430 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -4,7 +4,7 @@ - 多页面自动化面板 + codex-oauth-automation-extension { +const api = new Function(` +const SIGNUP_ENTRY_TRIGGER_PATTERN = /鍏嶈垂娉ㄥ唽|绔嬪嵆娉ㄥ唽|娉ㄥ唽|sign\\s*up|register|create\\s*account|create\\s+account/i; +const location = { href: 'https://chatgpt.com/' }; +const hiddenSection = { + tagName: 'DIV', + id: 'mobile-cta', + className: 'max-xs:hidden', + hidden: false, + parentElement: null, + hasAttribute() { + return false; + }, + getAttribute(name) { + if (name === 'aria-hidden') return ''; + return ''; + }, + getBoundingClientRect() { + return { width: 0, height: 0 }; + }, + _style: { + display: 'none', + visibility: 'visible', + opacity: '1', + pointerEvents: 'auto', + }, +}; +const hiddenSignupButton = { + tagName: 'BUTTON', + textContent: 'Sign up for free', + disabled: false, + className: 'signup-button', + hidden: false, + parentElement: hiddenSection, + hasAttribute() { + return false; + }, + getBoundingClientRect() { + return { width: 0, height: 0 }; + }, + getAttribute(name) { + if (name === 'type') return ''; + if (name === 'aria-hidden') return ''; + return ''; + }, + _style: { + display: 'block', + visibility: 'visible', + opacity: '1', + pointerEvents: 'auto', + }, +}; +const document = { + title: 'ChatGPT', + readyState: 'complete', + querySelector() { + return null; + }, + querySelectorAll(selector) { + if (selector === 'a, button, [role="button"], [role="link"], input[type="button"], input[type="submit"]') { + return [hiddenSignupButton]; + } + return []; + }, +}; +const window = { + innerWidth: 390, + innerHeight: 844, + outerWidth: 390, + outerHeight: 844, + devicePixelRatio: 3, + getComputedStyle(el) { + return el?._style || { + display: 'block', + visibility: 'visible', + opacity: '1', + pointerEvents: 'auto', + }; + }, +}; + +function isVisibleElement(el) { + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && rect.width > 0 + && rect.height > 0; +} + +function getActionText(el) { + return [el?.textContent, el?.value, el?.getAttribute?.('aria-label'), el?.getAttribute?.('title')] + .filter(Boolean) + .join(' ') + .replace(/\\s+/g, ' ') + .trim(); +} + +function isActionEnabled(el) { + return Boolean(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true'; +} + +function getSignupEmailInput() { + return null; +} + +function getSignupPasswordInput() { + return null; +} + +function getPageTextSnapshot() { + return 'ChatGPT 登录'; +} + +${extractFunction('getSignupEntryDiagnostics')} + +return { + run() { + return getSignupEntryDiagnostics(); + }, +}; +`)(); + + const result = api.run(); + + assert.deepStrictEqual(result.viewport, { + innerWidth: 390, + innerHeight: 844, + outerWidth: 390, + outerHeight: 844, + devicePixelRatio: 3, + }); + assert.deepStrictEqual(result.signupLikeActionCounts, { + total: 1, + visible: 0, + hidden: 1, + }); + assert.equal(result.signupLikeActions[0]?.text, 'Sign up for free'); + assert.equal(result.signupLikeActions[0]?.className, 'signup-button'); + assert.equal(result.signupLikeActions[0]?.display, 'block'); + assert.equal(result.signupLikeActions[0]?.blockingAncestor?.className, 'max-xs:hidden'); + assert.equal(result.signupLikeActions[0]?.blockingAncestor?.display, 'none'); +}); + +test('signup password diagnostics summarizes password inputs, submit button, and alternate code entry', () => { +const api = new Function(` +const location = { href: 'https://auth.openai.com/create-account/password' }; +const form = { action: 'https://auth.openai.com/u/signup/password' }; +const passwordInput = { + tagName: 'INPUT', + type: 'password', + name: 'new-password', + id: 'password-field', + value: 'SecretLength14', + className: 'password-input', + disabled: false, + form, + getBoundingClientRect() { + return { width: 320, height: 44 }; + }, + getAttribute(name) { + if (name === 'type') return 'password'; + if (name === 'name') return 'new-password'; + if (name === 'autocomplete') return 'new-password'; + if (name === 'placeholder') return 'Password'; + if (name === 'aria-disabled') return 'false'; + return ''; + }, + _style: { + display: 'block', + visibility: 'visible', + opacity: '1', + pointerEvents: 'auto', + }, +}; +const submitButton = { + tagName: 'BUTTON', + textContent: 'Continue', + className: 'submit-btn', + disabled: false, + form, + getBoundingClientRect() { + return { width: 160, height: 40 }; + }, + getAttribute(name) { + if (name === 'type') return 'submit'; + if (name === 'aria-disabled') return 'false'; + if (name === 'data-dd-action-name') return 'Continue'; + return ''; + }, + _style: { + display: 'block', + visibility: 'visible', + opacity: '1', + pointerEvents: 'auto', + }, +}; +const oneTimeCodeButton = { + tagName: 'BUTTON', + textContent: 'Use a one-time code instead', + className: 'switch-btn', + disabled: false, + getBoundingClientRect() { + return { width: 220, height: 36 }; + }, + getAttribute(name) { + if (name === 'type') return 'button'; + if (name === 'aria-disabled') return 'false'; + return ''; + }, + _style: { + display: 'block', + visibility: 'visible', + opacity: '1', + pointerEvents: 'auto', + }, +}; +const document = { + title: 'Create your account', + readyState: 'complete', + querySelectorAll(selector) { + if (selector === 'input[type="password"], input[name*="password" i], input[autocomplete="new-password"], input[autocomplete="current-password"]') { + return [passwordInput]; + } + if (selector === 'button, a, [role="button"], [role="link"], input[type="button"], input[type="submit"]') { + return [submitButton, oneTimeCodeButton]; + } + return []; + }, +}; +const window = { + getComputedStyle(el) { + return el?._style || { + display: 'block', + visibility: 'visible', + opacity: '1', + pointerEvents: 'auto', + }; + }, +}; + +function isVisibleElement(el) { + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && rect.width > 0 + && rect.height > 0; +} + +function isActionEnabled(el) { + return Boolean(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true'; +} + +function getActionText(el) { + return [el?.textContent, el?.value, el?.getAttribute?.('aria-label'), el?.getAttribute?.('title')] + .filter(Boolean) + .join(' ') + .replace(/\\s+/g, ' ') + .trim(); +} + +function getPageTextSnapshot() { + return 'Create your account Use a one-time code instead'; +} + +function getSignupPasswordInput() { + return passwordInput; +} + +function getSignupPasswordSubmitButton() { + return submitButton; +} + +function getSignupPasswordDisplayedEmail() { + return 'user@example.com'; +} + +function findOneTimeCodeLoginTrigger() { + return oneTimeCodeButton; +} + +function getSignupPasswordTimeoutErrorPageState() { + return { + retryEnabled: true, + userAlreadyExistsBlocked: false, + }; +} + +${extractFunction('getSignupPasswordDiagnostics')} + +return { + run() { + return getSignupPasswordDiagnostics(); + }, +}; +`)(); + + const result = api.run(); + + assert.equal(result.url, 'https://auth.openai.com/create-account/password'); + assert.equal(result.displayedEmail, 'user@example.com'); + assert.equal(result.hasVisiblePasswordInput, true); + assert.equal(result.passwordInputCount, 1); + assert.equal(result.visiblePasswordInputCount, 1); + assert.equal(result.passwordInputs[0]?.name, 'new-password'); + assert.equal(result.passwordInputs[0]?.valueLength, 14); + assert.equal(result.submitButton?.text, 'Continue'); + assert.equal(result.oneTimeCodeTrigger?.text, 'Use a one-time code instead'); + assert.equal(result.retryPage, true); + assert.equal(result.retryEnabled, true); + assert.match(result.bodyTextPreview, /one-time code/); +}); From f8b919e8839fddc740e90b7d50a82d415f1b4b3d Mon Sep 17 00:00:00 2001 From: initiatione <269353933@qq.com> Date: Wed, 22 Apr 2026 13:57:21 +0800 Subject: [PATCH 02/42] Add codex2api as a protocol-first OAuth source This keeps the existing CPA, SUB2API, and contribution flows intact while introducing codex2api as a separate source that plugs into Step 7 and Step 10 through backend APIs instead of page DOM. The sidepanel now exposes codex2api settings, preserves the built-in local default through placeholder-only UI, and accepts user-provided public admin URLs. Step 7 now treats missing or invalid management secrets as terminal config errors so the flow stops instead of retrying needlessly. Constraint: New source must be additive and must not break existing CPA/SUB2API/contribution paths Rejected: Drive codex2api through admin page button clicks | too brittle against frontend DOM changes Rejected: Reuse contribution mode as a source surface | violates existing panelMode/runtime-mode boundary Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep codex2api on the protocol path unless its API becomes insufficient; do not add a panel content script without proving the protocol path cannot work Tested: npm test; targeted codex2api/auth retry loops repeated 40 times; full suite repeated 5 times Not-tested: Real browser run against a live codex2api instance with a real Admin Secret and OpenAI authorization --- README.md | 24 +++- background.js | 61 ++++++++- background/message-router.js | 2 + background/navigation-utils.js | 37 +++++- background/panel-bridge.js | 102 +++++++++++++++ background/steps/oauth-login.js | 21 +++ background/steps/platform-verify.js | 123 ++++++++++++++++++ sidepanel/contribution-mode.js | 2 + sidepanel/sidepanel.css | 11 ++ sidepanel/sidepanel.html | 11 ++ sidepanel/sidepanel.js | 38 +++++- ...ackground-account-history-settings.test.js | 16 ++- tests/background-contribution-mode.test.js | 4 +- ...background-navigation-utils-module.test.js | 20 +++ tests/background-panel-bridge-module.test.js | 50 +++++++ ...ckground-platform-verify-codex2api.test.js | 81 ++++++++++++ tests/background-step6-retry-limit.test.js | 85 ++++++++++++ tests/sidepanel-contribution-mode.test.js | 8 ++ 项目完整链路说明.md | 33 +++-- 项目开发规范(AI协作).md | 10 +- 项目文件结构说明.md | 13 +- 21 files changed, 720 insertions(+), 32 deletions(-) create mode 100644 tests/background-platform-verify-codex2api.test.js diff --git a/README.md b/README.md index 43468ac..9a2faa0 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,15 @@ 4. Step 1 会直接在 SUB2API 后台生成 OAuth 链接 5. Step 10 会把 localhost 回调提交回 SUB2API,并直接创建 OpenAI 账号 -### 方案 C:`Hotmail 账号池` +### 方案 C:`Codex2API + QQ / 163 / 163 VIP` + +1. `来源` 选择 `Codex2API` +2. 填好 `Codex2API` 后台地址、管理密钥 +3. `Mail` 与 `邮箱生成` 的配置方式同方案 A +4. Step 7 会直接通过 Codex2API 协议 `/api/admin/oauth/generate-auth-url` 生成 OAuth 链接 +5. Step 10 会把 localhost 回调中的 `code / state` 通过 `/api/admin/oauth/exchange-code` 直接提交给 Codex2API + +### 方案 D:`Hotmail 账号池` 1. `Mail` 选择 `Hotmail` 2. 在 `Hotmail 账号池` 中添加 `邮箱 / Client ID / Refresh Token` @@ -177,6 +185,20 @@ Step 1 和 Step 10 都依赖这个地址。 插件会在 Step 1 和 Step 10 自动从 `/api/v1/admin/proxies/all` 解析这个代理,并在 OAuth 链接生成、授权码交换和账号创建请求中附带 `proxy_id`。如果名称匹配到多个代理,请改填代理 ID;留空则不会发送 `proxy_id`。 +### `Codex2API` + +当 `来源 = Codex2API` 时,需要配置: + +- `Codex2API`:后台账号管理页地址,默认 `http://localhost:8080/admin/accounts` +- `管理密钥`:Codex2API 的 `Admin Secret` + +插件会在: + +- Step 7 调用 `POST /api/admin/oauth/generate-auth-url` 生成授权链接 +- Step 10 调用 `POST /api/admin/oauth/exchange-code` 完成 localhost callback 的授权码交换并创建账号 + +这条来源是协议直连,不依赖 Codex2API 后台页面的“添加账号 / OAuth 授权 / 生成授权链接”按钮 DOM。 + ### `Mail` 支持五种验证码来源: diff --git a/background.js b/background.js index 7297909..ca48d74 100644 --- a/background.js +++ b/background.js @@ -155,6 +155,7 @@ const OAUTH_FLOW_TIMEOUT_MS = 6 * 60 * 1000; const SUB2API_STEP1_RESPONSE_TIMEOUT_MS = 90000; const SUB2API_STEP9_RESPONSE_TIMEOUT_MS = 120000; const DEFAULT_SUB2API_URL = 'https://sub2api.hisence.fun/admin/accounts'; +const DEFAULT_CODEX2API_URL = 'http://localhost:8080/admin/accounts'; const DEFAULT_SUB2API_GROUP_NAME = 'codex'; const DEFAULT_SUB2API_PROXY_NAME = ''; const DEFAULT_SUB2API_REDIRECT_URI = 'http://localhost:1455/auth/callback'; @@ -251,6 +252,8 @@ const PERSISTED_SETTING_DEFAULTS = { sub2apiPassword: '', sub2apiGroupName: DEFAULT_SUB2API_GROUP_NAME, sub2apiDefaultProxyName: DEFAULT_SUB2API_PROXY_NAME, + codex2apiUrl: DEFAULT_CODEX2API_URL, + codex2apiAdminKey: '', customPassword: '', autoRunSkipFailures: false, autoRunFallbackThreadIntervalMinutes: 0, @@ -327,6 +330,8 @@ const DEFAULT_STATE = { sub2apiGroupId: null, // SUB2API 目标分组 ID。 sub2apiDraftName: null, // SUB2API 本轮预生成的账号名称。 sub2apiProxyId: null, // SUB2API 本轮使用的代理 ID。 + codex2apiSessionId: null, // Codex2API OAuth 会话 ID。 + codex2apiOAuthState: null, // Codex2API OAuth state。 flowStartTime: null, // 当前流程开始时间。 tabRegistry: {}, // 程序维护的标签页注册表。 sourceLastUrls: {}, // 各来源页面最近一次打开的地址记录。 @@ -651,7 +656,14 @@ function normalizeEmailGenerator(value = '') { } function normalizePanelMode(value = '') { - return String(value || '').trim().toLowerCase() === 'sub2api' ? 'sub2api' : 'cpa'; + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'sub2api') { + return 'sub2api'; + } + if (normalized === 'codex2api') { + return 'codex2api'; + } + return 'cpa'; } function normalizeMailProvider(value = '') { @@ -872,6 +884,10 @@ function normalizePersistentSettingValue(key, value) { return String(value || '').trim(); case 'sub2apiDefaultProxyName': return String(value || '').trim(); + case 'codex2apiUrl': + return normalizeCodex2ApiUrl(value); + case 'codex2apiAdminKey': + return String(value || '').trim(); case 'customPassword': return String(value || ''); case 'autoRunSkipFailures': @@ -3620,11 +3636,31 @@ function normalizeSub2ApiUrl(rawUrl) { return parsed.toString(); } +function normalizeCodex2ApiUrl(rawUrl) { + if (typeof navigationUtils !== 'undefined' && navigationUtils?.normalizeCodex2ApiUrl) { + return navigationUtils.normalizeCodex2ApiUrl(rawUrl); + } + const input = (rawUrl || '').trim() || DEFAULT_CODEX2API_URL; + const withProtocol = /^https?:\/\//i.test(input) ? input : `http://${input}`; + const parsed = new URL(withProtocol); + if (!parsed.pathname || parsed.pathname === '/' || parsed.pathname === '/admin') { + parsed.pathname = '/admin/accounts'; + } + parsed.hash = ''; + return parsed.toString(); +} + function getPanelMode(state = {}) { if (typeof navigationUtils !== 'undefined' && navigationUtils?.getPanelMode) { return navigationUtils.getPanelMode(state); } - return state.panelMode === 'sub2api' ? 'sub2api' : 'cpa'; + if (state.panelMode === 'sub2api') { + return 'sub2api'; + } + if (state.panelMode === 'codex2api') { + return 'codex2api'; + } + return 'cpa'; } function getPanelModeLabel(modeOrState) { @@ -3632,7 +3668,13 @@ function getPanelModeLabel(modeOrState) { return navigationUtils.getPanelModeLabel(modeOrState); } const mode = typeof modeOrState === 'string' ? modeOrState : getPanelMode(modeOrState); - return mode === 'sub2api' ? 'SUB2API' : 'CPA'; + if (mode === 'sub2api') { + return 'SUB2API'; + } + if (mode === 'codex2api') { + return 'Codex2API'; + } + return 'CPA'; } function isSignupPageHost(hostname = '') { @@ -3936,6 +3978,7 @@ function isRetryableContentScriptTransportError(error) { } const navigationUtils = self.MultiPageBackgroundNavigationUtils?.createNavigationUtils({ + DEFAULT_CODEX2API_URL, DEFAULT_SUB2API_URL, normalizeLocalCpaStep9Mode, }); @@ -4111,6 +4154,8 @@ function getDownstreamStateResets(step) { sub2apiOAuthState: null, sub2apiGroupId: null, sub2apiDraftName: null, + codex2apiSessionId: null, + codex2apiOAuthState: null, flowStartTime: null, password: null, lastEmailTimestamp: null, @@ -4896,6 +4941,8 @@ async function handleStepData(step, payload) { if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null; if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null; if (payload.sub2apiProxyId !== undefined) updates.sub2apiProxyId = payload.sub2apiProxyId || null; + if (payload.codex2apiSessionId !== undefined) updates.codex2apiSessionId = payload.codex2apiSessionId || null; + if (payload.codex2apiOAuthState !== undefined) updates.codex2apiOAuthState = payload.codex2apiOAuthState || null; if (Object.keys(updates).length) { await setState(updates); } @@ -6159,6 +6206,7 @@ const panelBridge = self.MultiPageBackgroundPanelBridge?.createPanelBridge({ closeConflictingTabsForSource, ensureContentScriptReadyOnTab, getPanelMode, + normalizeCodex2ApiUrl, normalizeSub2ApiUrl, rememberSourceLastUrl, sendToContentScript, @@ -6334,6 +6382,7 @@ const step10Executor = self.MultiPageBackgroundStep10?.createStep10Executor({ getTabId, isLocalhostOAuthCallbackUrl, isTabAlive, + normalizeCodex2ApiUrl, normalizeSub2ApiUrl, rememberSourceLastUrl, reuseOrCreateTab, @@ -6782,7 +6831,7 @@ async function runPreStep6CookieCleanup() { async function refreshOAuthUrlBeforeStep6(state) { if (state?.contributionModeExpected && !state?.contributionMode) { - throw new Error('步骤 7:当前自动流程预期使用贡献模式,但运行态 contributionMode 已丢失,已阻止回退到普通 CPA / SUB2API 链路。请重新进入贡献模式后再点击自动。'); + throw new Error('步骤 7:当前自动流程预期使用贡献模式,但运行态 contributionMode 已丢失,已阻止回退到普通 CPA / SUB2API / Codex2API 链路。请重新进入贡献模式后再点击自动。'); } if (state?.contributionMode && contributionOAuthManager?.startContributionFlow) { await addLog('步骤 7:contributionMode=true,走公开贡献接口,正在申请 OAuth 登录地址...', 'info'); @@ -6798,7 +6847,7 @@ async function refreshOAuthUrlBeforeStep6(state) { await handleStepData(1, { oauthUrl }); return oauthUrl; } - await addLog(`步骤 7:contributionMode=false,走普通 CPA / SUB2API 链路(当前面板:${getPanelModeLabel(state)}),正在刷新 OAuth 登录地址...`, 'info'); + await addLog(`步骤 7:contributionMode=false,走普通 CPA / SUB2API / Codex2API 链路(当前面板:${getPanelModeLabel(state)}),正在刷新 OAuth 登录地址...`, 'info'); console.log(LOG_PREFIX, '[refreshOAuthUrlBeforeStep6] requesting fresh OAuth directly from panel'); const refreshResult = await requestOAuthUrlFromPanel(state, { logLabel: '步骤 7' }); await handleStepData(1, refreshResult); @@ -7524,7 +7573,7 @@ async function executeContributionStep10(state) { async function executeStep10(state) { if (state?.contributionModeExpected && !state?.contributionMode) { - throw new Error('步骤 10:当前自动流程预期使用贡献模式,但运行态 contributionMode 已丢失,已阻止回退到普通 CPA / SUB2API 提交。请重新进入贡献模式后再点击自动。'); + throw new Error('步骤 10:当前自动流程预期使用贡献模式,但运行态 contributionMode 已丢失,已阻止回退到普通 CPA / SUB2API / Codex2API 提交。请重新进入贡献模式后再点击自动。'); } if (state?.contributionMode) { return executeContributionStep10(state); diff --git a/background/message-router.js b/background/message-router.js index b8580fd..1a4a92e 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -121,6 +121,8 @@ if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null; if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null; if (payload.sub2apiProxyId !== undefined) updates.sub2apiProxyId = payload.sub2apiProxyId || null; + if (payload.codex2apiSessionId !== undefined) updates.codex2apiSessionId = payload.codex2apiSessionId || null; + if (payload.codex2apiOAuthState !== undefined) updates.codex2apiOAuthState = payload.codex2apiOAuthState || null; if (Object.keys(updates).length) { await setState(updates); } diff --git a/background/navigation-utils.js b/background/navigation-utils.js index 2c7b793..edde364 100644 --- a/background/navigation-utils.js +++ b/background/navigation-utils.js @@ -3,6 +3,7 @@ })(typeof self !== 'undefined' ? self : globalThis, function createBackgroundNavigationUtilsModule() { function createNavigationUtils(deps = {}) { const { + DEFAULT_CODEX2API_URL, DEFAULT_SUB2API_URL, normalizeLocalCpaStep9Mode, } = deps; @@ -27,13 +28,36 @@ return parsed.toString(); } + function normalizeCodex2ApiUrl(rawUrl) { + const input = (rawUrl || '').trim() || DEFAULT_CODEX2API_URL; + const withProtocol = /^https?:\/\//i.test(input) ? input : `http://${input}`; + const parsed = new URL(withProtocol); + if (!parsed.pathname || parsed.pathname === '/' || parsed.pathname === '/admin') { + parsed.pathname = '/admin/accounts'; + } + parsed.hash = ''; + return parsed.toString(); + } + function getPanelMode(state = {}) { - return state.panelMode === 'sub2api' ? 'sub2api' : 'cpa'; + if (state.panelMode === 'sub2api') { + return 'sub2api'; + } + if (state.panelMode === 'codex2api') { + return 'codex2api'; + } + return 'cpa'; } function getPanelModeLabel(modeOrState) { const mode = typeof modeOrState === 'string' ? modeOrState : getPanelMode(modeOrState); - return mode === 'sub2api' ? 'SUB2API' : 'CPA'; + if (mode === 'sub2api') { + return 'SUB2API'; + } + if (mode === 'codex2api') { + return 'Codex2API'; + } + return 'CPA'; } function isSignupPageHost(hostname = '') { @@ -124,6 +148,14 @@ || candidate.pathname.startsWith('/login') || candidate.pathname === '/' ); + case 'codex2api-panel': + return Boolean(reference) + && candidate.origin === reference.origin + && ( + candidate.pathname.startsWith('/admin/accounts') + || candidate.pathname === '/admin' + || candidate.pathname === '/' + ); default: return false; } @@ -160,6 +192,7 @@ isSignupPageHost, isSignupPasswordPageUrl, matchesSourceUrlFamily, + normalizeCodex2ApiUrl, normalizeSub2ApiUrl, parseUrlSafely, shouldBypassStep9ForLocalCpa, diff --git a/background/panel-bridge.js b/background/panel-bridge.js index ad97e6c..a667960 100644 --- a/background/panel-bridge.js +++ b/background/panel-bridge.js @@ -8,6 +8,7 @@ closeConflictingTabsForSource, ensureContentScriptReadyOnTab, getPanelMode, + normalizeCodex2ApiUrl, normalizeSub2ApiUrl, rememberSourceLastUrl, sendToContentScript, @@ -17,7 +18,74 @@ SUB2API_STEP1_RESPONSE_TIMEOUT_MS, } = deps; + function normalizeAdminKey(value = '') { + return String(value || '').trim(); + } + + function extractStateFromAuthUrl(authUrl = '') { + try { + return new URL(authUrl).searchParams.get('state') || ''; + } catch { + return ''; + } + } + + function getCodex2ApiErrorMessage(payload, responseStatus = 500) { + const candidates = [ + payload?.error, + payload?.message, + payload?.detail, + payload?.reason, + ]; + const message = candidates + .map((value) => String(value || '').trim()) + .find(Boolean); + return message || `Codex2API 请求失败(HTTP ${responseStatus})。`; + } + + async function fetchCodex2ApiJson(origin, path, options = {}) { + const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 30000)); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(`${origin}${path}`, { + method: options.method || 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-Admin-Key': normalizeAdminKey(options.adminKey), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + signal: controller.signal, + }); + + let payload = {}; + try { + payload = await response.json(); + } catch { + payload = {}; + } + + if (!response.ok) { + throw new Error(getCodex2ApiErrorMessage(payload, response.status)); + } + + return payload; + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error('Codex2API 请求超时,请稍后重试。'); + } + throw error; + } finally { + clearTimeout(timer); + } + } + async function requestOAuthUrlFromPanel(state, options = {}) { + if (getPanelMode(state) === 'codex2api') { + return requestCodex2ApiOAuthUrl(state, options); + } if (getPanelMode(state) === 'sub2api') { return requestSub2ApiOAuthUrl(state, options); } @@ -74,6 +142,39 @@ return result || {}; } + async function requestCodex2ApiOAuthUrl(state, options = {}) { + const { logLabel = 'OAuth 刷新' } = options; + const codex2apiUrl = normalizeCodex2ApiUrl(state.codex2apiUrl); + const adminKey = normalizeAdminKey(state.codex2apiAdminKey); + + if (!adminKey) { + throw new Error('尚未配置 Codex2API 管理密钥,请先在侧边栏填写。'); + } + + const origin = new URL(codex2apiUrl).origin; + await addLog(`${logLabel}:正在通过 Codex2API 协议生成 OAuth 授权链接...`); + + const result = await fetchCodex2ApiJson(origin, '/api/admin/oauth/generate-auth-url', { + adminKey, + method: 'POST', + body: {}, + }); + + const oauthUrl = String(result?.auth_url || result?.authUrl || '').trim(); + const sessionId = String(result?.session_id || result?.sessionId || '').trim(); + const oauthState = extractStateFromAuthUrl(oauthUrl); + + if (!oauthUrl || !sessionId) { + throw new Error('Codex2API 未返回有效的 auth_url 或 session_id。'); + } + + return { + oauthUrl, + codex2apiSessionId: sessionId, + codex2apiOAuthState: oauthState || null, + }; + } + async function requestSub2ApiOAuthUrl(state, options = {}) { const { logLabel = 'OAuth 刷新' } = options; const sub2apiUrl = normalizeSub2ApiUrl(state.sub2apiUrl); @@ -135,6 +236,7 @@ return { requestOAuthUrlFromPanel, + requestCodex2ApiOAuthUrl, requestCpaOAuthUrl, requestSub2ApiOAuthUrl, }; diff --git a/background/steps/oauth-login.js b/background/steps/oauth-login.js index 596ac9f..0d1c9e8 100644 --- a/background/steps/oauth-login.js +++ b/background/steps/oauth-login.js @@ -23,6 +23,20 @@ throwIfStopped, } = deps; + function isManagementSecretConfigError(error) { + const message = String(typeof error === 'string' ? error : error?.message || '').trim(); + if (!message) { + return false; + } + + const mentionsSecret = /管理密钥|Admin Secret|X-Admin-Key|CPA Key/i.test(message); + if (!mentionsSecret) { + return false; + } + + return /缺少|未配置|请输入|无效|错误|失败|401|认证失败|未授权|unauthorized|invalid/i.test(message); + } + async function executeStep7(state) { if (!state.email) { throw new Error('缺少邮箱地址,请先完成步骤 3。'); @@ -99,6 +113,13 @@ if (isAddPhoneAuthFailure(err)) { throw err; } + if (isManagementSecretConfigError(err)) { + await addLog( + `步骤 7:检测到来源后台管理密钥缺失或错误,不再重试,当前流程停止。原因:${getErrorMessage(err)}`, + 'error' + ); + throw err; + } lastError = err; if (attempt >= STEP6_MAX_ATTEMPTS) { break; diff --git a/background/steps/platform-verify.js b/background/steps/platform-verify.js index 7edfe5d..72fa9f5 100644 --- a/background/steps/platform-verify.js +++ b/background/steps/platform-verify.js @@ -12,6 +12,7 @@ getTabId, isLocalhostOAuthCallbackUrl, isTabAlive, + normalizeCodex2ApiUrl, normalizeSub2ApiUrl, rememberSourceLastUrl, reuseOrCreateTab, @@ -21,7 +22,86 @@ SUB2API_STEP9_RESPONSE_TIMEOUT_MS, } = deps; + function normalizeString(value = '') { + return String(value || '').trim(); + } + + function parseLocalhostCallback(rawUrl) { + let parsed; + try { + parsed = new URL(rawUrl); + } catch { + throw new Error('步骤 10 捕获到的 localhost OAuth 回调地址格式无效,请重新执行步骤 9。'); + } + + const code = normalizeString(parsed.searchParams.get('code')); + const state = normalizeString(parsed.searchParams.get('state')); + if (!code || !state) { + throw new Error('步骤 10 捕获到的 localhost OAuth 回调地址缺少 code 或 state,请重新执行步骤 9。'); + } + + return { + url: parsed.toString(), + code, + state, + }; + } + + function getCodex2ApiErrorMessage(payload, responseStatus = 500) { + const details = [ + payload?.error, + payload?.message, + payload?.detail, + payload?.reason, + ] + .map((value) => normalizeString(value)) + .find(Boolean); + return details || `Codex2API 请求失败(HTTP ${responseStatus})。`; + } + + async function fetchCodex2ApiJson(origin, path, options = {}) { + const controller = new AbortController(); + const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 30000)); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(`${origin}${path}`, { + method: options.method || 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-Admin-Key': normalizeString(options.adminKey), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + signal: controller.signal, + }); + + let payload = {}; + try { + payload = await response.json(); + } catch { + payload = {}; + } + + if (!response.ok) { + throw new Error(getCodex2ApiErrorMessage(payload, response.status)); + } + + return payload; + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error('Codex2API 请求超时,请稍后重试。'); + } + throw error; + } finally { + clearTimeout(timer); + } + } + async function executeStep10(state) { + if (getPanelMode(state) === 'codex2api') { + return executeCodex2ApiStep10(state); + } if (getPanelMode(state) === 'sub2api') { return executeSub2ApiStep10(state); } @@ -89,6 +169,48 @@ } } + async function executeCodex2ApiStep10(state) { + if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) { + throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。'); + } + if (!state.localhostUrl) { + throw new Error('缺少 localhost 回调地址,请先完成步骤 9。'); + } + if (!state.codex2apiSessionId) { + throw new Error('缺少 Codex2API 会话信息,请重新执行步骤 7。'); + } + if (!normalizeString(state.codex2apiAdminKey)) { + throw new Error('尚未配置 Codex2API 管理密钥,请先在侧边栏填写。'); + } + + const callback = parseLocalhostCallback(state.localhostUrl); + const expectedState = normalizeString(state.codex2apiOAuthState); + if (expectedState && expectedState !== callback.state) { + throw new Error('Codex2API 回调 state 与当前授权会话不匹配,请重新执行步骤 7。'); + } + + const codex2apiUrl = normalizeCodex2ApiUrl(state.codex2apiUrl); + const origin = new URL(codex2apiUrl).origin; + + await addLog('步骤 10:正在向 Codex2API 提交回调并创建账号...'); + const result = await fetchCodex2ApiJson(origin, '/api/admin/oauth/exchange-code', { + adminKey: state.codex2apiAdminKey, + method: 'POST', + body: { + session_id: state.codex2apiSessionId, + code: callback.code, + state: callback.state, + }, + }); + + const verifiedStatus = normalizeString(result?.message) || 'Codex2API OAuth 账号添加成功'; + await addLog(`步骤 10:${verifiedStatus}`, 'ok'); + await completeStepFromBackground(10, { + localhostUrl: callback.url, + verifiedStatus, + }); + } + async function executeSub2ApiStep10(state) { if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) { throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。'); @@ -160,6 +282,7 @@ return { executeCpaStep10, + executeCodex2ApiStep10, executeStep10, executeSub2ApiStep10, }; diff --git a/sidepanel/contribution-mode.js b/sidepanel/contribution-mode.js index 50aedf7..c54081c 100644 --- a/sidepanel/contribution-mode.js +++ b/sidepanel/contribution-mode.js @@ -24,6 +24,8 @@ dom.rowSub2ApiPassword, dom.rowSub2ApiGroup, dom.rowSub2ApiDefaultProxy, + dom.rowCodex2ApiUrl, + dom.rowCodex2ApiAdminKey, dom.rowCustomPassword, dom.rowAccountRunHistoryHelperBaseUrl, ].filter(Boolean); diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index f40f857..b5002c9 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -806,6 +806,17 @@ header { flex-shrink: 0; } +#row-codex2api-url .data-label, +#row-codex2api-admin-key .data-label { + width: 76px; +} + +#row-codex2api-url .data-label { + font-size: 10px; + letter-spacing: 0.02em; + text-transform: none; +} + .data-value { font-size: 13px; color: var(--text-muted); diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 6b75d35..dd1e052 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -112,6 +112,7 @@ + +
账户密码
diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 37a6d3e..34e6870 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -94,6 +94,10 @@ const rowSub2ApiGroup = document.getElementById('row-sub2api-group'); const inputSub2ApiGroup = document.getElementById('input-sub2api-group'); const rowSub2ApiDefaultProxy = document.getElementById('row-sub2api-default-proxy'); const inputSub2ApiDefaultProxy = document.getElementById('input-sub2api-default-proxy'); +const rowCodex2ApiUrl = document.getElementById('row-codex2api-url'); +const inputCodex2ApiUrl = document.getElementById('input-codex2api-url'); +const rowCodex2ApiAdminKey = document.getElementById('row-codex2api-admin-key'); +const inputCodex2ApiAdminKey = document.getElementById('input-codex2api-admin-key'); const rowCustomPassword = document.getElementById('row-custom-password'); const selectMailProvider = document.getElementById('select-mail-provider'); const btnMailLogin = document.getElementById('btn-mail-login'); @@ -1479,6 +1483,8 @@ function collectSettingsPayload() { sub2apiPassword: inputSub2ApiPassword.value, sub2apiGroupName: inputSub2ApiGroup.value.trim(), sub2apiDefaultProxyName: inputSub2ApiDefaultProxy.value.trim(), + codex2apiUrl: inputCodex2ApiUrl.value.trim(), + codex2apiAdminKey: inputCodex2ApiAdminKey.value.trim(), ...(contributionModeEnabled ? {} : { customPassword: inputPassword.value, }), @@ -1858,6 +1864,8 @@ function applySettingsState(state) { inputSub2ApiPassword.value = state?.sub2apiPassword || ''; inputSub2ApiGroup.value = state?.sub2apiGroupName || ''; inputSub2ApiDefaultProxy.value = state?.sub2apiDefaultProxyName || ''; + inputCodex2ApiUrl.value = state?.codex2apiUrl || ''; + inputCodex2ApiAdminKey.value = state?.codex2apiAdminKey || ''; const restoredMailProvider = isCustomMailProvider(state?.mailProvider) || [ICLOUD_PROVIDER, 'hotmail-api', GMAIL_PROVIDER, 'luckmail-api', '163', '163-vip', 'qq', 'inbucket', '2925', 'cloudflare-temp-email'].includes(String(state?.mailProvider || '').trim()) ? String(state?.mailProvider || '163').trim() @@ -2864,18 +2872,24 @@ async function saveCloudflareTempEmailDomainSettings(domains, activeDomain, opti function updatePanelModeUI() { const useSub2Api = selectPanelMode.value === 'sub2api'; - rowVpsUrl.style.display = useSub2Api ? 'none' : ''; - rowVpsPassword.style.display = useSub2Api ? 'none' : ''; - rowLocalCpaStep9Mode.style.display = useSub2Api ? 'none' : ''; + const useCodex2Api = selectPanelMode.value === 'codex2api'; + const useCpa = !useSub2Api && !useCodex2Api; + rowVpsUrl.style.display = useCpa ? '' : 'none'; + rowVpsPassword.style.display = useCpa ? '' : 'none'; + rowLocalCpaStep9Mode.style.display = useCpa ? '' : 'none'; rowSub2ApiUrl.style.display = useSub2Api ? '' : 'none'; rowSub2ApiEmail.style.display = useSub2Api ? '' : 'none'; rowSub2ApiPassword.style.display = useSub2Api ? '' : 'none'; rowSub2ApiGroup.style.display = useSub2Api ? '' : 'none'; rowSub2ApiDefaultProxy.style.display = useSub2Api ? '' : 'none'; + rowCodex2ApiUrl.style.display = useCodex2Api ? '' : 'none'; + rowCodex2ApiAdminKey.style.display = useCodex2Api ? '' : 'none'; const step9Btn = document.querySelector('.step-btn[data-step-key="platform-verify"]'); if (step9Btn) { - step9Btn.textContent = useSub2Api ? 'SUB2API 回调验证' : 'CPA 回调验证'; + step9Btn.textContent = useSub2Api + ? 'SUB2API 回调验证' + : (useCodex2Api ? 'Codex2API 回调验证' : 'CPA 回调验证'); } } @@ -4270,6 +4284,22 @@ inputSub2ApiDefaultProxy.addEventListener('blur', () => { saveSettings({ silent: true }).catch(() => { }); }); +inputCodex2ApiUrl.addEventListener('input', () => { + markSettingsDirty(true); + scheduleSettingsAutoSave(); +}); +inputCodex2ApiUrl.addEventListener('blur', () => { + saveSettings({ silent: true }).catch(() => { }); +}); + +inputCodex2ApiAdminKey.addEventListener('input', () => { + markSettingsDirty(true); + scheduleSettingsAutoSave(); +}); +inputCodex2ApiAdminKey.addEventListener('blur', () => { + saveSettings({ silent: true }).catch(() => { }); +}); + inputEmailPrefix.addEventListener('input', () => { maybeClearGeneratedAliasAfterEmailPrefixChange().catch(() => { }); syncManagedAliasBaseEmailDraftFromInput(); diff --git a/tests/background-account-history-settings.test.js b/tests/background-account-history-settings.test.js index ef54eda..5a2ebe8 100644 --- a/tests/background-account-history-settings.test.js +++ b/tests/background-account-history-settings.test.js @@ -50,6 +50,7 @@ function extractFunction(name) { test('background account history settings are normalized independently from hotmail service mode', () => { const bundle = [ + extractFunction('normalizeCodex2ApiUrl'), extractFunction('normalizeHotmailLocalBaseUrl'), extractFunction('normalizeAccountRunHistoryHelperBaseUrl'), extractFunction('normalizeVerificationResendCount'), @@ -60,6 +61,7 @@ test('background account history settings are normalized independently from hotm const DEFAULT_HOTMAIL_LOCAL_BASE_URL = 'http://127.0.0.1:17373'; const DEFAULT_ACCOUNT_RUN_HISTORY_HELPER_BASE_URL = DEFAULT_HOTMAIL_LOCAL_BASE_URL; const DEFAULT_HOTMAIL_REMOTE_BASE_URL = ''; +const DEFAULT_CODEX2API_URL = 'http://localhost:8080/admin/accounts'; const DEFAULT_VERIFICATION_RESEND_COUNT = 4; const DEFAULT_SUB2API_PROXY_NAME = ''; const HOTMAIL_SERVICE_MODE_REMOTE = 'remote'; @@ -70,7 +72,7 @@ const PERSISTED_SETTING_DEFAULTS = { autoStepDelaySeconds: null, mailProvider: '163', }; -function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : 'cpa'; } +function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : (value === 'codex2api' ? 'codex2api' : 'cpa'); } function normalizeLocalCpaStep9Mode(value) { return value === 'bypass' ? 'bypass' : 'submit'; } function normalizeAutoRunFallbackThreadIntervalMinutes(value) { return Number(value) || 0; } function normalizeAutoRunDelayMinutes(value) { return Number(value) || 30; } @@ -118,4 +120,16 @@ return { api.normalizePersistentSettingValue('sub2apiDefaultProxyName', ' proxy-a '), 'proxy-a' ); + assert.equal( + api.normalizePersistentSettingValue('codex2apiUrl', 'localhost:8080/admin'), + 'http://localhost:8080/admin/accounts' + ); + assert.equal( + api.normalizePersistentSettingValue('codex2apiUrl', 'https://codex-admin.example.com/'), + 'https://codex-admin.example.com/admin/accounts' + ); + assert.equal( + api.normalizePersistentSettingValue('codex2apiAdminKey', ' secret-key '), + 'secret-key' + ); }); diff --git a/tests/background-contribution-mode.test.js b/tests/background-contribution-mode.test.js index b3d6901..0d5682c 100644 --- a/tests/background-contribution-mode.test.js +++ b/tests/background-contribution-mode.test.js @@ -536,7 +536,7 @@ return { refreshOAuthUrlBeforeStep6 }; delete globalThis.LOG_PREFIX; }); -test('refreshOAuthUrlBeforeStep6 logs the normal CPA/SUB2API path explicitly when contributionMode=false', async () => { +test('refreshOAuthUrlBeforeStep6 logs the normal CPA/SUB2API/Codex2API path explicitly when contributionMode=false', async () => { const bundle = extractFunction(backgroundSource, 'refreshOAuthUrlBeforeStep6'); const calls = []; @@ -574,7 +574,7 @@ return { refreshOAuthUrlBeforeStep6 }; assert.equal(oauthUrl, 'https://panel.example.com/oauth'); assert.deepStrictEqual(calls, [ - { type: 'log', message: '步骤 7:contributionMode=false,走普通 CPA / SUB2API 链路(当前面板:SUB2API),正在刷新 OAuth 登录地址...' }, + { type: 'log', message: '步骤 7:contributionMode=false,走普通 CPA / SUB2API / Codex2API 链路(当前面板:SUB2API),正在刷新 OAuth 登录地址...' }, { type: 'panel' }, { type: 'step', diff --git a/tests/background-navigation-utils-module.test.js b/tests/background-navigation-utils-module.test.js index cf03bf0..a501ac4 100644 --- a/tests/background-navigation-utils-module.test.js +++ b/tests/background-navigation-utils-module.test.js @@ -15,3 +15,23 @@ test('navigation utils module exposes a factory', () => { assert.equal(typeof api?.createNavigationUtils, 'function'); }); + +test('navigation utils support codex2api mode and url normalization', () => { + const source = fs.readFileSync('background/navigation-utils.js', 'utf8'); + const globalScope = {}; + + const api = new Function('self', `${source}; return self.MultiPageBackgroundNavigationUtils;`)(globalScope); + const utils = api.createNavigationUtils({ + DEFAULT_CODEX2API_URL: 'http://localhost:8080/admin/accounts', + DEFAULT_SUB2API_URL: 'https://sub.example.com/admin/accounts', + normalizeLocalCpaStep9Mode: (value) => value, + }); + + assert.equal(utils.normalizeCodex2ApiUrl('localhost:8080/admin'), 'http://localhost:8080/admin/accounts'); + assert.equal( + utils.normalizeCodex2ApiUrl('https://codex-admin.example.com/'), + 'https://codex-admin.example.com/admin/accounts' + ); + assert.equal(utils.getPanelMode({ panelMode: 'codex2api' }), 'codex2api'); + assert.equal(utils.getPanelModeLabel('codex2api'), 'Codex2API'); +}); diff --git a/tests/background-panel-bridge-module.test.js b/tests/background-panel-bridge-module.test.js index 8473571..353c990 100644 --- a/tests/background-panel-bridge-module.test.js +++ b/tests/background-panel-bridge-module.test.js @@ -21,3 +21,53 @@ test('panel bridge requests oauth url with step 7 log label payload', () => { assert.match(source, /logStep:\s*7/); assert.doesNotMatch(source, /logStep:\s*6/); }); + +test('panel bridge can request codex2api oauth url via protocol', async () => { + const source = fs.readFileSync('background/panel-bridge.js', 'utf8'); + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url, options = {}) => { + assert.equal(url, 'http://localhost:8080/api/admin/oauth/generate-auth-url'); + assert.equal(options.method, 'POST'); + assert.equal(options.headers['X-Admin-Key'], 'admin-secret'); + return { + ok: true, + json: async () => ({ + auth_url: 'https://auth.openai.com/authorize?state=oauth-state', + session_id: 'session-123', + }), + }; + }; + + try { + const api = new Function('self', `${source}; return self.MultiPageBackgroundPanelBridge;`)({}); + const bridge = api.createPanelBridge({ + addLog: async () => {}, + chrome: {}, + closeConflictingTabsForSource: async () => {}, + ensureContentScriptReadyOnTab: async () => {}, + getPanelMode: () => 'codex2api', + normalizeCodex2ApiUrl: (value) => value ? `http://${value.replace(/^https?:\/\//, '')}`.replace(/\/admin$/, '/admin/accounts') : 'http://localhost:8080/admin/accounts', + normalizeSub2ApiUrl: (value) => value, + rememberSourceLastUrl: async () => {}, + sendToContentScript: async () => ({}), + sendToContentScriptResilient: async () => ({}), + waitForTabUrlFamily: async () => null, + DEFAULT_SUB2API_GROUP_NAME: 'codex', + SUB2API_STEP1_RESPONSE_TIMEOUT_MS: 90000, + }); + + const result = await bridge.requestOAuthUrlFromPanel({ + panelMode: 'codex2api', + codex2apiUrl: 'localhost:8080/admin', + codex2apiAdminKey: 'admin-secret', + }, { logLabel: '步骤 7' }); + + assert.deepStrictEqual(result, { + oauthUrl: 'https://auth.openai.com/authorize?state=oauth-state', + codex2apiSessionId: 'session-123', + codex2apiOAuthState: 'oauth-state', + }); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/background-platform-verify-codex2api.test.js b/tests/background-platform-verify-codex2api.test.js new file mode 100644 index 0000000..44c8bcc --- /dev/null +++ b/tests/background-platform-verify-codex2api.test.js @@ -0,0 +1,81 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const test = require('node:test'); + +test('platform verify module supports codex2api protocol callback exchange', async () => { + const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8'); + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url, options = {}) => { + assert.equal(url, 'http://localhost:8080/api/admin/oauth/exchange-code'); + assert.equal(options.method, 'POST'); + assert.equal(options.headers['X-Admin-Key'], 'admin-secret'); + assert.deepStrictEqual(JSON.parse(options.body), { + session_id: 'session-123', + code: 'callback-code', + state: 'oauth-state', + }); + return { + ok: true, + json: async () => ({ + message: 'OAuth 账号 flow@example.com 添加成功', + id: 42, + email: 'flow@example.com', + plan_type: 'pro', + }), + }; + }; + + try { + const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({}); + const completed = []; + const logs = []; + const executor = api.createStep10Executor({ + addLog: async (message, level = 'info') => { + logs.push({ message, level }); + }, + chrome: {}, + closeConflictingTabsForSource: async () => {}, + completeStepFromBackground: async (step, payload) => { + completed.push({ step, payload }); + }, + ensureContentScriptReadyOnTab: async () => {}, + getPanelMode: () => 'codex2api', + getTabId: async () => 0, + isLocalhostOAuthCallbackUrl: (value) => String(value || '').includes('/auth/callback?code='), + isTabAlive: async () => false, + normalizeCodex2ApiUrl: () => 'http://localhost:8080/admin/accounts', + normalizeSub2ApiUrl: (value) => value, + rememberSourceLastUrl: async () => {}, + reuseOrCreateTab: async () => 0, + sendToContentScript: async () => ({}), + sendToContentScriptResilient: async () => ({}), + shouldBypassStep9ForLocalCpa: () => false, + SUB2API_STEP9_RESPONSE_TIMEOUT_MS: 120000, + }); + + await executor.executeStep10({ + panelMode: 'codex2api', + localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state', + codex2apiUrl: 'http://localhost:8080/admin/accounts', + codex2apiAdminKey: 'admin-secret', + codex2apiSessionId: 'session-123', + codex2apiOAuthState: 'oauth-state', + }); + + assert.deepStrictEqual(logs, [ + { message: '步骤 10:正在向 Codex2API 提交回调并创建账号...', level: 'info' }, + { message: '步骤 10:OAuth 账号 flow@example.com 添加成功', level: 'ok' }, + ]); + assert.deepStrictEqual(completed, [ + { + step: 10, + payload: { + localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state', + verifiedStatus: 'OAuth 账号 flow@example.com 添加成功', + }, + }, + ]); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/background-step6-retry-limit.test.js b/tests/background-step6-retry-limit.test.js index af1783c..091a579 100644 --- a/tests/background-step6-retry-limit.test.js +++ b/tests/background-step6-retry-limit.test.js @@ -177,3 +177,88 @@ test('step 7 starts a new oauth timeout window for each refreshed oauth url', as }, ]); }); + +test('step 7 stops immediately when management secret is missing', async () => { + const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageBackgroundStep7;`)(globalScope); + + const events = { + refreshCalls: 0, + sendCalls: 0, + logs: [], + }; + + const executor = api.createStep7Executor({ + addLog: async (message, level = 'info') => { + events.logs.push({ message, level }); + }, + completeStepFromBackground: async () => {}, + getErrorMessage: (error) => error?.message || String(error || ''), + getLoginAuthStateLabel: (state) => state || 'unknown', + getState: async () => ({ email: 'user@example.com', password: 'secret' }), + isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable', + isStep6SuccessResult: (result) => result?.step6Outcome === 'success', + refreshOAuthUrlBeforeStep6: async () => { + events.refreshCalls += 1; + throw new Error('尚未配置 Codex2API 管理密钥,请先在侧边栏填写。'); + }, + reuseOrCreateTab: async () => {}, + sendToContentScriptResilient: async () => { + events.sendCalls += 1; + return { step6Outcome: 'success' }; + }, + STEP6_MAX_ATTEMPTS: 3, + throwIfStopped: () => {}, + }); + + await assert.rejects( + () => executor.executeStep7({ email: 'user@example.com', password: 'secret' }), + /管理密钥/ + ); + + assert.equal(events.refreshCalls, 1); + assert.equal(events.sendCalls, 0); + assert.ok(events.logs.some(({ message }) => /管理密钥缺失或错误,不再重试,当前流程停止/.test(message))); + assert.ok(!events.logs.some(({ message }) => /准备重试/.test(message))); +}); + +test('step 7 stops immediately when management secret is invalid', async () => { + const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageBackgroundStep7;`)(globalScope); + + const events = { + refreshCalls: 0, + logs: [], + }; + + const executor = api.createStep7Executor({ + addLog: async (message, level = 'info') => { + events.logs.push({ message, level }); + }, + completeStepFromBackground: async () => {}, + getErrorMessage: (error) => error?.message || String(error || ''), + getLoginAuthStateLabel: (state) => state || 'unknown', + getState: async () => ({ email: 'user@example.com', password: 'secret' }), + isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable', + isStep6SuccessResult: (result) => result?.step6Outcome === 'success', + refreshOAuthUrlBeforeStep6: async () => { + events.refreshCalls += 1; + throw new Error('Codex2API 请求失败(HTTP 401)。X-Admin-Key 无效或未授权。'); + }, + reuseOrCreateTab: async () => {}, + sendToContentScriptResilient: async () => ({ step6Outcome: 'success' }), + STEP6_MAX_ATTEMPTS: 3, + throwIfStopped: () => {}, + }); + + await assert.rejects( + () => executor.executeStep7({ email: 'user@example.com', password: 'secret' }), + /401|未授权|无效/ + ); + + assert.equal(events.refreshCalls, 1); + assert.ok(events.logs.some(({ message }) => /管理密钥缺失或错误,不再重试,当前流程停止/.test(message))); + assert.ok(!events.logs.some(({ message }) => /准备重试/.test(message))); +}); diff --git a/tests/sidepanel-contribution-mode.test.js b/tests/sidepanel-contribution-mode.test.js index fca11f2..decc8cb 100644 --- a/tests/sidepanel-contribution-mode.test.js +++ b/tests/sidepanel-contribution-mode.test.js @@ -135,6 +135,8 @@ const inputSub2ApiEmail = { value: 'user@example.com' }; const inputSub2ApiPassword = { value: 'sub-secret' }; const inputSub2ApiGroup = { value: ' codex ' }; const inputSub2ApiDefaultProxy = { value: ' proxy-a ' }; +const inputCodex2ApiUrl = { value: 'http://localhost:8080/admin/accounts' }; +const inputCodex2ApiAdminKey = { value: 'codex-admin-secret' }; const inputPassword = { value: 'Secret123!' }; const selectMailProvider = { value: '163' }; const selectEmailGenerator = { value: 'duck' }; @@ -196,6 +198,8 @@ return { assert.equal(normalPayload.customPassword, 'Secret123!'); assert.equal(normalPayload.accountRunHistoryTextEnabled, true); assert.equal(normalPayload.accountRunHistoryHelperBaseUrl, 'http://127.0.0.1:17373'); + assert.equal(normalPayload.codex2apiUrl, 'http://localhost:8080/admin/accounts'); + assert.equal(normalPayload.codex2apiAdminKey, 'codex-admin-secret'); }); test('contribution mode manager enters mode, starts main auto flow, polls contribution status, and exits cleanly', async () => { @@ -264,6 +268,8 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri rowSub2ApiGroup: createElement(), rowSub2ApiPassword: createElement(), rowSub2ApiUrl: createElement(), + rowCodex2ApiUrl: createElement(), + rowCodex2ApiAdminKey: createElement(), rowVpsPassword: createElement(), rowVpsUrl: createElement(), selectPanelMode: createElement({ value: 'sub2api' }), @@ -414,6 +420,8 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri assert.equal(dom.contributionModeSummary.textContent.length > 0, true); assert.equal(dom.btnContributionMode.classList.contains('is-active'), true); assert.equal(dom.rowVpsUrl.classList.contains('is-contribution-hidden'), true); + assert.equal(dom.rowCodex2ApiUrl.classList.contains('is-contribution-hidden'), true); + assert.equal(dom.rowCodex2ApiAdminKey.classList.contains('is-contribution-hidden'), true); assert.ok(closeConfigMenuCount >= 1); assert.ok(closeAccountRecordsCount >= 1); assert.ok(updatePanelModeCount >= 1); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index 7f4f5c6..fd759a6 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -22,7 +22,7 @@ - 刷新 OAuth 链接并登录 - 轮询登录验证码 - 自动确认 OAuth 同意页 -- 把 localhost 回调提交到 CPA 或 SUB2API +- 把 localhost 回调提交到 CPA、SUB2API 或 Codex2API ## 2. 核心运行参与者 @@ -152,6 +152,7 @@ 保存持久配置与账号运行历史: - CPA / SUB2API 配置 +- Codex2API 配置 - 邮箱 provider 配置 - Hotmail 账号池 - 2925 账号池 @@ -166,7 +167,7 @@ 注意: - `contributionMode` 不属于持久配置,也不参与导入/导出;它只存在于运行态 -- `panelMode` 仍然只表示 `cpa | sub2api` 来源,不能把贡献模式实现成新的来源枚举 +- `panelMode` 当前表示 `cpa | sub2api | codex2api` 三个来源;贡献模式仍不是新的来源枚举 当启用了独立的账号运行历史本地同步配置时,账号运行历史会通过 [scripts/hotmail_helper.py](c:/Users/projectf/Downloads/codex注册扩展/scripts/hotmail_helper.py) 整体同步写入 `data/account-run-history.json` 快照文件,便于开发者直接查看完整记录。 这条配置链路独立于 `mailProvider` 和 Hotmail 的接码模式。 @@ -189,10 +190,10 @@ 1. 用户点击顶部 `贡献` 2. sidepanel 直接通过 `SET_CONTRIBUTION_MODE` 切换运行态,不再弹确认窗口 3. 进入贡献模式后会强制 `panelMode = cpa`,并临时清空运行态 `customPassword`、禁用运行态账号记录快照同步 -4. sidepanel 隐藏 CPA 管理地址、管理密钥、SUB2API 配置、自定义密码、本地同步等普通模式配置,并禁用来源选择、配置菜单和记录入口 +4. sidepanel 隐藏 CPA 管理地址、管理密钥、SUB2API 配置、Codex2API 配置、自定义密码、本地同步等普通模式配置,并禁用来源选择、配置菜单和记录入口 5. 用户点击 `开始贡献` 后,不再单独走一条旁路 OAuth 流程,而是直接复用顶部 `自动` 的同一套主自动流 6. 步骤 1~6 仍按原来的注册自动化执行 -7. 当主流程进入步骤 7 时,后台改为调用公开接口 `POST https://apikey.qzz.io/oauth/api/start` 申请贡献登录地址,而不是去 CPA / SUB2API 面板刷新 OAuth +7. 当主流程进入步骤 7 时,后台改为调用公开接口 `POST https://apikey.qzz.io/oauth/api/start` 申请贡献登录地址,而不是去 CPA / SUB2API / Codex2API 来源刷新 OAuth 8. 步骤 7 拿到 `session_id / auth_url / state` 后,继续沿用原有登录链路进入授权页 9. 步骤 9 仍负责捕获 localhost callback;贡献模式下后台也会持续监听导航变化,必要时提前兼容处理 callback 10. 步骤 10 在贡献模式下不再打开 CPA 管理页,而是围绕公开贡献会话做 callback 提交兼容和最终状态确认;当状态进入 `auto_approved / auto_rejected / manual_review_required / expired / error` 时结束 @@ -378,7 +379,10 @@ 流程: -1. 通过 CPA / SUB2API 刷新 OAuth 地址 +1. 按当前来源刷新 OAuth 地址: + - CPA:打开管理页并读取 OAuth 地址 + - SUB2API:打开后台并生成 OAuth 地址 + - Codex2API:直接调用后台协议 `/api/admin/oauth/generate-auth-url` 2. 打开最新 OAuth 链接 3. 登录;如果进入密码页且当前有密码,则填写并提交密码;如果当前没有密码但检测到一次性验证码入口,则直接切换到一次性验证码登录 4. 确保真正进入验证码页 @@ -388,7 +392,7 @@ 贡献模式补充: -- 贡献模式下,步骤 7 不再从 CPA / SUB2API 面板刷新 OAuth,而是直接调用公开贡献接口 `/oauth/api/start` +- 贡献模式下,步骤 7 不再从 CPA / SUB2API / Codex2API 来源刷新 OAuth,而是直接调用公开贡献接口 `/oauth/api/start` - 贡献接口返回的 `auth_url` 会写回运行态 `oauthUrl`,后续步骤 7 / 8 / 9 继续复用现有授权链路 ### Step 8 @@ -435,15 +439,22 @@ 流程: 1. 校验 localhost callback 是否有效 -2. 判断是 CPA 还是 SUB2API -3. 打开相应后台 -4. 提交回调地址 +2. 判断当前来源是 CPA、SUB2API 还是 Codex2API +3. CPA / SUB2API 打开相应后台;Codex2API 直接走协议分支,不打开后台页面 +4. 提交回调地址或等价的授权码交换请求 5. 仅当出现精确成功徽标,且该徽标不是红色/错误态、页面上也没有同时可见的失败提示时,才判定成功 6. 识别 `认证失败:*`、`认证失败: timeout of 30000ms exceeded`、`回调 URL 提交失败: oauth flow is not pending` 等失败提示并立即报错 7. 完成平台侧验证 8. 追加账号运行历史成功记录 9. 做成功后的清理与标记 +Codex2API 补充: + +- 步骤 7 直接调用 `POST /api/admin/oauth/generate-auth-url` 获取 `auth_url / session_id` +- 授权页仍沿用现有 OpenAI 登录、验证码、OAuth 同意页与 localhost callback 主链 +- 步骤 10 直接调用 `POST /api/admin/oauth/exchange-code`,用 callback 中的 `code / state` 完成账号创建 +- Codex2API 这条来源不新增 panel content script,也不依赖“添加账号 -> OAuth 授权 -> 生成授权链接”页面按钮 DOM + 贡献模式补充: - 贡献模式下,步骤 10 不再打开 CPA 管理页 @@ -677,6 +688,10 @@ 5. Step 4 / 7 的验证码流 6. 成功收尾逻辑 +如果来源本身提供稳定协议接口,还必须额外判断: + +7. 是否可以不打开来源后台页面,直接把步骤 7 / 10 收敛到协议分支 + ### 新增配置项 必须同时检查: diff --git a/项目开发规范(AI协作).md b/项目开发规范(AI协作).md index 8b7ef6a..65f006c 100644 --- a/项目开发规范(AI协作).md +++ b/项目开发规范(AI协作).md @@ -82,6 +82,14 @@ 5. 是否需要成功收尾逻辑 6. 是否需要 README 与完整链路文档更新 +补充约定: + +- 如果新增来源本身已经提供稳定的后台协议接口,可以直接走协议分支接入: + - 步骤 7 通过 `background/panel-bridge.js` 生成 `auth_url` + - 步骤 10 通过 `background/steps/platform-verify.js` 直接提交 localhost callback +- 这类来源优先复用现有 OpenAI 授权页与 localhost callback 主链,不要为了“看起来统一”再额外新增一套页面 DOM 自动点击内容脚本。 +- 只有当目标来源没有可用协议接口、必须依赖后台页面按钮时,才新增对应的 panel content script。 + ### 3.2.1 共享别名邮箱逻辑补充 当 Gmail / 2925 这类“既影响注册邮箱生成,又影响 sidepanel 表单行为”的 provider 发生变化时,必须优先检查是否应落入共享层,而不是继续把规则分散写在: @@ -119,7 +127,7 @@ 当前约定示例: - `contributionMode` 是 sidepanel 的运行态 UI 模式,不是新的 `panelMode` -- `panelMode` 仍然只允许 `cpa | sub2api` +- `panelMode` 当前允许 `cpa | sub2api | codex2api` - 运行态模式不能混进 `PERSISTED_SETTING_DEFAULTS` - 运行态模式不能混进配置导入/导出 - 如果运行态模式会临时覆盖某些持久配置的显示值,必须同时处理好“退出模式后恢复”和“自动保存不能误覆盖原配置”这两个问题 diff --git a/项目文件结构说明.md b/项目文件结构说明.md index 7974da3..2d002fc 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -48,8 +48,8 @@ - `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层;当前额外承接 `add-phone / 手机号页` 这类认证 fatal 错误的共享判定。 - `background/mail-2925-session.js`:2925 会话模块,负责 2925 账号池持久化、当前账号切换、cookie 清理登出、自动登录、命中“子邮箱已达上限邮箱”后的 24 小时禁用与自动切号。 - `background/message-router.js`:后台消息路由层,负责处理 `chrome.runtime.onMessage` 进入的所有业务消息;当前额外接入 2925 账号池的新增、导入、切换、登录、禁用与删除消息。 -- `background/navigation-utils.js`:导航与 URL 判断工具层,负责 callback、入口页、CPA / SUB2API 地址、步骤跳转相关判断。 -- `background/panel-bridge.js`:CPA / SUB2API 面板桥接层,封装 OAuth 地址获取所需的页面打开、脚本注入和通信。 +- `background/navigation-utils.js`:导航与 URL 判断工具层,负责 callback、入口页、CPA / SUB2API / Codex2API 地址归一化、来源标签页家族判断与步骤跳转相关判断。 +- `background/panel-bridge.js`:来源桥接层;CPA / SUB2API 继续封装页面打开、脚本注入和通信,Codex2API 则直接通过后台协议生成 OAuth 地址。 - `background/signup-flow-helpers.js`:注册页辅助层,负责打开注册入口、等待密码页以及解析当前流程所用邮箱;在 Gmail 与 `2925 + provide` 模式下会优先复用已经存在且仍兼容的完整注册邮箱,只有不兼容或为空时才重新生成;当 provider 为 2925 且启用了 provide 模式下的号池时,会先确保账号池中已选中可用账号。 - `background/tab-runtime.js`:标签页与内容脚本运行时基础设施,封装标签注册、冲突清理、消息超时、注入重试与队列;当前等待标签完成、注入后的短暂延迟和内容脚本重试等待都已做 Stop 感知,避免用户停止后后台还继续等待并恢复执行。 - `background/verification-flow.js`:注册/登录验证码共享流程层,封装重发、轮询、提交、失败回退、自定义邮箱跳过、共享验证码自动重发次数配置以及 2925 长轮询参数;当前验证码提交重试上限为 15 次,2925 每次重发验证码之间都会固定跑完一轮 15 次邮箱刷新轮询;若 `mail2925Mode = receive`,会额外把目标注册邮箱传给 2925 内容脚本做弱匹配;若 2925 轮询命中“子邮箱已达上限邮箱”,会转入 2925 会话模块执行“记录时间、禁用 24 小时、切下一个账号并重新登录”,然后直接结束当前尝试。 @@ -64,7 +64,7 @@ - `background/steps/fill-profile.js`:步骤 5 实现,负责姓名、生日填写并把资料提交给注册页内容脚本。 - `background/steps/oauth-login.js`:步骤 7 实现,负责刷新 OAuth 链接、登录和确保进入验证码页;普通可恢复登录态失败会按上限重试,但一旦识别到认证流程进入 `add-phone / 手机号页`,会立即退出步骤 7 内部重试。 - `background/steps/open-chatgpt.js`:步骤 1 实现,负责打开 ChatGPT 官网并确认入口就绪。 -- `background/steps/platform-verify.js`:步骤 10 实现,负责 CPA / SUB2API 回调验证。 +- `background/steps/platform-verify.js`:步骤 10 实现,负责 CPA / SUB2API 回调验证,以及 Codex2API 的协议式 callback code/state 交换。 - `background/steps/registry.js`:步骤注册表工厂,负责用稳定的步骤元数据映射到执行器。 - `background/steps/submit-signup-email.js`:步骤 2 实现,负责注册入口点击、邮箱提交与提交后落地页分支判断。 @@ -119,10 +119,10 @@ - `sidepanel/mail-2925-manager.js`:侧边栏 2925 账号池管理器,负责 2925 账号的新增、导入、切换、手动登录、启停、清冷却与删除。 - `sidepanel/account-records-manager.js`:侧边栏邮箱记录面板管理器,负责“记录”按钮、覆盖层开关、分页列表、成功/失败/停止统计摘要和清理确认。 - `sidepanel/contribution-content-update-service.js`:侧边栏贡献内容更新服务,负责拉取 `https://apikey.qzz.io/api/content-summary`、缓存公开公告/教程摘要,并输出可用于提示展示的 `promptVersion` 与最新更新时间。 -- `sidepanel/contribution-mode.js`:侧边栏贡献模式管理器,负责顶部“贡献”按钮、确认弹窗、贡献模式显隐、复用主自动流程启动、侧栏内贡献状态轮询、上传页跳转,以及贡献模式下对来源选择、配置入口、记录入口和敏感配置行的禁用与隐藏。 +- `sidepanel/contribution-mode.js`:侧边栏贡献模式管理器,负责顶部“贡献”按钮、确认弹窗、贡献模式显隐、复用主自动流程启动、侧栏内贡献状态轮询、上传页跳转,以及贡献模式下对来源选择、配置入口、记录入口和敏感配置行(包括 Codex2API 配置)的禁用与隐藏。 - `sidepanel/sidepanel.css`:侧边栏样式文件;当前额外提供 Hotmail / 2925 共用的号池表单容器、操作按钮行与统一的收起态列表高度样式。 -- `sidepanel/sidepanel.html`:侧边栏页面结构;当前步骤列表已改为动态容器,日志区提供“记录”按钮并挂接邮箱记录覆盖层,顶部新增“贡献”按钮并在设置卡片中新增贡献模式主面板;贡献按钮下方额外挂接一个可关闭的轻提示气泡,用于提示公开公告 / 使用教程有更新;贡献面板内展示 `OAUTH / 回调 / 总状态` 三块真实运行态信息,同时把“本地同步”与“验证码重发”拆成独立行以避免特殊模式隐藏时互相影响;页面继续加载 `managed-alias-utils.js`,并把旧的“邮箱前缀”字段语义改为“别名基邮箱”;当 provider 为 2925 时,会额外显示 `提供邮箱 / 接收邮箱` 模式切换,并把“2925 号池”从别名基邮箱行拆成独立配置行,避免 receive 模式把账号池开关一起隐藏;Hotmail / 2925 两个账号池当前都使用统一的头部“添加账号/取消添加”按钮和共享表单容器。 -- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、独立本地同步配置、共享验证码自动重发次数配置与广播接收,并装配 Hotmail / 2925 / iCloud / LuckMail / 贡献模式 / 邮箱记录面板 / 贡献内容更新服务;当前贡献模式的“开始贡献”会直接复用主自动流程启动逻辑,而独立 manager 负责贡献运行态展示与轮询,同时把 Gmail / 2925 的基邮箱输入、完整注册邮箱输入、自动生成按钮与兼容性校验统一接到共享别名逻辑上;当 provider 为 2925 时,会根据 `mail2925Mode` 决定是否启用别名基邮箱链路,而 2925 号池开关与当前账号选择则独立显示并同时服务于 provide / receive 两种模式;Hotmail / 2925 的新增表单显隐统一接到 `sidepanel/account-pool-ui.js` 这一层共享 helper;侧边栏初始化与点击“自动”前会刷新一次贡献站公开内容摘要,并按本地关闭版本决定是否展示轻提示。 +- `sidepanel/sidepanel.html`:侧边栏页面结构;当前步骤列表已改为动态容器,日志区提供“记录”按钮并挂接邮箱记录覆盖层,顶部新增“贡献”按钮并在设置卡片中新增贡献模式主面板;贡献按钮下方额外挂接一个可关闭的轻提示气泡,用于提示公开公告 / 使用教程有更新;贡献面板内展示 `OAUTH / 回调 / 总状态` 三块真实运行态信息,同时把“本地同步”与“验证码重发”拆成独立行以避免特殊模式隐藏时互相影响;页面继续加载 `managed-alias-utils.js`,并把旧的“邮箱前缀”字段语义改为“别名基邮箱”;当 provider 为 2925 时,会额外显示 `提供邮箱 / 接收邮箱` 模式切换,并把“2925 号池”从别名基邮箱行拆成独立配置行,避免 receive 模式把账号池开关一起隐藏;来源下拉框当前支持 `CPA / SUB2API / Codex2API`,其中 Codex2API 额外提供后台地址和管理密钥配置行;Hotmail / 2925 两个账号池当前都使用统一的头部“添加账号/取消添加”按钮和共享表单容器。 +- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、独立本地同步配置、共享验证码自动重发次数配置与广播接收,并装配 Hotmail / 2925 / iCloud / LuckMail / 贡献模式 / 邮箱记录面板 / 贡献内容更新服务;当前贡献模式的“开始贡献”会直接复用主自动流程启动逻辑,而独立 manager 负责贡献运行态展示与轮询,同时把 Gmail / 2925 的基邮箱输入、完整注册邮箱输入、自动生成按钮与兼容性校验统一接到共享别名逻辑上;当 provider 为 2925 时,会根据 `mail2925Mode` 决定是否启用别名基邮箱链路,而 2925 号池开关与当前账号选择则独立显示并同时服务于 provide / receive 两种模式;新来源 Codex2API 在这里仅补充来源配置接线、表单显隐和 Step 10 按钮文案,不承接协议业务逻辑;Hotmail / 2925 的新增表单显隐统一接到 `sidepanel/account-pool-ui.js` 这一层共享 helper;侧边栏初始化与点击“自动”前会刷新一次贡献站公开内容摘要,并按本地关闭版本决定是否展示轻提示。 - `sidepanel/update-service.js`:侧边栏更新检查服务,负责 GitHub Releases 查询、`Pro` / `v` 双版本族排序、缓存读取与版本展示。 ## `tests/` @@ -150,6 +150,7 @@ - `tests/background-message-router-step2-skip.test.js`:测试步骤 2 直接落到验证码页时的跳步与状态保护逻辑。 - `tests/background-navigation-utils-module.test.js`:测试导航工具模块已接入且导出工厂。 - `tests/background-panel-bridge-module.test.js`:测试面板桥接模块已接入且导出工厂。 +- `tests/background-platform-verify-codex2api.test.js`:测试 Codex2API 新来源在步骤 10 走协议式 callback 交换,不依赖后台页面注入。 - `tests/background-signup-flow-module.test.js`:测试注册页辅助模块已接入且导出工厂。 - `tests/background-skip-step-linking.test.js`:测试手动跳过步骤 1 时,会级联跳过步骤 2~5,并仅跳过其中未完成且未运行的步骤。 - `tests/background-signup-step2-branching.test.js`:测试在 Gmail / 2925 模式下,已有兼容别名邮箱时应直接复用,不应再次重生成。 From 68b4469dec4d3c4b8ed067ccd0291032a6b25353 Mon Sep 17 00:00:00 2001 From: Twelveeee Date: Wed, 22 Apr 2026 16:23:16 +0800 Subject: [PATCH 03/42] feat: improve Cloudflare Temp Email random subdomain flow and sidepanel UX --- background.js | 3 + background/generated-email-helpers.js | 1 + sidepanel/sidepanel.css | 13 + sidepanel/sidepanel.html | 75 +++-- sidepanel/sidepanel.js | 126 +++++++- ...und-cloudflare-temp-email-settings.test.js | 145 +++++++++ .../background-generated-email-module.test.js | 169 +++++++++- ...dflare-temp-email-random-subdomain.test.js | 297 ++++++++++++++++++ tests/sidepanel-contribution-mode.test.js | 3 + 9 files changed, 792 insertions(+), 40 deletions(-) create mode 100644 tests/background-cloudflare-temp-email-settings.test.js create mode 100644 tests/sidepanel-cloudflare-temp-email-random-subdomain.test.js diff --git a/background.js b/background.js index 7297909..3b68339 100644 --- a/background.js +++ b/background.js @@ -280,6 +280,7 @@ const PERSISTED_SETTING_DEFAULTS = { cloudflareTempEmailAdminAuth: '', cloudflareTempEmailCustomAuth: '', cloudflareTempEmailReceiveMailbox: '', + cloudflareTempEmailUseRandomSubdomain: false, cloudflareTempEmailDomain: '', cloudflareTempEmailDomains: [], hotmailAccounts: [], @@ -827,6 +828,7 @@ function getCloudflareTempEmailConfig(state = {}) { adminAuth: String(state.cloudflareTempEmailAdminAuth || ''), customAuth: String(state.cloudflareTempEmailCustomAuth || ''), receiveMailbox: normalizeCloudflareTempEmailReceiveMailbox(state.cloudflareTempEmailReceiveMailbox), + useRandomSubdomain: Boolean(state.cloudflareTempEmailUseRandomSubdomain), domain: normalizeCloudflareTempEmailDomain(state.cloudflareTempEmailDomain), domains: normalizeCloudflareTempEmailDomains(state.cloudflareTempEmailDomains), }; @@ -895,6 +897,7 @@ function normalizePersistentSettingValue(key, value) { return normalizeEmailGenerator(value); case 'autoDeleteUsedIcloudAlias': case 'accountRunHistoryTextEnabled': + case 'cloudflareTempEmailUseRandomSubdomain': return Boolean(value); case 'icloudHostPreference': return normalizeIcloudHost(value) || 'auto'; diff --git a/background/generated-email-helpers.js b/background/generated-email-helpers.js index 3d924a7..7db5c98 100644 --- a/background/generated-email-helpers.js +++ b/background/generated-email-helpers.js @@ -146,6 +146,7 @@ const requestedName = String(options.localPart || options.name || '').trim().toLowerCase() || generateCloudflareAliasLocalPart(); const payload = { enablePrefix: true, + enableRandomSubdomain: Boolean(config.useRandomSubdomain), name: requestedName, domain: config.domain, }; diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index f40f857..aa9e607 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -2393,6 +2393,19 @@ header { line-height: 1.55; color: var(--text-secondary); margin-bottom: 14px; + white-space: pre-line; +} + +.modal-message a, +.modal-alert a { + color: var(--blue); + text-decoration: underline; + text-underline-offset: 2px; +} + +.modal-message a:hover, +.modal-alert a:hover { + color: var(--cyan); } .modal-alert { diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 6b75d35..7cb4fba 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -235,31 +235,6 @@
- - - - -
+
- 本地 CPA -
- - + 回调方式 +
+ +
+