diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..806531c --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# Multi-Page Automation + +ChatGPT OAuth 批量注册自动化 Chrome 扩展。 + +## 前提 + +- 注册 [DuckDuckGo Email Protection](https://duckduckgo.com/email/),获取 @duck.com 邮箱地址,转发目标设为 163 或 QQ 邮箱 +- Chrome 浏览器,开发者模式加载本扩展 +- 提前登录好 VPS 管理面板和对应的邮箱(163 或 QQ) + +## 安装 + +1. 打开 `chrome://extensions/`,开启「开发者模式」 +2. 点击「加载已解压的扩展程序」,选择本项目文件夹 +3. 点击浏览器工具栏的扩展图标,打开侧边面板 + +## 执行流程 + +在侧边面板填入 VPS 管理面板地址,选择邮箱类型,设置运行次数,点击 Auto: + +1. **获取 OAuth 链接** — 自动打开 VPS 面板,点击登录拿到 OpenAI 授权链接 +2. **打开注册页** — 用授权链接打开 OpenAI 注册页,自动点击 Sign up +3. ⏸️ **暂停等待** — 面板弹出橙色提示,**手动粘贴一个新的 @duck.com 邮箱地址**,点击 Continue +4. **填写注册信息** — 自动将 @duck.com 地址填入注册表单,自动生成随机密码,提交 +5. **获取注册验证码** — 自动切到邮箱轮询收件箱,提取 6 位验证码,填回注册页 +6. **完善资料** — 自动随机生成英文姓名和生日,提交完成注册 +7. **登录** — 重新打开 OAuth 链接,用刚注册的账号自动登录 +8. **获取登录验证码** — 再次轮询邮箱拿验证码,自动填入 +9. **完成 OAuth 授权** — 自动点击授权页「继续」,后台捕获回调地址 +10. **VPS 回调验证** — 自动将回调地址提交到 VPS 面板,完成认证 + +> 全程仅需在第 3 步手动粘贴一次邮箱地址,其余全自动。 +> 支持设置 N 次连续批量运行,每轮在第 2 步后暂停等待粘贴新邮箱。 + +## 安全说明 + +- VPS 地址在面板中以密码框显示,不会明文泄露 +- 每次注册自动生成随机强密码(14位,含大小写+数字+符号) +- 所有状态存储在 `chrome.storage.session`,浏览器关闭即清除 +- 代码中无硬编码的密码、IP 地址等敏感信息 + diff --git a/background.js b/background.js index f07be54..bb0be8f 100644 --- a/background.js +++ b/background.js @@ -3,6 +3,10 @@ importScripts('data/names.js'); const LOG_PREFIX = '[MultiPage:bg]'; +const DUCK_AUTOFILL_URL = 'https://duckduckgo.com/email/settings/autofill'; +const STOP_ERROR_MESSAGE = 'Flow stopped by user.'; +const HUMAN_STEP_DELAY_MIN = 700; +const HUMAN_STEP_DELAY_MAX = 2200; // ============================================================ // State Management (chrome.storage.session) @@ -37,6 +41,18 @@ async function setState(updates) { await chrome.storage.session.set(updates); } +function broadcastDataUpdate(payload) { + chrome.runtime.sendMessage({ + type: 'DATA_UPDATED', + payload, + }).catch(() => {}); +} + +async function setEmailState(email) { + await setState({ email }); + broadcastDataUpdate({ email }); +} + async function resetState() { console.log(LOG_PREFIX, 'Resetting all state'); // Preserve settings and persistent data across resets @@ -143,6 +159,15 @@ function flushCommand(source, tabId) { } } +function cancelPendingCommands(reason = STOP_ERROR_MESSAGE) { + for (const [source, pending] of pendingCommands.entries()) { + clearTimeout(pending.timer); + pending.reject(new Error(reason)); + pendingCommands.delete(source); + console.log(LOG_PREFIX, `Cancelled queued command for ${source}`); + } +} + // ============================================================ // Reuse or create tab // ============================================================ @@ -151,9 +176,30 @@ async function reuseOrCreateTab(source, url, options = {}) { const alive = await isTabAlive(source); if (alive) { const tabId = await getTabId(source); + const currentTab = await chrome.tabs.get(tabId); + const sameUrl = currentTab.url === url; + + const registry = await getTabRegistry(); + if (sameUrl) { + await chrome.tabs.update(tabId, { active: true }); + console.log(LOG_PREFIX, `Reused tab ${source} (${tabId}) on same URL`); + + // For dynamically injected pages like the VPS panel, re-inject immediately. + // Waiting for a navigation event here will hang when the URL hasn't changed. + if (options.inject) { + if (registry[source]) registry[source].ready = false; + await setState({ tabRegistry: registry }); + await chrome.scripting.executeScript({ + target: { tabId }, + files: options.inject, + }); + await new Promise(r => setTimeout(r, 500)); + } + + return tabId; + } // Mark as not ready BEFORE navigating — so READY signal from new page is captured correctly - const registry = await getTabRegistry(); if (registry[source]) registry[source].ready = false; await setState({ tabRegistry: registry }); @@ -271,6 +317,50 @@ async function setStepStatus(step, status) { }).catch(() => {}); } +function isStopError(error) { + const message = typeof error === 'string' ? error : error?.message; + return message === STOP_ERROR_MESSAGE; +} + +function clearStopRequest() { + stopRequested = false; +} + +function throwIfStopped() { + if (stopRequested) { + throw new Error(STOP_ERROR_MESSAGE); + } +} + +async function sleepWithStop(ms) { + const start = Date.now(); + while (Date.now() - start < ms) { + throwIfStopped(); + await new Promise(r => setTimeout(r, Math.min(100, ms - (Date.now() - start)))); + } +} + +async function humanStepDelay(min = HUMAN_STEP_DELAY_MIN, max = HUMAN_STEP_DELAY_MAX) { + const duration = Math.floor(Math.random() * (max - min + 1)) + min; + await sleepWithStop(duration); +} + +async function broadcastStopToContentScripts() { + const registry = await getTabRegistry(); + for (const entry of Object.values(registry)) { + if (!entry?.tabId) continue; + try { + await chrome.tabs.sendMessage(entry.tabId, { + type: 'STOP_FLOW', + source: 'background', + payload: {}, + }); + } catch {} + } +} + +let stopRequested = false; + // ============================================================ // Message Handler (central router) // ============================================================ @@ -307,6 +397,11 @@ async function handleMessage(message, sender) { } case 'STEP_COMPLETE': { + if (stopRequested) { + await setStepStatus(message.step, 'stopped'); + notifyStepError(message.step, STOP_ERROR_MESSAGE); + return { ok: true }; + } await setStepStatus(message.step, 'completed'); await addLog(`Step ${message.step} completed`, 'ok'); await handleStepData(message.step, message.payload); @@ -315,9 +410,15 @@ async function handleMessage(message, sender) { } case 'STEP_ERROR': { - await setStepStatus(message.step, 'failed'); - await addLog(`Step ${message.step} failed: ${message.error}`, 'error'); - notifyStepError(message.step, message.error); + if (isStopError(message.error)) { + await setStepStatus(message.step, 'stopped'); + await addLog(`Step ${message.step} stopped by user`, 'warn'); + notifyStepError(message.step, message.error); + } else { + await setStepStatus(message.step, 'failed'); + await addLog(`Step ${message.step} failed: ${message.error}`, 'error'); + notifyStepError(message.step, message.error); + } return { ok: true }; } @@ -326,30 +427,34 @@ async function handleMessage(message, sender) { } case 'RESET': { + clearStopRequest(); await resetState(); await addLog('Flow reset', 'info'); return { ok: true }; } case 'EXECUTE_STEP': { + clearStopRequest(); const step = message.payload.step; // Save email if provided (from side panel step 3) if (message.payload.email) { - await setState({ email: message.payload.email }); + await setEmailState(message.payload.email); } await executeStep(step); return { ok: true }; } case 'AUTO_RUN': { + clearStopRequest(); const totalRuns = message.payload?.totalRuns || 1; autoRunLoop(totalRuns); // fire-and-forget return { ok: true }; } case 'RESUME_AUTO_RUN': { + clearStopRequest(); if (message.payload.email) { - await setState({ email: message.payload.email }); + await setEmailState(message.payload.email); } resumeAutoRun(); // fire-and-forget return { ok: true }; @@ -365,7 +470,18 @@ async function handleMessage(message, sender) { // Side panel data updates case 'SAVE_EMAIL': { - await setState({ email: message.payload.email }); + await setEmailState(message.payload.email); + return { ok: true, email: message.payload.email }; + } + + case 'FETCH_DUCK_EMAIL': { + clearStopRequest(); + const email = await fetchDuckEmail(message.payload || {}); + return { ok: true, email }; + } + + case 'STOP_FLOW': { + await requestStop(); return { ok: true }; } @@ -384,15 +500,11 @@ async function handleStepData(step, payload) { case 1: if (payload.oauthUrl) { await setState({ oauthUrl: payload.oauthUrl }); - // Broadcast OAuth URL to side panel - chrome.runtime.sendMessage({ - type: 'DATA_UPDATED', - payload: { oauthUrl: payload.oauthUrl }, - }).catch(() => {}); + broadcastDataUpdate({ oauthUrl: payload.oauthUrl }); } break; case 3: - if (payload.email) await setState({ email: payload.email }); + if (payload.email) await setEmailState(payload.email); break; case 4: if (payload.emailTimestamp) await setState({ lastEmailTimestamp: payload.emailTimestamp }); @@ -400,10 +512,7 @@ async function handleStepData(step, payload) { case 8: if (payload.localhostUrl) { await setState({ localhostUrl: payload.localhostUrl }); - chrome.runtime.sendMessage({ - type: 'DATA_UPDATED', - payload: { localhostUrl: payload.localhostUrl }, - }).catch(() => {}); + broadcastDataUpdate({ localhostUrl: payload.localhostUrl }); } break; } @@ -415,9 +524,11 @@ async function handleStepData(step, payload) { // Map of step -> { resolve, reject } for waiting on step completion const stepWaiters = new Map(); +let resumeWaiter = null; function waitForStepComplete(step, timeoutMs = 120000) { return new Promise((resolve, reject) => { + throwIfStopped(); const timer = setTimeout(() => { stepWaiters.delete(step); reject(new Error(`Step ${step} timed out after ${timeoutMs / 1000}s`)); @@ -440,14 +551,59 @@ function notifyStepError(step, error) { if (waiter) waiter.reject(new Error(error)); } +async function markRunningStepsStopped() { + const state = await getState(); + const runningSteps = Object.entries(state.stepStatuses || {}) + .filter(([, status]) => status === 'running') + .map(([step]) => Number(step)); + + for (const step of runningSteps) { + await setStepStatus(step, 'stopped'); + } +} + +async function requestStop() { + if (stopRequested) return; + + stopRequested = true; + cancelPendingCommands(); + if (webNavListener) { + chrome.webNavigation.onBeforeNavigate.removeListener(webNavListener); + webNavListener = null; + } + + await addLog('Stop requested. Cancelling current operations...', 'warn'); + await broadcastStopToContentScripts(); + + for (const waiter of stepWaiters.values()) { + waiter.reject(new Error(STOP_ERROR_MESSAGE)); + } + stepWaiters.clear(); + + if (resumeWaiter) { + resumeWaiter.reject(new Error(STOP_ERROR_MESSAGE)); + resumeWaiter = null; + } + + await markRunningStepsStopped(); + autoRunActive = false; + await setState({ autoRunning: false }); + chrome.runtime.sendMessage({ + type: 'AUTO_RUN_STATUS', + payload: { phase: 'stopped', currentRun: autoRunCurrentRun, totalRuns: autoRunTotalRuns }, + }).catch(() => {}); +} + // ============================================================ // Step Execution // ============================================================ async function executeStep(step) { console.log(LOG_PREFIX, `Executing step ${step}`); + throwIfStopped(); await setStepStatus(step, 'running'); await addLog(`Step ${step} started`); + await humanStepDelay(); const state = await getState(); @@ -471,8 +627,14 @@ async function executeStep(step) { throw new Error(`Unknown step: ${step}`); } } catch (err) { + if (isStopError(err)) { + await setStepStatus(step, 'stopped'); + await addLog(`Step ${step} stopped by user`, 'warn'); + throw err; + } await setStepStatus(step, 'failed'); await addLog(`Step ${step} failed: ${err.message}`, 'error'); + throw err; } } @@ -482,15 +644,41 @@ async function executeStep(step) { * @param {number} delayAfter - ms to wait after completion (for page transitions) */ async function executeStepAndWait(step, delayAfter = 2000) { + throwIfStopped(); const promise = waitForStepComplete(step, 120000); await executeStep(step); await promise; // Extra delay for page transitions / DOM updates if (delayAfter > 0) { - await new Promise(r => setTimeout(r, delayAfter)); + await sleepWithStop(delayAfter + Math.floor(Math.random() * 1200)); } } +async function fetchDuckEmail(options = {}) { + throwIfStopped(); + const { generateNew = true } = options; + + await addLog(`Duck Mail: Opening autofill settings (${generateNew ? 'generate new' : 'reuse current'})...`); + await reuseOrCreateTab('duck-mail', DUCK_AUTOFILL_URL); + + const result = await sendToContentScript('duck-mail', { + type: 'FETCH_DUCK_EMAIL', + source: 'background', + payload: { generateNew }, + }); + + if (result?.error) { + throw new Error(result.error); + } + if (!result?.email) { + throw new Error('Duck email not returned.'); + } + + await setEmailState(result.email); + await addLog(`Duck Mail: ${result.generated ? 'Generated' : 'Loaded'} ${result.email}`, 'ok'); + return result.email; +} + // ============================================================ // Auto Run Flow // ============================================================ @@ -506,6 +694,7 @@ async function autoRunLoop(totalRuns) { return; } + clearStopRequest(); autoRunActive = true; autoRunTotalRuns = totalRuns; await setState({ autoRunning: true }); @@ -524,33 +713,49 @@ async function autoRunLoop(totalRuns) { await setState(keepSettings); // Tell side panel to reset all UI chrome.runtime.sendMessage({ type: 'AUTO_RUN_RESET' }).catch(() => {}); - await new Promise(r => setTimeout(r, 500)); + await sleepWithStop(500); await addLog(`=== Auto Run ${run}/${totalRuns} — Phase 1: Get OAuth link & open signup ===`, 'info'); const status = (phase) => ({ type: 'AUTO_RUN_STATUS', payload: { phase, currentRun: run, totalRuns } }); try { + throwIfStopped(); chrome.runtime.sendMessage(status('running')).catch(() => {}); await executeStepAndWait(1, 2000); await executeStepAndWait(2, 2000); - // Pause for email - await addLog(`=== Run ${run}/${totalRuns} PAUSED: Paste DuckDuckGo email, click Continue ===`, 'warn'); - chrome.runtime.sendMessage(status('waiting_email')).catch(() => {}); + let emailReady = false; + try { + const duckEmail = await fetchDuckEmail({ generateNew: true }); + await addLog(`=== Run ${run}/${totalRuns} — Duck email ready: ${duckEmail} ===`, 'ok'); + emailReady = true; + } catch (err) { + await addLog(`Duck Mail auto-fetch failed: ${err.message}`, 'warn'); + } - // Wait for RESUME_AUTO_RUN — sets a promise that resumeAutoRun resolves - await waitForResume(); + if (!emailReady) { + await addLog(`=== Run ${run}/${totalRuns} PAUSED: Fetch Duck email or paste manually, then continue ===`, 'warn'); + chrome.runtime.sendMessage(status('waiting_email')).catch(() => {}); - const state = await getState(); - if (!state.email) { - await addLog('Cannot resume: no email address.', 'error'); - break; + // Wait for RESUME_AUTO_RUN — sets a promise that resumeAutoRun resolves + await waitForResume(); + + const resumedState = await getState(); + if (!resumedState.email) { + await addLog('Cannot resume: no email address.', 'error'); + break; + } } await addLog(`=== Run ${run}/${totalRuns} — Phase 2: Register, verify, login, complete ===`, 'info'); chrome.runtime.sendMessage(status('running')).catch(() => {}); + const signupTabId = await getTabId('signup-page'); + if (signupTabId) { + await chrome.tabs.update(signupTabId, { active: true }); + } + await executeStepAndWait(3, 3000); await executeStepAndWait(4, 2000); await executeStepAndWait(5, 3000); @@ -562,41 +767,49 @@ async function autoRunLoop(totalRuns) { await addLog(`=== Run ${run}/${totalRuns} COMPLETE! ===`, 'ok'); } catch (err) { - await addLog(`Run ${run}/${totalRuns} failed: ${err.message}`, 'error'); + if (isStopError(err)) { + await addLog(`Run ${run}/${totalRuns} stopped by user`, 'warn'); + } else { + await addLog(`Run ${run}/${totalRuns} failed: ${err.message}`, 'error'); + } chrome.runtime.sendMessage(status('stopped')).catch(() => {}); break; // Stop on error } } const completedRuns = autoRunCurrentRun; - if (completedRuns >= autoRunTotalRuns) { + if (stopRequested) { + await addLog(`=== Stopped after ${Math.max(0, completedRuns - 1)}/${autoRunTotalRuns} runs ===`, 'warn'); + chrome.runtime.sendMessage({ type: 'AUTO_RUN_STATUS', payload: { phase: 'stopped', currentRun: completedRuns, totalRuns: autoRunTotalRuns } }).catch(() => {}); + } else if (completedRuns >= autoRunTotalRuns) { await addLog(`=== All ${autoRunTotalRuns} runs completed successfully ===`, 'ok'); + chrome.runtime.sendMessage({ type: 'AUTO_RUN_STATUS', payload: { phase: 'complete', currentRun: completedRuns, totalRuns: autoRunTotalRuns } }).catch(() => {}); } else { await addLog(`=== Stopped after ${completedRuns}/${autoRunTotalRuns} runs ===`, 'warn'); + chrome.runtime.sendMessage({ type: 'AUTO_RUN_STATUS', payload: { phase: 'stopped', currentRun: completedRuns, totalRuns: autoRunTotalRuns } }).catch(() => {}); } - chrome.runtime.sendMessage({ type: 'AUTO_RUN_STATUS', payload: { phase: 'complete', currentRun: completedRuns, totalRuns: autoRunTotalRuns } }).catch(() => {}); autoRunActive = false; await setState({ autoRunning: false }); + clearStopRequest(); } -// Promise-based pause/resume mechanism -let resumeResolver = null; - function waitForResume() { - return new Promise((resolve) => { - resumeResolver = resolve; + return new Promise((resolve, reject) => { + throwIfStopped(); + resumeWaiter = { resolve, reject }; }); } async function resumeAutoRun() { + throwIfStopped(); const state = await getState(); if (!state.email) { await addLog('Cannot resume: no email address. Paste email in Side Panel first.', 'error'); return; } - if (resumeResolver) { - resumeResolver(); - resumeResolver = null; + if (resumeWaiter) { + resumeWaiter.resolve(); + resumeWaiter = null; } } @@ -823,7 +1036,7 @@ async function executeStep7(state) { } // ============================================================ -// Step 8: Complete OAuth (webNavigation listener + chatgpt.js navigates) +// Step 8: Complete OAuth (manual click + localhost listener) // ============================================================ let webNavListener = null; @@ -833,7 +1046,7 @@ async function executeStep8(state) { throw new Error('No OAuth URL. Complete step 1 first.'); } - await addLog('Step 8: Setting up localhost redirect listener...'); + await addLog('Step 8: Setting up localhost redirect listener for manual confirmation...'); // Register webNavigation listener (scoped to this step) return new Promise((resolve, reject) => { @@ -843,9 +1056,9 @@ async function executeStep8(state) { webNavListener = null; } setStepStatus(8, 'failed'); - addLog('Step 8: Localhost redirect not captured after 30s. Check if OAuth authorization completed.', 'error'); - reject(new Error('Localhost redirect not captured after 30s. Check if OAuth authorization completed.')); - }, 30000); + addLog('Step 8: Localhost redirect not captured after 120s. Please confirm you clicked "继续" on the OAuth page.', 'error'); + reject(new Error('Localhost redirect not captured after 120s. Please click "继续" on the OAuth page.')); + }, 120000); webNavListener = (details) => { if (details.url.startsWith('http://localhost')) { @@ -858,10 +1071,7 @@ async function executeStep8(state) { addLog(`Step 8: Captured localhost URL: ${details.url}`, 'ok'); setStepStatus(8, 'completed'); notifyStepComplete(8, { localhostUrl: details.url }); - chrome.runtime.sendMessage({ - type: 'DATA_UPDATED', - payload: { localhostUrl: details.url }, - }).catch(() => {}); + broadcastDataUpdate({ localhostUrl: details.url }); resolve(); }); } @@ -870,28 +1080,16 @@ async function executeStep8(state) { chrome.webNavigation.onBeforeNavigate.addListener(webNavListener); // After step 7, the auth page shows a consent screen ("使用 ChatGPT 登录到 Codex") - // with a "继续" button. We need to click it, which triggers the localhost redirect. + // with a "继续" button. The user must click it manually. (async () => { try { const signupTabId = await getTabId('signup-page'); if (signupTabId) { await chrome.tabs.update(signupTabId, { active: true }); - await addLog('Step 8: Switching to auth page, clicking "继续" to complete OAuth...'); - await sendToContentScript('signup-page', { - type: 'EXECUTE_STEP', - step: 8, - source: 'background', - payload: {}, - }); + await addLog('Step 8: Switched to auth page. Please click "继续" manually to complete OAuth.', 'warn'); } else { await reuseOrCreateTab('signup-page', state.oauthUrl); - await addLog('Step 8: Auth tab reopened...'); - await sendToContentScript('signup-page', { - type: 'EXECUTE_STEP', - step: 8, - source: 'background', - payload: {}, - }); + await addLog('Step 8: Auth tab reopened. Please click "继续" manually to complete OAuth.', 'warn'); } } catch (err) { clearTimeout(timeout); diff --git a/content/duck-mail.js b/content/duck-mail.js new file mode 100644 index 0000000..4acc564 --- /dev/null +++ b/content/duck-mail.js @@ -0,0 +1,74 @@ +// content/duck-mail.js — Content script for DuckDuckGo Email Protection autofill settings + +console.log('[MultiPage:duck-mail] Content script loaded on', location.href); + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.type !== 'FETCH_DUCK_EMAIL') return; + + resetStopState(); + fetchDuckEmail(message.payload).then(result => { + sendResponse(result); + }).catch(err => { + if (isStopError(err)) { + log('Duck Mail: Stopped by user.', 'warn'); + sendResponse({ stopped: true, error: err.message }); + return; + } + sendResponse({ error: err.message }); + }); + + return true; +}); + +async function fetchDuckEmail(payload = {}) { + const { generateNew = true } = payload; + + log(`Duck Mail: ${generateNew ? 'Generating' : 'Reading'} private address...`); + + await waitForElement( + 'input.AutofillSettingsPanel__PrivateDuckAddressValue, button.AutofillSettingsPanel__GeneratorButton', + 15000 + ); + + const getAddressInput = () => document.querySelector('input.AutofillSettingsPanel__PrivateDuckAddressValue'); + const getGeneratorButton = () => document.querySelector('button.AutofillSettingsPanel__GeneratorButton') + || Array.from(document.querySelectorAll('button')).find(btn => /generate private duck address/i.test(btn.textContent || '')); + const readEmail = () => { + const value = getAddressInput()?.value?.trim() || ''; + return value.includes('@duck.com') ? value : ''; + }; + + const waitForEmailValue = async (previousValue = '') => { + for (let i = 0; i < 100; i++) { + const nextValue = readEmail(); + if (nextValue && nextValue !== previousValue) { + return nextValue; + } + await sleep(150); + } + throw new Error('Timed out waiting for Duck address to appear.'); + }; + + const currentEmail = readEmail(); + if (currentEmail && !generateNew) { + log(`Duck Mail: Found existing address ${currentEmail}`); + return { email: currentEmail, generated: false }; + } + + await humanPause(500, 1300); + const generatorButton = getGeneratorButton(); + if (!generatorButton) { + if (currentEmail) { + log(`Duck Mail: Reusing existing address ${currentEmail}`, 'warn'); + return { email: currentEmail, generated: false }; + } + throw new Error('Could not find "Generate Private Duck Address" button.'); + } + + generatorButton.click(); + log('Duck Mail: Clicked "Generate Private Duck Address"'); + + const nextEmail = await waitForEmailValue(currentEmail); + log(`Duck Mail: Ready address ${nextEmail}`, 'ok'); + return { email: nextEmail, generated: true }; +} diff --git a/content/mail-163.js b/content/mail-163.js index b54f92b..9e8dab3 100644 --- a/content/mail-163.js +++ b/content/mail-163.js @@ -41,9 +41,15 @@ async function persistSeenCodes() { chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === 'POLL_EMAIL') { + resetStopState(); handlePollEmail(message.step, message.payload).then(result => { sendResponse(result); }).catch(err => { + if (isStopError(err)) { + log(`Step ${message.step}: Stopped by user.`, 'warn'); + sendResponse({ stopped: true, error: err.message }); + return; + } reportError(message.step, err.message); sendResponse({ error: err.message }); }); diff --git a/content/qq-mail.js b/content/qq-mail.js index d2cabaa..a8ccfe2 100644 --- a/content/qq-mail.js +++ b/content/qq-mail.js @@ -22,9 +22,15 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { sendResponse({ ok: false, reason: 'wrong-frame' }); return; } + resetStopState(); handlePollEmail(message.step, message.payload).then(result => { sendResponse(result); }).catch(err => { + if (isStopError(err)) { + log(`Step ${message.step}: Stopped by user.`, 'warn'); + sendResponse({ stopped: true, error: err.message }); + return; + } reportError(message.step, err.message); sendResponse({ error: err.message }); }); diff --git a/content/signup-page.js b/content/signup-page.js index 2a214c4..c48cc11 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -6,9 +6,15 @@ console.log('[MultiPage:signup-page] Content script loaded on', location.href); // Listen for commands from Background chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === 'EXECUTE_STEP' || message.type === 'FILL_CODE') { + resetStopState(); handleCommand(message).then(() => { sendResponse({ ok: true }); }).catch(err => { + if (isStopError(err)) { + log(`Step ${message.step}: Stopped by user.`, 'warn'); + sendResponse({ stopped: true, error: err.message }); + return; + } reportError(message.step, err.message); sendResponse({ error: err.message }); }); @@ -59,6 +65,7 @@ async function step2_clickRegister() { } } + await humanPause(450, 1200); reportComplete(2); simulateClick(registerBtn); log('Step 2: Clicked Register button'); @@ -85,6 +92,7 @@ async function step3_fillEmailPassword(payload) { throw new Error('Could not find email input field on signup page. URL: ' + location.href); } + await humanPause(500, 1400); fillInput(emailInput, email); log('Step 3: Email filled'); @@ -98,6 +106,7 @@ async function step3_fillEmailPassword(payload) { || await waitForElementByText('button', /continue|next|submit|继续|下一步/i, 5000).catch(() => null); if (submitBtn) { + await humanPause(400, 1100); simulateClick(submitBtn); log('Step 3: Submitted email, waiting for password field...'); await sleep(2000); @@ -111,6 +120,7 @@ async function step3_fillEmailPassword(payload) { } if (!payload.password) throw new Error('No password provided. Step 3 requires a generated password.'); + await humanPause(600, 1500); fillInput(passwordInput, payload.password); log('Step 3: Password filled'); @@ -124,6 +134,7 @@ async function step3_fillEmailPassword(payload) { || await waitForElementByText('button', /continue|sign\s*up|submit|注册|创建|create/i, 5000).catch(() => null); if (submitBtn) { + await humanPause(500, 1300); simulateClick(submitBtn); log('Step 3: Form submitted'); } @@ -174,6 +185,7 @@ async function fillVerificationCode(step, payload) { || await waitForElementByText('button', /verify|confirm|submit|continue|确认|验证/i, 5000).catch(() => null); if (submitBtn) { + await humanPause(450, 1200); simulateClick(submitBtn); log(`Step ${step}: Verification submitted`); } @@ -200,6 +212,7 @@ async function step6_login(payload) { throw new Error('Could not find email input on login page. URL: ' + location.href); } + await humanPause(500, 1400); fillInput(emailInput, email); log('Step 6: Email filled'); @@ -208,6 +221,7 @@ async function step6_login(payload) { const submitBtn1 = document.querySelector('button[type="submit"]') || await waitForElementByText('button', /continue|next|submit|继续|下一步/i, 5000).catch(() => null); if (submitBtn1) { + await humanPause(400, 1100); simulateClick(submitBtn1); log('Step 6: Submitted email'); } @@ -218,6 +232,7 @@ async function step6_login(payload) { const passwordInput = document.querySelector('input[type="password"]'); if (passwordInput) { log('Step 6: Password field found, filling password...'); + await humanPause(550, 1450); fillInput(passwordInput, password); await sleep(500); @@ -227,6 +242,7 @@ async function step6_login(payload) { reportComplete(6, { needsOTP: true }); if (submitBtn2) { + await humanPause(450, 1200); simulateClick(submitBtn2); log('Step 6: Submitted password, may need verification code (step 7)'); } @@ -239,14 +255,14 @@ async function step6_login(payload) { } // ============================================================ -// Step 8: Click "继续" on OAuth consent page +// Step 8: Focus "继续" on OAuth consent page for manual click // ============================================================ // After login + verification, page shows: // "使用 ChatGPT 登录到 Codex" with a "继续" submit button. -// Clicking it triggers redirect to localhost URL. +// We only locate and focus it so the user can click manually. async function step8_clickContinue() { - log('Step 8: Looking for OAuth consent "继续" button...'); + log('Step 8: Looking for OAuth consent "继续" button for manual click...'); // Wait for the consent page to be ready // Look for the submit button with text "继续" or data-dd-action-name="Continue" @@ -264,40 +280,33 @@ async function step8_clickContinue() { } } - log('Step 8: Found "继续" button, clicking...'); - - // Use native .click() — simulateClick (dispatchEvent) may not trigger form submit - continueBtn.click(); - log('Step 8: Clicked via .click()'); - - // Also try submitting the form directly as a fallback - await sleep(500); - const form = continueBtn.closest('form'); - if (form) { - form.requestSubmit(continueBtn); - log('Step 8: Also triggered form.requestSubmit()'); - } - - log('Step 8: Redirecting to localhost... (background will capture URL)'); - - // Don't reportComplete — background handles it via webNavigation listener + await humanPause(350, 900); + continueBtn.scrollIntoView({ behavior: 'smooth', block: 'center' }); + continueBtn.focus(); + log('Step 8: Found "继续" button and focused it. Please click it manually.'); } // ============================================================ -// Step 5: Fill Name & Birthday +// Step 5: Fill Name & Birthday / Age // ============================================================ async function step5_fillNameBirthday(payload) { - const { firstName, lastName, year, month, day } = payload; + const { firstName, lastName, age, year, month, day } = payload; if (!firstName || !lastName) throw new Error('No name data provided.'); + const resolvedAge = age ?? (year ? new Date().getFullYear() - Number(year) : null); + const hasBirthdayData = [year, month, day].every(value => value != null && !Number.isNaN(Number(value))); + if (!hasBirthdayData && (resolvedAge == null || Number.isNaN(Number(resolvedAge)))) { + throw new Error('No birthday or age data provided.'); + } + const fullName = `${firstName} ${lastName}`; - log(`Step 5: Filling name: ${fullName}, Birthday: ${year}-${String(month).padStart(2,'0')}-${String(day).padStart(2,'0')}`); + log(`Step 5: Filling name: ${fullName}`); // Actual DOM structure: // - Full name: - // - Birthday: React Aria DateField with 3 spinbutton divs (year/month/day) - // + + // - Birthday: React Aria DateField or hidden input[name="birthday"] + // - Age: // --- Full Name (single field, not first+last) --- let nameInput = null; @@ -309,74 +318,85 @@ async function step5_fillNameBirthday(payload) { } catch { throw new Error('Could not find name input. URL: ' + location.href); } + await humanPause(500, 1300); fillInput(nameInput, fullName); log(`Step 5: Name filled: ${fullName}`); - // --- Birthday (React Aria DateField with spinbutton segments) --- - // The date field has three contenteditable divs with role="spinbutton" - // and data-type="year", data-type="month", data-type="day" - // There's also a hidden input[name="birthday"] that stores the actual value + let birthdayMode = false; + let ageInput = null; - const yearSpinner = document.querySelector('[role="spinbutton"][data-type="year"]'); - const monthSpinner = document.querySelector('[role="spinbutton"][data-type="month"]'); - const daySpinner = document.querySelector('[role="spinbutton"][data-type="day"]'); + for (let i = 0; i < 100; i++) { + const yearSpinner = document.querySelector('[role="spinbutton"][data-type="year"]'); + const monthSpinner = document.querySelector('[role="spinbutton"][data-type="month"]'); + const daySpinner = document.querySelector('[role="spinbutton"][data-type="day"]'); + const hiddenBirthday = document.querySelector('input[name="birthday"]'); + ageInput = document.querySelector('input[name="age"]'); - if (yearSpinner && monthSpinner && daySpinner) { - log('Step 5: Found React Aria DateField spinbuttons'); + if ((yearSpinner && monthSpinner && daySpinner) || hiddenBirthday) { + birthdayMode = true; + break; + } + if (ageInput) break; + await sleep(100); + } - // Helper to set a spinbutton value via focus + keyboard input - async function setSpinButton(el, value) { - el.focus(); - await sleep(100); - - // Select all existing text - document.execCommand('selectAll', false, null); - await sleep(50); - - // Type the new value digit by digit - const valueStr = String(value); - for (const char of valueStr) { - el.dispatchEvent(new KeyboardEvent('keydown', { key: char, code: `Digit${char}`, bubbles: true })); - el.dispatchEvent(new KeyboardEvent('keypress', { key: char, code: `Digit${char}`, bubbles: true })); - // Also use InputEvent for React Aria - el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: char, bubbles: true })); - el.dispatchEvent(new InputEvent('input', { inputType: 'insertText', data: char, bubbles: true })); - await sleep(50); - } - - el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab', code: 'Tab', bubbles: true })); - el.blur(); - await sleep(100); + if (birthdayMode) { + if (!hasBirthdayData) { + throw new Error('Birthday field detected, but no birthday data provided.'); } - await setSpinButton(yearSpinner, year); - log(`Step 5: Year set: ${year}`); + const yearSpinner = document.querySelector('[role="spinbutton"][data-type="year"]'); + const monthSpinner = document.querySelector('[role="spinbutton"][data-type="month"]'); + const daySpinner = document.querySelector('[role="spinbutton"][data-type="day"]'); - await setSpinButton(monthSpinner, String(month).padStart(2, '0')); - log(`Step 5: Month set: ${month}`); + if (yearSpinner && monthSpinner && daySpinner) { + log('Step 5: Birthday fields detected, filling birthday...'); - await setSpinButton(daySpinner, String(day).padStart(2, '0')); - log(`Step 5: Day set: ${day}`); + async function setSpinButton(el, value) { + el.focus(); + await sleep(100); + document.execCommand('selectAll', false, null); + await sleep(50); - // Also update the hidden input directly as a safety measure - const hiddenBirthday = document.querySelector('input[type="hidden"][name="birthday"]'); + const valueStr = String(value); + for (const char of valueStr) { + el.dispatchEvent(new KeyboardEvent('keydown', { key: char, code: `Digit${char}`, bubbles: true })); + el.dispatchEvent(new KeyboardEvent('keypress', { key: char, code: `Digit${char}`, bubbles: true })); + el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: char, bubbles: true })); + el.dispatchEvent(new InputEvent('input', { inputType: 'insertText', data: char, bubbles: true })); + await sleep(50); + } + + el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab', code: 'Tab', bubbles: true })); + el.blur(); + await sleep(100); + } + + await humanPause(450, 1100); + await setSpinButton(yearSpinner, year); + await humanPause(250, 650); + await setSpinButton(monthSpinner, String(month).padStart(2, '0')); + await humanPause(250, 650); + await setSpinButton(daySpinner, String(day).padStart(2, '0')); + log(`Step 5: Birthday filled: ${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`); + } + + const hiddenBirthday = document.querySelector('input[name="birthday"]'); if (hiddenBirthday) { const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; hiddenBirthday.value = dateStr; hiddenBirthday.dispatchEvent(new Event('change', { bubbles: true })); log(`Step 5: Hidden birthday input set: ${dateStr}`); } - } else { - // Fallback: try setting hidden input directly - const hiddenBirthday = document.querySelector('input[name="birthday"]'); - if (hiddenBirthday) { - const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; - hiddenBirthday.value = dateStr; - hiddenBirthday.dispatchEvent(new Event('change', { bubbles: true })); - log(`Step 5: Birthday set via hidden input: ${dateStr}`); - } else { - log('Step 5: WARNING - Could not find birthday fields. May need to adjust selectors.', 'warn'); + } else if (ageInput) { + if (resolvedAge == null || Number.isNaN(Number(resolvedAge))) { + throw new Error('Age field detected, but no age data provided.'); } + await humanPause(500, 1300); + fillInput(ageInput, String(resolvedAge)); + log(`Step 5: Age filled: ${resolvedAge}`); + } else { + throw new Error('Could not find birthday or age input. URL: ' + location.href); } // Click "完成帐户创建" button @@ -388,6 +408,7 @@ async function step5_fillNameBirthday(payload) { reportComplete(5); if (completeBtn) { + await humanPause(500, 1300); simulateClick(completeBtn); log('Step 5: Clicked "完成帐户创建"'); } diff --git a/content/utils.js b/content/utils.js index 6e1c218..55f0bf3 100644 --- a/content/utils.js +++ b/content/utils.js @@ -5,12 +5,37 @@ const SCRIPT_SOURCE = (() => { if (url.includes('auth0.openai.com') || url.includes('auth.openai.com') || url.includes('accounts.openai.com')) return 'signup-page'; if (url.includes('mail.qq.com')) return 'qq-mail'; if (url.includes('mail.163.com')) return 'mail-163'; + if (url.includes('duckduckgo.com/email/settings/autofill')) return 'duck-mail'; if (url.includes('chatgpt.com')) return 'chatgpt'; // VPS panel — detected dynamically since URL is configurable return 'vps-panel'; })(); const LOG_PREFIX = `[MultiPage:${SCRIPT_SOURCE}]`; +const STOP_ERROR_MESSAGE = 'Flow stopped by user.'; +let flowStopped = false; + +chrome.runtime.onMessage.addListener((message) => { + if (message.type === 'STOP_FLOW') { + flowStopped = true; + console.warn(LOG_PREFIX, STOP_ERROR_MESSAGE); + } +}); + +function resetStopState() { + flowStopped = false; +} + +function isStopError(error) { + const message = typeof error === 'string' ? error : error?.message; + return message === STOP_ERROR_MESSAGE; +} + +function throwIfStopped() { + if (flowStopped) { + throw new Error(STOP_ERROR_MESSAGE); + } +} /** * Wait for a DOM element to appear. @@ -20,6 +45,8 @@ const LOG_PREFIX = `[MultiPage:${SCRIPT_SOURCE}]`; */ function waitForElement(selector, timeout = 10000) { return new Promise((resolve, reject) => { + throwIfStopped(); + const existing = document.querySelector(selector); if (existing) { console.log(LOG_PREFIX, `Found immediately: ${selector}`); @@ -31,11 +58,25 @@ function waitForElement(selector, timeout = 10000) { console.log(LOG_PREFIX, `Waiting for: ${selector} (timeout: ${timeout}ms)`); log(`Waiting for selector: ${selector}...`); + let settled = false; + let stopTimer = null; + const cleanup = () => { + if (settled) return; + settled = true; + observer.disconnect(); + clearTimeout(timer); + clearTimeout(stopTimer); + }; + const observer = new MutationObserver(() => { + if (flowStopped) { + cleanup(); + reject(new Error(STOP_ERROR_MESSAGE)); + return; + } const el = document.querySelector(selector); if (el) { - observer.disconnect(); - clearTimeout(timer); + cleanup(); console.log(LOG_PREFIX, `Found after wait: ${selector}`); log(`Found element: ${selector}`); resolve(el); @@ -48,11 +89,22 @@ function waitForElement(selector, timeout = 10000) { }); const timer = setTimeout(() => { - observer.disconnect(); + cleanup(); const msg = `Timeout waiting for ${selector} after ${timeout}ms on ${location.href}`; console.error(LOG_PREFIX, msg); reject(new Error(msg)); }, timeout); + + const pollStop = () => { + if (settled) return; + if (flowStopped) { + cleanup(); + reject(new Error(STOP_ERROR_MESSAGE)); + return; + } + stopTimer = setTimeout(pollStop, 100); + }; + pollStop(); }); } @@ -65,6 +117,8 @@ function waitForElement(selector, timeout = 10000) { */ function waitForElementByText(containerSelector, textPattern, timeout = 10000) { return new Promise((resolve, reject) => { + throwIfStopped(); + function search() { const candidates = document.querySelectorAll(containerSelector); for (const el of candidates) { @@ -86,11 +140,25 @@ function waitForElementByText(containerSelector, textPattern, timeout = 10000) { console.log(LOG_PREFIX, `Waiting for text match: ${containerSelector} / ${textPattern}`); log(`Waiting for element with text: ${textPattern}...`); + let settled = false; + let stopTimer = null; + const cleanup = () => { + if (settled) return; + settled = true; + observer.disconnect(); + clearTimeout(timer); + clearTimeout(stopTimer); + }; + const observer = new MutationObserver(() => { + if (flowStopped) { + cleanup(); + reject(new Error(STOP_ERROR_MESSAGE)); + return; + } const el = search(); if (el) { - observer.disconnect(); - clearTimeout(timer); + cleanup(); console.log(LOG_PREFIX, `Found by text after wait: ${textPattern}`); log(`Found element by text: ${textPattern}`); resolve(el); @@ -103,11 +171,22 @@ function waitForElementByText(containerSelector, textPattern, timeout = 10000) { }); const timer = setTimeout(() => { - observer.disconnect(); + cleanup(); const msg = `Timeout waiting for text "${textPattern}" in "${containerSelector}" after ${timeout}ms on ${location.href}`; console.error(LOG_PREFIX, msg); reject(new Error(msg)); }, timeout); + + const pollStop = () => { + if (settled) return; + if (flowStopped) { + cleanup(); + reject(new Error(STOP_ERROR_MESSAGE)); + return; + } + stopTimer = setTimeout(pollStop, 100); + }; + pollStop(); }); } @@ -118,6 +197,7 @@ function waitForElementByText(containerSelector, textPattern, timeout = 10000) { * @param {string} value */ function fillInput(el, value) { + throwIfStopped(); const nativeInputValueSetter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, 'value' @@ -135,6 +215,7 @@ function fillInput(el, value) { * @param {string} value */ function fillSelect(el, value) { + throwIfStopped(); el.value = value; el.dispatchEvent(new Event('change', { bubbles: true })); console.log(LOG_PREFIX, `Selected value ${value} in ${el.name || el.id}`); @@ -209,6 +290,7 @@ function reportError(step, errorMessage) { * @param {Element} el */ function simulateClick(el) { + throwIfStopped(); el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); console.log(LOG_PREFIX, `Clicked: ${el.tagName} ${el.textContent?.slice(0, 30) || ''}`); log(`Clicked [${el.tagName}] "${el.textContent?.trim().slice(0, 30) || ''}"`); @@ -220,7 +302,28 @@ function simulateClick(el) { * @returns {Promise} */ function sleep(ms) { - return new Promise(r => setTimeout(r, ms)); + return new Promise((resolve, reject) => { + const start = Date.now(); + + function tick() { + if (flowStopped) { + reject(new Error(STOP_ERROR_MESSAGE)); + return; + } + if (Date.now() - start >= ms) { + resolve(); + return; + } + setTimeout(tick, Math.min(100, Math.max(25, ms - (Date.now() - start)))); + } + + tick(); + }); +} + +async function humanPause(min = 250, max = 850) { + const duration = Math.floor(Math.random() * (max - min + 1)) + min; + await sleep(duration); } // Auto-report ready on load diff --git a/content/vps-panel.js b/content/vps-panel.js index f885889..a5f34a9 100644 --- a/content/vps-panel.js +++ b/content/vps-panel.js @@ -28,9 +28,15 @@ console.log('[MultiPage:vps-panel] Content script loaded on', location.href); // Listen for commands from Background chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === 'EXECUTE_STEP') { + resetStopState(); handleStep(message.step, message.payload).then(() => { sendResponse({ ok: true }); }).catch(err => { + if (isStopError(err)) { + log(`Step ${message.step}: Stopped by user.`, 'warn'); + sendResponse({ stopped: true, error: err.message }); + return; + } reportError(message.step, err.message); sendResponse({ error: err.message }); }); @@ -77,6 +83,7 @@ async function step1_getOAuthLink() { if (loginBtn.disabled) { log('Step 1: Login button is disabled (already loading), waiting for auth URL...'); } else { + await humanPause(500, 1400); simulateClick(loginBtn); log('Step 1: Clicked login button, waiting for auth URL...'); } @@ -133,6 +140,7 @@ async function step9_vpsVerify(payload) { } } + await humanPause(600, 1500); fillInput(urlInput, localhostUrl); log(`Step 9: Filled callback URL: ${localhostUrl.slice(0, 80)}...`); @@ -152,6 +160,7 @@ async function step9_vpsVerify(payload) { } } + await humanPause(450, 1200); simulateClick(submitBtn); log('Step 9: Clicked "提交回调 URL", waiting for authentication result...'); diff --git a/manifest.json b/manifest.json index 2db9ea0..903132a 100644 --- a/manifest.json +++ b/manifest.json @@ -46,6 +46,13 @@ "js": ["content/utils.js", "content/mail-163.js"], "all_frames": true, "run_at": "document_idle" + }, + { + "matches": [ + "https://duckduckgo.com/email/settings/autofill*" + ], + "js": ["content/utils.js", "content/duck-mail.js"], + "run_at": "document_idle" } ], "action": { diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index d33f7c8..f35d1e3 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -173,6 +173,12 @@ header { } .btn-success:hover { opacity: 0.9; box-shadow: 0 2px 8px var(--green-soft); } +.btn-danger { + background: var(--red); + color: #fff; +} +.btn-danger:hover { opacity: 0.9; box-shadow: 0 2px 8px var(--red-soft); } + .run-group { display: flex; align-items: center; @@ -194,7 +200,7 @@ header { } .run-count-input:focus { border-color: var(--blue); } .run-count-input::-webkit-inner-spin-button { opacity: 0.5; } -.btn-success:disabled, .btn-primary:disabled { background: var(--bg-elevated); color: var(--text-muted); cursor: not-allowed; box-shadow: none; } +.btn-success:disabled, .btn-primary:disabled, .btn-danger:disabled { background: var(--bg-elevated); color: var(--text-muted); cursor: not-allowed; box-shadow: none; } .run-count-input:disabled { opacity: 0.5; cursor: not-allowed; } .btn-ghost { @@ -238,6 +244,14 @@ header { gap: 8px; } +.data-inline { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; +} + .data-label { width: 56px; font-size: 11px; @@ -283,6 +297,10 @@ header { .data-input::placeholder { color: var(--text-muted); } .data-input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); } +#btn-fetch-email { + padding-inline: 10px; +} + .data-select { flex: 1; padding: 7px 10px; @@ -337,6 +355,9 @@ header { .status-bar.failed .status-dot { background: var(--red); } .status-bar.failed { color: var(--red); } +.status-bar.stopped .status-dot { background: var(--cyan); } +.status-bar.stopped { color: var(--cyan); } + @keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.85); } @@ -433,6 +454,9 @@ header { .step-row.failed .step-indicator { border-color: var(--red); background: var(--red-soft); } .step-row.failed .step-num { color: var(--red); } +.step-row.stopped .step-indicator { border-color: var(--cyan); background: rgba(8, 145, 178, 0.08); } +.step-row.stopped .step-num { color: var(--cyan); } + /* Step Button */ .step-btn { flex: 1; @@ -455,6 +479,7 @@ header { .step-row.running .step-btn { border-color: var(--orange); color: var(--orange); } .step-row.completed .step-btn { border-color: var(--border-subtle); color: var(--text-secondary); opacity: 0.7; } .step-row.failed .step-btn { border-color: var(--red); color: var(--red); } +.step-row.stopped .step-btn { border-color: var(--cyan); color: var(--cyan); } .step-status { width: 20px; @@ -465,6 +490,7 @@ header { } .step-row.completed .step-status { color: var(--green); } .step-row.failed .step-status { color: var(--red); } +.step-row.stopped .step-status { color: var(--cyan); } /* ============================================================ Log / Console Section diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 746ce6c..513f963 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -24,6 +24,7 @@ Auto + +
OAuth @@ -73,7 +77,7 @@
@@ -121,7 +125,7 @@
8
- +
diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 9b4d441..59e669f 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -5,6 +5,7 @@ const STATUS_ICONS = { running: '', completed: '\u2713', // ✓ failed: '\u2717', // ✗ + stopped: '\u25A0', // ■ }; const logArea = document.getElementById('log-area'); @@ -13,6 +14,8 @@ const displayLocalhostUrl = document.getElementById('display-localhost-url'); const displayStatus = document.getElementById('display-status'); const statusBar = document.getElementById('status-bar'); const inputEmail = document.getElementById('input-email'); +const btnFetchEmail = document.getElementById('btn-fetch-email'); +const btnStop = document.getElementById('btn-stop'); const btnReset = document.getElementById('btn-reset'); const stepsProgress = document.getElementById('steps-progress'); const btnAutoRun = document.getElementById('btn-auto-run'); @@ -132,6 +135,7 @@ function updateButtonStates() { if (row.classList.contains('completed')) statuses[step] = 'completed'; else if (row.classList.contains('running')) statuses[step] = 'running'; else if (row.classList.contains('failed')) statuses[step] = 'failed'; + else if (row.classList.contains('stopped')) statuses[step] = 'stopped'; else statuses[step] = 'pending'; }); @@ -148,9 +152,15 @@ function updateButtonStates() { } else { const prevStatus = statuses[step - 1]; const currentStatus = statuses[step]; - btn.disabled = !(prevStatus === 'completed' || currentStatus === 'failed' || currentStatus === 'completed'); + btn.disabled = !(prevStatus === 'completed' || currentStatus === 'failed' || currentStatus === 'completed' || currentStatus === 'stopped'); } } + + updateStopButtonState(anyRunning || autoContinueBar.style.display !== 'none'); +} + +function updateStopButtonState(active) { + btnStop.disabled = !active; } function updateStatusDisplay(state) { @@ -172,6 +182,13 @@ function updateStatusDisplay(state) { return; } + const stopped = Object.entries(state.stepStatuses).find(([, s]) => s === 'stopped'); + if (stopped) { + displayStatus.textContent = `Step ${stopped[0]} stopped`; + statusBar.classList.add('stopped'); + return; + } + const lastCompleted = Object.entries(state.stepStatuses) .filter(([, s]) => s === 'completed') .map(([k]) => Number(k)) @@ -214,6 +231,37 @@ function escapeHtml(text) { return div.innerHTML; } +async function fetchDuckEmail() { + const defaultLabel = 'Auto'; + btnFetchEmail.disabled = true; + btnFetchEmail.textContent = '...'; + + try { + const response = await chrome.runtime.sendMessage({ + type: 'FETCH_DUCK_EMAIL', + source: 'sidepanel', + payload: { generateNew: true }, + }); + + if (response?.error) { + throw new Error(response.error); + } + if (!response?.email) { + throw new Error('Duck email was not returned.'); + } + + inputEmail.value = response.email; + showToast(`Fetched ${response.email}`, 'success', 2500); + return response.email; + } catch (err) { + showToast(`Auto fetch failed: ${err.message}`, 'error'); + throw err; + } finally { + btnFetchEmail.disabled = false; + btnFetchEmail.textContent = defaultLabel; + } +} + // ============================================================ // Button Handlers // ============================================================ @@ -224,7 +272,7 @@ document.querySelectorAll('.step-btn').forEach(btn => { if (step === 3) { const email = inputEmail.value.trim(); if (!email) { - showToast('Please paste email address first', 'warn'); + showToast('Please paste email address or use Auto first', 'warn'); return; } await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step, email } }); @@ -234,6 +282,16 @@ document.querySelectorAll('.step-btn').forEach(btn => { }); }); +btnFetchEmail.addEventListener('click', async () => { + await fetchDuckEmail().catch(() => {}); +}); + +btnStop.addEventListener('click', async () => { + btnStop.disabled = true; + await chrome.runtime.sendMessage({ type: 'STOP_FLOW', source: 'sidepanel', payload: {} }); + showToast('Stopping current flow...', 'warn', 2000); +}); + // Auto Run btnAutoRun.addEventListener('click', async () => { const totalRuns = parseInt(inputRunCount.value) || 1; @@ -246,7 +304,7 @@ btnAutoRun.addEventListener('click', async () => { btnAutoContinue.addEventListener('click', async () => { const email = inputEmail.value.trim(); if (!email) { - showToast('Please paste DuckDuckGo email first!', 'warn'); + showToast('Please fetch or paste DuckDuckGo email first!', 'warn'); return; } autoContinueBar.style.display = 'none'; @@ -268,8 +326,10 @@ btnReset.addEventListener('click', async () => { document.querySelectorAll('.step-row').forEach(row => row.className = 'step-row'); document.querySelectorAll('.step-status').forEach(el => el.textContent = ''); btnAutoRun.disabled = false; + inputRunCount.disabled = false; btnAutoRun.innerHTML = ' Auto'; autoContinueBar.style.display = 'none'; + updateStopButtonState(false); updateButtonStates(); updateProgressCounter(); } @@ -346,11 +406,15 @@ chrome.runtime.onMessage.addListener((message) => { logArea.innerHTML = ''; document.querySelectorAll('.step-row').forEach(row => row.className = 'step-row'); document.querySelectorAll('.step-status').forEach(el => el.textContent = ''); + updateStopButtonState(false); updateProgressCounter(); break; } case 'DATA_UPDATED': { + if (message.payload.email) { + inputEmail.value = message.payload.email; + } if (message.payload.oauthUrl) { displayOauthUrl.textContent = message.payload.oauthUrl; displayOauthUrl.classList.add('has-value'); @@ -369,21 +433,25 @@ chrome.runtime.onMessage.addListener((message) => { case 'waiting_email': autoContinueBar.style.display = 'flex'; btnAutoRun.innerHTML = `Paused${runLabel}`; + updateStopButtonState(true); break; case 'running': btnAutoRun.innerHTML = `Running${runLabel}`; + updateStopButtonState(true); break; case 'complete': btnAutoRun.disabled = false; inputRunCount.disabled = false; btnAutoRun.innerHTML = ' Auto'; autoContinueBar.style.display = 'none'; + updateStopButtonState(false); break; case 'stopped': btnAutoRun.disabled = false; inputRunCount.disabled = false; btnAutoRun.innerHTML = ' Auto'; autoContinueBar.style.display = 'none'; + updateStopButtonState(false); break; } break;