Bladeren bron

feat: initial implementation of Multi-Page Automation Chrome extension

- manifest.json: MV3 config with sidePanel, tabs, webNavigation, storage, scripting permissions
- background.js: service worker with state management (chrome.storage.session), tab registry,
  message routing, command queue, and step orchestration for all 9 steps
- sidepanel/: persistent side panel UI with 9 step buttons, interlock logic, log area,
  state restore, and reset functionality
- content/utils.js: shared utilities (waitForElement, fillInput, simulateClick, log, report*)
- content/vps-panel.js: VPS panel automation (steps 1, 9)
- content/signup-page.js: OpenAI auth page automation (steps 2, 3, 4-fill, 5)
- content/qq-mail.js: QQ Mail email polling and code extraction (steps 4, 7)
- content/chatgpt.js: ChatGPT login automation (steps 6, 7-fill, 8)
- data/names.js: random English name and birthday generation
unknown 4 maanden geleden
commit
887709e824
14 gewijzigde bestanden met toevoegingen van 2354 en 0 verwijderingen
  1. 604 0
      background.js
  2. 211 0
      content/chatgpt.js
  3. 261 0
      content/qq-mail.js
  4. 266 0
      content/signup-page.js
  5. 226 0
      content/utils.js
  6. 207 0
      content/vps-panel.js
  7. 38 0
      data/names.js
  8. BIN
      icons/icon128.png
  9. BIN
      icons/icon16.png
  10. BIN
      icons/icon48.png
  11. 72 0
      manifest.json
  12. 146 0
      sidepanel/sidepanel.css
  13. 89 0
      sidepanel/sidepanel.html
  14. 234 0
      sidepanel/sidepanel.js

+ 604 - 0
background.js

