Merge branch 'dev' into master
This commit is contained in:
+187
-1
@@ -3,6 +3,7 @@
|
||||
importScripts(
|
||||
'managed-alias-utils.js',
|
||||
'background/account-run-history.js',
|
||||
'background/contribution-oauth.js',
|
||||
'background/panel-bridge.js',
|
||||
'background/generated-email-helpers.js',
|
||||
'background/signup-flow-helpers.js',
|
||||
@@ -175,6 +176,23 @@ const DISPLAY_TIMEZONE = 'Asia/Shanghai';
|
||||
const MICROSOFT_TOKEN_DNR_RULE_ID = 1001;
|
||||
const PERSISTENT_ALIAS_STATE_KEYS = ['manualAliasUsage', 'preservedAliases'];
|
||||
const ACCOUNT_RUN_HISTORY_STORAGE_KEY = 'accountRunHistory';
|
||||
const CONTRIBUTION_RUNTIME_DEFAULTS = self.MultiPageBackgroundContributionOAuth?.RUNTIME_DEFAULTS || {
|
||||
contributionMode: false,
|
||||
contributionModeExpected: false,
|
||||
contributionSessionId: '',
|
||||
contributionAuthUrl: '',
|
||||
contributionAuthState: '',
|
||||
contributionCallbackUrl: '',
|
||||
contributionStatus: '',
|
||||
contributionStatusMessage: '',
|
||||
contributionLastPollAt: 0,
|
||||
contributionCallbackStatus: 'idle',
|
||||
contributionCallbackMessage: '',
|
||||
contributionAuthOpenedAt: 0,
|
||||
contributionAuthTabId: 0,
|
||||
};
|
||||
const CONTRIBUTION_RUNTIME_KEYS = self.MultiPageBackgroundContributionOAuth?.RUNTIME_KEYS
|
||||
|| Object.keys(CONTRIBUTION_RUNTIME_DEFAULTS);
|
||||
|
||||
initializeSessionStorageAccess();
|
||||
setupDeclarativeNetRequestRules();
|
||||
@@ -276,6 +294,7 @@ const PRE_LOGIN_COOKIE_CLEAR_ORIGINS = [
|
||||
const DEFAULT_STATE = {
|
||||
currentStep: 0, // 当前流程执行到的步骤编号。
|
||||
stepStatuses: Object.fromEntries(STEP_IDS.map((stepId) => [stepId, 'pending'])),
|
||||
...CONTRIBUTION_RUNTIME_DEFAULTS,
|
||||
oauthUrl: null, // 运行时抓取到的 OAuth 地址,不要手动预填。
|
||||
email: null, // 运行时邮箱,由程序自动获取并写入,不能手动预填。
|
||||
password: null, // 运行时实际密码,由 customPassword 或程序自动生成后写入。
|
||||
@@ -1118,6 +1137,67 @@ async function setPasswordState(password) {
|
||||
broadcastDataUpdate({ password });
|
||||
}
|
||||
|
||||
function buildContributionModeState(enabled, persistedSettings = {}, currentState = {}) {
|
||||
const currentContributionState = {};
|
||||
for (const key of CONTRIBUTION_RUNTIME_KEYS) {
|
||||
currentContributionState[key] = currentState[key] !== undefined
|
||||
? currentState[key]
|
||||
: CONTRIBUTION_RUNTIME_DEFAULTS[key];
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
return {
|
||||
...currentContributionState,
|
||||
contributionMode: true,
|
||||
contributionModeExpected: true,
|
||||
panelMode: 'cpa',
|
||||
customPassword: '',
|
||||
accountRunHistoryTextEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...CONTRIBUTION_RUNTIME_DEFAULTS,
|
||||
contributionMode: false,
|
||||
contributionModeExpected: false,
|
||||
panelMode: persistedSettings.panelMode || DEFAULT_STATE.panelMode,
|
||||
customPassword: persistedSettings.customPassword || '',
|
||||
accountRunHistoryTextEnabled: Boolean(persistedSettings.accountRunHistoryTextEnabled),
|
||||
};
|
||||
}
|
||||
|
||||
async function setContributionMode(enabled) {
|
||||
const normalizedEnabled = Boolean(enabled);
|
||||
const [persistedSettings, currentState] = await Promise.all([
|
||||
getPersistedSettings(),
|
||||
getState(),
|
||||
]);
|
||||
|
||||
if (normalizedEnabled) {
|
||||
await setPersistentSettings({ panelMode: 'cpa' });
|
||||
}
|
||||
|
||||
const updates = buildContributionModeState(normalizedEnabled, {
|
||||
...persistedSettings,
|
||||
...(normalizedEnabled ? { panelMode: 'cpa' } : {}),
|
||||
}, currentState);
|
||||
|
||||
await setState(updates);
|
||||
const nextState = await getState();
|
||||
const contributionBroadcast = {};
|
||||
for (const key of CONTRIBUTION_RUNTIME_KEYS) {
|
||||
contributionBroadcast[key] = nextState[key];
|
||||
}
|
||||
broadcastDataUpdate({
|
||||
...contributionBroadcast,
|
||||
panelMode: nextState.panelMode,
|
||||
customPassword: nextState.customPassword,
|
||||
accountRunHistoryTextEnabled: nextState.accountRunHistoryTextEnabled,
|
||||
accountRunHistoryHelperBaseUrl: nextState.accountRunHistoryHelperBaseUrl,
|
||||
});
|
||||
return nextState;
|
||||
}
|
||||
|
||||
function getLuckmailUsedPurchases(state = {}) {
|
||||
return normalizeLuckmailUsedPurchases(state?.luckmailUsedPurchases);
|
||||
}
|
||||
@@ -1268,15 +1348,21 @@ async function resetState() {
|
||||
'luckmailPreserveTagId',
|
||||
'luckmailPreserveTagName',
|
||||
'preferredIcloudHost',
|
||||
...CONTRIBUTION_RUNTIME_KEYS,
|
||||
]),
|
||||
getPersistedSettings(),
|
||||
getPersistedAliasState(),
|
||||
]);
|
||||
const contributionModeState = buildContributionModeState(Boolean(prev.contributionMode), {
|
||||
...persistedSettings,
|
||||
...(prev.contributionMode ? { panelMode: 'cpa' } : {}),
|
||||
}, prev);
|
||||
await chrome.storage.session.clear();
|
||||
await chrome.storage.session.set({
|
||||
...DEFAULT_STATE,
|
||||
...persistedSettings,
|
||||
...persistedAliasState,
|
||||
...contributionModeState,
|
||||
seenCodes: prev.seenCodes || [],
|
||||
seenInbucketMailIds: prev.seenInbucketMailIds || [],
|
||||
accounts: prev.accounts || [],
|
||||
@@ -5344,6 +5430,15 @@ const accountRunHistoryHelpers = self.MultiPageBackgroundAccountRunHistory?.crea
|
||||
getState,
|
||||
normalizeAccountRunHistoryHelperBaseUrl,
|
||||
});
|
||||
const contributionOAuthManager = self.MultiPageBackgroundContributionOAuth?.createContributionOAuthManager({
|
||||
addLog,
|
||||
broadcastDataUpdate,
|
||||
chrome,
|
||||
closeLocalhostCallbackTabs,
|
||||
getState,
|
||||
setState,
|
||||
});
|
||||
contributionOAuthManager?.ensureCallbackListeners?.();
|
||||
|
||||
async function broadcastAccountRunHistoryUpdate() {
|
||||
if (!accountRunHistoryHelpers?.getPersistedAccountRunHistory) {
|
||||
@@ -6085,7 +6180,7 @@ const stepExecutorsByKey = {
|
||||
'oauth-login': (state) => step7Executor.executeStep7(state),
|
||||
'fetch-login-code': (state) => step8Executor.executeStep8(state),
|
||||
'confirm-oauth': (state) => step9Executor.executeStep9(state),
|
||||
'platform-verify': (state) => step10Executor.executeStep10(state),
|
||||
'platform-verify': (state) => executeStep10(state),
|
||||
};
|
||||
const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter({
|
||||
addLog,
|
||||
@@ -6158,6 +6253,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter
|
||||
scheduleAutoRun,
|
||||
selectLuckmailPurchase,
|
||||
setCurrentHotmailAccount,
|
||||
setContributionMode,
|
||||
setEmailState,
|
||||
setEmailStateSilently,
|
||||
setIcloudAliasPreservedState,
|
||||
@@ -6170,7 +6266,9 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter
|
||||
setStepStatus,
|
||||
skipAutoRunCountdown,
|
||||
skipStep,
|
||||
startContributionFlow: (...args) => contributionOAuthManager?.startContributionFlow?.(...args),
|
||||
startAutoRunLoop,
|
||||
pollContributionStatus: (...args) => contributionOAuthManager?.pollContributionStatus?.(...args),
|
||||
syncHotmailAccounts,
|
||||
testHotmailAccountMailAccess,
|
||||
upsertHotmailAccount,
|
||||
@@ -6501,7 +6599,26 @@ async function runPreStep6CookieCleanup() {
|
||||
// ============================================================
|
||||
|
||||
async function refreshOAuthUrlBeforeStep6(state) {
|
||||
if (state?.contributionModeExpected && !state?.contributionMode) {
|
||||
throw new Error('步骤 7:当前自动流程预期使用贡献模式,但运行态 contributionMode 已丢失,已阻止回退到普通 CPA 面板。请重新进入贡献模式后再点击自动。');
|
||||
}
|
||||
if (state?.contributionMode && contributionOAuthManager?.startContributionFlow) {
|
||||
await addLog('步骤 7:contributionMode=true,正在通过公开贡献接口申请 OAuth 链接...', 'info');
|
||||
await addLog('步骤 7:贡献模式正在申请贡献登录地址...');
|
||||
const contributionState = await contributionOAuthManager.startContributionFlow({
|
||||
nickname: state.email,
|
||||
openAuthTab: false,
|
||||
stateOverride: state,
|
||||
});
|
||||
const oauthUrl = String(contributionState?.contributionAuthUrl || '').trim();
|
||||
if (!oauthUrl) {
|
||||
throw new Error('贡献模式未返回可用的登录地址,请稍后重试。');
|
||||
}
|
||||
await handleStepData(1, { oauthUrl });
|
||||
return oauthUrl;
|
||||
}
|
||||
await addLog(`步骤 7:正在刷新登录用的 ${getPanelModeLabel(state)} OAuth 链接...`);
|
||||
await addLog(`步骤 7:contributionMode=${Boolean(state?.contributionMode)},当前将回退到 ${getPanelModeLabel(state)} 面板刷新 OAuth。`, 'warn');
|
||||
console.log(LOG_PREFIX, '[refreshOAuthUrlBeforeStep6] requesting fresh OAuth directly from panel');
|
||||
const refreshResult = await requestOAuthUrlFromPanel(state, { logLabel: '步骤 7' });
|
||||
await handleStepData(1, refreshResult);
|
||||
@@ -7162,7 +7279,76 @@ async function executeStep9(state) {
|
||||
// Step 10: 平台回调验证
|
||||
// ============================================================
|
||||
|
||||
async function executeContributionStep10(state) {
|
||||
if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) {
|
||||
throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。');
|
||||
}
|
||||
if (!state.localhostUrl) {
|
||||
throw new Error('缺少 localhost 回调地址,请先完成步骤 9。');
|
||||
}
|
||||
if (!state.contributionSessionId) {
|
||||
throw new Error('缺少贡献会话信息,请重新从步骤 7 开始。');
|
||||
}
|
||||
if (!contributionOAuthManager?.pollContributionStatus) {
|
||||
throw new Error('贡献 OAuth 流程尚未接入,无法完成贡献模式的步骤 10。');
|
||||
}
|
||||
|
||||
await addLog('步骤 10:贡献模式正在提交回调并等待最终结果...');
|
||||
|
||||
let latestState = await getState();
|
||||
const callbackUrl = latestState.localhostUrl || state.localhostUrl;
|
||||
|
||||
if (!latestState.contributionCallbackUrl && contributionOAuthManager?.handleCapturedCallback) {
|
||||
latestState = await contributionOAuthManager.handleCapturedCallback(callbackUrl, {
|
||||
source: 'step10',
|
||||
});
|
||||
} else {
|
||||
latestState = await contributionOAuthManager.pollContributionStatus({
|
||||
reason: 'step10_initial',
|
||||
stateOverride: latestState,
|
||||
});
|
||||
}
|
||||
|
||||
const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
|
||||
? await getOAuthFlowStepTimeoutMs(120000, {
|
||||
step: 10,
|
||||
actionLabel: '贡献流程最终结果',
|
||||
})
|
||||
: 120000;
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const status = String(latestState.contributionStatus || '').trim().toLowerCase();
|
||||
if (contributionOAuthManager?.isContributionFinalStatus?.(status)) {
|
||||
if (status === 'auto_approved' || status === 'manual_review_required') {
|
||||
await addLog(`步骤 10:贡献流程已结束,最终状态:${latestState.contributionStatusMessage || status}`, status === 'auto_approved' ? 'ok' : 'warn');
|
||||
await completeStepFromBackground(10, {
|
||||
contributionStatus: status,
|
||||
contributionStatusMessage: latestState.contributionStatusMessage || '',
|
||||
localhostUrl: callbackUrl,
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new Error(latestState.contributionStatusMessage || '贡献流程失败。');
|
||||
}
|
||||
|
||||
await sleepWithStop(2500);
|
||||
latestState = await contributionOAuthManager.pollContributionStatus({
|
||||
reason: 'step10_wait_final',
|
||||
stateOverride: latestState,
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error('步骤 10:等待贡献流程最终结果超时。');
|
||||
}
|
||||
|
||||
async function executeStep10(state) {
|
||||
if (state?.contributionModeExpected && !state?.contributionMode) {
|
||||
throw new Error('步骤 10:当前自动流程预期使用贡献模式,但运行态 contributionMode 已丢失,已阻止回退到普通 CPA / SUB2API 提交。请重新进入贡献模式后再点击自动。');
|
||||
}
|
||||
if (state?.contributionMode) {
|
||||
return executeContributionStep10(state);
|
||||
}
|
||||
return step10Executor.executeStep10(state);
|
||||
}
|
||||
|
||||
|
||||
@@ -331,6 +331,9 @@
|
||||
}
|
||||
|
||||
function shouldSyncAccountRunHistorySnapshot(state = {}) {
|
||||
if (Boolean(state.contributionMode)) {
|
||||
return false;
|
||||
}
|
||||
if (!Boolean(state.accountRunHistoryTextEnabled)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,723 @@
|
||||
(function attachBackgroundContributionOAuth(root, factory) {
|
||||
root.MultiPageBackgroundContributionOAuth = factory();
|
||||
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundContributionOAuthModule() {
|
||||
const API_BASE_URL = 'https://apikey.qzz.io/oauth/api';
|
||||
const ACTIVE_STATUSES = new Set(['started', 'waiting', 'processing']);
|
||||
const FINAL_STATUSES = new Set(['auto_approved', 'auto_rejected', 'manual_review_required', 'expired', 'error']);
|
||||
const CALLBACK_FINAL_STATUSES = new Set(['submitted', 'not_required']);
|
||||
const CALLBACK_WAITING_STATUSES = new Set(['idle', 'waiting', 'captured', 'failed', 'submitting']);
|
||||
|
||||
const RUNTIME_DEFAULTS = {
|
||||
contributionMode: false,
|
||||
contributionModeExpected: false,
|
||||
contributionSessionId: '',
|
||||
contributionAuthUrl: '',
|
||||
contributionAuthState: '',
|
||||
contributionCallbackUrl: '',
|
||||
contributionStatus: '',
|
||||
contributionStatusMessage: '',
|
||||
contributionLastPollAt: 0,
|
||||
contributionCallbackStatus: 'idle',
|
||||
contributionCallbackMessage: '',
|
||||
contributionAuthOpenedAt: 0,
|
||||
contributionAuthTabId: 0,
|
||||
};
|
||||
|
||||
const RUNTIME_KEYS = Object.keys(RUNTIME_DEFAULTS);
|
||||
|
||||
function createContributionOAuthManager(deps = {}) {
|
||||
const {
|
||||
addLog,
|
||||
broadcastDataUpdate,
|
||||
chrome,
|
||||
closeLocalhostCallbackTabs,
|
||||
getState,
|
||||
setState,
|
||||
} = deps;
|
||||
|
||||
let listenersBound = false;
|
||||
|
||||
function normalizeString(value = '') {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value, fallback = 0) {
|
||||
const numeric = Math.floor(Number(value) || 0);
|
||||
return numeric > 0 ? numeric : fallback;
|
||||
}
|
||||
|
||||
function normalizeContributionStatus(value = '') {
|
||||
const normalized = normalizeString(value).toLowerCase();
|
||||
switch (normalized) {
|
||||
case 'started':
|
||||
return 'started';
|
||||
case 'waiting':
|
||||
case 'wait':
|
||||
return 'waiting';
|
||||
case 'processing':
|
||||
return 'processing';
|
||||
case 'auto_approved':
|
||||
case 'approved':
|
||||
return 'auto_approved';
|
||||
case 'auto_rejected':
|
||||
case 'rejected':
|
||||
return 'auto_rejected';
|
||||
case 'manual_review_required':
|
||||
case 'manual_review':
|
||||
return 'manual_review_required';
|
||||
case 'expired':
|
||||
case 'timeout':
|
||||
return 'expired';
|
||||
case 'error':
|
||||
case 'failed':
|
||||
return 'error';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeContributionCallbackStatus(value = '') {
|
||||
const normalized = normalizeString(value).toLowerCase();
|
||||
switch (normalized) {
|
||||
case 'idle':
|
||||
return 'idle';
|
||||
case 'waiting':
|
||||
case 'pending':
|
||||
return 'waiting';
|
||||
case 'captured':
|
||||
return 'captured';
|
||||
case 'submitting':
|
||||
case 'processing':
|
||||
return 'submitting';
|
||||
case 'submitted':
|
||||
case 'success':
|
||||
case 'done':
|
||||
return 'submitted';
|
||||
case 'not_required':
|
||||
case 'no_need':
|
||||
case 'no_callback_required':
|
||||
return 'not_required';
|
||||
case 'failed':
|
||||
case 'error':
|
||||
return 'failed';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function isContributionFinalStatus(status = '') {
|
||||
return FINAL_STATUSES.has(normalizeContributionStatus(status));
|
||||
}
|
||||
|
||||
function getStatusLabel(status = '') {
|
||||
switch (normalizeContributionStatus(status)) {
|
||||
case 'started':
|
||||
return '已生成登录地址';
|
||||
case 'waiting':
|
||||
return '等待授权完成';
|
||||
case 'processing':
|
||||
return '已授权,正在自动审核';
|
||||
case 'auto_approved':
|
||||
return '贡献成功,已自动导入';
|
||||
case 'auto_rejected':
|
||||
return '贡献未通过自动审核';
|
||||
case 'manual_review_required':
|
||||
return '已提交,等待人工处理';
|
||||
case 'expired':
|
||||
return '贡献会话已超时';
|
||||
case 'error':
|
||||
return '贡献流程失败';
|
||||
default:
|
||||
return '等待开始贡献';
|
||||
}
|
||||
}
|
||||
|
||||
function getCallbackLabel(status = '') {
|
||||
switch (normalizeContributionCallbackStatus(status)) {
|
||||
case 'waiting':
|
||||
case 'idle':
|
||||
return '等待回调';
|
||||
case 'captured':
|
||||
return '已捕获回调地址';
|
||||
case 'submitting':
|
||||
return '正在提交回调';
|
||||
case 'submitted':
|
||||
return '已提交回调';
|
||||
case 'not_required':
|
||||
return '当前流程无需手动回调';
|
||||
case 'failed':
|
||||
return '回调提交失败';
|
||||
default:
|
||||
return '等待回调';
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapPayload(payload) {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)) {
|
||||
return { ...payload.data, ...payload };
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function getErrorMessage(payload, responseStatus = 500) {
|
||||
const details = [
|
||||
payload?.message,
|
||||
payload?.detail,
|
||||
payload?.error,
|
||||
payload?.reason,
|
||||
]
|
||||
.map((item) => normalizeString(item))
|
||||
.find(Boolean);
|
||||
|
||||
if (details) {
|
||||
return details;
|
||||
}
|
||||
|
||||
return `贡献服务请求失败(HTTP ${responseStatus})。`;
|
||||
}
|
||||
|
||||
async function fetchContributionJson(endpoint, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 15000));
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
let rawPayload = {};
|
||||
try {
|
||||
rawPayload = await response.json();
|
||||
} catch {
|
||||
rawPayload = {};
|
||||
}
|
||||
|
||||
const payload = unwrapPayload(rawPayload);
|
||||
if (!response.ok || payload.ok === false) {
|
||||
const error = new Error(getErrorMessage(payload, response.status));
|
||||
error.payload = payload;
|
||||
error.responseStatus = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error('贡献服务请求超时,请稍后重试。');
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function pickContributionState(state = {}) {
|
||||
const picked = {};
|
||||
for (const key of RUNTIME_KEYS) {
|
||||
picked[key] = state[key] !== undefined ? state[key] : RUNTIME_DEFAULTS[key];
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
async function applyRuntimeUpdates(updates = {}) {
|
||||
if (!updates || typeof updates !== 'object' || Array.isArray(updates) || Object.keys(updates).length === 0) {
|
||||
return getState();
|
||||
}
|
||||
|
||||
await setState(updates);
|
||||
broadcastDataUpdate(updates);
|
||||
return getState();
|
||||
}
|
||||
|
||||
function extractAuthStateFromUrl(authUrl = '') {
|
||||
try {
|
||||
return new URL(authUrl).searchParams.get('state') || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function buildNickname(state = {}, preferredNickname = '') {
|
||||
const nickname = normalizeString(preferredNickname)
|
||||
|| normalizeString(state.email)
|
||||
|| normalizeString(state.contributionNickname);
|
||||
return nickname || 'codex-extension-user';
|
||||
}
|
||||
|
||||
function buildStatusMessage(status, payload = {}) {
|
||||
const label = getStatusLabel(status);
|
||||
const details = [
|
||||
payload.status_message,
|
||||
payload.statusMessage,
|
||||
payload.message,
|
||||
payload.detail,
|
||||
payload.reason,
|
||||
]
|
||||
.map((item) => normalizeString(item))
|
||||
.find(Boolean);
|
||||
|
||||
if (!details || details === label) {
|
||||
return label;
|
||||
}
|
||||
|
||||
return `${label}:${details}`;
|
||||
}
|
||||
|
||||
function buildCallbackMessage(status, payload = {}) {
|
||||
const label = getCallbackLabel(status);
|
||||
const details = [
|
||||
payload.callback_message,
|
||||
payload.callbackMessage,
|
||||
payload.message,
|
||||
payload.detail,
|
||||
payload.reason,
|
||||
]
|
||||
.map((item) => normalizeString(item))
|
||||
.find(Boolean);
|
||||
|
||||
if (!details || details === label) {
|
||||
return label;
|
||||
}
|
||||
|
||||
return `${label}:${details}`;
|
||||
}
|
||||
|
||||
function looksLikeNoNeedCallback(payload = {}) {
|
||||
const combined = [
|
||||
payload.message,
|
||||
payload.detail,
|
||||
payload.reason,
|
||||
payload.error,
|
||||
]
|
||||
.map((item) => normalizeString(item).toLowerCase())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return Boolean(payload.callback_required === false
|
||||
|| payload.callbackRequired === false
|
||||
|| /无需手动|鏃犻渶鎵嬪姩|not required|already enabled/i.test(combined));
|
||||
}
|
||||
|
||||
function deriveCallbackState(payload = {}, state = {}) {
|
||||
const existingStatus = normalizeContributionCallbackStatus(state.contributionCallbackStatus);
|
||||
const callbackUrl = normalizeString(
|
||||
payload.callback_url
|
||||
|| payload.callbackUrl
|
||||
|| state.contributionCallbackUrl
|
||||
);
|
||||
const explicitStatus = normalizeContributionCallbackStatus(
|
||||
payload.callback_status
|
||||
|| payload.callbackStatus
|
||||
);
|
||||
|
||||
if (explicitStatus) {
|
||||
return {
|
||||
status: explicitStatus,
|
||||
message: buildCallbackMessage(explicitStatus, payload),
|
||||
callbackUrl,
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.callback_submitted === true || payload.callbackSubmitted === true) {
|
||||
return {
|
||||
status: 'submitted',
|
||||
message: buildCallbackMessage('submitted', payload),
|
||||
callbackUrl,
|
||||
};
|
||||
}
|
||||
|
||||
if (looksLikeNoNeedCallback(payload)) {
|
||||
return {
|
||||
status: 'not_required',
|
||||
message: buildCallbackMessage('not_required', payload),
|
||||
callbackUrl,
|
||||
};
|
||||
}
|
||||
|
||||
if (callbackUrl) {
|
||||
return {
|
||||
status: CALLBACK_FINAL_STATUSES.has(existingStatus) ? existingStatus : 'captured',
|
||||
message: buildCallbackMessage(CALLBACK_FINAL_STATUSES.has(existingStatus) ? existingStatus : 'captured', payload),
|
||||
callbackUrl,
|
||||
};
|
||||
}
|
||||
|
||||
if (CALLBACK_FINAL_STATUSES.has(existingStatus) || existingStatus === 'failed') {
|
||||
return {
|
||||
status: existingStatus,
|
||||
message: normalizeString(state.contributionCallbackMessage) || buildCallbackMessage(existingStatus),
|
||||
callbackUrl: normalizeString(state.contributionCallbackUrl),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'waiting',
|
||||
message: buildCallbackMessage('waiting', payload),
|
||||
callbackUrl: '',
|
||||
};
|
||||
}
|
||||
|
||||
function isContributionCallbackUrl(rawUrl, state = {}) {
|
||||
const urlText = normalizeString(rawUrl);
|
||||
if (!urlText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(urlText);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const code = normalizeString(parsed.searchParams.get('code'));
|
||||
const authState = normalizeString(parsed.searchParams.get('state'));
|
||||
if (!code || !authState) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hostLooksLocal = ['localhost', '127.0.0.1'].includes(parsed.hostname);
|
||||
const pathLooksLikeCallback = /callback/i.test(parsed.pathname || '');
|
||||
if (!hostLooksLocal && !pathLooksLikeCallback) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedState = normalizeString(state.contributionAuthState);
|
||||
return !expectedState || expectedState === authState;
|
||||
}
|
||||
|
||||
async function openContributionAuthUrl(authUrl, options = {}) {
|
||||
const normalizedUrl = normalizeString(authUrl);
|
||||
if (!normalizedUrl) {
|
||||
throw new Error('贡献服务未返回有效的登录地址。');
|
||||
}
|
||||
|
||||
const currentState = options.stateOverride || await getState();
|
||||
const preferredTabId = normalizePositiveInteger(options.tabId || currentState.contributionAuthTabId, 0);
|
||||
let tab = null;
|
||||
|
||||
if (preferredTabId) {
|
||||
tab = await chrome.tabs.update(preferredTabId, {
|
||||
url: normalizedUrl,
|
||||
active: true,
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
if (!tab) {
|
||||
tab = await chrome.tabs.create({ url: normalizedUrl, active: true });
|
||||
}
|
||||
|
||||
await applyRuntimeUpdates({
|
||||
contributionAuthUrl: normalizedUrl,
|
||||
contributionAuthOpenedAt: Date.now(),
|
||||
contributionAuthTabId: normalizePositiveInteger(tab?.id, 0),
|
||||
});
|
||||
|
||||
return tab;
|
||||
}
|
||||
|
||||
async function fetchContributionResult(sessionId) {
|
||||
try {
|
||||
return await fetchContributionJson(`/result?session_id=${encodeURIComponent(sessionId)}`);
|
||||
} catch (error) {
|
||||
if (typeof addLog === 'function') {
|
||||
await addLog(`贡献模式:获取最终结果失败:${error.message}`, 'warn');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitContributionCallback(callbackUrl, options = {}) {
|
||||
const currentState = options.stateOverride || await getState();
|
||||
const sessionId = normalizeString(currentState.contributionSessionId);
|
||||
const normalizedUrl = normalizeString(callbackUrl);
|
||||
|
||||
if (!sessionId || !normalizedUrl) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus);
|
||||
if (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting') {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
await applyRuntimeUpdates({
|
||||
contributionCallbackUrl: normalizedUrl,
|
||||
contributionCallbackStatus: 'submitting',
|
||||
contributionCallbackMessage: buildCallbackMessage('submitting'),
|
||||
});
|
||||
|
||||
try {
|
||||
const payload = await fetchContributionJson('/submit-callback', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
session_id: sessionId,
|
||||
callback_url: normalizedUrl,
|
||||
},
|
||||
});
|
||||
|
||||
const nextStatus = looksLikeNoNeedCallback(payload) ? 'not_required' : 'submitted';
|
||||
await applyRuntimeUpdates({
|
||||
contributionCallbackUrl: normalizedUrl,
|
||||
contributionCallbackStatus: nextStatus,
|
||||
contributionCallbackMessage: buildCallbackMessage(nextStatus, payload),
|
||||
});
|
||||
|
||||
if (typeof closeLocalhostCallbackTabs === 'function') {
|
||||
await closeLocalhostCallbackTabs(normalizedUrl).catch(() => {});
|
||||
}
|
||||
|
||||
return await pollContributionStatus({ reason: options.reason || 'submit_callback' });
|
||||
} catch (error) {
|
||||
if (looksLikeNoNeedCallback(error?.payload || {})) {
|
||||
await applyRuntimeUpdates({
|
||||
contributionCallbackUrl: normalizedUrl,
|
||||
contributionCallbackStatus: 'not_required',
|
||||
contributionCallbackMessage: buildCallbackMessage('not_required', error.payload || {}),
|
||||
});
|
||||
|
||||
if (typeof closeLocalhostCallbackTabs === 'function') {
|
||||
await closeLocalhostCallbackTabs(normalizedUrl).catch(() => {});
|
||||
}
|
||||
|
||||
return pollContributionStatus({ reason: options.reason || 'submit_callback_not_required' }).catch(() => getState());
|
||||
}
|
||||
|
||||
await applyRuntimeUpdates({
|
||||
contributionCallbackUrl: normalizedUrl,
|
||||
contributionCallbackStatus: 'failed',
|
||||
contributionCallbackMessage: `回调提交失败:${error.message}`,
|
||||
});
|
||||
|
||||
if (typeof addLog === 'function') {
|
||||
await addLog(`贡献模式:回调提交失败:${error.message}`, 'warn');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCapturedCallback(rawUrl, metadata = {}) {
|
||||
const currentState = await getState();
|
||||
if (!normalizeString(currentState.contributionSessionId) || !currentState.contributionMode) {
|
||||
return currentState;
|
||||
}
|
||||
if (!isContributionCallbackUrl(rawUrl, currentState)) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
const normalizedUrl = normalizeString(rawUrl);
|
||||
const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus);
|
||||
if (
|
||||
normalizedUrl
|
||||
&& normalizeString(currentState.contributionCallbackUrl) === normalizedUrl
|
||||
&& (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting')
|
||||
) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
await applyRuntimeUpdates({
|
||||
contributionCallbackUrl: normalizedUrl,
|
||||
contributionCallbackStatus: 'captured',
|
||||
contributionCallbackMessage: buildCallbackMessage('captured'),
|
||||
});
|
||||
|
||||
if (typeof addLog === 'function') {
|
||||
await addLog(`贡献模式:已捕获回调地址(${metadata.source || 'unknown'})。`, 'info');
|
||||
}
|
||||
|
||||
try {
|
||||
return await submitContributionCallback(normalizedUrl, {
|
||||
reason: metadata.source || 'navigation',
|
||||
stateOverride: await getState(),
|
||||
});
|
||||
} catch {
|
||||
return getState();
|
||||
}
|
||||
}
|
||||
|
||||
async function pollContributionStatus(options = {}) {
|
||||
const currentState = options.stateOverride || await getState();
|
||||
const sessionId = normalizeString(currentState.contributionSessionId);
|
||||
if (!sessionId) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
const payload = await fetchContributionJson(`/status?session_id=${encodeURIComponent(sessionId)}`);
|
||||
const nextStatus = normalizeContributionStatus(payload.status || payload.state || payload.phase) || currentState.contributionStatus || 'waiting';
|
||||
let finalPayload = null;
|
||||
|
||||
if (isContributionFinalStatus(nextStatus)) {
|
||||
finalPayload = await fetchContributionResult(sessionId);
|
||||
}
|
||||
|
||||
const mergedPayload = finalPayload ? { ...payload, ...finalPayload } : payload;
|
||||
const normalizedStatus = normalizeContributionStatus(mergedPayload.status || mergedPayload.state || mergedPayload.phase) || nextStatus;
|
||||
const callbackState = deriveCallbackState(mergedPayload, currentState);
|
||||
const updates = {
|
||||
contributionLastPollAt: Date.now(),
|
||||
contributionStatus: normalizedStatus,
|
||||
contributionStatusMessage: buildStatusMessage(normalizedStatus, mergedPayload),
|
||||
contributionCallbackUrl: callbackState.callbackUrl,
|
||||
contributionCallbackStatus: callbackState.status,
|
||||
contributionCallbackMessage: callbackState.message,
|
||||
};
|
||||
|
||||
const authUrl = normalizeString(mergedPayload.auth_url || mergedPayload.authUrl);
|
||||
if (authUrl) {
|
||||
updates.contributionAuthUrl = authUrl;
|
||||
}
|
||||
|
||||
const authState = normalizeString(mergedPayload.state || mergedPayload.auth_state || mergedPayload.authState)
|
||||
|| (authUrl ? extractAuthStateFromUrl(authUrl) : '');
|
||||
if (authState) {
|
||||
updates.contributionAuthState = authState;
|
||||
}
|
||||
|
||||
await applyRuntimeUpdates(updates);
|
||||
const nextState = await getState();
|
||||
|
||||
if (
|
||||
normalizeString(nextState.contributionCallbackUrl)
|
||||
&& CALLBACK_WAITING_STATUSES.has(normalizeContributionCallbackStatus(nextState.contributionCallbackStatus))
|
||||
) {
|
||||
try {
|
||||
return await submitContributionCallback(nextState.contributionCallbackUrl, {
|
||||
reason: options.reason || 'status_poll',
|
||||
stateOverride: nextState,
|
||||
});
|
||||
} catch {
|
||||
return getState();
|
||||
}
|
||||
}
|
||||
|
||||
return nextState;
|
||||
}
|
||||
|
||||
async function startContributionFlow(options = {}) {
|
||||
const currentState = options.stateOverride || await getState();
|
||||
const shouldOpenAuthTab = options.openAuthTab !== false;
|
||||
if (!currentState.contributionMode) {
|
||||
throw new Error('请先进入贡献模式。');
|
||||
}
|
||||
|
||||
const currentSessionId = normalizeString(currentState.contributionSessionId);
|
||||
const currentStatus = normalizeContributionStatus(currentState.contributionStatus);
|
||||
if (currentSessionId && ACTIVE_STATUSES.has(currentStatus)) {
|
||||
if (normalizeString(currentState.contributionAuthUrl)) {
|
||||
if (shouldOpenAuthTab) {
|
||||
await openContributionAuthUrl(currentState.contributionAuthUrl, {
|
||||
stateOverride: currentState,
|
||||
}).catch(() => null);
|
||||
}
|
||||
}
|
||||
return pollContributionStatus({ reason: 'resume_existing' });
|
||||
}
|
||||
|
||||
const payload = await fetchContributionJson('/start', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
nickname: buildNickname(currentState, options.nickname),
|
||||
source: 'cpa',
|
||||
channel: 'codex-extension',
|
||||
},
|
||||
});
|
||||
|
||||
const sessionId = normalizeString(payload.session_id || payload.sessionId);
|
||||
const authUrl = normalizeString(payload.auth_url || payload.authUrl);
|
||||
const authState = normalizeString(payload.state || payload.auth_state || payload.authState) || extractAuthStateFromUrl(authUrl);
|
||||
if (!sessionId || !authUrl) {
|
||||
throw new Error('贡献服务未返回有效的 session_id 或 auth_url。');
|
||||
}
|
||||
|
||||
await applyRuntimeUpdates({
|
||||
contributionSessionId: sessionId,
|
||||
contributionAuthUrl: authUrl,
|
||||
contributionAuthState: authState,
|
||||
contributionCallbackUrl: '',
|
||||
contributionStatus: normalizeContributionStatus(payload.status) || 'started',
|
||||
contributionStatusMessage: buildStatusMessage(normalizeContributionStatus(payload.status) || 'started', payload),
|
||||
contributionLastPollAt: 0,
|
||||
contributionCallbackStatus: 'waiting',
|
||||
contributionCallbackMessage: buildCallbackMessage('waiting'),
|
||||
contributionAuthOpenedAt: 0,
|
||||
contributionAuthTabId: 0,
|
||||
});
|
||||
|
||||
if (shouldOpenAuthTab) {
|
||||
await openContributionAuthUrl(authUrl);
|
||||
}
|
||||
return pollContributionStatus({ reason: 'after_start' });
|
||||
}
|
||||
|
||||
function onNavigationEvent(details = {}, source) {
|
||||
if (details?.frameId !== undefined && Number(details.frameId) !== 0) {
|
||||
return;
|
||||
}
|
||||
handleCapturedCallback(details?.url || '', {
|
||||
source,
|
||||
tabId: normalizePositiveInteger(details?.tabId, 0),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function onTabUpdated(tabId, changeInfo, tab) {
|
||||
const candidateUrl = normalizeString(changeInfo?.url || tab?.url);
|
||||
if (!candidateUrl) {
|
||||
return;
|
||||
}
|
||||
handleCapturedCallback(candidateUrl, {
|
||||
source: 'tabs.onUpdated',
|
||||
tabId: normalizePositiveInteger(tabId, 0),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function ensureCallbackListeners() {
|
||||
if (listenersBound) {
|
||||
return;
|
||||
}
|
||||
|
||||
chrome.webNavigation.onCommitted.addListener((details) => {
|
||||
onNavigationEvent(details, 'webNavigation.onCommitted');
|
||||
});
|
||||
chrome.webNavigation.onHistoryStateUpdated.addListener((details) => {
|
||||
onNavigationEvent(details, 'webNavigation.onHistoryStateUpdated');
|
||||
});
|
||||
chrome.tabs.onUpdated.addListener(onTabUpdated);
|
||||
listenersBound = true;
|
||||
}
|
||||
|
||||
return {
|
||||
ensureCallbackListeners,
|
||||
handleCapturedCallback,
|
||||
isContributionCallbackUrl,
|
||||
isContributionFinalStatus,
|
||||
pollContributionStatus,
|
||||
startContributionFlow,
|
||||
submitContributionCallback,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ACTIVE_STATUSES,
|
||||
FINAL_STATUSES,
|
||||
RUNTIME_DEFAULTS,
|
||||
RUNTIME_KEYS,
|
||||
createContributionOAuthManager,
|
||||
};
|
||||
});
|
||||
@@ -57,6 +57,7 @@
|
||||
notifyStepComplete,
|
||||
notifyStepError,
|
||||
patchHotmailAccount,
|
||||
pollContributionStatus,
|
||||
registerTab,
|
||||
requestStop,
|
||||
handleCloudflareSecurityBlocked,
|
||||
@@ -65,6 +66,7 @@
|
||||
scheduleAutoRun,
|
||||
selectLuckmailPurchase,
|
||||
setCurrentHotmailAccount,
|
||||
setContributionMode,
|
||||
setEmailState,
|
||||
setEmailStateSilently,
|
||||
setIcloudAliasPreservedState,
|
||||
@@ -77,6 +79,7 @@
|
||||
setStepStatus,
|
||||
skipAutoRunCountdown,
|
||||
skipStep,
|
||||
startContributionFlow,
|
||||
startAutoRunLoop,
|
||||
syncHotmailAccounts,
|
||||
testHotmailAccountMailAccess,
|
||||
@@ -289,6 +292,49 @@
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'SET_CONTRIBUTION_MODE': {
|
||||
const enabled = Boolean(message.payload?.enabled);
|
||||
const state = await ensureManualInteractionAllowed(enabled ? '进入贡献模式' : '退出贡献模式');
|
||||
if (Object.values(state.stepStatuses || {}).some((status) => status === 'running')) {
|
||||
throw new Error(enabled ? '当前有步骤正在执行,无法进入贡献模式。' : '当前有步骤正在执行,无法退出贡献模式。');
|
||||
}
|
||||
if (typeof setContributionMode !== 'function') {
|
||||
throw new Error('贡献模式切换能力未接入。');
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
state: await setContributionMode(enabled),
|
||||
};
|
||||
}
|
||||
|
||||
case 'START_CONTRIBUTION_FLOW': {
|
||||
const state = await ensureManualInteractionAllowed('开始贡献');
|
||||
if (Object.values(state.stepStatuses || {}).some((status) => status === 'running')) {
|
||||
throw new Error('当前有步骤正在执行,无法开始贡献流程。');
|
||||
}
|
||||
if (typeof startContributionFlow !== 'function') {
|
||||
throw new Error('贡献 OAuth 流程尚未接入。');
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
state: await startContributionFlow({
|
||||
nickname: message.payload?.nickname,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
case 'POLL_CONTRIBUTION_STATUS': {
|
||||
if (typeof pollContributionStatus !== 'function') {
|
||||
throw new Error('贡献状态轮询能力尚未接入。');
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
state: await pollContributionStatus({
|
||||
reason: message.payload?.reason || 'sidepanel_poll',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
case 'CLEAR_ACCOUNT_RUN_HISTORY': {
|
||||
const state = await getState();
|
||||
if (isAutoRunLockedState(state)) {
|
||||
@@ -340,6 +386,9 @@
|
||||
|
||||
case 'AUTO_RUN': {
|
||||
clearStopRequest();
|
||||
if (Boolean(message.payload?.contributionMode) && typeof setContributionMode === 'function') {
|
||||
await setContributionMode(true);
|
||||
}
|
||||
const state = await getState();
|
||||
if (getPendingAutoRunTimerPlan(state)) {
|
||||
throw new Error('已有自动运行倒计时计划,请先取消或立即开始。');
|
||||
@@ -354,6 +403,9 @@
|
||||
|
||||
case 'SCHEDULE_AUTO_RUN': {
|
||||
clearStopRequest();
|
||||
if (Boolean(message.payload?.contributionMode) && typeof setContributionMode === 'function') {
|
||||
await setContributionMode(true);
|
||||
}
|
||||
const totalRuns = normalizeRunCount(message.payload?.totalRuns || 1);
|
||||
return await scheduleAutoRun(totalRuns, {
|
||||
delayMinutes: message.payload?.delayMinutes,
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
(function attachSidepanelContributionMode(globalScope) {
|
||||
const ACTIVE_STATUSES = new Set(['started', 'waiting', 'processing']);
|
||||
const FINAL_STATUSES = new Set(['auto_approved', 'auto_rejected', 'manual_review_required', 'expired', 'error']);
|
||||
const DEFAULT_COPY = '当前账号将用于支持项目维护。扩展会自动申请贡献登录地址并持续跟踪授权状态;如检测到回调地址,会自动提交,无需手动复制。';
|
||||
|
||||
function createContributionModeManager(context = {}) {
|
||||
const {
|
||||
state,
|
||||
dom,
|
||||
helpers,
|
||||
runtime,
|
||||
constants = {},
|
||||
} = context;
|
||||
|
||||
const contributionUploadUrl = constants.contributionUploadUrl || 'https://apikey.qzz.io/';
|
||||
const pollIntervalMs = Math.max(1500, Math.floor(Number(constants.pollIntervalMs) || 2500));
|
||||
|
||||
const hiddenRows = [
|
||||
dom.rowVpsUrl,
|
||||
dom.rowVpsPassword,
|
||||
dom.rowLocalCpaStep9Mode,
|
||||
dom.rowSub2ApiUrl,
|
||||
dom.rowSub2ApiEmail,
|
||||
dom.rowSub2ApiPassword,
|
||||
dom.rowSub2ApiGroup,
|
||||
dom.rowSub2ApiDefaultProxy,
|
||||
dom.rowCustomPassword,
|
||||
dom.rowAccountRunHistoryTextEnabled,
|
||||
dom.rowAccountRunHistoryHelperBaseUrl,
|
||||
].filter(Boolean);
|
||||
|
||||
let actionInFlight = false;
|
||||
let pollInFlight = false;
|
||||
let pollTimer = null;
|
||||
|
||||
function getLatestState() {
|
||||
return state.getLatestState?.() || {};
|
||||
}
|
||||
|
||||
function normalizeString(value = '') {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function normalizeStatus(value = '') {
|
||||
const normalized = normalizeString(value).toLowerCase();
|
||||
if (ACTIVE_STATUSES.has(normalized) || FINAL_STATUSES.has(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeCallbackStatus(value = '') {
|
||||
const normalized = normalizeString(value).toLowerCase();
|
||||
switch (normalized) {
|
||||
case 'waiting':
|
||||
case 'captured':
|
||||
case 'submitting':
|
||||
case 'submitted':
|
||||
case 'not_required':
|
||||
case 'failed':
|
||||
case 'idle':
|
||||
return normalized;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function isContributionModeEnabled(currentState = getLatestState()) {
|
||||
return Boolean(currentState.contributionMode);
|
||||
}
|
||||
|
||||
function hasActiveContributionSession(currentState = getLatestState()) {
|
||||
const status = normalizeStatus(currentState.contributionStatus);
|
||||
return Boolean(normalizeString(currentState.contributionSessionId) && status && !FINAL_STATUSES.has(status));
|
||||
}
|
||||
|
||||
function isModeSwitchBlocked() {
|
||||
return Boolean(helpers.isModeSwitchBlocked?.(getLatestState()));
|
||||
}
|
||||
|
||||
function setContributionHidden(element, hidden) {
|
||||
element?.classList.toggle('is-contribution-hidden', hidden);
|
||||
}
|
||||
|
||||
function syncContributionRows(enabled) {
|
||||
hiddenRows.forEach((row) => {
|
||||
setContributionHidden(row, enabled);
|
||||
});
|
||||
}
|
||||
|
||||
function syncContributionButton(enabled, blocked) {
|
||||
if (!dom.btnContributionMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
dom.btnContributionMode.classList.toggle('is-active', enabled);
|
||||
dom.btnContributionMode.setAttribute('aria-pressed', String(enabled));
|
||||
dom.btnContributionMode.disabled = enabled || blocked;
|
||||
dom.btnContributionMode.title = blocked
|
||||
? '当前流程运行中,暂时不能切换贡献模式'
|
||||
: (enabled ? '当前已在贡献模式' : '进入贡献模式');
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePolling(delayMs = pollIntervalMs) {
|
||||
stopPolling();
|
||||
if (!isContributionModeEnabled() || !hasActiveContributionSession()) {
|
||||
return;
|
||||
}
|
||||
|
||||
pollTimer = setTimeout(() => {
|
||||
pollOnce({ silentError: true }).catch(() => {});
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function ensurePolling() {
|
||||
if (!isContributionModeEnabled() || !hasActiveContributionSession()) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pollTimer && !pollInFlight) {
|
||||
schedulePolling(1200);
|
||||
}
|
||||
}
|
||||
|
||||
function getOauthStatusText(currentState = getLatestState()) {
|
||||
const status = normalizeStatus(currentState.contributionStatus);
|
||||
const hasAuthUrl = Boolean(normalizeString(currentState.contributionAuthUrl));
|
||||
if (!normalizeString(currentState.contributionSessionId) || !hasAuthUrl) {
|
||||
return '未生成登录地址';
|
||||
}
|
||||
if (status === 'waiting') {
|
||||
return '等待授权完成';
|
||||
}
|
||||
if (status === 'processing' || status === 'auto_approved' || status === 'auto_rejected' || status === 'manual_review_required') {
|
||||
return '授权已完成';
|
||||
}
|
||||
if (status === 'expired' || status === 'error') {
|
||||
return '授权失败';
|
||||
}
|
||||
if (Number(currentState.contributionAuthOpenedAt) > 0) {
|
||||
return '已打开授权页';
|
||||
}
|
||||
return '登录地址已生成';
|
||||
}
|
||||
|
||||
function getCallbackStatusText(currentState = getLatestState()) {
|
||||
const status = normalizeCallbackStatus(currentState.contributionCallbackStatus);
|
||||
switch (status) {
|
||||
case 'captured':
|
||||
return '已捕获回调地址';
|
||||
case 'submitting':
|
||||
return '正在提交回调';
|
||||
case 'submitted':
|
||||
return '已提交回调';
|
||||
case 'not_required':
|
||||
return '当前流程无需手动回调';
|
||||
case 'failed':
|
||||
return '回调提交失败';
|
||||
case 'waiting':
|
||||
case 'idle':
|
||||
default:
|
||||
return normalizeString(currentState.contributionCallbackUrl)
|
||||
? '已捕获回调地址'
|
||||
: '等待回调';
|
||||
}
|
||||
}
|
||||
|
||||
function getSummaryText(currentState = getLatestState()) {
|
||||
return normalizeString(currentState.contributionStatusMessage) || DEFAULT_COPY;
|
||||
}
|
||||
|
||||
async function requestContributionMode(enabled) {
|
||||
const response = await runtime.sendMessage({
|
||||
type: 'SET_CONTRIBUTION_MODE',
|
||||
source: 'sidepanel',
|
||||
payload: { enabled: Boolean(enabled) },
|
||||
});
|
||||
|
||||
if (response?.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
if (!response?.state) {
|
||||
throw new Error('贡献模式切换后未返回最新状态。');
|
||||
}
|
||||
|
||||
helpers.applySettingsState?.(response.state);
|
||||
helpers.updateStatusDisplay?.(response.state);
|
||||
render();
|
||||
}
|
||||
|
||||
async function pollOnce(options = {}) {
|
||||
if (pollInFlight || !isContributionModeEnabled() || !hasActiveContributionSession()) {
|
||||
if (!hasActiveContributionSession()) {
|
||||
stopPolling();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
pollInFlight = true;
|
||||
try {
|
||||
const response = await runtime.sendMessage({
|
||||
type: 'POLL_CONTRIBUTION_STATUS',
|
||||
source: 'sidepanel',
|
||||
payload: {
|
||||
reason: options.reason || 'sidepanel_poll',
|
||||
},
|
||||
});
|
||||
|
||||
if (response?.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
if (response?.state) {
|
||||
helpers.applySettingsState?.(response.state);
|
||||
helpers.updateStatusDisplay?.(response.state);
|
||||
}
|
||||
} finally {
|
||||
pollInFlight = false;
|
||||
render();
|
||||
if (hasActiveContributionSession()) {
|
||||
schedulePolling();
|
||||
} else {
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startContributionFlow() {
|
||||
if (typeof helpers.startContributionAutoRun !== 'function') {
|
||||
throw new Error('贡献模式尚未接入主自动流程启动能力。');
|
||||
}
|
||||
|
||||
const started = await helpers.startContributionAutoRun();
|
||||
if (!started) {
|
||||
return;
|
||||
}
|
||||
|
||||
helpers.showToast?.('贡献自动流程已启动。', 'info', 1800);
|
||||
render();
|
||||
}
|
||||
|
||||
async function enterContributionMode() {
|
||||
const confirmed = await helpers.openConfirmModal?.({
|
||||
title: '贡献账号',
|
||||
message: '是否确认给作者 QLHazyCoder 提供账号以支持继续维护项目?',
|
||||
confirmLabel: '确定',
|
||||
confirmVariant: 'btn-primary',
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await requestContributionMode(true);
|
||||
helpers.showToast?.('已进入贡献模式。', 'success', 1800);
|
||||
}
|
||||
|
||||
async function exitContributionMode() {
|
||||
stopPolling();
|
||||
await requestContributionMode(false);
|
||||
helpers.showToast?.('已退出贡献模式。', 'info', 1800);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const currentState = getLatestState();
|
||||
const enabled = isContributionModeEnabled(currentState);
|
||||
const blocked = isModeSwitchBlocked();
|
||||
|
||||
if (enabled && dom.selectPanelMode) {
|
||||
dom.selectPanelMode.value = 'cpa';
|
||||
}
|
||||
|
||||
helpers.updatePanelModeUI?.();
|
||||
helpers.updateAccountRunHistorySettingsUI?.();
|
||||
|
||||
if (dom.contributionModePanel) {
|
||||
dom.contributionModePanel.hidden = !enabled;
|
||||
}
|
||||
if (dom.contributionModeText) {
|
||||
dom.contributionModeText.textContent = DEFAULT_COPY;
|
||||
}
|
||||
if (dom.contributionOauthStatus) {
|
||||
dom.contributionOauthStatus.textContent = getOauthStatusText(currentState);
|
||||
}
|
||||
if (dom.contributionCallbackStatus) {
|
||||
dom.contributionCallbackStatus.textContent = getCallbackStatusText(currentState);
|
||||
}
|
||||
if (dom.contributionModeSummary) {
|
||||
dom.contributionModeSummary.textContent = getSummaryText(currentState);
|
||||
}
|
||||
|
||||
syncContributionRows(enabled);
|
||||
syncContributionButton(enabled, blocked);
|
||||
|
||||
if (dom.selectPanelMode) {
|
||||
dom.selectPanelMode.disabled = enabled;
|
||||
}
|
||||
|
||||
if (dom.btnStartContribution) {
|
||||
dom.btnStartContribution.disabled = actionInFlight || blocked;
|
||||
}
|
||||
|
||||
if (dom.btnOpenContributionUpload) {
|
||||
dom.btnOpenContributionUpload.disabled = false;
|
||||
}
|
||||
|
||||
if (dom.btnExitContributionMode) {
|
||||
dom.btnExitContributionMode.disabled = actionInFlight || blocked;
|
||||
dom.btnExitContributionMode.title = blocked ? '当前流程运行中,暂时不能退出贡献模式' : '退出贡献模式';
|
||||
}
|
||||
|
||||
if (dom.btnOpenAccountRecords) {
|
||||
dom.btnOpenAccountRecords.disabled = enabled;
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
helpers.closeConfigMenu?.();
|
||||
helpers.closeAccountRecordsPanel?.();
|
||||
ensurePolling();
|
||||
} else {
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
helpers.updateConfigMenuControls?.();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
dom.btnContributionMode?.addEventListener('click', async () => {
|
||||
if (actionInFlight) {
|
||||
return;
|
||||
}
|
||||
actionInFlight = true;
|
||||
render();
|
||||
try {
|
||||
await enterContributionMode();
|
||||
} catch (error) {
|
||||
helpers.showToast?.(error.message, 'error');
|
||||
} finally {
|
||||
actionInFlight = false;
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
dom.btnStartContribution?.addEventListener('click', async () => {
|
||||
if (actionInFlight) {
|
||||
return;
|
||||
}
|
||||
actionInFlight = true;
|
||||
render();
|
||||
try {
|
||||
await startContributionFlow();
|
||||
} catch (error) {
|
||||
helpers.showToast?.(error.message, 'error');
|
||||
} finally {
|
||||
actionInFlight = false;
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
dom.btnOpenContributionUpload?.addEventListener('click', () => {
|
||||
try {
|
||||
helpers.openExternalUrl?.(contributionUploadUrl);
|
||||
} catch (error) {
|
||||
helpers.showToast?.(`打开上传页面失败:${error.message}`, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
dom.btnExitContributionMode?.addEventListener('click', async () => {
|
||||
if (actionInFlight) {
|
||||
return;
|
||||
}
|
||||
actionInFlight = true;
|
||||
render();
|
||||
try {
|
||||
await exitContributionMode();
|
||||
} catch (error) {
|
||||
helpers.showToast?.(error.message, 'error');
|
||||
} finally {
|
||||
actionInFlight = false;
|
||||
render();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
bindEvents,
|
||||
pollOnce,
|
||||
render,
|
||||
stopPolling,
|
||||
};
|
||||
}
|
||||
|
||||
globalScope.SidepanelContributionMode = {
|
||||
createContributionModeManager,
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
+112
-1
@@ -100,7 +100,9 @@ body {
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 200;
|
||||
@@ -228,12 +230,19 @@ header {
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-contribution-mode {
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.btn-contribution-mode.is-active {
|
||||
border-color: color-mix(in srgb, var(--orange) 58%, var(--border));
|
||||
background: color-mix(in srgb, var(--orange) 14%, var(--bg-base));
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Theme Toggle
|
||||
============================================================ */
|
||||
@@ -541,6 +550,108 @@ header {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.is-contribution-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.contribution-mode-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--orange) 28%, var(--border));
|
||||
border-radius: var(--radius-md);
|
||||
background:
|
||||
linear-gradient(180deg,
|
||||
color-mix(in srgb, var(--orange) 10%, var(--bg-base)),
|
||||
color-mix(in srgb, var(--bg-surface) 96%, var(--bg-base))
|
||||
);
|
||||
}
|
||||
|
||||
.contribution-mode-panel[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.contribution-mode-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.contribution-mode-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--orange) 16%, var(--bg-base));
|
||||
color: var(--orange);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.contribution-mode-text {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.contribution-mode-status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.contribution-mode-status-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--bg-base) 92%, var(--orange-soft));
|
||||
}
|
||||
|
||||
.contribution-mode-status-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.contribution-mode-status-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.contribution-mode-summary {
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--bg-base) 88%, var(--orange-soft));
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.contribution-mode-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.contribution-mode-actions .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.contribution-mode-status-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.section-mini-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -52,6 +52,8 @@
|
||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
||||
</svg>
|
||||
</button>
|
||||
<button id="btn-contribution-mode" class="btn btn-outline btn-sm btn-contribution-mode" type="button"
|
||||
aria-pressed="false">贡献</button>
|
||||
<button id="btn-theme" class="theme-toggle" title="切换主题">
|
||||
<svg class="icon-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -104,6 +106,29 @@
|
||||
<option value="sub2api">SUB2API</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="contribution-mode-panel" class="contribution-mode-panel" hidden>
|
||||
<div class="contribution-mode-panel-header">
|
||||
<span class="section-label">贡献模式</span>
|
||||
<span class="contribution-mode-badge">CPA</span>
|
||||
</div>
|
||||
<p id="contribution-mode-text" class="contribution-mode-text">当前账号将用于支持项目维护。扩展会自动申请贡献登录地址并持续跟踪授权状态;如检测到回调地址,会自动提交,无需手动复制。</p>
|
||||
<div class="contribution-mode-status-grid">
|
||||
<div class="contribution-mode-status-card">
|
||||
<span class="contribution-mode-status-label">OAUTH</span>
|
||||
<span id="contribution-oauth-status" class="contribution-mode-status-value">未生成登录地址</span>
|
||||
</div>
|
||||
<div class="contribution-mode-status-card">
|
||||
<span class="contribution-mode-status-label">回调</span>
|
||||
<span id="contribution-callback-status" class="contribution-mode-status-value">等待回调</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="contribution-mode-summary" class="contribution-mode-summary">等待开始贡献</div>
|
||||
<div class="contribution-mode-actions">
|
||||
<button id="btn-start-contribution" class="btn btn-primary btn-sm" type="button">开始贡献</button>
|
||||
<button id="btn-open-contribution-upload" class="btn btn-outline btn-sm" type="button">已有认证文件?前往上传</button>
|
||||
<button id="btn-exit-contribution-mode" class="btn btn-ghost btn-sm" type="button">退出贡献模式</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-vps-url">
|
||||
<span class="data-label">CPA</span>
|
||||
<div class="input-with-icon">
|
||||
@@ -151,7 +176,7 @@
|
||||
<input type="text" id="input-sub2api-default-proxy" class="data-input"
|
||||
placeholder="留空则不使用代理;或填写代理名称 / ID" />
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<div class="data-row" id="row-custom-password">
|
||||
<span class="data-label">账户密码</span>
|
||||
<div class="input-with-icon">
|
||||
<input type="password" id="input-password" class="data-input data-input-with-icon"
|
||||
@@ -292,16 +317,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<div class="data-row" id="row-account-run-history-text-enabled">
|
||||
<span class="data-label">本地同步</span>
|
||||
<div class="data-inline auto-delay-inline">
|
||||
<div class="data-inline">
|
||||
<label class="toggle-switch" for="input-account-run-history-text-enabled">
|
||||
<input type="checkbox" id="input-account-run-history-text-enabled" />
|
||||
<span class="toggle-switch-track" aria-hidden="true">
|
||||
<span class="toggle-switch-thumb"></span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="timing-field timing-field-right setting-inline-right">
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-verification-resend-count">
|
||||
<span class="data-label">验证码</span>
|
||||
<div class="data-inline">
|
||||
<div class="timing-field timing-field-labeled timing-field-right setting-inline-right">
|
||||
<span class="auto-delay-caption">验证码重发</span>
|
||||
<div class="auto-delay-controls">
|
||||
<input type="number" id="input-verification-resend-count" class="data-input auto-delay-input" value="4"
|
||||
@@ -628,6 +658,7 @@
|
||||
<script src="hotmail-manager.js"></script>
|
||||
<script src="icloud-manager.js"></script>
|
||||
<script src="luckmail-manager.js"></script>
|
||||
<script src="contribution-mode.js"></script>
|
||||
<script src="account-records-manager.js"></script>
|
||||
<script src="sidepanel.js"></script>
|
||||
</body>
|
||||
|
||||
+165
-10
@@ -33,6 +33,14 @@ const updateCardSummary = document.getElementById('update-card-summary');
|
||||
const updateReleaseList = document.getElementById('update-release-list');
|
||||
const btnOpenRelease = document.getElementById('btn-open-release');
|
||||
const settingsCard = document.getElementById('settings-card');
|
||||
const contributionModePanel = document.getElementById('contribution-mode-panel');
|
||||
const contributionModeText = document.getElementById('contribution-mode-text');
|
||||
const contributionOauthStatus = document.getElementById('contribution-oauth-status');
|
||||
const contributionCallbackStatus = document.getElementById('contribution-callback-status');
|
||||
const contributionModeSummary = document.getElementById('contribution-mode-summary');
|
||||
const btnStartContribution = document.getElementById('btn-start-contribution');
|
||||
const btnOpenContributionUpload = document.getElementById('btn-open-contribution-upload');
|
||||
const btnExitContributionMode = document.getElementById('btn-exit-contribution-mode');
|
||||
const displayOauthUrl = document.getElementById('display-oauth-url');
|
||||
const displayLocalhostUrl = document.getElementById('display-localhost-url');
|
||||
const displayStatus = document.getElementById('display-status');
|
||||
@@ -80,6 +88,7 @@ const rowSub2ApiGroup = document.getElementById('row-sub2api-group');
|
||||
const inputSub2ApiGroup = document.getElementById('input-sub2api-group');
|
||||
const rowSub2ApiDefaultProxy = document.getElementById('row-sub2api-default-proxy');
|
||||
const inputSub2ApiDefaultProxy = document.getElementById('input-sub2api-default-proxy');
|
||||
const rowCustomPassword = document.getElementById('row-custom-password');
|
||||
const selectMailProvider = document.getElementById('select-mail-provider');
|
||||
const btnMailLogin = document.getElementById('btn-mail-login');
|
||||
const rowMail2925Mode = document.getElementById('row-mail-2925-mode');
|
||||
@@ -175,6 +184,7 @@ const inputAutoDelayEnabled = document.getElementById('input-auto-delay-enabled'
|
||||
const inputAutoDelayMinutes = document.getElementById('input-auto-delay-minutes');
|
||||
const inputAutoStepDelaySeconds = document.getElementById('input-auto-step-delay-seconds');
|
||||
const inputVerificationResendCount = document.getElementById('input-verification-resend-count');
|
||||
const rowAccountRunHistoryTextEnabled = document.getElementById('row-account-run-history-text-enabled');
|
||||
const inputAccountRunHistoryTextEnabled = document.getElementById('input-account-run-history-text-enabled');
|
||||
const rowAccountRunHistoryHelperBaseUrl = document.getElementById('row-account-run-history-helper-base-url');
|
||||
const inputAccountRunHistoryHelperBaseUrl = document.getElementById('input-account-run-history-helper-base-url');
|
||||
@@ -776,18 +786,23 @@ async function openAutoRunFallbackRiskConfirmModal(totalRuns, fallbackThreadInte
|
||||
|
||||
function updateConfigMenuControls() {
|
||||
const disabled = configActionInFlight || settingsSaveInFlight;
|
||||
const contributionModeEnabled = Boolean(latestState?.contributionMode);
|
||||
if (contributionModeEnabled && configMenuOpen) {
|
||||
configMenuOpen = false;
|
||||
}
|
||||
const importLocked = disabled
|
||||
|| contributionModeEnabled
|
||||
|| currentAutoRun.autoRunning
|
||||
|| Object.values(getStepStatuses()).some((status) => status === 'running');
|
||||
if (btnConfigMenu) {
|
||||
btnConfigMenu.disabled = disabled;
|
||||
btnConfigMenu.disabled = disabled || contributionModeEnabled;
|
||||
btnConfigMenu.setAttribute('aria-expanded', String(configMenuOpen));
|
||||
}
|
||||
if (configMenu) {
|
||||
configMenu.hidden = !configMenuOpen;
|
||||
configMenu.hidden = contributionModeEnabled || !configMenuOpen;
|
||||
}
|
||||
if (btnExportSettings) {
|
||||
btnExportSettings.disabled = disabled;
|
||||
btnExportSettings.disabled = disabled || contributionModeEnabled;
|
||||
}
|
||||
if (btnImportSettings) {
|
||||
btnImportSettings.disabled = importLocked;
|
||||
@@ -870,6 +885,12 @@ function hasSavedProgress(state = latestState) {
|
||||
return Object.values(statuses).some((status) => status !== 'pending');
|
||||
}
|
||||
|
||||
function isContributionModeSwitchBlocked(state = latestState) {
|
||||
const statuses = getStepStatuses(state);
|
||||
const anyRunning = Object.values(statuses).some((status) => status === 'running');
|
||||
return anyRunning || isAutoRunLockedPhase() || isAutoRunPausedPhase() || isAutoRunScheduledPhase();
|
||||
}
|
||||
|
||||
function shouldOfferAutoModeChoice(state = latestState) {
|
||||
return hasSavedProgress(state) && getFirstUnfinishedStep(state) !== null;
|
||||
}
|
||||
@@ -1319,6 +1340,7 @@ function collectSettingsPayload() {
|
||||
const selectedCloudflareTempEmailDomain = normalizeCloudflareTempEmailDomainValue(
|
||||
!cloudflareTempEmailDomainEditMode ? selectTempEmailDomain.value : tempEmailActiveDomain
|
||||
) || tempEmailActiveDomain;
|
||||
const contributionModeEnabled = Boolean(latestState?.contributionMode);
|
||||
return {
|
||||
panelMode: selectPanelMode.value,
|
||||
vpsUrl: inputVpsUrl.value.trim(),
|
||||
@@ -1329,14 +1351,18 @@ function collectSettingsPayload() {
|
||||
sub2apiPassword: inputSub2ApiPassword.value,
|
||||
sub2apiGroupName: inputSub2ApiGroup.value.trim(),
|
||||
sub2apiDefaultProxyName: inputSub2ApiDefaultProxy.value.trim(),
|
||||
customPassword: inputPassword.value,
|
||||
...(contributionModeEnabled ? {} : {
|
||||
customPassword: inputPassword.value,
|
||||
}),
|
||||
mailProvider: selectMailProvider.value,
|
||||
mail2925Mode: getSelectedMail2925Mode(),
|
||||
emailGenerator: selectEmailGenerator.value,
|
||||
autoDeleteUsedIcloudAlias: checkboxAutoDeleteIcloud?.checked,
|
||||
icloudHostPreference: selectIcloudHostPreference?.value || 'auto',
|
||||
accountRunHistoryTextEnabled: Boolean(inputAccountRunHistoryTextEnabled?.checked),
|
||||
accountRunHistoryHelperBaseUrl: normalizeAccountRunHistoryHelperBaseUrlValue(inputAccountRunHistoryHelperBaseUrl?.value),
|
||||
...(contributionModeEnabled ? {} : {
|
||||
accountRunHistoryTextEnabled: Boolean(inputAccountRunHistoryTextEnabled?.checked),
|
||||
accountRunHistoryHelperBaseUrl: normalizeAccountRunHistoryHelperBaseUrlValue(inputAccountRunHistoryHelperBaseUrl?.value),
|
||||
}),
|
||||
...buildManagedAliasBaseEmailPayload(),
|
||||
inbucketHost: inputInbucketHost.value.trim(),
|
||||
inbucketMailbox: inputInbucketMailbox.value.trim(),
|
||||
@@ -1461,7 +1487,9 @@ function updateAccountRunHistorySettingsUI() {
|
||||
return;
|
||||
}
|
||||
|
||||
rowAccountRunHistoryHelperBaseUrl.style.display = inputAccountRunHistoryTextEnabled.checked ? '' : 'none';
|
||||
rowAccountRunHistoryHelperBaseUrl.style.display = inputAccountRunHistoryTextEnabled.checked && !latestState?.contributionMode
|
||||
? ''
|
||||
: 'none';
|
||||
}
|
||||
|
||||
function setSettingsCardLocked(locked) {
|
||||
@@ -1632,6 +1660,7 @@ function applyAutoRunStatus(payload = currentAutoRun) {
|
||||
syncScheduledCountdownTicker();
|
||||
updateStopButtonState(scheduled || paused || locked || Object.values(getStepStatuses()).some(status => status === 'running'));
|
||||
updateConfigMenuControls();
|
||||
renderContributionMode();
|
||||
}
|
||||
|
||||
function initializeManualStepActions() {
|
||||
@@ -1813,6 +1842,7 @@ async function restoreState() {
|
||||
|
||||
updateStatusDisplay(latestState);
|
||||
updateProgressCounter();
|
||||
renderContributionMode();
|
||||
} catch (err) {
|
||||
console.error('Failed to restore state:', err);
|
||||
}
|
||||
@@ -2072,7 +2102,7 @@ async function initializeReleaseInfo() {
|
||||
}
|
||||
|
||||
function syncPasswordField(state) {
|
||||
inputPassword.value = state.customPassword || state.password || '';
|
||||
inputPassword.value = state?.contributionMode ? '' : (state.customPassword || state.password || '');
|
||||
}
|
||||
|
||||
function isCustomMailProvider(provider = selectMailProvider.value) {
|
||||
@@ -2634,6 +2664,7 @@ function updateButtonStates() {
|
||||
if (checkboxAutoDeleteIcloud) checkboxAutoDeleteIcloud.disabled = disableIcloudControls;
|
||||
if (btnContributionMode) btnContributionMode.disabled = isContributionButtonLocked();
|
||||
updateStopButtonState(anyRunning || autoScheduled || isAutoRunPausedPhase() || autoLocked);
|
||||
renderContributionMode();
|
||||
}
|
||||
|
||||
function updateStopButtonState(active) {
|
||||
@@ -3010,7 +3041,66 @@ const renderAccountRecords = accountRecordsManager?.render
|
||||
|| (() => { });
|
||||
const bindAccountRecordEvents = accountRecordsManager?.bindEvents
|
||||
|| (() => { });
|
||||
const closeAccountRecordsPanel = accountRecordsManager?.closePanel
|
||||
|| (() => { });
|
||||
bindAccountRecordEvents();
|
||||
const contributionModeManager = window.SidepanelContributionMode?.createContributionModeManager({
|
||||
state: {
|
||||
getLatestState: () => latestState,
|
||||
},
|
||||
dom: {
|
||||
btnConfigMenu,
|
||||
btnContributionMode,
|
||||
contributionCallbackStatus,
|
||||
btnExitContributionMode,
|
||||
btnOpenAccountRecords,
|
||||
btnOpenContributionUpload,
|
||||
btnStartContribution,
|
||||
contributionModePanel,
|
||||
contributionModeSummary,
|
||||
contributionModeText,
|
||||
contributionOauthStatus,
|
||||
rowAccountRunHistoryHelperBaseUrl,
|
||||
rowAccountRunHistoryTextEnabled,
|
||||
rowCustomPassword,
|
||||
rowLocalCpaStep9Mode,
|
||||
rowSub2ApiDefaultProxy,
|
||||
rowSub2ApiEmail,
|
||||
rowSub2ApiGroup,
|
||||
rowSub2ApiPassword,
|
||||
rowSub2ApiUrl,
|
||||
rowVpsPassword,
|
||||
rowVpsUrl,
|
||||
selectPanelMode,
|
||||
},
|
||||
helpers: {
|
||||
applySettingsState,
|
||||
closeAccountRecordsPanel,
|
||||
closeConfigMenu,
|
||||
getContributionNickname: () => latestState?.email || '',
|
||||
isModeSwitchBlocked: isContributionModeSwitchBlocked,
|
||||
openConfirmModal,
|
||||
openExternalUrl,
|
||||
showToast,
|
||||
startContributionAutoRun: () => startAutoRunFromCurrentSettings(),
|
||||
updateAccountRunHistorySettingsUI,
|
||||
updateConfigMenuControls,
|
||||
updatePanelModeUI,
|
||||
updateStatusDisplay,
|
||||
},
|
||||
runtime: {
|
||||
sendMessage: (message) => chrome.runtime.sendMessage(message),
|
||||
},
|
||||
constants: {
|
||||
contributionOauthUrl: 'https://apikey.qzz.io/oauth/',
|
||||
contributionUploadUrl: 'https://apikey.qzz.io/',
|
||||
},
|
||||
});
|
||||
const renderContributionMode = contributionModeManager?.render
|
||||
|| (() => { });
|
||||
const bindContributionModeEvents = contributionModeManager?.bindEvents
|
||||
|| (() => { });
|
||||
bindContributionModeEvents();
|
||||
renderStepsList();
|
||||
|
||||
async function exportSettingsFile() {
|
||||
@@ -3370,9 +3460,65 @@ autoStartModal?.addEventListener('click', (event) => {
|
||||
});
|
||||
btnAutoStartClose?.addEventListener('click', () => resolveModalChoice(null));
|
||||
|
||||
async function startAutoRunFromCurrentSettings() {
|
||||
const totalRuns = getRunCountValue();
|
||||
let mode = 'restart';
|
||||
const autoRunSkipFailures = inputAutoSkipFailures.checked;
|
||||
const fallbackThreadIntervalMinutes = normalizeAutoRunThreadIntervalMinutes(
|
||||
inputAutoSkipFailuresThreadIntervalMinutes.value
|
||||
);
|
||||
inputAutoSkipFailuresThreadIntervalMinutes.value = String(fallbackThreadIntervalMinutes);
|
||||
|
||||
if (shouldOfferAutoModeChoice()) {
|
||||
const startStep = getFirstUnfinishedStep();
|
||||
const runningStep = getRunningSteps()[0] ?? null;
|
||||
const choice = await openAutoStartChoiceDialog(startStep, { runningStep });
|
||||
if (!choice) {
|
||||
return false;
|
||||
}
|
||||
mode = choice;
|
||||
}
|
||||
|
||||
if (shouldWarnAutoRunFallbackRisk(totalRuns, autoRunSkipFailures)
|
||||
&& !isAutoRunFallbackRiskPromptDismissed()) {
|
||||
const result = await openAutoRunFallbackRiskConfirmModal(totalRuns, fallbackThreadIntervalMinutes);
|
||||
if (!result.confirmed) {
|
||||
return false;
|
||||
}
|
||||
if (result.dismissPrompt) {
|
||||
setAutoRunFallbackRiskPromptDismissed(true);
|
||||
}
|
||||
}
|
||||
|
||||
btnAutoRun.disabled = true;
|
||||
inputRunCount.disabled = true;
|
||||
const delayEnabled = inputAutoDelayEnabled.checked;
|
||||
const delayMinutes = normalizeAutoDelayMinutes(inputAutoDelayMinutes.value);
|
||||
inputAutoDelayMinutes.value = String(delayMinutes);
|
||||
btnAutoRun.innerHTML = delayEnabled
|
||||
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg> 璁″垝涓?..'
|
||||
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg> 杩愯涓?..';
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: delayEnabled ? 'SCHEDULE_AUTO_RUN' : 'AUTO_RUN',
|
||||
source: 'sidepanel',
|
||||
payload: {
|
||||
totalRuns,
|
||||
delayMinutes,
|
||||
autoRunSkipFailures,
|
||||
contributionMode: Boolean(latestState?.contributionMode),
|
||||
mode,
|
||||
},
|
||||
});
|
||||
if (response?.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Auto Run
|
||||
btnAutoRun.addEventListener('click', async () => {
|
||||
try {
|
||||
return await startAutoRunFromCurrentSettings();
|
||||
const totalRuns = getRunCountValue();
|
||||
let mode = 'restart';
|
||||
const autoRunSkipFailures = inputAutoSkipFailures.checked;
|
||||
@@ -4072,12 +4218,20 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
if (message.payload.email !== undefined) {
|
||||
inputEmail.value = message.payload.email || '';
|
||||
}
|
||||
if (message.payload.password !== undefined) {
|
||||
inputPassword.value = message.payload.password || '';
|
||||
if (
|
||||
message.payload.password !== undefined
|
||||
|| message.payload.customPassword !== undefined
|
||||
|| message.payload.contributionMode !== undefined
|
||||
) {
|
||||
syncPasswordField(latestState || {});
|
||||
}
|
||||
if (message.payload.localCpaStep9Mode !== undefined) {
|
||||
setLocalCpaStep9Mode(message.payload.localCpaStep9Mode);
|
||||
}
|
||||
if (message.payload.panelMode !== undefined) {
|
||||
selectPanelMode.value = message.payload.panelMode || 'cpa';
|
||||
updatePanelModeUI();
|
||||
}
|
||||
if (message.payload.oauthUrl !== undefined) {
|
||||
displayOauthUrl.textContent = message.payload.oauthUrl || '等待中...';
|
||||
displayOauthUrl.classList.toggle('has-value', Boolean(message.payload.oauthUrl));
|
||||
@@ -4180,6 +4334,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
)
|
||||
);
|
||||
}
|
||||
renderContributionMode();
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const backgroundSource = fs.readFileSync('background.js', 'utf8');
|
||||
|
||||
function extractFunction(source, 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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function createMockResponse(ok, status, payload) {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
async json() {
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('background imports contribution oauth module and keeps contribution runtime out of persisted settings', () => {
|
||||
const persistedStart = backgroundSource.indexOf('const PERSISTED_SETTING_DEFAULTS = {');
|
||||
const persistedEnd = backgroundSource.indexOf('const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);');
|
||||
const defaultStateStart = backgroundSource.indexOf('const DEFAULT_STATE = {');
|
||||
const defaultStateEnd = backgroundSource.indexOf('async function getState()');
|
||||
|
||||
const persistedBlock = backgroundSource.slice(persistedStart, persistedEnd);
|
||||
const defaultStateBlock = backgroundSource.slice(defaultStateStart, defaultStateEnd);
|
||||
|
||||
assert.match(backgroundSource, /background\/contribution-oauth\.js/);
|
||||
assert.doesNotMatch(persistedBlock, /contributionSessionId|contributionAuthUrl|contributionCallbackUrl|contributionStatus/);
|
||||
assert.match(defaultStateBlock, /contributionMode:\s*false|CONTRIBUTION_RUNTIME_DEFAULTS/);
|
||||
});
|
||||
|
||||
test('contribution oauth module exposes a factory', () => {
|
||||
const source = fs.readFileSync('background/contribution-oauth.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', 'fetch', `${source}; return self.MultiPageBackgroundContributionOAuth;`)(
|
||||
globalScope,
|
||||
async () => createMockResponse(true, 200, { ok: true })
|
||||
);
|
||||
|
||||
assert.equal(typeof api?.createContributionOAuthManager, 'function');
|
||||
assert.equal(Array.isArray(api?.RUNTIME_KEYS), true);
|
||||
});
|
||||
|
||||
test('buildContributionModeState preserves active contribution runtime while forcing CPA mode', () => {
|
||||
const bundle = extractFunction(backgroundSource, 'buildContributionModeState');
|
||||
|
||||
const api = new Function(`
|
||||
const DEFAULT_STATE = { panelMode: 'cpa' };
|
||||
const CONTRIBUTION_RUNTIME_DEFAULTS = {
|
||||
contributionMode: false,
|
||||
contributionModeExpected: false,
|
||||
contributionSessionId: '',
|
||||
contributionAuthUrl: '',
|
||||
contributionAuthState: '',
|
||||
contributionCallbackUrl: '',
|
||||
contributionStatus: '',
|
||||
contributionStatusMessage: '',
|
||||
contributionLastPollAt: 0,
|
||||
contributionCallbackStatus: 'idle',
|
||||
contributionCallbackMessage: '',
|
||||
contributionAuthOpenedAt: 0,
|
||||
contributionAuthTabId: 0,
|
||||
};
|
||||
const CONTRIBUTION_RUNTIME_KEYS = Object.keys(CONTRIBUTION_RUNTIME_DEFAULTS);
|
||||
${bundle}
|
||||
return { buildContributionModeState };
|
||||
`)();
|
||||
|
||||
assert.deepStrictEqual(
|
||||
api.buildContributionModeState(true, {
|
||||
panelMode: 'sub2api',
|
||||
customPassword: 'Secret123!',
|
||||
accountRunHistoryTextEnabled: true,
|
||||
}, {
|
||||
contributionSessionId: 'session-001',
|
||||
contributionAuthUrl: 'https://auth.example.com',
|
||||
contributionStatus: 'waiting',
|
||||
contributionCallbackStatus: 'waiting',
|
||||
}),
|
||||
{
|
||||
contributionMode: true,
|
||||
contributionModeExpected: true,
|
||||
contributionSessionId: 'session-001',
|
||||
contributionAuthUrl: 'https://auth.example.com',
|
||||
contributionAuthState: '',
|
||||
contributionCallbackUrl: '',
|
||||
contributionStatus: 'waiting',
|
||||
contributionStatusMessage: '',
|
||||
contributionLastPollAt: 0,
|
||||
contributionCallbackStatus: 'waiting',
|
||||
contributionCallbackMessage: '',
|
||||
contributionAuthOpenedAt: 0,
|
||||
contributionAuthTabId: 0,
|
||||
panelMode: 'cpa',
|
||||
customPassword: '',
|
||||
accountRunHistoryTextEnabled: false,
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
api.buildContributionModeState(false, {
|
||||
panelMode: 'sub2api',
|
||||
customPassword: 'Secret123!',
|
||||
accountRunHistoryTextEnabled: true,
|
||||
}, {
|
||||
contributionSessionId: 'session-001',
|
||||
contributionAuthUrl: 'https://auth.example.com',
|
||||
contributionStatus: 'waiting',
|
||||
}),
|
||||
{
|
||||
contributionMode: false,
|
||||
contributionModeExpected: false,
|
||||
contributionSessionId: '',
|
||||
contributionAuthUrl: '',
|
||||
contributionAuthState: '',
|
||||
contributionCallbackUrl: '',
|
||||
contributionStatus: '',
|
||||
contributionStatusMessage: '',
|
||||
contributionLastPollAt: 0,
|
||||
contributionCallbackStatus: 'idle',
|
||||
contributionCallbackMessage: '',
|
||||
contributionAuthOpenedAt: 0,
|
||||
contributionAuthTabId: 0,
|
||||
panelMode: 'sub2api',
|
||||
customPassword: 'Secret123!',
|
||||
accountRunHistoryTextEnabled: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('resetState preserves contribution runtime across reset', () => {
|
||||
assert.match(backgroundSource, /CONTRIBUTION_RUNTIME_KEYS/);
|
||||
assert.match(backgroundSource, /const contributionModeState = buildContributionModeState/);
|
||||
assert.match(backgroundSource, /\.\.\.contributionModeState/);
|
||||
});
|
||||
|
||||
test('message router handles contribution mode, start flow, and status polling messages', async () => {
|
||||
const source = fs.readFileSync('background/message-router.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope);
|
||||
|
||||
const calls = [];
|
||||
const router = api.createMessageRouter({
|
||||
ensureManualInteractionAllowed: async () => ({
|
||||
stepStatuses: { 1: 'pending', 2: 'completed' },
|
||||
contributionMode: true,
|
||||
}),
|
||||
pollContributionStatus: async (options) => {
|
||||
calls.push({ type: 'poll', options });
|
||||
return { contributionStatus: 'waiting' };
|
||||
},
|
||||
setContributionMode: async (enabled) => {
|
||||
calls.push({ type: 'toggle', enabled });
|
||||
return {
|
||||
contributionMode: Boolean(enabled),
|
||||
panelMode: 'cpa',
|
||||
};
|
||||
},
|
||||
startContributionFlow: async (options) => {
|
||||
calls.push({ type: 'start', options });
|
||||
return {
|
||||
contributionMode: true,
|
||||
contributionSessionId: 'session-001',
|
||||
contributionStatus: 'started',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const enableResponse = await router.handleMessage({
|
||||
type: 'SET_CONTRIBUTION_MODE',
|
||||
payload: { enabled: true },
|
||||
});
|
||||
const startResponse = await router.handleMessage({
|
||||
type: 'START_CONTRIBUTION_FLOW',
|
||||
payload: { nickname: '阿青' },
|
||||
});
|
||||
const pollResponse = await router.handleMessage({
|
||||
type: 'POLL_CONTRIBUTION_STATUS',
|
||||
payload: { reason: 'test_poll' },
|
||||
});
|
||||
|
||||
assert.equal(enableResponse.ok, true);
|
||||
assert.equal(startResponse.ok, true);
|
||||
assert.equal(pollResponse.ok, true);
|
||||
assert.deepStrictEqual(calls, [
|
||||
{ type: 'toggle', enabled: true },
|
||||
{ type: 'start', options: { nickname: '阿青' } },
|
||||
{ type: 'poll', options: { reason: 'test_poll' } },
|
||||
]);
|
||||
});
|
||||
|
||||
test('message router re-syncs contribution mode before AUTO_RUN when sidepanel payload marks contributionMode=true', async () => {
|
||||
const source = fs.readFileSync('background/message-router.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope);
|
||||
|
||||
const calls = [];
|
||||
const router = api.createMessageRouter({
|
||||
clearStopRequest: () => {},
|
||||
getPendingAutoRunTimerPlan: () => null,
|
||||
getState: async () => ({
|
||||
contributionMode: false,
|
||||
stepStatuses: {},
|
||||
}),
|
||||
normalizeRunCount: (value) => Number(value) || 1,
|
||||
setContributionMode: async (enabled) => {
|
||||
calls.push({ type: 'toggle', enabled });
|
||||
return { contributionMode: true };
|
||||
},
|
||||
setState: async (updates) => {
|
||||
calls.push({ type: 'setState', updates });
|
||||
},
|
||||
startAutoRunLoop: (totalRuns, options) => {
|
||||
calls.push({ type: 'startAutoRunLoop', totalRuns, options });
|
||||
},
|
||||
});
|
||||
|
||||
const response = await router.handleMessage({
|
||||
type: 'AUTO_RUN',
|
||||
payload: {
|
||||
totalRuns: 2,
|
||||
autoRunSkipFailures: true,
|
||||
mode: 'restart',
|
||||
contributionMode: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.ok, true);
|
||||
assert.deepStrictEqual(calls, [
|
||||
{ type: 'toggle', enabled: true },
|
||||
{ type: 'setState', updates: { autoRunSkipFailures: true } },
|
||||
{ type: 'startAutoRunLoop', totalRuns: 2, options: { autoRunSkipFailures: true, mode: 'restart' } },
|
||||
]);
|
||||
});
|
||||
|
||||
test('account run history snapshot sync is disabled in contribution mode', () => {
|
||||
const source = fs.readFileSync('background/account-run-history.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundAccountRunHistory;`)(globalScope);
|
||||
|
||||
const helpers = api.createAccountRunHistoryHelpers({
|
||||
addLog: async () => {},
|
||||
buildLocalHelperEndpoint: (baseUrl, path) => `${baseUrl}${path}`,
|
||||
chrome: {
|
||||
storage: {
|
||||
local: {
|
||||
get: async () => ({}),
|
||||
set: async () => {},
|
||||
},
|
||||
},
|
||||
},
|
||||
getErrorMessage: (error) => error?.message || String(error || ''),
|
||||
getState: async () => ({}),
|
||||
normalizeAccountRunHistoryHelperBaseUrl: (value) => String(value || '').trim(),
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
helpers.shouldSyncAccountRunHistorySnapshot({
|
||||
contributionMode: true,
|
||||
accountRunHistoryTextEnabled: true,
|
||||
accountRunHistoryHelperBaseUrl: 'http://127.0.0.1:17373',
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
helpers.shouldSyncAccountRunHistorySnapshot({
|
||||
contributionMode: false,
|
||||
accountRunHistoryTextEnabled: true,
|
||||
accountRunHistoryHelperBaseUrl: 'http://127.0.0.1:17373',
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('contribution oauth manager starts session, opens auth url, polls real statuses, and treats callback submit no-op as success', async () => {
|
||||
const source = fs.readFileSync('background/contribution-oauth.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const fetchCalls = [];
|
||||
const tabCalls = [];
|
||||
const closeCallbackCalls = [];
|
||||
let currentState = {
|
||||
contributionMode: true,
|
||||
email: 'user@example.com',
|
||||
contributionSessionId: '',
|
||||
contributionStatus: '',
|
||||
contributionCallbackStatus: 'idle',
|
||||
};
|
||||
const broadcasts = [];
|
||||
|
||||
const api = new Function('self', 'fetch', `${source}; return self.MultiPageBackgroundContributionOAuth;`)(
|
||||
globalScope,
|
||||
async (url, options = {}) => {
|
||||
fetchCalls.push({ url, options });
|
||||
if (String(url).endsWith('/start')) {
|
||||
return createMockResponse(true, 200, {
|
||||
ok: true,
|
||||
session_id: 'session-001',
|
||||
state: 'oauth-state-001',
|
||||
auth_url: 'https://auth.example.com/oauth?state=oauth-state-001',
|
||||
message: '登录地址已生成',
|
||||
});
|
||||
}
|
||||
if (String(url).includes('/status?')) {
|
||||
return createMockResponse(true, 200, {
|
||||
ok: true,
|
||||
session_id: 'session-001',
|
||||
status: 'waiting',
|
||||
message: '等待 OAuth 回调完成',
|
||||
});
|
||||
}
|
||||
if (String(url).endsWith('/submit-callback')) {
|
||||
return createMockResponse(false, 400, {
|
||||
ok: false,
|
||||
message: '当前已启用自动回调转发,无需手动粘贴回调 URL。',
|
||||
callback_url_received: true,
|
||||
});
|
||||
}
|
||||
return createMockResponse(true, 200, {
|
||||
ok: true,
|
||||
session_id: 'session-001',
|
||||
status: 'processing',
|
||||
message: '授权已完成,正在自动审核并导入。',
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const manager = api.createContributionOAuthManager({
|
||||
addLog: async () => {},
|
||||
broadcastDataUpdate(updates) {
|
||||
broadcasts.push(updates);
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
chrome: {
|
||||
tabs: {
|
||||
async create(payload) {
|
||||
tabCalls.push(payload);
|
||||
return { id: 88, url: payload.url };
|
||||
},
|
||||
async update() {
|
||||
return null;
|
||||
},
|
||||
onUpdated: { addListener() {} },
|
||||
},
|
||||
webNavigation: {
|
||||
onCommitted: { addListener() {} },
|
||||
onHistoryStateUpdated: { addListener() {} },
|
||||
},
|
||||
},
|
||||
closeLocalhostCallbackTabs: async (callbackUrl) => {
|
||||
closeCallbackCalls.push(callbackUrl);
|
||||
},
|
||||
getState: async () => currentState,
|
||||
setState: async (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
});
|
||||
|
||||
const startedState = await manager.startContributionFlow();
|
||||
assert.equal(startedState.contributionSessionId, 'session-001');
|
||||
assert.equal(startedState.contributionAuthState, 'oauth-state-001');
|
||||
assert.equal(startedState.contributionStatus, 'waiting');
|
||||
assert.equal(startedState.contributionAuthTabId, 88);
|
||||
assert.equal(tabCalls.length, 1);
|
||||
assert.match(fetchCalls[0].url, /\/start$/);
|
||||
assert.match(fetchCalls[1].url, /\/status\?/);
|
||||
|
||||
const callbackState = await manager.handleCapturedCallback(
|
||||
'http://localhost:1455/auth/callback?code=abc123&state=oauth-state-001',
|
||||
{ source: 'test' }
|
||||
);
|
||||
|
||||
assert.equal(callbackState.contributionCallbackUrl, 'http://localhost:1455/auth/callback?code=abc123&state=oauth-state-001');
|
||||
assert.equal(callbackState.contributionCallbackStatus, 'not_required');
|
||||
assert.equal(closeCallbackCalls[0], 'http://localhost:1455/auth/callback?code=abc123&state=oauth-state-001');
|
||||
assert.ok(fetchCalls.some((call) => String(call.url).endsWith('/submit-callback')));
|
||||
assert.ok(broadcasts.some((item) => item.contributionCallbackStatus === 'captured'));
|
||||
assert.ok(broadcasts.some((item) => item.contributionCallbackStatus === 'not_required'));
|
||||
});
|
||||
|
||||
test('refreshOAuthUrlBeforeStep6 uses contribution oauth session instead of panel bridge in contribution mode', async () => {
|
||||
const bundle = extractFunction(backgroundSource, 'refreshOAuthUrlBeforeStep6');
|
||||
const calls = [];
|
||||
|
||||
const api = new Function(`
|
||||
${bundle}
|
||||
return { refreshOAuthUrlBeforeStep6 };
|
||||
`)();
|
||||
|
||||
globalThis.addLog = async (message) => {
|
||||
calls.push({ type: 'log', message });
|
||||
};
|
||||
globalThis.contributionOAuthManager = {
|
||||
async startContributionFlow(options) {
|
||||
calls.push({ type: 'contribution', options });
|
||||
return {
|
||||
contributionAuthUrl: 'https://auth.example.com/oauth?state=oauth-state-001',
|
||||
};
|
||||
},
|
||||
};
|
||||
globalThis.handleStepData = async (step, payload) => {
|
||||
calls.push({ type: 'step', step, payload });
|
||||
};
|
||||
globalThis.getPanelModeLabel = () => 'CPA';
|
||||
globalThis.requestOAuthUrlFromPanel = async () => {
|
||||
calls.push({ type: 'panel' });
|
||||
return { oauthUrl: 'https://panel.example.com/oauth' };
|
||||
};
|
||||
globalThis.LOG_PREFIX = '[test]';
|
||||
|
||||
const oauthUrl = await api.refreshOAuthUrlBeforeStep6({
|
||||
contributionMode: true,
|
||||
email: 'user@example.com',
|
||||
});
|
||||
|
||||
assert.equal(oauthUrl, 'https://auth.example.com/oauth?state=oauth-state-001');
|
||||
assert.deepStrictEqual(calls, [
|
||||
{ type: 'log', message: '步骤 7:contributionMode=true,正在通过公开贡献接口申请 OAuth 链接...' },
|
||||
{ type: 'log', message: '步骤 7:贡献模式正在申请贡献登录地址...' },
|
||||
{
|
||||
type: 'contribution',
|
||||
options: {
|
||||
nickname: 'user@example.com',
|
||||
openAuthTab: false,
|
||||
stateOverride: {
|
||||
contributionMode: true,
|
||||
email: 'user@example.com',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'step',
|
||||
step: 1,
|
||||
payload: {
|
||||
oauthUrl: 'https://auth.example.com/oauth?state=oauth-state-001',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
delete globalThis.addLog;
|
||||
delete globalThis.contributionOAuthManager;
|
||||
delete globalThis.handleStepData;
|
||||
delete globalThis.getPanelModeLabel;
|
||||
delete globalThis.requestOAuthUrlFromPanel;
|
||||
delete globalThis.LOG_PREFIX;
|
||||
});
|
||||
|
||||
test('executeStep10 blocks silent fallback when contributionModeExpected=true but contributionMode=false', async () => {
|
||||
const bundle = extractFunction(backgroundSource, 'executeStep10');
|
||||
|
||||
const api = new Function(`
|
||||
${bundle}
|
||||
return { executeStep10 };
|
||||
`)();
|
||||
|
||||
globalThis.executeContributionStep10 = async () => ({ ok: true });
|
||||
globalThis.step10Executor = {
|
||||
async executeStep10() {
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => api.executeStep10({
|
||||
contributionModeExpected: true,
|
||||
contributionMode: false,
|
||||
}),
|
||||
/步骤 10:当前自动流程预期使用贡献模式/
|
||||
);
|
||||
|
||||
delete globalThis.executeContributionStep10;
|
||||
delete globalThis.step10Executor;
|
||||
});
|
||||
@@ -406,7 +406,10 @@ return {
|
||||
});
|
||||
|
||||
test('resetState preserves LuckMail session config, used map, and preserve tag cache while clearing runtime purchase state', async () => {
|
||||
const bundle = extractFunction('resetState');
|
||||
const bundle = [
|
||||
extractFunction('buildContributionModeState'),
|
||||
extractFunction('resetState'),
|
||||
].join('\n');
|
||||
|
||||
const factory = new Function([
|
||||
'let cleared = false;',
|
||||
@@ -418,6 +421,7 @@ test('resetState preserves LuckMail session config, used map, and preserve tag c
|
||||
" luckmailBaseUrl: 'https://mails.luckyous.com',",
|
||||
" luckmailEmailType: 'ms_graph',",
|
||||
" luckmailDomain: '',",
|
||||
" panelMode: 'cpa',",
|
||||
' luckmailUsedPurchases: {},',
|
||||
' luckmailPreserveTagId: 0,',
|
||||
" luckmailPreserveTagName: '保留',",
|
||||
@@ -425,6 +429,21 @@ test('resetState preserves LuckMail session config, used map, and preserve tag c
|
||||
" currentLuckmailMailCursor: { messageId: 'stale' },",
|
||||
' email: null,',
|
||||
'};',
|
||||
'const CONTRIBUTION_RUNTIME_DEFAULTS = {',
|
||||
' contributionMode: false,',
|
||||
" contributionSessionId: '',",
|
||||
" contributionAuthUrl: '',",
|
||||
" contributionAuthState: '',",
|
||||
" contributionCallbackUrl: '',",
|
||||
" contributionStatus: '',",
|
||||
" contributionStatusMessage: '',",
|
||||
' contributionLastPollAt: 0,',
|
||||
" contributionCallbackStatus: 'idle',",
|
||||
" contributionCallbackMessage: '',",
|
||||
' contributionAuthOpenedAt: 0,',
|
||||
' contributionAuthTabId: 0,',
|
||||
'};',
|
||||
'const CONTRIBUTION_RUNTIME_KEYS = Object.keys(CONTRIBUTION_RUNTIME_DEFAULTS);',
|
||||
'function normalizeLuckmailBaseUrl(value) {',
|
||||
" const normalized = String(value || '').trim() || 'https://mails.luckyous.com';",
|
||||
" return normalized.replace(/\\/$/, '');",
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const sidepanelSource = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
|
||||
|
||||
function extractFunction(name) {
|
||||
const markers = [`async function ${name}(`, `function ${name}(`];
|
||||
const start = markers
|
||||
.map((marker) => sidepanelSource.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 < sidepanelSource.length; i += 1) {
|
||||
const ch = sidepanelSource[i];
|
||||
if (ch === '(') {
|
||||
parenDepth += 1;
|
||||
} else if (ch === ')') {
|
||||
parenDepth -= 1;
|
||||
if (parenDepth === 0) {
|
||||
signatureEnded = true;
|
||||
}
|
||||
} else if (ch === '{' && signatureEnded) {
|
||||
braceStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
let end = braceStart;
|
||||
for (; end < sidepanelSource.length; end += 1) {
|
||||
const ch = sidepanelSource[end];
|
||||
if (ch === '{') depth += 1;
|
||||
if (ch === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
end += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sidepanelSource.slice(start, end);
|
||||
}
|
||||
|
||||
function createClassList() {
|
||||
const values = new Set();
|
||||
return {
|
||||
add(name) {
|
||||
values.add(name);
|
||||
},
|
||||
remove(name) {
|
||||
values.delete(name);
|
||||
},
|
||||
toggle(name, force) {
|
||||
if (force === undefined) {
|
||||
if (values.has(name)) {
|
||||
values.delete(name);
|
||||
return false;
|
||||
}
|
||||
values.add(name);
|
||||
return true;
|
||||
}
|
||||
if (force) {
|
||||
values.add(name);
|
||||
return true;
|
||||
}
|
||||
values.delete(name);
|
||||
return false;
|
||||
},
|
||||
contains(name) {
|
||||
return values.has(name);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createElement(initial = {}) {
|
||||
return {
|
||||
disabled: Boolean(initial.disabled),
|
||||
hidden: Boolean(initial.hidden),
|
||||
value: initial.value || '',
|
||||
title: '',
|
||||
textContent: initial.textContent || '',
|
||||
listeners: {},
|
||||
attributes: {},
|
||||
classList: createClassList(),
|
||||
addEventListener(type, handler) {
|
||||
this.listeners[type] = handler;
|
||||
},
|
||||
setAttribute(name, value) {
|
||||
this.attributes[name] = String(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('sidepanel html contains contribution mode runtime UI and loads the module before sidepanel bootstrap', () => {
|
||||
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
|
||||
const moduleIndex = html.indexOf('<script src="contribution-mode.js"></script>');
|
||||
const sidepanelIndex = html.indexOf('<script src="sidepanel.js"></script>');
|
||||
|
||||
assert.match(html, /id="btn-contribution-mode"/);
|
||||
assert.match(html, /id="contribution-mode-panel"/);
|
||||
assert.match(html, /id="contribution-oauth-status"/);
|
||||
assert.match(html, /id="contribution-callback-status"/);
|
||||
assert.match(html, /id="contribution-mode-summary"/);
|
||||
assert.match(html, /id="btn-start-contribution"/);
|
||||
assert.match(html, /id="btn-open-contribution-upload"/);
|
||||
assert.match(html, /id="btn-exit-contribution-mode"/);
|
||||
assert.notEqual(moduleIndex, -1);
|
||||
assert.notEqual(sidepanelIndex, -1);
|
||||
assert.ok(moduleIndex < sidepanelIndex);
|
||||
});
|
||||
|
||||
test('collectSettingsPayload omits custom password and local sync settings in contribution mode', () => {
|
||||
const bundle = extractFunction('collectSettingsPayload');
|
||||
|
||||
const api = new Function(`
|
||||
let latestState = { contributionMode: true };
|
||||
let cloudflareDomainEditMode = false;
|
||||
let cloudflareTempEmailDomainEditMode = false;
|
||||
const selectCfDomain = { value: 'example.com' };
|
||||
const selectTempEmailDomain = { value: 'mail.example.com' };
|
||||
const selectPanelMode = { value: 'cpa' };
|
||||
const inputVpsUrl = { value: 'https://panel.example.com' };
|
||||
const inputVpsPassword = { value: 'panel-secret' };
|
||||
const inputSub2ApiUrl = { value: 'https://sub.example.com' };
|
||||
const inputSub2ApiEmail = { value: 'user@example.com' };
|
||||
const inputSub2ApiPassword = { value: 'sub-secret' };
|
||||
const inputSub2ApiGroup = { value: ' codex ' };
|
||||
const inputSub2ApiDefaultProxy = { value: ' proxy-a ' };
|
||||
const inputPassword = { value: 'Secret123!' };
|
||||
const selectMailProvider = { value: '163' };
|
||||
const selectEmailGenerator = { value: 'duck' };
|
||||
const checkboxAutoDeleteIcloud = { checked: true };
|
||||
const selectIcloudHostPreference = { value: 'auto' };
|
||||
const inputAccountRunHistoryTextEnabled = { checked: true };
|
||||
const inputAccountRunHistoryHelperBaseUrl = { value: 'http://127.0.0.1:17373' };
|
||||
const inputInbucketHost = { value: 'inbucket.local' };
|
||||
const inputInbucketMailbox = { value: 'demo' };
|
||||
const inputHotmailRemoteBaseUrl = { value: 'https://hotmail.example.com' };
|
||||
const inputHotmailLocalBaseUrl = { value: 'http://127.0.0.1:17373' };
|
||||
const inputLuckmailApiKey = { value: 'lk-api-key' };
|
||||
const inputLuckmailBaseUrl = { value: 'https://mails.example.com' };
|
||||
const selectLuckmailEmailType = { value: 'ms_graph' };
|
||||
const inputLuckmailDomain = { value: 'luckmail.example.com' };
|
||||
const inputTempEmailBaseUrl = { value: 'https://temp.example.com' };
|
||||
const inputTempEmailAdminAuth = { value: 'admin-secret' };
|
||||
const inputTempEmailCustomAuth = { value: 'custom-secret' };
|
||||
const inputTempEmailReceiveMailbox = { value: 'relay@example.com' };
|
||||
const inputAutoSkipFailures = { checked: false };
|
||||
const inputAutoSkipFailuresThreadIntervalMinutes = { value: '5' };
|
||||
const inputAutoDelayEnabled = { checked: true };
|
||||
const inputAutoDelayMinutes = { value: '30' };
|
||||
const inputAutoStepDelaySeconds = { value: '10' };
|
||||
const inputVerificationResendCount = { value: '6' };
|
||||
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
|
||||
|
||||
function getCloudflareDomainsFromState() { return { domains: ['example.com'], activeDomain: 'example.com' }; }
|
||||
function normalizeCloudflareDomainValue(value) { return String(value || '').trim(); }
|
||||
function getCloudflareTempEmailDomainsFromState() { return { domains: ['mail.example.com'], activeDomain: 'mail.example.com' }; }
|
||||
function normalizeCloudflareTempEmailDomainValue(value) { return String(value || '').trim(); }
|
||||
function getSelectedLocalCpaStep9Mode() { return 'submit'; }
|
||||
function getSelectedMail2925Mode() { return 'provide'; }
|
||||
function normalizeAccountRunHistoryHelperBaseUrlValue(value) { return String(value || '').trim(); }
|
||||
function buildManagedAliasBaseEmailPayload() { return { gmailBaseEmail: '', mail2925BaseEmail: '', emailPrefix: '' }; }
|
||||
function getSelectedHotmailServiceMode() { return 'local'; }
|
||||
function normalizeLuckmailBaseUrl(value) { return String(value || '').trim(); }
|
||||
function normalizeLuckmailEmailType(value) { return String(value || '').trim(); }
|
||||
function normalizeCloudflareTempEmailBaseUrlValue(value) { return String(value || '').trim(); }
|
||||
function normalizeCloudflareTempEmailReceiveMailboxValue(value) { return String(value || '').trim(); }
|
||||
function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) || 0; }
|
||||
function normalizeAutoDelayMinutes(value) { return Number(value) || 30; }
|
||||
function normalizeAutoStepDelaySeconds(value) { return value === '' ? null : Number(value); }
|
||||
function normalizeVerificationResendCount(value, fallback) { return Number.isFinite(Number(value)) ? Number(value) : fallback; }
|
||||
${bundle}
|
||||
return {
|
||||
collectSettingsPayload,
|
||||
setLatestState(nextState) { latestState = nextState; },
|
||||
};
|
||||
`)();
|
||||
|
||||
const contributionPayload = api.collectSettingsPayload();
|
||||
assert.equal('customPassword' in contributionPayload, false);
|
||||
assert.equal('accountRunHistoryTextEnabled' in contributionPayload, false);
|
||||
assert.equal('accountRunHistoryHelperBaseUrl' in contributionPayload, false);
|
||||
|
||||
api.setLatestState({ contributionMode: false });
|
||||
const normalPayload = api.collectSettingsPayload();
|
||||
assert.equal(normalPayload.customPassword, 'Secret123!');
|
||||
assert.equal(normalPayload.accountRunHistoryTextEnabled, true);
|
||||
assert.equal(normalPayload.accountRunHistoryHelperBaseUrl, 'http://127.0.0.1:17373');
|
||||
});
|
||||
|
||||
test('contribution mode manager enters mode, starts main auto flow, polls contribution status, and exits cleanly', async () => {
|
||||
const source = fs.readFileSync('sidepanel/contribution-mode.js', 'utf8');
|
||||
const windowObject = {};
|
||||
const timers = [];
|
||||
|
||||
const api = new Function('window', 'setTimeout', 'clearTimeout', `${source}; return window.SidepanelContributionMode;`)(
|
||||
windowObject,
|
||||
(handler) => {
|
||||
timers.push(handler);
|
||||
return timers.length;
|
||||
},
|
||||
(id) => {
|
||||
if (timers[id - 1]) {
|
||||
timers[id - 1] = null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(typeof api?.createContributionModeManager, 'function');
|
||||
|
||||
let latestState = {
|
||||
contributionMode: false,
|
||||
panelMode: 'sub2api',
|
||||
contributionSessionId: '',
|
||||
contributionStatus: '',
|
||||
contributionStatusMessage: '',
|
||||
contributionCallbackStatus: 'idle',
|
||||
contributionCallbackMessage: '',
|
||||
email: 'user@example.com',
|
||||
};
|
||||
let blocked = false;
|
||||
let appliedState = null;
|
||||
let statusState = null;
|
||||
let closeConfigMenuCount = 0;
|
||||
let closeAccountRecordsCount = 0;
|
||||
let contributionAutoRunStartCount = 0;
|
||||
let updatePanelModeCount = 0;
|
||||
let updateSyncUiCount = 0;
|
||||
let updateConfigMenuCount = 0;
|
||||
const toasts = [];
|
||||
const openedUrls = [];
|
||||
const sentMessages = [];
|
||||
|
||||
const dom = {
|
||||
btnConfigMenu: createElement(),
|
||||
btnContributionMode: createElement(),
|
||||
btnExitContributionMode: createElement(),
|
||||
btnOpenAccountRecords: createElement(),
|
||||
btnOpenContributionUpload: createElement(),
|
||||
btnStartContribution: createElement(),
|
||||
contributionCallbackStatus: createElement(),
|
||||
contributionModePanel: createElement({ hidden: true }),
|
||||
contributionModeSummary: createElement(),
|
||||
contributionModeText: createElement(),
|
||||
contributionOauthStatus: createElement(),
|
||||
rowAccountRunHistoryHelperBaseUrl: createElement(),
|
||||
rowAccountRunHistoryTextEnabled: createElement(),
|
||||
rowCustomPassword: createElement(),
|
||||
rowLocalCpaStep9Mode: createElement(),
|
||||
rowSub2ApiDefaultProxy: createElement(),
|
||||
rowSub2ApiEmail: createElement(),
|
||||
rowSub2ApiGroup: createElement(),
|
||||
rowSub2ApiPassword: createElement(),
|
||||
rowSub2ApiUrl: createElement(),
|
||||
rowVpsPassword: createElement(),
|
||||
rowVpsUrl: createElement(),
|
||||
selectPanelMode: createElement({ value: 'sub2api' }),
|
||||
};
|
||||
|
||||
const manager = api.createContributionModeManager({
|
||||
state: {
|
||||
getLatestState: () => latestState,
|
||||
},
|
||||
dom,
|
||||
helpers: {
|
||||
applySettingsState(nextState) {
|
||||
latestState = nextState;
|
||||
appliedState = nextState;
|
||||
},
|
||||
closeAccountRecordsPanel() {
|
||||
closeAccountRecordsCount += 1;
|
||||
},
|
||||
closeConfigMenu() {
|
||||
closeConfigMenuCount += 1;
|
||||
},
|
||||
getContributionNickname() {
|
||||
return latestState.email;
|
||||
},
|
||||
isModeSwitchBlocked() {
|
||||
return blocked;
|
||||
},
|
||||
openConfirmModal: async () => true,
|
||||
openExternalUrl(url) {
|
||||
openedUrls.push(url);
|
||||
},
|
||||
showToast(message, type) {
|
||||
toasts.push({ message, type });
|
||||
},
|
||||
async startContributionAutoRun() {
|
||||
contributionAutoRunStartCount += 1;
|
||||
latestState = {
|
||||
...latestState,
|
||||
contributionSessionId: 'session-002',
|
||||
contributionAuthUrl: 'https://auth.example.com/oauth?state=oauth-state-002',
|
||||
contributionAuthState: 'oauth-state-002',
|
||||
contributionStatus: 'started',
|
||||
contributionStatusMessage: '\u5df2\u751f\u6210\u767b\u5f55\u5730\u5740',
|
||||
contributionCallbackStatus: 'waiting',
|
||||
contributionCallbackMessage: '\u7b49\u5f85\u56de\u8c03',
|
||||
};
|
||||
return true;
|
||||
},
|
||||
updateAccountRunHistorySettingsUI() {
|
||||
updateSyncUiCount += 1;
|
||||
},
|
||||
updateConfigMenuControls() {
|
||||
updateConfigMenuCount += 1;
|
||||
},
|
||||
updatePanelModeUI() {
|
||||
updatePanelModeCount += 1;
|
||||
},
|
||||
updateStatusDisplay(nextState) {
|
||||
statusState = nextState;
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
sendMessage: async (message) => {
|
||||
sentMessages.push(message);
|
||||
if (message.type === 'SET_CONTRIBUTION_MODE') {
|
||||
return {
|
||||
state: message.payload.enabled
|
||||
? {
|
||||
contributionMode: true,
|
||||
panelMode: 'cpa',
|
||||
contributionSessionId: '',
|
||||
contributionAuthUrl: '',
|
||||
contributionAuthState: '',
|
||||
contributionStatus: '',
|
||||
contributionStatusMessage: '',
|
||||
contributionCallbackStatus: 'idle',
|
||||
contributionCallbackMessage: '',
|
||||
email: latestState.email,
|
||||
}
|
||||
: {
|
||||
contributionMode: false,
|
||||
panelMode: 'cpa',
|
||||
contributionSessionId: '',
|
||||
contributionAuthUrl: '',
|
||||
contributionAuthState: '',
|
||||
contributionCallbackUrl: '',
|
||||
contributionStatus: '',
|
||||
contributionStatusMessage: '',
|
||||
contributionCallbackStatus: 'idle',
|
||||
contributionCallbackMessage: '',
|
||||
email: latestState.email,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (message.type === 'POLL_CONTRIBUTION_STATUS') {
|
||||
return {
|
||||
state: {
|
||||
...latestState,
|
||||
contributionStatus: 'processing',
|
||||
contributionStatusMessage: '已授权,正在自动审核',
|
||||
contributionCallbackStatus: 'not_required',
|
||||
contributionCallbackMessage: '当前流程无需手动回调',
|
||||
},
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
},
|
||||
constants: {
|
||||
contributionUploadUrl: 'https://apikey.qzz.io/',
|
||||
pollIntervalMs: 2500,
|
||||
},
|
||||
});
|
||||
|
||||
manager.render();
|
||||
assert.equal(dom.contributionModePanel.hidden, true);
|
||||
assert.equal(dom.btnContributionMode.disabled, false);
|
||||
|
||||
manager.bindEvents();
|
||||
await dom.btnContributionMode.listeners.click();
|
||||
|
||||
assert.equal(dom.contributionModePanel.hidden, false);
|
||||
assert.equal(dom.selectPanelMode.value, 'cpa');
|
||||
assert.equal(dom.selectPanelMode.disabled, true);
|
||||
assert.equal(dom.btnOpenAccountRecords.disabled, true);
|
||||
assert.equal(dom.contributionOauthStatus.textContent, '\u672a\u751f\u6210\u767b\u5f55\u5730\u5740');
|
||||
assert.equal(dom.contributionCallbackStatus.textContent, '\u7b49\u5f85\u56de\u8c03');
|
||||
assert.equal(dom.contributionModeSummary.textContent.length > 0, true);
|
||||
assert.equal(dom.btnContributionMode.classList.contains('is-active'), true);
|
||||
assert.equal(dom.rowVpsUrl.classList.contains('is-contribution-hidden'), true);
|
||||
assert.ok(closeConfigMenuCount >= 1);
|
||||
assert.ok(closeAccountRecordsCount >= 1);
|
||||
assert.ok(updatePanelModeCount >= 1);
|
||||
assert.ok(updateSyncUiCount >= 1);
|
||||
assert.ok(updateConfigMenuCount >= 1);
|
||||
assert.equal(timers.length, 0);
|
||||
|
||||
await dom.btnStartContribution.listeners.click();
|
||||
assert.equal(contributionAutoRunStartCount, 1);
|
||||
assert.equal(appliedState.contributionSessionId, '');
|
||||
assert.equal(latestState.contributionSessionId, 'session-002');
|
||||
assert.equal(latestState.contributionStatus, 'started');
|
||||
assert.equal(timers.length > 0, true);
|
||||
|
||||
await manager.pollOnce({ reason: 'test_poll' });
|
||||
assert.equal(statusState.contributionStatus, 'processing');
|
||||
assert.equal(dom.contributionOauthStatus.textContent, '\u6388\u6743\u5df2\u5b8c\u6210');
|
||||
assert.equal(dom.contributionCallbackStatus.textContent, '\u5f53\u524d\u6d41\u7a0b\u65e0\u9700\u624b\u52a8\u56de\u8c03');
|
||||
assert.equal(dom.contributionModeSummary.textContent, '\u5df2\u6388\u6743\uff0c\u6b63\u5728\u81ea\u52a8\u5ba1\u6838');
|
||||
|
||||
dom.btnOpenContributionUpload.listeners.click();
|
||||
assert.deepStrictEqual(openedUrls, ['https://apikey.qzz.io/']);
|
||||
|
||||
await dom.btnExitContributionMode.listeners.click();
|
||||
manager.render();
|
||||
assert.equal(dom.contributionModePanel.hidden, true);
|
||||
assert.equal(dom.btnContributionMode.classList.contains('is-active'), false);
|
||||
assert.equal(dom.selectPanelMode.disabled, false);
|
||||
assert.equal(dom.rowVpsUrl.classList.contains('is-contribution-hidden'), false);
|
||||
assert.deepStrictEqual(
|
||||
sentMessages.map((message) => message.type),
|
||||
['SET_CONTRIBUTION_MODE', 'POLL_CONTRIBUTION_STATUS', 'SET_CONTRIBUTION_MODE']
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
toasts.map((item) => item.message),
|
||||
['\u5df2\u8fdb\u5165\u8d21\u732e\u6a21\u5f0f\u3002', '\u8d21\u732e\u81ea\u52a8\u6d41\u7a0b\u5df2\u542f\u52a8\u3002', '\u5df2\u9000\u51fa\u8d21\u732e\u6a21\u5f0f\u3002']
|
||||
);
|
||||
|
||||
blocked = true;
|
||||
latestState = {
|
||||
contributionMode: true,
|
||||
panelMode: 'cpa',
|
||||
contributionSessionId: 'session-002',
|
||||
contributionAuthUrl: 'https://auth.example.com/oauth?state=oauth-state-002',
|
||||
contributionStatus: 'waiting',
|
||||
contributionStatusMessage: '\u7b49\u5f85\u6388\u6743\u5b8c\u6210',
|
||||
contributionCallbackStatus: 'waiting',
|
||||
contributionCallbackMessage: '\u7b49\u5f85\u56de\u8c03',
|
||||
};
|
||||
manager.render();
|
||||
assert.equal(dom.btnExitContributionMode.disabled, true);
|
||||
manager.stopPolling();
|
||||
});
|
||||
+54
-17
@@ -37,8 +37,9 @@
|
||||
- 向后台发送命令
|
||||
- 接收后台广播并更新 UI
|
||||
- 动态渲染步骤列表
|
||||
- 顶部提供一个临时 `贡献` 按钮,用于直接打开账号贡献上传页
|
||||
- 在日志区通过“记录”按钮打开独立的邮箱记录覆盖层,并展示成功/失败/停止/重试统计、按状态筛选、分页列表与多选删除操作
|
||||
- 管理顶部“贡献”按钮与贡献模式主面板;贡献模式本身是 sidepanel 的运行态 UI 模式,不是新的 `panelMode` 来源
|
||||
- 在贡献模式下复用同一套主自动流程启动,并在面板内展示贡献链路的 `OAUTH / 回调 / 总状态` 三块实时状态
|
||||
- 在日志区通过“记录”按钮打开独立的邮箱记录覆盖层,并展示成功/失败/停止/重试统计与分页列表
|
||||
- 查询 GitHub Releases 并展示更新卡片;当前更新服务会区分 `Pro` 与 legacy `v` 两个版本族,排序时优先保持版本族语义一致,同时会在读取缓存后重新排序,避免旧缓存把 `v` 版本误显示为比 `Pro` 更新
|
||||
|
||||
### 2.2 Background Service Worker
|
||||
@@ -119,6 +120,15 @@
|
||||
保存运行态:
|
||||
|
||||
- 当前步骤状态
|
||||
- 当前 sidepanel UI 模式 `contributionMode`
|
||||
- 贡献 OAuth 会话字段:
|
||||
- `contributionSessionId`
|
||||
- `contributionAuthUrl`
|
||||
- `contributionAuthState`
|
||||
- `contributionCallbackUrl`
|
||||
- `contributionStatus`
|
||||
- `contributionStatusMessage`
|
||||
- `contributionLastPollAt`
|
||||
- OAuth 链接
|
||||
- 当前邮箱 / 密码
|
||||
- 第 8 步固定的验证码页显示邮箱 `step8VerificationTargetEmail`
|
||||
@@ -144,9 +154,14 @@
|
||||
- iCloud 相关偏好
|
||||
- LuckMail API 配置
|
||||
- 自动运行默认配置
|
||||
- 账号运行历史 `accountRunHistory`(以邮箱为主键,保存该邮箱最近一次状态:成功/失败/停止;停止记录会优先归一到具体步骤,如“步骤 7 停止”)
|
||||
- 账号运行历史 `accountRunHistory`(以邮箱为主键,保存该邮箱最近一次状态:成功/失败/停止)
|
||||
- 账号运行历史本地同步开关与 helper 地址
|
||||
|
||||
注意:
|
||||
|
||||
- `contributionMode` 不属于持久配置,也不参与导入/导出;它只存在于运行态
|
||||
- `panelMode` 仍然只表示 `cpa | sub2api` 来源,不能把贡献模式实现成新的来源枚举
|
||||
|
||||
当启用了独立的账号运行历史本地同步配置时,账号运行历史会通过 [scripts/hotmail_helper.py](c:/Users/projectf/Downloads/codex注册扩展/scripts/hotmail_helper.py) 整体同步写入 `data/account-run-history.json` 快照文件,便于开发者直接查看完整记录。
|
||||
这条配置链路独立于 `mailProvider` 和 Hotmail 的接码模式。
|
||||
|
||||
@@ -161,23 +176,34 @@
|
||||
- `ICLOUD_LOGIN_REQUIRED`
|
||||
- `ICLOUD_ALIASES_CHANGED`
|
||||
|
||||
### 4.4 临时贡献入口
|
||||
### 4.4 贡献模式链路
|
||||
|
||||
当前 `dev` 分支里的贡献能力先以“临时上传入口”形式提供,而不是完整接入主自动链。
|
||||
|
||||
流程:
|
||||
贡献模式的目标不是新增一个 provider,而是在 sidepanel 内开启一套“贡献账号”专用 UI,并把公开贡献服务并进原有 10 步主链:
|
||||
|
||||
1. 用户点击顶部 `贡献`
|
||||
2. 扩展调用统一的新标签页打开逻辑
|
||||
3. 浏览器直接打开 `https://apikey.qzz.io/`
|
||||
2. sidepanel 复用现有 `openConfirmModal(...)` 打开确认弹窗
|
||||
3. 用户确认后,后台通过 `SET_CONTRIBUTION_MODE` 切换运行态
|
||||
4. 进入贡献模式后会强制 `panelMode = cpa`,并临时清空运行态 `customPassword`、禁用运行态账号记录快照同步
|
||||
5. sidepanel 隐藏 CPA 管理地址、管理密钥、SUB2API 配置、自定义密码、本地同步等普通模式配置,并禁用来源选择、配置菜单和记录入口
|
||||
6. 用户点击 `开始贡献` 后,不再单独走一条旁路 OAuth 流程,而是直接复用顶部 `自动` 的同一套主自动流
|
||||
7. 步骤 1~6 仍按原来的注册自动化执行
|
||||
8. 当主流程进入步骤 7 时,后台改为调用公开接口 `POST https://apikey.qzz.io/oauth/api/start` 申请贡献登录地址,而不是去 CPA / SUB2API 面板刷新 OAuth
|
||||
9. 步骤 7 拿到 `session_id / auth_url / state` 后,继续沿用原有登录链路进入授权页
|
||||
10. 步骤 9 仍负责捕获 localhost callback;贡献模式下后台也会持续监听导航变化,必要时提前兼容处理 callback
|
||||
11. 步骤 10 在贡献模式下不再打开 CPA 管理页,而是围绕公开贡献会话做 callback 提交兼容和最终状态确认;当状态进入 `auto_approved / auto_rejected / manual_review_required / expired / error` 时结束
|
||||
12. 当前服务端如果返回“无需手动提交 callback”,扩展会把它视为兼容成功态,而不是报错
|
||||
13. `已有认证文件?前往上传` 继续打开 `https://apikey.qzz.io/`
|
||||
14. `退出贡献模式` 会清理贡献会话相关运行态,`重置` 只重置主流程状态,不会自动退出贡献模式
|
||||
|
||||
边界:
|
||||
这条链路的关键边界是:
|
||||
|
||||
- 当前这颗按钮不参与 1~10 步主流程
|
||||
- 当前这颗按钮不调用后台管理接口
|
||||
- 当前这颗按钮不保存额外运行态
|
||||
- Auto 运行中不会跟随主流程按钮一起被禁用,仍可直接点击打开上传页
|
||||
- 手动单步流程运行时,按钮仍会禁用,避免和同页操作冲突
|
||||
- 扩展会调用公开贡献 API,但这条调用被折叠进主 10 步自动链,而不是额外分出一条独立“贡献 OAuth 小流程”
|
||||
- 扩展不会保存 token
|
||||
- 扩展不会持有任何管理密钥
|
||||
- 扩展代码中不能直接调用 `/v0/management/*`
|
||||
- 扩展不会直接访问生产管理面或生产 auth-dir
|
||||
- 安全边界仍然在服务端;扩展只负责公开 OAuth 贡献前端流程
|
||||
- 退出贡献模式后,要恢复普通模式 UI,并恢复持久配置中的自定义密码/本地同步偏好
|
||||
|
||||
## 5. 内容脚本通信链路
|
||||
|
||||
@@ -238,7 +264,7 @@
|
||||
1. 解析本轮应使用的邮箱
|
||||
2. 打开或复用注册页
|
||||
3. 点击注册入口并提交邮箱
|
||||
4. 以当前邮箱先写入一条“停止(流程尚未完成)”的记录占位;该占位记录后续会随真实停止点归一成“步骤 X 停止”或被成功/失败状态覆盖
|
||||
4. 以当前邮箱先写入一条“停止(流程尚未完成)”的记录占位
|
||||
5. 等待邮箱提交后的真实落地页
|
||||
6. 如果进入密码页,则继续执行 Step 3
|
||||
7. 如果直接进入邮箱验证码页,则自动跳过 Step 3 并进入 Step 4
|
||||
@@ -333,6 +359,11 @@
|
||||
6. 自动运行一旦进入步骤 7 之后的链路,若后续步骤报错且认证页未进入 `https://auth.openai.com/add-phone`,则统一回到步骤 7 重新开始授权流程
|
||||
7. CPA 回调阶段固定为步骤 9,不再支持“第七步回调”跳过步骤 7/8
|
||||
|
||||
贡献模式补充:
|
||||
|
||||
- 贡献模式下,步骤 7 不再从 CPA / SUB2API 面板刷新 OAuth,而是直接调用公开贡献接口 `/oauth/api/start`
|
||||
- 贡献接口返回的 `auth_url` 会写回运行态 `oauthUrl`,后续步骤 7 / 8 / 9 继续复用现有授权链路
|
||||
|
||||
### Step 8
|
||||
|
||||
文件:
|
||||
@@ -385,6 +416,12 @@
|
||||
8. 追加账号运行历史成功记录
|
||||
9. 做成功后的清理与标记
|
||||
|
||||
贡献模式补充:
|
||||
|
||||
- 贡献模式下,步骤 10 不再打开 CPA 管理页
|
||||
- 步骤 10 会先尝试提交已捕获的 callback URL,随后轮询公开贡献状态,直到进入最终态
|
||||
- `auto_approved` 与 `manual_review_required` 视为主流程完成;`auto_rejected / expired / error` 视为当前轮失败
|
||||
|
||||
## 7. 邮箱与 provider 链路
|
||||
|
||||
### 7.1 2026-04-17 补充:Gmail / 2925 统一别名邮箱链路
|
||||
@@ -495,7 +532,7 @@
|
||||
- 当前轮重试
|
||||
- 下一轮继续
|
||||
7. 当前轮最终失败时,写入邮箱记录,并在自动重试链路中累计重试次数
|
||||
- 手动停止与自动停止会写入“停止”状态;如果能确定当前运行步骤或下一未完成步骤,则会优先归一成“步骤 X 停止”(若后续同邮箱成功/失败,会被覆盖为最新状态)
|
||||
- 手动停止与自动停止会写入“停止”状态(若后续同邮箱成功/失败,会被覆盖为最新状态)
|
||||
8. 如果配置了线程间隔,则挂计时计划;计时计划会带上当前 `autoRunSessionId`
|
||||
9. 旧 timer / 旧 alarm / 旧恢复入口只有在 session 仍有效时才允许恢复执行;Stop 会立即使当前 session 失效,防止“停止后旧倒计时又把流程重新拉起”
|
||||
10. 所有轮次结束后输出汇总,并清空当前 session 标识
|
||||
|
||||
+19
-6
@@ -109,14 +109,27 @@
|
||||
6. 是否挂在正确的职责域中
|
||||
7. 文档
|
||||
|
||||
### 3.4 新增顶部入口按钮
|
||||
### 3.4 新增运行态模式
|
||||
|
||||
如果新增的是 sidepanel 顶部快捷入口按钮,至少同步检查:
|
||||
如果新增的是“运行态 UI 模式”而不是持久配置或新来源,必须先把边界说清楚,再落代码。
|
||||
|
||||
1. 是否应该复用现有确认弹窗与统一跳转逻辑
|
||||
2. 是否需要在步骤运行中或自动运行中禁用
|
||||
3. 是否补了最小 HTML 接线测试和按钮行为测试
|
||||
4. 是否同步更新结构文档与链路文档
|
||||
当前约定示例:
|
||||
|
||||
- `contributionMode` 是 sidepanel 的运行态 UI 模式,不是新的 `panelMode`
|
||||
- `panelMode` 仍然只允许 `cpa | sub2api`
|
||||
- 运行态模式不能混进 `PERSISTED_SETTING_DEFAULTS`
|
||||
- 运行态模式不能混进配置导入/导出
|
||||
- 如果运行态模式会临时覆盖某些持久配置的显示值,必须同时处理好“退出模式后恢复”和“自动保存不能误覆盖原配置”这两个问题
|
||||
- 如果运行态模式要隐藏某一行 UI,必须先检查这行里是否绑定了不该一起隐藏的其他设置;必要时先拆行,再做显隐
|
||||
|
||||
当前贡献模式补充约定:
|
||||
|
||||
- 贡献模式属于 `CPA` 来源下的特殊业务模式,不是新的 provider
|
||||
- 贡献模式允许扩展内承接公开 OAuth 交互,但只能调用公开接口,不能触碰 `/v0/management/*`
|
||||
- 如果产品要求“开始贡献”和主自动流走同一条链路,则优先把贡献服务接入步骤 7 / 10,而不是额外分出一套平行 mini-flow
|
||||
- 贡献流程的后台公开 OAuth 状态机应优先收敛到独立模块,例如 `background/contribution-oauth.js`
|
||||
- 贡献模式的侧栏按钮、状态展示和轮询调度应优先收敛到独立 manager,例如 `sidepanel/contribution-mode.js`
|
||||
- 如果服务端当前返回“无需手动提交 callback”,扩展端必须把它当兼容成功态处理,不能简单按 HTTP 非 200 直接视为失败
|
||||
|
||||
## 4. 测试规范
|
||||
|
||||
|
||||
+13
-10
@@ -40,13 +40,14 @@
|
||||
|
||||
## `background/`
|
||||
|
||||
- `background/account-run-history.js`:邮箱记录模块,负责以邮箱为主键维护最新记录(同邮箱后续状态覆盖),在步骤 2 设定邮箱后支持先写入“停止/未完成”占位状态,统一归一化成功/失败/停止三态并落地到 `chrome.storage.local`,停止记录会尽量推断并归一到具体步骤标签(如“步骤 7 停止”),支持清理记录与按选中记录局部删除,并在启用独立本地同步配置后把完整快照同步到本地 helper。
|
||||
- `background/account-run-history.js`:邮箱记录模块,负责以邮箱为主键维护最新记录(同邮箱后续状态覆盖),在步骤 2 设定邮箱后支持先写入“停止/未完成”占位状态,统一归一化成功/失败/停止三态并落地到 `chrome.storage.local`,支持清理记录,并在启用独立本地同步配置后把完整快照同步到本地 helper。
|
||||
- `background/auto-run-controller.js`:自动运行主控制器,封装多轮执行、重试、轮次摘要、线程间隔与倒计时恢复逻辑;当前自动流程会绑定 `autoRunSessionId`,手动停止后旧的倒计时计划、旧重试链路和旧恢复入口不会再复活已失效的自动运行;fresh-attempt reset 时会额外保留 `gmailBaseEmail` 与 `mail2925BaseEmail`,避免自动流程重置后丢失别名基邮箱配置。
|
||||
- `background/contribution-oauth.js`:贡献模式的公开 OAuth 流程模块,负责调用 `apikey.qzz.io` 的公开贡献接口、保存贡献会话运行态、在主 10 步流程里为步骤 7 提供贡献登录地址、在步骤 9/10 衔接 callback 捕获与兼容提交 `/oauth/api/submit-callback`,并把真实服务端状态映射回 sidepanel 运行态。
|
||||
- `background/generated-email-helpers.js`:生成邮箱辅助层,除了 Duck / Cloudflare / iCloud / Cloudflare Temp Email,也统一承接 Gmail / 2925 的别名邮箱生成入口。
|
||||
- `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层;当前额外承接 `add-phone / 手机号页` 这类认证 fatal 错误的共享判定。
|
||||
- `background/message-router.js`:后台消息路由层,负责处理 `chrome.runtime.onMessage` 进入的所有业务消息。
|
||||
- `background/navigation-utils.js`:导航与 URL 判断工具层,负责 callback、入口页、CPA / SUB2API 地址、步骤跳转相关判断。
|
||||
- `background/panel-bridge.js`:CPA / SUB2API 面板桥接层,封装 OAuth 地址获取所需的页面打开、脚本注入和通信;当前 `REQUEST_OAUTH_URL` 链路的面板日志会统一按步骤 7 记录,避免继续误标为旧的步骤 6。
|
||||
- `background/panel-bridge.js`:CPA / SUB2API 面板桥接层,封装 OAuth 地址获取所需的页面打开、脚本注入和通信。
|
||||
- `background/signup-flow-helpers.js`:注册页辅助层,负责打开注册入口、等待密码页以及解析当前流程所用邮箱;在 Gmail / 2925 模式下会优先复用已经存在且仍兼容的完整注册邮箱,只有不兼容或为空时才重新生成。
|
||||
- `background/tab-runtime.js`:标签页与内容脚本运行时基础设施,封装标签注册、冲突清理、消息超时、注入重试与队列;当前等待标签完成、注入后的短暂延迟和内容脚本重试等待都已做 Stop 感知,避免用户停止后后台还继续等待并恢复执行。
|
||||
- `background/verification-flow.js`:注册/登录验证码共享流程层,封装重发、轮询、提交、失败回退、自定义邮箱跳过、共享验证码自动重发次数配置以及 2925 长轮询参数;当前验证码提交重试上限为 7 次,步骤 8 提交登录验证码后若页面进入 `add-phone / 手机号页`,会直接抛出 fatal 错误而不是把当前步骤视为成功,并在 2925 提交成功后异步触发“删除全部邮件”的清理消息。
|
||||
@@ -55,11 +56,11 @@
|
||||
|
||||
- `background/steps/clear-login-cookies.js`:步骤 6 实现,负责登录前 Cookie 清理。
|
||||
- `background/steps/confirm-oauth.js`:步骤 9 实现,负责 OAuth 同意页按钮定位、点击、localhost 回调监听与回调完成。
|
||||
- `background/steps/fetch-login-code.js`:步骤 8 实现,负责登录验证码阶段的邮箱轮询、验证码回填与回退控制;验证码获取后直接提交,不再在提交前回放步骤 7 刷新 OAuth;对非 2925 provider 会固定当前验证码页显示邮箱作为本次 Step 8 的目标邮箱;当步骤 8 需要回退到步骤 7 重开授权链时,会走后台统一恢复入口,避免直接并发拉起新的 Step 7。
|
||||
- `background/steps/fetch-login-code.js`:步骤 8 实现,负责登录验证码阶段的邮箱轮询、验证码回填与回退控制;验证码获取后直接提交,不再在提交前回放步骤 7 刷新 OAuth;对非 2925 provider 会固定当前验证码页显示邮箱作为本次 Step 8 的目标邮箱。
|
||||
- `background/steps/fetch-signup-code.js`:步骤 4 实现,负责注册验证码阶段的页面准备与验证码流程入口。
|
||||
- `background/steps/fill-password.js`:步骤 3 实现,负责密码生成、保存、回填与提交。
|
||||
- `background/steps/fill-profile.js`:步骤 5 实现,负责姓名、生日填写并把资料提交给注册页内容脚本。
|
||||
- `background/steps/oauth-login.js`:步骤 7 实现,负责刷新 OAuth 链接、登录和确保进入验证码页;普通可恢复登录态失败会按上限重试,但一旦识别到认证流程进入 `add-phone / 手机号页`,会立即退出步骤 7 内部重试;当前每次拿到新的 OAuth 链接都会刷新对应的 6 分钟授权预算,并把预算与具体 OAuth URL 绑定,避免旧链路 deadline 污染新链路。
|
||||
- `background/steps/oauth-login.js`:步骤 7 实现,负责刷新 OAuth 链接、登录和确保进入验证码页;普通可恢复登录态失败会按上限重试,但一旦识别到认证流程进入 `add-phone / 手机号页`,会立即退出步骤 7 内部重试。
|
||||
- `background/steps/open-chatgpt.js`:步骤 1 实现,负责打开 ChatGPT 官网并确认入口就绪。
|
||||
- `background/steps/platform-verify.js`:步骤 10 实现,负责 CPA / SUB2API 回调验证。
|
||||
- `background/steps/registry.js`:步骤注册表工厂,负责用稳定的步骤元数据映射到执行器。
|
||||
@@ -112,10 +113,11 @@
|
||||
- `sidepanel/hotmail-manager.js`:侧边栏 Hotmail 账号池管理器,负责列表、导入、验证、测试收信和批量操作。
|
||||
- `sidepanel/icloud-manager.js`:侧边栏 iCloud 隐私邮箱管理器,负责列表、筛选、保留、删除和批量操作。
|
||||
- `sidepanel/luckmail-manager.js`:侧边栏 LuckMail 管理器,负责邮箱列表、筛选、启停、保留与批量操作。
|
||||
- `sidepanel/account-records-manager.js`:侧边栏邮箱记录面板管理器,负责“记录”按钮、覆盖层开关、分页列表、成功/失败/停止统计摘要、按状态筛选、多选删除、清理确认,以及多层弹窗下的工具栏交互状态管理。
|
||||
- `sidepanel/account-records-manager.js`:侧边栏邮箱记录面板管理器,负责“记录”按钮、覆盖层开关、分页列表、成功/失败/停止统计摘要和清理确认。
|
||||
- `sidepanel/contribution-mode.js`:侧边栏贡献模式管理器,负责顶部“贡献”按钮、确认弹窗、贡献模式显隐、复用主自动流程启动、侧栏内贡献状态轮询、上传页跳转,以及贡献模式下对来源选择、配置入口、记录入口和敏感配置行的禁用与隐藏。
|
||||
- `sidepanel/sidepanel.css`:侧边栏样式文件。
|
||||
- `sidepanel/sidepanel.html`:侧边栏页面结构;当前步骤列表已改为动态容器,日志区提供“记录”按钮并挂接邮箱记录覆盖层,顶部新增临时 `贡献` 按钮用于直接打开账号贡献上传页,同时继续加载 `managed-alias-utils.js`,把旧的“邮箱前缀”字段语义改为“别名基邮箱”。
|
||||
- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、独立本地同步配置、共享验证码自动重发次数配置与广播接收,并装配 Hotmail / iCloud / LuckMail / 邮箱记录面板 manager;当前顶部 `贡献` 按钮会在新标签页中直接打开账号贡献上传页。
|
||||
- `sidepanel/sidepanel.html`:侧边栏页面结构;当前步骤列表已改为动态容器,日志区提供“记录”按钮并挂接邮箱记录覆盖层,顶部新增“贡献”按钮并在设置卡片中新增贡献模式主面板;贡献面板内展示 `OAUTH / 回调 / 总状态` 三块真实运行态信息,同时把“本地同步”与“验证码重发”拆成独立行以避免特殊模式隐藏时互相影响;页面继续加载 `managed-alias-utils.js`,并把旧的“邮箱前缀”字段语义改为“别名基邮箱”。
|
||||
- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、独立本地同步配置、共享验证码自动重发次数配置与广播接收,并装配 Hotmail / iCloud / LuckMail / 贡献模式 / 邮箱记录面板 manager;当前贡献模式的“开始贡献”会直接复用主自动流程启动逻辑,而独立 manager 负责贡献运行态展示与轮询,同时把 Gmail / 2925 的基邮箱输入、完整注册邮箱输入、自动生成按钮与兼容性校验统一接到共享别名逻辑上。
|
||||
- `sidepanel/update-service.js`:侧边栏更新检查服务,负责 GitHub Releases 查询、`Pro` / `v` 双版本族排序、缓存读取与版本展示。
|
||||
|
||||
## `tests/`
|
||||
@@ -128,9 +130,9 @@
|
||||
- `tests/auto-run-step6-restart.test.js`:测试自动运行在后半段授权链路遇错时会回到步骤 7 重开,并在命中 add-phone 或泛化手机号页 fatal 错误时停止重开。
|
||||
- `tests/auto-step-random-delay.test.js`:测试自动运行步间延迟与旧配置键兼容解析。
|
||||
- `tests/background-auto-run-module.test.js`:测试自动运行控制器模块已接入且导出工厂。
|
||||
- `tests/background-account-run-history-module.test.js`:测试邮箱记录模块已接入、导出工厂,能够保留并归一化停止记录、归一化失败/停止标签、计算自动重试次数,并支持清理后同步完整快照。
|
||||
- `tests/background-stop-gap-record.test.js`:测试通用 stopped 记录在步骤空档期会归一到“下一未完成步骤停止”,并校验停止原因文案归一化。
|
||||
- `tests/background-account-run-history-module.test.js`:测试邮箱记录模块已接入、导出工厂,能够保留并归一化停止记录、归一化失败标签、计算自动重试次数,并支持清理后同步完整快照。
|
||||
- `tests/background-account-history-settings.test.js`:测试账号运行历史的独立配置项归一化,不再复用 Hotmail 模式作为启停条件。
|
||||
- `tests/background-contribution-mode.test.js`:测试贡献模式的后台运行态与公开 OAuth 接入,覆盖 `contributionMode` 只存在于运行态、`SET_CONTRIBUTION_MODE / POLL_CONTRIBUTION_STATUS` 消息接入、reset 保留贡献运行态,以及 callback 自动提交在“无需手动提交”场景下的兼容处理。
|
||||
- `tests/background-generated-email-module.test.js`:测试生成邮箱辅助模块已接入且导出工厂。
|
||||
- `tests/background-icloud.test.js`:测试 iCloud 相关后台纯函数与别名收尾逻辑。
|
||||
- `tests/background-icloud-mail-provider.test.js`:测试 iCloud 邮箱 provider 配置解析。
|
||||
@@ -163,7 +165,8 @@
|
||||
- `tests/microsoft-email.test.js`:测试 Microsoft 邮件拉取与验证码提取逻辑。
|
||||
- `tests/sidepanel-hotmail-manager.test.js`:测试侧边栏 Hotmail 管理器模块接线与空态渲染。
|
||||
- `tests/sidepanel-contribution-button.test.js`:测试侧边栏顶部 `贡献` 按钮的 HTML 接线,以及直接打开账号贡献上传页的最小行为。
|
||||
- `tests/sidepanel-account-records-manager.test.js`:测试侧边栏邮箱记录覆盖层的 HTML 接入、helper 地址归一化、筛选/多选删除交互,以及确认弹窗层级高于记录覆盖层。
|
||||
- `tests/sidepanel-account-records-manager.test.js`:测试侧边栏邮箱记录覆盖层的 HTML 接入、helper 地址归一化与 manager 渲染逻辑。
|
||||
- `tests/sidepanel-contribution-mode.test.js`:测试侧边栏贡献模式的 HTML 接线、runtime-only 设置保护,以及贡献模式 manager 复用主自动流启动、状态轮询和退出清理逻辑。
|
||||
- `tests/sidepanel-icloud-manager.test.js`:测试侧边栏 iCloud 管理器模块接线与空态渲染。
|
||||
- `tests/sidepanel-icloud-provider.test.js`:测试侧边栏 iCloud 登录地址解析逻辑。
|
||||
- `tests/sidepanel-luckmail-manager.test.js`:测试侧边栏 LuckMail 管理器模块接线与空态渲染。
|
||||
|
||||
Reference in New Issue
Block a user