diff --git a/background.js b/background.js index 3bec528..56761fe 100644 --- a/background.js +++ b/background.js @@ -8,6 +8,9 @@ const STOP_ERROR_MESSAGE = '流程已被用户停止。'; const HUMAN_STEP_DELAY_MIN = 700; const HUMAN_STEP_DELAY_MAX = 2200; const STEP7_RESTART_MAX_ROUNDS = 8; +const AUTO_RUN_ALARM_NAME = 'scheduled-auto-run'; +const AUTO_RUN_DELAY_MIN_MINUTES = 1; +const AUTO_RUN_DELAY_MAX_MINUTES = 1440; initializeSessionStorageAccess(); @@ -20,6 +23,8 @@ const PERSISTED_SETTING_DEFAULTS = { vpsPassword: '', // VPS 面板登录密码,可手动填写。 customPassword: '', // 自定义账号密码;留空时由程序自动生成随机密码。 autoRunSkipFailures: false, // 自动运行遇到失败步骤后,是否继续执行后续流程。 + autoRunDelayEnabled: false, // 自动运行是否启用启动前倒计时。 + autoRunDelayMinutes: 30, // 自动运行倒计时分钟数。 mailProvider: '163', // 验证码邮箱来源,当前支持 163 / inbucket。 inbucketHost: '', // 仅当 mailProvider 为 inbucket 时填写 Inbucket 地址,其他情况保持为空。 inbucketMailbox: '', // 仅当 mailProvider 为 inbucket 时填写邮箱名,其他情况保持为空。 @@ -51,14 +56,49 @@ const DEFAULT_STATE = { autoRunCurrentRun: 0, // 自动运行当前执行到第几轮。 autoRunTotalRuns: 1, // 自动运行计划总轮数。 autoRunAttemptRun: 0, // 当前轮次的重试序号。 + scheduledAutoRunAt: null, // 自动运行计划启动时间戳。 + scheduledAutoRunPlan: null, // 自动运行计划参数快照。 }; +function normalizeAutoRunDelayMinutes(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return PERSISTED_SETTING_DEFAULTS.autoRunDelayMinutes; + } + return Math.min( + AUTO_RUN_DELAY_MAX_MINUTES, + Math.max(AUTO_RUN_DELAY_MIN_MINUTES, Math.floor(numeric)) + ); +} + +function normalizeRunCount(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return 1; + } + return Math.min(50, Math.max(1, Math.floor(numeric))); +} + +function normalizeScheduledAutoRunPlan(plan) { + if (!plan || typeof plan !== 'object') { + return null; + } + + return { + totalRuns: normalizeRunCount(plan.totalRuns), + autoRunSkipFailures: Boolean(plan.autoRunSkipFailures), + mode: plan.mode === 'continue' ? 'continue' : 'restart', + }; +} + async function getPersistedSettings() { const stored = await chrome.storage.local.get(PERSISTED_SETTING_KEYS); return { ...PERSISTED_SETTING_DEFAULTS, ...stored, autoRunSkipFailures: Boolean(stored.autoRunSkipFailures ?? PERSISTED_SETTING_DEFAULTS.autoRunSkipFailures), + autoRunDelayEnabled: Boolean(stored.autoRunDelayEnabled ?? PERSISTED_SETTING_DEFAULTS.autoRunDelayEnabled), + autoRunDelayMinutes: normalizeAutoRunDelayMinutes(stored.autoRunDelayMinutes ?? PERSISTED_SETTING_DEFAULTS.autoRunDelayMinutes), }; } @@ -92,9 +132,13 @@ async function setPersistentSettings(updates) { const persistedUpdates = {}; for (const key of PERSISTED_SETTING_KEYS) { if (updates[key] !== undefined) { - persistedUpdates[key] = key === 'autoRunSkipFailures' - ? Boolean(updates[key]) - : updates[key]; + if (key === 'autoRunSkipFailures' || key === 'autoRunDelayEnabled') { + persistedUpdates[key] = Boolean(updates[key]); + } else if (key === 'autoRunDelayMinutes') { + persistedUpdates[key] = normalizeAutoRunDelayMinutes(updates[key]); + } else { + persistedUpdates[key] = updates[key]; + } } } @@ -1074,7 +1118,11 @@ function getAutoRunStatusPayload(phase, payload = {}) { const currentRun = payload.currentRun ?? autoRunCurrentRun; const totalRuns = payload.totalRuns ?? autoRunTotalRuns; const attemptRun = payload.attemptRun ?? autoRunAttemptRun; - const autoRunning = phase === 'running' || phase === 'waiting_email' || phase === 'retrying'; + const rawScheduledAt = phase === 'scheduled' + ? (payload.scheduledAt ?? payload.scheduledAutoRunAt ?? null) + : null; + const scheduledAt = rawScheduledAt === null ? null : Number(rawScheduledAt); + const autoRunning = phase === 'scheduled' || phase === 'running' || phase === 'waiting_email' || phase === 'retrying'; return { autoRunning, @@ -1082,18 +1130,26 @@ function getAutoRunStatusPayload(phase, payload = {}) { autoRunCurrentRun: currentRun, autoRunTotalRuns: totalRuns, autoRunAttemptRun: attemptRun, + scheduledAutoRunAt: Number.isFinite(scheduledAt) ? scheduledAt : null, }; } -async function broadcastAutoRunStatus(phase, payload = {}) { +async function broadcastAutoRunStatus(phase, payload = {}, extraState = {}) { + const rawScheduledAt = phase === 'scheduled' + ? (payload.scheduledAt ?? payload.scheduledAutoRunAt ?? null) + : null; const statusPayload = { phase, currentRun: payload.currentRun ?? autoRunCurrentRun, totalRuns: payload.totalRuns ?? autoRunTotalRuns, attemptRun: payload.attemptRun ?? autoRunAttemptRun, + scheduledAt: rawScheduledAt === null ? null : Number(rawScheduledAt), }; - await setState(getAutoRunStatusPayload(phase, statusPayload)); + await setState({ + ...extraState, + ...getAutoRunStatusPayload(phase, statusPayload), + }); chrome.runtime.sendMessage({ type: 'AUTO_RUN_STATUS', payload: statusPayload, @@ -1108,6 +1164,202 @@ function isAutoRunPausedState(state) { return Boolean(state.autoRunning) && state.autoRunPhase === 'waiting_email'; } +function isAutoRunScheduledState(state) { + const scheduledAt = state.scheduledAutoRunAt === null ? null : Number(state.scheduledAutoRunAt); + return Boolean(state.autoRunning) + && state.autoRunPhase === 'scheduled' + && Number.isFinite(scheduledAt) + && Boolean(normalizeScheduledAutoRunPlan(state.scheduledAutoRunPlan)); +} + +function formatAutoRunScheduleTime(timestamp) { + return new Date(timestamp).toLocaleString('zh-CN', { + hour12: false, + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +async function ensureScheduledAutoRunAlarm(scheduledAt) { + if (!Number.isFinite(scheduledAt) || scheduledAt <= Date.now()) { + return false; + } + + const existingAlarm = await chrome.alarms.get(AUTO_RUN_ALARM_NAME); + if (!existingAlarm || Math.abs((existingAlarm.scheduledTime || 0) - scheduledAt) > 1000) { + await chrome.alarms.clear(AUTO_RUN_ALARM_NAME); + await chrome.alarms.create(AUTO_RUN_ALARM_NAME, { when: scheduledAt }); + } + + return true; +} + +async function clearScheduledAutoRunAlarm() { + await chrome.alarms.clear(AUTO_RUN_ALARM_NAME); +} + +async function scheduleAutoRun(totalRuns, options = {}) { + const state = await getState(); + if (isAutoRunLockedState(state) || isAutoRunPausedState(state) || autoRunActive) { + throw new Error('自动运行已在进行中,请先停止后再重新计划。'); + } + if (isAutoRunScheduledState(state)) { + throw new Error('已有自动运行倒计时计划,请先取消或立即开始。'); + } + + const delayMinutes = normalizeAutoRunDelayMinutes(options.delayMinutes); + const plan = normalizeScheduledAutoRunPlan({ + totalRuns, + autoRunSkipFailures: options.autoRunSkipFailures, + mode: options.mode, + }); + const scheduledAt = Date.now() + delayMinutes * 60 * 1000; + + autoRunCurrentRun = 0; + autoRunTotalRuns = plan.totalRuns; + autoRunAttemptRun = 0; + + await ensureScheduledAutoRunAlarm(scheduledAt); + await broadcastAutoRunStatus( + 'scheduled', + { + currentRun: 0, + totalRuns: plan.totalRuns, + attemptRun: 0, + scheduledAt, + }, + { + autoRunSkipFailures: plan.autoRunSkipFailures, + scheduledAutoRunPlan: plan, + } + ); + await addLog( + `自动运行已计划:${delayMinutes} 分钟后启动(${formatAutoRunScheduleTime(scheduledAt)}),目标 ${plan.totalRuns} 轮。`, + 'info' + ); + return { ok: true, scheduledAt }; +} + +let scheduledAutoRunLaunching = false; + +async function launchScheduledAutoRun(trigger = 'alarm') { + if (scheduledAutoRunLaunching) { + return false; + } + + scheduledAutoRunLaunching = true; + try { + const state = await getState(); + if (!isAutoRunScheduledState(state)) { + return false; + } + if (autoRunActive) { + return false; + } + + const plan = normalizeScheduledAutoRunPlan(state.scheduledAutoRunPlan); + if (!plan) { + await clearScheduledAutoRunAlarm(); + await broadcastAutoRunStatus('idle', { + currentRun: 0, + totalRuns: 1, + attemptRun: 0, + }, { + scheduledAutoRunPlan: null, + }); + return false; + } + + await clearScheduledAutoRunAlarm(); + await broadcastAutoRunStatus( + 'running', + { + currentRun: 0, + totalRuns: plan.totalRuns, + attemptRun: 0, + }, + { + autoRunSkipFailures: plan.autoRunSkipFailures, + scheduledAutoRunPlan: null, + } + ); + + clearStopRequest(); + await addLog( + trigger === 'manual' + ? '已手动跳过倒计时,自动运行立即开始。' + : '倒计时结束,自动运行开始执行。', + 'info' + ); + autoRunLoop(plan.totalRuns, { + autoRunSkipFailures: plan.autoRunSkipFailures, + mode: plan.mode, + }); + return true; + } finally { + scheduledAutoRunLaunching = false; + } +} + +async function cancelScheduledAutoRun(options = {}) { + const state = await getState(); + if (!isAutoRunScheduledState(state)) { + return false; + } + const plan = normalizeScheduledAutoRunPlan(state.scheduledAutoRunPlan); + + await clearScheduledAutoRunAlarm(); + autoRunCurrentRun = 0; + autoRunTotalRuns = plan?.totalRuns || 1; + autoRunAttemptRun = 0; + await broadcastAutoRunStatus( + 'idle', + { + currentRun: 0, + totalRuns: plan?.totalRuns || 1, + attemptRun: 0, + }, + { + scheduledAutoRunPlan: null, + } + ); + if (options.logMessage !== false) { + await addLog(options.logMessage || '已取消自动运行倒计时计划。', 'warn'); + } + return true; +} + +async function restoreScheduledAutoRunIfNeeded() { + const state = await getState(); + if (state.autoRunPhase !== 'scheduled') { + return; + } + + const plan = normalizeScheduledAutoRunPlan(state.scheduledAutoRunPlan); + const scheduledAt = state.scheduledAutoRunAt === null ? null : Number(state.scheduledAutoRunAt); + if (!plan || !Number.isFinite(scheduledAt)) { + await clearScheduledAutoRunAlarm(); + await broadcastAutoRunStatus('idle', { + currentRun: 0, + totalRuns: 1, + attemptRun: 0, + }, { + scheduledAutoRunPlan: null, + }); + return; + } + + if (scheduledAt <= Date.now()) { + await launchScheduledAutoRun('restore'); + return; + } + + await ensureScheduledAutoRunAlarm(scheduledAt); +} + async function ensureManualInteractionAllowed(actionLabel) { const state = await getState(); @@ -1117,6 +1369,9 @@ async function ensureManualInteractionAllowed(actionLabel) { if (isAutoRunPausedState(state)) { throw new Error(`自动流程当前已暂停。请点击“继续”,或先确认接管自动流程后再${actionLabel}。`); } + if (isAutoRunScheduledState(state)) { + throw new Error(`自动流程已计划启动。请先取消计划,或立即开始后再${actionLabel}。`); + } return state; } @@ -1313,6 +1568,7 @@ async function handleMessage(message, sender) { case 'RESET': { clearStopRequest(); + await clearScheduledAutoRunAlarm(); await resetState(); await addLog('流程已重置', 'info'); return { ok: true }; @@ -1337,7 +1593,11 @@ async function handleMessage(message, sender) { case 'AUTO_RUN': { clearStopRequest(); - const totalRuns = message.payload?.totalRuns || 1; + const state = await getState(); + if (isAutoRunScheduledState(state)) { + throw new Error('已有自动运行倒计时计划,请先取消或立即开始。'); + } + const totalRuns = normalizeRunCount(message.payload?.totalRuns || 1); const autoRunSkipFailures = Boolean(message.payload?.autoRunSkipFailures); const mode = message.payload?.mode === 'continue' ? 'continue' : 'restart'; await setState({ autoRunSkipFailures }); @@ -1345,6 +1605,33 @@ async function handleMessage(message, sender) { return { ok: true }; } + case 'SCHEDULE_AUTO_RUN': { + clearStopRequest(); + const totalRuns = normalizeRunCount(message.payload?.totalRuns || 1); + return await scheduleAutoRun(totalRuns, { + delayMinutes: message.payload?.delayMinutes, + autoRunSkipFailures: Boolean(message.payload?.autoRunSkipFailures), + mode: message.payload?.mode, + }); + } + + case 'START_SCHEDULED_AUTO_RUN_NOW': { + clearStopRequest(); + const started = await launchScheduledAutoRun('manual'); + if (!started) { + throw new Error('当前没有可立即开始的倒计时计划。'); + } + return { ok: true }; + } + + case 'CANCEL_SCHEDULED_AUTO_RUN': { + const cancelled = await cancelScheduledAutoRun(); + if (!cancelled) { + throw new Error('当前没有可取消的倒计时计划。'); + } + return { ok: true }; + } + case 'RESUME_AUTO_RUN': { clearStopRequest(); if (message.payload.email) { @@ -1371,6 +1658,8 @@ async function handleMessage(message, sender) { if (message.payload.vpsPassword !== undefined) updates.vpsPassword = message.payload.vpsPassword; if (message.payload.customPassword !== undefined) updates.customPassword = message.payload.customPassword; if (message.payload.autoRunSkipFailures !== undefined) updates.autoRunSkipFailures = Boolean(message.payload.autoRunSkipFailures); + if (message.payload.autoRunDelayEnabled !== undefined) updates.autoRunDelayEnabled = Boolean(message.payload.autoRunDelayEnabled); + if (message.payload.autoRunDelayMinutes !== undefined) updates.autoRunDelayMinutes = normalizeAutoRunDelayMinutes(message.payload.autoRunDelayMinutes); if (message.payload.mailProvider !== undefined) updates.mailProvider = message.payload.mailProvider; if (message.payload.inbucketHost !== undefined) updates.inbucketHost = message.payload.inbucketHost; if (message.payload.inbucketMailbox !== undefined) updates.inbucketMailbox = message.payload.inbucketMailbox; @@ -1516,6 +1805,17 @@ async function markRunningStepsStopped() { async function requestStop(options = {}) { const { logMessage = '已收到停止请求,正在取消当前操作...' } = options; + const state = await getState(); + + if (isAutoRunScheduledState(state) && !autoRunActive) { + await cancelScheduledAutoRun({ + logMessage: options.logMessage === false + ? false + : (options.logMessage || '已取消自动运行倒计时计划。'), + }); + return; + } + if (stopRequested) return; stopRequested = true; @@ -3004,3 +3304,28 @@ async function executeStep9(state) { // ============================================================ chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }); + +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name !== AUTO_RUN_ALARM_NAME) { + return; + } + launchScheduledAutoRun('alarm').catch((err) => { + console.error(LOG_PREFIX, 'Failed to launch scheduled auto run from alarm:', err); + }); +}); + +chrome.runtime.onStartup.addListener(() => { + restoreScheduledAutoRunIfNeeded().catch((err) => { + console.error(LOG_PREFIX, 'Failed to restore scheduled auto run on startup:', err); + }); +}); + +chrome.runtime.onInstalled.addListener(() => { + restoreScheduledAutoRunIfNeeded().catch((err) => { + console.error(LOG_PREFIX, 'Failed to restore scheduled auto run on install/update:', err); + }); +}); + +restoreScheduledAutoRunIfNeeded().catch((err) => { + console.error(LOG_PREFIX, 'Failed to restore scheduled auto run:', err); +}); diff --git a/manifest.json b/manifest.json index 9638b71..14473c3 100644 --- a/manifest.json +++ b/manifest.json @@ -5,6 +5,7 @@ "description": "用于自动执行多步骤 OAuth 注册流程", "permissions": [ "sidePanel", + "alarms", "tabs", "webNavigation", "debugger", diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index d1dea21..7de5e70 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -376,6 +376,35 @@ header { flex-shrink: 0; } +.auto-delay-inline { + justify-content: space-between; + gap: 12px; +} + +.auto-delay-check { + flex: 0 1 auto; +} + +.auto-delay-controls { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.auto-delay-input { + width: 72px; + flex: 0 0 auto; + text-align: center; +} + +.data-unit { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + flex-shrink: 0; +} + /* Status Bar */ .status-bar { display: flex; @@ -422,6 +451,12 @@ header { } .status-bar.paused { color: var(--orange); } +.status-bar.scheduled .status-dot { + background: var(--blue); + animation: pulse 1.5s ease-in-out infinite; +} +.status-bar.scheduled { color: var(--blue); } + @keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.85); } @@ -441,6 +476,51 @@ header { .auto-continue-bar svg { color: var(--orange); flex-shrink: 0; } .auto-hint { font-size: 13px; color: var(--orange); flex: 1; font-weight: 500; } +.auto-schedule-bar { + margin-top: 8px; + padding: 10px; + background: var(--blue-soft); + border: 1px solid rgba(37, 99, 235, 0.2); + border-radius: var(--radius-sm); + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.auto-schedule-bar svg { + color: var(--blue); + flex-shrink: 0; +} + +.auto-schedule-copy { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-width: 0; +} + +.auto-schedule-title { + font-size: 13px; + font-weight: 700; + color: var(--blue); +} + +.auto-schedule-meta { + font-size: 12px; + line-height: 1.5; + color: var(--text-secondary); +} + +.auto-schedule-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + flex-shrink: 0; +} + /* ============================================================ Steps Section ============================================================ */ diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 98a4d9c..3c69dfe 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -96,6 +96,28 @@ 出现错误无法继续时,直接丢弃当前线程并新开一轮,直到补足目标运行次数 +