@@ -0,0 +1,604 @@
+// background.js — Service Worker: orchestration, state, tab management, message routing
+
+importScripts('data/names.js');
+
+const LOG_PREFIX = '[MultiPage:bg]';
+
+// ============================================================
+// State Management (chrome.storage.session)
+// ============================================================
+
+const DEFAULT_STATE = {
+  currentStep: 0,
+  stepStatuses: {
+    1: 'pending', 2: 'pending', 3: 'pending', 4: 'pending', 5: 'pending',
+    6: 'pending', 7: 'pending', 8: 'pending', 9: 'pending',
+  },
+  oauthUrl: null,
+  email: null,
+  password: 'mimashisha0.0',
+  lastEmailTimestamp: null,
+  localhostUrl: null,
+  flowStartTime: null,
+  tabRegistry: {},
+  logs: [],
+};
+
+async function getState() {
+  const state = await chrome.storage.session.get(null);
+  return { ...DEFAULT_STATE, ...state };
+}
+
+async function setState(updates) {
+  console.log(LOG_PREFIX, 'storage.set:', JSON.stringify(updates).slice(0, 200));
+  await chrome.storage.session.set(updates);
+}
+
+async function resetState() {
+  console.log(LOG_PREFIX, 'Resetting all state');
+  await chrome.storage.session.clear();
+  await chrome.storage.session.set({ ...DEFAULT_STATE });
+}
+
+// ============================================================
+// Tab Registry
+// ============================================================
+
+async function getTabRegistry() {
+  const state = await getState();
+  return state.tabRegistry || {};
+}
+
+async function registerTab(source, tabId) {
+  const registry = await getTabRegistry();
+  registry[source] = { tabId, ready: true };
+  await setState({ tabRegistry: registry });
+  console.log(LOG_PREFIX, `Tab registered: ${source} -> ${tabId}`);
+}
+
+async function isTabAlive(source) {
+  const registry = await getTabRegistry();
+  const entry = registry[source];
+  if (!entry) return false;
+  try {
+    await chrome.tabs.get(entry.tabId);
+    return true;
+  } catch {
+    // Tab no longer exists — clean up registry
+    registry[source] = null;
+    await setState({ tabRegistry: registry });
+    return false;
+  }
+}
+
+async function getTabId(source) {
+  const registry = await getTabRegistry();
+  return registry[source]?.tabId || null;
+}
+
+// ============================================================
+// Command Queue (for content scripts not yet ready)
+// ============================================================
+
+const pendingCommands = new Map(); // source -> { message, resolve, reject, timer }
+
+function queueCommand(source, message, timeout = 15000) {
+  return new Promise((resolve, reject) => {
+    const timer = setTimeout(() => {
+      pendingCommands.delete(source);
+      const err = `Content script on ${source} did not respond in ${timeout / 1000}s. Try refreshing the tab and retry.`;
+      console.error(LOG_PREFIX, err);
+      reject(new Error(err));
+    }, timeout);
+    pendingCommands.set(source, { message, resolve, reject, timer });
+    console.log(LOG_PREFIX, `Command queued for ${source} (waiting for ready)`);
+  });
+}
+
+function flushCommand(source, tabId) {
+  const pending = pendingCommands.get(source);
+  if (pending) {
+    clearTimeout(pending.timer);
+    pendingCommands.delete(source);
+    chrome.tabs.sendMessage(tabId, pending.message).then(pending.resolve).catch(pending.reject);
+    console.log(LOG_PREFIX, `Flushed queued command to ${source} (tab ${tabId})`);
+  }
+}
+
+// ============================================================
+// Send command to content script (with readiness check)
+// ============================================================
+
+async function sendToContentScript(source, message) {
+  const registry = await getTabRegistry();
+  const entry = registry[source];
+
+  if (!entry || !entry.ready) {
+    console.log(LOG_PREFIX, `${source} not ready, queuing command`);
+    return queueCommand(source, message);
+  }
+
+  // Verify tab is still alive
+  const alive = await isTabAlive(source);
+  if (!alive) {
+    // Tab was closed — queue the command, it will be sent when tab is reopened
+    console.log(LOG_PREFIX, `${source} tab was closed, queuing command`);
+    return queueCommand(source, message);
+  }
+
+  console.log(LOG_PREFIX, `Sending to ${source} (tab ${entry.tabId}):`, message.type);
+  return chrome.tabs.sendMessage(entry.tabId, message);
+}
+
+// ============================================================
+// Logging
+// ============================================================
+
+async function addLog(message, level = 'info') {
+  const state = await getState();
+  const logs = state.logs || [];
+  const entry = { message, level, timestamp: Date.now() };
+  logs.push(entry);
+  // Keep last 500 logs
+  if (logs.length > 500) logs.splice(0, logs.length - 500);
+  await setState({ logs });
+  // Broadcast to side panel
+  chrome.runtime.sendMessage({ type: 'LOG_ENTRY', payload: entry }).catch(() => {});
+}
+
+// ============================================================
+// Step Status Management
+// ============================================================
+
+async function setStepStatus(step, status) {
+  const state = await getState();
+  const statuses = { ...state.stepStatuses };
+  statuses[step] = status;
+  await setState({ stepStatuses: statuses, currentStep: step });
+  // Broadcast to side panel
+  chrome.runtime.sendMessage({
+    type: 'STEP_STATUS_CHANGED',
+    payload: { step, status },
+  }).catch(() => {});
+}
+
+// ============================================================
+// Message Handler (central router)
+// ============================================================
+
+chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+  console.log(LOG_PREFIX, `Received: ${message.type} from ${message.source || 'sidepanel'}`, message);
+
+  handleMessage(message, sender).then(response => {
+    sendResponse(response);
+  }).catch(err => {
+    console.error(LOG_PREFIX, 'Handler error:', err);
+    sendResponse({ error: err.message });
+  });
+
+  return true; // async response
+});
+
+async function handleMessage(message, sender) {
+  switch (message.type) {
+    case 'CONTENT_SCRIPT_READY': {
+      const tabId = sender.tab?.id;
+      if (tabId && message.source) {
+        await registerTab(message.source, tabId);
+        flushCommand(message.source, tabId);
+        await addLog(`Content script ready: ${message.source} (tab ${tabId})`);
+      }
+      return { ok: true };
+    }
+
+    case 'LOG': {
+      const { message: msg, level } = message.payload;
+      await addLog(`[${message.source}] ${msg}`, level);
+      return { ok: true };
+    }
+
+    case 'STEP_COMPLETE': {
+      await setStepStatus(message.step, 'completed');
+      await addLog(`Step ${message.step} completed`, 'ok');
+      // Store step-specific data
+      await handleStepData(message.step, message.payload);
+      return { ok: true };
+    }
+
+    case 'STEP_ERROR': {
+      await setStepStatus(message.step, 'failed');
+      await addLog(`Step ${message.step} failed: ${message.error}`, 'error');
+      return { ok: true };
+    }
+
+    case 'GET_STATE': {
+      return await getState();
+    }
+
+    case 'RESET': {
+      await resetState();
+      await addLog('Flow reset', 'info');
+      return { ok: true };
+    }
+
+    case 'EXECUTE_STEP': {
+      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 executeStep(step);
+      return { ok: true };
+    }
+
+    // Side panel data updates
+    case 'SAVE_EMAIL': {
+      await setState({ email: message.payload.email });
+      return { ok: true };
+    }
+
+    default:
+      console.warn(LOG_PREFIX, `Unknown message type: ${message.type}`);
+      return { error: `Unknown message type: ${message.type}` };
+  }
+}
+
+// ============================================================
+// Step Data Handlers
+// ============================================================
+
+async function handleStepData(step, payload) {
+  switch (step) {
+    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(() => {});
+      }
+      break;
+    case 3:
+      if (payload.email) await setState({ email: payload.email });
+      break;
+    case 4:
+      if (payload.emailTimestamp) await setState({ lastEmailTimestamp: payload.emailTimestamp });
+      break;
+    case 8:
+      if (payload.localhostUrl) {
+        await setState({ localhostUrl: payload.localhostUrl });
+        chrome.runtime.sendMessage({
+          type: 'DATA_UPDATED',
+          payload: { localhostUrl: payload.localhostUrl },
+        }).catch(() => {});
+      }
+      break;
+  }
+}
+
+// ============================================================
+// Step Execution
+// ============================================================
+
+async function executeStep(step) {
+  console.log(LOG_PREFIX, `Executing step ${step}`);
+  await setStepStatus(step, 'running');
+  await addLog(`Step ${step} started`);
+
+  const state = await getState();
+
+  // Set flow start time on first step
+  if (step === 1 && !state.flowStartTime) {
+    await setState({ flowStartTime: Date.now() });
+  }
+
+  try {
+    switch (step) {
+      case 1: await executeStep1(state); break;
+      case 2: await executeStep2(state); break;
+      case 3: await executeStep3(state); break;
+      case 4: await executeStep4(state); break;
+      case 5: await executeStep5(state); break;
+      case 6: await executeStep6(state); break;
+      case 7: await executeStep7(state); break;
+      case 8: await executeStep8(state); break;
+      case 9: await executeStep9(state); break;
+      default:
+        throw new Error(`Unknown step: ${step}`);
+    }
+  } catch (err) {
+    await setStepStatus(step, 'failed');
+    await addLog(`Step ${step} failed: ${err.message}`, 'error');
+  }
+}
+
+// ============================================================
+// Step 1: Get OAuth Link (via vps-panel.js)
+// ============================================================
+
+async function executeStep1(state) {
+  await sendToContentScript('vps-panel', {
+    type: 'EXECUTE_STEP',
+    step: 1,
+    source: 'background',
+    payload: {},
+  });
+}
+
+// ============================================================
+// Step 2: Open Signup Page (Background opens tab, signup-page.js clicks Register)
+// ============================================================
+
+async function executeStep2(state) {
+  if (!state.oauthUrl) {
+    throw new Error('No OAuth URL. Complete step 1 first.');
+  }
+  await addLog(`Step 2: Opening auth URL in new tab: ${state.oauthUrl.slice(0, 80)}...`);
+  const tab = await chrome.tabs.create({ url: state.oauthUrl, active: true });
+  // signup-page.js will auto-inject via manifest content_scripts
+  // Queue the command — it will flush when script sends READY signal
+  await sendToContentScript('signup-page', {
+    type: 'EXECUTE_STEP',
+    step: 2,
+    source: 'background',
+    payload: {},
+  });
+}
+
+// ============================================================
+// Step 3: Fill Email & Password (via signup-page.js)
+// ============================================================
+
+async function executeStep3(state) {
+  if (!state.email) {
+    throw new Error('No email address. Paste email in Side Panel first.');
+  }
+  await addLog(`Step 3: Filling email ${state.email} and password`);
+  await sendToContentScript('signup-page', {
+    type: 'EXECUTE_STEP',
+    step: 3,
+    source: 'background',
+    payload: { email: state.email },
+  });
+}
+
+// ============================================================
+// Step 4: Get Signup Verification Code (qq-mail.js polls, then fills in signup-page.js)
+// ============================================================
+
+async function executeStep4(state) {
+  // Ensure QQ Mail tab is open
+  const alive = await isTabAlive('qq-mail');
+  if (!alive) {
+    await addLog('Step 4: Opening QQ Mail...');
+    await chrome.tabs.create({ url: 'https://wx.mail.qq.com/', active: true });
+  } else {
+    const tabId = await getTabId('qq-mail');
+    if (tabId) await chrome.tabs.update(tabId, { active: true });
+  }
+
+  // Send poll command to qq-mail
+  const result = await sendToContentScript('qq-mail', {
+    type: 'POLL_EMAIL',
+    step: 4,
+    source: 'background',
+    payload: {
+      filterAfterTimestamp: state.flowStartTime || 0,
+      senderFilters: ['openai', 'noreply', 'verify', 'auth'],
+      subjectFilters: ['verify', 'verification', 'code', '验证', 'confirm'],
+      maxAttempts: 20,
+      intervalMs: 3000,
+    },
+  });
+
+  if (result && result.error) {
+    throw new Error(result.error);
+  }
+
+  if (result && result.code) {
+    await setState({ lastEmailTimestamp: result.emailTimestamp });
+    await addLog(`Step 4: Got verification code: ${result.code}`);
+
+    // Switch to signup tab and fill code
+    const signupTabId = await getTabId('signup-page');
+    if (signupTabId) {
+      await chrome.tabs.update(signupTabId, { active: true });
+      await sendToContentScript('signup-page', {
+        type: 'FILL_CODE',
+        step: 4,
+        source: 'background',
+        payload: { code: result.code },
+      });
+    } else {
+      throw new Error('Signup page tab was closed. Cannot fill verification code.');
+    }
+  }
+}
+
+// ============================================================
+// Step 5: Fill Name & Birthday (via signup-page.js)
+// ============================================================
+
+async function executeStep5(state) {
+  const { firstName, lastName } = generateRandomName();
+  const { year, month, day } = generateRandomBirthday();
+
+  await addLog(`Step 5: Generated name: ${firstName} ${lastName}, Birthday: ${year}-${month}-${day}`);
+
+  await sendToContentScript('signup-page', {
+    type: 'EXECUTE_STEP',
+    step: 5,
+    source: 'background',
+    payload: { firstName, lastName, year, month, day },
+  });
+}
+
+// ============================================================
+// Step 6: Login ChatGPT (Background opens tab, chatgpt.js handles login)
+// ============================================================
+
+async function executeStep6(state) {
+  const alive = await isTabAlive('chatgpt');
+  if (!alive) {
+    await addLog('Step 6: Opening ChatGPT...');
+    await chrome.tabs.create({ url: 'https://chatgpt.com/', active: true });
+  } else {
+    const tabId = await getTabId('chatgpt');
+    if (tabId) await chrome.tabs.update(tabId, { active: true });
+  }
+
+  await sendToContentScript('chatgpt', {
+    type: 'EXECUTE_STEP',
+    step: 6,
+    source: 'background',
+    payload: {},
+  });
+}
+
+// ============================================================
+// Step 7: Get Login Verification Code (qq-mail.js polls, then fills in chatgpt.js)
+// ============================================================
+
+async function executeStep7(state) {
+  const alive = await isTabAlive('qq-mail');
+  if (!alive) {
+    await addLog('Step 7: Opening QQ Mail...');
+    await chrome.tabs.create({ url: 'https://wx.mail.qq.com/', active: true });
+  } else {
+    const tabId = await getTabId('qq-mail');
+    if (tabId) await chrome.tabs.update(tabId, { active: true });
+  }
+
+  const result = await sendToContentScript('qq-mail', {
+    type: 'POLL_EMAIL',
+    step: 7,
+    source: 'background',
+    payload: {
+      filterAfterTimestamp: state.lastEmailTimestamp || state.flowStartTime || 0,
+      senderFilters: ['openai', 'noreply', 'verify', 'auth', 'chatgpt'],
+      subjectFilters: ['verify', 'verification', 'code', '验证', 'confirm', 'login'],
+      maxAttempts: 20,
+      intervalMs: 3000,
+    },
+  });
+
+  if (result && result.error) {
+    throw new Error(result.error);
+  }
+
+  if (result && result.code) {
+    await addLog(`Step 7: Got login verification code: ${result.code}`);
+
+    // Switch to ChatGPT tab and fill code
+    const chatgptTabId = await getTabId('chatgpt');
+    if (chatgptTabId) {
+      await chrome.tabs.update(chatgptTabId, { active: true });
+      await sendToContentScript('chatgpt', {
+        type: 'FILL_CODE',
+        step: 7,
+        source: 'background',
+        payload: { code: result.code },
+      });
+    } else {
+      throw new Error('ChatGPT tab was closed. Cannot fill verification code.');
+    }
+  }
+}
+
+// ============================================================
+// Step 8: Complete OAuth (webNavigation listener + chatgpt.js navigates)
+// ============================================================
+
+let webNavListener = null;
+
+async function executeStep8(state) {
+  if (!state.oauthUrl) {
+    throw new Error('No OAuth URL. Complete step 1 first.');
+  }
+
+  await addLog('Step 8: Setting up localhost redirect listener...');
+
+  // Register webNavigation listener (scoped to this step)
+  return new Promise((resolve, reject) => {
+    const timeout = setTimeout(() => {
+      if (webNavListener) {
+        chrome.webNavigation.onBeforeNavigate.removeListener(webNavListener);
+        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);
+
+    webNavListener = (details) => {
+      if (details.url.startsWith('http://localhost')) {
+        console.log(LOG_PREFIX, `Captured localhost redirect: ${details.url}`);
+        chrome.webNavigation.onBeforeNavigate.removeListener(webNavListener);
+        webNavListener = null;
+        clearTimeout(timeout);
+
+        setState({ localhostUrl: details.url }).then(() => {
+          addLog(`Step 8: Captured localhost URL: ${details.url}`, 'ok');
+          setStepStatus(8, 'completed');
+          // Broadcast to side panel
+          chrome.runtime.sendMessage({
+            type: 'DATA_UPDATED',
+            payload: { localhostUrl: details.url },
+          }).catch(() => {});
+          resolve();
+        });
+      }
+    };
+
+    chrome.webNavigation.onBeforeNavigate.addListener(webNavListener);
+
+    // Tell chatgpt.js to navigate to OAuth URL
+    sendToContentScript('chatgpt', {
+      type: 'EXECUTE_STEP',
+      step: 8,
+      source: 'background',
+      payload: {},
+    }).catch(err => {
+      clearTimeout(timeout);
+      if (webNavListener) {
+        chrome.webNavigation.onBeforeNavigate.removeListener(webNavListener);
+        webNavListener = null;
+      }
+      reject(err);
+    });
+  });
+}
+
+// ============================================================
+// Step 9: VPS Verify (via vps-panel.js)
+// ============================================================
+
+async function executeStep9(state) {
+  if (!state.localhostUrl) {
+    throw new Error('No localhost URL. Complete step 8 first.');
+  }
+
+  // Switch to VPS panel tab
+  const alive = await isTabAlive('vps-panel');
+  if (!alive) {
+    await addLog('Step 9: Opening VPS panel...');
+    await chrome.tabs.create({ url: 'http://154.26.182.181:8317/management.html#/oauth', active: true });
+  } else {
+    const tabId = await getTabId('vps-panel');
+    if (tabId) await chrome.tabs.update(tabId, { active: true });
+  }
+
+  await sendToContentScript('vps-panel', {
+    type: 'EXECUTE_STEP',
+    step: 9,
+    source: 'background',
+    payload: {},
+  });
+}
+
+// ============================================================
+// Open Side Panel on extension icon click
+// ============================================================
+
+chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });

