fix: sync LuckMail PR with dev

This commit is contained in:
QLHazyCoder
2026-04-29 22:12:28 +08:00
52 changed files with 8013 additions and 909 deletions
+5 -6
View File
@@ -8,11 +8,12 @@
一百五十个号,一个401
进官网,获取最新交流群:<https://apikey.qzz.io/>
<table>
<tr>
<td align="center" width="100%">
<td align="center" width="50%">
<img src="docs/images/交流群.jpg" alt="QQ交流群,便于大家交流" width="100%" />
</td>
<td align="center" width="50%">
<img src="docs/images/十轮自动.png" alt="最新版本运行日志" width="100%" />
</td>
</tr>
@@ -74,7 +75,6 @@
- 至少准备一种验证码接收方式:
- DuckDuckGo `@duck.com` + QQ / 163 / Inbucket 转发
- Cloudflare 自定义域邮箱前缀 + QQ / 163 / Inbucket 转发
- iCloud Hide My Email,可选择直接从 iCloud 收件箱收码,或转发到 QQ / 163 / 163 VIP / 126 / Gmail 后收码
- 手动填写一个可收信邮箱
- 如果使用 `QQ` / `163` / `163 VIP` / `126` / `Inbucket`,对应页面需要提前能正常打开
@@ -592,7 +592,6 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。
支持:
- `Hotmail`(远程服务 / 本地助手)
- `iCloud` 收件箱,或 iCloud Hide My Email 转发到 QQ / 163 / 163 VIP / 126 / Gmail 后收码
- `content/qq-mail.js`
- `content/mail-163.js`163 / 163 VIP / 126
- `content/inbucket-mail.js`
@@ -774,7 +773,7 @@ content/utils.js 通用工具:等待元素、点击、日志、停
content/vps-panel.js CPA 面板步骤:内部 OAuth 刷新 / Step 10
content/signup-page.js ChatGPT 官网 + OpenAI 注册/登录页步骤:Step 1 / 2 / 3 / 5 / 7 / 9
hotmail-utils.js Hotmail 收信相关通用辅助
mail-provider-utils.js 网页邮箱 provider 与 iCloud 转发收码配置辅助
mail-provider-utils.js 网页邮箱 provider 配置辅助
content/duck-mail.js Duck 邮箱自动获取
content/qq-mail.js QQ 邮箱验证码轮询
content/mail-163.js 163 / 163 VIP / 126 邮箱验证码轮询
+463
View File
@@ -3,10 +3,12 @@
importScripts(
'managed-alias-utils.js',
'mail2925-utils.js',
'paypal-utils.js',
'background/phone-verification-flow.js',
'background/account-run-history.js',
'background/contribution-oauth.js',
'background/mail-2925-session.js',
'background/paypal-account-store.js',
'background/ip-proxy-provider-711proxy.js',
'background/ip-proxy-core.js',
'background/panel-bridge.js',
@@ -263,6 +265,21 @@ const AUTO_STEP_DELAY_MAX_ALLOWED_SECONDS = 600;
const VERIFICATION_RESEND_COUNT_MIN = 0;
const VERIFICATION_RESEND_COUNT_MAX = 20;
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
const PHONE_REPLACEMENT_LIMIT_MIN = 1;
const PHONE_REPLACEMENT_LIMIT_MAX = 20;
const DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT = 3;
const PHONE_CODE_WAIT_SECONDS_MIN = 15;
const PHONE_CODE_WAIT_SECONDS_MAX = 300;
const DEFAULT_PHONE_CODE_WAIT_SECONDS = 60;
const PHONE_CODE_TIMEOUT_WINDOWS_MIN = 1;
const PHONE_CODE_TIMEOUT_WINDOWS_MAX = 10;
const DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS = 2;
const PHONE_CODE_POLL_INTERVAL_SECONDS_MIN = 1;
const PHONE_CODE_POLL_INTERVAL_SECONDS_MAX = 30;
const DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS = 5;
const PHONE_CODE_POLL_ROUNDS_MIN = 1;
const PHONE_CODE_POLL_ROUNDS_MAX = 120;
const DEFAULT_PHONE_CODE_POLL_ROUNDS = 4;
const LEGACY_AUTO_STEP_DELAY_KEYS = ['autoStepRandomDelayMinSeconds', 'autoStepRandomDelayMaxSeconds'];
const LEGACY_VERIFICATION_RESEND_COUNT_KEYS = ['signupVerificationResendCount', 'loginVerificationResendCount'];
const DEFAULT_LOCAL_CPA_STEP9_MODE = 'submit';
@@ -281,6 +298,10 @@ const HERO_SMS_SERVICE_CODE = 'dr';
const HERO_SMS_SERVICE_LABEL = 'OpenAI';
const HERO_SMS_COUNTRY_ID = 52;
const HERO_SMS_COUNTRY_LABEL = 'Thailand';
const DEFAULT_HERO_SMS_REUSE_ENABLED = true;
const HERO_SMS_ACQUIRE_PRIORITY_COUNTRY = 'country';
const HERO_SMS_ACQUIRE_PRIORITY_PRICE = 'price';
const DEFAULT_HERO_SMS_ACQUIRE_PRIORITY = HERO_SMS_ACQUIRE_PRIORITY_COUNTRY;
const DISPLAY_TIMEZONE = 'Asia/Shanghai';
const MICROSOFT_TOKEN_DNR_RULE_ID = 1001;
const PERSISTENT_ALIAS_STATE_KEYS = [
@@ -436,6 +457,7 @@ const PERSISTED_SETTING_DEFAULTS = {
plusModeEnabled: false,
paypalEmail: '',
paypalPassword: '',
currentPayPalAccountId: '',
autoRunSkipFailures: false,
autoRunFallbackThreadIntervalMinutes: 0,
autoRunDelayEnabled: false,
@@ -443,6 +465,11 @@ const PERSISTED_SETTING_DEFAULTS = {
autoStepDelaySeconds: null,
phoneVerificationEnabled: false,
verificationResendCount: DEFAULT_VERIFICATION_RESEND_COUNT,
phoneVerificationReplacementLimit: DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT,
phoneCodeWaitSeconds: DEFAULT_PHONE_CODE_WAIT_SECONDS,
phoneCodeTimeoutWindows: DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS,
phoneCodePollIntervalSeconds: DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS,
phoneCodePollMaxRounds: DEFAULT_PHONE_CODE_POLL_ROUNDS,
mailProvider: '163',
mail2925Mode: DEFAULT_MAIL_2925_MODE,
mail2925UseAccountPool: false,
@@ -480,9 +507,14 @@ const PERSISTED_SETTING_DEFAULTS = {
cloudflareTempEmailDomains: [],
hotmailAccounts: [],
mail2925Accounts: [],
paypalAccounts: [],
heroSmsApiKey: '',
heroSmsReuseEnabled: DEFAULT_HERO_SMS_REUSE_ENABLED,
heroSmsAcquirePriority: DEFAULT_HERO_SMS_ACQUIRE_PRIORITY,
heroSmsMaxPrice: '',
heroSmsCountryId: HERO_SMS_COUNTRY_ID,
heroSmsCountryLabel: HERO_SMS_COUNTRY_LABEL,
heroSmsCountryFallback: [],
};
const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);
@@ -523,6 +555,8 @@ const DEFAULT_STATE = {
lastSignupCode: null, // 注册验证码,运行时由程序自动读取并写入。
lastLoginCode: null, // 登录验证码,运行时由程序自动读取并写入。
localhostUrl: null, // 运行时捕获到的 localhost 回调地址,不要手动预填。
cpaOAuthState: null, // CPA OAuth state。
cpaManagementOrigin: null, // CPA 管理接口 origin。
sub2apiSessionId: null, // SUB2API OpenAI Auth 会话 ID。
sub2apiOAuthState: null, // SUB2API OpenAI Auth state。
sub2apiGroupId: null, // SUB2API 目标分组 ID。
@@ -562,7 +596,14 @@ const DEFAULT_STATE = {
currentLuckmailPurchase: null,
currentLuckmailMailCursor: null,
currentPhoneActivation: null,
currentPhoneVerificationCode: '',
reusablePhoneActivation: null,
heroSmsLastPriceTiers: [],
heroSmsLastPriceCountryId: 0,
heroSmsLastPriceCountryLabel: '',
heroSmsLastPriceUserLimit: '',
heroSmsLastPriceAt: 0,
pendingPhoneActivationConfirmation: null,
autoRunning: false, // 当前是否处于自动运行中。
autoRunPhase: 'idle', // 当前自动运行阶段。
autoRunCurrentRun: 0, // 自动运行当前执行到第几轮。
@@ -579,6 +620,7 @@ const DEFAULT_STATE = {
loginVerificationRequestedAt: null,
oauthFlowDeadlineAt: null,
oauthFlowDeadlineSourceUrl: null,
currentPayPalAccountId: null,
currentHotmailAccountId: null,
currentMail2925AccountId: null,
preferredIcloudHost: '',
@@ -661,6 +703,143 @@ function normalizeVerificationResendCount(value, fallback) {
);
}
function normalizePhoneVerificationReplacementLimit(value, fallback = DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT) {
const rawValue = String(value ?? '').trim();
const numeric = Number(rawValue);
if (!rawValue || !Number.isFinite(numeric)) {
return Math.min(
PHONE_REPLACEMENT_LIMIT_MAX,
Math.max(PHONE_REPLACEMENT_LIMIT_MIN, Math.floor(Number(fallback) || DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT))
);
}
return Math.min(
PHONE_REPLACEMENT_LIMIT_MAX,
Math.max(PHONE_REPLACEMENT_LIMIT_MIN, Math.floor(numeric))
);
}
function normalizePhoneCodeWaitSeconds(value, fallback = DEFAULT_PHONE_CODE_WAIT_SECONDS) {
const rawValue = String(value ?? '').trim();
const numeric = Number(rawValue);
if (!rawValue || !Number.isFinite(numeric)) {
return Math.min(
PHONE_CODE_WAIT_SECONDS_MAX,
Math.max(PHONE_CODE_WAIT_SECONDS_MIN, Math.floor(Number(fallback) || DEFAULT_PHONE_CODE_WAIT_SECONDS))
);
}
return Math.min(
PHONE_CODE_WAIT_SECONDS_MAX,
Math.max(PHONE_CODE_WAIT_SECONDS_MIN, Math.floor(numeric))
);
}
function normalizePhoneCodeTimeoutWindows(value, fallback = DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS) {
const rawValue = String(value ?? '').trim();
const numeric = Number(rawValue);
if (!rawValue || !Number.isFinite(numeric)) {
return Math.min(
PHONE_CODE_TIMEOUT_WINDOWS_MAX,
Math.max(PHONE_CODE_TIMEOUT_WINDOWS_MIN, Math.floor(Number(fallback) || DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS))
);
}
return Math.min(
PHONE_CODE_TIMEOUT_WINDOWS_MAX,
Math.max(PHONE_CODE_TIMEOUT_WINDOWS_MIN, Math.floor(numeric))
);
}
function normalizePhoneCodePollIntervalSeconds(value, fallback = DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS) {
const rawValue = String(value ?? '').trim();
const numeric = Number(rawValue);
if (!rawValue || !Number.isFinite(numeric)) {
return Math.min(
PHONE_CODE_POLL_INTERVAL_SECONDS_MAX,
Math.max(PHONE_CODE_POLL_INTERVAL_SECONDS_MIN, Math.floor(Number(fallback) || DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS))
);
}
return Math.min(
PHONE_CODE_POLL_INTERVAL_SECONDS_MAX,
Math.max(PHONE_CODE_POLL_INTERVAL_SECONDS_MIN, Math.floor(numeric))
);
}
function normalizePhoneCodePollMaxRounds(value, fallback = DEFAULT_PHONE_CODE_POLL_ROUNDS) {
const rawValue = String(value ?? '').trim();
const numeric = Number(rawValue);
if (!rawValue || !Number.isFinite(numeric)) {
return Math.min(
PHONE_CODE_POLL_ROUNDS_MAX,
Math.max(PHONE_CODE_POLL_ROUNDS_MIN, Math.floor(Number(fallback) || DEFAULT_PHONE_CODE_POLL_ROUNDS))
);
}
return Math.min(
PHONE_CODE_POLL_ROUNDS_MAX,
Math.max(PHONE_CODE_POLL_ROUNDS_MIN, Math.floor(numeric))
);
}
function normalizeHeroSmsMaxPrice(value = '') {
const rawValue = String(value ?? '').trim();
if (!rawValue) {
return '';
}
const numeric = Number(rawValue);
if (!Number.isFinite(numeric) || numeric <= 0) {
return '';
}
return String(Math.round(numeric * 10000) / 10000);
}
function normalizeHeroSmsAcquirePriority(value = '') {
return String(value || '').trim().toLowerCase() === HERO_SMS_ACQUIRE_PRIORITY_PRICE
? HERO_SMS_ACQUIRE_PRIORITY_PRICE
: HERO_SMS_ACQUIRE_PRIORITY_COUNTRY;
}
function normalizeHeroSmsCountryFallback(value = []) {
const source = Array.isArray(value)
? value
: String(value || '')
.split(/[\r\n,;]+/)
.map((entry) => String(entry || '').trim())
.filter(Boolean);
const seenIds = new Set();
const normalized = [];
for (const entry of source) {
let countryId = 0;
let countryLabel = '';
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
countryId = Math.floor(Number(entry.countryId ?? entry.id) || 0);
countryLabel = String((entry.countryLabel ?? entry.label) || '').trim();
} else {
const text = String(entry || '').trim();
const structuredMatch = text.match(/^(\d+)\s*(?:[:|/-]\s*(.+))?$/);
if (structuredMatch) {
countryId = Math.floor(Number(structuredMatch[1]) || 0);
countryLabel = String(structuredMatch[2] || '').trim();
} else {
countryId = Math.floor(Number(text) || 0);
}
}
if (!Number.isFinite(countryId) || countryId <= 0 || seenIds.has(countryId)) {
continue;
}
seenIds.add(countryId);
normalized.push({
id: countryId,
label: countryLabel || `Country #${countryId}`,
});
if (normalized.length >= 20) {
break;
}
}
return normalized;
}
function resolveLegacyAutoStepDelaySeconds(input = {}) {
const hasLegacyMin = input.autoStepRandomDelayMinSeconds !== undefined;
const hasLegacyMax = input.autoStepRandomDelayMaxSeconds !== undefined;
@@ -1246,6 +1425,8 @@ function normalizePersistentSettingValue(key, value) {
return String(value || '').trim();
case 'paypalPassword':
return String(value || '');
case 'currentPayPalAccountId':
return String(value || '').trim();
case 'autoRunSkipFailures':
case 'autoRunDelayEnabled':
case 'phoneVerificationEnabled':
@@ -1259,6 +1440,16 @@ function normalizePersistentSettingValue(key, value) {
return normalizeAutoStepDelaySeconds(value, PERSISTED_SETTING_DEFAULTS.autoStepDelaySeconds);
case 'verificationResendCount':
return normalizeVerificationResendCount(value, DEFAULT_VERIFICATION_RESEND_COUNT);
case 'phoneVerificationReplacementLimit':
return normalizePhoneVerificationReplacementLimit(value, DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT);
case 'phoneCodeWaitSeconds':
return normalizePhoneCodeWaitSeconds(value, DEFAULT_PHONE_CODE_WAIT_SECONDS);
case 'phoneCodeTimeoutWindows':
return normalizePhoneCodeTimeoutWindows(value, DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS);
case 'phoneCodePollIntervalSeconds':
return normalizePhoneCodePollIntervalSeconds(value, DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS);
case 'phoneCodePollMaxRounds':
return normalizePhoneCodePollMaxRounds(value, DEFAULT_PHONE_CODE_POLL_ROUNDS);
case 'mailProvider':
return normalizeMailProvider(value);
case 'mail2925Mode':
@@ -1326,12 +1517,22 @@ function normalizePersistentSettingValue(key, value) {
return normalizeHotmailAccounts(value);
case 'mail2925Accounts':
return normalizeMail2925Accounts(value);
case 'paypalAccounts':
return normalizePayPalAccounts(value);
case 'heroSmsApiKey':
return String(value || '');
case 'heroSmsReuseEnabled':
return Boolean(value);
case 'heroSmsAcquirePriority':
return normalizeHeroSmsAcquirePriority(value);
case 'heroSmsMaxPrice':
return normalizeHeroSmsMaxPrice(value);
case 'heroSmsCountryId':
return Math.max(1, Math.floor(Number(value) || HERO_SMS_COUNTRY_ID));
case 'heroSmsCountryLabel':
return String(value || HERO_SMS_COUNTRY_LABEL).trim() || HERO_SMS_COUNTRY_LABEL;
case 'heroSmsCountryFallback':
return normalizeHeroSmsCountryFallback(value);
default:
return value;
}
@@ -1877,6 +2078,7 @@ async function resetState() {
'accounts',
'tabRegistry',
'sourceLastUrls',
'reusablePhoneActivation',
'luckmailApiKey',
'luckmailBaseUrl',
'luckmailEmailType',
@@ -1891,6 +2093,25 @@ async function resetState() {
getPersistedAliasState(),
]);
const contributionModeState = buildContributionModeState(Boolean(prev.contributionMode), persistedSettings, prev);
const reusablePhoneActivation = (
prev.reusablePhoneActivation
&& typeof prev.reusablePhoneActivation === 'object'
&& !Array.isArray(prev.reusablePhoneActivation)
&& String(
prev.reusablePhoneActivation.activationId
?? prev.reusablePhoneActivation.id
?? prev.reusablePhoneActivation.activation
?? ''
).trim()
&& String(
prev.reusablePhoneActivation.phoneNumber
?? prev.reusablePhoneActivation.number
?? prev.reusablePhoneActivation.phone
?? ''
).trim()
)
? prev.reusablePhoneActivation
: null;
await chrome.storage.session.clear();
await chrome.storage.session.set({
...DEFAULT_STATE,
@@ -1911,6 +2132,8 @@ async function resetState() {
luckmailPreserveTagName: String(prev.luckmailPreserveTagName || '').trim() || DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME,
currentLuckmailPurchase: null,
currentLuckmailMailCursor: null,
// Keep reusable phone activation across round resets so the same number can be reactivated up to maxUses.
reusablePhoneActivation,
preferredIcloudHost: prev.preferredIcloudHost || '',
});
}
@@ -1941,6 +2164,50 @@ function generatePassword() {
return pw.split('').sort(() => Math.random() - 0.5).join('');
}
function normalizePayPalAccount(account = {}) {
if (self.PayPalUtils?.normalizePayPalAccount) {
return self.PayPalUtils.normalizePayPalAccount(account);
}
return {
id: String(account.id || crypto.randomUUID()),
email: String(account.email || '').trim().toLowerCase(),
password: String(account.password || ''),
createdAt: Number.isFinite(Number(account.createdAt)) ? Number(account.createdAt) : Date.now(),
updatedAt: Number.isFinite(Number(account.updatedAt)) ? Number(account.updatedAt) : Date.now(),
lastUsedAt: Number.isFinite(Number(account.lastUsedAt)) ? Number(account.lastUsedAt) : 0,
};
}
function normalizePayPalAccounts(accounts) {
if (self.PayPalUtils?.normalizePayPalAccounts) {
return self.PayPalUtils.normalizePayPalAccounts(accounts);
}
return Array.isArray(accounts) ? accounts.map((account) => normalizePayPalAccount(account)) : [];
}
function findPayPalAccount(accounts, accountId) {
if (self.PayPalUtils?.findPayPalAccount) {
return self.PayPalUtils.findPayPalAccount(accounts, accountId);
}
const normalizedId = String(accountId || '').trim();
if (!normalizedId) return null;
return normalizePayPalAccounts(accounts).find((account) => account.id === normalizedId) || null;
}
function upsertPayPalAccountInList(accounts, nextAccount) {
if (self.PayPalUtils?.upsertPayPalAccountInList) {
return self.PayPalUtils.upsertPayPalAccountInList(accounts, nextAccount);
}
const normalizedNext = normalizePayPalAccount(nextAccount);
const list = normalizePayPalAccounts(accounts);
const existingIndex = list.findIndex((account) => account.id === normalizedNext.id);
if (existingIndex >= 0) {
list[existingIndex] = normalizedNext;
return list;
}
return [...list, normalizedNext];
}
function normalizeHotmailAccount(account = {}) {
const normalizedLastAuthAt = Number.isFinite(Number(account.lastAuthAt)) ? Number(account.lastAuthAt) : 0;
const normalizedStatus = String(
@@ -5251,6 +5518,13 @@ async function finalizeIcloudAliasAfterSuccessfulFlow(state) {
}
}
async function finalizePhoneActivationAfterSuccessfulFlow(state) {
if (typeof phoneVerificationHelpers?.finalizePendingPhoneActivationConfirmation !== 'function') {
return null;
}
return phoneVerificationHelpers.finalizePendingPhoneActivationConfirmation(state);
}
// ============================================================
// Tab Registry
// ============================================================
@@ -5973,6 +6247,8 @@ function getDownstreamStateResets(step, state = {}) {
return {
...plusRuntimeResets,
oauthUrl: null,
cpaOAuthState: null,
cpaManagementOrigin: null,
sub2apiSessionId: null,
sub2apiOAuthState: null,
sub2apiGroupId: null,
@@ -5986,9 +6262,11 @@ function getDownstreamStateResets(step, state = {}) {
loginVerificationRequestedAt: null,
oauthFlowDeadlineAt: null,
oauthFlowDeadlineSourceUrl: null,
pendingPhoneActivationConfirmation: null,
lastSignupCode: null,
lastLoginCode: null,
localhostUrl: null,
currentPhoneVerificationCode: '',
};
}
if (step === 2) {
@@ -6000,9 +6278,11 @@ function getDownstreamStateResets(step, state = {}) {
loginVerificationRequestedAt: null,
oauthFlowDeadlineAt: null,
oauthFlowDeadlineSourceUrl: null,
pendingPhoneActivationConfirmation: null,
lastSignupCode: null,
lastLoginCode: null,
localhostUrl: null,
currentPhoneVerificationCode: '',
};
}
if (step === 3 || step === 4) {
@@ -6013,9 +6293,11 @@ function getDownstreamStateResets(step, state = {}) {
loginVerificationRequestedAt: null,
oauthFlowDeadlineAt: null,
oauthFlowDeadlineSourceUrl: null,
pendingPhoneActivationConfirmation: null,
lastSignupCode: null,
lastLoginCode: null,
localhostUrl: null,
currentPhoneVerificationCode: '',
};
}
if (step === 5 || step === 6 || step === 7 || step === 8) {
@@ -6035,13 +6317,17 @@ function getDownstreamStateResets(step, state = {}) {
loginVerificationRequestedAt: null,
oauthFlowDeadlineAt: null,
oauthFlowDeadlineSourceUrl: null,
pendingPhoneActivationConfirmation: null,
localhostUrl: null,
currentPhoneVerificationCode: '',
};
}
if (step === 9) {
return {
pendingPhoneActivationConfirmation: null,
plusReturnUrl: '',
localhostUrl: null,
currentPhoneVerificationCode: '',
};
}
if (stepKey === 'oauth-login' || stepKey === 'fetch-login-code') {
@@ -6050,11 +6336,14 @@ function getDownstreamStateResets(step, state = {}) {
loginVerificationRequestedAt: null,
oauthFlowDeadlineAt: null,
oauthFlowDeadlineSourceUrl: null,
pendingPhoneActivationConfirmation: null,
localhostUrl: null,
currentPhoneVerificationCode: '',
};
}
if (stepKey === 'confirm-oauth') {
return {
pendingPhoneActivationConfirmation: null,
localhostUrl: null,
};
}
@@ -6811,6 +7100,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.cpaOAuthState !== undefined) updates.cpaOAuthState = payload.cpaOAuthState || null;
if (payload.cpaManagementOrigin !== undefined) updates.cpaManagementOrigin = payload.cpaManagementOrigin || null;
if (payload.codex2apiSessionId !== undefined) updates.codex2apiSessionId = payload.codex2apiSessionId || null;
if (payload.codex2apiOAuthState !== undefined) updates.codex2apiOAuthState = payload.codex2apiOAuthState || null;
if (Object.keys(updates).length) {
@@ -6905,6 +7196,7 @@ async function handleStepData(step, payload) {
if ((shouldUseCustomRegistrationEmail(latestState) || shouldClearCustomPoolEmail) && latestState.email) {
await setEmailStateSilently(null);
}
await finalizePhoneActivationAfterSuccessfulFlow(latestState);
break;
}
}
@@ -7556,6 +7848,39 @@ function isMail2925PoolExhaustedPauseError(error) {
return /^MAIL2925_POOL_EXHAUSTED_PAUSE::/.test(message);
}
const payPalAccountStore = self.MultiPageBackgroundPayPalAccountStore?.createPayPalAccountStore({
broadcastDataUpdate,
findPayPalAccount,
getState,
normalizePayPalAccount,
normalizePayPalAccounts,
setPersistentSettings,
setState,
upsertPayPalAccountInList,
});
async function syncPayPalAccounts(accounts) {
return payPalAccountStore?.syncPayPalAccounts?.(accounts) || [];
}
async function upsertPayPalAccount(input = {}) {
if (!payPalAccountStore?.upsertPayPalAccount) {
throw new Error('PayPal 账号存储能力尚未接入。');
}
return payPalAccountStore.upsertPayPalAccount(input);
}
async function setCurrentPayPalAccount(accountId) {
if (!payPalAccountStore?.setCurrentPayPalAccount) {
throw new Error('PayPal 账号选择能力尚未接入。');
}
return payPalAccountStore.setCurrentPayPalAccount(accountId);
}
function getCurrentPayPalAccount(state = null) {
return payPalAccountStore?.getCurrentPayPalAccount?.(state || {}) || null;
}
const generatedEmailHelpers = self.MultiPageGeneratedEmailHelpers?.createGeneratedEmailHelpers({
addLog,
buildGeneratedAliasEmail,
@@ -8505,6 +8830,11 @@ const verificationFlowHelpers = self.MultiPageBackgroundVerificationFlow?.create
const phoneVerificationHelpers = self.MultiPageBackgroundPhoneVerification?.createPhoneVerificationHelpers({
addLog,
DEFAULT_HERO_SMS_BASE_URL,
DEFAULT_HERO_SMS_REUSE_ENABLED,
DEFAULT_PHONE_CODE_WAIT_SECONDS,
DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS,
DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS,
DEFAULT_PHONE_CODE_POLL_ROUNDS,
ensureStep8SignupPageReady,
getOAuthFlowStepTimeoutMs,
getState,
@@ -8741,6 +9071,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter
deleteHotmailAccounts,
deleteIcloudAlias,
deleteUsedIcloudAliases,
findPayPalAccount,
disableUsedLuckmailPurchases,
doesStepUseCompletionSignal,
ensureMail2925MailboxSession,
@@ -8749,6 +9080,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter
executeStepViaCompletionSignal,
exportSettingsBundle,
fetchGeneratedEmail,
finalizePhoneActivationAfterSuccessfulFlow,
finalizeStep3Completion: async () => {
const currentState = await getState();
const signupTabId = await getTabId('signup-page');
@@ -8785,9 +9117,11 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter
listIcloudAliases,
listLuckmailPurchasesForManagement,
refreshIpProxyPool,
getCurrentPayPalAccount,
getCurrentMail2925Account,
normalizeHotmailAccounts,
normalizeMail2925Accounts,
normalizePayPalAccounts,
normalizeRunCount,
AUTO_RUN_TIMER_KIND_SCHEDULED_START,
notifyStepComplete,
@@ -8803,6 +9137,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter
selectLuckmailPurchase,
switchIpProxy,
changeIpProxyExit,
setCurrentPayPalAccount,
setCurrentHotmailAccount,
setCurrentMail2925Account,
setContributionMode,
@@ -8822,9 +9157,11 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter
startAutoRunLoop,
pollContributionStatus: (...args) => contributionOAuthManager?.pollContributionStatus?.(...args),
syncHotmailAccounts,
syncPayPalAccounts,
deleteMail2925Account,
deleteMail2925Accounts,
testHotmailAccountMailAccess,
upsertPayPalAccount,
upsertMail2925Account,
upsertHotmailAccount,
verifyHotmailAccount,
@@ -9348,6 +9685,10 @@ async function getPostStep6AutoRestartDecision(step, error) {
const hasTransientNetworkSignal = /connect:\s*connection refused|failed to fetch|i\/o timeout|context deadline exceeded|eof|connection reset by peer/i.test(normalizedMessage);
return mentionsTokenExchange && hasTransientNetworkSignal;
};
const isPhoneVerificationLocalFailure = (errorMessage = '') => {
const normalizedMessage = String(errorMessage || '');
return /HeroSMS|phone verification did not succeed|number replacements|sms_timeout_after_resend|phone number is already linked|add-phone keeps rejecting current number|接码|手机号|手机验证码|步骤\s*9.*(?:手机号|验证码)|Step\s*9.*phone verification/i.test(normalizedMessage);
};
const normalizedStep = Number(step);
const errorMessage = getErrorMessage(error);
@@ -9378,6 +9719,17 @@ async function getPostStep6AutoRestartDecision(step, error) {
};
}
if (isPhoneVerificationLocalFailure(errorMessage)) {
return {
shouldRestart: false,
blockedByAddPhone: true,
forcedByPhoneVerificationTimeout: false,
restartStep: authChainStartStep,
errorMessage,
authState: null,
};
}
if (shouldForceRestartFromStep7) {
return {
shouldRestart: true,
@@ -9980,6 +10332,116 @@ function getStep8EffectLabel(effect) {
}
}
function isStep9OAuthLocalhostTimeoutError(error, visibleStep = 9) {
const message = getErrorMessage(error);
if (!message) {
return false;
}
if (!/从拿到 OAuth 登录地址开始/.test(message)) {
return false;
}
if (!/localhost 回调|OAuth localhost 回调/i.test(message)) {
return false;
}
const normalizedStep = Number(visibleStep);
if (Number.isFinite(normalizedStep) && normalizedStep > 0) {
const stepPrefix = new RegExp(`步骤\\s*${normalizedStep}\\s*`);
if (!stepPrefix.test(message)) {
return false;
}
}
return true;
}
async function recoverOAuthLocalhostTimeout(details = {}) {
const {
error,
state,
visibleStep = 9,
} = details;
if (!isStep9OAuthLocalhostTimeoutError(error, visibleStep)) {
return null;
}
const authLoginStep = typeof getAuthChainStartStepId === 'function'
? getAuthChainStartStepId(state || {})
: FINAL_OAUTH_CHAIN_START_STEP;
const loginCodeStep = Number(visibleStep) >= 12 ? 11 : 8;
await addLog(
`步骤 ${visibleStep}:检测到 OAuth localhost 回调等待窗口已过期,正在复核认证页并回到步骤 ${authLoginStep} 重拉授权链路。`,
'warn'
);
let authState = null;
try {
authState = await getLoginAuthStateFromContent({
timeoutMs: 10000,
responseTimeoutMs: 10000,
logMessage: `步骤 ${visibleStep}:正在复核认证页状态,确认是否可自动恢复 localhost 回调链路...`,
});
} catch (inspectError) {
await addLog(
`步骤 ${visibleStep}:复核认证页状态失败(${getErrorMessage(inspectError)}),将先尝试按步骤 ${loginCodeStep} 收尾恢复。`,
'warn'
);
}
if (isAddPhoneAuthState(authState)) {
const stateLabel = getLoginAuthStateLabel(authState.state);
await addLog(
`步骤 ${visibleStep}:当前认证页为 ${stateLabel},将直接回到步骤 ${authLoginStep} 重新拉起授权链路,避免步骤 8/9 恢复冲突。`,
'warn'
);
} else if (authState && authState.state && !['verification_page', 'oauth_consent_page'].includes(authState.state)) {
const stateLabel = getLoginAuthStateLabel(authState.state);
await addLog(
`步骤 ${visibleStep}:当前认证页为 ${stateLabel},不满足快速恢复条件,将回到步骤 ${authLoginStep} 重开授权链路。`,
'warn'
);
}
const latestState = await getState();
if (!step7Executor?.executeStep7 || !step8Executor?.executeStep8) {
return null;
}
await addLog(
`步骤 ${visibleStep}:正在自动重开步骤 ${authLoginStep} -> ${loginCodeStep},恢复到可继续捕获 localhost 回调的状态。`,
'warn'
);
await step7Executor.executeStep7({
...latestState,
visibleStep: authLoginStep,
});
const stateAfterStep7 = await getState();
await step8Executor.executeStep8({
...stateAfterStep7,
visibleStep: loginCodeStep,
});
const recoveredState = await getState();
const oauthUrl = String(recoveredState?.oauthUrl || state?.oauthUrl || '').trim();
if (oauthUrl && typeof startOAuthFlowTimeoutWindow === 'function') {
await startOAuthFlowTimeoutWindow({
step: Number(visibleStep) || 9,
oauthUrl,
});
}
await setState({
localhostUrl: null,
});
await addLog(
`步骤 ${visibleStep}:已恢复到步骤 ${authLoginStep} -> ${loginCodeStep} 收尾状态,并刷新 OAuth localhost 回调等待窗口,准备重试当前步骤。`,
'warn'
);
return await getState();
}
const step9Executor = self.MultiPageBackgroundStep9?.createStep9Executor({
addLog,
chrome,
@@ -9997,6 +10459,7 @@ const step9Executor = self.MultiPageBackgroundStep9?.createStep9Executor({
getStep8TabUpdatedListener,
isTabAlive,
prepareStep8DebuggerClick,
recoverOAuthLocalhostTimeout,
reloadStep8ConsentPage,
reuseOrCreateTab,
setStep8PendingReject,
+1
View File
@@ -392,6 +392,7 @@
inbucketMailbox: prevState.inbucketMailbox,
cloudflareDomain: prevState.cloudflareDomain,
cloudflareDomains: prevState.cloudflareDomains,
reusablePhoneActivation: prevState.reusablePhoneActivation,
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
autoRunSessionId: sessionId,
tabRegistry: {},
+132 -144
View File
@@ -3,19 +3,15 @@
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundContributionOAuthModule() {
const API_BASE_URL = 'https://apikey.qzz.io/oauth/api';
const ACTIVE_STATUSES = new Set(['started', 'waiting', 'processing']);
const FINAL_STATUSES = new Set(['auto_approved', 'auto_rejected', 'expired', 'error']);
const FINAL_STATUSES = new Set(['auto_approved', 'auto_rejected', 'manual_review_required', 'expired', 'error']);
const CALLBACK_FINAL_STATUSES = new Set(['submitted']);
const CALLBACK_WAITING_STATUSES = new Set(['idle', 'waiting', 'captured', 'failed', 'submitting']);
const CONTRIBUTION_SOURCE_CPA = 'cpa';
const CONTRIBUTION_SOURCE_SUB2API = 'sub2api';
const CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME = 'codex号池';
const CONTRIBUTION_SUB2API_PLUS_GROUP_NAME = 'openai-plus';
const RUNTIME_DEFAULTS = {
contributionMode: false,
contributionModeExpected: false,
contributionSource: CONTRIBUTION_SOURCE_SUB2API,
contributionTargetGroupName: CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME,
contributionSource: 'sub2api',
contributionTargetGroupName: 'codex号池',
contributionNickname: '',
contributionQq: '',
contributionSessionId: '',
@@ -44,7 +40,8 @@
} = deps;
let listenersBound = false;
const inFlightCapturedCallbackTasks = new Map();
const pendingCallbackSubmissions = new Map();
const pendingCapturedCallbacks = new Map();
function normalizeString(value = '') {
return String(value || '').trim();
@@ -71,6 +68,9 @@
case 'auto_rejected':
case 'rejected':
return 'auto_rejected';
case 'manual_review_required':
case 'manual_review':
return 'manual_review_required';
case 'expired':
case 'timeout':
return 'expired';
@@ -111,63 +111,6 @@
return FINAL_STATUSES.has(normalizeContributionStatus(status));
}
function runCapturedCallbackOnce(callbackUrl, executor) {
const normalizedUrl = normalizeString(callbackUrl);
if (!normalizedUrl) {
return Promise.resolve().then(executor);
}
const existingTask = inFlightCapturedCallbackTasks.get(normalizedUrl);
if (existingTask) {
return existingTask;
}
let task = null;
task = Promise.resolve()
.then(executor)
.finally(() => {
if (inFlightCapturedCallbackTasks.get(normalizedUrl) === task) {
inFlightCapturedCallbackTasks.delete(normalizedUrl);
}
});
inFlightCapturedCallbackTasks.set(normalizedUrl, task);
return task;
}
function normalizeContributionSource(value = '') {
const normalized = normalizeString(value).toLowerCase();
return normalized === CONTRIBUTION_SOURCE_SUB2API
? CONTRIBUTION_SOURCE_SUB2API
: CONTRIBUTION_SOURCE_CPA;
}
function resolveContributionRouting(state = {}) {
const currentStatus = normalizeContributionStatus(state.contributionStatus);
const currentSource = normalizeContributionSource(state.contributionSource);
const hasActiveSession = Boolean(
normalizeString(state.contributionSessionId)
&& currentStatus
&& !FINAL_STATUSES.has(currentStatus)
);
if (hasActiveSession) {
return {
source: currentSource,
targetGroupName: currentSource === CONTRIBUTION_SOURCE_SUB2API
? (normalizeString(state.contributionTargetGroupName) || CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME)
: '',
};
}
const source = CONTRIBUTION_SOURCE_SUB2API;
return {
source,
targetGroupName: Boolean(state.plusModeEnabled)
? CONTRIBUTION_SUB2API_PLUS_GROUP_NAME
: (normalizeString(state.contributionTargetGroupName) || CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME),
};
}
function getStatusLabel(status = '') {
switch (normalizeContributionStatus(status)) {
case 'started':
@@ -175,11 +118,13 @@
case 'waiting':
return '等待提交回调';
case 'processing':
return '已提交回调,等待服务端确认';
return '已提交回调,等待 CPA 确认';
case 'auto_approved':
return '贡献成功,服务端已确认';
return '贡献成功,CPA 已确认';
case 'auto_rejected':
return '贡献未通过确认';
case 'manual_review_required':
return '已提交,等待人工处理';
case 'expired':
return '贡献会话已超时';
case 'error':
@@ -315,6 +260,41 @@
return qq;
}
function isPlusModeState(state = {}) {
return Boolean(state?.plusModeEnabled);
}
function normalizeContributionModeSource(value = '') {
const normalized = normalizeString(value).toLowerCase();
return normalized === 'sub2api' ? 'sub2api' : 'cpa';
}
function resolveContributionModeRoutingState(state = {}) {
const currentStatus = normalizeString(state?.contributionStatus).toLowerCase();
const currentSource = normalizeContributionModeSource(state?.contributionSource);
const hasActiveSession = Boolean(
normalizeString(state?.contributionSessionId)
&& currentStatus
&& !FINAL_STATUSES.has(currentStatus)
);
if (hasActiveSession) {
return {
source: currentSource,
targetGroupName: currentSource === 'sub2api'
? (normalizeString(state?.contributionTargetGroupName) || 'codex号池')
: '',
};
}
return {
source: 'sub2api',
targetGroupName: isPlusModeState(state)
? 'openai-plus'
: (normalizeString(state?.contributionTargetGroupName) || 'codex号池'),
};
}
function buildStatusMessage(status, payload = {}) {
const label = getStatusLabel(status);
const details = [
@@ -494,68 +474,87 @@
return currentState;
}
await applyRuntimeUpdates({
contributionCallbackUrl: normalizedUrl,
contributionCallbackStatus: 'submitting',
contributionCallbackMessage: buildCallbackMessage('submitting'),
});
try {
const payload = await fetchContributionJson('/submit-callback', {
method: 'POST',
body: {
session_id: sessionId,
callback_url: normalizedUrl,
},
});
const nextStatus = 'submitted';
await applyRuntimeUpdates({
contributionCallbackUrl: normalizedUrl,
contributionCallbackStatus: nextStatus,
contributionCallbackMessage: buildCallbackMessage(nextStatus, payload),
});
if (typeof closeLocalhostCallbackTabs === 'function') {
await closeLocalhostCallbackTabs(normalizedUrl).catch(() => {});
}
return await pollContributionStatus({ reason: options.reason || 'submit_callback' });
} catch (error) {
await applyRuntimeUpdates({
contributionCallbackUrl: normalizedUrl,
contributionCallbackStatus: 'failed',
contributionCallbackMessage: `回调提交失败:${error.message}`,
});
if (typeof addLog === 'function') {
await addLog(`贡献模式:回调提交失败:${error.message}`, 'warn');
}
throw error;
const dedupeKey = `${sessionId}::${normalizedUrl}`;
if (pendingCallbackSubmissions.has(dedupeKey)) {
return pendingCallbackSubmissions.get(dedupeKey);
}
const task = (async () => {
await applyRuntimeUpdates({
contributionCallbackUrl: normalizedUrl,
contributionCallbackStatus: 'submitting',
contributionCallbackMessage: buildCallbackMessage('submitting'),
});
try {
const payload = await fetchContributionJson('/submit-callback', {
method: 'POST',
body: {
session_id: sessionId,
callback_url: normalizedUrl,
},
});
const nextStatus = 'submitted';
await applyRuntimeUpdates({
contributionCallbackUrl: normalizedUrl,
contributionCallbackStatus: nextStatus,
contributionCallbackMessage: buildCallbackMessage(nextStatus, payload),
});
if (typeof closeLocalhostCallbackTabs === 'function') {
await closeLocalhostCallbackTabs(normalizedUrl).catch(() => {});
}
return await pollContributionStatus({ reason: options.reason || 'submit_callback' });
} catch (error) {
await applyRuntimeUpdates({
contributionCallbackUrl: normalizedUrl,
contributionCallbackStatus: 'failed',
contributionCallbackMessage: `回调提交失败:${error.message}`,
});
if (typeof addLog === 'function') {
await addLog(`贡献模式:回调提交失败:${error.message}`, 'warn');
}
throw error;
} finally {
pendingCallbackSubmissions.delete(dedupeKey);
}
})();
pendingCallbackSubmissions.set(dedupeKey, task);
return task;
}
async function handleCapturedCallback(rawUrl, metadata = {}) {
const currentState = await getState();
if (!normalizeString(currentState.contributionSessionId) || !currentState.contributionMode) {
return currentState;
}
if (!isContributionCallbackUrl(rawUrl, currentState)) {
return currentState;
}
const normalizedUrl = normalizeString(rawUrl);
return runCapturedCallbackOnce(normalizedUrl, async () => {
const currentState = await getState();
if (!normalizeString(currentState.contributionSessionId) || !currentState.contributionMode) {
return currentState;
}
if (!isContributionCallbackUrl(normalizedUrl, currentState)) {
return currentState;
}
const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus);
if (
normalizedUrl
&& normalizeString(currentState.contributionCallbackUrl) === normalizedUrl
&& (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting')
) {
return currentState;
}
const callbackDedupeKey = `${normalizeString(currentState.contributionSessionId)}::${normalizedUrl}`;
if (pendingCapturedCallbacks.has(callbackDedupeKey)) {
return pendingCapturedCallbacks.get(callbackDedupeKey);
}
if (pendingCallbackSubmissions.has(callbackDedupeKey)) {
return pendingCallbackSubmissions.get(callbackDedupeKey);
}
const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus);
if (
normalizedUrl
&& normalizeString(currentState.contributionCallbackUrl) === normalizedUrl
&& (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting')
) {
return currentState;
}
const task = (async () => {
await applyRuntimeUpdates({
contributionCallbackUrl: normalizedUrl,
contributionCallbackStatus: 'captured',
@@ -573,8 +572,13 @@
});
} catch {
return getState();
} finally {
pendingCapturedCallbacks.delete(callbackDedupeKey);
}
});
})();
pendingCapturedCallbacks.set(callbackDedupeKey, task);
return task;
}
async function pollContributionStatus(options = {}) {
@@ -597,16 +601,6 @@
const callbackState = deriveCallbackState(mergedPayload, currentState);
const updates = {
contributionLastPollAt: Date.now(),
contributionSource: normalizeContributionSource(
mergedPayload.source
|| mergedPayload.source_kind
|| currentState.contributionSource
),
contributionTargetGroupName: normalizeString(
mergedPayload.target_group_name
|| mergedPayload.group_name
|| currentState.contributionTargetGroupName
),
contributionStatus: normalizedStatus,
contributionStatusMessage: buildStatusMessage(normalizedStatus, mergedPayload),
contributionCallbackUrl: callbackState.callbackUrl,
@@ -648,7 +642,6 @@
async function startContributionFlow(options = {}) {
const currentState = options.stateOverride || await getState();
const shouldOpenAuthTab = options.openAuthTab !== false;
const routing = resolveContributionRouting(currentState);
if (!currentState.contributionMode) {
throw new Error('请先进入贡献模式。');
}
@@ -666,14 +659,15 @@
return pollContributionStatus({ reason: 'resume_existing' });
}
const routingState = resolveContributionModeRoutingState(currentState);
const payload = await fetchContributionJson('/start', {
method: 'POST',
body: {
nickname: buildNickname(currentState, options.nickname),
qq: buildContributionQq(currentState, options.qq),
email: normalizeString(currentState.email),
source: routing.source,
target_group_name: routing.targetGroupName,
source: routingState.source,
target_group_name: routingState.targetGroupName,
channel: 'codex-extension',
},
});
@@ -686,12 +680,6 @@
}
await applyRuntimeUpdates({
contributionSource: normalizeContributionSource(payload.source || routing.source),
contributionTargetGroupName: normalizeString(
payload.target_group_name
|| payload.group_name
|| routing.targetGroupName
),
contributionSessionId: sessionId,
contributionAuthUrl: authUrl,
contributionAuthState: authState,
@@ -722,7 +710,7 @@
}
function onTabUpdated(tabId, changeInfo, tab) {
const candidateUrl = normalizeString(changeInfo?.url);
const candidateUrl = normalizeString(changeInfo?.url || tab?.url);
if (!candidateUrl) {
return;
}
+41 -18
View File
@@ -32,11 +32,14 @@
executeStepViaCompletionSignal,
exportSettingsBundle,
fetchGeneratedEmail,
finalizePhoneActivationAfterSuccessfulFlow,
finalizeStep3Completion,
finalizeIcloudAliasAfterSuccessfulFlow,
findHotmailAccount,
findPayPalAccount,
flushCommand,
getCurrentLuckmailPurchase,
getCurrentPayPalAccount,
getCurrentMail2925Account,
getPendingAutoRunTimerPlan,
getSourceLabel,
@@ -62,6 +65,7 @@
refreshIpProxyPool,
normalizeHotmailAccounts,
normalizeMail2925Accounts,
normalizePayPalAccounts,
normalizeRunCount,
AUTO_RUN_TIMER_KIND_SCHEDULED_START,
notifyStepComplete,
@@ -79,6 +83,7 @@
selectLuckmailPurchase,
switchIpProxy,
changeIpProxyExit,
setCurrentPayPalAccount,
setCurrentMail2925Account,
setCurrentHotmailAccount,
setContributionMode,
@@ -99,7 +104,9 @@
deleteMail2925Account,
deleteMail2925Accounts,
syncHotmailAccounts,
syncPayPalAccounts,
testHotmailAccountMailAccess,
upsertPayPalAccount,
upsertMail2925Account,
upsertHotmailAccount,
verifyHotmailAccount,
@@ -203,9 +210,33 @@
});
}
await finalizeIcloudAliasAfterSuccessfulFlow(latestState);
if (typeof finalizePhoneActivationAfterSuccessfulFlow === 'function') {
await finalizePhoneActivationAfterSuccessfulFlow(latestState);
}
}
async function handleStepData(step, payload) {
if (step === 1) {
const updates = {};
if (payload.oauthUrl) {
updates.oauthUrl = payload.oauthUrl;
broadcastDataUpdate({ oauthUrl: payload.oauthUrl });
}
if (payload.sub2apiSessionId !== undefined) updates.sub2apiSessionId = payload.sub2apiSessionId || null;
if (payload.sub2apiOAuthState !== undefined) updates.sub2apiOAuthState = payload.sub2apiOAuthState || null;
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.cpaOAuthState !== undefined) updates.cpaOAuthState = payload.cpaOAuthState || null;
if (payload.cpaManagementOrigin !== undefined) updates.cpaManagementOrigin = payload.cpaManagementOrigin || 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);
}
return;
}
const stateForStep = await getState();
const stepKey = getStepKeyForState(step, stateForStep);
@@ -252,24 +283,6 @@
}
switch (step) {
case 1: {
const updates = {};
if (payload.oauthUrl) {
updates.oauthUrl = payload.oauthUrl;
broadcastDataUpdate({ oauthUrl: payload.oauthUrl });
}
if (payload.sub2apiSessionId !== undefined) updates.sub2apiSessionId = payload.sub2apiSessionId || null;
if (payload.sub2apiOAuthState !== undefined) updates.sub2apiOAuthState = payload.sub2apiOAuthState || null;
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);
}
break;
}
case 2:
if (payload.email) {
await setEmailState(payload.email);
@@ -779,6 +792,16 @@
return { ok: true, account };
}
case 'UPSERT_PAYPAL_ACCOUNT': {
const account = await upsertPayPalAccount(message.payload || {});
return { ok: true, account };
}
case 'SELECT_PAYPAL_ACCOUNT': {
const account = await setCurrentPayPalAccount(String(message.payload?.accountId || ''));
return { ok: true, account };
}
case 'DELETE_HOTMAIL_ACCOUNT': {
await deleteHotmailAccount(String(message.payload?.accountId || ''));
return { ok: true };
+108 -37
View File
@@ -43,6 +43,78 @@
return message || `Codex2API 请求失败(HTTP ${responseStatus})。`;
}
function deriveCpaManagementOrigin(vpsUrl) {
const normalizedUrl = String(vpsUrl || '').trim();
if (!normalizedUrl) {
throw new Error('尚未配置 CPA 地址,请先在侧边栏填写。');
}
let parsed;
try {
parsed = new URL(normalizedUrl);
} catch {
throw new Error('CPA 地址格式无效,请先在侧边栏检查。');
}
return parsed.origin;
}
function getCpaApiErrorMessage(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 || `CPA 管理接口请求失败(HTTP ${responseStatus})。`;
}
async function fetchCpaManagementJson(origin, path, options = {}) {
const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 20000));
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const managementKey = String(options.managementKey || '').trim();
const headers = {
Accept: 'application/json',
'Content-Type': 'application/json',
};
if (managementKey) {
headers.Authorization = `Bearer ${managementKey}`;
headers['X-Management-Key'] = managementKey;
}
const response = await fetch(`${origin}${path}`, {
method: options.method || 'POST',
headers,
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(getCpaApiErrorMessage(payload, response.status));
}
return payload;
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error('CPA 管理接口请求超时,请稍后重试。');
}
throw error;
} finally {
clearTimeout(timer);
}
}
async function fetchCodex2ApiJson(origin, path, options = {}) {
const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 30000));
const controller = new AbortController();
@@ -97,49 +169,48 @@
if (!state.vpsUrl) {
throw new Error('尚未配置 CPA 地址,请先在侧边栏填写。');
}
await addLog(`${logLabel}:正在打开 CPA 面板...`);
const injectFiles = ['content/activation-utils.js', 'content/utils.js', 'content/vps-panel.js'];
await closeConflictingTabsForSource('vps-panel', state.vpsUrl);
const tab = await chrome.tabs.create({ url: state.vpsUrl, active: true });
const tabId = tab.id;
await rememberSourceLastUrl('vps-panel', state.vpsUrl);
await addLog(`${logLabel}:CPA 面板已打开,正在等待页面进入目标地址...`);
const matchedTab = await waitForTabUrlFamily('vps-panel', tabId, state.vpsUrl, {
timeoutMs: 15000,
retryDelayMs: 400,
});
if (!matchedTab) {
await addLog(`${logLabel}:CPA 页面尚未完全进入目标地址,继续尝试连接内容脚本...`, 'warn');
const managementKey = String(state.vpsPassword || '').trim();
if (!managementKey) {
throw new Error('尚未配置 CPA 管理密钥,请先在侧边栏填写。');
}
await ensureContentScriptReadyOnTab('vps-panel', tabId, {
inject: injectFiles,
timeoutMs: 45000,
retryDelayMs: 900,
logMessage: `${logLabel}:CPA 面板仍在加载,正在重试连接内容脚本...`,
const origin = deriveCpaManagementOrigin(state.vpsUrl);
await addLog(`${logLabel}:正在通过 CPA 管理接口获取 OAuth 授权链接...`);
const result = await fetchCpaManagementJson(origin, '/v0/management/codex-auth-url', {
method: 'GET',
managementKey,
});
const result = await sendToContentScriptResilient('vps-panel', {
type: 'REQUEST_OAUTH_URL',
source: 'background',
payload: {
vpsPassword: state.vpsPassword,
logStep: 7,
},
}, {
timeoutMs: 30000,
retryDelayMs: 700,
logMessage: `${logLabel}:CPA 面板通信未就绪,正在等待页面恢复...`,
});
const oauthUrl = String(
result?.url
|| result?.auth_url
|| result?.authUrl
|| result?.data?.url
|| result?.data?.auth_url
|| result?.data?.authUrl
|| ''
).trim();
const oauthState = String(
result?.state
|| result?.auth_state
|| result?.authState
|| result?.data?.state
|| result?.data?.auth_state
|| result?.data?.authState
|| ''
).trim()
|| extractStateFromAuthUrl(oauthUrl);
if (result?.error) {
throw new Error(result.error);
if (!oauthUrl || !oauthUrl.startsWith('http')) {
throw new Error('CPA 管理接口未返回有效的 auth_url。');
}
return result || {};
return {
oauthUrl,
cpaOAuthState: oauthState || null,
cpaManagementOrigin: origin,
};
}
async function requestCodex2ApiOAuthUrl(state, options = {}) {
+110
View File
@@ -0,0 +1,110 @@
(function attachBackgroundPayPalAccountStore(root, factory) {
root.MultiPageBackgroundPayPalAccountStore = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundPayPalAccountStoreModule() {
function createPayPalAccountStore(deps = {}) {
const {
broadcastDataUpdate,
findPayPalAccount,
getState,
normalizePayPalAccount,
normalizePayPalAccounts,
setPersistentSettings,
setState,
upsertPayPalAccountInList,
} = deps;
async function syncSelectedPayPalAccountState(account = null) {
const updates = account
? {
currentPayPalAccountId: account.id,
paypalEmail: String(account.email || '').trim(),
paypalPassword: String(account.password || ''),
}
: {
currentPayPalAccountId: '',
paypalEmail: '',
paypalPassword: '',
};
await setPersistentSettings(updates);
await setState({
currentPayPalAccountId: updates.currentPayPalAccountId || null,
paypalEmail: updates.paypalEmail,
paypalPassword: updates.paypalPassword,
});
broadcastDataUpdate({
currentPayPalAccountId: updates.currentPayPalAccountId || null,
paypalEmail: updates.paypalEmail,
paypalPassword: updates.paypalPassword,
});
}
async function syncPayPalAccounts(accounts) {
const normalized = normalizePayPalAccounts(accounts);
await setPersistentSettings({ paypalAccounts: normalized });
await setState({ paypalAccounts: normalized });
broadcastDataUpdate({ paypalAccounts: normalized });
const state = await getState();
if (state.currentPayPalAccountId && !findPayPalAccount(normalized, state.currentPayPalAccountId)) {
await syncSelectedPayPalAccountState(null);
}
return normalized;
}
function getCurrentPayPalAccount(state = {}) {
return findPayPalAccount(state.paypalAccounts, state.currentPayPalAccountId) || null;
}
async function upsertPayPalAccount(input = {}) {
const state = await getState();
const accounts = normalizePayPalAccounts(state.paypalAccounts);
const normalizedEmail = String(input?.email || '').trim().toLowerCase();
const existing = input?.id
? findPayPalAccount(accounts, input.id)
: accounts.find((account) => account.email === normalizedEmail) || null;
const normalized = normalizePayPalAccount({
...(existing || {}),
...input,
id: input?.id || existing?.id || crypto.randomUUID(),
createdAt: existing?.createdAt || Date.now(),
updatedAt: Date.now(),
});
const nextAccounts = typeof upsertPayPalAccountInList === 'function'
? upsertPayPalAccountInList(accounts, normalized)
: accounts.concat(normalized);
await syncPayPalAccounts(nextAccounts);
if (state.currentPayPalAccountId === normalized.id) {
await syncSelectedPayPalAccountState(normalized);
}
return normalized;
}
async function setCurrentPayPalAccount(accountId) {
const state = await getState();
const accounts = normalizePayPalAccounts(state.paypalAccounts);
const account = findPayPalAccount(accounts, accountId);
if (!account) {
throw new Error('未找到对应的 PayPal 账号。');
}
await syncSelectedPayPalAccountState(account);
return account;
}
return {
getCurrentPayPalAccount,
setCurrentPayPalAccount,
syncPayPalAccounts,
upsertPayPalAccount,
};
}
return {
createPayPalAccountStore,
};
});
File diff suppressed because it is too large Load Diff
+33 -8
View File
@@ -16,6 +16,7 @@
getTabId,
isTabAlive,
prepareStep8DebuggerClick,
recoverOAuthLocalhostTimeout,
reloadStep8ConsentPage,
reuseOrCreateTab,
sleepWithStop,
@@ -44,19 +45,43 @@
async function executeStep9(state) {
const visibleStep = getVisibleStep(state, 9);
if (!state.oauthUrl) {
let activeState = state;
if (!activeState.oauthUrl) {
const authLoginStep = getAuthLoginStepForVisibleStep(visibleStep);
throw new Error(`缺少登录用 OAuth 链接,请先完成步骤 ${authLoginStep}`);
}
await addLog(`步骤 ${visibleStep}:正在监听 localhost 回调地址...`);
const callbackTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(240000, {
step: visibleStep,
actionLabel: 'OAuth localhost 回调',
})
: 240000;
let callbackTimeoutMs = 240000;
let timeoutRecoveryAttempted = false;
while (true) {
try {
callbackTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(240000, {
step: visibleStep,
actionLabel: 'OAuth localhost 回调',
oauthUrl: activeState?.oauthUrl || '',
})
: 240000;
break;
} catch (error) {
if (timeoutRecoveryAttempted || typeof recoverOAuthLocalhostTimeout !== 'function') {
throw error;
}
const recoveredState = await recoverOAuthLocalhostTimeout({
error,
state: activeState,
visibleStep,
});
if (!recoveredState) {
throw error;
}
activeState = recoveredState;
timeoutRecoveryAttempted = true;
}
}
return new Promise((resolve, reject) => {
let resolved = false;
@@ -124,7 +149,7 @@
await chrome.tabs.update(signupTabId, { active: true });
await addLog(`步骤 ${visibleStep}:已切回认证页,正在准备调试器点击...`);
} else {
signupTabId = await reuseOrCreateTab('signup-page', state.oauthUrl);
signupTabId = await reuseOrCreateTab('signup-page', activeState.oauthUrl);
await addLog(`步骤 ${visibleStep}:已重新打开认证页,正在准备调试器点击...`);
}
+67 -3
View File
@@ -27,6 +27,7 @@
reuseOrCreateTab,
setState,
shouldUseCustomRegistrationEmail,
sleepWithStop,
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS,
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS,
throwIfStopped,
@@ -167,10 +168,26 @@
});
}
async function runStep8Attempt(state) {
function getStep8ResendIntervalMs(state = {}) {
const mail = getMailConfig(state);
if (mail?.provider === LUCKMAIL_PROVIDER) {
return 15000;
}
if (mail?.provider === HOTMAIL_PROVIDER || mail?.provider === '2925') {
return 0;
}
return Math.max(0, Number(STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS) || 0);
}
async function runStep8Attempt(state, runtime = {}) {
const visibleStep = getVisibleStep(state, 8);
const mail = getMailConfig(state);
if (mail.error) throw new Error(mail.error);
const stateLastResendAt = Number(state?.loginVerificationRequestedAt) || 0;
let latestResendAt = Math.max(0, Number(runtime?.stickyLastResendAt) || 0, stateLastResendAt);
const notifyResendRequestedAt = typeof runtime?.onResendRequestedAt === 'function'
? runtime.onResendRequestedAt
: null;
const stepStartedAt = Date.now();
const verificationFilterAfterTimestamp = mail.provider === '2925'
@@ -267,6 +284,16 @@
disableTimeBudgetCap: mail.provider === '2925',
getRemainingTimeMs: getStep8RemainingTimeResolver(state?.oauthUrl || '', visibleStep),
requestFreshCodeFirst: false,
lastResendAt: latestResendAt,
onResendRequestedAt: async (requestedAt) => {
const numericRequestedAt = Number(requestedAt) || 0;
if (numericRequestedAt > 0) {
latestResendAt = Math.max(latestResendAt, numericRequestedAt);
}
if (notifyResendRequestedAt) {
await notifyResendRequestedAt(latestResendAt);
}
},
targetEmail: fixedTargetEmail,
resendIntervalMs: mail.provider === LUCKMAIL_PROVIDER
? 15000
@@ -274,6 +301,9 @@
? 0
: STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS),
});
return {
lastResendAt: latestResendAt,
};
}
function isStep8RestartStep7Error(error) {
@@ -285,10 +315,22 @@
let currentState = state;
let mailPollingAttempt = 1;
let lastMailPollingError = null;
let stickyLastResendAt = Number(state?.loginVerificationRequestedAt) || 0;
while (true) {
try {
await runStep8Attempt(currentState);
const result = await runStep8Attempt(currentState, {
stickyLastResendAt,
onResendRequestedAt: async (requestedAt) => {
const numericRequestedAt = Number(requestedAt) || 0;
if (numericRequestedAt > 0) {
stickyLastResendAt = Math.max(stickyLastResendAt, numericRequestedAt);
}
},
});
if (Number(result?.lastResendAt) > 0) {
stickyLastResendAt = Math.max(stickyLastResendAt, Number(result.lastResendAt) || 0);
}
return;
} catch (err) {
const visibleStep = getVisibleStep(currentState, 8);
@@ -323,7 +365,29 @@
`步骤 ${visibleStep}:认证页仍保持在验证码页,将在当前链路直接重试(${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS}),不回到步骤 ${authLoginStep}`,
'warn'
);
currentState = await getState();
const latestState = await getState();
const latestStateResendAt = Number(latestState?.loginVerificationRequestedAt) || 0;
if (latestStateResendAt > 0) {
stickyLastResendAt = Math.max(stickyLastResendAt, latestStateResendAt);
}
currentState = latestState;
if (stickyLastResendAt > 0 && (!latestStateResendAt || latestStateResendAt < stickyLastResendAt)) {
currentState = {
...latestState,
loginVerificationRequestedAt: stickyLastResendAt,
};
}
const resendIntervalMs = getStep8ResendIntervalMs(currentState);
const remainingBeforeRetryMs = stickyLastResendAt > 0 && resendIntervalMs > 0
? Math.max(0, resendIntervalMs - (Date.now() - stickyLastResendAt))
: 0;
if (remainingBeforeRetryMs > 0 && typeof sleepWithStop === 'function') {
await addLog(
`步骤 ${visibleStep}:上轮已触发重发验证码,为避免重复重发,先等待 ${Math.ceil(remainingBeforeRetryMs / 1000)} 秒后继续当前链路重试。`,
'info'
);
await sleepWithStop(Math.min(remainingBeforeRetryMs, 3000));
}
continue;
}
await addLog(
+18 -21
View File
@@ -40,17 +40,14 @@
return /缺少|未配置|请输入|无效|错误|失败|401|认证失败|未授权|unauthorized|invalid/i.test(message);
}
function getVisibleStep(state, fallback = 7) {
const visibleStep = Math.floor(Number(state?.visibleStep) || 0);
return visibleStep > 0 ? visibleStep : fallback;
}
async function executeStep7(state) {
const visibleStep = getVisibleStep(state, 7);
if (!state.email) {
throw new Error('缺少邮箱地址,请先完成步骤 3。');
}
const visibleStep = Math.floor(Number(state?.visibleStep) || 0);
const completionStep = visibleStep > 0 ? visibleStep : 7;
let attempt = 0;
let lastError = null;
@@ -60,22 +57,22 @@
try {
const currentState = attempt === 1 ? state : await getState();
const password = currentState.password || currentState.customPassword || '';
const oauthUrl = await refreshOAuthUrlBeforeStep6(currentState, { visibleStep });
const oauthUrl = await refreshOAuthUrlBeforeStep6(currentState);
if (typeof startOAuthFlowTimeoutWindow === 'function') {
await startOAuthFlowTimeoutWindow({ step: visibleStep, oauthUrl });
await startOAuthFlowTimeoutWindow({ step: 7, oauthUrl });
}
const loginTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(180000, {
step: visibleStep,
step: 7,
actionLabel: 'OAuth 登录并进入验证码页',
oauthUrl,
})
: 180000;
if (attempt === 1) {
await addLog(`步骤 ${visibleStep}:正在打开最新 OAuth 链接并登录...`);
await addLog('步骤 7:正在打开最新 OAuth 链接并登录...');
} else {
await addLog(`步骤 ${visibleStep}:上一轮失败后,正在进行第 ${attempt} 次尝试(最多 ${STEP6_MAX_ATTEMPTS} 次)...`, 'warn');
await addLog(`步骤 7:上一轮失败后,正在进行第 ${attempt} 次尝试(最多 ${STEP6_MAX_ATTEMPTS} 次)...`, 'warn');
}
await reuseOrCreateTab('signup-page', oauthUrl);
@@ -89,14 +86,13 @@
payload: {
email: currentState.email,
password,
visibleStep,
},
},
{
timeoutMs: loginTimeoutMs,
responseTimeoutMs: loginTimeoutMs,
retryDelayMs: 700,
logMessage: `步骤 ${visibleStep}:认证页正在切换,等待页面重新就绪后继续登录...`,
logMessage: '步骤 7:认证页正在切换,等待页面重新就绪后继续登录...',
}
);
@@ -108,13 +104,14 @@
const completionPayload = {
loginVerificationRequestedAt: result.loginVerificationRequestedAt || null,
};
if (result.skipLoginVerificationStep) {
completionPayload.skipLoginVerificationStep = true;
if (Object.prototype.hasOwnProperty.call(result || {}, 'skipLoginVerificationStep')) {
completionPayload.skipLoginVerificationStep = Boolean(result.skipLoginVerificationStep);
}
if (result.directOAuthConsentPage) {
completionPayload.directOAuthConsentPage = true;
if (Object.prototype.hasOwnProperty.call(result || {}, 'directOAuthConsentPage')) {
completionPayload.directOAuthConsentPage = Boolean(result.directOAuthConsentPage);
}
await completeStepFromBackground(visibleStep, completionPayload);
await completeStepFromBackground(completionStep, completionPayload);
return;
}
@@ -132,7 +129,7 @@
}
if (isManagementSecretConfigError(err)) {
await addLog(
`步骤 ${visibleStep}:检测到来源后台管理密钥缺失或错误,不再重试,当前流程停止。原因:${getErrorMessage(err)}`,
`步骤 7:检测到来源后台管理密钥缺失或错误,不再重试,当前流程停止。原因:${getErrorMessage(err)}`,
'error'
);
throw err;
@@ -142,11 +139,11 @@
break;
}
await addLog(`步骤 ${visibleStep}:第 ${attempt} 次尝试失败,原因:${getErrorMessage(err)};准备重试...`, 'warn');
await addLog(`步骤 7:第 ${attempt} 次尝试失败,原因:${getErrorMessage(err)};准备重试...`, 'warn');
}
}
throw new Error(`步骤 ${visibleStep}:判断失败后已重试 ${STEP6_MAX_ATTEMPTS - 1} 次,仍未成功。最后原因:${getErrorMessage(lastError)}`);
throw new Error(`步骤 7:判断失败后已重试 ${STEP6_MAX_ATTEMPTS - 1} 次,仍未成功。最后原因:${getErrorMessage(lastError)}`);
}
return { executeStep7 };
+17 -4
View File
@@ -99,17 +99,30 @@
return result || {};
}
function resolvePayPalCredentials(state = {}) {
const currentId = String(state?.currentPayPalAccountId || '').trim();
const accounts = Array.isArray(state?.paypalAccounts) ? state.paypalAccounts : [];
const selectedAccount = currentId
? accounts.find((account) => String(account?.id || '').trim() === currentId) || null
: null;
return {
email: String(selectedAccount?.email || state?.paypalEmail || '').trim(),
password: String(selectedAccount?.password || state?.paypalPassword || ''),
};
}
async function submitLogin(tabId, state = {}) {
if (!state.paypalPassword) {
throw new Error('步骤 8:未配置 PayPal 密码,请先在侧边栏填写。');
const credentials = resolvePayPalCredentials(state);
if (!credentials.password) {
throw new Error('步骤 8:未配置可用的 PayPal 账号,请先在侧边栏添加并选择账号。');
}
await addLog('步骤 8:正在填写 PayPal 登录信息并提交...', 'info');
const result = await sendTabMessageUntilStopped(tabId, PAYPAL_SOURCE, {
type: 'PAYPAL_SUBMIT_LOGIN',
source: 'background',
payload: {
email: state.paypalEmail || '',
password: state.paypalPassword || '',
email: credentials.email,
password: credentials.password,
},
});
if (result?.error) {
+126 -73
View File
@@ -26,31 +26,23 @@
return String(value || '').trim();
}
function getVisibleStep(state, fallback = 10) {
function resolvePlatformVerifyStep(state = {}) {
const visibleStep = Math.floor(Number(state?.visibleStep) || 0);
return visibleStep > 0 ? visibleStep : fallback;
return visibleStep >= 10 ? visibleStep : 10;
}
function getConfirmStepForVisibleStep(visibleStep) {
return visibleStep >= 13 ? 12 : 9;
}
function getAuthLoginStepForVisibleStep(visibleStep) {
return visibleStep >= 13 ? 10 : 7;
}
function parseLocalhostCallback(rawUrl, visibleStep = 10, confirmStep = 9) {
function parseLocalhostCallback(rawUrl) {
let parsed;
try {
parsed = new URL(rawUrl);
} catch {
throw new Error(`步骤 ${visibleStep} 捕获到的 localhost OAuth 回调地址格式无效,请重新执行步骤 ${confirmStep}`);
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(`步骤 ${visibleStep} 捕获到的 localhost OAuth 回调地址缺少 code 或 state,请重新执行步骤 ${confirmStep}`);
throw new Error('步骤 10 捕获到的 localhost OAuth 回调地址缺少 code 或 state,请重新执行步骤 9。');
}
return {
@@ -72,6 +64,77 @@
return details || `Codex2API 请求失败(HTTP ${responseStatus})。`;
}
function deriveCpaManagementOrigin(vpsUrl) {
const normalizedUrl = normalizeString(vpsUrl);
if (!normalizedUrl) {
throw new Error('尚未填写 CPA 地址,请先在侧边栏输入。');
}
let parsed;
try {
parsed = new URL(normalizedUrl);
} catch {
throw new Error('CPA 地址格式无效,请先在侧边栏检查。');
}
return parsed.origin;
}
function getCpaApiErrorMessage(payload, responseStatus = 500) {
const details = [
payload?.error,
payload?.message,
payload?.detail,
payload?.reason,
]
.map((value) => normalizeString(value))
.find(Boolean);
return details || `CPA 管理接口请求失败(HTTP ${responseStatus})。`;
}
async function fetchCpaManagementJson(origin, path, options = {}) {
const controller = new AbortController();
const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 20000));
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const managementKey = normalizeString(options.managementKey);
const headers = {
Accept: 'application/json',
'Content-Type': 'application/json',
};
if (managementKey) {
headers.Authorization = `Bearer ${managementKey}`;
headers['X-Management-Key'] = managementKey;
}
const response = await fetch(`${origin}${path}`, {
method: options.method || 'POST',
headers,
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(getCpaApiErrorMessage(payload, response.status));
}
return payload;
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error('CPA 管理接口请求超时,请稍后重试。');
}
throw error;
} finally {
clearTimeout(timer);
}
}
async function fetchCodex2ApiJson(origin, path, options = {}) {
const controller = new AbortController();
const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 30000));
@@ -122,95 +185,88 @@
}
async function executeCpaStep10(state) {
const visibleStep = getVisibleStep(state, 10);
const confirmStep = getConfirmStepForVisibleStep(visibleStep);
const platformVerifyStep = resolvePlatformVerifyStep(state);
if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) {
throw new Error(`步骤 ${confirmStep} 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 ${confirmStep}`);
throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。');
}
if (!state.localhostUrl) {
throw new Error(`缺少 localhost 回调地址,请先完成步骤 ${confirmStep}`);
throw new Error('缺少 localhost 回调地址,请先完成步骤 9。');
}
if (!state.vpsUrl) {
throw new Error('尚未填写 CPA 地址,请先在侧边栏输入。');
}
if (shouldBypassStep9ForLocalCpa(state)) {
await addLog(`步骤 ${visibleStep}:检测到本地 CPA,且当前策略为“跳过第10步”,本轮不再重复提交回调地址。`, 'info');
await completeStepFromBackground(visibleStep, {
await addLog('步骤 10:检测到本地 CPA,且当前策略为“跳过第10步”,本轮不再重复提交回调地址。', 'info');
await completeStepFromBackground(platformVerifyStep, {
localhostUrl: state.localhostUrl,
verifiedStatus: 'local-auto',
});
return;
}
await addLog(`步骤 ${visibleStep}:正在打开 CPA 面板...`);
const injectFiles = ['content/activation-utils.js', 'content/utils.js', 'content/vps-panel.js'];
let tabId = await getTabId('vps-panel');
const alive = tabId && await isTabAlive('vps-panel');
if (!alive) {
tabId = await reuseOrCreateTab('vps-panel', state.vpsUrl, {
inject: injectFiles,
reloadIfSameUrl: true,
});
} else {
await closeConflictingTabsForSource('vps-panel', state.vpsUrl, { excludeTabIds: [tabId] });
await chrome.tabs.update(tabId, { active: true });
await rememberSourceLastUrl('vps-panel', state.vpsUrl);
const callback = parseLocalhostCallback(state.localhostUrl);
const expectedState = normalizeString(state.cpaOAuthState);
if (expectedState && expectedState !== callback.state) {
throw new Error('CPA 回调 state 与当前授权会话不匹配,请重新执行步骤 7。');
}
const managementKey = normalizeString(state.vpsPassword);
if (!managementKey) {
throw new Error('尚未配置 CPA 管理密钥,请先在侧边栏填写。');
}
await ensureContentScriptReadyOnTab('vps-panel', tabId, {
inject: injectFiles,
timeoutMs: 45000,
retryDelayMs: 900,
logMessage: `步骤 ${visibleStep}:CPA 面板仍在加载,正在重试连接...`,
});
await addLog('步骤 10:正在通过 CPA 管理接口提交回调地址...');
try {
const origin = normalizeString(state.cpaManagementOrigin) || deriveCpaManagementOrigin(state.vpsUrl);
const result = await fetchCpaManagementJson(origin, '/v0/management/oauth-callback', {
method: 'POST',
managementKey,
body: {
provider: 'codex',
redirect_url: callback.url,
},
});
await addLog(`步骤 ${visibleStep}:正在填写回调地址...`);
const result = await sendToContentScriptResilient('vps-panel', {
type: 'EXECUTE_STEP',
step: visibleStep,
source: 'background',
payload: { localhostUrl: state.localhostUrl, vpsPassword: state.vpsPassword, visibleStep },
}, {
timeoutMs: 125000,
responseTimeoutMs: 125000,
retryDelayMs: 700,
logMessage: `步骤 ${visibleStep}:CPA 面板通信未就绪,正在等待页面恢复...`,
});
if (result?.error) {
throw new Error(result.error);
const verifiedStatus = normalizeString(result?.message)
|| normalizeString(result?.status_message)
|| 'CPA 已通过接口提交回调';
await addLog(`步骤 10${verifiedStatus}`, 'ok');
await completeStepFromBackground(platformVerifyStep, {
localhostUrl: callback.url,
verifiedStatus,
});
} catch (error) {
const reason = normalizeString(error?.message) || 'unknown error';
await addLog(`步骤 10CPA 接口提交失败:${reason}`, 'error');
throw error;
}
}
async function executeCodex2ApiStep10(state) {
const visibleStep = getVisibleStep(state, 10);
const confirmStep = getConfirmStepForVisibleStep(visibleStep);
const platformVerifyStep = resolvePlatformVerifyStep(state);
if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) {
throw new Error(`步骤 ${confirmStep} 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 ${confirmStep}`);
throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。');
}
if (!state.localhostUrl) {
throw new Error(`缺少 localhost 回调地址,请先完成步骤 ${confirmStep}`);
throw new Error('缺少 localhost 回调地址,请先完成步骤 9。');
}
if (!state.codex2apiSessionId) {
throw new Error(`缺少 Codex2API 会话信息,请重新执行步骤 ${getAuthLoginStepForVisibleStep(visibleStep)}`);
throw new Error('缺少 Codex2API 会话信息,请重新执行步骤 7。');
}
if (!normalizeString(state.codex2apiAdminKey)) {
throw new Error('尚未配置 Codex2API 管理密钥,请先在侧边栏填写。');
}
const callback = parseLocalhostCallback(state.localhostUrl, visibleStep, confirmStep);
const callback = parseLocalhostCallback(state.localhostUrl);
const expectedState = normalizeString(state.codex2apiOAuthState);
if (expectedState && expectedState !== callback.state) {
throw new Error(`Codex2API 回调 state 与当前授权会话不匹配,请重新执行步骤 ${getAuthLoginStepForVisibleStep(visibleStep)}`);
throw new Error('Codex2API 回调 state 与当前授权会话不匹配,请重新执行步骤 7。');
}
const codex2apiUrl = normalizeCodex2ApiUrl(state.codex2apiUrl);
const origin = new URL(codex2apiUrl).origin;
await addLog(`步骤 ${visibleStep}:正在向 Codex2API 提交回调并创建账号...`);
await addLog('步骤 10:正在向 Codex2API 提交回调并创建账号...');
const result = await fetchCodex2ApiJson(origin, '/api/admin/oauth/exchange-code', {
adminKey: state.codex2apiAdminKey,
method: 'POST',
@@ -222,21 +278,19 @@
});
const verifiedStatus = normalizeString(result?.message) || 'Codex2API OAuth 账号添加成功';
await addLog(`步骤 ${visibleStep}${verifiedStatus}`, 'ok');
await completeStepFromBackground(visibleStep, {
await addLog(`步骤 10${verifiedStatus}`, 'ok');
await completeStepFromBackground(platformVerifyStep, {
localhostUrl: callback.url,
verifiedStatus,
});
}
async function executeSub2ApiStep10(state) {
const visibleStep = getVisibleStep(state, 10);
const confirmStep = getConfirmStepForVisibleStep(visibleStep);
if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) {
throw new Error(`步骤 ${confirmStep} 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 ${confirmStep}`);
throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。');
}
if (!state.localhostUrl) {
throw new Error(`缺少 localhost 回调地址,请先完成步骤 ${confirmStep}`);
throw new Error('缺少 localhost 回调地址,请先完成步骤 9。');
}
if (!state.sub2apiSessionId) {
throw new Error('缺少 SUB2API 会话信息,请重新执行步骤 1。');
@@ -251,7 +305,7 @@
const sub2apiUrl = normalizeSub2ApiUrl(state.sub2apiUrl);
const injectFiles = ['content/utils.js', 'content/sub2api-panel.js'];
await addLog(`步骤 ${visibleStep}:正在打开 SUB2API 后台...`);
await addLog('步骤 10:正在打开 SUB2API 后台...');
let tabId = await getTabId('sub2api-panel');
const alive = tabId && await isTabAlive('sub2api-panel');
@@ -273,13 +327,12 @@
injectSource: 'sub2api-panel',
});
await addLog(`步骤 ${visibleStep}:正在向 SUB2API 提交回调并创建账号...`);
await addLog('步骤 10:正在向 SUB2API 提交回调并创建账号...');
const result = await sendToContentScript('sub2api-panel', {
type: 'EXECUTE_STEP',
step: visibleStep,
step: 10,
source: 'background',
payload: {
visibleStep,
localhostUrl: state.localhostUrl,
sub2apiUrl,
sub2apiEmail: state.sub2apiEmail,
+38 -2
View File
@@ -222,6 +222,28 @@
return Math.min(20, Math.max(0, Math.floor(numeric)));
}
function getVerificationRequestedAtStateKey(step) {
if (Number(step) === 4) return 'signupVerificationRequestedAt';
if (Number(step) === 8) return 'loginVerificationRequestedAt';
return '';
}
function resolveInitialVerificationRequestedAt(step, state = {}, fallback = 0) {
const stateKey = getVerificationRequestedAtStateKey(step);
const candidateValues = [
fallback,
stateKey ? state?.[stateKey] : 0,
];
for (const value of candidateValues) {
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric > 0) {
return Math.floor(numeric);
}
}
return 0;
}
function getLegacyVerificationResendCountDefault(step, options = {}) {
const requestFreshCodeFirst = Boolean(options.requestFreshCodeFirst);
const legacyMaxRounds = Math.max(1, Math.floor(Number(VERIFICATION_POLL_MAX_ROUNDS) || 1));
@@ -985,9 +1007,23 @@
: getConfiguredVerificationResendCount(step, state, { requestFreshCodeFirst });
const maxSubmitAttempts = mail.provider === LUCKMAIL_PROVIDER ? 3 : 15;
const resendIntervalMs = Math.max(0, Number(options.resendIntervalMs) || 0);
let lastResendAt = Number(options.lastResendAt) || 0;
const externalOnResendRequestedAt = typeof options.onResendRequestedAt === 'function'
? options.onResendRequestedAt
: null;
let lastResendAt = resolveInitialVerificationRequestedAt(
step,
state,
Number(options.lastResendAt) || 0
);
const updateFilterAfterTimestampForVerificationStep = async (_requestedAt) => {
const updateFilterAfterTimestampForVerificationStep = async (requestedAt) => {
if (externalOnResendRequestedAt) {
try {
await externalOnResendRequestedAt(requestedAt);
} catch (_) {
// Keep resend flow best-effort; state sync callback failures should not break verification.
}
}
return nextFilterAfterTimestamp;
};
+16 -8
View File
@@ -227,6 +227,18 @@ function getPayPalLoginPhase(emailInput, passwordInput) {
return '';
}
function refillPayPalEmailInput(emailInput, email) {
if (!emailInput) return;
if (typeof emailInput.focus === 'function') {
emailInput.focus();
}
fillInput(emailInput, '');
fillInput(emailInput, email);
if (typeof emailInput.blur === 'function') {
emailInput.blur();
}
}
async function submitPayPalLogin(payload = {}) {
await waitForDocumentComplete();
@@ -241,9 +253,7 @@ async function submitPayPalLogin(payload = {}) {
const emailNextButton = findEmailNextButton();
if (emailInput && emailNextButton && isEnabledControl(emailNextButton) && (!passwordInput || !findPasswordLoginButton())) {
if (normalizeText(emailInput.value || '') !== email) {
fillInput(emailInput, email);
}
refillPayPalEmailInput(emailInput, email);
simulateClick(emailNextButton);
return {
submitted: false,
@@ -253,9 +263,7 @@ async function submitPayPalLogin(payload = {}) {
}
if (!passwordInput && emailInput && email) {
if (normalizeText(emailInput.value || '') !== email) {
fillInput(emailInput, email);
}
refillPayPalEmailInput(emailInput, email);
const nextButton = await waitUntil(() => {
const button = findEmailNextButton() || findLoginNextButton();
return button && isEnabledControl(button) ? button : null;
@@ -272,8 +280,8 @@ async function submitPayPalLogin(payload = {}) {
};
} else if (!passwordInput && emailInput && !email) {
throw new Error('PayPal 账号为空,请先在侧边栏配置。');
} else if (emailInput && email && normalizeText(emailInput.value || '') !== email) {
fillInput(emailInput, email);
} else if (emailInput && email) {
refillPayPalEmailInput(emailInput, email);
}
passwordInput = passwordInput || await waitUntil(() => findPasswordInput(), {
+119
View File
@@ -18,6 +18,8 @@
throwIfStopped,
waitForElement,
} = deps;
const PHONE_RESEND_THROTTLED_ERROR_PREFIX = 'PHONE_RESEND_THROTTLED::';
const PHONE_RESEND_THROTTLED_PATTERN = /tried\s+to\s+resend\s+too\s+many\s+times|please\s+try\s+again\s+later|too\s+many\s+resend|resend\s+too\s+many|发送.*过于频繁|稍后再试|重试次数过多/i;
function dispatchInputEvents(element) {
if (!element) return;
@@ -312,6 +314,90 @@
return matches?.[0] ? matches[0].replace(/\s+/g, ' ').trim() : '';
}
function getAddPhoneErrorText() {
const form = getAddPhoneForm();
if (!form) {
return '';
}
const messages = [];
const selectors = [
'.react-aria-FieldError',
'[slot="errorMessage"]',
'[id$="-error"]',
'[data-invalid="true"] + *',
'[aria-invalid="true"] + *',
'[class*="error"]',
];
for (const selector of selectors) {
form.querySelectorAll(selector).forEach((el) => {
const text = String(el?.textContent || '').replace(/\s+/g, ' ').trim();
if (text) {
messages.push(text);
}
});
}
const invalidInput = form.querySelector('input[aria-invalid="true"], input[data-invalid="true"]');
if (invalidInput) {
const wrapper = invalidInput.closest('form, [data-rac], div');
const text = String(wrapper?.textContent || '').replace(/\s+/g, ' ').trim();
if (text) {
messages.push(text);
}
}
const preferred = messages.find((text) => (
/already|used|linked|eligible|invalid|phone|号码|手机号|错误|失败|try\s+again/i.test(text)
));
return preferred || messages[0] || '';
}
function getPhoneVerificationInlineMessages() {
const form = getPhoneVerificationForm();
if (!form) {
return [];
}
const messages = [];
const selectors = [
'.react-aria-FieldError',
'[slot="errorMessage"]',
'[id$="-error"]',
'[data-invalid="true"] + *',
'[aria-invalid="true"] + *',
'[class*="error"]',
];
for (const selector of selectors) {
form.querySelectorAll(selector).forEach((element) => {
const text = String(element?.textContent || '').replace(/\s+/g, ' ').trim();
if (text) {
messages.push(text);
}
});
}
const verificationError = String(getVerificationErrorText?.() || '').trim();
if (verificationError) {
messages.push(verificationError);
}
return messages;
}
function getPhoneResendThrottleText() {
const inlineMatch = getPhoneVerificationInlineMessages()
.find((text) => PHONE_RESEND_THROTTLED_PATTERN.test(text));
if (inlineMatch) {
return inlineMatch;
}
const pageSnapshot = String(getPageTextSnapshot?.() || '').replace(/\s+/g, ' ').trim();
if (pageSnapshot && PHONE_RESEND_THROTTLED_PATTERN.test(pageSnapshot)) {
const concise = pageSnapshot.match(
/tried\s+to\s+resend\s+too\s+many\s+times[^.。!?]*[.。!?]?|please\s+try\s+again\s+later[^.。!?]*[.。!?]?|发送.*过于频繁[^。!?]*[。!?]?|稍后再试[^。!?]*[。!?]?/i
);
return String(concise?.[0] || pageSnapshot).trim();
}
return '';
}
async function waitForAddPhoneReady(timeout = 20000) {
const start = Date.now();
while (Date.now() - start < timeout) {
@@ -335,8 +421,28 @@
url: location.href,
};
}
if (isAddPhonePageReady()) {
const errorText = getAddPhoneErrorText();
if (errorText) {
return {
addPhoneRejected: true,
errorText,
url: location.href,
};
}
}
await sleep(150);
}
if (isAddPhonePageReady()) {
const errorText = getAddPhoneErrorText();
if (errorText) {
return {
addPhoneRejected: true,
errorText,
url: location.href,
};
}
}
throw new Error('Timed out waiting for phone verification page.');
}
@@ -466,11 +572,19 @@
const start = Date.now();
while (Date.now() - start < timeout) {
throwIfStopped();
const throttledText = getPhoneResendThrottleText();
if (throttledText) {
throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${throttledText}`);
}
const resendButton = getPhoneVerificationResendButton({ allowDisabled: true });
if (resendButton && isActionEnabled(resendButton)) {
await humanPause(250, 700);
simulateClick(resendButton);
await sleep(1000);
const afterClickThrottleText = getPhoneResendThrottleText();
if (afterClickThrottleText) {
throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${afterClickThrottleText}`);
}
return {
resent: true,
url: location.href,
@@ -479,6 +593,11 @@
await sleep(250);
}
const timeoutThrottleText = getPhoneResendThrottleText();
if (timeoutThrottleText) {
throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${timeoutThrottleText}`);
}
throw new Error('Timed out waiting for the phone verification resend button.');
}
+201 -7
View File
@@ -1,6 +1,6 @@
# Codex 注册扩展相关项目、更新、邮箱、PayPal 与 Clash Verge 配置教程
本教程用于说明相关项目地址、扩展更新方式、`Cloudflare Temp Email``iCloud 隐私邮箱``QQ 邮箱` 的使用方法、`PayPal` 注册绑卡流程、节点检测与纯净度检查,以及 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` 配置方法。
本教程用于说明相关项目地址、扩展更新方式、`Cloudflare Temp Email``iCloud 隐私邮箱``QQ 邮箱` 的使用方法、`HeroSMS` 手机接码扩展能力、`PayPal` 注册绑卡流程、扩展内置动态 IP 代理、节点检测与纯净度检查,以及 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` 配置方法。
## 适用场景
@@ -10,7 +10,9 @@
- 需要使用 `iCloud+``隐藏邮件地址` 作为隐私邮箱
- 需要临时切换 `QQ 邮箱` 地址继续使用
- 需要使用 `网易邮箱``网易邮箱大师` 注册多个邮箱或替身邮箱
- 需要在 `Step 9` 的手机号验证阶段使用 `HeroSMS` 接码(含多国家回退与价格策略)
- 需要注册并使用 `PayPal` 个人账户
- 需要在扩展内使用 `711Proxy` 动态 IP,并在自动流程中按轮次切换出口
- 需要检查当前节点的出口 IP、纯净度、泄露情况和访问速度
- 需要在 `Clash Verge` 中启用 `🔁 非港轮询`
@@ -25,7 +27,9 @@
- 一个可正常登录的 `QQ 邮箱`
- 手机端已安装 `网易邮箱``网易邮箱大师`
- 一个可正常接收短信的手机号
- 一个可用的 `HeroSMS API Key`(用于手机号接码)
- 一张可在线支付的借记卡或信用卡
- 如需使用扩展内置动态 IP,需准备 `711Proxy` 的 Host、Port、代理账号和代理密码
- 如需部署 `cpa`,部署环境必须可以访问 `OpenAI`
- 已安装 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev),并已导入可用订阅
@@ -219,7 +223,71 @@
只要网络环境不要太差,并且不要连续高频注册,一般不容易马上触发额外手机号验证。
建议注册一个后换节奏再继续,不要一口气连续创建。
### 第七部分:`PayPal` 注册与绑卡使用教程
### 第七部分:`HeroSMS` 手机接码扩展使用教程(新增)
本部分用于说明扩展中 `Step 9` 手机号验证链路的最新接码能力与推荐用法。
目标是减少“重复重发导致封禁”与“同国家持续拿不到码”的失败率,并让失败可以在 `Step 9` 内部自愈。
#### 一、入口与启用
1. 打开扩展侧栏,找到 `接码设置`
2. 打开右侧接码开关(`IP 代理`下方同风格开关)。
3. 展开设置后,先确认 `接码平台` 显示为 `HeroSMS / OpenAI`
#### 二、国家与优先级(新增能力)
1.`接码国家` 中多选国家(最多 `3` 个),按你的业务顺序排列。
2.`国家优先级` 中选择策略:
- `国家优先`:严格按你选择的顺序先后尝试。
- `价格优先`:在候选国家里先选可用价格更低的国家,再尝试拿号。
3. 当同一国家连续失败达到阈值后,流程会自动优先尝试下一个候选国家。
#### 三、价格控制与价格预览(新增能力)
1. 填写 `接码 API`(支持右侧眼睛按钮显示/隐藏,避免粘贴错误)。
2.`价格` 一行点击 `查询价格`,查看候选国家当前档位。
3. 设置 `价格上限``maxPrice`)限制成本,避免异常高价拿号。
4. 如果日志提示 `no numbers within maxPrice`,说明上限太低,按当前价格结果适度上调即可。
#### 四、号码复用与参数建议
1. `号码复用` 开关:
- 开启:同号码在可复用范围内优先复用,减少频繁拿号。
- 关闭:每次优先新号,适合风控更严格的场景。
2. 建议参数(稳妥起步):
- `验证码重发``0``1`
- `换号上限``3`
- `验证码限时``60`
- `超时次数``2`
- `轮询间隔``5`
- `轮询次数``3-4` 次(不要设置过高)
#### 五、失败自愈策略(新增能力)
当前版本已内置以下保护逻辑:
1. 当页面出现 `Tried to resend too many times. Please try again later.` 时,流程会停止继续狂点 `Resend`,改为换号/换国家路径。
2. 单个号码不会无限重发,避免被目标站点判定重发过频。
3. 收不到短信时优先在 `Step 9` 内局部恢复,不直接回卷到 `Step 1`
#### 六、运行状态怎么看
`运行状态` 中重点看:
1. `当前分配`:当前拿到的手机号与国家。
2. `验证码`:是否已收到可提交验证码。
3. `价格预览`:当前国家候选的可用价格信息(查询后刷新)。
#### 七、常见问题与处理
1. `no numbers available across ...`
表示候选国家当前无号或价格上限过低。先提高价格上限,再调整国家顺序。
2. `NO_NUMBERS` 频繁出现
增加候选国家数量(最多 3 个),并优先启用 `价格优先`
3. 页面提示重发过多
不要手工持续点重发,让流程自动换号/换国家即可。
### 第八部分:`PayPal` 注册与绑卡使用教程
1. 打开注册页面
打开 [https://www.paypal.com/signin](https://www.paypal.com/signin)。
@@ -271,14 +339,14 @@
常见情况是上传身份证件。
`PayPal` 官方帮助中心说明,通常会在 `2 个工作日` 内审核,但某些情况可能更久。
### 第部分:0元试用 ChatGPT Plus 教程
### 第部分:0元试用 ChatGPT Plus 教程
本部分说明如何在已登录 ChatGPT 的状态下,通过脚本快速生成 Plus 支付链接,然后选择 PayPal 支付完成0元试用订阅。
#### 准备工作
1. 已有一个登录状态的 ChatGPT 账户。
2. 一个可用的 PayPal 账户(参考第部分进行注册和绑卡)。
2. 一个可用的 PayPal 账户(参考第部分进行注册和绑卡)。
3. 能够接收生成的账单地址的真实地址或虚拟地址。
4. Chrome 浏览器(推荐使用地址补全功能)。
@@ -426,7 +494,113 @@
- **PayPal 登录后页面无法继续跳转**
稍等片刻让页面加载完毕。如果长时间未响应,检查浏览器是否有弹窗被隐藏,或尝试刷新页面。
### 第部分:节点检测与纯净度检查网站
### 第部分:扩展内置动态 IP 代理使用教程
本部分说明扩展侧边栏里的 `IP代理` 功能。
它和 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 这类系统代理不一样:扩展会通过浏览器代理 API 给当前浏览器设置 PAC 代理,并自动回填代理鉴权。当前首版只开放 `711Proxy` 的账号密码模式,`API 拉取` 和多账号列表入口暂未开放。
#### 一、什么时候需要打开 `IP代理`
如果你已经在 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 或其他客户端里稳定使用节点,可以继续使用外部代理。
如果你希望在扩展里直接控制动态 IP、检查当前出口、按成功轮次自动切换,或者不想依赖系统全局代理,就使用这里的 `IP代理`
开启 `IP代理` 后,扩展会接管浏览器代理。
关闭 `IP代理` 后,浏览器会恢复默认代理或外部全局代理。
#### 二、注册或登录 `711Proxy`
在侧边栏打开 `IP代理` 后,`代理服务` 当前固定为 `711Proxy(首版)`
点击旁边的 `注册` 按钮,按页面提示完成注册或登录。
在网站中完成注册、登录和套餐准备后,进入代理信息页面,找到可用于账号密码代理的 `Host``Port``Username``Password`
#### 三、填写固定账号代理
1. 在扩展侧边栏打开 `IP代理` 开关。
2. `代理模式` 保持 `账号密码`
3. 填写 `代理 Host`,例如 `global.rotgb.711proxy.com`
4. 填写 `代理 Port`,例如 `10000`
5. `代理协议` 一般保持 `HTTP`,除非你的代理信息明确要求 `HTTPS``SOCKS5``SOCKS4`
6. 填写 `代理账号``代理密码`
7. 按需填写 `地区参数`,例如 `US``DE``HK`
8. 按需填写 `会话(session)``时长(life)`
`地区参数` 只接受两个字母的国家或地区代码。
`会话(session)` 会被追加或写回到用户名里的 `session-xxxx`
`时长(life)` 会被追加或写回到用户名里的 `sessTime-xx`711Proxy 的有效范围按 `5-180` 分钟使用更稳。
如果你的 `711Proxy` 用户名里已经带了 `region-US``session-xxxx``sessTime-30`,扩展会优先识别用户名里的值。
如果你在表单里修改地区、session 或时长,扩展会把它们同步回最终生效的代理用户名。
#### 四、同步、下一条、Change 和检测出口
填写或修改代理信息后,不要只保存后就直接开跑。建议按下面顺序操作:
1. 点击 `同步`
扩展会保存当前配置,生成代理规则,应用到浏览器,并自动做一次出口检测。
这是修改 Host、Port、账号、密码、地区、session 或时长后最常用的按钮。
2. 查看 `代理状态`
状态卡会显示当前代理、当前出口 IP、出口地区和诊断详情。
如果显示 `当前代理``当前出口`,说明扩展已经能看到代理出口。
3. 点击 `检测出口`
它只重新检测当前出口,不主动更换节点。
如果你怀疑检测结果没刷新,或者刚刚切换过外部网络,可以点它复测。
4. 点击 `检查IP`
它会打开 `https://ipinfo.io/what-is-my-ip`,用于人工确认网页看到的公网 IP。
5. 点击 `下一条`
当前首版主要是固定账号模式。如果只有一条可用代理,`下一条` 会重绑当前节点并复测,不保证一定换出口。
如果后续开放多账号列表或 API 池,`下一条` 才会真正切到池里的下一条。
6. 点击 `Change`
`Change` 只支持 `711Proxy + 账号密码模式 + 用户名包含 session` 的场景。
它会保持当前 session,强制重绑代理链路并刷新出口,适合想换出口但不想改整条账号配置时使用。
#### 五、任务切换阈值
`任务切换阈值` 表示自动流程成功多少轮后尝试自动切换一次代理。
默认是 `20` 轮,可填写 `1-500`
需要注意:
- 当前只有一条固定账号时,即使命中阈值,也会跳过自动切换。
- 如果后续有多条可切换代理,命中阈值后会自动执行 `下一条`
- 这个阈值只影响自动流程成功后的切换节奏,不会替代你手动点击 `同步``检测出口`
#### 六、状态卡怎么看
常见状态含义如下:
- `未开启`:扩展没有接管浏览器代理,继续使用默认网络或外部全局代理。
- `当前代理:host:port;当前出口:x.x.x.x`:代理已经应用,并检测到出口 IP。
- `已启用,但账号模式没有可用代理`:Host/Port 为空或格式不完整,需要先补齐代理信息。
- `连通性失败`:代理应用了,但没有检测到可用出口,优先检查 Host、Port、协议、账号和密码。
- `地区校验未通过`:检测到的出口地区和你填写的地区参数不一致。711Proxy 场景下扩展会保留接管并给出警告,你需要决定是否继续使用这个出口。
- `当前链路未收到 407 代理鉴权挑战`:代理服务没有按浏览器扩展代理 API 预期返回标准鉴权挑战,可能导致账号密码无法自动回填。可以尝试 `Change`、关闭再开启 `IP代理`、换 session,或者更换代理节点。
如果代理不可用,扩展会启用目标站点阻断保护,避免在代理失效时继续直连访问目标站点。修好代理后重新点击 `同步``检测出口` 即可恢复。
#### 七、推荐使用顺序
首次配置建议按这个顺序:
1. 点击 `注册` 打开 `711Proxy`,准备代理账号信息。
2. 打开扩展侧边栏的 `IP代理`
3. 填写 Host、Port、协议、账号、密码。
4. 填写地区参数,例如 `US`
5. 填写 session 和时长,例如 session 为随机前缀、时长为 `30`
6. 点击 `同步`
7. 确认 `代理状态` 里有出口 IP。
8. 点击 `检查IP`,人工确认网页检测结果。
9. 再继续执行注册、登录或自动流程。
如果后续要换出口,优先尝试 `Change`
如果 `Change` 不可用,再调整 session 或代理账号后点击 `同步`
### 第十一部分:节点检测与纯净度检查网站
本部分用于检查当前代理节点的出口 IP、归属地、风险标签、泄露情况和网站访问速度。
建议每次切换节点后都重新打开这些网站检查一次。
@@ -460,7 +634,7 @@
接着打开 [IPPure](https://ippure.com/) 检查纯净度、黑名单、代理识别和泄露情况。
最后用 [TCPTest 网站测速](https://www.tcptest.cn/?cckey=e62f31db) 测试目标网站的访问速度和连通性。
### 第十部分:配置 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询`
### 第十部分:配置 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询`
#### 第一步:添加扩展脚本
@@ -569,7 +743,7 @@ function main(config, profileName) {
5. 确认左侧 `设置` 中的 `系统代理``System Proxy`)已经开启。
![2026 04 25 003703](https://apikey.qzz.io/content-assets/library/2026/04/20260424-163745--2026-04-25-003703--21e892504b82.png)
### 第十部分:订阅节点与自建推荐
### 第十部分:订阅节点与自建推荐
如果你还没有订阅节点或想要寻找稳定、便宜的科学上网方式,可以参考以下三种方案获取。
@@ -661,6 +835,23 @@ function main(config, profileName) {
请先确认脚本已经完整粘贴并保存,然后回到 `代理` 页面重新查看。若仍未出现,请继续确认当前订阅可用、`代理模式``规则模式`,并且 `系统代理` 已开启。
### 扩展 `IP代理` 开启后,为什么状态还是没有出口 IP?
先确认 Host、Port、协议、代理账号和代理密码都已填写正确。
修改这些字段后,需要点击 `同步` 才会重新应用代理并检测出口。
如果诊断提示没有收到 `407` 代理鉴权挑战,可以尝试点击 `Change`、关闭再开启 `IP代理`、更换 session,或者换一条代理节点。
### `Change` 按钮为什么点不了?
`Change` 只在 `711Proxy` 的账号密码模式下可用,并且当前生效用户名必须包含 `session-xxxx` 这类 session 参数。
如果没有 session,请先在 `会话(session)` 中填写一个随机前缀,再点击 `同步`
### `IP代理` 和 Clash Verge 应该同时开吗?
通常二选一即可。
如果开启扩展内置 `IP代理`,浏览器会优先使用扩展设置的 PAC 代理;如果关闭它,浏览器会回到默认网络或外部全局代理。
排查问题时建议先只保留一种代理方式,确认出口 IP 稳定后再继续流程。
### 网站测速结果好,是否代表节点纯净?
不代表。
@@ -688,6 +879,9 @@ function main(config, profileName) {
- 中国大陆居民注册 `PayPal` 个人账户时,姓名请按身份证上的中文姓名填写,不要使用拼音。
- `PayPal` 官方说明,绑定新卡时可能会向发卡行发送最高 `1 USD` 或等值货币的临时授权验证,所以完全没有余额的卡也可能因为授权失败而无法绑定。
- 为了控制风险,更稳妥的做法是使用单独管理、余额较低的借记卡,并在绑卡后及时检查 `钱包` 页面和右上角通知。
- 使用扩展内置 `IP代理` 时,修改 Host、Port、账号、密码、地区、session 或时长后,请点击 `同步` 让新配置真正生效。
- `711Proxy``Change` 需要用户名中包含 session;没有 session 时请先填写 `会话(session)` 并同步。
- 扩展 `IP代理` 报连通性失败时,不要直接继续自动流程,先用 `检测出口``检查IP` 确认网页看到的出口 IP。
- 检查节点时,先确认出口 IP,再看风险标签和泄露检测,最后再看网站测速。
- [TCPTest 网站测速](https://www.tcptest.cn/?cckey=e62f31db) 主要用于判断访问速度和连通性,不要单独用它判断节点纯净度。
- [IPPure](https://ippure.com/) 如果提示 `WebRTC``DNS` 泄露,请优先检查浏览器和代理客户端的相关设置。
+56
View File
@@ -0,0 +1,56 @@
(function attachPayPalUtils(root, factory) {
root.PayPalUtils = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createPayPalUtils() {
function normalizePayPalAccount(account = {}) {
const normalizedEmail = String(account.email || '').trim().toLowerCase();
const now = Date.now();
return {
id: String(account.id || crypto.randomUUID()),
email: normalizedEmail,
password: String(account.password || ''),
createdAt: Number.isFinite(Number(account.createdAt)) ? Number(account.createdAt) : now,
updatedAt: Number.isFinite(Number(account.updatedAt)) ? Number(account.updatedAt) : now,
lastUsedAt: Number.isFinite(Number(account.lastUsedAt)) ? Number(account.lastUsedAt) : 0,
};
}
function normalizePayPalAccounts(accounts) {
if (!Array.isArray(accounts)) return [];
const deduped = new Map();
for (const account of accounts) {
const normalized = normalizePayPalAccount(account);
if (!normalized.email) continue;
deduped.set(normalized.id, normalized);
}
return [...deduped.values()];
}
function findPayPalAccount(accounts = [], accountId = '') {
const normalizedId = String(accountId || '').trim();
if (!normalizedId) return null;
return normalizePayPalAccounts(accounts).find((account) => account.id === normalizedId) || null;
}
function upsertPayPalAccountInList(accounts = [], nextAccount = null) {
if (!nextAccount) {
return normalizePayPalAccounts(accounts);
}
const normalizedNext = normalizePayPalAccount(nextAccount);
const list = normalizePayPalAccounts(accounts);
const existingIndex = list.findIndex((account) => account.id === normalizedNext.id);
if (existingIndex >= 0) {
list[existingIndex] = normalizedNext;
return list;
}
return [...list, normalizedNext];
}
return {
findPayPalAccount,
normalizePayPalAccount,
normalizePayPalAccounts,
upsertPayPalAccountInList,
};
});
+50
View File
@@ -123,6 +123,48 @@ def log_info(message):
print(f"[HotmailHelper] {message}", flush=True)
def is_openai_sender(address):
sender = str(address or "").strip().lower()
return "openai" in sender
def get_message_body_content(message):
body = message.get("body") or {}
if not isinstance(body, dict):
return ""
return str(body.get("content") or "").strip()
def log_openai_messages(messages, transport=""):
for message in messages or []:
sender_info = message.get("from", {}).get("emailAddress", {}) or {}
sender = str(sender_info.get("address") or "").strip()
if not is_openai_sender(sender):
continue
sender_name = str(sender_info.get("name") or "").strip()
mailbox = str(message.get("mailbox") or "").strip() or "INBOX"
subject = str(message.get("subject") or "").strip()
transport_label = str(transport or "").strip() or "unknown"
base = (
f"transport={transport_label} mailbox={mailbox} sender={sender} "
f"senderName={sender_name or '-'} subject={subject}"
)
log_info(f"openai mail received {base}")
body_content = get_message_body_content(message)
if body_content:
log_info(f"openai mail full body start {base}")
print(body_content, flush=True)
log_info("openai mail full body end")
continue
preview = str(message.get("bodyPreview") or "").strip()
if preview:
log_info(f"openai mail preview {base} preview={compact_text(preview, 1000)}")
def get_proxy_debug_context():
names = ["all_proxy", "http_proxy", "https_proxy", "ALL_PROXY", "HTTP_PROXY", "HTTPS_PROXY"]
parts = []
@@ -481,6 +523,9 @@ def normalize_message(message_id, raw_bytes, mailbox):
}
},
"bodyPreview": body[:500],
"body": {
"content": body,
},
"receivedDateTime": to_iso_string(timestamp_ms),
"receivedTimestamp": timestamp_ms,
}
@@ -680,6 +725,7 @@ def collect_messages(email_addr, client_id, refresh_token, mailboxes, top):
try:
log_info(f"message collection start transport={transport_name}")
result = collector(email_addr, client_id, refresh_token, mailboxes, top)
log_openai_messages(result.get("messages") or [], transport=transport_name)
log_info(
f"message collection success transport={transport_name} "
f"tokenEndpoint={result['token_payload'].get('token_endpoint', '')}"
@@ -722,6 +768,10 @@ def select_latest_code(messages, sender_filters, subject_filters, exclude_codes,
preview = str(message.get("bodyPreview", ""))
combined = " ".join([sender, subject.lower(), preview.lower()])
code = extract_code(" ".join([subject, preview, sender]))
if not code:
body_content = get_message_body_content(message)
if body_content:
code = extract_code(" ".join([subject, body_content, sender]))
if not code or code in excluded:
return None
+269
View File
@@ -0,0 +1,269 @@
(function attachSidepanelFormDialog(globalScope) {
const FORM_EYE_OPEN_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>';
const FORM_EYE_CLOSED_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.94 10.94 0 0 1 12 19C5 19 1 12 1 12a21.77 21.77 0 0 1 5.06-6.94"/><path d="M9.9 4.24A10.94 10.94 0 0 1 12 5c7 0 11 7 11 7a21.86 21.86 0 0 1-2.16 3.19"/><path d="M1 1l22 22"/><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/></svg>';
function syncPasswordToggleButton(button, input, labels) {
if (!button || !input) return;
const isHidden = input.type === 'password';
button.innerHTML = isHidden ? FORM_EYE_OPEN_ICON : FORM_EYE_CLOSED_ICON;
button.setAttribute('aria-label', isHidden ? labels.show : labels.hide);
button.title = isHidden ? labels.show : labels.hide;
}
function createFormDialog(context = {}) {
const {
overlay = null,
titleNode = null,
closeButton = null,
messageNode = null,
alertNode = null,
fieldsContainer = null,
cancelButton = null,
confirmButton = null,
documentRef = globalScope.document,
} = context;
let resolver = null;
let currentConfig = null;
let currentInputs = [];
function setHidden(node, hidden) {
if (!node) return;
node.hidden = Boolean(hidden);
}
function resetAlert() {
if (!alertNode) return;
alertNode.textContent = '';
alertNode.className = 'modal-alert modal-form-alert';
alertNode.hidden = true;
}
function setAlert(message = '', tone = 'danger') {
if (!alertNode) return;
const text = String(message || '').trim();
if (!text) {
resetAlert();
return;
}
alertNode.textContent = text;
alertNode.className = `modal-alert modal-form-alert${tone === 'danger' ? ' is-danger' : ''}`;
alertNode.hidden = false;
}
function close(result = null) {
if (resolver) {
resolver(result);
resolver = null;
}
currentConfig = null;
currentInputs = [];
resetAlert();
if (fieldsContainer) {
fieldsContainer.innerHTML = '';
}
if (overlay) {
overlay.hidden = true;
}
}
function buildFieldNode(field, values) {
const wrapper = documentRef.createElement('div');
wrapper.className = 'modal-form-row';
const label = documentRef.createElement('label');
label.className = 'modal-form-label';
const labelText = String(field.label || field.key || '').trim();
label.textContent = labelText;
wrapper.appendChild(label);
let input = null;
if (field.type === 'textarea') {
input = documentRef.createElement('textarea');
input.className = 'data-textarea';
} else if (field.type === 'select') {
input = documentRef.createElement('select');
input.className = 'data-select';
const options = Array.isArray(field.options) ? field.options : [];
options.forEach((option) => {
const optionNode = documentRef.createElement('option');
optionNode.value = String(option?.value || '');
optionNode.textContent = String(option?.label || option?.value || '');
input.appendChild(optionNode);
});
} else {
input = documentRef.createElement('input');
input.type = field.type === 'password' ? 'password' : 'text';
input.className = field.type === 'password'
? 'data-input data-input-with-icon'
: 'data-input';
}
const normalizedValue = Object.prototype.hasOwnProperty.call(values, field.key)
? values[field.key]
: field.value;
if (normalizedValue !== undefined && normalizedValue !== null) {
input.value = String(normalizedValue);
}
if (field.placeholder) {
input.placeholder = String(field.placeholder);
}
if (field.autocomplete) {
input.autocomplete = String(field.autocomplete);
}
if (field.inputMode) {
input.inputMode = String(field.inputMode);
}
if (field.rows && field.type === 'textarea') {
input.rows = Number(field.rows) || 3;
}
input.dataset.fieldKey = String(field.key || '');
label.htmlFor = field.key;
input.id = field.key;
if (field.type === 'password') {
const inputShell = documentRef.createElement('div');
inputShell.className = 'input-with-icon';
inputShell.appendChild(input);
const toggleButton = documentRef.createElement('button');
toggleButton.className = 'input-icon-btn';
toggleButton.type = 'button';
const labels = {
show: String(field.showPasswordLabel || `\u663e\u793a${labelText || '\u5bc6\u7801'}`),
hide: String(field.hidePasswordLabel || `\u9690\u85cf${labelText || '\u5bc6\u7801'}`),
};
syncPasswordToggleButton(toggleButton, input, labels);
toggleButton.addEventListener('click', () => {
input.type = input.type === 'password' ? 'text' : 'password';
syncPasswordToggleButton(toggleButton, input, labels);
});
inputShell.appendChild(toggleButton);
wrapper.appendChild(inputShell);
} else {
wrapper.appendChild(input);
}
if (field.type !== 'textarea') {
input.addEventListener('keydown', (event) => {
if (event.key !== 'Enter') {
return;
}
event.preventDefault();
void handleConfirm();
});
}
currentInputs.push({ field, input });
return wrapper;
}
function collectValues() {
return currentInputs.reduce((result, item) => {
result[item.field.key] = item.input.value;
return result;
}, {});
}
async function handleConfirm() {
if (!currentConfig) {
close(null);
return;
}
const values = collectValues();
resetAlert();
for (const item of currentInputs) {
const { field, input } = item;
const rawValue = values[field.key];
const textValue = String(rawValue || '').trim();
if (field.required && !textValue) {
setAlert(field.requiredMessage || `${field.label || field.key}不能为空。`);
input.focus?.();
return;
}
if (typeof field.validate === 'function') {
const validationMessage = await field.validate(rawValue, values);
if (validationMessage) {
setAlert(validationMessage);
input.focus?.();
return;
}
}
}
close(values);
}
function bindEvents() {
overlay?.addEventListener('click', (event) => {
if (event.target === overlay) {
close(null);
}
});
closeButton?.addEventListener('click', () => close(null));
cancelButton?.addEventListener('click', () => close(null));
confirmButton?.addEventListener('click', () => {
void handleConfirm();
});
}
async function open(config = {}) {
if (!overlay || !titleNode || !fieldsContainer || !confirmButton) {
return null;
}
if (resolver) {
close(null);
}
currentConfig = config || {};
currentInputs = [];
titleNode.textContent = String(currentConfig.title || '填写表单');
if (messageNode) {
const message = String(currentConfig.message || '').trim();
messageNode.textContent = message;
setHidden(messageNode, !message);
}
resetAlert();
if (currentConfig.alert?.text) {
setAlert(currentConfig.alert.text, currentConfig.alert.tone || 'danger');
}
confirmButton.textContent = String(currentConfig.confirmLabel || '确认');
confirmButton.className = `btn ${currentConfig.confirmVariant || 'btn-primary'} btn-sm`;
fieldsContainer.innerHTML = '';
const initialValues = currentConfig.initialValues && typeof currentConfig.initialValues === 'object'
? currentConfig.initialValues
: {};
const fields = Array.isArray(currentConfig.fields) ? currentConfig.fields : [];
fields.forEach((field) => {
fieldsContainer.appendChild(buildFieldNode(field, initialValues));
});
overlay.hidden = false;
const firstInput = currentInputs[0]?.input || null;
if (firstInput && typeof globalScope.requestAnimationFrame === 'function') {
globalScope.requestAnimationFrame(() => firstInput.focus?.());
} else {
firstInput?.focus?.();
}
return new Promise((resolve) => {
resolver = resolve;
});
}
bindEvents();
return {
close,
open,
};
}
globalScope.SidepanelFormDialog = {
createFormDialog,
};
})(window);
+82 -44
View File
@@ -14,6 +14,47 @@ const ipProxyActionState = {
busy: false,
action: '',
};
const IP_PROXY_SECTION_EXPANDED_STORAGE_KEY = 'multipage-ip-proxy-section-expanded';
let ipProxySectionExpanded = false;
function readIpProxySectionExpanded() {
try {
return globalThis.localStorage?.getItem(IP_PROXY_SECTION_EXPANDED_STORAGE_KEY) === '1';
} catch (err) {
return false;
}
}
function persistIpProxySectionExpanded(expanded) {
try {
if (expanded) {
globalThis.localStorage?.setItem(IP_PROXY_SECTION_EXPANDED_STORAGE_KEY, '1');
} else {
globalThis.localStorage?.removeItem(IP_PROXY_SECTION_EXPANDED_STORAGE_KEY);
}
} catch (err) {
// Ignore storage errors; the in-memory collapsed state is still enough for this session.
}
}
function setIpProxySectionExpanded(expanded) {
ipProxySectionExpanded = Boolean(expanded);
persistIpProxySectionExpanded(ipProxySectionExpanded);
if (typeof updateIpProxyUI === 'function') {
updateIpProxyUI(latestState);
}
}
function toggleIpProxySectionExpanded() {
setIpProxySectionExpanded(!ipProxySectionExpanded);
}
function initIpProxySectionExpandedState() {
ipProxySectionExpanded = readIpProxySectionExpanded();
if (typeof updateIpProxyUI === 'function') {
updateIpProxyUI(latestState);
}
}
function normalizeIpProxyActionType(value = '') {
const normalized = String(value || '').trim().toLowerCase();
@@ -866,14 +907,18 @@ function buildIpProxyActionHintText(options = {}) {
const mode = normalizeIpProxyModeForCurrentRelease(options?.mode || DEFAULT_IP_PROXY_MODE);
const poolCount = Math.max(0, Number(options?.poolCount) || 0);
const changeAvailable = Boolean(options?.changeAvailable);
const dynamicPoolCount = poolCount > 0 ? poolCount : 1;
if (mode === 'api') {
return '下一条:切到已拉取代理池的下一条。Change:仅账号模式可用。';
const nextPart = poolCount > 1
? `下一条:当前共 ${dynamicPoolCount} 条节点,切到已拉取代理池的下一条节点。`
: `下一条:当前仅 ${dynamicPoolCount} 条节点,执行重绑复测(不保证更换出口)。`;
return `${nextPart} Change:仅账号模式可用。`;
}
const nextPart = poolCount > 1
? '下一条:切到代理池的下一条节点。'
: '下一条:当前仅 1 条节点,执行重绑复测(不保证更换出口)。';
? `下一条:当前共 ${dynamicPoolCount} 条节点,切到代理池的下一条节点。`
: `下一条:当前仅 ${dynamicPoolCount} 条节点,执行重绑复测(不保证更换出口)。`;
const changePart = changeAvailable
? 'Change:保持当前 session 重绑链路并复测出口。'
: 'Change:需 711 账号模式且用户名包含 session。';
@@ -890,7 +935,6 @@ function setIpProxyCurrentDisplay(text = '', hasValue = false) {
function formatIpProxyCurrentDisplay(state = latestState) {
const mode = normalizeIpProxyModeForCurrentRelease(state?.ipProxyMode);
if (mode === 'account') {
const runtime = getIpProxyRuntimeSnapshot(state, mode);
const current = getIpProxyCurrentEntry(state);
if (!current) {
return {
@@ -898,10 +942,8 @@ function formatIpProxyCurrentDisplay(state = latestState) {
hasValue: false,
};
}
const count = runtime.pool.length > 0 ? runtime.pool.length : 1;
const index = runtime.index;
return {
text: `${current.host}:${current.port}${current.region ? ` [${current.region}]` : ''} (${Math.min(index + 1, count)}/${count})`,
text: `${current.host}:${current.port}${current.region ? ` [${current.region}]` : ''}`,
hasValue: true,
};
}
@@ -919,7 +961,7 @@ function formatIpProxyCurrentDisplay(state = latestState) {
const region = String(current.region || '').trim();
const label = region ? `${current.host}:${current.port} [${region}]` : `${current.host}:${current.port}`;
return {
text: `${label}${count ? ` (${Math.min(index + 1, count)}/${count})` : ''}`,
text: label,
hasValue: true,
};
}
@@ -945,19 +987,7 @@ function buildIpProxyCurrentDisplayText(display = {}, runtimeStatus = {}) {
if (!hasValue || !rawText) {
return rawText;
}
const runtimeText = String(runtimeStatus?.text || '').trim().toLowerCase();
if (!runtimeText) {
return rawText;
}
const endpointToken = extractIpProxyEndpointToken(rawText);
if (!endpointToken || !runtimeText.includes(endpointToken)) {
return rawText;
}
const indexToken = extractIpProxyIndexToken(rawText);
if (indexToken) {
return `节点 ${indexToken}`;
}
return '当前节点';
return rawText;
}
function formatIpProxyRuntimeStatus(state = latestState) {
@@ -1158,6 +1188,7 @@ function setIpProxyEnabledInlineStatus(state = {}, enabled = getSelectedIpProxyE
function updateIpProxyUI(state = latestState) {
const enabled = getSelectedIpProxyEnabled();
const showSettings = enabled && ipProxySectionExpanded;
const mode = getSelectedIpProxyMode();
const service = normalizeIpProxyService(selectIpProxyService?.value || state?.ipProxyService || DEFAULT_IP_PROXY_SERVICE);
const apiModeAvailable = isIpProxyApiModeAvailable();
@@ -1172,64 +1203,70 @@ function updateIpProxyUI(state = latestState) {
const busyAction = normalizeIpProxyActionType(actionState.action);
const runtimeState = state || latestState || {};
setIpProxyEnabledInlineStatus(runtimeState, enabled);
if (rowIpProxyEnabled) {
rowIpProxyEnabled.style.display = '';
}
if (btnToggleIpProxySection) {
btnToggleIpProxySection.disabled = !enabled;
btnToggleIpProxySection.textContent = showSettings ? '收起设置' : '展开设置';
btnToggleIpProxySection.title = enabled
? (showSettings ? '收起 IP 代理设置' : '展开 IP 代理设置')
: '开启 IP 代理后可展开设置';
btnToggleIpProxySection.setAttribute('aria-expanded', String(showSettings));
}
if (rowIpProxyFold) {
rowIpProxyFold.style.display = enabled ? '' : 'none';
rowIpProxyFold.style.display = showSettings ? '' : 'none';
}
if (rowIpProxyService) {
rowIpProxyService.style.display = enabled ? '' : 'none';
rowIpProxyService.style.display = showSettings ? '' : 'none';
}
if (rowIpProxyMode) {
rowIpProxyMode.style.display = enabled ? '' : 'none';
rowIpProxyMode.style.display = showSettings ? '' : 'none';
}
if (rowIpProxyLayout) {
rowIpProxyLayout.style.display = enabled ? '' : 'none';
rowIpProxyLayout.style.display = showSettings ? '' : 'none';
}
if (rowIpProxyApiUrl) {
rowIpProxyApiUrl.style.display = enabled && apiModeAvailable && isApiMode ? '' : 'none';
rowIpProxyApiUrl.style.display = showSettings && apiModeAvailable && isApiMode ? '' : 'none';
}
if (rowIpProxyAccountList) {
rowIpProxyAccountList.style.display = enabled && isAccountMode && accountListAvailable ? '' : 'none';
rowIpProxyAccountList.style.display = showSettings && isAccountMode && accountListAvailable ? '' : 'none';
}
if (rowIpProxyAccountSessionPrefix) {
rowIpProxyAccountSessionPrefix.style.display = enabled && showSessionOptions ? '' : 'none';
rowIpProxyAccountSessionPrefix.style.display = showSettings && showSessionOptions ? '' : 'none';
}
if (rowIpProxyAccountLifeMinutes) {
rowIpProxyAccountLifeMinutes.style.display = enabled && showSessionOptions ? '' : 'none';
rowIpProxyAccountLifeMinutes.style.display = showSettings && showSessionOptions ? '' : 'none';
}
if (rowIpProxyPoolTargetCount) {
rowIpProxyPoolTargetCount.style.display = enabled ? '' : 'none';
rowIpProxyPoolTargetCount.style.display = showSettings ? '' : 'none';
}
if (rowIpProxyHost) {
rowIpProxyHost.style.display = enabled && isAccountMode ? '' : 'none';
rowIpProxyHost.style.display = showSettings && isAccountMode ? '' : 'none';
}
if (rowIpProxyPort) {
rowIpProxyPort.style.display = enabled && isAccountMode ? '' : 'none';
rowIpProxyPort.style.display = showSettings && isAccountMode ? '' : 'none';
}
if (rowIpProxyProtocol) {
rowIpProxyProtocol.style.display = enabled ? '' : 'none';
rowIpProxyProtocol.style.display = showSettings ? '' : 'none';
}
if (rowIpProxyUsername) {
rowIpProxyUsername.style.display = enabled && isAccountMode ? '' : 'none';
rowIpProxyUsername.style.display = showSettings && isAccountMode ? '' : 'none';
}
if (rowIpProxyPassword) {
rowIpProxyPassword.style.display = enabled && isAccountMode ? '' : 'none';
rowIpProxyPassword.style.display = showSettings && isAccountMode ? '' : 'none';
}
if (rowIpProxyRegion) {
rowIpProxyRegion.style.display = enabled && isAccountMode ? '' : 'none';
rowIpProxyRegion.style.display = showSettings && isAccountMode ? '' : 'none';
}
if (rowIpProxyActions) {
rowIpProxyActions.style.display = enabled ? '' : 'none';
rowIpProxyActions.style.display = showSettings ? '' : 'none';
}
if (ipProxyActionButtons) {
ipProxyActionButtons.style.display = enabled ? '' : 'none';
ipProxyActionButtons.style.display = showSettings ? '' : 'none';
}
if (rowIpProxyRuntimeStatus) {
rowIpProxyRuntimeStatus.style.display = enabled ? '' : 'none';
rowIpProxyRuntimeStatus.style.display = showSettings ? '' : 'none';
}
if (ipProxyLayout) {
ipProxyLayout.classList.toggle('is-account-only', !apiModeAvailable);
@@ -1256,7 +1293,7 @@ function updateIpProxyUI(state = latestState) {
ipProxyApiPanel.classList.toggle('is-disabled', !apiModeAvailable);
ipProxyApiPanel.setAttribute('aria-disabled', String(!apiModeAvailable));
ipProxyApiPanel.hidden = !apiModeAvailable;
ipProxyApiPanel.style.display = enabled && apiModeAvailable ? '' : 'none';
ipProxyApiPanel.style.display = showSettings && apiModeAvailable ? '' : 'none';
}
if (inputIpProxyApiUrl) {
inputIpProxyApiUrl.disabled = !enabled || !apiModeAvailable;
@@ -1305,11 +1342,12 @@ function updateIpProxyUI(state = latestState) {
setIpProxyCurrentDisplay(currentDisplayText, currentDisplay.hasValue);
const runtimeSnapshot = getIpProxyRuntimeSnapshot(runtimeState, mode, service);
const runtimePoolCount = Array.isArray(runtimeSnapshot?.pool) ? runtimeSnapshot.pool.length : 0;
const runtimePoolCountForDisplay = runtimePoolCount > 0 ? runtimePoolCount : 1;
const hasCurrentEntry = Boolean(getIpProxyCurrentEntry(runtimeState));
const changeAvailable = canChangeIpProxyExitWithCurrentSession(runtimeState);
const nextActionTitle = runtimePoolCount > 1
? '切换到代理池下一条节点并应用'
: '当前仅 1 条节点:重绑当前节点并复测连通性(不保证更换出口)';
? `切换到代理池下一条节点并应用(当前共 ${runtimePoolCountForDisplay} 条)`
: `当前仅 ${runtimePoolCountForDisplay} 条节点:重绑当前节点并复测连通性(不保证更换出口)`;
if (btnIpProxyRefresh) {
btnIpProxyRefresh.disabled = actionBusy || !enabled || !canOperate;
+191
View File
@@ -0,0 +1,191 @@
(function attachSidepanelPayPalManager(globalScope) {
function createPayPalManager(context = {}) {
const {
state,
dom,
helpers,
runtime,
paypalUtils = {},
} = context;
let actionInFlight = false;
function getPayPalAccounts(currentState = state.getLatestState()) {
return helpers.getPayPalAccounts(currentState);
}
function getCurrentPayPalAccountId(currentState = state.getLatestState()) {
return String(currentState?.currentPayPalAccountId || '').trim();
}
function buildSelectOptions(accounts = []) {
if (!accounts.length) {
return '<option value="">请先添加 PayPal 账号</option>';
}
return accounts.map((account) => (
`<option value="${helpers.escapeHtml(account.id)}">${helpers.escapeHtml(account.email || '(未命名账号)')}</option>`
)).join('');
}
function applyPayPalAccountMutation(account) {
if (!account?.id) return;
const latestState = state.getLatestState();
const nextAccounts = typeof paypalUtils.upsertPayPalAccountInList === 'function'
? paypalUtils.upsertPayPalAccountInList(getPayPalAccounts(latestState), account)
: [...getPayPalAccounts(latestState), account];
state.syncLatestState({ paypalAccounts: nextAccounts });
renderPayPalAccounts();
}
function renderPayPalAccounts() {
if (!dom.selectPayPalAccount) return;
const latestState = state.getLatestState();
const accounts = getPayPalAccounts(latestState);
const currentId = getCurrentPayPalAccountId(latestState);
dom.selectPayPalAccount.innerHTML = buildSelectOptions(accounts);
dom.selectPayPalAccount.disabled = accounts.length === 0;
dom.selectPayPalAccount.value = accounts.some((account) => account.id === currentId) ? currentId : '';
}
async function syncSelectedPayPalAccount(options = {}) {
const { silent = false } = options;
const accountId = String(dom.selectPayPalAccount?.value || '').trim();
if (!accountId) {
state.syncLatestState({
currentPayPalAccountId: null,
paypalEmail: '',
paypalPassword: '',
});
renderPayPalAccounts();
return null;
}
const response = await runtime.sendMessage({
type: 'SELECT_PAYPAL_ACCOUNT',
source: 'sidepanel',
payload: { accountId },
});
if (response?.error) {
throw new Error(response.error);
}
state.syncLatestState({
currentPayPalAccountId: response.account?.id || accountId,
paypalEmail: String(response.account?.email || '').trim(),
paypalPassword: String(response.account?.password || ''),
});
renderPayPalAccounts();
if (!silent) {
helpers.showToast(`已切换当前 PayPal 账号为 ${response.account?.email || accountId}`, 'success', 1800);
}
return response.account || null;
}
async function openPayPalAccountDialog() {
if (typeof helpers.openFormDialog !== 'function') {
throw new Error('表单弹窗能力未加载,请刷新扩展后重试。');
}
return helpers.openFormDialog({
title: '添加 PayPal 账号',
confirmLabel: '保存账号',
confirmVariant: 'btn-primary',
fields: [
{
key: 'email',
label: 'PayPal 账号',
type: 'text',
placeholder: '请输入 PayPal 登录邮箱',
autocomplete: 'username',
required: true,
requiredMessage: '请先填写 PayPal 账号。',
validate: (value) => {
const normalized = String(value || '').trim();
if (!normalized.includes('@')) {
return 'PayPal 账号需填写邮箱格式。';
}
return '';
},
},
{
key: 'password',
label: 'PayPal 密码',
type: 'password',
placeholder: '请输入 PayPal 登录密码',
autocomplete: 'current-password',
required: true,
requiredMessage: '请先填写 PayPal 密码。',
},
],
});
}
async function handleAddPayPalAccount() {
if (actionInFlight) return;
const formValues = await openPayPalAccountDialog();
if (!formValues) {
return;
}
actionInFlight = true;
if (dom.btnAddPayPalAccount) {
dom.btnAddPayPalAccount.disabled = true;
}
try {
const response = await runtime.sendMessage({
type: 'UPSERT_PAYPAL_ACCOUNT',
source: 'sidepanel',
payload: {
email: String(formValues.email || '').trim(),
password: String(formValues.password || ''),
},
});
if (response?.error) {
throw new Error(response.error);
}
applyPayPalAccountMutation(response.account);
if (response.account?.id) {
state.syncLatestState({ currentPayPalAccountId: response.account.id });
renderPayPalAccounts();
dom.selectPayPalAccount.value = response.account.id;
await syncSelectedPayPalAccount({ silent: true });
}
helpers.showToast(`已保存 PayPal 账号 ${response.account?.email || ''}`, 'success', 2200);
} catch (error) {
helpers.showToast(`保存 PayPal 账号失败:${error.message}`, 'error');
throw error;
} finally {
actionInFlight = false;
if (dom.btnAddPayPalAccount) {
dom.btnAddPayPalAccount.disabled = false;
}
}
}
function bindPayPalEvents() {
dom.btnAddPayPalAccount?.addEventListener('click', () => {
void handleAddPayPalAccount();
});
dom.selectPayPalAccount?.addEventListener('change', () => {
void syncSelectedPayPalAccount().catch((error) => {
helpers.showToast(error.message, 'error');
renderPayPalAccounts();
});
});
}
return {
bindPayPalEvents,
renderPayPalAccounts,
syncSelectedPayPalAccount,
};
}
globalScope.SidepanelPayPalManager = {
createPayPalManager,
};
})(window);
+425 -46
View File
@@ -607,7 +607,12 @@ header {
Data Card
============================================================ */
#data-section { margin-bottom: 14px; }
#data-section {
margin-bottom: 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.data-card {
background: var(--bg-surface);
@@ -769,6 +774,21 @@ header {
gap: 8px;
}
#settings-card .data-row.module-divider-start {
position: relative;
margin-top: 10px;
padding-top: 12px;
}
#settings-card .data-row.module-divider-start::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
border-top: 1px solid color-mix(in srgb, var(--border) 76%, transparent);
}
.data-check-row {
align-items: flex-start;
}
@@ -785,6 +805,19 @@ header {
display: block;
}
.ip-proxy-card {
margin-top: 10px;
}
.ip-proxy-header-actions {
flex: 0 0 auto;
align-items: center;
}
#btn-toggle-ip-proxy-section {
white-space: nowrap;
}
.ip-proxy-fold {
width: 100%;
border: none;
@@ -801,6 +834,39 @@ header {
padding-top: 0;
}
.phone-verification-card {
margin-top: 10px;
}
.phone-verification-header-actions {
flex: 0 0 auto;
align-items: center;
}
#btn-toggle-phone-verification-section {
white-space: nowrap;
}
.phone-verification-fold-row {
display: block;
}
.phone-verification-fold {
width: 100%;
border: none;
border-radius: 0;
background: transparent;
padding: 0;
}
.phone-verification-fold-body {
display: flex;
flex-direction: column;
gap: 8px;
border-top: none;
padding-top: 0;
}
.ip-proxy-layout-row {
display: block;
}
@@ -858,17 +924,20 @@ header {
}
}
.ip-proxy-enabled-inline {
justify-content: flex-end;
gap: 10px;
}
.ip-proxy-actions-inline {
flex-wrap: wrap;
align-items: center;
align-items: flex-start;
row-gap: 6px;
}
#row-ip-proxy-actions {
align-items: flex-start;
}
#row-ip-proxy-actions > .data-label {
padding-top: 9px;
}
.ip-proxy-action-grid {
width: 100%;
display: flex;
@@ -888,42 +957,6 @@ header {
color: var(--text-secondary);
}
.ip-proxy-enabled-status {
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
min-width: 92px;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-align: right;
white-space: nowrap;
}
.ip-proxy-enabled-status-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--text-muted);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--text-muted) 25%, transparent);
flex-shrink: 0;
}
.ip-proxy-enabled-status.is-on {
color: var(--green);
}
.ip-proxy-enabled-status.is-on .ip-proxy-enabled-status-dot {
background: var(--green);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--green) 30%, transparent);
}
.ip-proxy-enabled-status.is-off {
color: var(--text-muted);
}
.ip-proxy-runtime-status {
display: inline-flex;
align-items: flex-start;
@@ -946,19 +979,25 @@ header {
.ip-proxy-runtime-main {
min-width: 0;
font-size: 12px;
line-height: 1.45;
}
.ip-proxy-runtime-meta {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
gap: 8px;
}
.ip-proxy-check-ip-btn {
min-width: 64px;
padding-inline: 10px;
min-width: 0;
padding-inline: 8px;
flex-shrink: 0;
margin-left: 0;
position: absolute;
top: 0;
right: 0;
}
.ip-proxy-runtime-current {
@@ -972,6 +1011,14 @@ header {
color: var(--text-primary);
}
#row-ip-proxy-runtime-status {
align-items: flex-start;
}
#row-ip-proxy-runtime-status > .data-label {
padding-top: 9px;
}
.ip-proxy-runtime-dot {
width: 8px;
height: 8px;
@@ -1000,20 +1047,53 @@ header {
.ip-proxy-runtime-details {
margin: 0;
padding: 0;
min-width: 0;
width: 100%;
padding-right: 84px;
}
.ip-proxy-runtime-details-row {
position: relative;
min-width: 0;
width: 100%;
min-height: 24px;
}
.ip-proxy-runtime-details summary {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 24px;
cursor: pointer;
user-select: none;
color: var(--text-secondary);
font-size: 11px;
line-height: 1.4;
list-style: none;
}
.ip-proxy-runtime-details summary::-webkit-details-marker {
display: none;
}
.ip-proxy-runtime-details summary::after {
content: '▾';
font-size: 10px;
line-height: 1;
color: inherit;
transform: rotate(-90deg);
transform-origin: center;
transition: transform var(--transition);
}
.ip-proxy-runtime-details[open] summary {
color: var(--text-primary);
}
.ip-proxy-runtime-details[open] summary::after {
transform: rotate(0deg);
}
.ip-proxy-runtime-details-text {
margin-top: 4px;
font-size: 11px;
@@ -1869,6 +1949,271 @@ header {
text-align: center;
}
.hero-sms-country-stack {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 6px;
}
.hero-sms-country-mainline {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.hero-sms-country-note {
font-size: 12px;
color: var(--text-muted);
}
.hero-sms-reuse-max-inline {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: nowrap;
}
.hero-sms-reuse-max-left {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
}
.hero-sms-reuse-max-right {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.hero-sms-max-price-input {
width: 72px;
text-align: center;
}
.hero-sms-country-menu {
position: relative;
flex: 1;
min-width: 260px;
}
.hero-sms-country-menu-btn {
width: 100%;
height: 33px;
min-height: 33px;
padding-top: 0;
padding-bottom: 0;
justify-content: flex-start;
overflow: hidden;
text-overflow: ellipsis;
}
.hero-sms-country-menu-btn[aria-expanded="true"] {
border-color: var(--blue);
color: var(--blue);
background: var(--blue-soft);
}
.hero-sms-country-menu-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 1200;
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px;
max-height: 180px;
overflow-y: auto;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-md);
}
.hero-sms-country-menu-search {
padding-bottom: 6px;
border-bottom: 1px solid var(--border-subtle);
}
.hero-sms-country-menu-search-input {
width: 100%;
}
.hero-sms-country-menu-dropdown[hidden] {
display: none !important;
}
.hero-sms-country-menu-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
text-align: left;
}
.hero-sms-country-menu-item-label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hero-sms-country-menu-item-badge {
flex: 0 0 auto;
min-width: 42px;
text-align: right;
color: var(--brand);
font-weight: 700;
}
.hero-sms-runtime-grid {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px 12px;
}
.hero-sms-runtime-cell {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.hero-sms-runtime-cell-span2 {
grid-column: 1 / -1;
}
.hero-sms-runtime-key {
flex: 0 0 auto;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
white-space: nowrap;
}
.hero-sms-runtime-value {
flex: 1 1 auto;
min-width: 0;
}
.hero-sms-price-preview-stack {
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
}
.hero-sms-price-preview-head {
width: 100%;
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: nowrap;
gap: 8px;
}
#btn-hero-sms-price-preview {
height: 33px;
min-height: 33px;
padding-top: 0;
padding-bottom: 0;
align-self: flex-start;
}
.hero-sms-price-controls-grid {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 12px;
}
.hero-sms-price-control {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.hero-sms-price-control .setting-controls {
margin-left: auto;
width: 104px;
justify-content: flex-start;
}
.hero-sms-price-control-reuse {
justify-content: space-between;
}
#row-hero-sms-max-price,
#row-phone-code-settings-group {
align-items: flex-start;
}
#row-hero-sms-max-price > .data-label,
#row-phone-code-settings-group > .data-label {
padding-top: 9px;
}
.hero-sms-toggle-controls {
width: 104px;
justify-content: flex-start;
}
.hero-sms-price-preview-result {
width: 100%;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-surface);
padding: 6px 8px;
}
.hero-sms-price-preview-text {
display: block;
white-space: pre-line;
line-height: 1.45;
}
.hero-sms-settings-grid {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 12px;
}
.hero-sms-settings-cell {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
min-width: 0;
}
.hero-sms-settings-cell .setting-controls {
margin-left: auto;
}
.hero-sms-settings-caption {
flex: 0 0 auto;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
white-space: nowrap;
}
.data-unit {
font-size: 12px;
font-weight: 600;
@@ -2722,6 +3067,40 @@ header {
cursor: pointer;
}
.modal-form-fields {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 14px;
}
.modal-form-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.modal-form-label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.modal-form-row .data-input,
.modal-form-row .data-select,
.modal-form-row .data-textarea {
width: 100%;
}
.modal-form-row .input-with-icon {
width: 100%;
}
.modal-form-alert {
margin-top: -4px;
margin-bottom: 14px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
+455 -261
View File
@@ -184,7 +184,13 @@
</div>
<div class="data-row" id="row-sub2api-password" style="display:none;">
<span class="data-label">密码</span>
<input type="password" id="input-sub2api-password" class="data-input" placeholder="请输入 SUB2API 登录密码" />
<div class="input-with-icon">
<input type="password" id="input-sub2api-password" class="data-input data-input-with-icon"
placeholder="请输入 SUB2API 登录密码" />
<button id="btn-toggle-sub2api-password" class="input-icon-btn" type="button"
data-password-toggle="input-sub2api-password" data-show-label="显示 SUB2API 密码"
data-hide-label="隐藏 SUB2API 密码" aria-label="显示 SUB2API 密码" title="显示 SUB2API 密码"></button>
</div>
</div>
<div class="data-row" id="row-sub2api-group" style="display:none;">
<span class="data-label">分组</span>
@@ -194,19 +200,387 @@
<span class="data-label">默认代理</span>
<input type="text" id="input-sub2api-default-proxy" class="data-input" placeholder="留空则不使用代理;或填写代理名称 / ID" />
</div>
<div class="data-row" id="row-ip-proxy-enabled">
<span class="data-label">IP代理</span>
<div class="data-inline ip-proxy-enabled-inline">
<div class="data-row" id="row-codex2api-url" style="display:none;">
<span class="data-label">Codex2API</span>
<input type="text" id="input-codex2api-url" class="data-input"
placeholder="http://localhost:8080/admin/accounts" />
</div>
<div class="data-row" id="row-codex2api-admin-key" style="display:none;">
<span class="data-label">管理密钥</span>
<div class="input-with-icon">
<input type="password" id="input-codex2api-admin-key" class="data-input data-input-with-icon"
placeholder="请输入 Codex2API Admin Secret" />
<button id="btn-toggle-codex2api-admin-key" class="input-icon-btn" type="button"
data-password-toggle="input-codex2api-admin-key" data-show-label="显示 Codex2API 管理密钥"
data-hide-label="隐藏 Codex2API 管理密钥" aria-label="显示 Codex2API 管理密钥"
title="显示 Codex2API 管理密钥"></button>
</div>
</div>
<div class="data-row module-divider-start" id="row-custom-password">
<span class="data-label">账户密码</span>
<div class="input-with-icon">
<input type="password" id="input-password" class="data-input data-input-with-icon"
placeholder="codex密码,留空则自动生成" />
<button id="btn-toggle-password" class="input-icon-btn" type="button" aria-label="显示密码" title="显示密码"></button>
</div>
</div>
<div class="data-row" id="row-plus-mode">
<span class="data-label">Plus 模式</span>
<div class="data-inline setting-pair">
<div class="setting-group setting-group-primary">
<label class="toggle-switch" for="input-plus-mode-enabled" title="开启后使用 Plus Checkout + PayPal 授权流程">
<input type="checkbox" id="input-plus-mode-enabled" />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
</label>
</div>
<span class="setting-caption">PayPal 订阅链路</span>
</div>
</div>
<div class="data-row" id="row-paypal-account" style="display:none;">
<span class="data-label">PayPal 账号</span>
<div class="data-inline">
<select id="select-paypal-account" class="data-select">
<option value="">请先添加 PayPal 账号</option>
</select>
<button id="btn-add-paypal-account" class="btn btn-outline btn-sm data-inline-btn" type="button">添加</button>
</div>
</div>
<div class="data-row module-divider-start" id="row-mail-provider">
<span class="data-label">邮箱服务</span>
<div class="data-inline">
<select id="select-mail-provider" class="data-select">
<option value="custom">自定义邮箱</option>
<option value="hotmail-api">Hotmail(账号池)</option>
<option value="luckmail-api">LuckMailAPI 购邮)</option>
<option value="icloud">iCloud 邮箱</option>
<option value="163">163 邮箱 (mail.163.com)</option>
<option value="163-vip">163 VIP 邮箱 (vip.163.com)</option>
<option value="126">126 邮箱 (mail.126.com)</option>
<option value="qq">QQ 邮箱 (wx.mail.qq.com)</option>
<option value="inbucket">Inbucket(自定义主机)</option>
<option value="2925">2925 邮箱 (2925.com)</option>
<option value="gmail">Gmail 邮箱 (mail.google.com)</option>
<option value="cloudflare-temp-email">Cloudflare Temp Email</option>
</select>
<button id="btn-mail-login" class="btn btn-outline btn-sm data-inline-btn" type="button" disabled>登录</button>
</div>
</div>
<div class="data-row data-check-row" id="row-custom-mail-provider-pool" style="display:none;">
<span class="data-label">自定义号池</span>
<textarea id="input-custom-mail-provider-pool" class="data-textarea"
placeholder="每行一个注册邮箱,例如&#10;alias001@example.com&#10;alias002@example.com"></textarea>
</div>
<div class="data-row" id="row-mail-2925-mode" style="display:none;">
<span class="data-label">2925 模式</span>
<div id="mail-2925-mode-group" class="choice-group" role="group" aria-label="2925 邮箱模式">
<button type="button" class="choice-btn" data-mail2925-mode="provide">提供邮箱</button>
<button type="button" class="choice-btn" data-mail2925-mode="receive">接收邮箱</button>
</div>
</div>
<div class="data-row" id="row-email-generator">
<span class="data-label">邮箱生成</span>
<select id="select-email-generator" class="data-select">
<option value="gmail-alias">Gmail +tag</option>
<option value="duck">DuckDuckGo</option>
<option value="custom-pool">自定义邮箱池</option>
<option value="cloudflare">Cloudflare</option>
<option value="icloud">iCloud 隐私邮箱</option>
<option value="cloudflare-temp-email">Cloudflare Temp Email</option>
</select>
</div>
<div class="data-row data-check-row" id="row-custom-email-pool" style="display:none;">
<span class="data-label">邮箱池</span>
<textarea id="input-custom-email-pool" class="data-textarea"
placeholder="每行一个邮箱,例如&#10;alias001@gmail.com&#10;alias002@gmail.com"></textarea>
</div>
<div class="data-row" id="row-cf-domain" style="display:none;">
<span class="data-label">CF 域名</span>
<div class="data-inline">
<select id="select-cf-domain" class="data-select"></select>
<input type="text" id="input-cf-domain" class="data-input" placeholder="例如 yourdomain.xyz"
style="display:none;" />
<button id="btn-cf-domain-mode" class="btn btn-outline btn-sm data-inline-btn" type="button">添加</button>
</div>
</div>
<div class="data-row" id="row-mail2925-pool-settings" style="display:none;">
<span class="data-label">2925 号池</span>
<div class="data-inline mail2925-base-inline">
<label class="toggle-switch mail2925-pool-toggle" id="label-mail2925-use-account-pool"
for="input-mail2925-use-account-pool" style="display:none;" title="开启后启用 2925 账号池">
<input type="checkbox" id="input-mail2925-use-account-pool" />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
<span>号池</span>
</label>
<select id="select-mail2925-pool-account" class="data-select mail2925-pool-select" style="display:none;">
<option value="">请选择号池邮箱</option>
</select>
</div>
</div>
<div class="data-row" id="row-email-prefix" style="display:none;">
<span class="data-label" id="label-email-prefix">别名基邮箱</span>
<div class="data-inline">
<input type="text" id="input-email-prefix" class="data-input mail2925-base-input"
placeholder="例如 yourname@example.com" />
</div>
</div>
<div class="data-row" id="row-inbucket-host" style="display:none;">
<span class="data-label">Inbucket</span>
<input type="text" id="input-inbucket-host" class="data-input" placeholder="填写主机或 https://主机地址" />
</div>
<div class="data-row" id="row-inbucket-mailbox" style="display:none;">
<span class="data-label">邮箱名</span>
<input type="text" id="input-inbucket-mailbox" class="data-input" placeholder="例如 zju2001" />
</div>
<div class="data-row" id="row-auto-run-controls">
<span class="data-label">注册邮箱</span>
<div class="data-inline">
<input type="text" id="input-email" class="data-input" placeholder="自动生成或手动粘贴邮箱" />
<button id="btn-fetch-email" class="btn btn-outline btn-sm data-inline-btn" type="button">获取</button>
</div>
</div>
<div class="data-row module-divider-start" id="row-auto-delay-settings">
<span class="data-label">延迟</span>
<div class="data-inline setting-pair">
<div class="setting-group setting-group-primary">
<label class="toggle-switch" for="input-auto-delay-enabled">
<input type="checkbox" id="input-auto-delay-enabled" />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
</label>
<div class="setting-controls">
<input type="number" id="input-auto-delay-minutes" class="data-input auto-delay-input" value="30" min="1"
max="1440" step="1" title="启动前倒计时分钟数" />
<span class="data-unit">分钟</span>
</div>
</div>
<div class="setting-group setting-group-secondary">
<span class="setting-caption">步间间隔</span>
<div class="setting-controls">
<input type="number" id="input-auto-step-delay-seconds" class="data-input auto-delay-input" value=""
min="0" max="600" step="1" title="步间延迟秒数,0 或留空则不延迟" />
<span class="data-unit"></span>
</div>
</div>
</div>
</div>
<div class="data-row">
<span class="data-label">自动重试</span>
<div class="data-inline setting-pair">
<div class="setting-group setting-group-primary">
<label class="toggle-switch" for="input-auto-skip-failures">
<input type="checkbox" id="input-auto-skip-failures" />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
</label>
</div>
<div class="setting-group setting-group-secondary">
<span class="setting-caption">线程间隔</span>
<div class="setting-controls">
<input type="number" id="input-auto-skip-failures-thread-interval-minutes"
class="data-input auto-delay-input" value="0" min="0" max="1440" step="1" title="兜底模式下,两轮线程之间的等待分钟数" />
<span class="data-unit">分钟</span>
</div>
</div>
</div>
</div>
<div class="data-row module-divider-start" id="row-oauth-display">
<span class="data-label">OAuth</span>
<span id="display-oauth-url" class="data-value mono truncate">等待中...</span>
</div>
<div class="data-row">
<span class="data-label">回调</span>
<div class="data-inline data-value-actions">
<span id="display-localhost-url" class="data-value data-value-fill mono truncate">等待中...</span>
<button id="btn-save-settings" class="btn btn-outline btn-sm data-inline-btn" type="button">保存</button>
</div>
</div>
</div>
<div id="phone-verification-section" class="data-card phone-verification-card">
<div class="section-mini-header">
<div class="section-mini-copy">
<span class="section-label">接码设置</span>
<span class="data-value">手机号验证与 HeroSMS 获取策略</span>
</div>
<div id="row-phone-verification-enabled" class="section-mini-actions phone-verification-header-actions">
<button id="btn-toggle-phone-verification-section" class="btn btn-ghost btn-xs" type="button"
aria-expanded="false" aria-controls="row-phone-verification-fold">展开设置</button>
<label class="toggle-switch" for="input-phone-verification-enabled" title="启用或禁用手机号接码流程">
<input type="checkbox" id="input-phone-verification-enabled" />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
</label>
</div>
</div>
<div class="data-row phone-verification-fold-row" id="row-phone-verification-fold" style="display:none;">
<div id="phone-verification-fold" class="phone-verification-fold">
<div class="phone-verification-fold-body">
<div class="data-row" id="row-account-run-history-helper-base-url" style="display:none;">
<span class="data-label">同步服务</span>
<input type="text" id="input-account-run-history-helper-base-url" class="data-input mono"
placeholder="http://127.0.0.1:17373" />
</div>
<div class="data-row" id="row-hero-sms-platform" style="display:none;">
<span class="data-label">接码平台</span>
<span id="display-hero-sms-platform" class="data-value mono">HeroSMS / OpenAI / Thailand</span>
</div>
<div class="data-row" id="row-hero-sms-country" style="display:none;">
<span class="data-label">国家优先级</span>
<div class="data-inline hero-sms-country-stack">
<select id="select-hero-sms-country" class="data-input mono" multiple size="6" style="display:none;">
<option value="52" selected>Thailand</option>
</select>
<div class="hero-sms-country-mainline">
<div id="hero-sms-country-menu-shell" class="hero-sms-country-menu">
<button id="btn-hero-sms-country-menu" class="btn btn-outline btn-sm hero-sms-country-menu-btn" type="button" aria-haspopup="listbox" aria-expanded="false">
Thailand (1/3)
</button>
<div id="hero-sms-country-menu" class="hero-sms-country-menu-dropdown" role="listbox" aria-multiselectable="true" hidden></div>
</div>
</div>
<span class="data-value hero-sms-country-note">多选最多 3 个,按点击顺序生效。</span>
</div>
</div>
<div class="data-row" id="row-hero-sms-country-fallback" style="display:none;">
<span class="data-label">生效顺序</span>
<div class="data-inline data-value-actions">
<span id="display-hero-sms-country-fallback-order" class="data-value data-value-fill mono">Thailand(52)</span>
<button id="btn-hero-sms-country-clear" class="btn btn-ghost btn-xs data-inline-btn" type="button">清空</button>
</div>
</div>
<div class="data-row" id="row-hero-sms-acquire-priority" style="display:none;">
<span class="data-label">拿号优先级</span>
<select id="select-hero-sms-acquire-priority" class="data-input mono">
<option value="country">国家优先(默认)</option>
<option value="price">价格优先(同价按国家顺序)</option>
</select>
</div>
<div class="data-row" id="row-hero-sms-api-key" style="display:none;">
<span class="data-label">接码 API</span>
<div class="input-with-icon">
<input type="password" id="input-hero-sms-api-key" class="data-input data-input-with-icon mono"
placeholder="请输入 HeroSMS API Key" />
<button id="btn-toggle-hero-sms-api-key" class="input-icon-btn" type="button"
data-password-toggle="input-hero-sms-api-key" data-show-label="显示 HeroSMS API Key"
data-hide-label="隐藏 HeroSMS API Key" aria-label="显示 HeroSMS API Key" title="显示 HeroSMS API Key"></button>
</div>
</div>
<div class="data-row" id="row-hero-sms-max-price" style="display:none;">
<span class="data-label">价格</span>
<div class="data-inline hero-sms-price-preview-stack">
<div class="hero-sms-price-preview-head">
<button id="btn-hero-sms-price-preview" class="btn btn-outline btn-xs data-inline-btn" type="button">查询价格</button>
</div>
<div id="row-hero-sms-price-tiers" class="hero-sms-price-preview-result" style="display:none;">
<span id="display-hero-sms-price-tiers" class="data-value mono hero-sms-price-preview-text">未获取</span>
</div>
<div class="hero-sms-price-controls-grid">
<div class="hero-sms-price-control">
<span class="hero-sms-settings-caption">价格上限</span>
<div class="setting-controls">
<input type="number" id="input-hero-sms-max-price" class="data-input auto-delay-input mono hero-sms-max-price-input" placeholder="0.12" min="0" step="0.0001" title="接码价格上限;可空(空=自动价格)" />
</div>
</div>
<div class="hero-sms-price-control hero-sms-price-control-reuse">
<span class="hero-sms-settings-caption">号码复用</span>
<div class="setting-controls hero-sms-toggle-controls">
<label class="toggle-switch hero-sms-price-reuse-toggle" for="input-hero-sms-reuse-enabled" title="开启后会优先复用未超次数的可用号码">
<input type="checkbox" id="input-hero-sms-reuse-enabled" />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
</label>
</div>
</div>
</div>
</div>
</div>
<div class="data-row" id="row-phone-code-settings-group" style="display:none;">
<span class="data-label">接码参数</span>
<div class="data-inline hero-sms-settings-grid">
<div id="row-phone-verification-resend-count" class="hero-sms-settings-cell" style="display:none;">
<span class="hero-sms-settings-caption">验证码重发</span>
<div class="setting-controls">
<input type="number" id="input-verification-resend-count" class="data-input auto-delay-input" value="4"
min="0" max="20" step="1" title="自动点击重新发送验证码的次数" />
<span class="data-unit"></span>
</div>
</div>
<div id="row-phone-replacement-limit" class="hero-sms-settings-cell" style="display:none;">
<span class="hero-sms-settings-caption">换号上限</span>
<div class="setting-controls">
<input type="number" id="input-phone-replacement-limit" class="data-input auto-delay-input" value="3" min="1" max="20" step="1" title="步骤 9 内部允许更换号码的最大次数" />
<span class="data-unit"></span>
</div>
</div>
<div id="row-phone-code-wait-seconds" class="hero-sms-settings-cell" style="display:none;">
<span class="hero-sms-settings-caption">验证码限时</span>
<div class="setting-controls">
<input type="number" id="input-phone-code-wait-seconds" class="data-input auto-delay-input" value="60" min="15" max="300" step="1" title="每轮等待验证码的秒数" />
<span class="data-unit"></span>
</div>
</div>
<div id="row-phone-code-timeout-windows" class="hero-sms-settings-cell" style="display:none;">
<span class="hero-sms-settings-caption">超时次数</span>
<div class="setting-controls">
<input type="number" id="input-phone-code-timeout-windows" class="data-input auto-delay-input" value="2" min="1" max="10" step="1" title="验证码超时后,最多继续等待几轮再换号" />
<span class="data-unit"></span>
</div>
</div>
<div id="row-phone-code-poll-interval-seconds" class="hero-sms-settings-cell" style="display:none;">
<span class="hero-sms-settings-caption">轮询间隔</span>
<div class="setting-controls">
<input type="number" id="input-phone-code-poll-interval-seconds" class="data-input auto-delay-input" value="5" min="1" max="30" step="1" title="向 HeroSMS 查询验证码状态的间隔秒数" />
<span class="data-unit"></span>
</div>
</div>
<div id="row-phone-code-poll-max-rounds" class="hero-sms-settings-cell" style="display:none;">
<span class="hero-sms-settings-caption">轮询次数</span>
<div class="setting-controls">
<input type="number" id="input-phone-code-poll-max-rounds" class="data-input auto-delay-input" value="4" min="1" max="120" step="1" title="每轮验证码等待窗口最多轮询次数" />
<span class="data-unit"></span>
</div>
</div>
</div>
</div>
<div class="data-row" id="row-hero-sms-runtime-pair" style="display:none;">
<span class="data-label">运行状态</span>
<div class="data-inline hero-sms-runtime-grid">
<div id="row-hero-sms-current-number" class="hero-sms-runtime-cell" style="display:none;">
<span class="hero-sms-runtime-key">当前分配</span>
<span id="display-hero-sms-current-number" class="data-value mono hero-sms-runtime-value">未分配</span>
</div>
<div id="row-hero-sms-current-code" class="hero-sms-runtime-cell" style="display:none;">
<span class="hero-sms-runtime-key">验证码</span>
<span id="display-hero-sms-current-code" class="data-value mono hero-sms-runtime-value">未获取</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="ip-proxy-section" class="data-card ip-proxy-card">
<div class="section-mini-header">
<div class="section-mini-copy">
<span class="section-label">IP 代理</span>
<span class="data-value">用于浏览器代理接管与出口切换</span>
</div>
<div id="row-ip-proxy-enabled" class="section-mini-actions ip-proxy-header-actions">
<button id="btn-toggle-ip-proxy-section" class="btn btn-ghost btn-xs" type="button"
aria-expanded="false" aria-controls="row-ip-proxy-fold">展开设置</button>
<label class="toggle-switch" for="input-ip-proxy-enabled" title="启用或禁用 IP 代理接管">
<input type="checkbox" id="input-ip-proxy-enabled" />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
</label>
<span id="ip-proxy-enabled-status" class="ip-proxy-enabled-status" aria-live="polite">
<span id="ip-proxy-enabled-status-dot" class="ip-proxy-enabled-status-dot" aria-hidden="true"></span>
<span id="ip-proxy-enabled-status-text" class="ip-proxy-enabled-status-text">未开启</span>
</span>
</div>
</div>
<div class="data-row ip-proxy-fold-row" id="row-ip-proxy-fold" style="display:none;">
@@ -219,7 +593,7 @@
<option value="711proxy">711Proxy(首版)</option>
</select>
<button id="btn-ip-proxy-service-login" class="btn btn-outline btn-sm data-inline-btn" type="button">
登录
注册
</button>
</div>
</div>
@@ -331,259 +705,26 @@
</div>
<div class="data-row" id="row-ip-proxy-runtime-status" style="display:none;">
<span class="data-label">代理状态</span>
<div id="ip-proxy-runtime-status" class="ip-proxy-runtime-status" aria-live="polite">
<div id="ip-proxy-runtime-status" class="ip-proxy-runtime-status state-idle">
<span id="ip-proxy-runtime-dot" class="ip-proxy-runtime-dot" aria-hidden="true"></span>
<div class="ip-proxy-runtime-content">
<div class="ip-proxy-runtime-main">
<span id="ip-proxy-runtime-text" class="data-value data-value-fill">未启用,沿用浏览器默认/全局代理。</span>
</div>
<div id="ip-proxy-runtime-text" class="ip-proxy-runtime-main">未启用,沿用浏览器默认/全局代理。</div>
<div class="ip-proxy-runtime-meta">
<span id="ip-proxy-current" class="ip-proxy-runtime-current mono truncate">未启用</span>
<button id="btn-ip-proxy-check-ip" class="btn btn-outline btn-xs data-inline-btn ip-proxy-check-ip-btn" type="button">检查IP</button>
<span id="ip-proxy-current" class="ip-proxy-runtime-current">暂无可用代理</span>
</div>
<div class="ip-proxy-runtime-details-row">
<details id="ip-proxy-runtime-details" class="ip-proxy-runtime-details" hidden>
<summary>详情</summary>
<div id="ip-proxy-runtime-details-text" class="ip-proxy-runtime-details-text"></div>
</details>
<button id="btn-ip-proxy-check-ip" class="btn btn-outline btn-xs ip-proxy-check-ip-btn" type="button">检查IP</button>
</div>
<details id="ip-proxy-runtime-details" class="ip-proxy-runtime-details" hidden>
<summary>查看详细信息</summary>
<div id="ip-proxy-runtime-details-text" class="ip-proxy-runtime-details-text"></div>
</details>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="data-row" id="row-codex2api-url" style="display:none;">
<span class="data-label">Codex2API</span>
<input type="text" id="input-codex2api-url" class="data-input"
placeholder="http://localhost:8080/admin/accounts" />
</div>
<div class="data-row" id="row-codex2api-admin-key" style="display:none;">
<span class="data-label">管理密钥</span>
<input type="password" id="input-codex2api-admin-key" class="data-input"
placeholder="请输入 Codex2API Admin Secret" />
</div>
<div class="data-row" id="row-custom-password">
<span class="data-label">账户密码</span>
<div class="input-with-icon">
<input type="password" id="input-password" class="data-input data-input-with-icon"
placeholder="codex密码,留空则自动生成" />
<button id="btn-toggle-password" class="input-icon-btn" type="button" aria-label="显示密码" title="显示密码"></button>
</div>
</div>
<div class="data-row" id="row-plus-mode">
<span class="data-label">Plus 模式</span>
<div class="data-inline setting-pair">
<div class="setting-group setting-group-primary">
<label class="toggle-switch" for="input-plus-mode-enabled" title="开启后使用 Plus Checkout + PayPal 授权流程">
<input type="checkbox" id="input-plus-mode-enabled" />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
</label>
</div>
<span class="setting-caption">PayPal 订阅链路</span>
</div>
</div>
<div class="data-row" id="row-paypal-email" style="display:none;">
<span class="data-label">PayPal 账号</span>
<input type="text" id="input-paypal-email" class="data-input" placeholder="请输入 PayPal 登录邮箱" />
</div>
<div class="data-row" id="row-paypal-password" style="display:none;">
<span class="data-label">PayPal 密码</span>
<input type="password" id="input-paypal-password" class="data-input" placeholder="请输入 PayPal 登录密码" />
</div>
<div class="data-row">
<span class="data-label">邮箱服务</span>
<div class="data-inline">
<select id="select-mail-provider" class="data-select">
<option value="custom">自定义邮箱</option>
<option value="hotmail-api">Hotmail(账号池)</option>
<option value="luckmail-api">LuckMailAPI 购邮)</option>
<option value="icloud">iCloud 邮箱</option>
<option value="163">163 邮箱 (mail.163.com)</option>
<option value="163-vip">163 VIP 邮箱 (vip.163.com)</option>
<option value="126">126 邮箱 (mail.126.com)</option>
<option value="qq">QQ 邮箱 (wx.mail.qq.com)</option>
<option value="inbucket">Inbucket(自定义主机)</option>
<option value="2925">2925 邮箱 (2925.com)</option>
<option value="gmail">Gmail 邮箱 (mail.google.com)</option>
<option value="cloudflare-temp-email">Cloudflare Temp Email</option>
</select>
<button id="btn-mail-login" class="btn btn-outline btn-sm data-inline-btn" type="button" disabled>登录</button>
</div>
</div>
<div class="data-row data-check-row" id="row-custom-mail-provider-pool" style="display:none;">
<span class="data-label">自定义号池</span>
<textarea id="input-custom-mail-provider-pool" class="data-textarea"
placeholder="每行一个注册邮箱,例如&#10;alias001@example.com&#10;alias002@example.com"></textarea>
</div>
<div class="data-row" id="row-mail-2925-mode" style="display:none;">
<span class="data-label">2925 模式</span>
<div id="mail-2925-mode-group" class="choice-group" role="group" aria-label="2925 邮箱模式">
<button type="button" class="choice-btn" data-mail2925-mode="provide">提供邮箱</button>
<button type="button" class="choice-btn" data-mail2925-mode="receive">接收邮箱</button>
</div>
</div>
<div class="data-row" id="row-email-generator">
<span class="data-label">邮箱生成</span>
<select id="select-email-generator" class="data-select">
<option value="gmail-alias">Gmail +tag</option>
<option value="duck">DuckDuckGo</option>
<option value="custom-pool">自定义邮箱池</option>
<option value="cloudflare">Cloudflare</option>
<option value="icloud">iCloud 隐私邮箱</option>
<option value="cloudflare-temp-email">Cloudflare Temp Email</option>
</select>
</div>
<div class="data-row data-check-row" id="row-custom-email-pool" style="display:none;">
<span class="data-label">邮箱池</span>
<textarea id="input-custom-email-pool" class="data-textarea"
placeholder="每行一个邮箱,例如&#10;alias001@gmail.com&#10;alias002@gmail.com"></textarea>
</div>
<div class="data-row" id="row-cf-domain" style="display:none;">
<span class="data-label">CF 域名</span>
<div class="data-inline">
<select id="select-cf-domain" class="data-select"></select>
<input type="text" id="input-cf-domain" class="data-input" placeholder="例如 yourdomain.xyz"
style="display:none;" />
<button id="btn-cf-domain-mode" class="btn btn-outline btn-sm data-inline-btn" type="button">添加</button>
</div>
</div>
<div class="data-row" id="row-mail2925-pool-settings" style="display:none;">
<span class="data-label">2925 号池</span>
<div class="data-inline mail2925-base-inline">
<label class="toggle-switch mail2925-pool-toggle" id="label-mail2925-use-account-pool"
for="input-mail2925-use-account-pool" style="display:none;" title="开启后启用 2925 账号池">
<input type="checkbox" id="input-mail2925-use-account-pool" />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
<span>号池</span>
</label>
<select id="select-mail2925-pool-account" class="data-select mail2925-pool-select" style="display:none;">
<option value="">请选择号池邮箱</option>
</select>
</div>
</div>
<div class="data-row" id="row-email-prefix" style="display:none;">
<span class="data-label" id="label-email-prefix">别名基邮箱</span>
<div class="data-inline">
<input type="text" id="input-email-prefix" class="data-input mail2925-base-input"
placeholder="例如 yourname@example.com" />
</div>
</div>
<div class="data-row" id="row-inbucket-host" style="display:none;">
<span class="data-label">Inbucket</span>
<input type="text" id="input-inbucket-host" class="data-input" placeholder="填写主机或 https://主机地址" />
</div>
<div class="data-row" id="row-inbucket-mailbox" style="display:none;">
<span class="data-label">邮箱名</span>
<input type="text" id="input-inbucket-mailbox" class="data-input" placeholder="例如 zju2001" />
</div>
<div class="data-row">
<span class="data-label">注册邮箱</span>
<div class="data-inline">
<input type="text" id="input-email" class="data-input" placeholder="自动生成或手动粘贴邮箱" />
<button id="btn-fetch-email" class="btn btn-outline btn-sm data-inline-btn" type="button">获取</button>
</div>
</div>
<div class="data-row">
<span class="data-label">延迟</span>
<div class="data-inline setting-pair">
<div class="setting-group setting-group-primary">
<label class="toggle-switch" for="input-auto-delay-enabled">
<input type="checkbox" id="input-auto-delay-enabled" />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
</label>
<div class="setting-controls">
<input type="number" id="input-auto-delay-minutes" class="data-input auto-delay-input" value="30" min="1"
max="1440" step="1" title="启动前倒计时分钟数" />
<span class="data-unit">分钟</span>
</div>
</div>
<div class="setting-group setting-group-secondary">
<span class="setting-caption">步间间隔</span>
<div class="setting-controls">
<input type="number" id="input-auto-step-delay-seconds" class="data-input auto-delay-input" value=""
min="0" max="600" step="1" title="步间延迟秒数,0 或留空则不延迟" />
<span class="data-unit"></span>
</div>
</div>
</div>
</div>
<div class="data-row">
<span class="data-label">自动重试</span>
<div class="data-inline setting-pair">
<div class="setting-group setting-group-primary">
<label class="toggle-switch" for="input-auto-skip-failures">
<input type="checkbox" id="input-auto-skip-failures" />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
</label>
</div>
<div class="setting-group setting-group-secondary">
<span class="setting-caption">线程间隔</span>
<div class="setting-controls">
<input type="number" id="input-auto-skip-failures-thread-interval-minutes"
class="data-input auto-delay-input" value="0" min="0" max="1440" step="1" title="兜底模式下,两轮线程之间的等待分钟数" />
<span class="data-unit">分钟</span>
</div>
</div>
</div>
</div>
<div class="data-row" id="row-phone-verification-enabled">
<span class="data-label">接码</span>
<div class="data-inline setting-pair">
<div class="setting-group setting-group-primary">
<label class="toggle-switch" for="input-phone-verification-enabled">
<input type="checkbox" id="input-phone-verification-enabled" />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
</label>
</div>
<div class="setting-group setting-group-secondary">
<span class="setting-caption">验证码重发</span>
<div class="setting-controls">
<input type="number" id="input-verification-resend-count" class="data-input auto-delay-input" value="4"
min="0" max="20" step="1" title="自动点击重新发送验证码的次数" />
<span class="data-unit"></span>
</div>
</div>
</div>
</div>
<div class="data-row" id="row-account-run-history-helper-base-url" style="display:none;">
<span class="data-label">同步服务</span>
<input type="text" id="input-account-run-history-helper-base-url" class="data-input mono"
placeholder="http://127.0.0.1:17373" />
</div>
<div class="data-row" id="row-hero-sms-platform" style="display:none;">
<span class="data-label">接码平台</span>
<span id="display-hero-sms-platform" class="data-value mono">HeroSMS / OpenAI / Thailand</span>
</div>
<div class="data-row" id="row-hero-sms-country" style="display:none;">
<span class="data-label">接码国家</span>
<select id="select-hero-sms-country" class="data-input mono">
<option value="52" selected>Thailand</option>
</select>
</div>
<div class="data-row" id="row-hero-sms-api-key" style="display:none;">
<span class="data-label">接码 API</span>
<input type="password" id="input-hero-sms-api-key" class="data-input mono" placeholder="请输入 HeroSMS API Key" />
</div>
<div class="data-row">
<span class="data-label">OAuth</span>
<span id="display-oauth-url" class="data-value mono truncate">等待中...</span>
</div>
<div class="data-row">
<span class="data-label">回调</span>
<div class="data-inline data-value-actions">
<span id="display-localhost-url" class="data-value data-value-fill mono truncate">等待中...</span>
<button id="btn-save-settings" class="btn btn-outline btn-sm data-inline-btn" type="button">保存</button>
</div>
</div>
</div>
<div id="cloudflare-temp-email-section" class="data-card hotmail-card" style="display:none;">
<div class="section-mini-header">
@@ -602,13 +743,23 @@
</div>
<div class="data-row" id="row-temp-email-admin-auth" style="display:none;">
<span class="data-label">Admin Auth</span>
<input type="password" id="input-temp-email-admin-auth" class="data-input data-input-with-icon"
placeholder="Cloudflare Temp Email admin password" />
<div class="input-with-icon">
<input type="password" id="input-temp-email-admin-auth" class="data-input data-input-with-icon"
placeholder="Cloudflare Temp Email admin password" />
<button id="btn-toggle-temp-email-admin-auth" class="input-icon-btn" type="button"
data-password-toggle="input-temp-email-admin-auth" data-show-label="显示 Admin Auth"
data-hide-label="隐藏 Admin Auth" aria-label="显示 Admin Auth" title="显示 Admin Auth"></button>
</div>
</div>
<div class="data-row" id="row-temp-email-custom-auth" style="display:none;">
<span class="data-label">Custom Auth</span>
<input type="password" id="input-temp-email-custom-auth" class="data-input data-input-with-icon"
placeholder="仅当站点启用了访问密码时再填写;这是额外鉴权,不替代 Admin Auth。" />
<div class="input-with-icon">
<input type="password" id="input-temp-email-custom-auth" class="data-input data-input-with-icon"
placeholder="仅当站点启用了访问密码时再填写;这是额外鉴权,不替代 Admin Auth。" />
<button id="btn-toggle-temp-email-custom-auth" class="input-icon-btn" type="button"
data-password-toggle="input-temp-email-custom-auth" data-show-label="显示 Custom Auth"
data-hide-label="隐藏 Custom Auth" aria-label="显示 Custom Auth" title="显示 Custom Auth"></button>
</div>
</div>
<div class="data-row" id="row-temp-email-receive-mailbox" style="display:none;">
<span class="data-label">邮件接收</span>
@@ -682,12 +833,24 @@
</div>
<div class="data-row">
<span class="data-label">邮箱密码</span>
<input type="password" id="input-hotmail-password" class="data-input" placeholder="可选,仅用于记录" />
<div class="input-with-icon">
<input type="password" id="input-hotmail-password" class="data-input data-input-with-icon"
placeholder="可选,仅用于记录" />
<button id="btn-toggle-hotmail-password" class="input-icon-btn" type="button"
data-password-toggle="input-hotmail-password" data-show-label="显示 Hotmail 密码"
data-hide-label="隐藏 Hotmail 密码" aria-label="显示 Hotmail 密码" title="显示 Hotmail 密码"></button>
</div>
</div>
<div class="data-row">
<span class="data-label">刷新令牌</span>
<input type="password" id="input-hotmail-refresh-token" class="data-input mono"
placeholder="必填,粘贴刷新令牌(refresh token" />
<div class="input-with-icon">
<input type="password" id="input-hotmail-refresh-token" class="data-input data-input-with-icon mono"
placeholder="必填,粘贴刷新令牌(refresh token" />
<button id="btn-toggle-hotmail-refresh-token" class="input-icon-btn" type="button"
data-password-toggle="input-hotmail-refresh-token" data-show-label="显示 Hotmail 刷新令牌"
data-hide-label="隐藏 Hotmail 刷新令牌" aria-label="显示 Hotmail 刷新令牌"
title="显示 Hotmail 刷新令牌"></button>
</div>
</div>
<div class="data-row hotmail-actions-row">
<span class="data-label"></span>
@@ -729,7 +892,13 @@
</div>
<div class="data-row">
<span class="data-label">密码</span>
<input type="password" id="input-mail2925-password" class="data-input" placeholder="2925 登录密码" />
<div class="input-with-icon">
<input type="password" id="input-mail2925-password" class="data-input data-input-with-icon"
placeholder="2925 登录密码" />
<button id="btn-toggle-mail2925-password" class="input-icon-btn" type="button"
data-password-toggle="input-mail2925-password" data-show-label="显示 2925 密码"
data-hide-label="隐藏 2925 密码" aria-label="显示 2925 密码" title="显示 2925 密码"></button>
</div>
</div>
<div class="data-row hotmail-actions-row">
<span class="data-label"></span>
@@ -759,7 +928,13 @@
</div>
<div class="data-row">
<span class="data-label">API Key</span>
<input type="password" id="input-luckmail-api-key" class="data-input mono" placeholder="请输入 LuckMail API Key" />
<div class="input-with-icon">
<input type="password" id="input-luckmail-api-key" class="data-input data-input-with-icon mono"
placeholder="请输入 LuckMail API Key" />
<button id="btn-toggle-luckmail-api-key" class="input-icon-btn" type="button"
data-password-toggle="input-luckmail-api-key" data-show-label="显示 LuckMail API Key"
data-hide-label="隐藏 LuckMail API Key" aria-label="显示 LuckMail API Key" title="显示 LuckMail API Key"></button>
</div>
</div>
<div class="data-row">
<span class="data-label">Base URL</span>
@@ -986,6 +1161,22 @@
</div>
</div>
<div id="shared-form-modal" class="modal-overlay" hidden>
<div class="modal-card modal-card-form">
<div class="modal-header">
<span id="shared-form-modal-title" class="modal-title">添加账号</span>
<button id="btn-shared-form-modal-close" class="modal-close" type="button" aria-label="关闭">×</button>
</div>
<p id="shared-form-modal-message" class="modal-message" hidden></p>
<p id="shared-form-modal-alert" class="modal-alert" hidden></p>
<div id="shared-form-modal-fields" class="modal-form-fields"></div>
<div class="modal-actions">
<button id="btn-shared-form-modal-cancel" class="btn btn-ghost btn-sm" type="button">取消</button>
<button id="btn-shared-form-modal-confirm" class="btn btn-primary btn-sm" type="button">确认</button>
</div>
</div>
</div>
<div id="auto-start-modal" class="modal-overlay" hidden>
<div class="modal-card">
<div class="modal-header">
@@ -1012,6 +1203,7 @@
<input id="input-import-settings-file" type="file" accept=".json,application/json" hidden />
<script src="../managed-alias-utils.js"></script>
<script src="../mail2925-utils.js"></script>
<script src="../paypal-utils.js"></script>
<script src="../icloud-utils.js"></script>
<script src="../mail-provider-utils.js"></script>
<script src="../hotmail-utils.js"></script>
@@ -1020,8 +1212,10 @@
<script src="update-service.js"></script>
<script src="contribution-content-update-service.js"></script>
<script src="account-pool-ui.js"></script>
<script src="form-dialog.js"></script>
<script src="hotmail-manager.js"></script>
<script src="mail-2925-manager.js"></script>
<script src="paypal-manager.js"></script>
<script src="icloud-manager.js"></script>
<script src="luckmail-manager.js"></script>
<script src="ip-proxy-provider-711proxy.js"></script>
+1567 -83
View File
File diff suppressed because it is too large Load Diff
@@ -114,6 +114,12 @@ let currentState = {
inbucketMailbox: '',
cloudflareDomain: '',
cloudflareDomains: [],
reusablePhoneActivation: {
activationId: '123456',
phoneNumber: '66959916439',
successfulUses: 1,
maxUses: 3,
},
tabRegistry: {},
sourceLastUrls: {},
};
@@ -329,6 +335,16 @@ return {
assert.strictEqual(snapshot.currentState.autoRunSessionId, 0, 'session id should be cleared after completion');
assert.strictEqual(snapshot.currentState.gmailBaseEmail, 'demo@gmail.com', 'gmail base email should survive fresh-attempt reset');
assert.strictEqual(snapshot.currentState.mail2925BaseEmail, 'demo@2925.com', '2925 base email should survive fresh-attempt reset');
assert.deepStrictEqual(
snapshot.currentState.reusablePhoneActivation,
{
activationId: '123456',
phoneNumber: '66959916439',
successfulUses: 1,
maxUses: 3,
},
'reusable phone activation should survive fresh-attempt reset'
);
console.log('auto-run fresh attempt reset tests passed');
})().catch((error) => {
+7 -5
View File
@@ -215,18 +215,20 @@ test('auto-run stops restarting on generic phone-page failure messages even with
assert.ok(!result.events.logs.some(({ message }) => /回到步骤 7 重新开始授权流程/.test(message)));
});
test('auto-run restarts from step 7 when phone verification cannot receive SMS after resend', async () => {
test('auto-run does not restart step 7 when phone verification exhausted replacement attempts in add-phone flow', async () => {
const harness = createHarness({
failureStep: 9,
failureBudget: 1,
failureMessage: 'Phone verification could not receive an SMS after resend. Restart step 7 with a new number. Current number: 66959916439.',
failureMessage: 'Step 9: phone verification did not succeed after 3 number replacements. Last reason: sms_timeout_after_resend.',
authState: { state: 'add_phone_page', url: 'https://auth.openai.com/add-phone' },
});
const events = await harness.run();
const result = await harness.runAndCaptureError();
assert.equal(events.invalidations.length, 1);
assert.deepStrictEqual(events.steps, [7, 8, 9, 7, 8, 9, 10]);
assert.ok(result?.error);
assert.equal(result.events.invalidations.length, 0);
assert.deepStrictEqual(result.events.steps, [7, 8, 9]);
assert.ok(!result.events.logs.some(({ message }) => /回到步骤 7 重新开始授权流程/.test(message)));
});
test('auto-run stop errors after step 7 are rethrown immediately instead of restarting', async () => {
@@ -54,6 +54,13 @@ test('background account history settings are normalized independently from hotm
extractFunction('normalizeHotmailLocalBaseUrl'),
extractFunction('normalizeAccountRunHistoryHelperBaseUrl'),
extractFunction('normalizeVerificationResendCount'),
extractFunction('normalizePhoneVerificationReplacementLimit'),
extractFunction('normalizePhoneCodeWaitSeconds'),
extractFunction('normalizePhoneCodeTimeoutWindows'),
extractFunction('normalizePhoneCodePollIntervalSeconds'),
extractFunction('normalizePhoneCodePollMaxRounds'),
extractFunction('normalizeHeroSmsMaxPrice'),
extractFunction('normalizeHeroSmsCountryFallback'),
extractFunction('normalizePersistentSettingValue'),
].join('\n');
@@ -63,11 +70,28 @@ const DEFAULT_ACCOUNT_RUN_HISTORY_HELPER_BASE_URL = DEFAULT_HOTMAIL_LOCAL_BASE_U
const DEFAULT_HOTMAIL_REMOTE_BASE_URL = '';
const DEFAULT_CODEX2API_URL = 'http://localhost:8080/admin/accounts';
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
const PHONE_REPLACEMENT_LIMIT_MIN = 1;
const PHONE_REPLACEMENT_LIMIT_MAX = 20;
const DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT = 3;
const PHONE_CODE_WAIT_SECONDS_MIN = 15;
const PHONE_CODE_WAIT_SECONDS_MAX = 300;
const DEFAULT_PHONE_CODE_WAIT_SECONDS = 60;
const PHONE_CODE_TIMEOUT_WINDOWS_MIN = 1;
const PHONE_CODE_TIMEOUT_WINDOWS_MAX = 10;
const DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS = 2;
const PHONE_CODE_POLL_INTERVAL_SECONDS_MIN = 1;
const PHONE_CODE_POLL_INTERVAL_SECONDS_MAX = 30;
const DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS = 5;
const PHONE_CODE_POLL_ROUNDS_MIN = 1;
const PHONE_CODE_POLL_ROUNDS_MAX = 120;
const DEFAULT_PHONE_CODE_POLL_ROUNDS = 4;
const DEFAULT_SUB2API_PROXY_NAME = '';
const HOTMAIL_SERVICE_MODE_REMOTE = 'remote';
const HOTMAIL_SERVICE_MODE_LOCAL = 'local';
const VERIFICATION_RESEND_COUNT_MIN = 0;
const VERIFICATION_RESEND_COUNT_MAX = 20;
const HERO_SMS_COUNTRY_ID = 52;
const HERO_SMS_COUNTRY_LABEL = 'Thailand';
const PERSISTED_SETTING_DEFAULTS = {
autoStepDelaySeconds: null,
mailProvider: '163',
@@ -101,6 +125,18 @@ return {
assert.equal(api.normalizePersistentSettingValue('phoneVerificationEnabled', 1), true);
assert.equal(api.normalizePersistentSettingValue('verificationResendCount', '7'), 7);
assert.equal(api.normalizePersistentSettingValue('verificationResendCount', '-1'), 0);
assert.equal(api.normalizePersistentSettingValue('phoneVerificationReplacementLimit', '9'), 9);
assert.equal(api.normalizePersistentSettingValue('phoneVerificationReplacementLimit', '-1'), 1);
assert.equal(api.normalizePersistentSettingValue('phoneCodeWaitSeconds', '75'), 75);
assert.equal(api.normalizePersistentSettingValue('phoneCodeTimeoutWindows', '3'), 3);
assert.equal(api.normalizePersistentSettingValue('phoneCodePollIntervalSeconds', '6'), 6);
assert.equal(api.normalizePersistentSettingValue('phoneCodePollMaxRounds', '18'), 18);
assert.equal(api.normalizePersistentSettingValue('heroSmsMaxPrice', '0.123456'), '0.1235');
assert.equal(api.normalizePersistentSettingValue('heroSmsMaxPrice', '0'), '');
assert.deepStrictEqual(
api.normalizePersistentSettingValue('heroSmsCountryFallback', [{ id: 16, label: 'United Kingdom' }, { id: 52 }]),
[{ id: 16, label: 'United Kingdom' }, { id: 52, label: 'Country #52' }]
);
assert.equal(
api.normalizePersistentSettingValue('accountRunHistoryHelperBaseUrl', 'http://127.0.0.1:17373/append-account-log'),
'http://127.0.0.1:17373'
@@ -0,0 +1,39 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
test('background step-1 state plumbing persists and resets cpa oauth runtime keys', () => {
const source = fs.readFileSync('background.js', 'utf8');
assert.match(source, /cpaOAuthState:\s*null/);
assert.match(source, /cpaManagementOrigin:\s*null/);
assert.match(source, /payload\.cpaOAuthState[^\n]*updates\.cpaOAuthState/);
assert.match(source, /payload\.cpaManagementOrigin[^\n]*updates\.cpaManagementOrigin/);
assert.match(source, /if \(step <= 1\) \{[\s\S]*cpaOAuthState:\s*null,[\s\S]*cpaManagementOrigin:\s*null,/);
});
test('message router step-1 handler stores cpa oauth runtime keys', async () => {
const source = fs.readFileSync('background/message-router.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope);
const updates = [];
const router = api.createMessageRouter({
broadcastDataUpdate: () => {},
setState: async (payload) => {
updates.push(payload);
},
});
await router.handleStepData(1, {
cpaOAuthState: 'oauth-state-1',
cpaManagementOrigin: 'http://localhost:8317',
});
assert.deepStrictEqual(updates, [
{
cpaOAuthState: 'oauth-state-1',
cpaManagementOrigin: 'http://localhost:8317',
},
]);
});
+14
View File
@@ -625,6 +625,8 @@ test('resetState preserves LuckMail session config, used map, and preserve tag c
" luckmailPreserveTagName: '保留',",
" currentLuckmailPurchase: { token: 'stale' },",
" currentLuckmailMailCursor: { messageId: 'stale' },",
' currentPhoneActivation: null,',
' reusablePhoneActivation: null,',
' email: null,',
'};',
'const CONTRIBUTION_RUNTIME_DEFAULTS = {',
@@ -670,6 +672,7 @@ test('resetState preserves LuckMail session config, used map, and preserve tag c
" accounts: [{ email: 'saved@example.com' }],",
" tabRegistry: { foo: { tabId: 1 } },",
" sourceLastUrls: { foo: 'https://example.com' },",
" reusablePhoneActivation: { activationId: 'rx-001', phoneNumber: '66951112222', provider: 'hero-sms', serviceCode: 'dr', countryId: 52, successfulUses: 1, maxUses: 3 },",
" luckmailApiKey: 'sk-session',",
" luckmailBaseUrl: 'https://demo.example.com/',",
" luckmailEmailType: 'ms_imap',",
@@ -711,6 +714,16 @@ test('resetState preserves LuckMail session config, used map, and preserve tag c
assert.equal(snapshot.storedPayload.luckmailPreserveTagName, '保留');
assert.equal(snapshot.storedPayload.currentLuckmailPurchase, null);
assert.equal(snapshot.storedPayload.currentLuckmailMailCursor, null);
assert.deepStrictEqual(snapshot.storedPayload.reusablePhoneActivation, {
activationId: 'rx-001',
phoneNumber: '66951112222',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 1,
maxUses: 3,
});
assert.equal(snapshot.storedPayload.currentPhoneActivation, null);
});
test('handleStepData step 10 marks current LuckMail purchase as used and clears runtime state', async () => {
@@ -765,6 +778,7 @@ function broadcastDataUpdate() {}
function isLocalhostOAuthCallbackUrl() {
return true;
}
async function finalizePhoneActivationAfterSuccessfulFlow() {}
async function finalizeIcloudAliasAfterSuccessfulFlow() {}
${bundle}
@@ -0,0 +1,105 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background/message-router.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope);
test('message router appends success record on Plus final step instead of hard-coded step 10', async () => {
const appendCalls = [];
const router = api.createMessageRouter({
addLog: async () => {},
appendAccountRunRecord: async (...args) => {
appendCalls.push(args);
},
batchUpdateLuckmailPurchases: async () => {},
buildLocalhostCleanupPrefix: () => '',
buildLuckmailSessionSettingsPayload: () => ({}),
buildPersistentSettingsPayload: () => ({}),
broadcastDataUpdate: () => {},
cancelScheduledAutoRun: async () => {},
checkIcloudSession: async () => {},
clearAutoRunTimerAlarm: async () => {},
clearLuckmailRuntimeState: async () => {},
clearStopRequest: () => {},
closeLocalhostCallbackTabs: async () => {},
closeTabsByUrlPrefix: async () => {},
deleteHotmailAccount: async () => {},
deleteHotmailAccounts: async () => {},
deleteIcloudAlias: async () => {},
deleteUsedIcloudAliases: async () => {},
disableUsedLuckmailPurchases: async () => {},
doesStepUseCompletionSignal: () => false,
ensureManualInteractionAllowed: async () => ({}),
executeStep: async () => {},
executeStepViaCompletionSignal: async () => {},
exportSettingsBundle: async () => ({}),
fetchGeneratedEmail: async () => '',
finalizeStep3Completion: async () => {},
finalizeIcloudAliasAfterSuccessfulFlow: async () => {},
findHotmailAccount: async () => null,
flushCommand: async () => {},
getCurrentLuckmailPurchase: () => null,
getPendingAutoRunTimerPlan: () => null,
getSourceLabel: () => '',
getState: async () => ({ plusModeEnabled: true, stepStatuses: { 13: 'pending' } }),
getLastStepIdForState: () => 13,
getStepDefinitionForState: (step) => ({ id: step, key: step === 10 ? 'oauth-login' : 'platform-verify' }),
getStepIdsForState: () => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13],
getTabId: async () => null,
getStopRequested: () => false,
handleAutoRunLoopUnhandledError: async () => {},
handleCloudflareSecurityBlocked: async () => '',
importSettingsBundle: async () => {},
invalidateDownstreamAfterStepRestart: async () => {},
isCloudflareSecurityBlockedError: () => false,
isAutoRunLockedState: () => false,
isHotmailProvider: () => false,
isLocalhostOAuthCallbackUrl: () => true,
isLuckmailProvider: () => false,
isStopError: () => false,
isTabAlive: async () => false,
launchAutoRunTimerPlan: async () => {},
listIcloudAliases: async () => [],
listLuckmailPurchasesForManagement: async () => [],
normalizeHotmailAccounts: (items) => items,
normalizeRunCount: (value) => value,
AUTO_RUN_TIMER_KIND_SCHEDULED_START: 'scheduled',
notifyStepComplete: () => {},
notifyStepError: () => {},
patchHotmailAccount: async () => {},
patchMail2925Account: async () => {},
registerTab: async () => {},
requestStop: async () => {},
resetState: async () => {},
resumeAutoRun: async () => {},
scheduleAutoRun: async () => {},
selectLuckmailPurchase: async () => {},
setCurrentHotmailAccount: async () => {},
setCurrentMail2925Account: async () => {},
setContributionMode: async () => {},
setEmailState: async () => {},
setEmailStateSilently: async () => {},
setIcloudAliasPreservedState: async () => {},
setIcloudAliasUsedState: async () => {},
setLuckmailPurchaseDisabledState: async () => {},
setLuckmailPurchasePreservedState: async () => {},
setLuckmailPurchaseUsedState: async () => {},
setPersistentSettings: async () => {},
setState: async () => {},
setStepStatus: async () => {},
skipAutoRunCountdown: async () => false,
skipStep: async () => {},
startAutoRunLoop: async () => {},
syncHotmailAccounts: async () => {},
testHotmailAccountMailAccess: async () => {},
upsertHotmailAccount: async () => {},
verifyHotmailAccount: async () => {},
});
await router.handleMessage({ type: 'STEP_COMPLETE', step: 13, payload: {} }, {});
assert.equal(appendCalls.length, 1);
assert.equal(appendCalls[0][0], 'success');
});
@@ -12,6 +12,7 @@ function createRouter(overrides = {}) {
stepStatuses: [],
emailStates: [],
finalizePayloads: [],
phoneFinalizations: [],
notifyCompletions: [],
notifyErrors: [],
securityBlocks: [],
@@ -49,10 +50,13 @@ function createRouter(overrides = {}) {
executeStepViaCompletionSignal: async () => {},
exportSettingsBundle: async () => ({}),
fetchGeneratedEmail: async () => '',
finalizePhoneActivationAfterSuccessfulFlow: overrides.finalizePhoneActivationAfterSuccessfulFlow || (async (state) => {
events.phoneFinalizations.push(state);
}),
finalizeStep3Completion: overrides.finalizeStep3Completion || (async (payload) => {
events.finalizePayloads.push(payload);
}),
finalizeIcloudAliasAfterSuccessfulFlow: async () => {},
finalizeIcloudAliasAfterSuccessfulFlow: overrides.finalizeIcloudAliasAfterSuccessfulFlow || (async () => {}),
findHotmailAccount: async () => null,
flushCommand: async () => {},
getCurrentLuckmailPurchase: () => null,
@@ -61,6 +65,7 @@ function createRouter(overrides = {}) {
getState: async () => overrides.state || { stepStatuses: { 3: 'pending' } },
getStepDefinitionForState: overrides.getStepDefinitionForState,
getStepIdsForState: overrides.getStepIdsForState,
getLastStepIdForState: overrides.getLastStepIdForState,
getTabId: overrides.getTabId || (async () => null),
getStopRequested: () => false,
handleAutoRunLoopUnhandledError: async () => {},
@@ -262,6 +267,68 @@ test('message router finalizes step 3 before marking it completed', async () =>
assert.deepStrictEqual(response, { ok: true });
});
test('message router finalizes pending phone activation on platform verify success', async () => {
const state = {
stepStatuses: { 10: 'pending' },
reusablePhoneActivation: {
activationId: '123456',
phoneNumber: '66959916439',
successfulUses: 0,
maxUses: 3,
},
pendingPhoneActivationConfirmation: {
activationId: '123456',
phoneNumber: '66959916439',
successfulUses: 0,
maxUses: 3,
},
};
const { router, events } = createRouter({
state,
getStepDefinitionForState: (step) => ({ id: step, key: step === 10 ? 'platform-verify' : '' }),
});
await router.handleStepData(10, {
localhostUrl: 'http://localhost:1455/auth/callback?code=ok',
});
assert.deepStrictEqual(events.phoneFinalizations, [state]);
});
test('message router does not finalize pending phone activation when icloud finalization fails', async () => {
const state = {
stepStatuses: { 10: 'pending' },
reusablePhoneActivation: {
activationId: '123456',
phoneNumber: '66959916439',
successfulUses: 0,
maxUses: 3,
},
pendingPhoneActivationConfirmation: {
activationId: '123456',
phoneNumber: '66959916439',
successfulUses: 0,
maxUses: 3,
},
};
const { router, events } = createRouter({
state,
getStepDefinitionForState: (step) => ({ id: step, key: step === 10 ? 'platform-verify' : '' }),
finalizeIcloudAliasAfterSuccessfulFlow: async () => {
throw new Error('icloud finalize failed');
},
});
await assert.rejects(
() => router.handleStepData(10, {
localhostUrl: 'http://localhost:1455/auth/callback?code=ok',
}),
/icloud finalize failed/
);
assert.deepStrictEqual(events.phoneFinalizations, []);
});
test('message router marks step 3 failed when post-submit finalize fails', async () => {
const { router, events } = createRouter({
finalizeStep3Completion: async () => {
@@ -71,3 +71,48 @@ test('panel bridge can request codex2api oauth url via protocol', async () => {
globalThis.fetch = originalFetch;
}
});
test('panel bridge can request cpa oauth url via management api', async () => {
const source = fs.readFileSync('background/panel-bridge.js', 'utf8');
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url, options = {}) => {
assert.equal(url, 'http://localhost:8317/v0/management/codex-auth-url');
assert.equal(options.method, 'GET');
assert.equal(options.headers.Authorization, 'Bearer cpa-key');
assert.equal(options.headers['X-Management-Key'], 'cpa-key');
return {
ok: true,
json: async () => ({
status: 'ok',
url: 'https://auth.openai.com/authorize?state=cpa-oauth-state',
state: 'cpa-oauth-state',
}),
};
};
try {
const api = new Function('self', `${source}; return self.MultiPageBackgroundPanelBridge;`)({});
const bridge = api.createPanelBridge({
addLog: async () => {},
getPanelMode: () => 'cpa',
normalizeCodex2ApiUrl: (value) => value,
normalizeSub2ApiUrl: (value) => value,
DEFAULT_SUB2API_GROUP_NAME: 'codex',
SUB2API_STEP1_RESPONSE_TIMEOUT_MS: 90000,
});
const result = await bridge.requestOAuthUrlFromPanel({
panelMode: 'cpa',
vpsUrl: 'http://localhost:8317/admin/oauth',
vpsPassword: 'cpa-key',
}, { logLabel: '步骤 7' });
assert.deepStrictEqual(result, {
oauthUrl: 'https://auth.openai.com/authorize?state=cpa-oauth-state',
cpaOAuthState: 'cpa-oauth-state',
cpaManagementOrigin: 'http://localhost:8317',
});
} finally {
globalThis.fetch = originalFetch;
}
});
@@ -0,0 +1,91 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
test('background imports paypal account store module', () => {
const source = fs.readFileSync('background.js', 'utf8');
assert.match(source, /background\/paypal-account-store\.js/);
assert.match(source, /paypal-utils\.js/);
});
test('paypal account store module exposes a factory', () => {
const source = fs.readFileSync('background/paypal-account-store.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundPayPalAccountStore;`)(globalScope);
assert.equal(typeof api?.createPayPalAccountStore, 'function');
});
test('paypal account store selects account and keeps legacy paypal credentials in sync', async () => {
const source = fs.readFileSync('background/paypal-account-store.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundPayPalAccountStore;`)(globalScope);
let latestState = {
paypalAccounts: [],
currentPayPalAccountId: '',
paypalEmail: '',
paypalPassword: '',
};
const broadcasts = [];
const store = api.createPayPalAccountStore({
broadcastDataUpdate(payload) {
broadcasts.push(payload);
},
findPayPalAccount(accounts, accountId) {
return (Array.isArray(accounts) ? accounts : []).find((account) => account.id === accountId) || null;
},
getState: async () => latestState,
normalizePayPalAccount(account = {}) {
return {
id: String(account.id || 'generated'),
email: String(account.email || '').trim().toLowerCase(),
password: String(account.password || ''),
createdAt: Number(account.createdAt) || 1,
updatedAt: Number(account.updatedAt) || 1,
lastUsedAt: Number(account.lastUsedAt) || 0,
};
},
normalizePayPalAccounts(accounts) {
return Array.isArray(accounts) ? accounts.slice() : [];
},
setPersistentSettings: async (updates) => {
latestState = { ...latestState, ...updates };
},
setState: async (updates) => {
latestState = { ...latestState, ...updates };
},
upsertPayPalAccountInList(accounts, nextAccount) {
const list = Array.isArray(accounts) ? accounts.slice() : [];
const existingIndex = list.findIndex((account) => account.id === nextAccount.id);
if (existingIndex >= 0) {
list[existingIndex] = nextAccount;
return list;
}
list.push(nextAccount);
return list;
},
});
const account = await store.upsertPayPalAccount({
id: 'pp-1',
email: 'User@Example.com',
password: 'secret',
});
assert.equal(account.email, 'user@example.com');
const selected = await store.setCurrentPayPalAccount('pp-1');
assert.equal(selected.id, 'pp-1');
assert.equal(latestState.currentPayPalAccountId, 'pp-1');
assert.equal(latestState.paypalEmail, 'user@example.com');
assert.equal(latestState.paypalPassword, 'secret');
assert.deepStrictEqual(
broadcasts.at(-1),
{
currentPayPalAccountId: 'pp-1',
paypalEmail: 'user@example.com',
paypalPassword: 'secret',
}
);
});
@@ -0,0 +1,239 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const test = require('node:test');
function createDeps(overrides = {}) {
const logs = [];
const completed = [];
const uiCalls = [];
const deps = {
addLog: async (message, level = 'info') => {
logs.push({ message, level });
},
chrome: {
tabs: {
update: async () => {},
},
},
closeConflictingTabsForSource: async () => {},
completeStepFromBackground: async (step, payload) => {
completed.push({ step, payload });
},
ensureContentScriptReadyOnTab: async () => {},
getPanelMode: () => 'cpa',
getTabId: async () => 0,
isLocalhostOAuthCallbackUrl: (value) => String(value || '').includes('/auth/callback?code='),
isTabAlive: async () => false,
normalizeCodex2ApiUrl: (value) => value,
normalizeSub2ApiUrl: (value) => value,
rememberSourceLastUrl: async () => {},
reuseOrCreateTab: async () => 91,
sendToContentScript: async () => ({}),
sendToContentScriptResilient: async (source, message, options) => {
uiCalls.push({ source, message, options });
return {};
},
shouldBypassStep9ForLocalCpa: () => false,
SUB2API_STEP9_RESPONSE_TIMEOUT_MS: 120000,
...overrides,
};
return { deps, logs, completed, uiCalls };
}
test('platform verify module submits CPA callback via management API first', async () => {
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
const originalFetch = globalThis.fetch;
let uiCalled = false;
globalThis.fetch = async (url, options = {}) => {
assert.equal(url, 'http://localhost:8317/v0/management/oauth-callback');
assert.equal(options.method, 'POST');
assert.equal(options.headers.Authorization, 'Bearer cpa-key');
assert.equal(options.headers['X-Management-Key'], 'cpa-key');
assert.deepStrictEqual(JSON.parse(options.body), {
provider: 'codex',
redirect_url: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
});
return {
ok: true,
json: async () => ({
message: 'CPA API 回调提交成功',
}),
};
};
try {
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
const { deps, logs, completed } = createDeps({
sendToContentScriptResilient: async () => {
uiCalled = true;
return {};
},
});
const executor = api.createStep10Executor(deps);
await executor.executeStep10({
panelMode: 'cpa',
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
vpsUrl: 'http://localhost:8317/admin/oauth',
vpsPassword: 'cpa-key',
});
assert.equal(uiCalled, false);
assert.deepStrictEqual(completed, [
{
step: 10,
payload: {
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
verifiedStatus: 'CPA API 回调提交成功',
},
},
]);
assert.deepStrictEqual(logs, [
{ message: '步骤 10:正在通过 CPA 管理接口提交回调地址...', level: 'info' },
{ message: '步骤 10CPA API 回调提交成功', level: 'ok' },
]);
} finally {
globalThis.fetch = originalFetch;
}
});
test('platform verify module prefers cpaManagementOrigin when provided', 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:9999/v0/management/oauth-callback');
assert.equal(options.method, 'POST');
assert.equal(options.headers.Authorization, 'Bearer cpa-key');
assert.equal(options.headers['X-Management-Key'], 'cpa-key');
return {
ok: true,
json: async () => ({
message: 'CPA API 回调提交成功',
}),
};
};
try {
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
const { deps, completed } = createDeps();
const executor = api.createStep10Executor(deps);
await executor.executeStep10({
panelMode: 'cpa',
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
cpaManagementOrigin: 'http://localhost:9999',
vpsUrl: 'http://localhost:8317/admin/oauth',
vpsPassword: 'cpa-key',
});
assert.equal(completed.length, 1);
} finally {
globalThis.fetch = originalFetch;
}
});
test('platform verify module fails fast when CPA API submit fails', async () => {
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => ({
ok: false,
status: 500,
json: async () => ({ message: 'failed to persist oauth callback' }),
});
try {
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
const { deps, logs, completed, uiCalls } = createDeps();
const executor = api.createStep10Executor(deps);
await assert.rejects(
() => executor.executeStep10({
panelMode: 'cpa',
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
vpsUrl: 'http://localhost:8317/admin/oauth',
vpsPassword: 'cpa-key',
}),
/failed to persist oauth callback/
);
assert.equal(uiCalls.length, 0);
assert.equal(completed.length, 0);
assert.equal(logs[0].message, '步骤 10:正在通过 CPA 管理接口提交回调地址...');
assert.match(logs[1].message, /步骤 10CPA 接口提交失败:failed to persist oauth callback/);
assert.equal(logs[1].level, 'error');
} finally {
globalThis.fetch = originalFetch;
}
});
test('platform verify module requires management key for CPA API-only flow', async () => {
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
const originalFetch = globalThis.fetch;
let fetchCalled = false;
globalThis.fetch = async () => {
fetchCalled = true;
return {
ok: true,
json: async () => ({}),
};
};
try {
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
const { deps, logs, completed, uiCalls } = createDeps();
const executor = api.createStep10Executor(deps);
await assert.rejects(
() => executor.executeStep10({
panelMode: 'cpa',
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
vpsUrl: 'http://localhost:8317/admin/oauth',
vpsPassword: ' ',
}),
/尚未配置 CPA 管理密钥/
);
assert.equal(fetchCalled, false);
assert.equal(uiCalls.length, 0);
assert.equal(completed.length, 0);
assert.equal(logs.length, 0);
} finally {
globalThis.fetch = originalFetch;
}
});
test('platform verify module rejects callback when cpa oauth state mismatches', async () => {
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
const originalFetch = globalThis.fetch;
let fetchCalled = false;
globalThis.fetch = async () => {
fetchCalled = true;
return {
ok: true,
json: async () => ({ message: 'should not happen' }),
};
};
try {
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
const { deps } = createDeps();
const executor = api.createStep10Executor(deps);
await assert.rejects(
() => executor.executeStep10({
panelMode: 'cpa',
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=callback-state',
cpaOAuthState: 'expected-state',
vpsUrl: 'http://localhost:8317/admin/oauth',
vpsPassword: 'cpa-key',
}),
/CPA 回调 state 与当前授权会话不匹配/
);
assert.equal(fetchCalled, false);
} finally {
globalThis.fetch = originalFetch;
}
});
+72
View File
@@ -310,6 +310,78 @@ test('step 8 retries in-place when polling fails but auth page still stays on ve
assert.equal(events.ensureCalls >= 3, true);
});
test('step 8 keeps resend cooldown timestamp across in-place retries to avoid repeated resend storms', async () => {
const events = {
resolveCalls: 0,
resolveLastResendAts: [],
sleepMs: [],
};
const realDateNow = Date.now;
Date.now = () => 230000;
const executor = api.createStep8Executor({
addLog: async () => {},
chrome: {
tabs: {
update: async () => {},
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
confirmCustomVerificationStepBypass: async () => {},
ensureStep8VerificationPageReady: async () => ({ state: 'verification_page', displayedEmail: 'user@example.com' }),
rerunStep7ForStep8Recovery: async () => {},
getOAuthFlowRemainingMs: async () => 9000,
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 9000),
getMailConfig: () => ({
provider: 'qq',
label: 'QQ 邮箱',
source: 'mail-qq',
url: 'https://mail.qq.com',
navigateOnReuse: false,
}),
getState: async () => ({ email: 'user@example.com', password: 'secret', loginVerificationRequestedAt: null }),
getTabId: async (sourceName) => (sourceName === 'signup-page' ? 1 : 2),
HOTMAIL_PROVIDER: 'hotmail-api',
isTabAlive: async () => true,
isVerificationMailPollingError: (error) => /页面通信异常|did not respond/i.test(String(error?.message || error || '')),
LUCKMAIL_PROVIDER: 'luckmail-api',
resolveVerificationStep: async (_step, _state, _mail, options) => {
events.resolveCalls += 1;
events.resolveLastResendAts.push(Number(options?.lastResendAt) || 0);
if (events.resolveCalls === 1) {
await options.onResendRequestedAt(222000);
throw new Error('步骤 8:页面通信异常 did not respond in 1s');
}
},
reuseOrCreateTab: async () => {},
setState: async () => {},
setStepStatus: async () => {},
shouldUseCustomRegistrationEmail: () => false,
sleepWithStop: async (ms) => {
events.sleepMs.push(ms);
},
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
throwIfStopped: () => {},
});
try {
await executor.executeStep8({
email: 'user@example.com',
password: 'secret',
oauthUrl: 'https://oauth.example/latest',
});
} finally {
Date.now = realDateNow;
}
assert.equal(events.resolveCalls, 2);
assert.deepStrictEqual(events.resolveLastResendAts, [0, 222000]);
assert.equal(events.sleepMs.length >= 1, true);
assert.equal(events.sleepMs[0], 3000);
});
test('step 8 completes when polling fails but recovery probe shows oauth consent page', async () => {
const events = {
ensureCalls: 0,
+103 -1
View File
@@ -18,6 +18,107 @@ hotmail_helper = load_hotmail_helper()
class HotmailHelperLoggingTest(unittest.TestCase):
def test_select_latest_code_can_use_full_body_when_preview_is_truncated(self):
css_prefix = (
'Your temporary ChatGPT verification code '
'@font-face { font-family: "Söhne"; src: url(https://cdn.openai.com/common/fonts/soehne/soehne-buch.woff2) format("woff2"); } '
'.ExternalClass { width: 100%; } '
'#bodyTable { width: 560px; } '
'body { min-width: 100% !important; } '
) * 8
full_body = (
css_prefix
+ 'Enter this temporary verification code to continue: 272964 '
+ 'Please ignore this email if this was not you.'
)
message = {
"id": "imap-1",
"mailbox": "INBOX",
"subject": "Your temporary ChatGPT verification code",
"from": {
"emailAddress": {
"address": "otp@tm1.openai.com",
"name": "OpenAI",
}
},
"bodyPreview": full_body[:500],
"body": {
"content": full_body,
},
"receivedTimestamp": 200,
}
result = hotmail_helper.select_latest_code(
[message],
['openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward'],
['verify', 'verification', 'code', '验证码', 'confirm', 'login'],
[],
0,
)
self.assertEqual(result["code"], "272964")
self.assertEqual(result["message"]["id"], "imap-1")
def test_log_openai_messages_logs_full_body_when_available(self):
messages = [{
"mailbox": "INBOX",
"subject": "Your verification code",
"from": {
"emailAddress": {
"address": "account-security@openai.com",
"name": "OpenAI",
}
},
"bodyPreview": "Use 123456 to continue.",
"body": {
"content": "Hello there\nUse 123456 to continue.",
},
}]
output = io.StringIO()
with redirect_stdout(output):
hotmail_helper.log_openai_messages(messages, transport="imap")
rendered = output.getvalue()
self.assertIn(
"[HotmailHelper] openai mail received transport=imap mailbox=INBOX sender=account-security@openai.com senderName=OpenAI subject=Your verification code",
rendered,
)
self.assertIn(
"[HotmailHelper] openai mail full body start transport=imap mailbox=INBOX sender=account-security@openai.com senderName=OpenAI subject=Your verification code",
rendered,
)
self.assertIn("Hello there\nUse 123456 to continue.", rendered)
self.assertIn("[HotmailHelper] openai mail full body end", rendered)
def test_log_openai_messages_falls_back_to_preview_without_full_body(self):
messages = [{
"mailbox": "Junk",
"subject": "Verify your sign in",
"from": {
"emailAddress": {
"address": "noreply@tm.openai.com",
"name": "ChatGPT",
}
},
"bodyPreview": "Use 654321 to continue.",
}]
output = io.StringIO()
with redirect_stdout(output):
hotmail_helper.log_openai_messages(messages, transport="graph")
rendered = output.getvalue()
self.assertIn(
"[HotmailHelper] openai mail received transport=graph mailbox=Junk sender=noreply@tm.openai.com senderName=ChatGPT subject=Verify your sign in",
rendered,
)
self.assertIn(
"[HotmailHelper] openai mail preview transport=graph mailbox=Junk sender=noreply@tm.openai.com senderName=ChatGPT subject=Verify your sign in preview=Use 654321 to continue.",
rendered,
)
self.assertNotIn("openai mail full body start", rendered)
def test_refresh_access_token_logs_invalid_grant_and_direct_connection_refused_separately(self):
failures = [
{
@@ -38,7 +139,8 @@ class HotmailHelperLoggingTest(unittest.TestCase):
},
]
with mock.patch.object(hotmail_helper, "try_refresh_access_token", side_effect=failures):
with mock.patch.object(hotmail_helper, "try_refresh_access_token", side_effect=failures), \
mock.patch.object(hotmail_helper, "get_proxy_debug_context", return_value="direct"):
output = io.StringIO()
with redirect_stdout(output):
with self.assertRaises(RuntimeError):
+26
View File
@@ -107,6 +107,32 @@ test('PayPal approve keeps original combined email and password login path', asy
assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), true);
});
test('PayPal approve prefers the selected paypal pool account over legacy fields', async () => {
const { executor, events } = createExecutor({
pageStates: [
{ needsLogin: true, hasEmailInput: true, hasPasswordInput: true, loginPhase: 'login_combined' },
{ needsLogin: false, approveReady: true },
{ needsLogin: false, approveReady: true },
],
submitResults: [
{ submitted: true, phase: 'password_submitted', awaiting: 'redirect_or_approval' },
],
});
await executor.executePayPalApprove({
paypalEmail: '',
paypalPassword: '',
currentPayPalAccountId: 'pp-1',
paypalAccounts: [
{ id: 'pp-1', email: 'pool@example.com', password: 'pool-secret' },
],
});
assert.deepStrictEqual(events.submittedPayloads, [
{ email: 'pool@example.com', password: 'pool-secret' },
]);
});
test('PayPal approve discovers an already open unregistered PayPal tab', async () => {
const { executor, events } = createExecutor({
pageStates: [
+105
View File
@@ -145,6 +145,57 @@ return {
`)(document, window);
}
function createSubmitApi(overrides = {}) {
const bindings = {
waitForDocumentComplete: async () => {},
normalizeText: (text = '') => String(text || '').replace(/\s+/g, ' ').trim(),
findPasswordInput: () => null,
findEmailInput: () => null,
findEmailNextButton: () => null,
isEnabledControl: () => true,
findPasswordLoginButton: () => null,
fillInput: () => {},
simulateClick: () => {},
waitUntil: async (predicate) => predicate(),
findLoginNextButton: () => null,
sleep: async () => {},
...overrides,
};
return new Function(
'waitForDocumentComplete',
'normalizeText',
'findPasswordInput',
'findEmailInput',
'findEmailNextButton',
'isEnabledControl',
'findPasswordLoginButton',
'fillInput',
'simulateClick',
'waitUntil',
'findLoginNextButton',
'sleep',
`
${extractFunction('refillPayPalEmailInput')}
${extractFunction('submitPayPalLogin')}
return { refillPayPalEmailInput, submitPayPalLogin };
`
)(
bindings.waitForDocumentComplete,
bindings.normalizeText,
bindings.findPasswordInput,
bindings.findEmailInput,
bindings.findEmailNextButton,
bindings.isEnabledControl,
bindings.findPasswordLoginButton,
bindings.fillInput,
bindings.simulateClick,
bindings.waitUntil,
bindings.findLoginNextButton,
bindings.sleep
);
}
test('PayPal email page ignores hidden pre-rendered password input', () => {
const hiddenPanel = createElement({ attrs: { 'aria-hidden': 'true' } });
const emailInput = createElement({
@@ -203,3 +254,57 @@ test('PayPal combined login page still sees visible password input', () => {
assert.equal(api.findPasswordLoginButton(), loginButton);
assert.equal(api.getPayPalLoginPhase(emailInput, passwordInput), 'login_combined');
});
test('PayPal email submit refills a prefilled email before clicking next', async () => {
const emailInput = createElement({
tag: 'input',
type: 'text',
id: 'login_email',
name: 'login_email',
value: 'user@example.com',
placeholder: 'Email',
});
const nextButton = createElement({
tag: 'button',
id: 'btnNext',
text: 'Next',
});
const fillValues = [];
const clicked = [];
let focusCount = 0;
let blurCount = 0;
emailInput.focus = () => {
focusCount += 1;
};
emailInput.blur = () => {
blurCount += 1;
};
const api = createSubmitApi({
findEmailInput: () => emailInput,
findEmailNextButton: () => nextButton,
fillInput: (element, value) => {
fillValues.push(value);
element.value = value;
},
simulateClick: (element) => {
clicked.push(element);
},
});
const result = await api.submitPayPalLogin({
email: 'user@example.com',
password: 'secret',
});
assert.deepEqual(fillValues, ['', 'user@example.com']);
assert.equal(focusCount, 1);
assert.equal(blurCount, 1);
assert.deepEqual(clicked, [nextButton]);
assert.deepEqual(result, {
submitted: false,
phase: 'email_submitted',
awaiting: 'password_page',
});
});
File diff suppressed because it is too large Load Diff
@@ -222,6 +222,7 @@ const inputAutoDelayMinutes = { value: '30' };
const inputAutoStepDelaySeconds = { value: '10' };
const inputVerificationResendCount = { value: '6' };
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
const DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT = 3;
function getCloudflareDomainsFromState() { return { domains: ['example.com'], activeDomain: 'example.com' }; }
function normalizeCloudflareDomainValue(value) { return String(value || '').trim(); }
+69
View File
@@ -130,6 +130,64 @@ const inputAutoDelayMinutes = { value: '30' };
const inputAutoStepDelaySeconds = { value: '' };
const inputVerificationResendCount = { value: '4' };
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
const DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT = 3;
const DEFAULT_PHONE_CODE_WAIT_SECONDS = 60;
const DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS = 2;
const DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS = 5;
const DEFAULT_PHONE_CODE_POLL_MAX_ROUNDS = 4;
const PHONE_CODE_WAIT_SECONDS_MIN = 15;
const PHONE_CODE_WAIT_SECONDS_MAX = 300;
const PHONE_CODE_TIMEOUT_WINDOWS_MIN = 1;
const PHONE_CODE_TIMEOUT_WINDOWS_MAX = 10;
const PHONE_CODE_POLL_INTERVAL_SECONDS_MIN = 1;
const PHONE_CODE_POLL_INTERVAL_SECONDS_MAX = 30;
const PHONE_CODE_POLL_MAX_ROUNDS_MIN = 1;
const PHONE_CODE_POLL_MAX_ROUNDS_MAX = 120;
const DEFAULT_HERO_SMS_REUSE_ENABLED = true;
const HERO_SMS_ACQUIRE_PRIORITY_COUNTRY = 'country';
const HERO_SMS_ACQUIRE_PRIORITY_PRICE = 'price';
const DEFAULT_HERO_SMS_ACQUIRE_PRIORITY = HERO_SMS_ACQUIRE_PRIORITY_COUNTRY;
const DEFAULT_HERO_SMS_COUNTRY_ID = 52;
const DEFAULT_HERO_SMS_COUNTRY_LABEL = 'Thailand';
const inputHeroSmsApiKey = { value: '' };
const inputHeroSmsReuseEnabled = { checked: true };
const selectHeroSmsAcquirePriority = { value: 'country' };
const inputHeroSmsMaxPrice = { value: '' };
const inputPhoneReplacementLimit = { value: '3' };
const inputPhoneCodeWaitSeconds = { value: '60' };
const inputPhoneCodeTimeoutWindows = { value: '2' };
const inputPhoneCodePollIntervalSeconds = { value: '5' };
const inputPhoneCodePollMaxRounds = { value: '4' };
const selectHeroSmsCountry = { value: '52', selectedIndex: 0, options: [{ value: '52', textContent: 'Thailand' }] };
function normalizeHeroSmsMaxPriceValue(value = '') { return String(value || '').trim(); }
function normalizeHeroSmsReuseEnabledValue(value) { return value === undefined || value === null ? true : Boolean(value); }
function normalizeHeroSmsAcquirePriority(value = '') { return String(value || '').trim().toLowerCase() === 'price' ? 'price' : 'country'; }
function normalizeHeroSmsCountryId(value) { return Math.max(1, Math.floor(Number(value) || 52)); }
function normalizeHeroSmsCountryLabel(value = '') { return String(value || '').trim() || 'Thailand'; }
function normalizePhoneVerificationReplacementLimit(value, fallback = 3) {
const parsed = Number.parseInt(String(value ?? '').trim(), 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function normalizePhoneCodeWaitSecondsValue(value, fallback = 60) {
const parsed = Number.parseInt(String(value ?? '').trim(), 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function normalizePhoneCodeTimeoutWindowsValue(value, fallback = 2) {
const parsed = Number.parseInt(String(value ?? '').trim(), 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function normalizePhoneCodePollIntervalSecondsValue(value, fallback = 5) {
const parsed = Number.parseInt(String(value ?? '').trim(), 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function normalizePhoneCodePollMaxRoundsValue(value, fallback = 12) {
const parsed = Number.parseInt(String(value ?? '').trim(), 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function getSelectedHeroSmsCountryOption() { return { id: 52, label: 'Thailand' }; }
function syncHeroSmsFallbackSelectionOrderFromSelect() { return [{ id: 52, label: 'Thailand' }]; }
function getPayPalAccounts() { return []; }
function getCurrentPayPalAccount() { return null; }
function getCloudflareDomainsFromState() { return { domains: [], activeDomain: '' }; }
function normalizeCloudflareDomainValue(value) { return String(value || '').trim(); }
function getCloudflareTempEmailDomainsFromState() { return { domains: [], activeDomain: '' }; }
@@ -321,9 +379,14 @@ const inputVerificationResendCount = { value: '' };
const inputPhoneVerificationEnabled = { checked: false };
const DEFAULT_PHONE_VERIFICATION_ENABLED = false;
const inputHeroSmsApiKey = { value: '' };
const inputHeroSmsMaxPrice = { value: '' };
const inputPhoneReplacementLimit = { value: '' };
const selectHeroSmsCountry = { value: '52', options: [{ value: '52' }] };
const inputRunCount = { value: '' };
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
const DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT = 3;
const PHONE_REPLACEMENT_LIMIT_MIN = 1;
const PHONE_REPLACEMENT_LIMIT_MAX = 20;
function syncLatestState(state) { latestState = { ...latestState, ...state }; }
function syncAutoRunState() {}
function syncPasswordField() {}
@@ -347,6 +410,12 @@ function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) ||
function normalizeAutoDelayMinutes(value) { return Number(value) || 30; }
function formatAutoStepDelayInputValue(value) { return value == null ? '' : String(value); }
function normalizeVerificationResendCount(value, fallback) { return Number(value) || fallback; }
function normalizeHeroSmsMaxPriceValue(value = '') { return String(value || '').trim(); }
function normalizePhoneVerificationReplacementLimit(value, fallback = 3) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return fallback;
return Math.max(PHONE_REPLACEMENT_LIMIT_MIN, Math.min(PHONE_REPLACEMENT_LIMIT_MAX, Math.floor(numeric)));
}
function normalizeHeroSmsCountryId() { return 52; }
function getSelectedHeroSmsCountryOption() { return { label: 'Thailand' }; }
function updateHeroSmsPlatformDisplay() {}
@@ -201,6 +201,7 @@ const inputAutoDelayMinutes = { value: '30' };
const inputAutoStepDelaySeconds = { value: '' };
const inputVerificationResendCount = { value: '4' };
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
const DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT = 3;
function getCloudflareDomainsFromState() {
return { domains: [], activeDomain: '' };
}
@@ -0,0 +1,39 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
test('sidepanel password inputs expose visibility toggles', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
const passwordInputIds = Array.from(
html.matchAll(/<input\b[^>]*type="password"[^>]*id="([^"]+)"/g),
(match) => match[1]
);
const legacyToggleIds = new Map([
['input-vps-url', 'btn-toggle-vps-url'],
['input-vps-password', 'btn-toggle-vps-password'],
['input-ip-proxy-username', 'btn-toggle-ip-proxy-username'],
['input-ip-proxy-password', 'btn-toggle-ip-proxy-password'],
['input-ip-proxy-api-url', 'btn-toggle-ip-proxy-api-url'],
['input-password', 'btn-toggle-password'],
]);
assert.ok(passwordInputIds.length > 0);
for (const inputId of passwordInputIds) {
const hasDataToggle = html.includes(`data-password-toggle="${inputId}"`);
const legacyToggleId = legacyToggleIds.get(inputId);
const hasLegacyToggle = legacyToggleId ? html.includes(`id="${legacyToggleId}"`) : false;
assert.equal(
hasDataToggle || hasLegacyToggle,
true,
`${inputId} should have a visibility toggle button`
);
}
});
test('shared form dialog adds visibility toggles for password fields', () => {
const source = fs.readFileSync('sidepanel/form-dialog.js', 'utf8');
assert.match(source, /field\.type === 'password'[\s\S]*data-input-with-icon/);
assert.match(source, /syncPasswordToggleButton\(toggleButton,\s*input,\s*labels\)/);
assert.match(source, /input\.type = input\.type === 'password' \? 'text' : 'password'/);
});
+133
View File
@@ -0,0 +1,133 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
test('sidepanel loads reusable form dialog and paypal manager before sidepanel bootstrap', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
const formDialogIndex = html.indexOf('<script src="form-dialog.js"></script>');
const managerIndex = html.indexOf('<script src="paypal-manager.js"></script>');
const sidepanelIndex = html.indexOf('<script src="sidepanel.js"></script>');
assert.notEqual(formDialogIndex, -1);
assert.notEqual(managerIndex, -1);
assert.notEqual(sidepanelIndex, -1);
assert.ok(formDialogIndex < managerIndex);
assert.ok(managerIndex < sidepanelIndex);
});
test('sidepanel html contains paypal select and add button controls', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
assert.match(html, /id="row-paypal-account"/);
assert.match(html, /id="select-paypal-account"/);
assert.match(html, /id="btn-add-paypal-account"/);
assert.match(html, /id="shared-form-modal"/);
});
test('paypal manager saves a paypal account and selects it immediately', async () => {
const source = fs.readFileSync('sidepanel/paypal-manager.js', 'utf8');
const windowObject = {};
const api = new Function('window', `${source}; return window.SidepanelPayPalManager;`)(windowObject);
let latestState = {
paypalAccounts: [],
currentPayPalAccountId: null,
paypalEmail: '',
paypalPassword: '',
};
const events = [];
const clickHandlers = {};
const changeHandlers = {};
const selectNode = {
innerHTML: '',
value: '',
disabled: false,
addEventListener(type, handler) {
changeHandlers[type] = handler;
},
};
const addButton = {
disabled: false,
addEventListener(type, handler) {
clickHandlers[type] = handler;
},
};
const manager = api.createPayPalManager({
state: {
getLatestState: () => latestState,
syncLatestState(updates) {
latestState = { ...latestState, ...updates };
},
},
dom: {
btnAddPayPalAccount: addButton,
selectPayPalAccount: selectNode,
},
helpers: {
escapeHtml: (value) => String(value || ''),
getPayPalAccounts: (state) => Array.isArray(state?.paypalAccounts) ? state.paypalAccounts : [],
openFormDialog: async () => ({ email: 'user@example.com', password: 'secret' }),
showToast(message, tone) {
events.push({ type: 'toast', message, tone });
},
},
runtime: {
sendMessage: async (message) => {
events.push({ type: 'message', message });
if (message.type === 'UPSERT_PAYPAL_ACCOUNT') {
return {
ok: true,
account: {
id: 'pp-1',
email: 'user@example.com',
password: 'secret',
},
};
}
if (message.type === 'SELECT_PAYPAL_ACCOUNT') {
return {
ok: true,
account: {
id: 'pp-1',
email: 'user@example.com',
password: 'secret',
},
};
}
throw new Error(`unexpected message ${message.type}`);
},
},
paypalUtils: {
upsertPayPalAccountInList(accounts, nextAccount) {
const list = Array.isArray(accounts) ? accounts.slice() : [];
const existingIndex = list.findIndex((account) => account.id === nextAccount.id);
if (existingIndex >= 0) {
list[existingIndex] = nextAccount;
return list;
}
list.push(nextAccount);
return list;
},
},
});
manager.bindPayPalEvents();
manager.renderPayPalAccounts();
assert.match(selectNode.innerHTML, /请先添加 PayPal 账号/);
clickHandlers.click();
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
assert.deepStrictEqual(
events.filter((event) => event.type === 'message').map((event) => event.message.type),
['UPSERT_PAYPAL_ACCOUNT', 'SELECT_PAYPAL_ACCOUNT']
);
assert.equal(latestState.currentPayPalAccountId, 'pp-1');
assert.equal(latestState.paypalEmail, 'user@example.com');
assert.equal(latestState.paypalPassword, 'secret');
assert.equal(selectNode.value, 'pp-1');
assert.equal(selectNode.disabled, false);
assert.match(events.at(-1)?.message || '', /已保存 PayPal 账号/);
});
@@ -56,41 +56,125 @@ test('sidepanel html exposes phone verification toggle and dedicated HeroSMS row
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
assert.match(html, /id="row-phone-verification-enabled"/);
assert.match(html, /id="btn-toggle-phone-verification-section"/);
assert.match(html, /id="row-phone-verification-fold"/);
assert.match(html, /id="input-phone-verification-enabled"/);
assert.match(html, /id="row-hero-sms-platform"/);
assert.match(html, /id="row-hero-sms-country"/);
assert.match(html, /id="row-hero-sms-country-fallback"/);
assert.match(html, /id="row-hero-sms-acquire-priority"/);
assert.match(html, /id="select-hero-sms-acquire-priority"/);
assert.match(html, /id="select-hero-sms-country"[^>]*multiple/);
assert.doesNotMatch(html, /id="select-hero-sms-country-fallback"/);
assert.match(html, /id="row-hero-sms-api-key"/);
assert.match(html, /id="row-hero-sms-max-price"/);
assert.match(html, /id="row-hero-sms-current-number"/);
assert.match(html, /id="row-hero-sms-price-tiers"/);
assert.match(html, /id="row-hero-sms-current-code"/);
assert.match(html, /id="row-phone-replacement-limit"/);
assert.match(html, /id="row-phone-verification-resend-count"/);
assert.match(html, /id="row-phone-code-wait-seconds"/);
assert.match(html, /id="row-phone-code-timeout-windows"/);
assert.match(html, /id="row-phone-code-poll-interval-seconds"/);
assert.match(html, /id="row-phone-code-poll-max-rounds"/);
assert.doesNotMatch(html, /id="input-account-run-history-text-enabled"/);
});
test('updatePhoneVerificationSettingsUI toggles HeroSMS rows from the sms switch', () => {
const api = new Function(`
const phoneVerificationSectionExpanded = true;
const inputPhoneVerificationEnabled = { checked: false };
const rowPhoneVerificationEnabled = { style: { display: 'none' } };
const rowPhoneVerificationFold = { style: { display: 'none' } };
const btnTogglePhoneVerificationSection = {
disabled: false,
textContent: '',
title: '',
setAttribute: () => {},
};
const rowHeroSmsPlatform = { style: { display: 'none' } };
const rowHeroSmsCountry = { style: { display: 'none' } };
const rowHeroSmsCountryFallback = { style: { display: 'none' } };
const rowHeroSmsAcquirePriority = { style: { display: 'none' } };
const rowHeroSmsApiKey = { style: { display: 'none' } };
const rowHeroSmsMaxPrice = { style: { display: 'none' } };
const rowHeroSmsCurrentNumber = { style: { display: 'none' } };
const rowHeroSmsPriceTiers = { style: { display: 'none' } };
const rowHeroSmsCurrentCode = { style: { display: 'none' } };
const rowPhoneVerificationResendCount = { style: { display: 'none' } };
const rowPhoneReplacementLimit = { style: { display: 'none' } };
const rowPhoneCodeWaitSeconds = { style: { display: 'none' } };
const rowPhoneCodeTimeoutWindows = { style: { display: 'none' } };
const rowPhoneCodePollIntervalSeconds = { style: { display: 'none' } };
const rowPhoneCodePollMaxRounds = { style: { display: 'none' } };
${extractFunction('updatePhoneVerificationSettingsUI')}
return {
rowPhoneVerificationEnabled,
rowPhoneVerificationFold,
btnTogglePhoneVerificationSection,
inputPhoneVerificationEnabled,
rowHeroSmsPlatform,
rowHeroSmsCountry,
rowHeroSmsCountryFallback,
rowHeroSmsAcquirePriority,
rowHeroSmsApiKey,
rowHeroSmsMaxPrice,
rowHeroSmsCurrentNumber,
rowHeroSmsPriceTiers,
rowHeroSmsCurrentCode,
rowPhoneVerificationResendCount,
rowPhoneReplacementLimit,
rowPhoneCodeWaitSeconds,
rowPhoneCodeTimeoutWindows,
rowPhoneCodePollIntervalSeconds,
rowPhoneCodePollMaxRounds,
updatePhoneVerificationSettingsUI,
};
`)();
api.updatePhoneVerificationSettingsUI();
assert.equal(api.rowPhoneVerificationEnabled.style.display, '');
assert.equal(api.rowPhoneVerificationFold.style.display, 'none');
assert.equal(api.btnTogglePhoneVerificationSection.disabled, true);
assert.equal(api.btnTogglePhoneVerificationSection.textContent, '展开设置');
assert.equal(api.rowHeroSmsPlatform.style.display, 'none');
assert.equal(api.rowHeroSmsCountry.style.display, 'none');
assert.equal(api.rowHeroSmsCountryFallback.style.display, 'none');
assert.equal(api.rowHeroSmsAcquirePriority.style.display, 'none');
assert.equal(api.rowHeroSmsApiKey.style.display, 'none');
assert.equal(api.rowHeroSmsMaxPrice.style.display, 'none');
assert.equal(api.rowHeroSmsCurrentNumber.style.display, 'none');
assert.equal(api.rowHeroSmsPriceTiers.style.display, 'none');
assert.equal(api.rowHeroSmsCurrentCode.style.display, 'none');
assert.equal(api.rowPhoneVerificationResendCount.style.display, 'none');
assert.equal(api.rowPhoneReplacementLimit.style.display, 'none');
assert.equal(api.rowPhoneCodeWaitSeconds.style.display, 'none');
assert.equal(api.rowPhoneCodeTimeoutWindows.style.display, 'none');
assert.equal(api.rowPhoneCodePollIntervalSeconds.style.display, 'none');
assert.equal(api.rowPhoneCodePollMaxRounds.style.display, 'none');
api.inputPhoneVerificationEnabled.checked = true;
api.updatePhoneVerificationSettingsUI();
assert.equal(api.rowPhoneVerificationFold.style.display, '');
assert.equal(api.btnTogglePhoneVerificationSection.disabled, false);
assert.equal(api.btnTogglePhoneVerificationSection.textContent, '收起设置');
assert.equal(api.rowHeroSmsPlatform.style.display, '');
assert.equal(api.rowHeroSmsCountry.style.display, '');
assert.equal(api.rowHeroSmsCountryFallback.style.display, '');
assert.equal(api.rowHeroSmsAcquirePriority.style.display, '');
assert.equal(api.rowHeroSmsApiKey.style.display, '');
assert.equal(api.rowHeroSmsMaxPrice.style.display, '');
assert.equal(api.rowHeroSmsCurrentNumber.style.display, '');
assert.equal(api.rowHeroSmsPriceTiers.style.display, 'none');
assert.equal(api.rowHeroSmsCurrentCode.style.display, '');
assert.equal(api.rowPhoneVerificationResendCount.style.display, '');
assert.equal(api.rowPhoneReplacementLimit.style.display, '');
assert.equal(api.rowPhoneCodeWaitSeconds.style.display, '');
assert.equal(api.rowPhoneCodeTimeoutWindows.style.display, '');
assert.equal(api.rowPhoneCodePollIntervalSeconds.style.display, '');
assert.equal(api.rowPhoneCodePollMaxRounds.style.display, '');
});
test('collectSettingsPayload keeps local helper sync enabled while persisting sms toggle state', () => {
@@ -141,8 +225,35 @@ const inputAutoStepDelaySeconds = { value: '' };
const inputPhoneVerificationEnabled = { checked: true };
const inputVerificationResendCount = { value: '4' };
const inputHeroSmsApiKey = { value: 'demo-key' };
const inputHeroSmsReuseEnabled = { checked: true };
const selectHeroSmsAcquirePriority = { value: 'price' };
const inputHeroSmsMaxPrice = { value: '0.12' };
const inputPhoneReplacementLimit = { value: '5' };
const inputPhoneCodeWaitSeconds = { value: '75' };
const inputPhoneCodeTimeoutWindows = { value: '3' };
const inputPhoneCodePollIntervalSeconds = { value: '6' };
const inputPhoneCodePollMaxRounds = { value: '18' };
const inputAccountRunHistoryHelperBaseUrl = { value: 'http://127.0.0.1:17373' };
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
const DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT = 3;
const DEFAULT_PHONE_CODE_WAIT_SECONDS = 60;
const DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS = 2;
const DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS = 5;
const DEFAULT_PHONE_CODE_POLL_MAX_ROUNDS = 4;
const PHONE_CODE_WAIT_SECONDS_MIN = 15;
const PHONE_CODE_WAIT_SECONDS_MAX = 300;
const PHONE_CODE_TIMEOUT_WINDOWS_MIN = 1;
const PHONE_CODE_TIMEOUT_WINDOWS_MAX = 10;
const PHONE_CODE_POLL_INTERVAL_SECONDS_MIN = 1;
const PHONE_CODE_POLL_INTERVAL_SECONDS_MAX = 30;
const PHONE_CODE_POLL_MAX_ROUNDS_MIN = 1;
const PHONE_CODE_POLL_MAX_ROUNDS_MAX = 120;
const DEFAULT_HERO_SMS_REUSE_ENABLED = true;
const HERO_SMS_ACQUIRE_PRIORITY_COUNTRY = 'country';
const HERO_SMS_ACQUIRE_PRIORITY_PRICE = 'price';
const DEFAULT_HERO_SMS_ACQUIRE_PRIORITY = HERO_SMS_ACQUIRE_PRIORITY_COUNTRY;
const PHONE_REPLACEMENT_LIMIT_MIN = 1;
const PHONE_REPLACEMENT_LIMIT_MAX = 20;
const DEFAULT_HERO_SMS_COUNTRY_ID = 52;
const DEFAULT_HERO_SMS_COUNTRY_LABEL = 'Thailand';
const selectHeroSmsCountry = {
@@ -167,9 +278,20 @@ function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) ||
function normalizeAutoDelayMinutes(value) { return Number(value) || 30; }
function normalizeAutoStepDelaySeconds(value) { return value === '' ? null : Number(value); }
function normalizeVerificationResendCount(value, fallback) { return Number(value) || fallback; }
${extractFunction('normalizeHeroSmsMaxPriceValue')}
${extractFunction('normalizePhoneVerificationReplacementLimit')}
${extractFunction('normalizePhoneCodeWaitSecondsValue')}
${extractFunction('normalizePhoneCodeTimeoutWindowsValue')}
${extractFunction('normalizePhoneCodePollIntervalSecondsValue')}
${extractFunction('normalizePhoneCodePollMaxRoundsValue')}
${extractFunction('normalizeHeroSmsReuseEnabledValue')}
${extractFunction('normalizeHeroSmsAcquirePriority')}
${extractFunction('normalizeHeroSmsCountryId')}
${extractFunction('normalizeHeroSmsCountryLabel')}
${extractFunction('getSelectedHeroSmsCountryOption')}
function syncHeroSmsFallbackSelectionOrderFromSelect() {
return [{ id: 52, label: 'Thailand' }, { id: 16, label: 'United Kingdom' }];
}
${extractFunction('collectSettingsPayload')}
return { collectSettingsPayload };
`)(normalizeIcloudTargetMailboxType, normalizeIcloudForwardMailProvider);
@@ -180,6 +302,15 @@ return { collectSettingsPayload };
assert.equal(payload.accountRunHistoryTextEnabled, true);
assert.equal(payload.accountRunHistoryHelperBaseUrl, 'http://127.0.0.1:17373');
assert.equal(payload.heroSmsApiKey, 'demo-key');
assert.equal(payload.heroSmsReuseEnabled, true);
assert.equal(payload.heroSmsAcquirePriority, 'price');
assert.equal(payload.heroSmsMaxPrice, '0.12');
assert.equal(payload.phoneVerificationReplacementLimit, 5);
assert.equal(payload.phoneCodeWaitSeconds, 75);
assert.equal(payload.phoneCodeTimeoutWindows, 3);
assert.equal(payload.phoneCodePollIntervalSeconds, 6);
assert.equal(payload.phoneCodePollMaxRounds, 18);
assert.equal(payload.heroSmsCountryId, 52);
assert.equal(payload.heroSmsCountryLabel, 'Thailand');
assert.deepStrictEqual(payload.heroSmsCountryFallback, [{ id: 16, label: 'United Kingdom' }]);
});
+3 -2
View File
@@ -68,6 +68,7 @@ test('sidepanel html loads shared step definitions before sidepanel bootstrap',
test('sidepanel html exposes Plus mode and PayPal settings', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
assert.match(html, /id="input-plus-mode-enabled"/);
assert.match(html, /id="input-paypal-email"/);
assert.match(html, /id="input-paypal-password"/);
assert.match(html, /id="select-paypal-account"/);
assert.match(html, /id="btn-add-paypal-account"/);
assert.match(html, /id="shared-form-modal"/);
});
@@ -114,6 +114,7 @@ function broadcastDataUpdate() {}
async function addLog(message) {
logMessages.push(message);
}
async function finalizePhoneActivationAfterSuccessfulFlow() {}
async function finalizeIcloudAliasAfterSuccessfulFlow() {}
function matchesSourceUrlFamily() {
return false;
+231
View File
@@ -0,0 +1,231 @@
const assert = require('assert');
const fs = require('fs');
const step9ModuleSource = fs.readFileSync('background/steps/confirm-oauth.js', 'utf8');
const api = new Function('step9ModuleSource', `
const self = {};
let webNavListener = null;
let webNavCommittedListener = null;
let step8TabUpdatedListener = null;
let step8PendingReject = null;
let cleanupCalls = 0;
let timeoutCalls = 0;
let recoveryCalls = 0;
let completePayload = null;
const logs = [];
const callbackUrl = 'http://localhost:1455/auth/callback?code=abc&state=xyz';
const chrome = {
webNavigation: {
onBeforeNavigate: {
addListener(listener) {
webNavListener = listener;
setTimeout(() => {
if (typeof webNavListener === 'function') {
webNavListener({ tabId: 123, url: callbackUrl });
}
}, 0);
},
removeListener() {},
},
onCommitted: {
addListener(listener) {
webNavCommittedListener = listener;
},
removeListener() {},
},
},
tabs: {
onUpdated: {
addListener(listener) {
step8TabUpdatedListener = listener;
},
removeListener() {},
},
async update() {},
},
};
function cleanupStep8NavigationListeners() {
cleanupCalls += 1;
webNavListener = null;
webNavCommittedListener = null;
step8TabUpdatedListener = null;
}
async function addLog(message) {
logs.push(message);
}
function throwIfStep8SettledOrStopped() {}
async function getTabId() {
return 123;
}
async function isTabAlive() {
return true;
}
async function ensureStep8SignupPageReady() {}
async function waitForStep8Ready() {
return {
consentReady: true,
url: 'https://auth.openai.com/sign-in-with-chatgpt/codex/consent',
};
}
async function triggerStep8ContentStrategy() {
return { success: true };
}
async function waitForStep8ClickEffect() {
return {
progressed: true,
reason: 'url_changed',
url: 'https://auth.openai.com/sign-in-with-chatgpt/codex/consent',
};
}
async function getOAuthFlowStepTimeoutMs(defaultTimeoutMs, options = {}) {
if (options.actionLabel === 'OAuth localhost 回调') {
timeoutCalls += 1;
if (timeoutCalls === 1) {
throw new Error('步骤 9:从拿到 OAuth 登录地址开始,5 分钟内未完成OAuth localhost 回调,结束当前链路,准备从步骤 7 重新开始。');
}
}
return defaultTimeoutMs;
}
async function recoverOAuthLocalhostTimeout(details = {}) {
recoveryCalls += 1;
return {
...(details.state || {}),
oauthUrl: 'https://auth.openai.com/recovered-oauth',
};
}
function getStep8CallbackUrlFromNavigation(details, signupTabId) {
if (
Number(signupTabId) === Number(details?.tabId)
&& String(details?.url || '').includes('http://localhost:1455/auth/callback')
) {
return details.url;
}
return '';
}
function getStep8CallbackUrlFromTabUpdate() {
return '';
}
function getStep8EffectLabel() {
return 'URL 已变化';
}
async function prepareStep8DebuggerClick() {
return { rect: { centerX: 10, centerY: 10 } };
}
async function clickWithDebugger() {}
async function reloadStep8ConsentPage() {}
async function reuseOrCreateTab() { return 123; }
async function sleepWithStop() {}
function setWebNavListener(listener) { webNavListener = listener; }
function getWebNavListener() { return webNavListener; }
function setWebNavCommittedListener(listener) { webNavCommittedListener = listener; }
function getWebNavCommittedListener() { return webNavCommittedListener; }
function setStep8TabUpdatedListener(listener) { step8TabUpdatedListener = listener; }
function getStep8TabUpdatedListener() { return step8TabUpdatedListener; }
function setStep8PendingReject(handler) { step8PendingReject = handler; }
async function completeStepFromBackground(step, payload) {
completePayload = { step, payload };
}
const STEP8_CLICK_RETRY_DELAY_MS = 200;
const STEP8_READY_WAIT_TIMEOUT_MS = 30000;
const STEP8_MAX_ROUNDS = 2;
const STEP8_STRATEGIES = [
{ mode: 'content', strategy: 'requestSubmit', label: 'form.requestSubmit' },
{ mode: 'debugger', label: 'debugger click' },
];
${step9ModuleSource}
const executor = self.MultiPageBackgroundStep9.createStep9Executor({
addLog,
chrome,
cleanupStep8NavigationListeners,
clickWithDebugger,
completeStepFromBackground,
ensureStep8SignupPageReady,
getOAuthFlowStepTimeoutMs,
getStep8CallbackUrlFromNavigation,
getStep8CallbackUrlFromTabUpdate,
getStep8EffectLabel,
getTabId,
getWebNavCommittedListener,
getWebNavListener,
getStep8TabUpdatedListener,
isTabAlive,
prepareStep8DebuggerClick,
recoverOAuthLocalhostTimeout,
reloadStep8ConsentPage,
reuseOrCreateTab,
setStep8PendingReject,
setStep8TabUpdatedListener,
setWebNavCommittedListener,
setWebNavListener,
sleepWithStop,
STEP8_CLICK_RETRY_DELAY_MS,
STEP8_MAX_ROUNDS,
STEP8_READY_WAIT_TIMEOUT_MS,
STEP8_STRATEGIES,
throwIfStep8SettledOrStopped,
triggerStep8ContentStrategy,
waitForStep8ClickEffect,
waitForStep8Ready,
});
return {
executeStep9: executor.executeStep9,
snapshot() {
return {
cleanupCalls,
timeoutCalls,
recoveryCalls,
completePayload,
hasPendingReject: Boolean(step8PendingReject),
logs,
};
},
};
`)(step9ModuleSource);
(async () => {
await api.executeStep9({
oauthUrl: 'https://auth.openai.com/original-oauth',
visibleStep: 9,
});
const snapshot = api.snapshot();
assert.strictEqual(snapshot.timeoutCalls, 2, 'step9 should retry timeout budget check after recovery');
assert.strictEqual(snapshot.recoveryCalls, 1, 'step9 should call timeout recovery hook exactly once');
assert.strictEqual(snapshot.cleanupCalls >= 1, true, 'step9 should cleanup navigation listeners');
assert.strictEqual(snapshot.hasPendingReject, false, 'step9 should clear pending reject after completion');
assert.deepStrictEqual(snapshot.completePayload, {
step: 9,
payload: {
localhostUrl: 'http://localhost:1455/auth/callback?code=abc&state=xyz',
},
});
console.log('step9 timeout recovery tests passed');
})().catch((error) => {
console.error(error);
process.exit(1);
});
+71
View File
@@ -1099,6 +1099,77 @@ test('verification flow clicks resend before waiting for the next LuckMail /code
]);
});
test('verification flow notifies onResendRequestedAt when resend is triggered', async () => {
const resendRequestedAtCalls = [];
const stateUpdates = [];
let pollCalls = 0;
const helpers = api.createVerificationFlowHelpers({
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
getState: async () => ({}),
getTabId: async () => 1,
HOTMAIL_PROVIDER: 'hotmail-api',
isStopError: () => false,
LUCKMAIL_PROVIDER: 'luckmail-api',
MAIL_2925_VERIFICATION_INTERVAL_MS: 15000,
MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15,
pollCloudflareTempEmailVerificationCode: async () => ({}),
pollHotmailVerificationCode: async () => ({}),
pollLuckmailVerificationCode: async () => ({}),
sendToContentScript: async (_source, message) => {
if (message.type === 'RESEND_VERIFICATION_CODE') {
return { resent: true };
}
return {};
},
sendToMailContentScriptResilient: async (_mail, message) => {
if (message.type !== 'POLL_EMAIL') {
return {};
}
pollCalls += 1;
return pollCalls === 1
? {}
: { code: '654321', emailTimestamp: 123 };
},
setState: async (payload) => {
stateUpdates.push(payload);
},
setStepStatus: async () => {},
sleepWithStop: async () => {},
throwIfStopped: () => {},
VERIFICATION_POLL_MAX_ROUNDS: 5,
});
await helpers.resolveVerificationStep(
8,
{
email: 'user@example.com',
lastLoginCode: null,
},
{ provider: 'qq', label: 'QQ 邮箱' },
{
maxResendRequests: 1,
resendIntervalMs: 25000,
onResendRequestedAt: async (requestedAt) => {
resendRequestedAtCalls.push(Number(requestedAt) || 0);
},
}
);
assert.equal(resendRequestedAtCalls.length >= 1, true);
assert.equal(resendRequestedAtCalls[0] > 0, true);
assert.equal(
stateUpdates.some((payload) => Number(payload?.loginVerificationRequestedAt) > 0),
true
);
});
test('verification flow uses resilient signup-page transport when submitting verification code', async () => {
const resilientCalls = [];
+5 -15
View File
@@ -23,7 +23,6 @@
- 轮询登录验证码
- 自动确认 OAuth 同意页
- 把 localhost 回调提交到 CPA、SUB2API 或 Codex2API
- 可选开启 Plus 模式:先完成 Plus Checkout、账单地址、PayPal 登录授权与订阅回跳确认,再复用 OAuth 后半段链路
## 2. 核心运行参与者
@@ -45,7 +44,7 @@
- 在日志区通过“记录”按钮打开独立的邮箱记录覆盖层,并展示成功/失败/停止/重试统计与分页列表
- 查询 GitHub Releases 并展示更新卡片;当前更新服务会区分 `Ultra`、历史 `Pro` 与 legacy `v` 三个版本族,排序时固定以 `Ultra` 为最高正式系列,同时会在读取缓存后重新排序,避免历史 `Pro``v` 版本误显示为比 `Ultra` 更新
- 展示一个单独的“接码”开关;开启后才展示 HeroSMS 的接码国家与 API Key 设置,用于 OAuth 登录链路命中手机号验证页时直接续跑手机验证
- 展示 `Plus 模式` 开关与 PayPal 账号/密码配置;开启后步骤列表切换为 Plus 模式 13 步定义,普通模式的 Cookie 清理步骤不再显示或执行,登录验证码步骤会移动到 Plus 可见第 11 步
- 展示 `Plus 模式` 开关与 PayPal 账号配置;开启后 PayPal 配置行会切换为“账号下拉框 + 添加按钮”,添加按钮复用公共表单弹窗录入账号和密码;步骤列表切换为 Plus 模式 13 步定义,普通模式的 Cookie 清理步骤不再显示或执行,登录验证码步骤会移动到 Plus 可见第 11 步
- 为 Hotmail / 2925 账号池复用同一套“添加账号 / 取消添加 / 批量导入 / 收起列表”表单交互;共享的显隐控制放在 `sidepanel/account-pool-ui.js`,各自 manager 只保留 provider 相关字段校验与业务操作
### 2.2 Background Service Worker
@@ -118,7 +117,6 @@
- 步骤顺序靠 `order`
- 步骤文件名靠语义
- 新增步骤时不需要重命名后续文件
- 普通模式使用 10 步定义;Plus 模式使用 13 步定义,其中 Plus 可见步骤 10/11/12/13 复用原 OAuth 登录、登录验证码、OAuth 同意页与平台回调验证执行器,但按 Plus 可见步骤编号记录状态
## 4. 状态与存储链路
@@ -141,7 +139,6 @@
- 第 8 步固定的验证码页显示邮箱 `step8VerificationTargetEmail`
- 当前手机号验证激活记录 `currentPhoneActivation`
- 可复用的手机号验证激活记录 `reusablePhoneActivation`
- Plus checkout / PayPal 运行态:`plusCheckoutTabId``plusCheckoutUrl``plusCheckoutCountry``plusCheckoutCurrency``plusBillingCountryText``plusBillingAddress``plusPaypalApprovedAt``plusReturnUrl`
- localhost 回调地址
- 自动运行轮次信息
- 当前自动运行 session 标识 `autoRunSessionId`
@@ -163,7 +160,7 @@
- Codex2API 配置
- IP 代理持久配置:`ipProxyEnabled`、服务商、模式、API 地址、服务商配置快照、账号列表、固定 Host / Port / Protocol / Username / Password、地区参数、session 与自动切换阈值
- Plus 模式开关 `plusModeEnabled`
- PayPal 登录配置 `paypalEmail / paypalPassword`
- PayPal 账号池配置 `paypalAccounts / currentPayPalAccountId`,以及供后台步骤兼容读取的 `paypalEmail / paypalPassword`
- 邮箱 provider 配置
- Hotmail 账号池
- 2925 账号池
@@ -171,8 +168,7 @@
- 2925 当前选中的号池账号 ID `currentMail2925AccountId`
- Cloudflare / Temp Email 设置
- 接码开关,以及 HeroSMS 的 API Key 与默认国家设置
- iCloud 相关偏好:Host、获取策略、成功后自动删除、目标邮箱类型与转发收码邮箱 provider
- iCloud Hide My Email 别名缓存:`icloudAliasCache` / `icloudAliasCacheAt`,用于在 iCloud 会话或网络上下文短暂波动时回退展示最近可用列表
- iCloud 相关偏好
- LuckMail API 配置
- 自动运行默认配置
- 账号运行历史 `accountRunHistory`(以邮箱为主键,保存该邮箱最近一次状态:成功/失败/停止)
@@ -458,7 +454,6 @@ IP 代理模块在同步、切换、Change、出口探测和自动运行成功
补充:
- HeroSMS 号码当前最多复用 3 次成功注册;超过上限后会清空可复用激活记录,下次重新申请新号码。
- HeroSMS 新号码申请会先查询当前国家与 OpenAI 服务的最低价并携带固定价格参数;如果 `getNumber` 返回 `NO_NUMBERS`,会回退到 `getNumberV2`,后续用 `getStatusV2` 轮询验证码。
- 如果同一个号码在重发短信后 60 秒仍收不到验证码,后台会抛出“回到步骤 7 重新拿新号码”的恢复错误,而不是把当前号码无限重试下去。
### Step 10
@@ -501,7 +496,7 @@ Plus 模式可见步骤:
1. 第 1~5 步:沿用普通注册入口、邮箱、密码、注册验证码、资料填写链路。
2. 第 6 步 `创建 Plus Checkout`:打开已登录 ChatGPT 页面,通过 `/api/auth/session` 读取 accessToken,再请求 `https://chatgpt.com/backend-api/payments/checkout` 创建 `chatgptplusplan` 的 checkout session,并打开 `https://chatgpt.com/checkout/openai_ie/{checkout_session_id}`
3. 第 7 步 `填写账单并提交订阅`:选择 PayPal,生成账单全名,从 `data/address-sources.js` 读取同国家 seed query,触发 checkout 内置 Google 地址推荐,选择推荐项并校验地址第 1 行、城市、州/省、邮编等结构化字段,再点击“订阅”。运行时会按 Stripe iframe 拆分执行:付款方式在 `elements-inner-payment` frame,账单地址在 `elements-inner-address` frameGoogle 推荐在 `elements-inner-autocompl` frame 时单独点击推荐项。
4. 第 8 步 `PayPal 登录与授权`在 PayPal 页面填写 `paypalEmail / paypalPassword`登录前固定等待 1 秒,关闭可见通行密钥提示,点击“同意并继续”。
4. 第 8 步 `PayPal 登录与授权`后台优先读取侧边栏当前选中的 PayPal 账号;为兼容旧链路,也会把该账号同步回 `paypalEmail / paypalPassword`。当页面处于账号输入阶段时,内容脚本会固定对邮箱输入框执行 `focus -> clear -> fill -> blur -> click next`,即使文本框里已经预填了同一个账号,也会重新触发输入事件,避免 PayPal 因跳过重填而停在邮箱页;登录前固定等待 1 秒,关闭可见通行密钥提示,点击“同意并继续”。
5. 第 9 步 `订阅回跳确认`:等待 PayPal 授权后回跳到 ChatGPT / OpenAI 页面,页面加载完成后固定等待 1 秒。
6. 第 10 步:复用原 Step 7 OAuth 登录执行器,但状态和日志按 Plus 可见第 10 步记录。
7. 第 11 步:复用原 Step 8 登录验证码执行器,但状态和日志按 Plus 可见第 11 步记录。
@@ -647,7 +642,6 @@ Plus 模式可见步骤:
- `163``163 VIP``126` 都走同一条“网易网页邮箱”验证码链路。
- sidepanel 只负责切换 provider 与展示登录入口;后台根据 provider 选择对应网页邮箱首页。
- 内容脚本来源统一归类到 `mail-163`,这样 Step 4 / Step 8 继续复用同一套验证码读取与邮件清理逻辑。
- `mail-provider-utils.js` 同时承接 iCloud 转发收码可选 provider 的归一化与入口配置,避免 background / sidepanel 重新复制 QQ、网易和 Gmail 的收码地址、label 与注入脚本。
- `manifest.json` 需要同时覆盖:
- `https://mail.163.com/*`
- `https://webmail.vip.163.com/*`
@@ -742,7 +736,6 @@ Plus 模式可见步骤:
组成:
- [icloud-utils.js](c:/Users/projectf/Downloads/codex注册扩展/icloud-utils.js)
- [mail-provider-utils.js](c:/Users/projectf/Downloads/codex注册扩展/mail-provider-utils.js)
- [content/icloud-mail.js](c:/Users/projectf/Downloads/codex注册扩展/content/icloud-mail.js)
配置:
@@ -804,7 +797,6 @@ Hide My Email 获取与管理链路:
- UI 层不能凭输入值直接判断“代理已接管”,只能展示后台返回的 `ipProxyApplied*` 状态。
- 新增代理服务商时,应优先新增 provider 规则模块,并让共享解析/运行态继续走 `background/ip-proxy-core.js`
- 修改代理字段、权限或链路时,需要同步更新 [docs/ip-proxy-module.md](c:/Users/projectf/Downloads/codex注册扩展/docs/ip-proxy-module.md)、当前完整链路说明和结构说明。
## 8. 自动运行完整链路
文件:
@@ -820,11 +812,9 @@ Hide My Email 获取与管理链路:
- 如果当前 `Mail = 自定义邮箱` 且配置了 `customMailProviderPool`,会先按当前目标轮次把号池中的对应邮箱写回运行态
- 如果当前生成方式是 `custom-pool`,会先按当前目标轮次把邮箱池中的对应邮箱写回运行态
5. 执行 `runAutoSequenceFromStep`
- 自动运行会按当前 `plusModeEnabled` 选择普通 10 步或 Plus 13 步可见步骤;Plus 模式下第 6~9 步走 checkout / PayPal,第 10~13 步复用 OAuth 后半段
- 步骤 7 内部仍保留登录态恢复的有限重试,但 `add-phone / 手机号页` 属于立即跳出的不可重试错误
- 步骤 8 若在验证码提交后进入 `add-phone / 手机号页`,会直接抛出 fatal 错误,不再先标记步骤成功
- 普通模式一旦进入步骤 7~10,遇到普通报错且认证流程未进入 `add-phone`,则自动回到步骤 7 无限重开
- Plus 模式在 checkout / PayPal 结束后,如果 OAuth 后半段遇到普通报错且认证流程未进入 `add-phone`,则自动回到 Plus 可见步骤 10 重新开始授权链路
- 一旦进入步骤 7~10,遇到普通报错且认证流程未进入 `add-phone`,则自动回到步骤 7 无限重开
- 如果命中 `add-phone / 手机号页` 这类 fatal 错误,则不会再做当前轮的内部重试;当开启自动重试/跳过失败时,会直接结束当前轮并继续下一轮,而不是把整条自动流程暂停
- 如果是手动停止,则立即退出自动流程,不会再触发“回到步骤 7 重开”
6. 如果失败,根据设置决定:
+14 -5
View File
@@ -29,6 +29,7 @@
- `mail-provider-utils.js`:网页邮箱 provider 配置纯工具,负责 `163 / 163 VIP / 126 / QQ / Inbucket / Hotmail` 的基础归一化与页面入口配置,并统一承接 iCloud 转发收码目标邮箱 provider 的归一化、选项列表和收码入口配置。
- `mail2925-utils.js`:2925 账号池相关的纯工具函数,负责账号归一化、冷却期判断、可用账号挑选、批量导入解析与列表更新。
- `managed-alias-utils.js`:共享 Gmail / 2925 的别名邮箱规则,负责解析基邮箱、校验“当前完整注册邮箱”是否仍与基邮箱兼容、生成 Gmail `+tag` 与 2925 随机后缀邮箱,并输出 sidepanel 可复用的 UI 文案;当前还统一承接 `mail2925Mode` 的归一化与“2925 仅在 provide 模式下参与别名生成”的共享判定。
- `paypal-utils.js`:PayPal 账号池相关的纯工具函数,负责账号归一化、按 ID 查找和列表 upsert。
- `manifest.json`:Chrome 扩展清单,声明权限、背景脚本、侧边栏、内容脚本与规则集;当前额外声明 `proxy / webRequest / webRequestAuthProvider`,用于 IP 代理 PAC 接管和代理鉴权回填。
- `microsoft-email.js`Microsoft Graph / Outlook 邮件读取辅助模块,负责刷新令牌换 token、邮箱夹轮询和验证码提取。
- `package.json`:仓库最小 Node 包配置,目前主要提供测试脚本定义。
@@ -50,8 +51,9 @@
- `background/ip-proxy-provider-711proxy.js`711Proxy provider 规则模块,负责从账号串中识别和写回 `region / session / sessTime` 等参数,并在固定账号模式下把侧栏配置转换为最终生效的代理账号。
- `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层;当前额外承接 `add-phone / 手机号页` 这类认证 fatal 错误的共享判定,并会把 Step 2 的“手机号输入模式未切成功”与真正的 auth `add-phone` 页面区分开,避免自动运行误停机。
- `background/mail-2925-session.js`:2925 会话模块,负责 2925 账号池持久化、当前账号切换、cookie 清理登出、自动登录、命中“子邮箱已达上限邮箱”后的 24 小时禁用与自动切号。
- `background/message-router.js`:后台消息路由层,负责处理 `chrome.runtime.onMessage` 进入的所有业务消息;当前额外接入 2925 账号池的新增、导入、切换、登录、禁用与删除消息,以及 IP 代理的同步、切换、Change 与出口检测消息。
- `background/message-router.js`:后台消息路由层,负责处理 `chrome.runtime.onMessage` 进入的所有业务消息;当前额外接入 PayPal 账号池的新增与切换、2925 账号池的新增、导入、切换、登录、禁用与删除消息,以及 IP 代理的同步、切换、Change 与出口检测消息。
- `background/navigation-utils.js`:导航与 URL 判断工具层,负责 callback、入口页、CPA / SUB2API / Codex2API 地址归一化、来源标签页家族判断与步骤跳转相关判断。
- `background/paypal-account-store.js`:PayPal 账号池持久化模块,负责保存账号列表、切换当前账号,并把当前选中账号同步回兼容字段 `paypalEmail / paypalPassword`
- `background/phone-verification-flow.js`:手机号验证共享流程模块,负责在 OAuth 链路命中 `add-phone / phone-verification` 页面后向 HeroSMS 申请或复用号码、轮询短信验证码、提交手机号码与短信验证码,并在号码长期收不到短信时把后续自动流拉回步骤 7 重新拿号。
- `background/panel-bridge.js`:来源桥接层;CPA / SUB2API 继续封装页面打开、脚本注入和通信,Codex2API 则直接通过后台协议生成 OAuth 地址。
- `background/signup-flow-helpers.js`:注册页辅助层,负责打开注册入口、等待密码页以及解析当前流程所用邮箱;在 Gmail 与 `2925 + provide` 模式下会优先复用已经存在且仍兼容的完整注册邮箱,只有不兼容或为空时才重新生成;当 provider 为 2925 且启用了 provide 模式下的号池时,会先确保账号池中已选中可用账号。
@@ -86,7 +88,7 @@
- `content/inbucket-mail.js`:Inbucket 邮箱轮询脚本,负责在 Inbucket 页面中读取、删除验证码邮件。
- `content/mail-163.js`163 / 163 VIP / 126 邮箱轮询脚本,负责网页邮箱验证码读取和邮件清理。
- `content/mail-2925.js`:2925 邮箱页面脚本,负责 2925 邮箱自动登录态确认、收件轮询、按步骤会话隔离“已试验证码”、在每次重发验证码之间执行一轮最多 15 次的邮箱刷新轮询、命中邮件后立即删当前邮件,以及在成功后配合后台执行整箱清理;若页面出现“子邮箱已达上限邮箱”提示,会立即上报后台进入切号链路;当后台显式开启 `mail2925MatchTargetEmail` 时,会对邮件里显式出现的目标邮箱做弱匹配,避免 `receive` 模式误捞别人的验证码。
- `content/paypal-flow.js`:PayPal 页面脚本,负责识别登录表单、填写 PayPal 账号密码、处理页面内可见通行密钥提示,并点击 PayPal 授权页的“同意并继续”按钮。
- `content/paypal-flow.js`:PayPal 页面脚本,负责识别登录表单、填写 PayPal 账号密码、在账号页固定执行 `focus -> clear -> fill -> blur` 重新触发邮箱输入事件、处理页面内可见通行密钥提示,并点击 PayPal 授权页的“同意并继续”按钮。
- `content/phone-auth.js`:认证页手机号验证脚本,负责识别 `add-phone / phone-verification` 页面、选择国家、填写手机号、提交短信验证码、触发重发短信,以及把“回到 add-phone / 进入 OAuth 同意页”的结果反馈给后台。
- `content/plus-checkout.js`ChatGPT Plus checkout 页面脚本,负责读取 `/api/auth/session` 创建 Plus checkout、选择 PayPal、填写账单姓名、触发 Google 地址推荐、校验结构化地址字段并点击订阅。
- `content/qq-mail.js`:QQ 邮箱轮询脚本,负责网页邮箱验证码读取。
@@ -130,6 +132,7 @@
- `sidepanel/icloud-manager.js`:侧边栏 iCloud 隐私邮箱管理器,负责列表、筛选、保留、删除和批量操作。
- `sidepanel/luckmail-manager.js`:侧边栏 LuckMail 管理器,负责邮箱列表、筛选、启停、保留与批量操作。
- `sidepanel/mail-2925-manager.js`:侧边栏 2925 账号池管理器,负责 2925 账号的新增、导入、切换、手动登录、启停、清冷却与删除。
- `sidepanel/form-dialog.js`:侧边栏公共表单弹窗模块,负责渲染可复用的小型表单弹窗,支持动态字段、校验、确认与取消。
- `sidepanel/account-records-manager.js`:侧边栏邮箱记录面板管理器,负责“记录”按钮、覆盖层开关、分页列表、成功/失败/停止统计摘要和清理确认。
- `sidepanel/contribution-content-update-service.js`:侧边栏贡献内容更新服务,负责拉取 `https://apikey.qzz.io/api/content-summary`、缓存公开公告/教程摘要,并输出可用于提示展示的 `promptVersion` 与最新更新时间。
- `sidepanel/contribution-mode.js`:侧边栏贡献模式管理器,负责顶部“贡献”按钮、确认弹窗、贡献模式显隐、复用主自动流程启动、侧栏内贡献状态轮询、上传页跳转,以及贡献模式下对来源选择、配置入口、记录入口和敏感配置行(包括 Codex2API 配置)的禁用与隐藏。
@@ -144,7 +147,8 @@
receive 模式把账号池开关一起隐藏;当前在 `邮箱生成` 区域新增 `自定义邮箱池` 选项和多行邮箱池输入框,并在 `邮箱服务 = 自定义邮箱`
额外显示 `自定义号池` 文本框;当邮箱服务为 iCloud 时,额外提供目标邮箱类型与转发邮箱 provider 配置,用于选择直接从 iCloud 收件箱收
码或从 QQ / 网易 / Gmail 转发目标邮箱收码;来源下拉框当前支持 `CPA / SUB2API / Codex2API`,其中 Codex2API 额外提供后台地址和管理密
钥配置行;设置卡片新增 `IP代理` 开关与代理配置折叠区,支持 711Proxy 账号密码模式、代理状态卡与出口检测按钮;设置卡片新增 `Plus 模式` 开关与 PayPal 账号/密码输入行Hotmail / 2925 两个账号池当前都使用统一的头部“添加账号/取消添加”按钮和共享表单容器。
钥配置行;设置卡片新增 `IP代理` 开关与代理配置折叠区,支持 711Proxy 账号密码模式、代理状态卡与出口检测按钮;设置卡片新增 `Plus 模式` 开关与 PayPal 账号下拉框,右侧使用公共表单弹窗添加账号Hotmail / 2925 两个账号池当前都使用统一的头部“添加账号/取消添加”按钮和共享表单容器。
- `sidepanel/paypal-manager.js`:侧边栏 PayPal 账号管理器,负责 Plus 模式下的账号下拉框渲染、添加账号弹窗、保存账号与切换当前账号。
- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、共享验证码自动重发次数配置与广播接收,并装
配 Hotmail / 2925 / iCloud / LuckMail / 贡献模式 / 邮箱记录面板 / 贡献内容更新服务;当前贡献模式的“开始贡献”会直接复用主自动流程启
动逻辑,而独立 manager 负责贡献运行态展示与轮询,同时把 Gmail / 2925 的基邮箱输入、完整注册邮箱输入、自动生成按钮与兼容性校验统一接
@@ -152,8 +156,9 @@
示并同时服务于 provide / receive 两种模式;当 provider 为 iCloud 时,会保存并回显目标邮箱类型与转发邮箱 provider,相关 provider 选
项和 label 复用 `mail-provider-utils.js``自定义邮箱池` 模式会按邮箱池长度锁定自动轮数,并把当前输入的邮箱池配置参与自动启动前保
存;`邮箱服务 = 自定义邮箱` 时,如果配置了 `customMailProviderPool`,也会按号池长度锁定自动轮数并在 Auto 中按轮次分配注册邮箱,同时
在普通失败时继续复用当前邮箱,只有成功或出现手机号验证时才切换下一个邮箱;Plus 模式开启后,步骤列表会切换为 13 步,并显示 PayPal
据配置;Step 8 的自定义邮箱确认弹窗当前额外提供“出现手机号验证”按钮,用于直接走与真实 add-phone 一致的 fatal 分支;HeroSMS 国家列表
在普通失败时继续复用当前邮箱,只有成功或出现手机号验证时才切换下一个邮箱;Plus 模式开启后,步骤列表会切换为 13 步,并显示 PayPal
号下拉框与添加入口;当前选中的 PayPal 账号会同步回兼容字段 `paypalEmail / paypalPassword` 供后台步骤复用;Step 8 的自定义邮箱确认弹
窗当前额外提供“出现手机号验证”按钮,用于直接走与真实 add-phone 一致的 fatal 分支;HeroSMS 国家列表
会在 sidepanel 初始化时拉取并恢复到本地保存的国家/API 设置,接码开关关闭时会直接隐藏相关配置;账号记录快照同步改为默认自动模式,只
要本地 helper 可用就会自动落盘;新来源 Codex2API 在这里仅补充来源配置接线、表单显隐和 Step 10 按钮文案,不承接协议业务逻辑;
Hotmail / 2925 的新增表单显隐统一接到 `sidepanel/account-pool-ui.js` 这一层共享 helper;IP 代理配置保存、服务配置快照、开关应用和运行态回显由这里接入消息路由,具体解析/状态渲染下沉到 `sidepanel/ip-proxy-panel.js`;侧边栏初始化与点击“自动”前会刷新一次贡献站
@@ -189,6 +194,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-paypal-account-store-module.test.js`:测试 PayPal 账号池后台模块已接入、导出工厂,并覆盖当前账号选择会同步回兼容字段 `paypalEmail / paypalPassword`
- `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,并仅跳过其中未完成且未运行的步骤。
@@ -202,6 +208,8 @@
- `tests/background-verification-flow-module.test.js`:测试验证码流程模块已接入且导出工厂。
- `tests/phone-auth-country-match.test.js`:测试认证页手机号国家选择在页面本地化时,仍能用 HeroSMS 的英文国家名匹配对应国家选项。
- `tests/phone-verification-flow.test.js`:测试手机号验证共享流程对 HeroSMS 的取号、复用、重发、换号与 Step 7 重开错误分支。
- `tests/paypal-approve-detection.test.js`:测试 Plus 第 8 步后台执行器对 PayPal 标签页发现、分离式账号/密码页识别、联合登录页识别,以及登录后直接离开 PayPal 页的分支判断。
- `tests/paypal-flow-content.test.js`:测试 PayPal 内容脚本对可见邮箱/密码输入框的识别,并覆盖邮箱页即使已预填相同账号也会先清空再重填后继续下一步。
- `tests/plus-checkout-billing-tab-resolution.test.js`:测试 Plus 第 7 步可直接接管当前 checkout 标签页,并把 PayPal、账单地址、Google 地址推荐和订阅按钮操作路由到对应 Stripe iframe。
- `tests/cloudflare-temp-email-provider.test.js`:测试 Cloudflare Temp Email provider 的轮询与目标邮箱选择逻辑。
- `tests/cloudflare-temp-email-utils.test.js`:测试 Cloudflare Temp Email 工具层的 URL、域名、邮件解析逻辑。
@@ -229,6 +237,7 @@
- `tests/sidepanel-luckmail-manager.test.js`:测试侧边栏 LuckMail 管理器模块接线与空态渲染。
- `tests/sidepanel-mail2925-manager.test.js`:测试侧边栏 2925 管理器模块接线、共享号池表单脚本加载顺序、显隐交互与空态渲染。
- `tests/sidepanel-mail2925-mode.test.js`:测试侧边栏保留 `2925 provide / receive` 模式行与独立的号池配置行,并验证 sidepanel 只在 provide 模式下把 2925 视为别名邮箱 provider。
- `tests/sidepanel-paypal-manager.test.js`:测试侧边栏公共表单弹窗脚本与 PayPal manager 的加载顺序,以及 PayPal 账号保存后会立即选中当前账号。
- `tests/signup-entry-diagnostics.test.js`:测试注册入口诊断快照输出。
- `tests/signup-step2-email-switch.test.js`:测试 Step 2 在手机号输入模式下切回邮箱输入模式,以及本地化邮箱输入框识别。
- `tests/signup-page-tab-cleanup.test.js`:测试注册页来源标签的冲突清理逻辑。