Merge commit 'f18e6951cd147a3839df51837f3cb296502febfa' into dev
# Conflicts: # sidepanel/sidepanel.js
This commit is contained in:
@@ -492,13 +492,27 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。
|
||||
|
||||
在登录前会先重新获取一遍最新的 CPA OAuth 链接,再使用刚注册的账号登录。
|
||||
|
||||
当前 Step 6 的完成标准不是“邮箱/密码已提交”,而是:
|
||||
|
||||
- 认证页已经真正进入登录验证码页面
|
||||
- 如遇登录超时报错或登录页长时间停滞,会由后台刷新 OAuth 后重跑整个 Step 6
|
||||
|
||||
支持:
|
||||
|
||||
- 邮箱 + 密码登录
|
||||
- 提交后进入验证码验证流程
|
||||
- 必要时切换到一次性验证码登录
|
||||
- 直到登录验证码页就绪才算步骤完成
|
||||
|
||||
### Step 7: Get Login Code
|
||||
|
||||
Step 7 默认要求当前认证页已经处于登录验证码页。
|
||||
|
||||
它只负责:
|
||||
|
||||
- 打开邮箱并轮询登录验证码
|
||||
- 填写并提交登录验证码
|
||||
- 验证码链路失败后按有限次数回退到 Step 6
|
||||
|
||||
与 Step 4 类似,但会使用稍微不同的关键词组合去找登录验证码邮件。
|
||||
|
||||
### Step 8: Manual OAuth Confirm
|
||||
|
||||
+744
-114
File diff suppressed because it is too large
Load Diff
+567
-144
@@ -11,7 +11,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
|| message.type === 'STEP8_FIND_AND_CLICK'
|
||||
|| message.type === 'STEP8_GET_STATE'
|
||||
|| message.type === 'STEP8_TRIGGER_CONTINUE'
|
||||
|| message.type === 'PREPARE_LOGIN_CODE'
|
||||
|| message.type === 'GET_LOGIN_AUTH_STATE'
|
||||
|| message.type === 'PREPARE_SIGNUP_VERIFICATION'
|
||||
|| message.type === 'RESEND_VERIFICATION_CODE'
|
||||
) {
|
||||
@@ -52,10 +52,10 @@ async function handleCommand(message) {
|
||||
case 'FILL_CODE':
|
||||
// Step 4 = signup code, Step 7 = login code (same handler)
|
||||
return await fillVerificationCode(message.step, message.payload);
|
||||
case 'GET_LOGIN_AUTH_STATE':
|
||||
return serializeLoginAuthState(inspectLoginAuthState());
|
||||
case 'PREPARE_SIGNUP_VERIFICATION':
|
||||
return await prepareSignupVerificationFlow(message.payload);
|
||||
case 'PREPARE_LOGIN_CODE':
|
||||
return await prepareLoginCodeFlow();
|
||||
case 'RESEND_VERIFICATION_CODE':
|
||||
return await resendVerificationCode(message.step);
|
||||
case 'STEP8_FIND_AND_CLICK':
|
||||
@@ -176,86 +176,9 @@ function isEmailVerificationPage() {
|
||||
return /\/email-verification(?:[/?#]|$)/i.test(location.pathname || '');
|
||||
}
|
||||
|
||||
async function prepareLoginCodeFlow(timeout = 15000) {
|
||||
const readyTarget = getVerificationCodeTarget();
|
||||
if (readyTarget) {
|
||||
log('步骤 7:验证码输入框已就绪。');
|
||||
return { ready: true, mode: readyTarget.type };
|
||||
}
|
||||
|
||||
if (isEmailVerificationPage() && isVerificationPageStillVisible()) {
|
||||
log('步骤 7:已进入邮箱验证码页面,正在等待验证码输入框或重发入口稳定。');
|
||||
return { ready: true, mode: 'verification_page' };
|
||||
}
|
||||
|
||||
const initialRestartSignal = getStep7RestartFromStep6Signal();
|
||||
if (initialRestartSignal) {
|
||||
log('步骤 7:检测到登录页超时报错,准备回到步骤 6 重新发起登录验证码流程...', 'warn');
|
||||
return initialRestartSignal;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
let switchClickCount = 0;
|
||||
let lastSwitchAttemptAt = 0;
|
||||
let loggedPasswordPage = false;
|
||||
let loggedVerificationPage = false;
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
throwIfStopped();
|
||||
|
||||
const target = getVerificationCodeTarget();
|
||||
if (target) {
|
||||
log('步骤 7:验证码页面已就绪。');
|
||||
return { ready: true, mode: target.type };
|
||||
}
|
||||
|
||||
if (isEmailVerificationPage() && isVerificationPageStillVisible()) {
|
||||
if (!loggedVerificationPage) {
|
||||
loggedVerificationPage = true;
|
||||
log('步骤 7:页面已进入邮箱验证码流程,继续等待验证码输入框渲染...');
|
||||
}
|
||||
await sleep(250);
|
||||
continue;
|
||||
}
|
||||
|
||||
const restartSignal = getStep7RestartFromStep6Signal();
|
||||
if (restartSignal) {
|
||||
log('步骤 7:检测到登录页超时报错,准备回到步骤 6 重新发起登录验证码流程...', 'warn');
|
||||
return restartSignal;
|
||||
}
|
||||
|
||||
const passwordInput = document.querySelector('input[type="password"]');
|
||||
const switchTrigger = findOneTimeCodeLoginTrigger();
|
||||
|
||||
if (switchTrigger && (switchClickCount === 0 || Date.now() - lastSwitchAttemptAt > 1500)) {
|
||||
switchClickCount += 1;
|
||||
lastSwitchAttemptAt = Date.now();
|
||||
loggedPasswordPage = false;
|
||||
log('步骤 7:检测到密码页,正在切换到一次性验证码登录...');
|
||||
await humanPause(350, 900);
|
||||
const verificationRequestedAt = Date.now();
|
||||
simulateClick(switchTrigger);
|
||||
await sleep(1200);
|
||||
return { ready: true, mode: 'verification_switch', verificationRequestedAt };
|
||||
}
|
||||
|
||||
if (passwordInput && !loggedPasswordPage) {
|
||||
loggedPasswordPage = true;
|
||||
log('步骤 7:正在等待密码页上的一次性验证码登录入口...');
|
||||
}
|
||||
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
throw new Error('无法切换到一次性验证码验证页面。URL: ' + location.href);
|
||||
}
|
||||
|
||||
async function resendVerificationCode(step, timeout = 45000) {
|
||||
if (step === 7) {
|
||||
const prepareResult = await prepareLoginCodeFlow();
|
||||
if (prepareResult?.restartFromStep6) {
|
||||
return prepareResult;
|
||||
}
|
||||
await waitForLoginVerificationPageReady();
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
@@ -730,26 +653,248 @@ function getLoginTimeoutErrorPageState() {
|
||||
});
|
||||
}
|
||||
|
||||
function isSignupPasswordErrorPage() {
|
||||
return Boolean(getSignupPasswordTimeoutErrorPageState());
|
||||
function getLoginEmailInput() {
|
||||
const input = document.querySelector(
|
||||
'input[type="email"], input[name="email"], input[name="username"], input[id*="email"], input[placeholder*="email" i], input[placeholder*="Email"]'
|
||||
);
|
||||
return input && isVisibleElement(input) ? input : null;
|
||||
}
|
||||
|
||||
function buildStep7RestartFromStep6Marker(reason, url = location.href) {
|
||||
return `STEP7_RESTART_FROM_STEP6::${reason || 'unknown'}::${url || ''}`;
|
||||
function getLoginPasswordInput() {
|
||||
const input = document.querySelector('input[type="password"]');
|
||||
return input && isVisibleElement(input) ? input : null;
|
||||
}
|
||||
|
||||
function getStep7RestartFromStep6Signal() {
|
||||
const timeoutPage = getLoginTimeoutErrorPageState();
|
||||
if (!timeoutPage) {
|
||||
return null;
|
||||
function getLoginSubmitButton({ allowDisabled = false } = {}) {
|
||||
const direct = document.querySelector('button[type="submit"], input[type="submit"]');
|
||||
if (direct && isVisibleElement(direct) && (allowDisabled || isActionEnabled(direct))) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
return {
|
||||
error: buildStep7RestartFromStep6Marker('login_timeout_error_page', timeoutPage.url),
|
||||
restartFromStep6: true,
|
||||
reason: 'login_timeout_error_page',
|
||||
url: timeoutPage.url,
|
||||
const candidates = document.querySelectorAll(
|
||||
'button, a, [role="button"], [role="link"], input[type="button"], input[type="submit"]'
|
||||
);
|
||||
return Array.from(candidates).find((el) => {
|
||||
if (!isVisibleElement(el) || (!allowDisabled && !isActionEnabled(el))) return false;
|
||||
const text = getActionText(el);
|
||||
if (!text || ONE_TIME_CODE_LOGIN_PATTERN.test(text)) return false;
|
||||
return /continue|next|submit|sign\s*in|log\s*in|继续|下一步|登录/i.test(text);
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function inspectLoginAuthState() {
|
||||
const retryState = getLoginTimeoutErrorPageState();
|
||||
const verificationTarget = getVerificationCodeTarget();
|
||||
const passwordInput = getLoginPasswordInput();
|
||||
const emailInput = getLoginEmailInput();
|
||||
const switchTrigger = findOneTimeCodeLoginTrigger();
|
||||
const submitButton = getLoginSubmitButton({ allowDisabled: true });
|
||||
const verificationVisible = isVerificationPageStillVisible();
|
||||
const addPhonePage = isAddPhonePageReady();
|
||||
const consentReady = isStep8Ready();
|
||||
const oauthConsentPage = isOAuthConsentPage();
|
||||
const baseState = {
|
||||
state: 'unknown',
|
||||
url: location.href,
|
||||
path: location.pathname || '',
|
||||
retryButton: retryState?.retryButton || null,
|
||||
retryEnabled: Boolean(retryState?.retryEnabled),
|
||||
titleMatched: Boolean(retryState?.titleMatched),
|
||||
detailMatched: Boolean(retryState?.detailMatched),
|
||||
verificationTarget,
|
||||
passwordInput,
|
||||
emailInput,
|
||||
submitButton,
|
||||
switchTrigger,
|
||||
verificationVisible,
|
||||
addPhonePage,
|
||||
oauthConsentPage,
|
||||
consentReady,
|
||||
};
|
||||
|
||||
if (verificationTarget || verificationVisible) {
|
||||
return {
|
||||
...baseState,
|
||||
state: 'verification_page',
|
||||
};
|
||||
}
|
||||
|
||||
if (retryState) {
|
||||
return {
|
||||
...baseState,
|
||||
state: 'login_timeout_error_page',
|
||||
};
|
||||
}
|
||||
|
||||
if (addPhonePage) {
|
||||
return {
|
||||
...baseState,
|
||||
state: 'add_phone_page',
|
||||
};
|
||||
}
|
||||
|
||||
if (oauthConsentPage) {
|
||||
return {
|
||||
...baseState,
|
||||
state: 'oauth_consent_page',
|
||||
};
|
||||
}
|
||||
|
||||
if (passwordInput || switchTrigger) {
|
||||
return {
|
||||
...baseState,
|
||||
state: 'password_page',
|
||||
};
|
||||
}
|
||||
|
||||
if (emailInput) {
|
||||
return {
|
||||
...baseState,
|
||||
state: 'email_page',
|
||||
};
|
||||
}
|
||||
|
||||
return baseState;
|
||||
}
|
||||
|
||||
function serializeLoginAuthState(snapshot) {
|
||||
return {
|
||||
state: snapshot?.state || 'unknown',
|
||||
url: snapshot?.url || location.href,
|
||||
path: snapshot?.path || location.pathname || '',
|
||||
retryEnabled: Boolean(snapshot?.retryEnabled),
|
||||
titleMatched: Boolean(snapshot?.titleMatched),
|
||||
detailMatched: Boolean(snapshot?.detailMatched),
|
||||
hasVerificationTarget: Boolean(snapshot?.verificationTarget),
|
||||
hasPasswordInput: Boolean(snapshot?.passwordInput),
|
||||
hasEmailInput: Boolean(snapshot?.emailInput),
|
||||
hasSubmitButton: Boolean(snapshot?.submitButton),
|
||||
hasSwitchTrigger: Boolean(snapshot?.switchTrigger),
|
||||
verificationVisible: Boolean(snapshot?.verificationVisible),
|
||||
addPhonePage: Boolean(snapshot?.addPhonePage),
|
||||
oauthConsentPage: Boolean(snapshot?.oauthConsentPage),
|
||||
consentReady: Boolean(snapshot?.consentReady),
|
||||
};
|
||||
}
|
||||
|
||||
function getLoginAuthStateLabel(snapshot) {
|
||||
switch (snapshot?.state) {
|
||||
case 'verification_page':
|
||||
return '登录验证码页';
|
||||
case 'password_page':
|
||||
return '密码页';
|
||||
case 'email_page':
|
||||
return '邮箱输入页';
|
||||
case 'login_timeout_error_page':
|
||||
return '登录超时报错页';
|
||||
case 'oauth_consent_page':
|
||||
return 'OAuth 授权页';
|
||||
case 'add_phone_page':
|
||||
return '手机号页';
|
||||
default:
|
||||
return '未知页面';
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForKnownLoginAuthState(timeout = 15000) {
|
||||
const start = Date.now();
|
||||
let snapshot = inspectLoginAuthState();
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
throwIfStopped();
|
||||
snapshot = inspectLoginAuthState();
|
||||
if (snapshot.state !== 'unknown') {
|
||||
return snapshot;
|
||||
}
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function waitForLoginVerificationPageReady(timeout = 10000) {
|
||||
const start = Date.now();
|
||||
let snapshot = inspectLoginAuthState();
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
throwIfStopped();
|
||||
snapshot = inspectLoginAuthState();
|
||||
if (snapshot.state === 'verification_page') {
|
||||
return snapshot;
|
||||
}
|
||||
if (snapshot.state !== 'unknown') {
|
||||
break;
|
||||
}
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`当前未进入登录验证码页面,请先重新完成步骤 6。当前状态:${getLoginAuthStateLabel(snapshot)}。URL: ${snapshot?.url || location.href}`
|
||||
);
|
||||
}
|
||||
|
||||
function createStep6SuccessResult(snapshot, options = {}) {
|
||||
return {
|
||||
step6Outcome: 'success',
|
||||
state: snapshot?.state || 'verification_page',
|
||||
url: snapshot?.url || location.href,
|
||||
via: options.via || '',
|
||||
loginVerificationRequestedAt: options.loginVerificationRequestedAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
function createStep6RecoverableResult(reason, snapshot, options = {}) {
|
||||
return {
|
||||
step6Outcome: 'recoverable',
|
||||
reason,
|
||||
state: snapshot?.state || 'unknown',
|
||||
url: snapshot?.url || location.href,
|
||||
message: options.message || '',
|
||||
loginVerificationRequestedAt: options.loginVerificationRequestedAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
function throwForStep6FatalState(snapshot) {
|
||||
switch (snapshot?.state) {
|
||||
case 'oauth_consent_page':
|
||||
throw new Error(`当前页面已进入 OAuth 授权页,未经过登录验证码页,无法完成步骤 6。URL: ${snapshot.url}`);
|
||||
case 'add_phone_page':
|
||||
throw new Error(`当前页面已进入手机号页面,未经过登录验证码页,无法完成步骤 6。URL: ${snapshot.url}`);
|
||||
case 'unknown':
|
||||
throw new Error(`无法识别当前登录页面状态。URL: ${snapshot?.url || location.href}`);
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerLoginSubmitAction(button, fallbackField) {
|
||||
const form = button?.form || fallbackField?.form || button?.closest?.('form') || fallbackField?.closest?.('form') || null;
|
||||
|
||||
await humanPause(400, 1100);
|
||||
if (button && isActionEnabled(button)) {
|
||||
simulateClick(button);
|
||||
return;
|
||||
}
|
||||
|
||||
if (form && typeof form.requestSubmit === 'function') {
|
||||
if (button && button.form === form) {
|
||||
form.requestSubmit(button);
|
||||
} else {
|
||||
form.requestSubmit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (button && typeof button.click === 'function') {
|
||||
button.click();
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('未找到可用的登录提交按钮。URL: ' + location.href);
|
||||
}
|
||||
|
||||
function isSignupPasswordErrorPage() {
|
||||
return Boolean(getSignupPasswordTimeoutErrorPageState());
|
||||
}
|
||||
|
||||
function isSignupEmailAlreadyExistsPage() {
|
||||
@@ -922,10 +1067,7 @@ async function fillVerificationCode(step, payload) {
|
||||
log(`步骤 ${step}:正在填写验证码:${code}`);
|
||||
|
||||
if (step === 7) {
|
||||
const prepareResult = await prepareLoginCodeFlow();
|
||||
if (prepareResult?.restartFromStep6) {
|
||||
return prepareResult;
|
||||
}
|
||||
await waitForLoginVerificationPageReady();
|
||||
}
|
||||
|
||||
// Find code input — could be a single input or multiple separate inputs
|
||||
@@ -986,63 +1128,344 @@ async function fillVerificationCode(step, payload) {
|
||||
// Step 6: Login with registered account (on OAuth auth page)
|
||||
// ============================================================
|
||||
|
||||
async function waitForStep6EmailSubmitTransition(emailSubmittedAt, timeout = 12000) {
|
||||
const start = Date.now();
|
||||
let snapshot = inspectLoginAuthState();
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
throwIfStopped();
|
||||
snapshot = inspectLoginAuthState();
|
||||
|
||||
if (snapshot.state === 'verification_page') {
|
||||
return {
|
||||
action: 'done',
|
||||
result: createStep6SuccessResult(snapshot, {
|
||||
via: 'email_submit',
|
||||
loginVerificationRequestedAt: emailSubmittedAt,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (snapshot.state === 'password_page') {
|
||||
return { action: 'password', snapshot };
|
||||
}
|
||||
|
||||
if (snapshot.state === 'login_timeout_error_page') {
|
||||
return {
|
||||
action: 'recoverable',
|
||||
result: createStep6RecoverableResult('login_timeout_error_page', snapshot, {
|
||||
message: '提交邮箱后进入登录超时报错页。',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (snapshot.state === 'oauth_consent_page') {
|
||||
throw new Error(`提交邮箱后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`);
|
||||
}
|
||||
|
||||
if (snapshot.state === 'add_phone_page') {
|
||||
throw new Error(`提交邮箱后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`);
|
||||
}
|
||||
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
snapshot = inspectLoginAuthState();
|
||||
if (snapshot.state === 'verification_page') {
|
||||
return {
|
||||
action: 'done',
|
||||
result: createStep6SuccessResult(snapshot, {
|
||||
via: 'email_submit',
|
||||
loginVerificationRequestedAt: emailSubmittedAt,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (snapshot.state === 'password_page') {
|
||||
return { action: 'password', snapshot };
|
||||
}
|
||||
if (snapshot.state === 'login_timeout_error_page') {
|
||||
return {
|
||||
action: 'recoverable',
|
||||
result: createStep6RecoverableResult('login_timeout_error_page', snapshot, {
|
||||
message: '提交邮箱后进入登录超时报错页。',
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (snapshot.state === 'oauth_consent_page') {
|
||||
throw new Error(`提交邮箱后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`);
|
||||
}
|
||||
if (snapshot.state === 'add_phone_page') {
|
||||
throw new Error(`提交邮箱后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`);
|
||||
}
|
||||
|
||||
return {
|
||||
action: 'recoverable',
|
||||
result: createStep6RecoverableResult('email_submit_stalled', snapshot, {
|
||||
message: '提交邮箱后长时间未进入密码页或登录验证码页。',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForStep6PasswordSubmitTransition(passwordSubmittedAt, timeout = 10000) {
|
||||
const start = Date.now();
|
||||
let snapshot = inspectLoginAuthState();
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
throwIfStopped();
|
||||
snapshot = inspectLoginAuthState();
|
||||
|
||||
if (snapshot.state === 'verification_page') {
|
||||
return {
|
||||
action: 'done',
|
||||
result: createStep6SuccessResult(snapshot, {
|
||||
via: 'password_submit',
|
||||
loginVerificationRequestedAt: passwordSubmittedAt,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (snapshot.state === 'login_timeout_error_page') {
|
||||
return {
|
||||
action: 'recoverable',
|
||||
result: createStep6RecoverableResult('login_timeout_error_page', snapshot, {
|
||||
message: '提交密码后进入登录超时报错页。',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (snapshot.state === 'oauth_consent_page') {
|
||||
throw new Error(`提交密码后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`);
|
||||
}
|
||||
|
||||
if (snapshot.state === 'add_phone_page') {
|
||||
throw new Error(`提交密码后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`);
|
||||
}
|
||||
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
snapshot = inspectLoginAuthState();
|
||||
if (snapshot.state === 'verification_page') {
|
||||
return {
|
||||
action: 'done',
|
||||
result: createStep6SuccessResult(snapshot, {
|
||||
via: 'password_submit',
|
||||
loginVerificationRequestedAt: passwordSubmittedAt,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (snapshot.state === 'login_timeout_error_page') {
|
||||
return {
|
||||
action: 'recoverable',
|
||||
result: createStep6RecoverableResult('login_timeout_error_page', snapshot, {
|
||||
message: '提交密码后进入登录超时报错页。',
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (snapshot.state === 'oauth_consent_page') {
|
||||
throw new Error(`提交密码后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`);
|
||||
}
|
||||
if (snapshot.state === 'add_phone_page') {
|
||||
throw new Error(`提交密码后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`);
|
||||
}
|
||||
if (snapshot.state === 'password_page' && snapshot.switchTrigger) {
|
||||
return { action: 'switch', snapshot };
|
||||
}
|
||||
|
||||
return {
|
||||
action: 'recoverable',
|
||||
result: createStep6RecoverableResult('password_submit_stalled', snapshot, {
|
||||
message: '提交密码后仍未进入登录验证码页。',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForStep6SwitchTransition(loginVerificationRequestedAt, timeout = 10000) {
|
||||
const start = Date.now();
|
||||
let snapshot = inspectLoginAuthState();
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
throwIfStopped();
|
||||
snapshot = inspectLoginAuthState();
|
||||
|
||||
if (snapshot.state === 'verification_page') {
|
||||
return createStep6SuccessResult(snapshot, {
|
||||
via: 'switch_to_one_time_code_login',
|
||||
loginVerificationRequestedAt,
|
||||
});
|
||||
}
|
||||
|
||||
if (snapshot.state === 'login_timeout_error_page') {
|
||||
return createStep6RecoverableResult('login_timeout_error_page', snapshot, {
|
||||
message: '切换到一次性验证码登录后进入登录超时报错页。',
|
||||
});
|
||||
}
|
||||
|
||||
if (snapshot.state === 'oauth_consent_page') {
|
||||
throw new Error(`切换到一次性验证码登录后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`);
|
||||
}
|
||||
|
||||
if (snapshot.state === 'add_phone_page') {
|
||||
throw new Error(`切换到一次性验证码登录后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`);
|
||||
}
|
||||
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
snapshot = inspectLoginAuthState();
|
||||
if (snapshot.state === 'verification_page') {
|
||||
return createStep6SuccessResult(snapshot, {
|
||||
via: 'switch_to_one_time_code_login',
|
||||
loginVerificationRequestedAt,
|
||||
});
|
||||
}
|
||||
if (snapshot.state === 'login_timeout_error_page') {
|
||||
return createStep6RecoverableResult('login_timeout_error_page', snapshot, {
|
||||
message: '切换到一次性验证码登录后进入登录超时报错页。',
|
||||
});
|
||||
}
|
||||
if (snapshot.state === 'oauth_consent_page') {
|
||||
throw new Error(`切换到一次性验证码登录后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`);
|
||||
}
|
||||
if (snapshot.state === 'add_phone_page') {
|
||||
throw new Error(`切换到一次性验证码登录后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`);
|
||||
}
|
||||
|
||||
return createStep6RecoverableResult('one_time_code_switch_stalled', snapshot, {
|
||||
message: '点击一次性验证码登录后仍未进入登录验证码页。',
|
||||
});
|
||||
}
|
||||
|
||||
async function step6SwitchToOneTimeCodeLogin(snapshot) {
|
||||
const switchTrigger = snapshot?.switchTrigger || findOneTimeCodeLoginTrigger();
|
||||
if (!switchTrigger || !isActionEnabled(switchTrigger)) {
|
||||
return createStep6RecoverableResult('missing_one_time_code_trigger', inspectLoginAuthState(), {
|
||||
message: '当前登录页没有可用的一次性验证码登录入口。',
|
||||
});
|
||||
}
|
||||
|
||||
log('步骤 6:已检测到一次性验证码登录入口,准备切换...');
|
||||
const loginVerificationRequestedAt = Date.now();
|
||||
await humanPause(350, 900);
|
||||
simulateClick(switchTrigger);
|
||||
log('步骤 6:已点击一次性验证码登录');
|
||||
await sleep(1200);
|
||||
return waitForStep6SwitchTransition(loginVerificationRequestedAt);
|
||||
}
|
||||
|
||||
async function step6LoginFromPasswordPage(payload, snapshot) {
|
||||
const currentSnapshot = snapshot || inspectLoginAuthState();
|
||||
|
||||
if (currentSnapshot.passwordInput) {
|
||||
if (!payload.password) {
|
||||
throw new Error('登录时缺少密码,步骤 6 无法继续。');
|
||||
}
|
||||
|
||||
log('步骤 6:已进入密码页,准备填写密码...');
|
||||
await humanPause(550, 1450);
|
||||
fillInput(currentSnapshot.passwordInput, payload.password);
|
||||
log('步骤 6:已填写密码');
|
||||
|
||||
await sleep(500);
|
||||
const passwordSubmittedAt = Date.now();
|
||||
await triggerLoginSubmitAction(currentSnapshot.submitButton, currentSnapshot.passwordInput);
|
||||
log('步骤 6:已提交密码');
|
||||
|
||||
const transition = await waitForStep6PasswordSubmitTransition(passwordSubmittedAt);
|
||||
if (transition.action === 'done') {
|
||||
log('步骤 6:已进入登录验证码页面。', 'ok');
|
||||
return transition.result;
|
||||
}
|
||||
if (transition.action === 'recoverable') {
|
||||
log(`步骤 6:${transition.result.message || '提交密码后仍未进入登录验证码页面,准备重新执行步骤 6。'}`, 'warn');
|
||||
return transition.result;
|
||||
}
|
||||
if (transition.action === 'switch') {
|
||||
return step6SwitchToOneTimeCodeLogin(transition.snapshot);
|
||||
}
|
||||
|
||||
return createStep6RecoverableResult('password_submit_unknown', inspectLoginAuthState(), {
|
||||
message: '提交密码后未得到可用的下一步状态。',
|
||||
});
|
||||
}
|
||||
|
||||
if (currentSnapshot.switchTrigger) {
|
||||
return step6SwitchToOneTimeCodeLogin(currentSnapshot);
|
||||
}
|
||||
|
||||
return createStep6RecoverableResult('password_page_unactionable', currentSnapshot, {
|
||||
message: '当前停留在登录页,但没有可提交密码的输入框,也没有一次性验证码登录入口。',
|
||||
});
|
||||
}
|
||||
|
||||
async function step6LoginFromEmailPage(payload, snapshot) {
|
||||
const currentSnapshot = snapshot || inspectLoginAuthState();
|
||||
const emailInput = currentSnapshot.emailInput || getLoginEmailInput();
|
||||
if (!emailInput) {
|
||||
throw new Error('在登录页未找到邮箱输入框。URL: ' + location.href);
|
||||
}
|
||||
|
||||
if ((emailInput.value || '').trim() !== payload.email) {
|
||||
await humanPause(500, 1400);
|
||||
fillInput(emailInput, payload.email);
|
||||
log('步骤 6:已填写邮箱');
|
||||
} else {
|
||||
log('步骤 6:邮箱已在输入框中,准备提交...');
|
||||
}
|
||||
|
||||
await sleep(500);
|
||||
const emailSubmittedAt = Date.now();
|
||||
await triggerLoginSubmitAction(currentSnapshot.submitButton, emailInput);
|
||||
log('步骤 6:已提交邮箱');
|
||||
|
||||
const transition = await waitForStep6EmailSubmitTransition(emailSubmittedAt);
|
||||
if (transition.action === 'done') {
|
||||
log('步骤 6:已进入登录验证码页面。', 'ok');
|
||||
return transition.result;
|
||||
}
|
||||
if (transition.action === 'recoverable') {
|
||||
log(`步骤 6:${transition.result.message || '提交邮箱后仍未进入目标页面,准备重新执行步骤 6。'}`, 'warn');
|
||||
return transition.result;
|
||||
}
|
||||
if (transition.action === 'password') {
|
||||
return step6LoginFromPasswordPage(payload, transition.snapshot);
|
||||
}
|
||||
|
||||
return createStep6RecoverableResult('email_submit_unknown', inspectLoginAuthState(), {
|
||||
message: '提交邮箱后未得到可用的下一步状态。',
|
||||
});
|
||||
}
|
||||
|
||||
async function step6_login(payload) {
|
||||
const { email, password } = payload;
|
||||
const { email } = payload;
|
||||
if (!email) throw new Error('登录时缺少邮箱地址。');
|
||||
|
||||
log(`步骤 6:正在使用 ${email} 登录...`);
|
||||
|
||||
// Wait for email input on the auth page
|
||||
let emailInput = null;
|
||||
try {
|
||||
emailInput = await waitForElement(
|
||||
'input[type="email"], input[name="email"], input[name="username"], input[id*="email"], input[placeholder*="email" i], input[placeholder*="Email"]',
|
||||
15000
|
||||
);
|
||||
} catch {
|
||||
throw new Error('在登录页未找到邮箱输入框。URL: ' + location.href);
|
||||
const snapshot = await waitForKnownLoginAuthState(15000);
|
||||
|
||||
if (snapshot.state === 'verification_page') {
|
||||
log('步骤 6:登录验证码页面已就绪。', 'ok');
|
||||
return createStep6SuccessResult(snapshot, { via: 'already_on_verification_page' });
|
||||
}
|
||||
|
||||
await humanPause(500, 1400);
|
||||
fillInput(emailInput, email);
|
||||
log('步骤 6:邮箱已填写');
|
||||
|
||||
// Submit email
|
||||
await sleep(500);
|
||||
const submitBtn1 = document.querySelector('button[type="submit"]')
|
||||
|| await waitForElementByText('button', /continue|next|submit|继续|下一步/i, 5000).catch(() => null);
|
||||
if (submitBtn1) {
|
||||
await humanPause(400, 1100);
|
||||
simulateClick(submitBtn1);
|
||||
log('步骤 6:邮箱已提交');
|
||||
if (snapshot.state === 'login_timeout_error_page') {
|
||||
log('步骤 6:检测到登录超时报错,准备重新执行步骤 6。', 'warn');
|
||||
return createStep6RecoverableResult('login_timeout_error_page', snapshot, {
|
||||
message: '当前页面处于登录超时报错页。',
|
||||
});
|
||||
}
|
||||
|
||||
await sleep(2000);
|
||||
|
||||
// Check for password field
|
||||
const passwordInput = document.querySelector('input[type="password"]');
|
||||
if (passwordInput) {
|
||||
log('步骤 6:已找到密码输入框,正在填写密码...');
|
||||
await humanPause(550, 1450);
|
||||
fillInput(passwordInput, password);
|
||||
|
||||
await sleep(500);
|
||||
const submitBtn2 = document.querySelector('button[type="submit"]')
|
||||
|| await waitForElementByText('button', /continue|log\s*in|submit|sign\s*in|登录|继续/i, 5000).catch(() => null);
|
||||
// Report complete BEFORE submit in case page navigates
|
||||
reportComplete(6, { needsOTP: true });
|
||||
|
||||
if (submitBtn2) {
|
||||
await humanPause(450, 1200);
|
||||
simulateClick(submitBtn2);
|
||||
log('步骤 6:密码已提交,可能还需要验证码(步骤 7)');
|
||||
}
|
||||
return;
|
||||
if (snapshot.state === 'email_page') {
|
||||
return step6LoginFromEmailPage(payload, snapshot);
|
||||
}
|
||||
|
||||
// No password field — OTP flow
|
||||
log('步骤 6:未发现密码输入框,可能进入验证码流程或自动跳转。');
|
||||
reportComplete(6, { needsOTP: true });
|
||||
if (snapshot.state === 'password_page') {
|
||||
return step6LoginFromPasswordPage(payload, snapshot);
|
||||
}
|
||||
|
||||
throwForStep6FatalState(snapshot);
|
||||
throw new Error(`无法识别当前登录页面状态。URL: ${snapshot?.url || location.href}`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
+69
-32
@@ -26,6 +26,7 @@
|
||||
10. 默认不编译测试。处理完成后提醒用户自行测试。
|
||||
11. 任何 GitHub 评论、回复、感谢留言发出后,AI 都必须立即再读一遍线上实际内容,确认正文不是乱码、不是 `?`、不是编码异常;如果发现异常,必须立刻修正后再继续后续流程。
|
||||
12. 如果流程执行过程中,PR 的实时目标分支被别人改掉,或者不再是 `dev`,AI 必须停止当前合并流程,重新拉取信息后再决定下一步。
|
||||
13. 进入“阶段 2:分析与审查”时,AI 必须先给当前 PR 添加 `审查中` 标签,并在开始正式审查前确认该标签已经在线上生效。
|
||||
|
||||
## 输入要求
|
||||
|
||||
@@ -53,7 +54,7 @@ AI 必须先执行下面这些动作,不能跳步:
|
||||
2. 拉取 PR 元数据:
|
||||
|
||||
```powershell
|
||||
gh pr view <PR_NUMBER> --repo <OWNER/REPO> --json number,title,body,author,baseRefName,headRefName,headRepository,headRepositoryOwner,changedFiles,additions,deletions,commits,files,isDraft,mergeStateStatus,mergeable,state,url
|
||||
gh pr view <PR_NUMBER> --repo <OWNER/REPO> --json number,title,body,author,baseRefName,headRefName,headRepository,headRepositoryOwner,changedFiles,additions,deletions,commits,files,labels,isDraft,mergeStateStatus,mergeable,state,url
|
||||
```
|
||||
|
||||
3. 先根据实时 `baseRefName` 处理目标分支:
|
||||
@@ -107,27 +108,37 @@ git worktree add --detach $TempWorktree origin/pr-<PR_NUMBER>
|
||||
|
||||
AI 分析时,必须完整执行下面这些要求:
|
||||
|
||||
1. 不能只看 PR 描述,必须结合真实 diff 和仓库现有代码上下文一起分析。
|
||||
2. 如果阶段 1 发生过 `master -> dev` 自动转向,分析时只能基于转向后的最新 diff,不得继续引用转向前的 diff 结论。
|
||||
3. 对于高风险文件,必须继续读取改动函数周边逻辑,确认消息流、状态流、配置项、回调流程、页面跳转流程是否一致。
|
||||
4. 必须分析并输出:
|
||||
1. 审查一开始必须先执行:
|
||||
|
||||
```powershell
|
||||
gh pr edit <PR_NUMBER> --add-label "审查中" --repo <OWNER/REPO>
|
||||
```
|
||||
|
||||
补充要求:
|
||||
|
||||
- 打标后必须立刻重新读取一次实时 PR 元数据,确认 `labels` 中已经出现 `审查中`。
|
||||
- 如果仓库里没有 `审查中` 标签、当前账号无权限打标,或者命令执行失败,必须立刻告知用户,不能假装已经开始审查。
|
||||
2. 不能只看 PR 描述,必须结合真实 diff 和仓库现有代码上下文一起分析。
|
||||
3. 如果阶段 1 发生过 `master -> dev` 自动转向,分析时只能基于转向后的最新 diff,不得继续引用转向前的 diff 结论。
|
||||
4. 对于高风险文件,必须继续读取改动函数周边逻辑,确认消息流、状态流、配置项、回调流程、页面跳转流程是否一致。
|
||||
5. 必须分析并输出:
|
||||
- 这个 PR 改了什么
|
||||
- 这些改动是否合理
|
||||
- 是否存在 bug / 风险 / 逻辑冲突
|
||||
- PR 当前是否可直接合并,例如 `mergeable`、`mergeStateStatus`
|
||||
5. 如果发现问题,结论必须按严重级别排序,优先写真正会影响功能、合并或后续维护的问题。
|
||||
6. 如果没有发现明确问题,也要说明剩余风险,例如:
|
||||
6. 如果发现问题,结论必须按严重级别排序,优先写真正会影响功能、合并或后续维护的问题。
|
||||
7. 如果没有发现明确问题,也要说明剩余风险,例如:
|
||||
- 未运行测试
|
||||
- 需要人工验证真实业务接口
|
||||
- 当前仅完成静态分析
|
||||
7. 在准备进入后续合并步骤前,必须再拉取一次实时 PR 元数据,确认:
|
||||
8. 在准备进入后续合并步骤前,必须再拉取一次实时 PR 元数据,确认:
|
||||
- PR 仍然是打开状态
|
||||
- PR 不是 draft
|
||||
- `baseRefName` 仍然是 `dev`
|
||||
|
||||
## 分析后评论规则
|
||||
|
||||
### 有问题时
|
||||
### 有问题 / 审核不通过时
|
||||
|
||||
如果发现需要作者或维护者注意的问题,AI 必须自动发 PR 评论,并且评论格式必须固定如下:
|
||||
|
||||
@@ -144,6 +155,10 @@ AI 分析时,必须完整执行下面这些要求:
|
||||
3. 正文内容要直接写问题,不要再套娃解释“下面是分析结果”。
|
||||
4. 评论内容必须基于实际检查到的问题,不能编。
|
||||
5. 评论发出后,必须立刻回读该评论的线上正文,确认不是乱码;如果有乱码,必须先修正评论,再继续后续动作。
|
||||
6. 如果问题能够明确定位到某个改动文件、代码块或行号,AI 应优先在对应文件的代码 diff / 代码附件上追加行级评论,直接指出问题和期望修改方式。
|
||||
7. 如果结论是“当前不能通过审查”,AI 必须明确写出“需要修改后再继续”,不能只写模糊提醒。
|
||||
8. 如果当前环境支持正式 PR review,且问题足以阻止合并,优先使用带“要求更改(Request changes)”语义的审查,而不是只留普通闲聊式评论。
|
||||
9. 行级评论是对总评论的补充,不得用零散行级评论替代总评论结论。
|
||||
|
||||
### 没问题时
|
||||
|
||||
@@ -158,49 +173,52 @@ AI 分析时,必须完整执行下面这些要求:
|
||||
如果 PR 有问题,但用户明确要求:
|
||||
|
||||
- 不等待 PR 作者修复
|
||||
- 由当前 AI 直接本地处理冲突和问题
|
||||
- 由当前 AI 直接修改 PR 分支代码,处理冲突和问题
|
||||
- 处理完成后继续合并
|
||||
|
||||
则必须进入下面的流程。
|
||||
|
||||
### 阶段 3:临时工作区合并
|
||||
### 阶段 3:临时工作区修复 PR 分支
|
||||
|
||||
禁止在用户当前工作区直接乱合并。
|
||||
|
||||
必须先创建临时 `worktree`,再在临时目录内处理:
|
||||
必须先创建临时 `worktree`,再在临时目录内处理 PR 分支:
|
||||
|
||||
```powershell
|
||||
git fetch origin dev
|
||||
git fetch origin pull/<PR_NUMBER>/head:refs/remotes/origin/pr-<PR_NUMBER>
|
||||
git worktree add <TEMP_WORKTREE> -b pr-<PR_NUMBER>-merge origin/dev
|
||||
git worktree add <TEMP_WORKTREE> -b pr-<PR_NUMBER>-fix origin/pr-<PR_NUMBER>
|
||||
```
|
||||
|
||||
进入临时目录后再执行:
|
||||
进入临时目录后,先把最新 `dev` 合到 PR 分支里,显式暴露冲突:
|
||||
|
||||
```powershell
|
||||
git merge --no-ff --no-commit origin/pr-<PR_NUMBER>
|
||||
git merge --no-ff --no-commit origin/dev
|
||||
```
|
||||
|
||||
处理规则:
|
||||
|
||||
1. 如果阶段 1 发生过 `master -> dev` 自动转向,本阶段必须以 `dev` 为唯一合并基准,不允许再回到 `master` 做本地吸收。
|
||||
2. 如果出现冲突,先解决冲突,再继续检查相关联逻辑。
|
||||
2. 如果出现冲突,先在 PR 分支上解决冲突,再继续检查相关联逻辑。
|
||||
3. 不能只消掉冲突标记就结束,必须继续看是否有设计冲突、状态字段不一致、调用链断裂、配置项名不一致、回调逻辑互相打架的问题。
|
||||
4. 如果 PR 本身逻辑有 bug,而用户又明确要求继续合并,AI 需要直接在本地修正。
|
||||
5. 修正时要清理无用旧代码,避免留下史山。
|
||||
6. 如果改了 SQL,按仓库规则同步本地 MySQL `xzs`。
|
||||
4. 如果 PR 本身逻辑有 bug,而用户又明确要求继续合并,AI 需要直接修改 PR 分支代码并修正。
|
||||
5. 修正完成后,必须把修改提交回 PR 分支并推送到远端;如果没有权限推送到 PR 来源分支,必须立刻停止并明确告知用户,不能假装 PR 已修好。
|
||||
6. 推送完成后,必须重新拉取一次实时 PR 元数据与最新 diff,确认线上 PR 已包含本次修复,再决定是否继续合并。
|
||||
7. 修正时要清理无用旧代码,避免留下史山。
|
||||
8. 如果改了 SQL,按仓库规则同步本地 MySQL `xzs`。
|
||||
|
||||
### 阶段 4:本地修复后的自检清单
|
||||
### 阶段 4:PR 修复推送后的自检清单
|
||||
|
||||
完成临时合并与本地修复后,必须自检下面这些项目:
|
||||
完成 PR 分支修复并推送后,必须自检下面这些项目:
|
||||
|
||||
1. `git status` 中不能再有未处理冲突。
|
||||
2. 相关文件中不能残留冲突标记。
|
||||
3. 新旧逻辑之间不能出现字段名、消息名、步骤编号、状态名不一致。
|
||||
4. 需要检查与本次功能直接相关的上下游代码,不能只改当前冲突文件。
|
||||
5. 要重新查看最终 diff,确认本地修复没有引入明显回归。
|
||||
5. 要重新查看最终线上 diff,确认修复后的 PR 没有引入明显回归。
|
||||
6. 要重新拉取一次实时 PR 元数据,确认该 PR 的 `baseRefName` 仍然是 `dev`;如果不是,停止后续合并并先反馈用户。
|
||||
7. 默认不编译测试,最后提醒用户自行测试。
|
||||
7. 要确认 PR 当前已经回到可继续处理的状态,例如不再是 `draft`,且 `mergeable` / `mergeStateStatus` 没有出现新的阻塞。
|
||||
8. 默认不编译测试,最后提醒用户自行测试。
|
||||
|
||||
## 合并提交信息规则
|
||||
|
||||
@@ -212,6 +230,8 @@ git merge --no-ff --no-commit origin/pr-<PR_NUMBER>
|
||||
|
||||
必须重新分析“最终合并结果到底做了什么”,然后重写提交信息。
|
||||
|
||||
这些规则同样适用于最终执行 `gh pr merge` 时使用的标题与正文。
|
||||
|
||||
### 提交信息要求
|
||||
|
||||
1. 标题必须描述最终落地功能,不是描述 Git 动作。
|
||||
@@ -243,9 +263,23 @@ feat: support SUB2API mode for OAuth generation and callback handling
|
||||
|
||||
## 最终合并与收尾
|
||||
|
||||
如果本地已经解决该 PR 的内容,并且最终代码已经进入 `dev` 分支,则继续执行下面动作。
|
||||
如果 PR 分支上的冲突 / 问题已经修好,并且复检确认该 PR 可以继续合并到 `dev`,则继续执行下面动作。
|
||||
|
||||
### 1. 感谢作者
|
||||
### 1. 合并 PR
|
||||
|
||||
优先直接合并这个 PR,而不是只在本地偷偷吸收代码后手工关闭:
|
||||
|
||||
```powershell
|
||||
gh pr merge <PR_NUMBER> --merge --subject "<TITLE>" --body "<BODY>" --repo <OWNER/REPO>
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
1. `--subject` 与 `--body` 必须遵守上面的“合并提交信息规则”。
|
||||
2. 合并前必须再次确认 PR 仍然是打开状态、目标分支仍然是 `dev`、线上 diff 已包含你刚刚推送的修复。
|
||||
3. 如果合并时发现新的冲突、状态检查阻塞或权限问题,必须停止并把真实阻塞原因反馈给用户。
|
||||
|
||||
### 2. 感谢作者
|
||||
|
||||
此时需要自动给 PR 留一条感谢评论。
|
||||
|
||||
@@ -263,7 +297,7 @@ feat: support SUB2API mode for OAuth generation and callback handling
|
||||
感谢贡献这次改动,核心思路和主体实现已经吸收进 dev 分支了。我这边补了一下合并过程里的冲突和相关修正,后续如果你还有类似改进也欢迎继续提交。
|
||||
```
|
||||
|
||||
### 2. 自动关闭 PR
|
||||
### 3. 自动关闭 PR
|
||||
|
||||
如果 PR 还没有因为合并而自动关闭,则感谢评论发完后,自动关闭该 PR:
|
||||
|
||||
@@ -273,7 +307,7 @@ gh pr close <PR_NUMBER> --repo <OWNER/REPO>
|
||||
|
||||
如果需要,先评论再关闭,不要把感谢遗漏掉。
|
||||
|
||||
### 3. 评论编码复检
|
||||
### 4. 评论编码复检
|
||||
|
||||
无论是问题评论、感谢评论,还是其他直接发到 GitHub 的回复,只要消息已经发出,AI 必须执行一次“发送后复检”:
|
||||
|
||||
@@ -291,11 +325,14 @@ AI 在对当前用户做最终反馈时,至少要说明:
|
||||
3. 是否已经执行 `master -> dev` 自动转向
|
||||
4. 是否发现重复的 `dev` PR
|
||||
5. 是否发现问题
|
||||
6. 是否已经发了 PR 评论
|
||||
7. 是否已经本地合并并修复
|
||||
8. 是否已经关闭 PR
|
||||
9. 如果改了代码但没跑测试,要明确提醒用户测试
|
||||
6. 是否已经给 PR 添加 `审查中` 标签
|
||||
7. 是否已经发了 PR 评论 / 行级代码评论
|
||||
8. 是否已经要求 PR 修改后再继续
|
||||
9. 是否已经修改 PR 分支并推回远端
|
||||
10. 是否已经完成 PR 合并
|
||||
11. 是否已经关闭 PR
|
||||
12. 如果改了代码但没跑测试,要明确提醒用户测试
|
||||
|
||||
## 给 AI 的一句话执行要求
|
||||
|
||||
拿到本文件后,AI 必须按“先真实读取 PR 元数据,先校正目标分支,再拉取最新 diff 做分析,再按用户要求决定是否进入临时合并修复,最后重写提交信息并在完成后感谢作者、关闭 PR”的顺序执行,不能跳步,不能猜,不能偷懒。
|
||||
拿到本文件后,AI 必须按“先真实读取 PR 元数据,先校正目标分支,再拉取最新 diff,在正式审查开始时先给 PR 打上 `审查中` 标签并确认已生效;如果审核不通过,就在对应代码附件上评论并明确要求更改;如果用户要求继续处理冲突和问题,就先修好并推回 PR 分支,再按规则合并 PR,最后感谢作者、关闭 PR”的顺序执行,不能跳步,不能猜,不能偷懒。
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
(function icloudUtilsModule(root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = factory();
|
||||
return;
|
||||
}
|
||||
|
||||
root.IcloudUtils = factory();
|
||||
})(typeof self !== 'undefined' ? self : globalThis, function createIcloudUtils() {
|
||||
function normalizeIcloudHost(rawHost) {
|
||||
const host = String(rawHost || '').trim().toLowerCase();
|
||||
if (!host) return '';
|
||||
if (host === 'icloud.com' || host === 'www.icloud.com' || host === 'setup.icloud.com') return 'icloud.com';
|
||||
if (host === 'icloud.com.cn' || host === 'www.icloud.com.cn' || host === 'setup.icloud.com.cn') return 'icloud.com.cn';
|
||||
return '';
|
||||
}
|
||||
|
||||
function getConfiguredIcloudHostPreference(stateOrValue = '') {
|
||||
const preference = typeof stateOrValue === 'object'
|
||||
? String(stateOrValue?.icloudHostPreference || '').trim().toLowerCase()
|
||||
: String(stateOrValue || '').trim().toLowerCase();
|
||||
if (!preference || preference === 'auto') return '';
|
||||
return normalizeIcloudHost(preference);
|
||||
}
|
||||
|
||||
function getIcloudLoginUrlForHost(host) {
|
||||
const normalizedHost = normalizeIcloudHost(host);
|
||||
if (normalizedHost === 'icloud.com') return 'https://www.icloud.com/';
|
||||
if (normalizedHost === 'icloud.com.cn') return 'https://www.icloud.com.cn/';
|
||||
return '';
|
||||
}
|
||||
|
||||
function getIcloudSetupUrlForHost(host) {
|
||||
const normalizedHost = normalizeIcloudHost(host);
|
||||
if (normalizedHost === 'icloud.com') return 'https://setup.icloud.com/setup/ws/1';
|
||||
if (normalizedHost === 'icloud.com.cn') return 'https://setup.icloud.com.cn/setup/ws/1';
|
||||
return '';
|
||||
}
|
||||
|
||||
function getIcloudHostHintFromMessage(message) {
|
||||
const lower = String(message || '').toLowerCase();
|
||||
if (lower.includes('setup.icloud.com.cn') || lower.includes('www.icloud.com.cn') || lower.includes('icloud.com.cn')) {
|
||||
return 'icloud.com.cn';
|
||||
}
|
||||
if (lower.includes('setup.icloud.com') || lower.includes('www.icloud.com') || lower.includes('icloud.com')) {
|
||||
return 'icloud.com';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeBooleanMap(rawValue = {}) {
|
||||
if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return Object.entries(rawValue).reduce((result, [key, value]) => {
|
||||
const normalizedKey = String(key || '').trim().toLowerCase();
|
||||
if (!normalizedKey) {
|
||||
return result;
|
||||
}
|
||||
result[normalizedKey] = Boolean(value);
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function toNormalizedEmailSet(values = []) {
|
||||
if (values instanceof Set) {
|
||||
return new Set(Array.from(values, (item) => String(item || '').trim().toLowerCase()).filter(Boolean));
|
||||
}
|
||||
if (Array.isArray(values)) {
|
||||
return new Set(values.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean));
|
||||
}
|
||||
if (values && typeof values === 'object') {
|
||||
const normalizedMap = normalizeBooleanMap(values);
|
||||
return new Set(Object.entries(normalizedMap)
|
||||
.filter(([, value]) => value)
|
||||
.map(([email]) => email));
|
||||
}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
function findIcloudAliasArray(node, depth = 0) {
|
||||
if (!node || depth > 4) return null;
|
||||
if (Array.isArray(node)) {
|
||||
return node.some((item) => item && typeof item === 'object') ? node : null;
|
||||
}
|
||||
if (typeof node !== 'object') return null;
|
||||
|
||||
const priorityKeys = ['hmeEmails', 'hmeEmailList', 'hmeList', 'hmes', 'aliases', 'items'];
|
||||
for (const key of priorityKeys) {
|
||||
if (Array.isArray(node[key])) return node[key];
|
||||
}
|
||||
|
||||
for (const value of Object.values(node)) {
|
||||
const nested = findIcloudAliasArray(value, depth + 1);
|
||||
if (nested) return nested;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeIcloudAliasRecord(raw, options = {}) {
|
||||
const usedEmails = toNormalizedEmailSet(options.usedEmails);
|
||||
const preservedEmails = toNormalizedEmailSet(options.preservedEmails);
|
||||
const anonymousId = String(raw?.anonymousId || raw?.id || '').trim();
|
||||
const email = String(
|
||||
raw?.hme
|
||||
|| raw?.email
|
||||
|| raw?.alias
|
||||
|| raw?.address
|
||||
|| raw?.metaData?.hme
|
||||
|| ''
|
||||
).trim().toLowerCase();
|
||||
|
||||
if (!email || !email.includes('@')) return null;
|
||||
|
||||
const label = String(raw?.label || raw?.metaData?.label || '').trim();
|
||||
const note = String(raw?.note || raw?.metaData?.note || '').trim();
|
||||
const state = String(raw?.state || raw?.status || '').trim().toLowerCase();
|
||||
const createdAt = raw?.createTimestamp
|
||||
|| raw?.createTime
|
||||
|| raw?.createdAt
|
||||
|| raw?.createdDate
|
||||
|| null;
|
||||
|
||||
return {
|
||||
anonymousId,
|
||||
email,
|
||||
label,
|
||||
note,
|
||||
state,
|
||||
active: raw?.active !== false && raw?.isActive !== false && state !== 'inactive' && state !== 'deleted',
|
||||
used: usedEmails.has(email),
|
||||
preserved: preservedEmails.has(email),
|
||||
createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeIcloudAliasList(response, options = {}) {
|
||||
const aliases = findIcloudAliasArray(response);
|
||||
if (!aliases) return [];
|
||||
|
||||
return aliases
|
||||
.map((alias) => normalizeIcloudAliasRecord(alias, options))
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => {
|
||||
if (left.active !== right.active) return left.active ? -1 : 1;
|
||||
if (left.used !== right.used) return left.used ? 1 : -1;
|
||||
return String(left.email).localeCompare(String(right.email));
|
||||
});
|
||||
}
|
||||
|
||||
function pickReusableIcloudAlias(aliases = []) {
|
||||
return (Array.isArray(aliases) ? aliases : []).find((alias) => alias?.active && !alias?.used) || null;
|
||||
}
|
||||
|
||||
function findIcloudAliasByEmail(aliases = [], email = '') {
|
||||
const normalizedEmail = String(email || '').trim().toLowerCase();
|
||||
if (!normalizedEmail) return null;
|
||||
return (Array.isArray(aliases) ? aliases : [])
|
||||
.find((alias) => String(alias?.email || '').trim().toLowerCase() === normalizedEmail) || null;
|
||||
}
|
||||
|
||||
return {
|
||||
findIcloudAliasArray,
|
||||
findIcloudAliasByEmail,
|
||||
getConfiguredIcloudHostPreference,
|
||||
getIcloudHostHintFromMessage,
|
||||
getIcloudLoginUrlForHost,
|
||||
getIcloudSetupUrlForHost,
|
||||
normalizeBooleanMap,
|
||||
normalizeIcloudAliasList,
|
||||
normalizeIcloudAliasRecord,
|
||||
normalizeIcloudHost,
|
||||
pickReusableIcloudAlias,
|
||||
toNormalizedEmailSet,
|
||||
};
|
||||
});
|
||||
@@ -8,17 +8,29 @@
|
||||
"alarms",
|
||||
"tabs",
|
||||
"webNavigation",
|
||||
"declarativeNetRequest",
|
||||
"debugger",
|
||||
"storage",
|
||||
"scripting",
|
||||
"activeTab"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://*.icloud.com/*",
|
||||
"https://*.icloud.com.cn/*",
|
||||
"<all_urls>"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"declarative_net_request": {
|
||||
"rule_resources": [
|
||||
{
|
||||
"id": "icloud_headers",
|
||||
"enabled": true,
|
||||
"path": "rules.json"
|
||||
}
|
||||
]
|
||||
},
|
||||
"side_panel": {
|
||||
"default_path": "sidepanel/sidepanel.html"
|
||||
},
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"priority": 1,
|
||||
"action": {
|
||||
"type": "modifyHeaders",
|
||||
"requestHeaders": [
|
||||
{
|
||||
"header": "Origin",
|
||||
"operation": "set",
|
||||
"value": "https://www.icloud.com"
|
||||
},
|
||||
{
|
||||
"header": "Referer",
|
||||
"operation": "set",
|
||||
"value": "https://www.icloud.com/"
|
||||
}
|
||||
]
|
||||
},
|
||||
"condition": {
|
||||
"urlFilter": "|https://*.icloud.com/*",
|
||||
"resourceTypes": ["xmlhttprequest"],
|
||||
"excludedInitiatorDomains": ["apple.com", "icloud.com"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"priority": 1,
|
||||
"action": {
|
||||
"type": "modifyHeaders",
|
||||
"requestHeaders": [
|
||||
{
|
||||
"header": "Origin",
|
||||
"operation": "set",
|
||||
"value": "https://www.icloud.com.cn"
|
||||
},
|
||||
{
|
||||
"header": "Referer",
|
||||
"operation": "set",
|
||||
"value": "https://www.icloud.com.cn/"
|
||||
}
|
||||
]
|
||||
},
|
||||
"condition": {
|
||||
"urlFilter": "|https://*.icloud.com.cn/*",
|
||||
"resourceTypes": ["xmlhttprequest"],
|
||||
"excludedInitiatorDomains": ["apple.com.cn", "icloud.com.cn"]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -877,6 +877,163 @@ header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icloud-card {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--bg-base) 84%, transparent);
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.icloud-summary {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.icloud-login-help {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--orange) 26%, var(--border));
|
||||
background: color-mix(in srgb, var(--orange-soft) 62%, var(--bg-base));
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.icloud-login-help-main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.icloud-login-help-title {
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.icloud-login-help-text {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.icloud-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icloud-search {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.icloud-filter {
|
||||
flex: 0 0 130px;
|
||||
}
|
||||
|
||||
.icloud-bulkbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.icloud-select-all {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.icloud-bulk-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.icloud-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.icloud-item {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-base);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.icloud-item-check {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.icloud-item-main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.icloud-item-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.icloud-item-email {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.icloud-item-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.icloud-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.icloud-tag.used {
|
||||
background: var(--orange-soft);
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
.icloud-tag.active {
|
||||
background: var(--green-soft);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.icloud-empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.data-select {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
|
||||
@@ -176,6 +176,7 @@
|
||||
<select id="select-email-generator" class="data-select">
|
||||
<option value="duck">DuckDuckGo</option>
|
||||
<option value="cloudflare">Cloudflare</option>
|
||||
<option value="icloud">iCloud 隐私邮箱</option>
|
||||
<option value="cloudflare-temp-email">Cloudflare Temp Email</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -346,6 +347,66 @@
|
||||
<div id="hotmail-accounts-list" class="hotmail-accounts-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="icloud-section" class="data-card hotmail-card" style="display:none;">
|
||||
<div class="section-mini-header">
|
||||
<div class="section-mini-copy">
|
||||
<span class="section-label">iCloud 隐私邮箱</span>
|
||||
</div>
|
||||
<div class="section-mini-actions">
|
||||
<button id="btn-icloud-refresh" class="btn btn-ghost btn-xs" type="button">刷新</button>
|
||||
<button id="btn-icloud-delete-used" class="btn btn-ghost btn-xs" type="button">删除已用</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-label">iCloud</span>
|
||||
<select id="select-icloud-host-preference" class="data-select">
|
||||
<option value="auto">自动</option>
|
||||
<option value="icloud.com">iCloud.com</option>
|
||||
<option value="icloud.com.cn">iCloud.com.cn</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-label">自动删除</span>
|
||||
<label class="option-toggle" for="checkbox-auto-delete-icloud">
|
||||
<input type="checkbox" id="checkbox-auto-delete-icloud" />
|
||||
<span>成功使用后自动删除 iCloud 别名</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="icloud-card">
|
||||
<div id="icloud-summary" class="icloud-summary">加载你的 iCloud Hide My Email 别名以便在这里管理。</div>
|
||||
<div id="icloud-login-help" class="icloud-login-help" style="display:none;">
|
||||
<div class="icloud-login-help-main">
|
||||
<div id="icloud-login-help-title" class="icloud-login-help-title">需要登录 iCloud</div>
|
||||
<div id="icloud-login-help-text" class="icloud-login-help-text">我已经为你打开 iCloud 登录页。请在那个页面完成登录,然后回到这里点击“我已登录”。</div>
|
||||
</div>
|
||||
<button id="btn-icloud-login-done" class="btn btn-primary btn-xs" type="button">我已登录</button>
|
||||
</div>
|
||||
<div class="icloud-toolbar">
|
||||
<input id="input-icloud-search" class="data-input icloud-search" type="text" placeholder="搜索邮箱 / 标签 / 备注" />
|
||||
<select id="select-icloud-filter" class="data-select icloud-filter">
|
||||
<option value="all">全部</option>
|
||||
<option value="active">可用</option>
|
||||
<option value="used">已用</option>
|
||||
<option value="unused">未用</option>
|
||||
<option value="preserved">保留</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="icloud-bulkbar">
|
||||
<label class="option-toggle icloud-select-all" for="checkbox-icloud-select-all">
|
||||
<input type="checkbox" id="checkbox-icloud-select-all" />
|
||||
<span id="icloud-selection-summary">已选 0 个</span>
|
||||
</label>
|
||||
<div class="icloud-bulk-actions">
|
||||
<button id="btn-icloud-bulk-used" class="btn btn-outline btn-xs" type="button">标记已用</button>
|
||||
<button id="btn-icloud-bulk-unused" class="btn btn-outline btn-xs" type="button">标记未用</button>
|
||||
<button id="btn-icloud-bulk-preserve" class="btn btn-outline btn-xs" type="button">保留</button>
|
||||
<button id="btn-icloud-bulk-unpreserve" class="btn btn-outline btn-xs" type="button">取消保留</button>
|
||||
<button id="btn-icloud-bulk-delete" class="btn btn-outline btn-xs" type="button">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="icloud-list" class="icloud-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="status-bar" class="status-bar">
|
||||
<div class="status-dot"></div>
|
||||
<span id="display-status">就绪</span>
|
||||
|
||||
+573
-7
@@ -81,6 +81,26 @@ const selectTempEmailDomain = document.getElementById('select-temp-email-domain'
|
||||
const inputTempEmailDomain = document.getElementById('input-temp-email-domain');
|
||||
const btnTempEmailDomainMode = document.getElementById('btn-temp-email-domain-mode');
|
||||
const hotmailSection = document.getElementById('hotmail-section');
|
||||
const icloudSection = document.getElementById('icloud-section');
|
||||
const icloudSummary = document.getElementById('icloud-summary');
|
||||
const icloudList = document.getElementById('icloud-list');
|
||||
const icloudLoginHelp = document.getElementById('icloud-login-help');
|
||||
const icloudLoginHelpTitle = document.getElementById('icloud-login-help-title');
|
||||
const icloudLoginHelpText = document.getElementById('icloud-login-help-text');
|
||||
const btnIcloudLoginDone = document.getElementById('btn-icloud-login-done');
|
||||
const btnIcloudRefresh = document.getElementById('btn-icloud-refresh');
|
||||
const btnIcloudDeleteUsed = document.getElementById('btn-icloud-delete-used');
|
||||
const selectIcloudHostPreference = document.getElementById('select-icloud-host-preference');
|
||||
const checkboxAutoDeleteIcloud = document.getElementById('checkbox-auto-delete-icloud');
|
||||
const inputIcloudSearch = document.getElementById('input-icloud-search');
|
||||
const selectIcloudFilter = document.getElementById('select-icloud-filter');
|
||||
const checkboxIcloudSelectAll = document.getElementById('checkbox-icloud-select-all');
|
||||
const icloudSelectionSummary = document.getElementById('icloud-selection-summary');
|
||||
const btnIcloudBulkUsed = document.getElementById('btn-icloud-bulk-used');
|
||||
const btnIcloudBulkUnused = document.getElementById('btn-icloud-bulk-unused');
|
||||
const btnIcloudBulkPreserve = document.getElementById('btn-icloud-bulk-preserve');
|
||||
const btnIcloudBulkUnpreserve = document.getElementById('btn-icloud-bulk-unpreserve');
|
||||
const btnIcloudBulkDelete = document.getElementById('btn-icloud-bulk-delete');
|
||||
const rowHotmailServiceMode = document.getElementById('row-hotmail-service-mode');
|
||||
const hotmailServiceModeButtons = Array.from(document.querySelectorAll('[data-hotmail-service-mode]'));
|
||||
const rowHotmailRemoteBaseUrl = document.getElementById('row-hotmail-remote-base-url');
|
||||
@@ -179,6 +199,11 @@ let settingsSaveInFlight = false;
|
||||
let settingsAutoSaveTimer = null;
|
||||
let cloudflareDomainEditMode = false;
|
||||
let cloudflareTempEmailDomainEditMode = false;
|
||||
let icloudRefreshQueued = false;
|
||||
let lastRenderedIcloudAliases = [];
|
||||
let icloudSelectedEmails = new Set();
|
||||
let icloudSearchTerm = '';
|
||||
let icloudFilterMode = 'all';
|
||||
let modalChoiceResolver = null;
|
||||
let currentModalActions = [];
|
||||
let modalResultBuilder = null;
|
||||
@@ -1039,6 +1064,8 @@ function collectSettingsPayload() {
|
||||
mailProvider: selectMailProvider.value,
|
||||
mail2925Mode: getSelectedMail2925Mode(),
|
||||
emailGenerator: selectEmailGenerator.value,
|
||||
autoDeleteUsedIcloudAlias: checkboxAutoDeleteIcloud?.checked,
|
||||
icloudHostPreference: selectIcloudHostPreference?.value || 'auto',
|
||||
emailPrefix: inputEmailPrefix.value.trim(),
|
||||
inbucketHost: inputInbucketHost.value.trim(),
|
||||
inbucketMailbox: inputInbucketMailbox.value.trim(),
|
||||
@@ -1351,10 +1378,26 @@ function applySettingsState(state) {
|
||||
: '163');
|
||||
selectMailProvider.value = restoredMailProvider;
|
||||
setMail2925Mode(state?.mail2925Mode);
|
||||
const restoredEmailGenerator = String(state?.emailGenerator || '').trim().toLowerCase();
|
||||
selectEmailGenerator.value = ['cloudflare', 'cloudflare-temp-email'].includes(restoredEmailGenerator)
|
||||
? restoredEmailGenerator
|
||||
: 'duck';
|
||||
{
|
||||
const restoredEmailGenerator = String(state?.emailGenerator || '').trim().toLowerCase();
|
||||
if (restoredEmailGenerator === 'icloud') {
|
||||
selectEmailGenerator.value = 'icloud';
|
||||
} else if (restoredEmailGenerator === 'cloudflare') {
|
||||
selectEmailGenerator.value = 'cloudflare';
|
||||
} else if (restoredEmailGenerator === 'cloudflare-temp-email') {
|
||||
selectEmailGenerator.value = 'cloudflare-temp-email';
|
||||
} else {
|
||||
selectEmailGenerator.value = 'duck';
|
||||
}
|
||||
}
|
||||
if (selectIcloudHostPreference) {
|
||||
selectIcloudHostPreference.value = String(state?.icloudHostPreference || '').trim().toLowerCase() === 'icloud.com'
|
||||
? 'icloud.com'
|
||||
: (String(state?.icloudHostPreference || '').trim().toLowerCase() === 'icloud.com.cn' ? 'icloud.com.cn' : 'auto');
|
||||
}
|
||||
if (checkboxAutoDeleteIcloud) {
|
||||
checkboxAutoDeleteIcloud.checked = Boolean(state?.autoDeleteUsedIcloudAlias);
|
||||
}
|
||||
inputEmailPrefix.value = state?.emailPrefix || '';
|
||||
inputInbucketHost.value = state?.inbucketHost || '';
|
||||
inputInbucketMailbox.value = state?.inbucketMailbox || '';
|
||||
@@ -1390,6 +1433,9 @@ async function restoreState() {
|
||||
try {
|
||||
const state = await chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' });
|
||||
applySettingsState(state);
|
||||
if (getSelectedEmailGenerator() === 'icloud' && icloudSection?.style.display !== 'none') {
|
||||
refreshIcloudAliases({ silent: true }).catch(() => { });
|
||||
}
|
||||
|
||||
if (state.oauthUrl) {
|
||||
displayOauthUrl.textContent = state.oauthUrl;
|
||||
@@ -1637,6 +1683,9 @@ function getSelectedEmailGenerator() {
|
||||
if (generator === 'custom' || generator === 'manual') {
|
||||
return 'custom';
|
||||
}
|
||||
if (generator === 'icloud') {
|
||||
return 'icloud';
|
||||
}
|
||||
if (generator === 'cloudflare') return 'cloudflare';
|
||||
if (generator === 'cloudflare-temp-email') return 'cloudflare-temp-email';
|
||||
return 'duck';
|
||||
@@ -1646,6 +1695,14 @@ function getEmailGeneratorUiCopy() {
|
||||
if (getSelectedEmailGenerator() === 'custom') {
|
||||
return getCustomMailProviderUiCopy();
|
||||
}
|
||||
if (getSelectedEmailGenerator() === 'icloud') {
|
||||
return {
|
||||
buttonLabel: '获取',
|
||||
placeholder: '点击获取 iCloud 隐私邮箱,或手动粘贴邮箱',
|
||||
successVerb: '获取',
|
||||
label: 'iCloud 隐私邮箱',
|
||||
};
|
||||
}
|
||||
if (getSelectedEmailGenerator() === 'cloudflare') {
|
||||
return {
|
||||
buttonLabel: '生成',
|
||||
@@ -1982,14 +2039,26 @@ function updateMailProviderUI() {
|
||||
const hotmailServiceMode = getSelectedHotmailServiceMode();
|
||||
rowInbucketHost.style.display = useInbucket ? '' : 'none';
|
||||
rowInbucketMailbox.style.display = useInbucket ? '' : 'none';
|
||||
const useCloudflare = selectEmailGenerator.value === 'cloudflare';
|
||||
const useCloudflareTempEmailGenerator = selectEmailGenerator.value === 'cloudflare-temp-email';
|
||||
const selectedGenerator = getSelectedEmailGenerator();
|
||||
const useCloudflare = selectedGenerator === 'cloudflare';
|
||||
const useIcloud = selectedGenerator === 'icloud';
|
||||
const useCloudflareTempEmailGenerator = selectedGenerator === 'cloudflare-temp-email';
|
||||
const showCloudflareDomain = useEmailGenerator && useCloudflare;
|
||||
const showCloudflareTempEmailSettings = useCloudflareTempEmailProvider || (useEmailGenerator && useCloudflareTempEmailGenerator);
|
||||
const showCloudflareTempEmailDomain = useEmailGenerator && useCloudflareTempEmailGenerator;
|
||||
if (rowEmailGenerator) {
|
||||
rowEmailGenerator.style.display = useEmailGenerator ? '' : 'none';
|
||||
}
|
||||
if (icloudSection) {
|
||||
const showIcloudSection = useEmailGenerator && useIcloud;
|
||||
icloudSection.style.display = showIcloudSection ? '' : 'none';
|
||||
if (showIcloudSection && !lastRenderedIcloudAliases.length) {
|
||||
queueIcloudAliasRefresh();
|
||||
}
|
||||
if (!showIcloudSection) {
|
||||
hideIcloudLoginHelp();
|
||||
}
|
||||
}
|
||||
rowCfDomain.style.display = showCloudflareDomain ? '' : 'none';
|
||||
const { domains } = getCloudflareDomainsFromState();
|
||||
if (showCloudflareDomain) {
|
||||
@@ -2038,7 +2107,7 @@ function updateMailProviderUI() {
|
||||
? '请先校验并选择一个 Hotmail 账号'
|
||||
: (useGeneratedAlias
|
||||
? '步骤 3 会自动生成邮箱,无需手动获取'
|
||||
: (useCustomEmail ? '请先填写自定义注册邮箱,成功一轮后会自动清空' : '先自动获取邮箱,或手动粘贴邮箱后再继续'));
|
||||
: (useCustomEmail ? '请先填写自定义注册邮箱,成功一轮后会自动清空' : `先自动获取${uiCopy.label},或手动粘贴邮箱后再继续`));
|
||||
}
|
||||
if (useHotmail) {
|
||||
inputEmail.value = getCurrentHotmailEmail();
|
||||
@@ -2202,6 +2271,11 @@ function updateButtonStates() {
|
||||
});
|
||||
|
||||
btnReset.disabled = anyRunning || autoScheduled || isAutoRunPausedPhase() || autoLocked;
|
||||
const disableIcloudControls = anyRunning || autoScheduled || autoLocked;
|
||||
if (btnIcloudRefresh) btnIcloudRefresh.disabled = disableIcloudControls;
|
||||
if (btnIcloudDeleteUsed) btnIcloudDeleteUsed.disabled = disableIcloudControls || !(lastRenderedIcloudAliases.some((alias) => alias.used && !alias.preserved));
|
||||
if (selectIcloudHostPreference) selectIcloudHostPreference.disabled = disableIcloudControls;
|
||||
if (checkboxAutoDeleteIcloud) checkboxAutoDeleteIcloud.disabled = disableIcloudControls;
|
||||
updateStopButtonState(anyRunning || autoScheduled || isAutoRunPausedPhase() || autoLocked);
|
||||
}
|
||||
|
||||
@@ -2324,6 +2398,375 @@ function escapeHtml(text) {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function normalizeIcloudSearchText(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getFilteredIcloudAliases(aliases = lastRenderedIcloudAliases) {
|
||||
const searchTerm = normalizeIcloudSearchText(icloudSearchTerm);
|
||||
return (Array.isArray(aliases) ? aliases : []).filter((alias) => {
|
||||
const matchesFilter = (() => {
|
||||
switch (icloudFilterMode) {
|
||||
case 'active': return Boolean(alias.active);
|
||||
case 'used': return Boolean(alias.used);
|
||||
case 'unused': return !alias.used;
|
||||
case 'preserved': return Boolean(alias.preserved);
|
||||
default: return true;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!matchesFilter) return false;
|
||||
if (!searchTerm) return true;
|
||||
|
||||
const haystack = [
|
||||
alias.email,
|
||||
alias.label,
|
||||
alias.note,
|
||||
alias.used ? '已用 used' : '未用 unused',
|
||||
alias.active ? '可用 active' : '不可用 inactive',
|
||||
alias.preserved ? '保留 preserved' : '',
|
||||
].join(' ').toLowerCase();
|
||||
|
||||
return haystack.includes(searchTerm);
|
||||
});
|
||||
}
|
||||
|
||||
function pruneIcloudSelection(aliases = lastRenderedIcloudAliases) {
|
||||
const existing = new Set((Array.isArray(aliases) ? aliases : []).map((alias) => alias.email));
|
||||
icloudSelectedEmails = new Set([...icloudSelectedEmails].filter((email) => existing.has(email)));
|
||||
}
|
||||
|
||||
function updateIcloudBulkUI(visibleAliases = getFilteredIcloudAliases()) {
|
||||
if (!checkboxIcloudSelectAll || !icloudSelectionSummary) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleEmails = visibleAliases.map((alias) => alias.email);
|
||||
const selectedVisibleCount = visibleEmails.filter((email) => icloudSelectedEmails.has(email)).length;
|
||||
const hasVisible = visibleEmails.length > 0;
|
||||
|
||||
checkboxIcloudSelectAll.checked = hasVisible && selectedVisibleCount === visibleEmails.length;
|
||||
checkboxIcloudSelectAll.indeterminate = selectedVisibleCount > 0 && selectedVisibleCount < visibleEmails.length;
|
||||
checkboxIcloudSelectAll.disabled = !hasVisible;
|
||||
icloudSelectionSummary.textContent = `已选 ${icloudSelectedEmails.size} 个(当前显示 ${visibleEmails.length} 个)`;
|
||||
|
||||
const hasSelection = icloudSelectedEmails.size > 0;
|
||||
if (btnIcloudBulkUsed) btnIcloudBulkUsed.disabled = !hasSelection;
|
||||
if (btnIcloudBulkUnused) btnIcloudBulkUnused.disabled = !hasSelection;
|
||||
if (btnIcloudBulkPreserve) btnIcloudBulkPreserve.disabled = !hasSelection;
|
||||
if (btnIcloudBulkUnpreserve) btnIcloudBulkUnpreserve.disabled = !hasSelection;
|
||||
if (btnIcloudBulkDelete) btnIcloudBulkDelete.disabled = !hasSelection;
|
||||
}
|
||||
|
||||
function setIcloudLoadingState(loading, summary = '') {
|
||||
if (btnIcloudRefresh) btnIcloudRefresh.disabled = loading;
|
||||
if (btnIcloudDeleteUsed) btnIcloudDeleteUsed.disabled = loading;
|
||||
if (btnIcloudLoginDone) btnIcloudLoginDone.disabled = loading;
|
||||
if (inputIcloudSearch) inputIcloudSearch.disabled = loading;
|
||||
if (selectIcloudFilter) selectIcloudFilter.disabled = loading;
|
||||
if (checkboxIcloudSelectAll) checkboxIcloudSelectAll.disabled = loading || getFilteredIcloudAliases().length === 0;
|
||||
if (btnIcloudBulkUsed) btnIcloudBulkUsed.disabled = loading || icloudSelectedEmails.size === 0;
|
||||
if (btnIcloudBulkUnused) btnIcloudBulkUnused.disabled = loading || icloudSelectedEmails.size === 0;
|
||||
if (btnIcloudBulkPreserve) btnIcloudBulkPreserve.disabled = loading || icloudSelectedEmails.size === 0;
|
||||
if (btnIcloudBulkUnpreserve) btnIcloudBulkUnpreserve.disabled = loading || icloudSelectedEmails.size === 0;
|
||||
if (btnIcloudBulkDelete) btnIcloudBulkDelete.disabled = loading || icloudSelectedEmails.size === 0;
|
||||
if (summary && icloudSummary) icloudSummary.textContent = summary;
|
||||
}
|
||||
|
||||
function showIcloudLoginHelp(payload = {}) {
|
||||
if (!icloudLoginHelp) return;
|
||||
const loginUrl = String(payload.loginUrl || '').trim();
|
||||
const host = loginUrl ? new URL(loginUrl).host : 'icloud.com.cn / icloud.com';
|
||||
if (icloudLoginHelpTitle) icloudLoginHelpTitle.textContent = '需要登录 iCloud';
|
||||
if (icloudLoginHelpText) icloudLoginHelpText.textContent = `我已经为你打开 ${host}。请在那个页面完成登录,然后回到这里点击“我已登录”。`;
|
||||
icloudLoginHelp.style.display = 'flex';
|
||||
}
|
||||
|
||||
function hideIcloudLoginHelp() {
|
||||
if (icloudLoginHelp) {
|
||||
icloudLoginHelp.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function renderIcloudAliases(aliases = []) {
|
||||
if (!icloudList || !icloudSummary) return;
|
||||
|
||||
lastRenderedIcloudAliases = Array.isArray(aliases) ? aliases : [];
|
||||
pruneIcloudSelection(lastRenderedIcloudAliases);
|
||||
icloudList.innerHTML = '';
|
||||
|
||||
if (!aliases.length) {
|
||||
icloudSelectedEmails.clear();
|
||||
icloudList.innerHTML = '<div class="icloud-empty">未找到 iCloud Hide My Email 别名。</div>';
|
||||
icloudSummary.textContent = '加载你的 iCloud Hide My Email 别名以便在这里管理。';
|
||||
if (btnIcloudDeleteUsed) btnIcloudDeleteUsed.disabled = true;
|
||||
updateIcloudBulkUI([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const usedCount = aliases.filter((alias) => alias.used).length;
|
||||
const deletableUsedCount = aliases.filter((alias) => alias.used && !alias.preserved).length;
|
||||
icloudSummary.textContent = `已加载 ${aliases.length} 个别名,其中 ${usedCount} 个已标记为已用。`;
|
||||
if (btnIcloudDeleteUsed) btnIcloudDeleteUsed.disabled = deletableUsedCount === 0;
|
||||
|
||||
const visibleAliases = getFilteredIcloudAliases(aliases);
|
||||
if (!visibleAliases.length) {
|
||||
icloudList.innerHTML = '<div class="icloud-empty">没有匹配当前筛选条件的别名。</div>';
|
||||
updateIcloudBulkUI([]);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const alias of visibleAliases) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'icloud-item';
|
||||
item.innerHTML = `
|
||||
<input class="icloud-item-check" type="checkbox" data-action="select" ${icloudSelectedEmails.has(alias.email) ? 'checked' : ''} />
|
||||
<div class="icloud-item-main">
|
||||
<div class="icloud-item-email">${escapeHtml(alias.email)}</div>
|
||||
<div class="icloud-item-meta">
|
||||
${alias.used ? '<span class="icloud-tag used">已用</span>' : ''}
|
||||
${!alias.used && alias.active ? '<span class="icloud-tag active">可用</span>' : ''}
|
||||
${alias.preserved ? '<span class="icloud-tag">保留</span>' : ''}
|
||||
${alias.label ? `<span class="icloud-tag">${escapeHtml(alias.label)}</span>` : ''}
|
||||
${alias.note ? `<span class="icloud-tag">${escapeHtml(alias.note)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="icloud-item-actions">
|
||||
<button class="btn btn-outline btn-xs" type="button" data-action="toggle-used">${escapeHtml(alias.used ? '标记未用' : '标记已用')}</button>
|
||||
<button class="btn btn-outline btn-xs" type="button" data-action="toggle-preserved">${escapeHtml(alias.preserved ? '取消保留' : '保留')}</button>
|
||||
<button class="btn btn-outline btn-xs" type="button" data-action="delete">删除</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
item.querySelector('[data-action="select"]').addEventListener('change', (event) => {
|
||||
if (event.target.checked) {
|
||||
icloudSelectedEmails.add(alias.email);
|
||||
} else {
|
||||
icloudSelectedEmails.delete(alias.email);
|
||||
}
|
||||
updateIcloudBulkUI(visibleAliases);
|
||||
});
|
||||
item.querySelector('[data-action="toggle-used"]').addEventListener('click', async () => {
|
||||
await setSingleIcloudAliasUsedState(alias, !alias.used);
|
||||
});
|
||||
item.querySelector('[data-action="toggle-preserved"]').addEventListener('click', async () => {
|
||||
await setSingleIcloudAliasPreservedState(alias, !alias.preserved);
|
||||
});
|
||||
item.querySelector('[data-action="delete"]').addEventListener('click', async () => {
|
||||
await deleteSingleIcloudAlias(alias);
|
||||
});
|
||||
icloudList.appendChild(item);
|
||||
}
|
||||
|
||||
updateIcloudBulkUI(visibleAliases);
|
||||
}
|
||||
|
||||
async function refreshIcloudAliases(options = {}) {
|
||||
const { silent = false } = options;
|
||||
if (!icloudSection || icloudSection.style.display === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!silent) setIcloudLoadingState(true, '正在加载 iCloud 别名...');
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'LIST_ICLOUD_ALIASES',
|
||||
source: 'sidepanel',
|
||||
payload: {},
|
||||
});
|
||||
if (response?.error) throw new Error(response.error);
|
||||
hideIcloudLoginHelp();
|
||||
renderIcloudAliases(response?.aliases || []);
|
||||
} catch (err) {
|
||||
icloudSelectedEmails.clear();
|
||||
if (icloudList) {
|
||||
icloudList.innerHTML = '<div class="icloud-empty">无法加载 iCloud 别名。</div>';
|
||||
}
|
||||
if (icloudSummary) {
|
||||
icloudSummary.textContent = err.message;
|
||||
}
|
||||
updateIcloudBulkUI([]);
|
||||
if (!silent) showToast(`iCloud 别名加载失败:${err.message}`, 'error');
|
||||
} finally {
|
||||
setIcloudLoadingState(false);
|
||||
}
|
||||
}
|
||||
|
||||
function queueIcloudAliasRefresh() {
|
||||
if (icloudRefreshQueued) return;
|
||||
icloudRefreshQueued = true;
|
||||
setTimeout(async () => {
|
||||
icloudRefreshQueued = false;
|
||||
await refreshIcloudAliases({ silent: true });
|
||||
}, 150);
|
||||
}
|
||||
|
||||
async function deleteSingleIcloudAlias(alias) {
|
||||
const confirmed = await openConfirmModal({
|
||||
title: '删除 iCloud 别名',
|
||||
message: `确认删除 ${alias.email} 吗?此操作不可撤销。`,
|
||||
confirmLabel: '确认删除',
|
||||
confirmVariant: 'btn-danger',
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIcloudLoadingState(true, `正在删除 ${alias.email} ...`);
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'DELETE_ICLOUD_ALIAS',
|
||||
source: 'sidepanel',
|
||||
payload: { email: alias.email, anonymousId: alias.anonymousId },
|
||||
});
|
||||
if (response?.error) throw new Error(response.error);
|
||||
showToast(`已删除 ${alias.email}`, 'success', 2200);
|
||||
await refreshIcloudAliases({ silent: true });
|
||||
} catch (err) {
|
||||
if (icloudSummary) icloudSummary.textContent = err.message;
|
||||
showToast(`删除 iCloud 别名失败:${err.message}`, 'error');
|
||||
} finally {
|
||||
setIcloudLoadingState(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function setSingleIcloudAliasUsedState(alias, used) {
|
||||
setIcloudLoadingState(true, `正在更新 ${alias.email} 的使用状态...`);
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'SET_ICLOUD_ALIAS_USED_STATE',
|
||||
source: 'sidepanel',
|
||||
payload: { email: alias.email, used },
|
||||
});
|
||||
if (response?.error) throw new Error(response.error);
|
||||
showToast(`${alias.email} 已${used ? '标记为已用' : '恢复为未用'}`, 'success', 2200);
|
||||
await refreshIcloudAliases({ silent: true });
|
||||
} catch (err) {
|
||||
if (icloudSummary) icloudSummary.textContent = err.message;
|
||||
showToast(`更新 iCloud 使用状态失败:${err.message}`, 'error');
|
||||
} finally {
|
||||
setIcloudLoadingState(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function setSingleIcloudAliasPreservedState(alias, preserved) {
|
||||
setIcloudLoadingState(true, `正在更新 ${alias.email} 的保留状态...`);
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'SET_ICLOUD_ALIAS_PRESERVED_STATE',
|
||||
source: 'sidepanel',
|
||||
payload: { email: alias.email, preserved },
|
||||
});
|
||||
if (response?.error) throw new Error(response.error);
|
||||
showToast(`${alias.email} 已${preserved ? '设为保留' : '取消保留'}`, 'success', 2200);
|
||||
await refreshIcloudAliases({ silent: true });
|
||||
} catch (err) {
|
||||
if (icloudSummary) icloudSummary.textContent = err.message;
|
||||
showToast(`更新 iCloud 保留状态失败:${err.message}`, 'error');
|
||||
} finally {
|
||||
setIcloudLoadingState(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runBulkIcloudAction(action) {
|
||||
const selectedAliases = lastRenderedIcloudAliases.filter((alias) => icloudSelectedEmails.has(alias.email));
|
||||
if (!selectedAliases.length) {
|
||||
updateIcloudBulkUI();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'delete') {
|
||||
const confirmed = await openConfirmModal({
|
||||
title: '批量删除 iCloud 别名',
|
||||
message: `确认删除选中的 ${selectedAliases.length} 个 iCloud 别名吗?此操作不可撤销。`,
|
||||
confirmLabel: '确认删除',
|
||||
confirmVariant: 'btn-danger',
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const actionLabelMap = {
|
||||
used: '标记已用',
|
||||
unused: '标记未用',
|
||||
preserve: '保留',
|
||||
unpreserve: '取消保留',
|
||||
delete: '删除',
|
||||
};
|
||||
setIcloudLoadingState(true, `正在批量${actionLabelMap[action] || '处理'} iCloud 别名...`);
|
||||
|
||||
try {
|
||||
for (const alias of selectedAliases) {
|
||||
let response = null;
|
||||
if (action === 'used' || action === 'unused') {
|
||||
response = await chrome.runtime.sendMessage({
|
||||
type: 'SET_ICLOUD_ALIAS_USED_STATE',
|
||||
source: 'sidepanel',
|
||||
payload: { email: alias.email, used: action === 'used' },
|
||||
});
|
||||
} else if (action === 'preserve' || action === 'unpreserve') {
|
||||
response = await chrome.runtime.sendMessage({
|
||||
type: 'SET_ICLOUD_ALIAS_PRESERVED_STATE',
|
||||
source: 'sidepanel',
|
||||
payload: { email: alias.email, preserved: action === 'preserve' },
|
||||
});
|
||||
} else if (action === 'delete') {
|
||||
response = await chrome.runtime.sendMessage({
|
||||
type: 'DELETE_ICLOUD_ALIAS',
|
||||
source: 'sidepanel',
|
||||
payload: { email: alias.email, anonymousId: alias.anonymousId },
|
||||
});
|
||||
icloudSelectedEmails.delete(alias.email);
|
||||
}
|
||||
|
||||
if (response?.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
}
|
||||
|
||||
showToast(`已批量${actionLabelMap[action] || '处理'} ${selectedAliases.length} 个 iCloud 别名`, 'success', 2400);
|
||||
await refreshIcloudAliases({ silent: true });
|
||||
} catch (err) {
|
||||
if (icloudSummary) icloudSummary.textContent = err.message;
|
||||
showToast(`批量处理 iCloud 别名失败:${err.message}`, 'error');
|
||||
} finally {
|
||||
setIcloudLoadingState(false);
|
||||
updateIcloudBulkUI();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUsedIcloudAliases() {
|
||||
const confirmed = await openConfirmModal({
|
||||
title: '删除已用 iCloud 别名',
|
||||
message: '确认删除所有未保留的已用 iCloud 别名吗?此操作不可撤销。',
|
||||
confirmLabel: '确认删除',
|
||||
confirmVariant: 'btn-danger',
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIcloudLoadingState(true, '正在删除已用 iCloud 别名...');
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'DELETE_USED_ICLOUD_ALIASES',
|
||||
source: 'sidepanel',
|
||||
payload: {},
|
||||
});
|
||||
if (response?.error) throw new Error(response.error);
|
||||
const deleted = response?.deleted || [];
|
||||
const skipped = response?.skipped || [];
|
||||
showToast(`已删除 ${deleted.length} 个已用别名,跳过 ${skipped.length} 个`, skipped.length ? 'warn' : 'success', 2800);
|
||||
await refreshIcloudAliases({ silent: true });
|
||||
} catch (err) {
|
||||
if (icloudSummary) icloudSummary.textContent = err.message;
|
||||
showToast(`删除已用 iCloud 别名失败:${err.message}`, 'error');
|
||||
} finally {
|
||||
setIcloudLoadingState(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGeneratedEmail(options = {}) {
|
||||
const { showFailureToast = true } = options;
|
||||
const uiCopy = getEmailGeneratorUiCopy();
|
||||
@@ -2352,6 +2795,9 @@ async function fetchGeneratedEmail(options = {}) {
|
||||
}
|
||||
|
||||
inputEmail.value = response.email;
|
||||
if (getSelectedEmailGenerator() === 'icloud') {
|
||||
queueIcloudAliasRefresh();
|
||||
}
|
||||
showToast(`已${uiCopy.successVerb} ${uiCopy.label}:${response.email}`, 'success', 2500);
|
||||
return response.email;
|
||||
} catch (err) {
|
||||
@@ -2664,6 +3110,75 @@ btnFetchEmail.addEventListener('click', async () => {
|
||||
await fetchGeneratedEmail().catch(() => { });
|
||||
});
|
||||
|
||||
btnIcloudRefresh?.addEventListener('click', async () => {
|
||||
await refreshIcloudAliases();
|
||||
});
|
||||
|
||||
btnIcloudDeleteUsed?.addEventListener('click', async () => {
|
||||
await deleteUsedIcloudAliases();
|
||||
});
|
||||
|
||||
inputIcloudSearch?.addEventListener('input', () => {
|
||||
icloudSearchTerm = inputIcloudSearch.value || '';
|
||||
renderIcloudAliases(lastRenderedIcloudAliases);
|
||||
});
|
||||
|
||||
selectIcloudFilter?.addEventListener('change', () => {
|
||||
icloudFilterMode = selectIcloudFilter.value || 'all';
|
||||
renderIcloudAliases(lastRenderedIcloudAliases);
|
||||
});
|
||||
|
||||
checkboxIcloudSelectAll?.addEventListener('change', () => {
|
||||
const visibleAliases = getFilteredIcloudAliases();
|
||||
if (checkboxIcloudSelectAll.checked) {
|
||||
visibleAliases.forEach((alias) => icloudSelectedEmails.add(alias.email));
|
||||
} else {
|
||||
visibleAliases.forEach((alias) => icloudSelectedEmails.delete(alias.email));
|
||||
}
|
||||
renderIcloudAliases(lastRenderedIcloudAliases);
|
||||
});
|
||||
|
||||
btnIcloudBulkUsed?.addEventListener('click', async () => {
|
||||
await runBulkIcloudAction('used');
|
||||
});
|
||||
|
||||
btnIcloudBulkUnused?.addEventListener('click', async () => {
|
||||
await runBulkIcloudAction('unused');
|
||||
});
|
||||
|
||||
btnIcloudBulkPreserve?.addEventListener('click', async () => {
|
||||
await runBulkIcloudAction('preserve');
|
||||
});
|
||||
|
||||
btnIcloudBulkUnpreserve?.addEventListener('click', async () => {
|
||||
await runBulkIcloudAction('unpreserve');
|
||||
});
|
||||
|
||||
btnIcloudBulkDelete?.addEventListener('click', async () => {
|
||||
await runBulkIcloudAction('delete');
|
||||
});
|
||||
|
||||
btnIcloudLoginDone?.addEventListener('click', async () => {
|
||||
btnIcloudLoginDone.disabled = true;
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'CHECK_ICLOUD_SESSION',
|
||||
source: 'sidepanel',
|
||||
payload: {},
|
||||
});
|
||||
if (response?.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
hideIcloudLoginHelp();
|
||||
showToast('iCloud 会话已恢复,别名列表已刷新。', 'success', 2600);
|
||||
await refreshIcloudAliases({ silent: true });
|
||||
} catch (err) {
|
||||
showToast(`看起来还没有登录完成:${err.message}`, 'warn', 4200);
|
||||
} finally {
|
||||
btnIcloudLoginDone.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
btnToggleHotmailList?.addEventListener('click', () => {
|
||||
setHotmailListExpanded(!hotmailListExpanded);
|
||||
});
|
||||
@@ -3142,6 +3657,15 @@ btnReset.addEventListener('click', async () => {
|
||||
displayStatus.textContent = '就绪';
|
||||
statusBar.className = 'status-bar';
|
||||
logArea.innerHTML = '';
|
||||
icloudSelectedEmails.clear();
|
||||
lastRenderedIcloudAliases = [];
|
||||
if (icloudList) {
|
||||
icloudList.innerHTML = '';
|
||||
}
|
||||
if (icloudSummary) {
|
||||
icloudSummary.textContent = '加载你的 iCloud Hide My Email 别名以便在这里管理。';
|
||||
}
|
||||
updateIcloudBulkUI([]);
|
||||
document.querySelectorAll('.step-row').forEach(row => row.className = 'step-row');
|
||||
document.querySelectorAll('.step-status').forEach(el => el.textContent = '');
|
||||
setDefaultAutoRunButton();
|
||||
@@ -3266,6 +3790,19 @@ selectEmailGenerator.addEventListener('change', () => {
|
||||
saveSettings({ silent: true }).catch(() => { });
|
||||
});
|
||||
|
||||
selectIcloudHostPreference?.addEventListener('change', () => {
|
||||
markSettingsDirty(true);
|
||||
saveSettings({ silent: true }).catch(() => { });
|
||||
if (getSelectedEmailGenerator() === 'icloud') {
|
||||
queueIcloudAliasRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
checkboxAutoDeleteIcloud?.addEventListener('change', () => {
|
||||
markSettingsDirty(true);
|
||||
saveSettings({ silent: true }).catch(() => { });
|
||||
});
|
||||
|
||||
selectPanelMode.addEventListener('change', () => {
|
||||
updatePanelModeUI();
|
||||
markSettingsDirty(true);
|
||||
@@ -3566,6 +4103,11 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
displayStatus.textContent = '就绪';
|
||||
statusBar.className = 'status-bar';
|
||||
logArea.innerHTML = '';
|
||||
icloudSelectedEmails.clear();
|
||||
lastRenderedIcloudAliases = [];
|
||||
if (icloudList) icloudList.innerHTML = '';
|
||||
if (icloudSummary) icloudSummary.textContent = '加载你的 iCloud Hide My Email 别名以便在这里管理。';
|
||||
updateIcloudBulkUI([]);
|
||||
document.querySelectorAll('.step-row').forEach(row => row.className = 'step-row');
|
||||
document.querySelectorAll('.step-status').forEach(el => el.textContent = '');
|
||||
syncAutoRunState({
|
||||
@@ -3623,6 +4165,15 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
inputEmail.value = getCurrentHotmailEmail();
|
||||
}
|
||||
}
|
||||
if (message.payload.autoDeleteUsedIcloudAlias !== undefined && checkboxAutoDeleteIcloud) {
|
||||
checkboxAutoDeleteIcloud.checked = Boolean(message.payload.autoDeleteUsedIcloudAlias);
|
||||
}
|
||||
if (message.payload.icloudHostPreference !== undefined && selectIcloudHostPreference) {
|
||||
const hostPreference = String(message.payload.icloudHostPreference || '').trim().toLowerCase();
|
||||
selectIcloudHostPreference.value = hostPreference === 'icloud.com'
|
||||
? 'icloud.com'
|
||||
: (hostPreference === 'icloud.com.cn' ? 'icloud.com.cn' : 'auto');
|
||||
}
|
||||
if (message.payload.autoRunSkipFailures !== undefined) {
|
||||
inputAutoSkipFailures.checked = Boolean(message.payload.autoRunSkipFailures);
|
||||
updateFallbackThreadIntervalInputState();
|
||||
@@ -3646,6 +4197,21 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ICLOUD_LOGIN_REQUIRED': {
|
||||
const loginMessage = '需要登录 iCloud,我已经为你打开登录页。';
|
||||
showToast(loginMessage, 'warn', 5000);
|
||||
if (icloudSummary) {
|
||||
icloudSummary.textContent = loginMessage;
|
||||
}
|
||||
showIcloudLoginHelp(message.payload || {});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ICLOUD_ALIASES_CHANGED': {
|
||||
queueIcloudAliasRefresh();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'AUTO_RUN_STATUS': {
|
||||
syncLatestState({
|
||||
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(message.payload.phase),
|
||||
|
||||
@@ -189,6 +189,20 @@ function cancelPendingCommands() {}
|
||||
function normalizeAutoRunFallbackThreadIntervalMinutes(value) {
|
||||
return Math.max(0, Math.floor(Number(value) || 0));
|
||||
}
|
||||
function buildAutoRunRoundSummaries(totalRuns, rawSummaries = []) {
|
||||
return Array.from({ length: totalRuns }, (_, index) => ({
|
||||
round: index + 1,
|
||||
status: rawSummaries[index]?.status || 'pending',
|
||||
attempts: rawSummaries[index]?.attempts || 0,
|
||||
failureReasons: [...(rawSummaries[index]?.failureReasons || [])],
|
||||
finalFailureReason: rawSummaries[index]?.finalFailureReason || '',
|
||||
}));
|
||||
}
|
||||
function serializeAutoRunRoundSummaries(totalRuns, roundSummaries = []) {
|
||||
return buildAutoRunRoundSummaries(totalRuns, roundSummaries);
|
||||
}
|
||||
async function logAutoRunFinalSummary() {}
|
||||
async function waitBetweenAutoRunRounds() {}
|
||||
|
||||
const chrome = {
|
||||
runtime: {
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
|
||||
const source = fs.readFileSync('background.js', 'utf8');
|
||||
|
||||
function extractFunction(name) {
|
||||
const markers = [`async function ${name}(`, `function ${name}(`];
|
||||
const start = markers
|
||||
.map((marker) => source.indexOf(marker))
|
||||
.find((index) => index >= 0);
|
||||
if (start < 0) {
|
||||
throw new Error(`missing function ${name}`);
|
||||
}
|
||||
|
||||
let parenDepth = 0;
|
||||
let signatureEnded = false;
|
||||
let braceStart = -1;
|
||||
for (let i = start; i < source.length; i += 1) {
|
||||
const ch = source[i];
|
||||
if (ch === '(') {
|
||||
parenDepth += 1;
|
||||
} else if (ch === ')') {
|
||||
parenDepth -= 1;
|
||||
if (parenDepth === 0) {
|
||||
signatureEnded = true;
|
||||
}
|
||||
} else if (ch === '{' && signatureEnded) {
|
||||
braceStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (braceStart < 0) {
|
||||
throw new Error(`missing body for function ${name}`);
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
let end = braceStart;
|
||||
for (; end < source.length; end += 1) {
|
||||
const ch = source[end];
|
||||
if (ch === '{') depth += 1;
|
||||
if (ch === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
end += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
const bundle = [
|
||||
extractFunction('normalizeEmailGenerator'),
|
||||
extractFunction('getEmailGeneratorLabel'),
|
||||
extractFunction('normalizePersistentSettingValue'),
|
||||
extractFunction('finalizeIcloudAliasAfterSuccessfulFlow'),
|
||||
].join('\n');
|
||||
|
||||
function createApi(overrides = {}) {
|
||||
return new Function('overrides', `
|
||||
const HOTMAIL_PROVIDER = 'hotmail-api';
|
||||
const HOTMAIL_SERVICE_MODE_LOCAL = 'local';
|
||||
const CLOUDFLARE_TEMP_EMAIL_GENERATOR = 'cloudflare-temp-email';
|
||||
const DEFAULT_LOCAL_CPA_STEP9_MODE = 'submit';
|
||||
const DEFAULT_HOTMAIL_REMOTE_BASE_URL = '';
|
||||
const DEFAULT_HOTMAIL_LOCAL_BASE_URL = 'http://127.0.0.1:17373';
|
||||
const PERSISTED_SETTING_DEFAULTS = {
|
||||
mailProvider: '163',
|
||||
autoStepDelaySeconds: null,
|
||||
};
|
||||
|
||||
const calls = {
|
||||
setUsed: [],
|
||||
logs: [],
|
||||
deletes: [],
|
||||
listCalls: 0,
|
||||
};
|
||||
|
||||
function normalizeIcloudHost(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return ['icloud.com', 'icloud.com.cn'].includes(normalized) ? normalized : '';
|
||||
}
|
||||
function normalizePanelMode(value = '') {
|
||||
return String(value || '').trim().toLowerCase() === 'sub2api' ? 'sub2api' : 'cpa';
|
||||
}
|
||||
function normalizeMailProvider(value = '') {
|
||||
return String(value || '').trim().toLowerCase() || '163';
|
||||
}
|
||||
function normalizeAutoRunFallbackThreadIntervalMinutes(value) {
|
||||
return Math.max(0, Math.floor(Number(value) || 0));
|
||||
}
|
||||
function normalizeAutoRunDelayMinutes(value) {
|
||||
return Math.max(1, Math.floor(Number(value) || 30));
|
||||
}
|
||||
function normalizeAutoStepDelaySeconds(value, fallback = null) {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? Math.max(0, Math.floor(numeric)) : fallback;
|
||||
}
|
||||
function normalizeHotmailServiceMode() {
|
||||
return HOTMAIL_SERVICE_MODE_LOCAL;
|
||||
}
|
||||
function normalizeHotmailRemoteBaseUrl(value = '') {
|
||||
return String(value || '').trim() || DEFAULT_HOTMAIL_REMOTE_BASE_URL;
|
||||
}
|
||||
function normalizeHotmailLocalBaseUrl(value = '') {
|
||||
return String(value || '').trim() || DEFAULT_HOTMAIL_LOCAL_BASE_URL;
|
||||
}
|
||||
function normalizeCloudflareDomain(value = '') {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
function normalizeCloudflareDomains(values = []) {
|
||||
return Array.isArray(values) ? values : [];
|
||||
}
|
||||
function normalizeHotmailAccounts(values = []) {
|
||||
return Array.isArray(values) ? values : [];
|
||||
}
|
||||
function getManualAliasUsageMap(state) {
|
||||
return { ...(state?.manualAliasUsage || {}) };
|
||||
}
|
||||
function getPreservedAliasMap(state) {
|
||||
return { ...(state?.preservedAliases || {}) };
|
||||
}
|
||||
function isAliasPreserved(state, email) {
|
||||
return Boolean(getPreservedAliasMap(state)[String(email || '').trim().toLowerCase()]);
|
||||
}
|
||||
async function setIcloudAliasUsedState(payload, options = {}) {
|
||||
calls.setUsed.push({ payload, options });
|
||||
}
|
||||
async function addLog(message, level = 'info') {
|
||||
calls.logs.push({ message, level });
|
||||
}
|
||||
async function deleteIcloudAlias(alias) {
|
||||
calls.deletes.push(alias);
|
||||
}
|
||||
async function listIcloudAliases() {
|
||||
calls.listCalls += 1;
|
||||
return overrides.listIcloudAliases ? overrides.listIcloudAliases() : [];
|
||||
}
|
||||
function findIcloudAliasByEmail(aliases, email) {
|
||||
return (aliases || []).find((alias) => String(alias.email || '').toLowerCase() === String(email || '').toLowerCase()) || null;
|
||||
}
|
||||
function getErrorMessage(error) {
|
||||
return String(typeof error === 'string' ? error : error?.message || '');
|
||||
}
|
||||
|
||||
${bundle}
|
||||
|
||||
return {
|
||||
calls,
|
||||
normalizeEmailGenerator,
|
||||
getEmailGeneratorLabel,
|
||||
normalizePersistentSettingValue,
|
||||
finalizeIcloudAliasAfterSuccessfulFlow,
|
||||
};
|
||||
`)(overrides);
|
||||
}
|
||||
|
||||
test('normalizeEmailGenerator and label support icloud', () => {
|
||||
const api = createApi();
|
||||
assert.equal(api.normalizeEmailGenerator('icloud'), 'icloud');
|
||||
assert.equal(api.getEmailGeneratorLabel('icloud'), 'iCloud 隐私邮箱');
|
||||
});
|
||||
|
||||
test('normalizePersistentSettingValue handles icloud settings', () => {
|
||||
const api = createApi();
|
||||
assert.equal(api.normalizePersistentSettingValue('icloudHostPreference', 'icloud.com'), 'icloud.com');
|
||||
assert.equal(api.normalizePersistentSettingValue('icloudHostPreference', 'bad-host'), 'auto');
|
||||
assert.equal(api.normalizePersistentSettingValue('autoDeleteUsedIcloudAlias', 1), true);
|
||||
});
|
||||
|
||||
test('finalizeIcloudAliasAfterSuccessfulFlow marks icloud aliases as used without deleting when auto-delete is off', async () => {
|
||||
const api = createApi();
|
||||
const result = await api.finalizeIcloudAliasAfterSuccessfulFlow({
|
||||
email: 'alias@icloud.com',
|
||||
emailGenerator: 'icloud',
|
||||
autoDeleteUsedIcloudAlias: false,
|
||||
manualAliasUsage: {},
|
||||
preservedAliases: {},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { handled: true, deleted: false });
|
||||
assert.equal(api.calls.setUsed.length, 1);
|
||||
assert.equal(api.calls.listCalls, 0);
|
||||
assert.equal(api.calls.deletes.length, 0);
|
||||
});
|
||||
|
||||
test('finalizeIcloudAliasAfterSuccessfulFlow skips deleting preserved aliases', async () => {
|
||||
const api = createApi();
|
||||
const result = await api.finalizeIcloudAliasAfterSuccessfulFlow({
|
||||
email: 'alias@icloud.com',
|
||||
emailGenerator: 'icloud',
|
||||
autoDeleteUsedIcloudAlias: true,
|
||||
manualAliasUsage: {},
|
||||
preservedAliases: { 'alias@icloud.com': true },
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { handled: true, deleted: false });
|
||||
assert.equal(api.calls.setUsed.length, 1);
|
||||
assert.equal(api.calls.listCalls, 0);
|
||||
assert.equal(api.calls.deletes.length, 0);
|
||||
});
|
||||
|
||||
test('finalizeIcloudAliasAfterSuccessfulFlow skips deleting aliases that are preserved in the latest alias list', async () => {
|
||||
const api = createApi({
|
||||
listIcloudAliases() {
|
||||
return [
|
||||
{ email: 'alias@icloud.com', anonymousId: 'anon-1', preserved: true },
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const result = await api.finalizeIcloudAliasAfterSuccessfulFlow({
|
||||
email: 'alias@icloud.com',
|
||||
emailGenerator: 'icloud',
|
||||
autoDeleteUsedIcloudAlias: true,
|
||||
manualAliasUsage: {},
|
||||
preservedAliases: {},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { handled: true, deleted: false });
|
||||
assert.equal(api.calls.setUsed.length, 1);
|
||||
assert.equal(api.calls.listCalls, 1);
|
||||
assert.equal(api.calls.deletes.length, 0);
|
||||
});
|
||||
|
||||
test('finalizeIcloudAliasAfterSuccessfulFlow deletes alias when auto-delete is enabled and alias exists', async () => {
|
||||
const api = createApi({
|
||||
listIcloudAliases() {
|
||||
return [
|
||||
{ email: 'alias@icloud.com', anonymousId: 'anon-1', preserved: false },
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const result = await api.finalizeIcloudAliasAfterSuccessfulFlow({
|
||||
email: 'alias@icloud.com',
|
||||
emailGenerator: 'icloud',
|
||||
autoDeleteUsedIcloudAlias: true,
|
||||
manualAliasUsage: {},
|
||||
preservedAliases: {},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { handled: true, deleted: true });
|
||||
assert.equal(api.calls.setUsed.length, 1);
|
||||
assert.equal(api.calls.listCalls, 1);
|
||||
assert.deepEqual(api.calls.deletes, [
|
||||
{ email: 'alias@icloud.com', anonymousId: 'anon-1', preserved: false },
|
||||
]);
|
||||
});
|
||||
|
||||
test('finalizeIcloudAliasAfterSuccessfulFlow ignores non-icloud flows', async () => {
|
||||
const api = createApi();
|
||||
const result = await api.finalizeIcloudAliasAfterSuccessfulFlow({
|
||||
email: 'plain@example.com',
|
||||
emailGenerator: 'duck',
|
||||
autoDeleteUsedIcloudAlias: true,
|
||||
manualAliasUsage: {},
|
||||
preservedAliases: {},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { handled: false, deleted: false });
|
||||
assert.equal(api.calls.setUsed.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
findIcloudAliasArray,
|
||||
findIcloudAliasByEmail,
|
||||
getConfiguredIcloudHostPreference,
|
||||
getIcloudHostHintFromMessage,
|
||||
getIcloudLoginUrlForHost,
|
||||
getIcloudSetupUrlForHost,
|
||||
normalizeBooleanMap,
|
||||
normalizeIcloudAliasList,
|
||||
normalizeIcloudAliasRecord,
|
||||
normalizeIcloudHost,
|
||||
pickReusableIcloudAlias,
|
||||
toNormalizedEmailSet,
|
||||
} = require('../icloud-utils.js');
|
||||
|
||||
test('normalizeIcloudHost and host preference helpers resolve supported hosts', () => {
|
||||
assert.equal(normalizeIcloudHost('www.icloud.com'), 'icloud.com');
|
||||
assert.equal(normalizeIcloudHost('setup.icloud.com.cn'), 'icloud.com.cn');
|
||||
assert.equal(normalizeIcloudHost('example.com'), '');
|
||||
|
||||
assert.equal(getConfiguredIcloudHostPreference({ icloudHostPreference: 'icloud.com' }), 'icloud.com');
|
||||
assert.equal(getConfiguredIcloudHostPreference({ icloudHostPreference: 'auto' }), '');
|
||||
assert.equal(getIcloudLoginUrlForHost('icloud.com.cn'), 'https://www.icloud.com.cn/');
|
||||
assert.equal(getIcloudSetupUrlForHost('icloud.com'), 'https://setup.icloud.com/setup/ws/1');
|
||||
});
|
||||
|
||||
test('getIcloudHostHintFromMessage can infer host from error text', () => {
|
||||
assert.equal(getIcloudHostHintFromMessage('status 401 from setup.icloud.com.cn/setup/ws/1'), 'icloud.com.cn');
|
||||
assert.equal(getIcloudHostHintFromMessage('request failed at https://www.icloud.com/'), 'icloud.com');
|
||||
assert.equal(getIcloudHostHintFromMessage('unknown host'), '');
|
||||
});
|
||||
|
||||
test('findIcloudAliasArray finds nested alias collections', () => {
|
||||
const payload = {
|
||||
result: {
|
||||
data: {
|
||||
items: [
|
||||
{ hme: 'first@icloud.com', anonymousId: 'a1' },
|
||||
{ hme: 'second@icloud.com', anonymousId: 'a2' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(findIcloudAliasArray(payload), payload.result.data.items);
|
||||
});
|
||||
|
||||
test('normalizeIcloudAliasRecord merges used and preserved state', () => {
|
||||
const alias = normalizeIcloudAliasRecord({
|
||||
anonymousId: 'alias-1',
|
||||
hme: 'Demo@iCloud.com',
|
||||
label: 'Test',
|
||||
note: 'Created by test',
|
||||
state: 'active',
|
||||
}, {
|
||||
usedEmails: ['demo@icloud.com'],
|
||||
preservedEmails: ['demo@icloud.com'],
|
||||
});
|
||||
|
||||
assert.deepEqual(alias, {
|
||||
anonymousId: 'alias-1',
|
||||
email: 'demo@icloud.com',
|
||||
label: 'Test',
|
||||
note: 'Created by test',
|
||||
state: 'active',
|
||||
active: true,
|
||||
used: true,
|
||||
preserved: true,
|
||||
createdAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizeIcloudAliasList orders active unused aliases before used aliases', () => {
|
||||
const aliases = normalizeIcloudAliasList({
|
||||
hmeEmails: [
|
||||
{ hme: 'used@icloud.com', anonymousId: 'u1', active: true },
|
||||
{ hme: 'fresh@icloud.com', anonymousId: 'f1', active: true },
|
||||
{ hme: 'inactive@icloud.com', anonymousId: 'i1', active: false },
|
||||
],
|
||||
}, {
|
||||
usedEmails: ['used@icloud.com'],
|
||||
preservedEmails: ['inactive@icloud.com'],
|
||||
});
|
||||
|
||||
assert.deepEqual(aliases.map((alias) => alias.email), [
|
||||
'fresh@icloud.com',
|
||||
'used@icloud.com',
|
||||
'inactive@icloud.com',
|
||||
]);
|
||||
assert.equal(aliases[2].preserved, true);
|
||||
});
|
||||
|
||||
test('pickReusableIcloudAlias and findIcloudAliasByEmail select expected aliases', () => {
|
||||
const aliases = normalizeIcloudAliasList({
|
||||
hmeEmails: [
|
||||
{ hme: 'used@icloud.com', anonymousId: 'u1', active: true },
|
||||
{ hme: 'fresh@icloud.com', anonymousId: 'f1', active: true },
|
||||
],
|
||||
}, {
|
||||
usedEmails: ['used@icloud.com'],
|
||||
});
|
||||
|
||||
assert.equal(pickReusableIcloudAlias(aliases)?.email, 'fresh@icloud.com');
|
||||
assert.equal(findIcloudAliasByEmail(aliases, 'FRESH@ICLOUD.COM')?.anonymousId, 'f1');
|
||||
});
|
||||
|
||||
test('normalizeBooleanMap and toNormalizedEmailSet normalize keys and truthy entries', () => {
|
||||
const normalized = normalizeBooleanMap({
|
||||
' Demo@icloud.com ': 1,
|
||||
'skip@icloud.com': 0,
|
||||
});
|
||||
|
||||
assert.deepEqual(normalized, {
|
||||
'demo@icloud.com': true,
|
||||
'skip@icloud.com': false,
|
||||
});
|
||||
assert.deepEqual([...toNormalizedEmailSet(normalized)], ['demo@icloud.com']);
|
||||
});
|
||||
@@ -110,6 +110,11 @@ async function addLog(message) {
|
||||
logMessages.push(message);
|
||||
}
|
||||
|
||||
async function finalizeIcloudAliasAfterSuccessfulFlow() {}
|
||||
function shouldUseCustomRegistrationEmail() {
|
||||
return false;
|
||||
}
|
||||
|
||||
${bundle}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user