+ 211 - 0
content/chatgpt.js

@@ -0,0 +1,211 @@
+// content/chatgpt.js — Content script for ChatGPT (steps 6, 7-receive, 8)
+// Injected on: chatgpt.com
+
+console.log('[MultiPage:chatgpt] 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') {
+    handleCommand(message).then(() => {
+      sendResponse({ ok: true });
+    }).catch(err => {
+      reportError(message.step, err.message);
+      sendResponse({ error: err.message });
+    });
+    return true;
+  }
+});
+
+async function handleCommand(message) {
+  switch (message.type) {
+    case 'EXECUTE_STEP':
+      switch (message.step) {
+        case 6: return await step6_loginChatGPT();
+        case 8: return await step8_navigateOAuth();
+        default: throw new Error(`chatgpt.js does not handle step ${message.step}`);
+      }
+    case 'FILL_CODE':
+      return await step7_fillLoginCode(message.payload);
+  }
+}
+
+// ============================================================
+// Step 6: Login ChatGPT
+// ============================================================
+
+async function step6_loginChatGPT() {
+  log('Step 6: Looking for login button on ChatGPT...');
+
+  // Get state for email and password
+  const state = await chrome.runtime.sendMessage({ type: 'GET_STATE' });
+  const email = state.email;
+  const password = state.password || 'mimashisha0.0';
+
+  if (!email) throw new Error('No email found in state. Complete earlier steps first.');
+
+  // Find login button on ChatGPT homepage
+  let loginBtn = null;
+  try {
+    loginBtn = await waitForElementByText(
+      'a, button, [role="button"], [role="link"]',
+      /log\s*in|sign\s*in|登录/i,
+      10000
+    );
+  } catch {
+    try {
+      loginBtn = await waitForElement(
+        '[data-testid="login-button"], a[href*="auth"], a[href*="login"]',
+        5000
+      );
+    } catch {
+      throw new Error('Could not find Login button on ChatGPT. URL: ' + location.href);
+    }
+  }
+
+  simulateClick(loginBtn);
+  log('Step 6: Clicked Login button, waiting for auth page...');
+  await sleep(3000);
+
+  // We may be redirected to an auth page (auth0.openai.com etc.)
+  // Or the login form may appear on the same page
+  // Try to find email input
+  let emailInput = null;
+  try {
+    emailInput = await waitForElement(
+      'input[type="email"], input[name="email"], input[name="username"], input[id*="email"], input[placeholder*="email" i]',
+      15000
+    );
+  } catch {
+    throw new Error('Could not find email input on login page. URL: ' + location.href);
+  }
+
+  fillInput(emailInput, email);
+  log(`Step 6: Filled email: ${email}`);
+
+  // Submit email
+  await sleep(500);
+  const submitBtn1 = document.querySelector('button[type="submit"]')
+    || await waitForElementByText('button', /continue|next|submit|继续/i, 5000).catch(() => null);
+  if (submitBtn1) {
+    simulateClick(submitBtn1);
+    log('Step 6: Submitted email');
+  }
+
+  await sleep(2000);
+
+  // Check: password field or OTP?
+  const passwordInput = document.querySelector('input[type="password"]');
+  if (passwordInput) {
+    log('Step 6: Password field found, filling password...');
+    fillInput(passwordInput, password);
+
+    await sleep(500);
+    const submitBtn2 = document.querySelector('button[type="submit"]')
+      || await waitForElementByText('button', /continue|log\s*in|submit|sign\s*in|登录/i, 5000).catch(() => null);
+    if (submitBtn2) {
+      simulateClick(submitBtn2);
+      log('Step 6: Submitted password');
+    }
+
+    await sleep(3000);
+
+    // Check if we need OTP after password
+    const codeInput = document.querySelector(
+      'input[name="code"], input[maxlength="6"], input[inputmode="numeric"], input[type="text"][maxlength="6"]'
+    );
+    if (codeInput) {
+      log('Step 6: OTP code input found after password. Waiting for step 7...');
+      reportComplete(6, { needsOTP: true });
+    } else {
+      // Check if we're actually logged in (redirected to chatgpt.com main page)
+      log('Step 6: Login appears successful (no OTP needed)');
+      reportComplete(6, { needsOTP: false });
+    }
+  } else {
+    // No password field — check for OTP input or "check your email" message
+    const codeInput = document.querySelector(
+      'input[name="code"], input[maxlength="6"], input[inputmode="numeric"]'
+    );
+    if (codeInput) {
+      log('Step 6: OTP flow detected (no password). Waiting for step 7...');
+      reportComplete(6, { needsOTP: true });
+    } else {
+      // Maybe the page is still loading or transitioning
+      log('Step 6: No password or OTP field found. May need email verification. Waiting for step 7...');
+      reportComplete(6, { needsOTP: true });
+    }
+  }
+}
+
+// ============================================================
+// Step 7 (receiving end): Fill Login Verification Code
+// ============================================================
+
+async function step7_fillLoginCode(payload) {
+  const { code } = payload;
+  if (!code) throw new Error('No verification code provided.');
+
+  log(`Step 7: Filling login verification code: ${code}`);
+
+  // Find code input — single input or multiple single-digit inputs
+  let codeInput = null;
+  try {
+    codeInput = await waitForElement(
+      'input[name="code"], input[name="otp"], input[maxlength="6"], input[type="text"][inputmode="numeric"], input[aria-label*="code" i], input[placeholder*="code" i]',
+      10000
+    );
+  } catch {
+    // Check for multiple single-digit inputs
+    const singleInputs = document.querySelectorAll('input[maxlength="1"]');
+    if (singleInputs.length >= 6) {
+      log('Step 7: Found single-digit code inputs, filling individually...');
+      for (let i = 0; i < 6 && i < singleInputs.length; i++) {
+        fillInput(singleInputs[i], code[i]);
+        await sleep(100);
+      }
+      await sleep(1000);
+
+      // Auto-submit may happen; if not, try clicking submit
+      const submitBtn = document.querySelector('button[type="submit"]')
+        || await waitForElementByText('button', /continue|verify|submit|confirm/i, 3000).catch(() => null);
+      if (submitBtn) simulateClick(submitBtn);
+
+      await sleep(3000);
+      reportComplete(7);
+      return;
+    }
+
+    throw new Error('Could not find verification code input on ChatGPT login. URL: ' + location.href);
+  }
+
+  fillInput(codeInput, code);
+
+  await sleep(500);
+  const submitBtn = document.querySelector('button[type="submit"]')
+    || await waitForElementByText('button', /continue|verify|submit|confirm|确认/i, 5000).catch(() => null);
+  if (submitBtn) {
+    simulateClick(submitBtn);
+    log('Step 7: Login verification code submitted');
+  }
+
+  await sleep(3000);
+  reportComplete(7);
+}
+
+// ============================================================
+// Step 8: Navigate to OAuth URL
+// ============================================================
+
+async function step8_navigateOAuth() {
+  const state = await chrome.runtime.sendMessage({ type: 'GET_STATE' });
+  const oauthUrl = state.oauthUrl;
+  if (!oauthUrl) throw new Error('No OAuth URL found. Complete step 1 first.');
+
+  log(`Step 8: Navigating to OAuth URL: ${oauthUrl.slice(0, 80)}...`);
+  log('Step 8: Waiting for localhost redirect (captured by background)...');
+
+  // Navigate — the webNavigation listener in background will capture localhost redirect
+  window.location.href = oauthUrl;
+
+  // Don't reportComplete here — background handles it via webNavigation
+}

+ 261 - 0
content/qq-mail.js

