Merge branch '贡献功能开发' into dev

This commit is contained in:
QLHazyCoder
2026-04-20 00:40:59 +08:00
14 changed files with 2736 additions and 37 deletions
+187 -1
View File
@@ -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 或程序自动生成后写入。
@@ -1117,6 +1136,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);
}
@@ -1267,15 +1347,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 || [],
@@ -5192,6 +5278,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) {
@@ -5931,7 +6026,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,
@@ -6004,6 +6099,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter
scheduleAutoRun,
selectLuckmailPurchase,
setCurrentHotmailAccount,
setContributionMode,
setEmailState,
setEmailStateSilently,
setIcloudAliasPreservedState,
@@ -6016,7 +6112,9 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter
setStepStatus,
skipAutoRunCountdown,
skipStep,
startContributionFlow: (...args) => contributionOAuthManager?.startContributionFlow?.(...args),
startAutoRunLoop,
pollContributionStatus: (...args) => contributionOAuthManager?.pollContributionStatus?.(...args),
syncHotmailAccounts,
testHotmailAccountMailAccess,
upsertHotmailAccount,
@@ -6347,7 +6445,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('步骤 7contributionMode=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(`步骤 7contributionMode=${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);
@@ -6951,7 +7068,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);
}
+3
View File
@@ -322,6 +322,9 @@
}
function shouldSyncAccountRunHistorySnapshot(state = {}) {
if (Boolean(state.contributionMode)) {
return false;
}
if (!Boolean(state.accountRunHistoryTextEnabled)) {
return false;
}
+723
View File
@@ -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,
};
});
+52
View File
@@ -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,
+403
View File
@@ -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
View File
@@ -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;
+35 -4
View File
@@ -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
View File
@@ -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;
}
@@ -1311,6 +1332,7 @@ function collectSettingsPayload() {
const selectedCloudflareTempEmailDomain = normalizeCloudflareTempEmailDomainValue(
!cloudflareTempEmailDomainEditMode ? selectTempEmailDomain.value : tempEmailActiveDomain
) || tempEmailActiveDomain;
const contributionModeEnabled = Boolean(latestState?.contributionMode);
return {
panelMode: selectPanelMode.value,
vpsUrl: inputVpsUrl.value.trim(),
@@ -1321,14 +1343,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(),
@@ -1453,7 +1479,9 @@ function updateAccountRunHistorySettingsUI() {
return;
}
rowAccountRunHistoryHelperBaseUrl.style.display = inputAccountRunHistoryTextEnabled.checked ? '' : 'none';
rowAccountRunHistoryHelperBaseUrl.style.display = inputAccountRunHistoryTextEnabled.checked && !latestState?.contributionMode
? ''
: 'none';
}
function setSettingsCardLocked(locked) {
@@ -1624,6 +1652,7 @@ function applyAutoRunStatus(payload = currentAutoRun) {
syncScheduledCountdownTicker();
updateStopButtonState(scheduled || paused || locked || Object.values(getStepStatuses()).some(status => status === 'running'));
updateConfigMenuControls();
renderContributionMode();
}
function initializeManualStepActions() {
@@ -1805,6 +1834,7 @@ async function restoreState() {
updateStatusDisplay(latestState);
updateProgressCounter();
renderContributionMode();
} catch (err) {
console.error('Failed to restore state:', err);
}
@@ -2064,7 +2094,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) {
@@ -2626,6 +2656,7 @@ function updateButtonStates() {
if (checkboxAutoDeleteIcloud) checkboxAutoDeleteIcloud.disabled = disableIcloudControls;
if (btnContributionMode) btnContributionMode.disabled = isContributionButtonLocked();
updateStopButtonState(anyRunning || autoScheduled || isAutoRunPausedPhase() || autoLocked);
renderContributionMode();
}
function updateStopButtonState(active) {
@@ -3002,7 +3033,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() {
@@ -3362,9 +3452,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;
@@ -4064,12 +4210,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));
@@ -4172,6 +4326,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
)
);
}
renderContributionMode();
break;
}
+516
View File
@@ -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: '步骤 7contributionMode=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;
});
+20 -1
View File
@@ -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(/\\/$/, '');",
+445
View File
@@ -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();
});
+50 -12
View File
@@ -37,7 +37,8 @@
- 向后台发送命令
- 接收后台广播并更新 UI
- 动态渲染步骤列表
- 顶部提供一个临时 `贡献` 按钮,用于直接打开账号贡献上传页
- 管理顶部“贡献”按钮与贡献模式主面板;贡献模式本身是 sidepanel 的运行态 UI 模式,不是新的 `panelMode` 来源
- 在贡献模式下复用同一套主自动流程启动,并在面板内展示贡献链路的 `OAUTH / 回调 / 总状态` 三块实时状态
- 在日志区通过“记录”按钮打开独立的邮箱记录覆盖层,并展示成功/失败/停止/重试统计与分页列表
- 查询 GitHub Releases 并展示更新卡片;当前更新服务会区分 `Pro` 与 legacy `v` 两个版本族,排序时优先保持版本族语义一致,同时会在读取缓存后重新排序,避免旧缓存把 `v` 版本误显示为比 `Pro` 更新
@@ -119,6 +120,15 @@
保存运行态:
- 当前步骤状态
- 当前 sidepanel UI 模式 `contributionMode`
- 贡献 OAuth 会话字段:
- `contributionSessionId`
- `contributionAuthUrl`
- `contributionAuthState`
- `contributionCallbackUrl`
- `contributionStatus`
- `contributionStatusMessage`
- `contributionLastPollAt`
- OAuth 链接
- 当前邮箱 / 密码
- 第 8 步固定的验证码页显示邮箱 `step8VerificationTargetEmail`
@@ -147,6 +157,11 @@
- 账号运行历史 `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,22 +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 步主流程
- 当前这颗按钮不调用后台管理接口
- 当前这颗按钮不保存额外运行态
- 当步骤正在运行或自动流程处于锁定状态时,按钮会禁用,避免和主流程冲突
- 扩展会调用公开贡献 API,但这条调用被折叠进主 10 步自动链,而不是额外分出一条独立“贡献 OAuth 小流程
- 扩展不会保存 token
- 扩展不会持有任何管理密钥
- 扩展代码中不能直接调用 `/v0/management/*`
- 扩展不会直接访问生产管理面或生产 auth-dir
- 安全边界仍然在服务端;扩展只负责公开 OAuth 贡献前端流程
- 退出贡献模式后,要恢复普通模式 UI,并恢复持久配置中的自定义密码/本地同步偏好
## 5. 内容脚本通信链路
@@ -332,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
文件:
@@ -384,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 统一别名邮箱链路
+19 -6
View File
@@ -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. 测试规范
+6 -2
View File
@@ -42,6 +42,7 @@
- `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` 进入的所有业务消息。
@@ -113,9 +114,10 @@
- `sidepanel/icloud-manager.js`:侧边栏 iCloud 隐私邮箱管理器,负责列表、筛选、保留、删除和批量操作。
- `sidepanel/luckmail-manager.js`:侧边栏 LuckMail 管理器,负责邮箱列表、筛选、启停、保留与批量操作。
- `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/`
@@ -130,6 +132,7 @@
- `tests/background-auto-run-module.test.js`:测试自动运行控制器模块已接入且导出工厂。
- `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,6 +166,7 @@
- `tests/sidepanel-hotmail-manager.test.js`:测试侧边栏 Hotmail 管理器模块接线与空态渲染。
- `tests/sidepanel-contribution-button.test.js`:测试侧边栏顶部 `贡献` 按钮的 HTML 接线,以及直接打开账号贡献上传页的最小行为。
- `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 管理器模块接线与空态渲染。