@@ -0,0 +1,261 @@
+// content/qq-mail.js — Content script for QQ Mail (steps 4, 7)
+// Injected on: mail.qq.com, wx.mail.qq.com
+// NOTE: all_frames: true — this script runs in every frame on QQ Mail
+
+const QQ_MAIL_PREFIX = '[MultiPage:qq-mail]';
+const isNewVersion = location.hostname === 'wx.mail.qq.com';
+const isTopFrame = window === window.top;
+
+console.log(QQ_MAIL_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
+
+// For old QQ Mail with iframes, only report ready from the top frame
+// to avoid duplicate registrations. The inbox frame will handle email ops.
+if (!isTopFrame && isNewVersion) {
+  console.log(QQ_MAIL_PREFIX, 'Skipping non-top frame on new QQ Mail');
+  // Don't do anything in child frames of new version
+}
+
+// ============================================================
+// Frame detection
+// ============================================================
+
+function isInboxFrame() {
+  if (isNewVersion) return isTopFrame;
+  // Old version: check if this frame has email list elements
+  return !!document.querySelector(
+    '#mailList, .mail-list, [id*="mailList"], #frm_main, .toarea, .mailList'
+  );
+}
+
+// ============================================================
+// Login State Check
+// ============================================================
+
+function checkLoginState() {
+  if (isNewVersion) {
+    // wx.mail.qq.com: look for compose button or folder list
+    return !!(
+      document.querySelector('[class*="folder"], [class*="compose"], [class*="sidebar"]') ||
+      document.querySelector('nav, [role="navigation"]') ||
+      document.querySelector('[class*="mail-list"], [class*="mailList"]')
+    );
+  } else {
+    // mail.qq.com: look for known logged-in elements
+    return !!(
+      document.querySelector('#folder_1, .folder_inbox, #composebtn, #mainFrameContainer')
+    );
+  }
+}
+
+// ============================================================
+// Message Handler
+// ============================================================
+
+chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+  if (message.type === 'POLL_EMAIL') {
+    // For old QQ Mail, only handle in the inbox frame
+    if (!isNewVersion && !isInboxFrame()) {
+      sendResponse({ ok: false, reason: 'wrong-frame' });
+      return;
+    }
+
+    handlePollEmail(message.step, message.payload).then(result => {
+      sendResponse(result);
+    }).catch(err => {
+      reportError(message.step, err.message);
+      sendResponse({ error: err.message });
+    });
+    return true; // async response
+  }
+
+  if (message.type === 'CHECK_LOGIN') {
+    if (!isTopFrame) {
+      sendResponse({ loggedIn: false });
+      return;
+    }
+    sendResponse({ loggedIn: checkLoginState() });
+    return;
+  }
+});
+
+// ============================================================
+// Email Polling
+// ============================================================
+
+async function handlePollEmail(step, payload) {
+  const { filterAfterTimestamp, senderFilters, subjectFilters, maxAttempts, intervalMs } = payload;
+
+  // Check login state first
+  if (isTopFrame && !checkLoginState()) {
+    throw new Error('QQ Mail not logged in. Please log in to QQ Mail and retry.');
+  }
+
+  log(`Step ${step}: Starting email poll (max ${maxAttempts} attempts, every ${intervalMs / 1000}s)`);
+
+  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+    log(`Polling QQ Mail... attempt ${attempt}/${maxAttempts}`);
+
+    // Try to refresh inbox
+    await refreshInbox();
+    await sleep(800); // Wait for refresh to take effect
+
+    // Search for matching email
+    const result = await findMatchingEmail(senderFilters, subjectFilters);
+
+    if (result) {
+      log(`Step ${step}: Found matching email! Extracting code...`);
+      const code = extractVerificationCode(result.content);
+      if (code) {
+        log(`Step ${step}: Verification code found: ${code}`, 'ok');
+        return { ok: true, code, emailTimestamp: Date.now() };
+      } else {
+        log(`Step ${step}: Email found but no 6-digit code in content. Preview: ${result.content.slice(0, 100)}`, 'warn');
+      }
+    }
+
+    if (attempt < maxAttempts) {
+      await sleep(intervalMs);
+    }
+  }
+
+  throw new Error(
+    `No matching email found after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s. ` +
+    'Check QQ Mail manually. Email may be in spam folder.'
+  );
+}
+
+// ============================================================
+// Inbox Refresh
+// ============================================================
+
+async function refreshInbox() {
+  if (isNewVersion) {
+    // wx.mail.qq.com: try multiple refresh selectors
+    const refreshBtn = document.querySelector(
+      '[class*="refresh"], [title*="刷新"], button[aria-label*="refresh"], ' +
+      '[data-action="refresh"], .toolbar-refresh'
+    );
+    if (refreshBtn) {
+      simulateClick(refreshBtn);
+      console.log(QQ_MAIL_PREFIX, 'Clicked refresh button (new version)');
+    } else {
+      // Fallback: click on "Inbox" folder to force reload
+      const inboxLink = document.querySelector(
+        '[class*="inbox"], [title*="收件箱"], a[href*="inbox"]'
+      );
+      if (inboxLink) {
+        simulateClick(inboxLink);
+        console.log(QQ_MAIL_PREFIX, 'Clicked inbox link to refresh');
+      }
+    }
+  } else {
+    // mail.qq.com: old version
+    const refreshBtn = document.querySelector(
+      '#refresh, .refresh_btn, [id*="refresh"], a[title*="刷新"]'
+    );
+    if (refreshBtn) {
+      simulateClick(refreshBtn);
+      console.log(QQ_MAIL_PREFIX, 'Clicked refresh button (old version)');
+    }
+  }
+}
+
+// ============================================================
+// Find Matching Email
+// ============================================================
+
+async function findMatchingEmail(senderFilters, subjectFilters) {
+  let emailItems;
+
+  if (isNewVersion) {
+    // wx.mail.qq.com: email list items
+    emailItems = document.querySelectorAll(
+      '[class*="mail-item"], [class*="list-item"], [class*="mail_item"], ' +
+      'tr[class*="mail"], div[class*="letter"], [class*="thread"]'
+    );
+  } else {
+    // mail.qq.com: email list inside table
+    emailItems = document.querySelectorAll(
+      '.toarea tr, #mailList tr, .mail_list tr, [id*="mail_"] tr'
+    );
+  }
+
+  console.log(QQ_MAIL_PREFIX, `Found ${emailItems.length} email items to scan`);
+
+  for (const item of emailItems) {
+    const text = (item.textContent || '').toLowerCase();
+
+    const senderMatch = senderFilters.some(f => text.includes(f.toLowerCase()));
+    const subjectMatch = subjectFilters.some(f => text.includes(f.toLowerCase()));
+
+    if (senderMatch || subjectMatch) {
+      console.log(QQ_MAIL_PREFIX, 'Found matching email item:', text.slice(0, 100));
+
+      // Try to get content from the visible text first
+      let content = item.textContent || '';
+
+      // Check if we can already extract a code from the preview
+      if (extractVerificationCode(content)) {
+        return { content };
+      }
+
+      // Need to click into the email for full body
+      log('Clicking email to read full content...');
+      simulateClick(item);
+      await sleep(1500);
+
+      // Read email body
+      const bodyEl = document.querySelector(
+        '[class*="mail-body"], [class*="mail_body"], [class*="letter-body"], ' +
+        '.body_content, #contentDiv, [class*="read-content"], [class*="mail-detail"]'
+      );
+
+      if (bodyEl) {
+        content = bodyEl.textContent || bodyEl.innerText || '';
+        console.log(QQ_MAIL_PREFIX, 'Email body content:', content.slice(0, 200));
+      }
+
+      // Go back to inbox after reading
+      await goBackToInbox();
+
+      return { content };
+    }
+  }
+
+  return null;
+}
+
+async function goBackToInbox() {
+  if (isNewVersion) {
+    // Click back or inbox link
+    const backBtn = document.querySelector(
+      '[class*="back"], [aria-label*="back"], [title*="返回"], [class*="return"]'
+    );
+    if (backBtn) {
+      simulateClick(backBtn);
+      await sleep(500);
+    }
+  }
+  // Old version typically uses frames, no need to go back
+}
+
+// ============================================================
+// Verification Code Extraction
+// ============================================================
+
+function extractVerificationCode(text) {
+  // Try various patterns for 6-digit verification codes
+  // Pattern 1: standalone 6 digits
+  const match6 = text.match(/\b(\d{6})\b/);
+  if (match6) return match6[1];
+
+  // Pattern 2: code/verification followed by digits
+  const matchLabeled = text.match(/(?:code|验证码|verification|verify)[:\s]*(\d{4,8})/i);
+  if (matchLabeled) return matchLabeled[1];
+
+  // Pattern 3: digits followed by "code" label (Chinese: 是您的验证码)
+  const matchReverse = text.match(/(\d{4,8})\s*(?:是|为|is)?\s*(?:您的)?(?:验证码|code)/i);
+  if (matchReverse) return matchReverse[1];
+
+  return null;
+}

+ 266 - 0
content/signup-page.js

@@ -0,0 +1,266 @@
+// content/signup-page.js — Content script for OpenAI auth pages (steps 2, 3, 4-receive, 5)
+// Injected on: auth0.openai.com, auth.openai.com, accounts.openai.com
+
+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') {
+    handleCommand(message).then(() => {
+      sendResponse({ ok: true });
+    }).catch(err => {
+      reportError(message.step, err.message);
+      sendResponse({ error: err.message });
+    });
+    return true;
+  }
+});
+
+async function handleCommand(message) {
+  switch (message.type) {
+    case 'EXECUTE_STEP':
+      switch (message.step) {
+        case 2: return await step2_clickRegister();
+        case 3: return await step3_fillEmailPassword(message.payload);
+        case 5: return await step5_fillNameBirthday(message.payload);
+        default: throw new Error(`signup-page.js does not handle step ${message.step}`);
+      }
+    case 'FILL_CODE':
+      return await step4_fillVerificationCode(message.payload);
+  }
+}
+
+// ============================================================
+// Step 2: Click Register
+// ============================================================
+
+async function step2_clickRegister() {
+  log('Step 2: Looking for Register/Sign up button...');
+
+  // TODO: Adjust selectors based on actual OpenAI auth page
+  let registerBtn = null;
+  try {
+    registerBtn = await waitForElementByText(
+      'a, button, [role="button"], [role="link"]',
+      /sign\s*up|register|create\s*account|注册/i,
+      10000
+    );
+  } catch {
+    // Some pages may have a direct link
+    try {
+      registerBtn = await waitForElement('a[href*="signup"], a[href*="register"]', 5000);
+    } catch {
+      throw new Error(
+        'Could not find Register/Sign up button. ' +
+        'Check auth page DOM in DevTools. URL: ' + location.href
+      );
+    }
+  }
+
+  simulateClick(registerBtn);
+  log('Step 2: Clicked Register button');
+
+  // Wait for page transition
+  await sleep(2000);
+  reportComplete(2);
+}
+
+// ============================================================
+// Step 3: Fill Email & Password
+// ============================================================
+
+async function step3_fillEmailPassword(payload) {
+  const { email } = payload;
+  if (!email) throw new Error('No email provided. Paste email in Side Panel first.');
+
+  log(`Step 3: Filling email: ${email}`);
+
+  // Find email input
+  let emailInput = null;
+  try {
+    emailInput = await waitForElement(
+      'input[type="email"], input[name="email"], input[name="username"], input[id*="email"], input[placeholder*="email"], input[placeholder*="Email"]',
+      10000
+    );
+  } catch {
+    throw new Error('Could not find email input field on signup page. URL: ' + location.href);
+  }
+
+  fillInput(emailInput, email);
+  log('Step 3: Email filled');
+
+  // Check if password field is on the same page
+  let passwordInput = document.querySelector('input[type="password"]');
+
+  if (!passwordInput) {
+    // Need to submit email first to get to password page
+    log('Step 3: No password field yet, submitting email first...');
+    const submitBtn = document.querySelector('button[type="submit"]')
+      || await waitForElementByText('button', /continue|next|submit|继续|下一步/i, 5000).catch(() => null);
+
+    if (submitBtn) {
+      simulateClick(submitBtn);
+      log('Step 3: Submitted email, waiting for password field...');
+      await sleep(2000);
+    }
+
+    try {
+      passwordInput = await waitForElement('input[type="password"]', 10000);
+    } catch {
+      throw new Error('Could not find password input after submitting email. URL: ' + location.href);
+    }
+  }
+
+  fillInput(passwordInput, 'mimashisha0.0');
+  log('Step 3: Password filled');
+
+  // Submit the form
+  await sleep(500);
+  const submitBtn = document.querySelector('button[type="submit"]')
+    || await waitForElementByText('button', /continue|sign\s*up|submit|注册|创建|create/i, 5000).catch(() => null);
+
+  if (submitBtn) {
+    simulateClick(submitBtn);
+    log('Step 3: Form submitted');
+  }
+
+  await sleep(2000);
+  reportComplete(3, { email });
+}
+
+// ============================================================
+// Step 4 (receiving end): Fill Verification Code
+// ============================================================
+
+async function step4_fillVerificationCode(payload) {
+  const { code } = payload;
+  if (!code) throw new Error('No verification code provided.');
+
+  log(`Step 4: Filling verification code: ${code}`);
+
+  // Find code input — could be a single input or multiple separate inputs
+  let codeInput = null;
+  try {
+    codeInput = await waitForElement(
+      'input[name="code"], input[name="otp"], input[type="text"][maxlength="6"], input[aria-label*="code"], input[placeholder*="code"], input[placeholder*="Code"], input[inputmode="numeric"]',
+      10000
+    );
+  } catch {
+    // Check for multiple single-digit inputs (common pattern)
+    const singleInputs = document.querySelectorAll('input[maxlength="1"]');
+    if (singleInputs.length >= 6) {
+      log('Step 4: Found single-digit code inputs, filling individually...');
+      for (let i = 0; i < 6 && i < singleInputs.length; i++) {
+        fillInput(singleInputs[i], code[i]);
+        await sleep(100);
+      }
+      await sleep(1000);
+      reportComplete(4);
+      return;
+    }
+    throw new Error('Could not find verification code input. URL: ' + location.href);
+  }
+
+  fillInput(codeInput, code);
+  log('Step 4: Code filled');
+
+  // Submit
+  await sleep(500);
+  const submitBtn = document.querySelector('button[type="submit"]')
+    || await waitForElementByText('button', /verify|confirm|submit|continue|确认|验证/i, 5000).catch(() => null);
+
+  if (submitBtn) {
+    simulateClick(submitBtn);
+    log('Step 4: Verification submitted');
+  }
+
+  // Wait for page transition
+  await sleep(2000);
+  reportComplete(4);
+}
+
+// ============================================================
+// Step 5: Fill Name & Birthday
+// ============================================================
+
+async function step5_fillNameBirthday(payload) {
+  const { firstName, lastName, year, month, day } = payload;
+  if (!firstName || !lastName) throw new Error('No name data provided.');
+
+  log(`Step 5: Filling name: ${firstName} ${lastName}, Birthday: ${year}-${month}-${day}`);
+
+  // --- First name ---
+  let firstNameInput = null;
+  try {
+    firstNameInput = await waitForElement(
+      'input[name="firstName"], input[name="first_name"], input[name="fname"], input[placeholder*="first" i], input[placeholder*="First"], input[id*="first" i]',
+      10000
+    );
+  } catch {
+    throw new Error('Could not find first name input. URL: ' + location.href);
+  }
+  fillInput(firstNameInput, firstName);
+  log(`Step 5: First name filled: ${firstName}`);
+
+  // --- Last name ---
+  let lastNameInput = null;
+  try {
+    lastNameInput = await waitForElement(
+      'input[name="lastName"], input[name="last_name"], input[name="lname"], input[placeholder*="last" i], input[placeholder*="Last"], input[id*="last" i]',
+      5000
+    );
+  } catch {
+    throw new Error('Could not find last name input. URL: ' + location.href);
+  }
+  fillInput(lastNameInput, lastName);
+  log(`Step 5: Last name filled: ${lastName}`);
+
+  // --- Birthday ---
+  const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
+
+  // Try single date input first
+  const dateInput = document.querySelector('input[type="date"], input[name="birthday"], input[name="dob"], input[name="birthdate"]');
+  if (dateInput) {
+    fillInput(dateInput, dateStr);
+    log(`Step 5: Birthday filled (single input): ${dateStr}`);
+  } else {
+    // Try separate fields (month/day/year selects or inputs)
+    log('Step 5: Looking for separate birthday fields...');
+
+    const monthEl = document.querySelector('select[name*="month" i], input[name*="month" i], input[placeholder*="month" i], select[id*="month" i]');
+    const dayEl = document.querySelector('select[name*="day" i], input[name*="day" i], input[placeholder*="day" i], select[id*="day" i]');
+    const yearEl = document.querySelector('select[name*="year" i], input[name*="year" i], input[placeholder*="year" i], select[id*="year" i]');
+
+    if (monthEl) {
+      if (monthEl.tagName === 'SELECT') fillSelect(monthEl, String(month));
+      else fillInput(monthEl, String(month).padStart(2, '0'));
+    }
+    if (dayEl) {
+      if (dayEl.tagName === 'SELECT') fillSelect(dayEl, String(day));
+      else fillInput(dayEl, String(day).padStart(2, '0'));
+    }
+    if (yearEl) {
+      if (yearEl.tagName === 'SELECT') fillSelect(yearEl, String(year));
+      else fillInput(yearEl, String(year));
+    }
+
+    if (!monthEl && !dayEl && !yearEl) {
+      log('Step 5: WARNING - Could not find any birthday fields. May need to adjust selectors.', 'warn');
+    } else {
+      log(`Step 5: Birthday filled (separate fields): ${year}-${month}-${day}`);
+    }
+  }
+
+  // Submit / Complete
+  await sleep(500);
+  const completeBtn = document.querySelector('button[type="submit"]')
+    || await waitForElementByText('button', /complete|continue|finish|done|create|agree|完成|创建|同意/i, 5000).catch(() => null);
+
+  if (completeBtn) {
+    simulateClick(completeBtn);
+    log('Step 5: Profile form submitted');
+  }
+
+  await sleep(2000);
+  reportComplete(5);
+}

+ 226 - 0
content/utils.js

@@ -0,0 +1,226 @@
+// content/utils.js — Shared utilities for all content scripts
+
+const SCRIPT_SOURCE = (() => {
+  const url = location.href;
+  if (url.includes('154.26.182.181')) return 'vps-panel';
+  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('chatgpt.com')) return 'chatgpt';
+  return 'unknown';
+})();
+
+const LOG_PREFIX = `[MultiPage:${SCRIPT_SOURCE}]`;
+
+/**
+ * Wait for a DOM element to appear.
+ * @param {string} selector - CSS selector
+ * @param {number} timeout - Max wait time in ms (default 10000)
+ * @returns {Promise<Element>}
+ */
+function waitForElement(selector, timeout = 10000) {
+  return new Promise((resolve, reject) => {
+    const existing = document.querySelector(selector);
+    if (existing) {
+      console.log(LOG_PREFIX, `Found immediately: ${selector}`);
+      log(`Found element: ${selector}`);
+      resolve(existing);
+      return;
+    }
+
+    console.log(LOG_PREFIX, `Waiting for: ${selector} (timeout: ${timeout}ms)`);
+    log(`Waiting for selector: ${selector}...`);
+
+    const observer = new MutationObserver(() => {
+      const el = document.querySelector(selector);
+      if (el) {
+        observer.disconnect();
+        clearTimeout(timer);
+        console.log(LOG_PREFIX, `Found after wait: ${selector}`);
+        log(`Found element: ${selector}`);
+        resolve(el);
+      }
+    });
+
+    observer.observe(document.body || document.documentElement, {
+      childList: true,
+      subtree: true,
+    });
+
+    const timer = setTimeout(() => {
+      observer.disconnect();
+      const msg = `Timeout waiting for ${selector} after ${timeout}ms on ${location.href}`;
+      console.error(LOG_PREFIX, msg);
+      reject(new Error(msg));
+    }, timeout);
+  });
+}
+
+/**
+ * Wait for an element matching a text pattern among multiple candidates.
+ * @param {string} containerSelector - Selector for candidate elements
+ * @param {RegExp} textPattern - Regex to match against textContent
+ * @param {number} timeout - Max wait time in ms
+ * @returns {Promise<Element>}
+ */
+function waitForElementByText(containerSelector, textPattern, timeout = 10000) {
+  return new Promise((resolve, reject) => {
+    function search() {
+      const candidates = document.querySelectorAll(containerSelector);
+      for (const el of candidates) {
+        if (textPattern.test(el.textContent)) {
+          return el;
+        }
+      }
+      return null;
+    }
+
+    const existing = search();
+    if (existing) {
+      console.log(LOG_PREFIX, `Found by text immediately: ${containerSelector} matching ${textPattern}`);
+      log(`Found element by text: ${textPattern}`);
+      resolve(existing);
+      return;
+    }
+
+    console.log(LOG_PREFIX, `Waiting for text match: ${containerSelector} / ${textPattern}`);
+    log(`Waiting for element with text: ${textPattern}...`);
+
+    const observer = new MutationObserver(() => {
+      const el = search();
+      if (el) {
+        observer.disconnect();
+        clearTimeout(timer);
+        console.log(LOG_PREFIX, `Found by text after wait: ${textPattern}`);
+        log(`Found element by text: ${textPattern}`);
+        resolve(el);
+      }
+    });
+
+    observer.observe(document.body || document.documentElement, {
+      childList: true,
+      subtree: true,
+    });
+
+    const timer = setTimeout(() => {
+      observer.disconnect();
+      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);
+  });
+}
+
+/**
+ * React-compatible form filling.
+ * Sets value via native setter and dispatches input + change events.
+ * @param {HTMLInputElement} el
+ * @param {string} value
+ */
+function fillInput(el, value) {
+  const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
+    window.HTMLInputElement.prototype,
+    'value'
+  ).set;
+  nativeInputValueSetter.call(el, value);
+  el.dispatchEvent(new Event('input', { bubbles: true }));
+  el.dispatchEvent(new Event('change', { bubbles: true }));
+  console.log(LOG_PREFIX, `Filled input ${el.name || el.id || el.type} with: ${value}`);
+  log(`Filled input [${el.name || el.id || el.type || 'unknown'}]`);
+}
+
+/**
+ * Fill a select element by setting its value and triggering change.
+ * @param {HTMLSelectElement} el
+ * @param {string} value
+ */
+function fillSelect(el, value) {
+  el.value = value;
+  el.dispatchEvent(new Event('change', { bubbles: true }));
+  console.log(LOG_PREFIX, `Selected value ${value} in ${el.name || el.id}`);
+  log(`Selected [${el.name || el.id || 'unknown'}] = ${value}`);
+}
+
+/**
+ * Send a log message to Side Panel via Background.
+ * @param {string} message
+ * @param {string} level - 'info' | 'ok' | 'warn' | 'error'
+ */
+function log(message, level = 'info') {
+  chrome.runtime.sendMessage({
+    type: 'LOG',
+    source: SCRIPT_SOURCE,
+    step: null,
+    payload: { message, level, timestamp: Date.now() },
+    error: null,
+  });
+}
+
+/**
+ * Report that this content script is loaded and ready.
+ */
+function reportReady() {
+  console.log(LOG_PREFIX, 'Content script ready');
+  chrome.runtime.sendMessage({
+    type: 'CONTENT_SCRIPT_READY',
+    source: SCRIPT_SOURCE,
+    step: null,
+    payload: {},
+    error: null,
+  });
+}
+
+/**
+ * Report step completion.
+ * @param {number} step
+ * @param {Object} data - Step output data
+ */
+function reportComplete(step, data = {}) {
+  console.log(LOG_PREFIX, `Step ${step} completed`, data);
+  log(`Step ${step} completed successfully`, 'ok');
+  chrome.runtime.sendMessage({
+    type: 'STEP_COMPLETE',
+    source: SCRIPT_SOURCE,
+    step,
+    payload: data,
+    error: null,
+  });
+}
+
+/**
+ * Report step error.
+ * @param {number} step
+ * @param {string} errorMessage
+ */
+function reportError(step, errorMessage) {
+  console.error(LOG_PREFIX, `Step ${step} failed: ${errorMessage}`);
+  log(`Step ${step} failed: ${errorMessage}`, 'error');
+  chrome.runtime.sendMessage({
+    type: 'STEP_ERROR',
+    source: SCRIPT_SOURCE,
+    step,
+    payload: {},
+    error: errorMessage,
+  });
+}
+
+/**
+ * Simulate a click with proper event dispatching.
+ * @param {Element} el
+ */
+function simulateClick(el) {
+  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) || ''}"`);
+}
+
+/**
+ * Wait a specified number of milliseconds.
+ * @param {number} ms
+ * @returns {Promise<void>}
+ */
+function sleep(ms) {
+  return new Promise(r => setTimeout(r, ms));
+}
+
+// Auto-report ready on load
+reportReady();

+ 207 - 0
content/vps-panel.js

@@ -0,0 +1,207 @@
+// content/vps-panel.js — Content script for VPS panel (steps 1, 9)
+// Injected on: http://154.26.182.181:8317/*
+
+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') {
+    handleStep(message.step, message.payload).then(() => {
+      sendResponse({ ok: true });
+    }).catch(err => {
+      reportError(message.step, err.message);
+      sendResponse({ error: err.message });
+    });
+    return true;
+  }
+});
+
+async function handleStep(step, payload) {
+  switch (step) {
+    case 1: return await step1_getOAuthLink();
+    case 9: return await step9_vpsVerify();
+    default:
+      throw new Error(`vps-panel.js does not handle step ${step}`);
+  }
+}
+
+// ============================================================
+// Step 1: Get OAuth Link
+// ============================================================
+
+async function step1_getOAuthLink() {
+  log('Step 1: Checking VPS panel state...');
+
+  // --- Selector strategy ---
+  // The VPS panel is at management.html#/oauth
+  // We need to: 1) find OAuth login section, 2) click Codex login, 3) read auth URL
+  // TODO: These selectors MUST be adjusted after inspecting the actual VPS panel DOM
+  // The selectors below are best-guess placeholders.
+
+  // Try to find OAuth login button by text content
+  log('Step 1: Looking for OAuth login button...');
+  let oauthBtn = null;
+  try {
+    oauthBtn = await waitForElementByText(
+      'button, a, [role="button"], .el-button, div[class*="btn"]',
+      /oauth|OAuth/i,
+      10000
+    );
+  } catch {
+    // Fallback: try common UI framework selectors
+    try {
+      oauthBtn = await waitForElement('.oauth-login-btn, [data-action="oauth-login"]', 5000);
+    } catch {
+      throw new Error(
+        'Could not find OAuth login button. ' +
+        'Please inspect the VPS panel page in DevTools and update the selector in vps-panel.js. ' +
+        'URL: ' + location.href
+      );
+    }
+  }
+
+  simulateClick(oauthBtn);
+  log('Step 1: Clicked OAuth login, waiting for Codex login option...');
+  await sleep(1500);
+
+  // Wait for Codex login option to appear
+  let codexBtn = null;
+  try {
+    codexBtn = await waitForElementByText(
+      'button, a, [role="button"], .el-button, div[class*="btn"], span',
+      /codex/i,
+      10000
+    );
+  } catch {
+    try {
+      codexBtn = await waitForElement('[data-action="codex-login"], .codex-login-btn', 5000);
+    } catch {
+      throw new Error(
+        'Could not find Codex login button after clicking OAuth. ' +
+        'Check the VPS panel DOM in DevTools. URL: ' + location.href
+      );
+    }
+  }
+
+  simulateClick(codexBtn);
+  log('Step 1: Clicked Codex login, waiting for auth URL...');
+  await sleep(2000);
+
+  // Extract the auth URL — could be in various elements
+  let oauthUrl = null;
+
+  // Strategy 1: Look for an input/textarea with a URL value
+  const inputs = document.querySelectorAll('input[readonly], input[type="text"], textarea, code, pre');
+  for (const el of inputs) {
+    const val = (el.value || el.textContent || '').trim();
+    if (val.startsWith('http') && val.length > 30) {
+      oauthUrl = val;
+      log(`Step 1: Found URL in <${el.tagName}>: ${val.slice(0, 80)}...`);
+      break;
+    }
+  }
+
+  // Strategy 2: Look for any element containing a long URL
+  if (!oauthUrl) {
+    const allElements = document.querySelectorAll('span, p, div, a, code, pre');
+    for (const el of allElements) {
+      const text = (el.textContent || '').trim();
+      // Match a URL that looks like an OAuth authorization URL
+      const urlMatch = text.match(/(https?:\/\/[^\s<>"']{30,})/);
+      if (urlMatch) {
+        oauthUrl = urlMatch[1];
+        log(`Step 1: Found URL in text: ${oauthUrl.slice(0, 80)}...`);
+        break;
+      }
+    }
+  }
+
+  // Strategy 3: Check clipboard (if the page auto-copies)
+  if (!oauthUrl) {
+    try {
+      oauthUrl = await navigator.clipboard.readText();
+      if (oauthUrl && oauthUrl.startsWith('http') && oauthUrl.length > 30) {
+        log(`Step 1: Found URL in clipboard: ${oauthUrl.slice(0, 80)}...`);
+      } else {
+        oauthUrl = null;
+      }
+    } catch {
+      // Clipboard access may be denied
+    }
+  }
+
+  if (!oauthUrl) {
+    throw new Error(
+      'Could not find auth URL. The URL may be displayed in a format we cannot detect. ' +
+      'Please check the VPS panel page and copy the URL manually, or update the extraction logic in vps-panel.js. ' +
+      'URL: ' + location.href
+    );
+  }
+
+  log(`Step 1: OAuth URL obtained: ${oauthUrl.slice(0, 80)}...`, 'ok');
+  reportComplete(1, { oauthUrl: oauthUrl.trim() });
+}
+
+// ============================================================
+// Step 9: VPS Verify
+// ============================================================
+
+async function step9_vpsVerify() {
+  log('Step 9: Getting localhost URL from storage...');
+
+  // Get localhostUrl from storage (via Background)
+  const state = await chrome.runtime.sendMessage({ type: 'GET_STATE' });
+  const localhostUrl = state.localhostUrl;
+  if (!localhostUrl) {
+    throw new Error('No localhost URL found. Complete step 8 first.');
+  }
+
+  log(`Step 9: Looking for URL input field on VPS panel...`);
+
+  // Try to find URL input field
+  let urlInput = null;
+  try {
+    urlInput = await waitForElement(
+      'input[placeholder*="localhost"], input[placeholder*="callback"], input[placeholder*="URL"], input[placeholder*="url"], input[name*="callback"], input[name*="url"]',
+      10000
+    );
+  } catch {
+    // Fallback: find any text input that's visible and empty
+    const inputs = document.querySelectorAll('input[type="text"], input:not([type])');
+    for (const input of inputs) {
+      if (input.offsetParent !== null && !input.value) {
+        urlInput = input;
+        log('Step 9: Using fallback empty input field');
+        break;
+      }
+    }
+    if (!urlInput) {
+      throw new Error(
+        'Could not find URL input field on VPS panel. ' +
+        'Check DOM structure in DevTools. URL: ' + location.href
+      );
+    }
+  }
+
+  fillInput(urlInput, localhostUrl);
+  log(`Step 9: Filled URL input with: ${localhostUrl}`);
+
+  // Find and click verify button
+  let verifyBtn = null;
+  try {
+    verifyBtn = await waitForElementByText(
+      'button, [role="button"], .el-button, a',
+      /verif|确认|验证|submit|提交/i,
+      10000
+    );
+  } catch {
+    throw new Error(
+      'Could not find verify/submit button. ' +
+      'Check VPS panel DOM in DevTools. URL: ' + location.href
+    );
+  }
+
+  simulateClick(verifyBtn);
+  log('Step 9: Clicked verify button', 'ok');
+  reportComplete(9);
+}

+ 38 - 0
data/names.js

@@ -0,0 +1,38 @@
+// data/names.js — English name lists for random generation
+
+const FIRST_NAMES = [
+  'James', 'John', 'Robert', 'Michael', 'William', 'David', 'Richard', 'Joseph', 'Thomas', 'Christopher',
+  'Mary', 'Patricia', 'Jennifer', 'Linda', 'Barbara', 'Elizabeth', 'Susan', 'Jessica', 'Sarah', 'Karen',
+  'Daniel', 'Matthew', 'Anthony', 'Mark', 'Donald', 'Steven', 'Andrew', 'Paul', 'Joshua', 'Kenneth',
+  'Emma', 'Olivia', 'Ava', 'Isabella', 'Sophia', 'Mia', 'Charlotte', 'Amelia', 'Harper', 'Evelyn',
+];
+
+const LAST_NAMES = [
+  'Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez',
+  'Hernandez', 'Lopez', 'Gonzalez', 'Wilson', 'Anderson', 'Thomas', 'Taylor', 'Moore', 'Jackson', 'Martin',
+  'Lee', 'Perez', 'Thompson', 'White', 'Harris', 'Sanchez', 'Clark', 'Ramirez', 'Lewis', 'Robinson',
+];
+
+/**
+ * Generate a random full name.
+ * @returns {{ firstName: string, lastName: string }}
+ */
+function generateRandomName() {
+  const firstName = FIRST_NAMES[Math.floor(Math.random() * FIRST_NAMES.length)];
+  const lastName = LAST_NAMES[Math.floor(Math.random() * LAST_NAMES.length)];
+  return { firstName, lastName };
+}
+
+/**
+ * Generate a random birthday (age 19-25).
+ * @returns {{ year: number, month: number, day: number }}
+ */
+function generateRandomBirthday() {
+  const currentYear = new Date().getFullYear();
+  const age = 19 + Math.floor(Math.random() * 7); // 19 to 25
+  const year = currentYear - age;
+  const month = 1 + Math.floor(Math.random() * 12); // 1 to 12
+  const maxDay = new Date(year, month, 0).getDate(); // days in that month
+  const day = 1 + Math.floor(Math.random() * maxDay);
+  return { year, month, day };
+}

BIN
icons/icon128.png


BIN
icons/icon16.png


BIN
icons/icon48.png


+ 72 - 0
manifest.json

@@ -0,0 +1,72 @@
+{
+  "manifest_version": 3,
+  "name": "Multi-Page Automation",
+  "version": "1.0.0",
+  "description": "Automates multi-step OAuth registration workflow",
+  "permissions": [
+    "sidePanel",
+    "tabs",
+    "webNavigation",
+    "storage",
+    "scripting",
+    "activeTab"
+  ],
+  "host_permissions": [
+    "http://154.26.182.181:8317/*",
+    "https://auth0.openai.com/*",
+    "https://auth.openai.com/*",
+    "https://accounts.openai.com/*",
+    "https://mail.qq.com/*",
+    "https://wx.mail.qq.com/*",
+    "https://chatgpt.com/*",
+    "http://localhost/*"
+  ],
+  "background": {
+    "service_worker": "background.js"
+  },
+  "side_panel": {
+    "default_path": "sidepanel/sidepanel.html"
+  },
+  "content_scripts": [
+    {
+      "matches": ["http://154.26.182.181:8317/*"],
+      "js": ["content/utils.js", "content/vps-panel.js"],
+      "run_at": "document_idle"
+    },
+    {
+      "matches": [
+        "https://auth0.openai.com/*",
+        "https://auth.openai.com/*",
+        "https://accounts.openai.com/*"
+      ],
+      "js": ["content/utils.js", "content/signup-page.js"],
+      "run_at": "document_idle"
+    },
+    {
+      "matches": [
+        "https://mail.qq.com/*",
+        "https://wx.mail.qq.com/*"
+      ],
+      "js": ["content/utils.js", "content/qq-mail.js"],
+      "all_frames": true,
+      "run_at": "document_idle"
+    },
+    {
+      "matches": ["https://chatgpt.com/*"],
+      "js": ["content/utils.js", "content/chatgpt.js"],
+      "run_at": "document_idle"
+    }
+  ],
+  "action": {
+    "default_icon": {
+      "16": "icons/icon16.png",
+      "48": "icons/icon48.png",
+      "128": "icons/icon128.png"
+    }
+  },
+  "icons": {
+    "16": "icons/icon16.png",
+    "48": "icons/icon48.png",
+    "128": "icons/icon128.png"
+  }
+}

+ 146 - 0
sidepanel/sidepanel.css

@@ -0,0 +1,146 @@
+/* sidepanel/sidepanel.css */
+* { margin: 0; padding: 0; box-sizing: border-box; }
+
+body {
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+  font-size: 13px;
+  color: #333;
+  background: #f8f9fa;
+  padding: 12px;
+  width: 100%;
+  min-height: 100vh;
+}
+
+header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12px;
+}
+
+header h1 {
+  font-size: 15px;
+  font-weight: 600;
+}
+
+#btn-reset {
+  padding: 4px 10px;
+  font-size: 12px;
+  background: #dc3545;
+  color: #fff;
+  border: none;
+  border-radius: 4px;
+  cursor: pointer;
+}
+#btn-reset:hover { background: #c82333; }
+
+/* Data Section */
+#data-section {
+  background: #fff;
+  border: 1px solid #dee2e6;
+  border-radius: 6px;
+  padding: 10px;
+  margin-bottom: 12px;
+}
+
+.data-row {
+  display: flex;
+  align-items: center;
+  margin-bottom: 6px;
+}
+.data-row:last-child { margin-bottom: 0; }
+
+.data-row label {
+  width: 80px;
+  font-weight: 600;
+  font-size: 12px;
+  color: #666;
+  flex-shrink: 0;
+}
+
+.data-value {
+  font-size: 12px;
+  color: #999;
+  word-break: break-all;
+}
+
+.data-value.has-value {
+  color: #333;
+}
+
+#input-email {
+  flex: 1;
+  padding: 4px 8px;
+  border: 1px solid #ced4da;
+  border-radius: 4px;
+  font-size: 12px;
+}
+
+/* Steps Section */
+#steps-section {
+  margin-bottom: 12px;
+}
+
+.step-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 4px;
+}
+
+.step-num {
+  width: 20px;
+  text-align: center;
+  font-weight: 600;
+  font-size: 12px;
+  color: #666;
+}
+
+.step-btn {
+  flex: 1;
+  padding: 6px 10px;
+  font-size: 12px;
+  background: #007bff;
+  color: #fff;
+  border: none;
+  border-radius: 4px;
+  cursor: pointer;
+  text-align: left;
+}
+.step-btn:hover:not(:disabled) { background: #0069d9; }
+.step-btn:disabled {
+  background: #ccc;
+  cursor: not-allowed;
+}
+
+.step-status {
+  width: 24px;
+  text-align: center;
+  font-size: 14px;
+}
+
+/* Log Section */
+#log-section h2 {
+  font-size: 13px;
+  font-weight: 600;
+  margin-bottom: 6px;
+}
+
+#log-area {
+  background: #1e1e1e;
+  color: #d4d4d4;
+  font-family: 'Consolas', 'Courier New', monospace;
+  font-size: 11px;
+  line-height: 1.5;
+  padding: 8px;
+  border-radius: 6px;
+  height: 250px;
+  overflow-y: auto;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+
+.log-info { color: #d4d4d4; }
+.log-ok { color: #4ec9b0; }
+.log-warn { color: #dcdcaa; }
+.log-error { color: #f44747; }

+ 89 - 0
sidepanel/sidepanel.html

@@ -0,0 +1,89 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>Multi-Page Automation</title>
+  <link rel="stylesheet" href="sidepanel.css">
+</head>
+<body>
+  <header>
+    <h1>Multi-Page Automation</h1>
+    <button id="btn-reset" title="Reset all steps">Reset</button>
+  </header>
+
+  <section id="data-section">
+    <div class="data-row">
+      <label>OAuth URL:</label>
+      <span id="display-oauth-url" class="data-value">Not obtained</span>
+    </div>
+    <div class="data-row">
+      <label>Email:</label>
+      <input type="text" id="input-email" placeholder="Paste DuckDuckGo email here" />
+    </div>
+    <div class="data-row">
+      <label>Localhost:</label>
+      <span id="display-localhost-url" class="data-value">Not captured</span>
+    </div>
+    <div class="data-row">
+      <label>Status:</label>
+      <span id="display-status" class="data-value">Waiting</span>
+    </div>
+  </section>
+
+  <section id="steps-section">
+    <div class="step-row" data-step="1">
+      <span class="step-num">1</span>
+      <button class="step-btn" data-step="1">Get OAuth Link</button>
+      <span class="step-status" data-step="1">⬚</span>
+    </div>
+    <div class="step-row" data-step="2">
+      <span class="step-num">2</span>
+      <button class="step-btn" data-step="2">Open Signup</button>
+      <span class="step-status" data-step="2">⬚</span>
+    </div>
+    <div class="step-row" data-step="3">
+      <span class="step-num">3</span>
+      <button class="step-btn" data-step="3">Fill Email/Password</button>
+      <span class="step-status" data-step="3">⬚</span>
+    </div>
+    <div class="step-row" data-step="4">
+      <span class="step-num">4</span>
+      <button class="step-btn" data-step="4">Get Signup Code</button>
+      <span class="step-status" data-step="4">⬚</span>
+    </div>
+    <div class="step-row" data-step="5">
+      <span class="step-num">5</span>
+      <button class="step-btn" data-step="5">Fill Name/Birthday</button>
+      <span class="step-status" data-step="5">⬚</span>
+    </div>
+    <div class="step-row" data-step="6">
+      <span class="step-num">6</span>
+      <button class="step-btn" data-step="6">Login ChatGPT</button>
+      <span class="step-status" data-step="6">⬚</span>
+    </div>
+    <div class="step-row" data-step="7">
+      <span class="step-num">7</span>
+      <button class="step-btn" data-step="7">Get Login Code</button>
+      <span class="step-status" data-step="7">⬚</span>
+    </div>
+    <div class="step-row" data-step="8">
+      <span class="step-num">8</span>
+      <button class="step-btn" data-step="8">Complete OAuth</button>
+      <span class="step-status" data-step="8">⬚</span>
+    </div>
+    <div class="step-row" data-step="9">
+      <span class="step-num">9</span>
+      <button class="step-btn" data-step="9">VPS Verify</button>
+      <span class="step-status" data-step="9">⬚</span>
+    </div>
+  </section>
+
+  <section id="log-section">
+    <h2>Log</h2>
+    <div id="log-area"></div>
+  </section>
+
+  <script src="sidepanel.js"></script>
+</body>
+</html>

+ 234 - 0
sidepanel/sidepanel.js

@@ -0,0 +1,234 @@
+// sidepanel/sidepanel.js — Side Panel logic
+
+const STATUS_ICONS = {
+  pending: '\u2B1A',    // ⬚
+  running: '\u23F3',    // ⏳
+  completed: '\u2705',  // ✅
+  failed: '\u274C',     // ❌
+};
+
+const logArea = document.getElementById('log-area');
+const displayOauthUrl = document.getElementById('display-oauth-url');
+const displayLocalhostUrl = document.getElementById('display-localhost-url');
+const displayStatus = document.getElementById('display-status');
+const inputEmail = document.getElementById('input-email');
+const btnReset = document.getElementById('btn-reset');
+
+// ============================================================
+// State Restore on load
+// ============================================================
+
+async function restoreState() {
+  try {
+    const state = await chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' });
+
+    // Restore data fields
+    if (state.oauthUrl) {
+      displayOauthUrl.textContent = state.oauthUrl;
+      displayOauthUrl.classList.add('has-value');
+    }
+    if (state.localhostUrl) {
+      displayLocalhostUrl.textContent = state.localhostUrl;
+      displayLocalhostUrl.classList.add('has-value');
+    }
+    if (state.email) {
+      inputEmail.value = state.email;
+    }
+
+    // Restore step statuses
+    if (state.stepStatuses) {
+      for (const [step, status] of Object.entries(state.stepStatuses)) {
+        updateStepUI(Number(step), status);
+      }
+    }
+
+    // Restore logs
+    if (state.logs) {
+      for (const entry of state.logs) {
+        appendLog(entry);
+      }
+    }
+
+    updateStatusDisplay(state);
+  } catch (err) {
+    console.error('Failed to restore state:', err);
+  }
+}
+
+// ============================================================
+// UI Updates
+// ============================================================
+
+function updateStepUI(step, status) {
+  const statusEl = document.querySelector(`.step-status[data-step="${step}"]`);
+  if (statusEl) statusEl.textContent = STATUS_ICONS[status] || '\u2B1A';
+
+  // Interlock logic
+  updateButtonStates();
+}
+
+function updateButtonStates() {
+  // Get all current statuses from DOM
+  const statuses = {};
+  document.querySelectorAll('.step-status').forEach(el => {
+    const step = Number(el.dataset.step);
+    const icon = el.textContent;
+    const status = Object.entries(STATUS_ICONS).find(([, v]) => v === icon)?.[0] || 'pending';
+    statuses[step] = status;
+  });
+
+  // Find if any step is running
+  const anyRunning = Object.values(statuses).some(s => s === 'running');
+
+  for (let step = 1; step <= 9; step++) {
+    const btn = document.querySelector(`.step-btn[data-step="${step}"]`);
+    if (!btn) continue;
+
+    if (anyRunning) {
+      // When any step is running, disable all buttons
+      btn.disabled = true;
+    } else if (step === 1) {
+      // Step 1 is always available (unless running)
+      btn.disabled = false;
+    } else {
+      // Steps 2-9: enabled if previous step completed (or current step failed for retry)
+      const prevStatus = statuses[step - 1];
+      const currentStatus = statuses[step];
+      btn.disabled = !(prevStatus === 'completed' || currentStatus === 'failed' || currentStatus === 'completed');
+    }
+  }
+}
+
+function updateStatusDisplay(state) {
+  if (!state || !state.stepStatuses) return;
+  const running = Object.entries(state.stepStatuses).find(([, s]) => s === 'running');
+  if (running) {
+    displayStatus.textContent = `Step ${running[0]} running...`;
+    displayStatus.classList.add('has-value');
+  } else {
+    const lastCompleted = Object.entries(state.stepStatuses)
+      .filter(([, s]) => s === 'completed')
+      .map(([k]) => Number(k))
+      .sort((a, b) => b - a)[0];
+    if (lastCompleted === 9) {
+      displayStatus.textContent = 'All steps completed!';
+      displayStatus.classList.add('has-value');
+    } else if (lastCompleted) {
+      displayStatus.textContent = `Step ${lastCompleted} done. Ready for step ${lastCompleted + 1}.`;
+      displayStatus.classList.add('has-value');
+    } else {
+      displayStatus.textContent = 'Waiting';
+      displayStatus.classList.remove('has-value');
+    }
+  }
+}
+
+function appendLog(entry) {
+  const time = new Date(entry.timestamp).toLocaleTimeString('en-US', { hour12: false });
+  const levelLabel = entry.level.toUpperCase().padEnd(5);
+  const line = document.createElement('div');
+  line.className = `log-${entry.level}`;
+  line.textContent = `${time} [${levelLabel}] ${entry.message}`;
+  logArea.appendChild(line);
+  logArea.scrollTop = logArea.scrollHeight;
+}
+
+// ============================================================
+// Button Handlers
+// ============================================================
+
+document.querySelectorAll('.step-btn').forEach(btn => {
+  btn.addEventListener('click', async () => {
+    const step = Number(btn.dataset.step);
+
+    // Save email if step 3 and email input has value
+    if (step === 3) {
+      const email = inputEmail.value.trim();
+      if (!email) {
+        appendLog({ message: 'Please paste email address first', level: 'error', timestamp: Date.now() });
+        return;
+      }
+      await chrome.runtime.sendMessage({
+        type: 'EXECUTE_STEP',
+        source: 'sidepanel',
+        payload: { step, email },
+      });
+    } else {
+      await chrome.runtime.sendMessage({
+        type: 'EXECUTE_STEP',
+        source: 'sidepanel',
+        payload: { step },
+      });
+    }
+  });
+});
+
+// Reset button
+btnReset.addEventListener('click', async () => {
+  if (confirm('Reset all steps and data?')) {
+    await chrome.runtime.sendMessage({ type: 'RESET', source: 'sidepanel' });
+    // Clear UI
+    displayOauthUrl.textContent = 'Not obtained';
+    displayOauthUrl.classList.remove('has-value');
+    displayLocalhostUrl.textContent = 'Not captured';
+    displayLocalhostUrl.classList.remove('has-value');
+    inputEmail.value = '';
+    displayStatus.textContent = 'Waiting';
+    displayStatus.classList.remove('has-value');
+    logArea.innerHTML = '';
+    document.querySelectorAll('.step-status').forEach(el => el.textContent = '\u2B1A');
+    updateButtonStates();
+  }
+});
+
+// Save email when user types/pastes
+inputEmail.addEventListener('change', async () => {
+  const email = inputEmail.value.trim();
+  if (email) {
+    await chrome.runtime.sendMessage({
+      type: 'SAVE_EMAIL',
+      source: 'sidepanel',
+      payload: { email },
+    });
+  }
+});
+
+// ============================================================
+// Listen for Background broadcasts
+// ============================================================
+
+chrome.runtime.onMessage.addListener((message) => {
+  switch (message.type) {
+    case 'LOG_ENTRY':
+      appendLog(message.payload);
+      break;
+
+    case 'STEP_STATUS_CHANGED': {
+      const { step, status } = message.payload;
+      updateStepUI(step, status);
+      // Update status display
+      chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' }).then(updateStatusDisplay);
+      break;
+    }
+
+    case 'DATA_UPDATED': {
+      if (message.payload.oauthUrl) {
+        displayOauthUrl.textContent = message.payload.oauthUrl;
+        displayOauthUrl.classList.add('has-value');
+      }
+      if (message.payload.localhostUrl) {
+        displayLocalhostUrl.textContent = message.payload.localhostUrl;
+        displayLocalhostUrl.classList.add('has-value');
+      }
+      break;
+    }
+  }
+});
+
+// ============================================================
+// Init
+// ============================================================
+
+restoreState().then(() => {
+  updateButtonStates();
+});