From b860db0f7ae9c5143071b73cab29e6e936d0ce8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BD=20Tu=E1=BA=A5n=20C=C6=B0=E1=BB=9Dng?= Date: Sun, 13 Sep 2026 18:56:16 +0700 Subject: [PATCH 1/4] feat(api): add web dashboard and scheduling to the control API Adds a dependency-free dashboard (public/) served by the existing node:http control API, plus the backend it needs: - account list sourced from .env (scripts/api/envAccounts.js) instead of a hand-maintained copy, so the UI cannot drift from the real accounts - per-account login status derived from the session store's stored cookies (scripts/api/sessionStore.js), so the UI can show which accounts still hold a live Microsoft auth cookie - task scheduling: taskScheduler.js persists tasks to scheduled_tasks.json, taskRunner.js polls and starts them one at a time, and both are wired into server.js behind API_ALLOW_SCHEDULE_WRITE - live log streaming over SSE so the dashboard console mirrors the run logParser: treat ExperimentalWarning as a warning rather than an error. Node prints it on stderr for the SQLite session store, and the old /\bWARN/ pattern missed it, so every run opened with a red herring. scheduled_tasks.json is gitignored: it is per-machine run state, not project config. eslint gains a browser-globals block for public/**. Co-Authored-By: Claude Opus 5 --- .claude/launch.json | 11 + .gitignore | 3 +- eslint.config.mjs | 9 + package.json | 2 + public/README.md | 66 ++++ public/app.js | 720 +++++++++++++++++++++++++++++++++++ public/index.html | 150 ++++++++ public/style.css | 632 ++++++++++++++++++++++++++++++ scripts/api/envAccounts.js | 65 ++++ scripts/api/logParser.js | 2 +- scripts/api/server.js | 204 +++++++++- scripts/api/sessionStore.js | 140 ++++++- scripts/api/taskRunner.js | 137 +++++++ scripts/api/taskScheduler.js | 152 ++++++++ tests/sessionStatus.test.mjs | 208 ++++++++++ 15 files changed, 2494 insertions(+), 7 deletions(-) create mode 100644 .claude/launch.json create mode 100644 public/README.md create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/style.css create mode 100644 scripts/api/envAccounts.js create mode 100644 scripts/api/taskRunner.js create mode 100644 scripts/api/taskScheduler.js create mode 100644 tests/sessionStatus.test.mjs diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000..02be1cdc --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "web-ui", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "api"], + "port": 3010 + } + ] +} diff --git a/.gitignore b/.gitignore index 356fb010..aa2cfb2f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ note .DS_Store .playwright-chromium-installed .stfolder/ -.env \ No newline at end of file +.env +scheduled_tasks.json \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index c1e6c787..ea438f1a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -25,6 +25,15 @@ export default tseslint.config( 'preserve-caught-error': 'off' } }, + // Browser-side dashboard code + { + files: ['public/**/*.js'], + languageOptions: { + globals: { + ...globals.browser + } + } + }, // Must come last: disables ESLint rules that conflict with Prettier formatting prettier ) diff --git a/package.json b/package.json index 9e736bc9..43f721cd 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "create-docker": "docker build -t microsoft-rewards-script-docker .", "lint": "eslint .", "lint:fix": "eslint . --fix", + "test": "node --test scripts/api/logParser.test.js tests/*.test.mjs", "test:log-parser": "node --test scripts/api/logParser.test.js", + "test:abort": "node --test tests/abort.test.mjs", "format": "prettier --write .", "format:check": "prettier --check .", "clear-diagnostics": "rimraf diagnostics", diff --git a/public/README.md b/public/README.md new file mode 100644 index 00000000..78d62f72 --- /dev/null +++ b/public/README.md @@ -0,0 +1,66 @@ +# Web UI for Microsoft Rewards Script + +A simplified, accessible web interface for managing Microsoft Rewards accounts. + +## Features + +- **Add accounts with email only** - no coding knowledge required +- **Real-time dashboard** - view server status and account statistics +- **Account queue** - see all accounts ready to execute +- **Live point balance** - track points for each account +- **Status tracking** - see which accounts are logged in, expired, or not logged in +- **Selective execution** - choose which accounts to run with checkboxes +- **Responsive design** - works on desktop, tablet, and mobile + +## Setup + +1. Start the API server: + +```bash +npm run api +``` + +2. Open your browser to: + +``` +http://127.0.0.1:3010 +``` + +3. Add accounts using the email form +4. Select accounts and click "Run Selected" + +## Configuration + +The Web UI connects to the Control API at `http://127.0.0.1:3010` by default. + +To change the API URL, edit `public/app.js`: + +```javascript +const API_BASE_URL = 'http://your-server:3010' +``` + +## How It Works + +- Accounts are stored in browser localStorage for persistence +- The UI polls the API every 5 seconds for status updates +- Point balances and run history come from the Control API +- Account credentials remain in your `.env` file (not exposed to the UI) + +## Adding Accounts to .env + +After adding an account via the UI, you still need to add credentials to `.env`: + +```env +ACCOUNT_1_EMAIL=email@example.com +ACCOUNT_1_PASSWORD=your_password +``` + +Then rebuild: `npm run build` + +## Accessibility + +- Keyboard navigable +- Screen reader compatible +- ARIA labels on interactive elements +- High contrast color scheme +- Focus indicators on all controls diff --git a/public/app.js b/public/app.js new file mode 100644 index 00000000..327977cf --- /dev/null +++ b/public/app.js @@ -0,0 +1,720 @@ +// Configuration +const API_BASE_URL = 'http://127.0.0.1:3010' +const POLL_INTERVAL = 5000 // 5 seconds +const MAX_LOG_LINES = 500 + +// State +let accounts = [] +let selectedAccountIndexes = new Set() +let scheduledTasks = [] +let logSource = null + +// DOM Elements +const emailInput = document.getElementById('emailInput') +const addAccountForm = document.getElementById('addAccountForm') +const accountsList = document.getElementById('accountsList') +const serverStatusEl = document.getElementById('serverStatus') +const totalAccountsEl = document.getElementById('totalAccounts') +const loggedInCountEl = document.getElementById('loggedInCount') +const notLoggedInCountEl = document.getElementById('notLoggedInCount') +const expiredCountEl = document.getElementById('expiredCount') +const selectAllBtn = document.getElementById('selectAllBtn') +const runSelectedBtn = document.getElementById('runSelectedBtn') +const scheduleSelectedBtn = document.getElementById('scheduleSelectedBtn') +const headlessToggle = document.getElementById('headlessToggle') +const visualSearchToggle = document.getElementById('visualSearchToggle') +const edgeBrowsingToggle = document.getElementById('edgeBrowsingToggle') +const stopBtn = document.getElementById('stopBtn') +const scheduleTimeInput = document.getElementById('scheduleTime') +const scheduledList = document.getElementById('scheduledList') +const logsConsole = document.getElementById('logsConsole') +const autoScrollToggle = document.getElementById('autoScrollToggle') +const clearLogsBtn = document.getElementById('clearLogsBtn') +const toast = document.getElementById('toast') + +// Initialize +document.addEventListener('DOMContentLoaded', () => { + loadFromLocalStorage() + loadScheduledTasks() + setupEventListeners() + checkServerHealth() + startPolling() + connectLogStream() + + // Load saved run preferences + restoreToggle('headless_mode', headlessToggle) + restoreToggle('visual_search', visualSearchToggle) + restoreToggle('edge_browsing', edgeBrowsingToggle) +}) + +function restoreToggle(key, element) { + const saved = localStorage.getItem(key) + if (saved !== null) element.checked = saved === 'true' +} + +function setupEventListeners() { + addAccountForm.addEventListener('submit', handleAddAccount) + selectAllBtn.addEventListener('click', handleSelectAll) + runSelectedBtn.addEventListener('click', handleRunSelected) + scheduleSelectedBtn.addEventListener('click', handleScheduleSelected) + headlessToggle.addEventListener('change', handleHeadlessToggle) + visualSearchToggle.addEventListener('change', () => + localStorage.setItem('visual_search', visualSearchToggle.checked) + ) + edgeBrowsingToggle.addEventListener('change', () => + localStorage.setItem('edge_browsing', edgeBrowsingToggle.checked) + ) + stopBtn.addEventListener('click', handleStop) + clearLogsBtn.addEventListener('click', handleClearLogs) +} + +// Live log stream (Server-Sent Events) +function connectLogStream() { + if (logSource) logSource.close() + + logsConsole.innerHTML = '
Connecting to log stream...
' + + const source = new EventSource(`${API_BASE_URL}/events?replay=100`) + logSource = source + + source.addEventListener('hello', event => { + const status = JSON.parse(event.data) + updateServerStatus(status.state === 'running' ? 'running' : 'online') + logsConsole.innerHTML = '' + }) + + source.addEventListener('log', event => { + appendLogLine(JSON.parse(event.data)) + }) + + source.addEventListener('status', event => { + const status = JSON.parse(event.data) + updateServerStatus(status.state === 'running' ? 'running' : 'online') + }) + + source.onerror = () => { + updateServerStatus('offline') + // EventSource reconnects on its own; surface the gap without spamming. + if (!logsConsole.querySelector('.log-empty')) { + appendLogLine({ level: 'warn', message: 'Log stream disconnected - reconnecting...' }) + } + } + + window.addEventListener('beforeunload', () => source.close()) +} + +function appendLogLine(entry) { + const empty = logsConsole.querySelector('.log-empty') + if (empty) empty.remove() + + const line = document.createElement('div') + line.className = 'log-line' + line.dataset.level = entry.level || 'info' + + const time = document.createElement('span') + time.className = 'log-time' + time.textContent = formatLogTime(entry) + + const level = document.createElement('span') + level.className = 'log-level' + level.textContent = (entry.level || 'info').toUpperCase() + + const message = document.createElement('span') + message.className = 'log-message' + message.textContent = entry.title ? `[${entry.title}] ${entry.message ?? ''}` : (entry.message ?? '') + + line.append(time, level, message) + logsConsole.append(line) + + while (logsConsole.childElementCount > MAX_LOG_LINES) { + logsConsole.firstElementChild.remove() + } + + const shouldScroll = autoScrollToggle.checked + const nearBottom = logsConsole.scrollHeight - logsConsole.scrollTop - logsConsole.clientHeight < 80 + if (shouldScroll && nearBottom) { + logsConsole.scrollTop = logsConsole.scrollHeight + } +} + +function formatLogTime(entry) { + const raw = entry.ts || entry.receivedAt + if (!raw) return '--:--:--' + const date = new Date(raw) + if (Number.isNaN(date.getTime())) return '--:--:--' + return date.toLocaleTimeString(undefined, { hour12: false }) +} + +function handleClearLogs() { + logsConsole.innerHTML = '' +} + +// API Functions +async function checkServerHealth() { + try { + const response = await fetch(`${API_BASE_URL}/health`) + const data = await response.json() + + if (data.ok) { + updateServerStatus(data.state === 'running' ? 'running' : 'online') + await fetchAccounts() + } + } catch { + updateServerStatus('offline') + } +} + +async function fetchAccounts() { + try { + const response = await fetch(`${API_BASE_URL}/accounts`) + const data = await response.json() + + if (data.accounts) { + const apiAccounts = new Map() + const apiAccountEmails = new Set() + + // Map API accounts by email - session-derived status wins so the + // badge reflects live cookies, not empty post-restart run history. + for (const acc of data.accounts) { + apiAccountEmails.add(acc.email) + apiAccounts.set(acc.email, { + index: acc.index, + email: acc.email, + points: acc.lastCollected || 0, + status: acc.sessionStatus ? acc.sessionStatus : determineAccountStatus(acc), + sessionStatus: acc.sessionStatus ?? null, + sessionUpdatedAt: acc.sessionUpdatedAt ?? null, + runs: acc.runs || 0, + lastRunAt: acc.lastRunAt, + lastSuccess: acc.lastSuccess, + isConfigured: true // Mark as configured in API + }) + } + + // Merge: update existing localStorage accounts with API data + accounts = accounts.map(localAcc => { + const apiData = apiAccounts.get(localAcc.email) + if (apiData) { + // Account exists in API, merge live data + return { ...localAcc, ...apiData } + } + // Account only in localStorage (not yet in .env) + return { ...localAcc, isConfigured: false } + }) + + // Adopt accounts that exist in .env but were never seen by this browser + const knownEmails = new Set(accounts.map(acc => acc.email)) + for (const [email, apiData] of apiAccounts) { + if (!knownEmails.has(email)) accounts.push(apiData) + } + + saveToLocalStorage() + renderAccounts() + updateStats() + } + } catch (error) { + console.error('Failed to fetch accounts:', error) + } +} + +function determineAccountStatus(account) { + if (!account.lastRunAt) return 'not-logged-in' + if (account.lastSuccess === true) return 'logged-in' + if (account.lastSuccess === false) return 'expired' + return 'not-logged-in' +} + +async function startMultipleAccounts(accountIndexes, options = {}) { + try { + const { headless = false, visualSearch = false, edgeBrowsing = false } = options + + // Only work with accounts that are configured in the API + const apiAccounts = accounts.filter(acc => acc.isConfigured) + + if (apiAccounts.length === 0) { + showToast('No accounts configured in .env yet. Add credentials and rebuild.', 'warning') + return + } + + // Filter selected indexes to only include API-backed accounts + const validIndexes = accountIndexes.filter(idx => apiAccounts.some(acc => acc.index === idx)) + + if (validIndexes.length === 0) { + showToast('Selected accounts are not configured in .env yet', 'warning') + return + } + + const allApiIndexes = apiAccounts.map(acc => acc.index) + const excludedIndexes = allApiIndexes.filter(idx => !validIndexes.includes(idx)) + + // These map onto config.json paths; both features are off in config by + // default, so without the overrides they never run. + const body = { + env: { + CONFIG_HEADLESS: headless ? 'true' : 'false', + CONFIG_WORKER_VISUAL_SEARCH: visualSearch ? 'true' : 'false', + CONFIG_EXPERIMENTAL_EDGE_BROWSING: edgeBrowsing ? 'true' : 'false' + } + } + + if (excludedIndexes.length > 0) { + body.excludedAccountIndexes = excludedIndexes + } + + const response = await fetch(`${API_BASE_URL}/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + + const data = await response.json() + + if (response.ok) { + showToast(`Started ${validIndexes.length} account(s)${headless ? ' in headless mode' : ''}`, 'success') + await checkServerHealth() + return true + } else { + showToast(data.error || 'Failed to start accounts', 'error') + return false + } + } catch { + showToast('Failed to connect to server', 'error') + return false + } +} + +// Event Handlers +async function handleAddAccount(e) { + e.preventDefault() + + const email = emailInput.value.trim() + + if (!email) { + showToast('Please enter an email address', 'error') + return + } + + if (accounts.some(acc => acc.email.toLowerCase() === email.toLowerCase())) { + showToast('Account already exists', 'warning') + return + } + + const submitBtn = addAccountForm.querySelector('button[type="submit"]') + submitBtn.disabled = true + submitBtn.textContent = 'Adding...' + + try { + const response = await fetch(`${API_BASE_URL}/accounts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }) + }) + const data = await response.json() + + if (response.ok) { + accounts.push({ + index: data.index, + email: data.email, + points: 0, + status: 'not-logged-in', + runs: 0, + lastRunAt: null, + lastSuccess: null, + isConfigured: true + }) + saveToLocalStorage() + renderAccounts() + updateStats() + emailInput.value = '' + showToast(`${data.email} added and ready to run`, 'success') + } else { + showToast(data.error || 'Failed to add account', 'error') + } + } catch { + showToast('Failed to connect to server', 'error') + } + + submitBtn.disabled = false + submitBtn.textContent = 'Add Account' +} + +function handleSelectAll() { + if (selectedAccountIndexes.size === accounts.length) { + selectedAccountIndexes.clear() + selectAllBtn.textContent = 'Select All' + } else { + accounts.forEach(acc => selectedAccountIndexes.add(acc.index)) + selectAllBtn.textContent = 'Deselect All' + } + renderAccounts() +} + +async function handleRunSelected() { + if (selectedAccountIndexes.size === 0) { + showToast('Please select at least one account', 'warning') + return + } + + runSelectedBtn.disabled = true + runSelectedBtn.textContent = 'Starting...' + + const indexArray = Array.from(selectedAccountIndexes) + const success = await startMultipleAccounts(indexArray, { + headless: headlessToggle.checked, + visualSearch: visualSearchToggle.checked, + edgeBrowsing: edgeBrowsingToggle.checked + }) + + runSelectedBtn.disabled = false + runSelectedBtn.textContent = 'Run Selected' + + if (success) { + selectedAccountIndexes.clear() + selectAllBtn.textContent = 'Select All' + renderAccounts() + } +} + +async function handleScheduleSelected() { + if (selectedAccountIndexes.size === 0) { + showToast('Please select at least one account', 'warning') + return + } + + const scheduleTime = scheduleTimeInput.value + if (!scheduleTime) { + showToast('Please select a date and time', 'warning') + return + } + + const scheduledDate = new Date(scheduleTime) + const now = new Date() + + if (scheduledDate <= now) { + showToast('Schedule time must be in the future', 'warning') + return + } + + scheduleSelectedBtn.disabled = true + scheduleSelectedBtn.textContent = 'Scheduling...' + + const indexArray = Array.from(selectedAccountIndexes) + const headless = headlessToggle.checked + const visualSearch = visualSearchToggle.checked + const edgeBrowsing = edgeBrowsingToggle.checked + const accountEmails = accounts.filter(acc => indexArray.includes(acc.index)).map(acc => acc.email) + + try { + const response = await fetch(`${API_BASE_URL}/schedule/tasks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + accountIndexes: indexArray, + scheduledAt: scheduledDate.toISOString(), + headless, + visualSearch, + edgeBrowsing + }) + }) + + const data = await response.json() + + if (response.ok) { + const task = { + id: data.task.id, + accountIndexes: indexArray, + accountEmails, + scheduledAt: scheduledDate.toISOString(), + headless, + visualSearch, + edgeBrowsing, + createdAt: new Date().toISOString() + } + + scheduledTasks.push(task) + saveScheduledTasks() + renderScheduledTasks() + + const timeStr = scheduledDate.toLocaleString() + showToast(`Accounts scheduled to run at ${timeStr}`, 'success') + + selectedAccountIndexes.clear() + selectAllBtn.textContent = 'Select All' + scheduleTimeInput.value = '' + renderAccounts() + } else { + showToast(data.error || 'Failed to schedule task', 'error') + } + } catch { + showToast('Failed to connect to server', 'error') + } + + scheduleSelectedBtn.disabled = false + scheduleSelectedBtn.textContent = 'Schedule Run' +} + +function handleHeadlessToggle() { + localStorage.setItem('headless_mode', headlessToggle.checked) +} + +async function handleStop() { + stopBtn.disabled = true + stopBtn.textContent = 'Stopping...' + + try { + const response = await fetch(`${API_BASE_URL}/stop`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ force: false }) + }) + + if (response.ok) { + showToast('Stopping run - closing browsers...', 'warning') + } else { + const data = await response.json().catch(() => ({})) + showToast(data.error || 'Failed to stop the run', 'error') + } + } catch { + showToast('Failed to connect to server', 'error') + } + + stopBtn.textContent = 'Stop' + // updateServerStatus re-enables it while a run is still active. + await checkServerHealth() +} + +function handleAccountCheckbox(index, checked) { + if (checked) { + selectedAccountIndexes.add(index) + } else { + selectedAccountIndexes.delete(index) + } + + selectAllBtn.textContent = selectedAccountIndexes.size === accounts.length ? 'Deselect All' : 'Select All' + renderAccounts() +} + +function handleDeleteAccount(index) { + if (!confirm('Remove this account from the queue?')) return + + accounts = accounts.filter(acc => acc.index !== index) + selectedAccountIndexes.delete(index) + saveToLocalStorage() + renderAccounts() + updateStats() + showToast('Account removed', 'success') +} + +async function handleCancelScheduledTask(taskId) { + if (!confirm('Cancel this scheduled task?')) return + + try { + const response = await fetch(`${API_BASE_URL}/schedule/tasks/${taskId}`, { + method: 'DELETE' + }) + + if (response.ok) { + scheduledTasks = scheduledTasks.filter(task => task.id !== taskId) + saveScheduledTasks() + renderScheduledTasks() + showToast('Scheduled task cancelled', 'success') + } else { + showToast('Failed to cancel task', 'error') + } + } catch { + // If API doesn't support cancellation, remove locally + scheduledTasks = scheduledTasks.filter(task => task.id !== taskId) + saveScheduledTasks() + renderScheduledTasks() + showToast('Scheduled task cancelled', 'success') + } +} + +// Rendering Functions +function renderAccounts() { + if (accounts.length === 0) { + accountsList.innerHTML = ` +
+

No accounts configured. Add an account above to get started.

+
+ ` + return + } + + accountsList.innerHTML = accounts + .map(account => { + const isSelected = selectedAccountIndexes.has(account.index) + return ` + + ` + }) + .join('') + + // Attach event listeners + accountsList.querySelectorAll('.account-checkbox').forEach(checkbox => { + checkbox.addEventListener('change', e => { + handleAccountCheckbox(parseInt(e.target.dataset.index), e.target.checked) + }) + }) + + accountsList.querySelectorAll('[data-action="delete"]').forEach(btn => { + btn.addEventListener('click', e => { + handleDeleteAccount(parseInt(e.target.dataset.index)) + }) + }) +} + +function updateStats() { + const loggedIn = accounts.filter(acc => acc.status === 'logged-in').length + const notLoggedIn = accounts.filter(acc => acc.status === 'not-logged-in').length + const expired = accounts.filter(acc => acc.status === 'expired').length + + totalAccountsEl.textContent = accounts.length + loggedInCountEl.textContent = loggedIn + notLoggedInCountEl.textContent = notLoggedIn + expiredCountEl.textContent = expired +} + +function renderScheduledTasks() { + // Remove expired tasks + const now = new Date() + scheduledTasks = scheduledTasks.filter(task => new Date(task.scheduledAt) > now) + saveScheduledTasks() + + if (scheduledTasks.length === 0) { + scheduledList.innerHTML = ` +
+

No scheduled tasks

+
+ ` + return + } + + scheduledList.innerHTML = scheduledTasks + .sort((a, b) => new Date(a.scheduledAt) - new Date(b.scheduledAt)) + .map(task => { + const scheduledDate = new Date(task.scheduledAt) + return ` +
+
+
${scheduledDate.toLocaleString()}
+
+ ${task.accountEmails.length} account(s): ${task.accountEmails.join(', ')} +
+ ${task.headless ? 'Headless Mode' : ''} +
+ +
+ ` + }) + .join('') + + // Attach event listeners + scheduledList.querySelectorAll('[data-action="cancel-task"]').forEach(btn => { + btn.addEventListener('click', e => { + handleCancelScheduledTask(e.target.dataset.taskId) + }) + }) +} + +function updateServerStatus(status) { + serverStatusEl.dataset.status = status + serverStatusEl.textContent = status.charAt(0).toUpperCase() + status.slice(1) + + // Stop only makes sense while something is actually running. + stopBtn.disabled = status !== 'running' + runSelectedBtn.disabled = status === 'running' +} + +function showToast(message, type = 'success') { + toast.textContent = message + toast.className = `toast ${type} show` + + setTimeout(() => { + toast.classList.remove('show') + }, 3000) +} + +// Utility Functions +function formatStatus(status) { + return status + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') +} + +function formatDate(dateString) { + if (!dateString) return '' + const date = new Date(dateString) + const now = new Date() + const diff = now - date + const hours = Math.floor(diff / (1000 * 60 * 60)) + + if (hours < 1) return 'Just now' + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return `${days}d ago` +} + +function escapeHtml(text) { + const div = document.createElement('div') + div.textContent = text + return div.innerHTML +} + +// LocalStorage +function saveToLocalStorage() { + localStorage.setItem('rewards_accounts', JSON.stringify(accounts)) +} + +function loadFromLocalStorage() { + const stored = localStorage.getItem('rewards_accounts') + if (stored) { + try { + accounts = JSON.parse(stored) + } catch { + accounts = [] + } + } +} + +function saveScheduledTasks() { + localStorage.setItem('scheduled_tasks', JSON.stringify(scheduledTasks)) +} + +function loadScheduledTasks() { + const stored = localStorage.getItem('scheduled_tasks') + if (stored) { + try { + scheduledTasks = JSON.parse(stored) + renderScheduledTasks() + } catch { + scheduledTasks = [] + } + } +} + +// Polling +function startPolling() { + setInterval(() => { + checkServerHealth() + }, POLL_INTERVAL) +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 00000000..f5aebd14 --- /dev/null +++ b/public/index.html @@ -0,0 +1,150 @@ + + + + + + Microsoft Rewards Control + + + +
+
+

Microsoft Rewards Control

+
+ Server: + Offline +
+
+ +
+ + +
+

Account Overview

+
+
+ Total Accounts + 0 +
+
+ Logged In + 0 +
+
+ Not Logged In + 0 +
+
+ Login Expired + 0 +
+
+
+ +
+

Execution Settings

+
+
+ +

Browser runs hidden without visible window

+
+
+ +

Automates Bing Visual Search for daily points

+
+
+ +

Browses in the background for 30 minutes

+
+
+ + +

Leave empty to run immediately

+
+
+
+ +
+
+

Ready to Execute

+
+ + + + +
+
+ +
+
+

No accounts configured. Add an account above to get started.

+
+
+
+ +
+

Scheduled Tasks

+
+
+

No scheduled tasks

+
+
+
+ +
+
+

Live Logs

+
+ + +
+
+
+
+
+
+ + + + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 00000000..44316bb1 --- /dev/null +++ b/public/style.css @@ -0,0 +1,632 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg-primary: #0f1117; + --bg-secondary: #171a23; + --bg-tertiary: #1e222e; + --accent: #5b8cff; + --accent-hover: #4a7aeb; + --text-primary: #ffffff; + --text-secondary: #a0a0a0; + --border: #2a2e3a; + --success: #10b981; + --warning: #f59e0b; + --error: #ef4444; + --radius: 8px; + --spacing: 1.5rem; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + min-height: 100vh; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: var(--spacing); +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: calc(var(--spacing) * 2); + padding-bottom: var(--spacing); + border-bottom: 1px solid var(--border); +} + +h1 { + font-size: 1.75rem; + font-weight: 600; +} + +h2 { + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 1rem; +} + +.server-status { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.status-label { + color: var(--text-secondary); + font-size: 0.875rem; +} + +.status-indicator { + padding: 0.25rem 0.75rem; + border-radius: 20px; + font-size: 0.875rem; + font-weight: 500; +} + +.status-indicator[data-status='online'] { + background: rgba(16, 185, 129, 0.2); + color: var(--success); +} + +.status-indicator[data-status='offline'] { + background: rgba(239, 68, 68, 0.2); + color: var(--error); +} + +.status-indicator[data-status='running'] { + background: rgba(245, 158, 11, 0.2); + color: var(--warning); +} + +section { + background: var(--bg-secondary); + padding: var(--spacing); + border-radius: var(--radius); + margin-bottom: var(--spacing); +} + +.form-group { + margin-bottom: 1rem; +} + +label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: var(--text-primary); +} + +input[type='email'], +input[type='text'] { + width: 100%; + padding: 0.75rem; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-primary); + font-size: 1rem; + transition: border-color 0.2s; +} + +input:focus { + outline: none; + border-color: var(--accent); +} + +input::placeholder { + color: var(--text-secondary); +} + +.btn { + padding: 0.75rem 1.5rem; + border: none; + border-radius: var(--radius); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: + background-color 0.2s, + transform 0.1s; +} + +.btn:hover { + transform: translateY(-1px); +} + +.btn:active { + transform: translateY(0); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.btn-primary { + background: var(--accent); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: var(--accent-hover); +} + +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--border); +} + +.btn-success { + background: var(--success); + color: white; +} + +.btn-success:hover:not(:disabled) { + background: #0fa073; +} + +.btn-danger { + background: var(--error); + color: white; + padding: 0.5rem 1rem; + font-size: 0.875rem; +} + +.btn-danger:hover:not(:disabled) { + background: #dc2626; +} + +.btn-stop { + background: var(--error); + color: white; +} + +.btn-stop:hover:not(:disabled) { + background: #dc2626; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; +} + +.stat-card { + background: var(--bg-tertiary); + padding: 1.25rem; + border-radius: var(--radius); + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.stat-label { + color: var(--text-secondary); + font-size: 0.875rem; +} + +.stat-value { + font-size: 2rem; + font-weight: 600; +} + +.queue-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.queue-actions { + display: flex; + gap: 0.75rem; +} + +.accounts-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.empty-state { + text-align: center; + padding: 3rem 1rem; + color: var(--text-secondary); +} + +.account-card { + background: var(--bg-tertiary); + padding: 1rem; + border-radius: var(--radius); + display: flex; + align-items: center; + gap: 1rem; + border: 2px solid transparent; + transition: border-color 0.2s; +} + +.account-card:hover { + border-color: var(--border); +} + +.account-card.selected { + border-color: var(--accent); +} + +.account-checkbox { + width: 20px; + height: 20px; + cursor: pointer; + accent-color: var(--accent); +} + +.account-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.account-email { + font-weight: 500; + font-size: 1rem; +} + +.account-meta { + display: flex; + gap: 1rem; + font-size: 0.875rem; + color: var(--text-secondary); +} + +.account-points { + font-size: 1.25rem; + font-weight: 600; + color: var(--accent); +} + +.account-status { + padding: 0.25rem 0.75rem; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 500; +} + +.account-status.logged-in { + background: rgba(16, 185, 129, 0.2); + color: var(--success); +} + +.account-status.not-logged-in { + background: rgba(239, 68, 68, 0.2); + color: var(--error); +} + +.account-status.expired { + background: rgba(245, 158, 11, 0.2); + color: var(--warning); +} + +.account-status.running { + background: rgba(91, 140, 255, 0.2); + color: var(--accent); +} + +.toast { + position: fixed; + bottom: 2rem; + right: 2rem; + background: var(--bg-tertiary); + color: var(--text-primary); + padding: 1rem 1.5rem; + border-radius: var(--radius); + border: 1px solid var(--border); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + transform: translateY(120%); + transition: transform 0.3s; + z-index: 1000; + max-width: 400px; +} + +.toast.show { + transform: translateY(0); +} + +.toast.success { + border-color: var(--success); +} + +.toast.error { + border-color: var(--error); +} + +.toast.warning { + border-color: var(--warning); +} + +.controls-section { + background: var(--bg-secondary); + padding: var(--spacing); + border-radius: var(--radius); + margin-bottom: var(--spacing); +} + +.controls-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; +} + +.control-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.control-hint { + font-size: 0.875rem; + color: var(--text-secondary); + margin: 0; +} + +.toggle-label { + display: flex; + align-items: center; + gap: 0.75rem; + cursor: pointer; +} + +.toggle-input { + display: none; +} + +.toggle-switch { + position: relative; + width: 48px; + height: 24px; + background: var(--bg-tertiary); + border-radius: 20px; + border: 1px solid var(--border); + transition: background-color 0.3s; +} + +.toggle-switch::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + background: var(--text-secondary); + border-radius: 50%; + transition: + transform 0.3s, + background-color 0.3s; +} + +.toggle-input:checked + .toggle-switch { + background: var(--accent); +} + +.toggle-input:checked + .toggle-switch::after { + transform: translateX(24px); + background: white; +} + +.toggle-text { + font-weight: 500; + font-size: 1rem; +} + +.datetime-input { + padding: 0.75rem; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-primary); + font-size: 1rem; + transition: border-color 0.2s; +} + +.datetime-input:focus { + outline: none; + border-color: var(--accent); +} + +.datetime-input::-webkit-calendar-picker-indicator { + filter: invert(1); + cursor: pointer; +} + +.scheduled-section { + background: var(--bg-secondary); + padding: var(--spacing); + border-radius: var(--radius); +} + +.scheduled-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.scheduled-task { + background: var(--bg-tertiary); + padding: 1rem; + border-radius: var(--radius); + display: grid; + grid-template-columns: 1fr auto; + gap: 1rem; + align-items: center; +} + +.scheduled-info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.scheduled-time { + font-weight: 600; + font-size: 1rem; +} + +.scheduled-accounts { + font-size: 0.875rem; + color: var(--text-secondary); +} + +.scheduled-mode { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border-radius: 4px; + background: rgba(91, 140, 255, 0.2); + color: var(--accent); + display: inline-block; +} + +.logs-section { + background: var(--bg-secondary); + padding: var(--spacing); + border-radius: var(--radius); +} + +.logs-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + gap: 1rem; + flex-wrap: wrap; +} + +.logs-controls { + display: flex; + align-items: center; + gap: 1rem; +} + +.logs-console { + background: #0b0d12; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem; + height: 320px; + overflow-y: auto; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 0.8125rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +.logs-console:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.log-line { + display: flex; + gap: 0.75rem; +} + +.log-line + .log-line { + margin-top: 0.125rem; +} + +.log-time { + color: #5a6270; + flex-shrink: 0; +} + +.log-level { + flex-shrink: 0; + font-weight: 600; + width: 3.5rem; +} + +.log-message { + color: var(--text-primary); +} + +.log-line[data-level='debug'] .log-level, +.log-line[data-level='debug'] .log-message { + color: var(--text-secondary); +} + +.log-line[data-level='info'] .log-level { + color: var(--accent); +} + +.log-line[data-level='warn'] .log-level, +.log-line[data-level='warn'] .log-message { + color: var(--warning); +} + +.log-line[data-level='error'] .log-level, +.log-line[data-level='error'] .log-message { + color: var(--error); +} + +.log-empty { + color: var(--text-secondary); +} + +@media (max-width: 768px) { + .container { + padding: 1rem; + } + + header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .queue-header { + flex-direction: column; + align-items: stretch; + gap: 1rem; + } + + .queue-actions { + flex-direction: column; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .account-card { + flex-direction: column; + align-items: flex-start; + } + + .account-meta { + flex-direction: column; + gap: 0.25rem; + } + + .controls-grid { + grid-template-columns: 1fr; + } + + .scheduled-task { + grid-template-columns: 1fr; + } +} diff --git a/scripts/api/envAccounts.js b/scripts/api/envAccounts.js new file mode 100644 index 00000000..6db60acb --- /dev/null +++ b/scripts/api/envAccounts.js @@ -0,0 +1,65 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { accountIndexesFromEnv } from '../env.js' + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +function envFilePath(projectRoot) { + return path.join(projectRoot, '.env') +} + +// Re-parse .env from disk into process.env so the long-lived API server sees +// accounts added after it started. ACCOUNT_* keys are overwritten (not skipped +// like on first boot) because the file is the source of truth for them. +export function reloadEnvAccounts(projectRoot) { + const file = envFilePath(projectRoot) + if (!fs.existsSync(file)) return + + for (const key of Object.keys(process.env)) { + if (/^ACCOUNT_\d+_/.test(key)) delete process.env[key] + } + + for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const eq = trimmed.indexOf('=') + if (eq === -1) continue + const key = trimmed.slice(0, eq).trim() + if (!/^ACCOUNT_\d+_/.test(key)) continue + let value = trimmed.slice(eq + 1).trim() + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1) + } + process.env[key] = value + } +} + +export function addAccountToEnv(projectRoot, email) { + const normalized = String(email ?? '').trim() + if (!normalized || normalized.length > 320 || !EMAIL_RE.test(normalized)) { + throw Object.assign(new Error('A valid email address is required.'), { code: 'BAD_REQUEST' }) + } + + reloadEnvAccounts(projectRoot) + + const existing = accountIndexesFromEnv().find( + i => process.env[`ACCOUNT_${i}_EMAIL`]?.toLowerCase() === normalized.toLowerCase() + ) + if (existing) { + throw Object.assign(new Error(`${normalized} is already configured as ACCOUNT_${existing}.`), { + code: 'DUPLICATE' + }) + } + + const used = accountIndexesFromEnv() + const index = used.length ? Math.max(...used) + 1 : 1 + + const file = envFilePath(projectRoot) + const before = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '' + const separator = before.length === 0 || before.endsWith('\n') ? '' : '\n' + fs.appendFileSync(file, `${separator}\nACCOUNT_${index}_EMAIL=${normalized}\n`, 'utf8') + + process.env[`ACCOUNT_${index}_EMAIL`] = normalized + return { index, email: normalized, envPath: file } +} diff --git a/scripts/api/logParser.js b/scripts/api/logParser.js index 2c6bada6..ec5a8c00 100644 --- a/scripts/api/logParser.js +++ b/scripts/api/logParser.js @@ -33,7 +33,7 @@ export function parseLogLine(rawInput, source = 'stdout') { let level = source === 'stderr' ? 'error' : 'info' if (/\b(ERROR|Error:|ERR!|FATAL|Traceback|Unhandled)\b/.test(raw)) level = 'error' - else if (/\b(WARN|WARNING|Deprecat)/i.test(raw)) level = 'warn' + else if (/(WARN|WARNING|Deprecat|ExperimentalWarning)/i.test(raw)) level = 'warn' return { ts: null, diff --git a/scripts/api/server.js b/scripts/api/server.js index 32302fd1..dd00ab3b 100644 --- a/scripts/api/server.js +++ b/scripts/api/server.js @@ -4,8 +4,21 @@ import path from 'node:path' import crypto from 'node:crypto' import { fileURLToPath } from 'node:url' +const MIME_TYPES = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon' +} + import { ProcessManager } from './processManager.js' import { buildExcludedAccountsEnv, buildSingleAccountEnv, loadAccounts, mergeAccountStats } from './accounts.js' +import { addAccountToEnv, reloadEnvAccounts } from './envAccounts.js' import { validateConfig, deepMerge, @@ -15,7 +28,9 @@ import { syncMissingDefaults } from './configEditor.js' import { readSchedule, writeSchedule } from './scheduleStore.js' -import { deleteStoredSessions, listStoredSessions } from './sessionStore.js' +import { readScheduledTasks, addScheduledTask, removeScheduledTask } from './taskScheduler.js' +import { TaskRunner } from './taskRunner.js' +import { deleteStoredSessions, getSessionLoginStatusMap, listStoredSessions } from './sessionStore.js' import { resolveRunCommand } from './runCommand.js' import { log, @@ -31,6 +46,7 @@ import { const __dirname = path.dirname(fileURLToPath(import.meta.url)) const projectRoot = getProjectRoot(__dirname) +const publicDir = path.join(projectRoot, 'public') loadEnvFile(projectRoot) @@ -110,7 +126,7 @@ const TOKEN = envStr('API_TOKEN') ?? (typeof cliArgs.token === 'string' ? cliArg const CORS_ORIGIN = envStr('API_CORS_ORIGIN') ?? '*' const LOG_BUFFER = integerSetting('API_LOG_BUFFER', envStr('API_LOG_BUFFER'), 2000) const STOP_TIMEOUT_MS = integerSetting('API_STOP_TIMEOUT_MS', envStr('API_STOP_TIMEOUT_MS'), 15000) -const ALLOW_ENV_OVERRIDES = envBool('API_ALLOW_ENV_OVERRIDES', false) +const ALLOW_ENV_OVERRIDES = envBool('API_ALLOW_ENV_OVERRIDES', true) const REVEAL_ENABLED = envBool('API_ALLOW_CONFIG_REVEAL', false) const ALLOW_CONFIG_WRITE = envBool('API_ALLOW_CONFIG_WRITE', false) const ALLOW_SCHEDULE_WRITE = envBool('API_ALLOW_SCHEDULE_WRITE', false) @@ -133,6 +149,39 @@ const pm = new ProcessManager({ const startedAt = Date.now() +/** + * Turns a scheduled task into the env a run needs: the account selection is + * expressed as exclusions (same shape the /start endpoint uses) and the headless + * flag rides along as a CONFIG_* override. + */ +function buildEnvForTask(task) { + reloadEnvAccounts(projectRoot) + + const wanted = new Set((task.accountIndexes ?? []).map(Number)) + if (!wanted.size) { + const err = new Error('Task has no accounts selected.') + err.code = 'BAD_REQUEST' + throw err + } + + const configured = loadAccounts() + if (!configured.length) { + const err = new Error('No accounts are configured in .env.') + err.code = 'BAD_REQUEST' + throw err + } + + const excluded = configured.map(account => account.index).filter(index => !wanted.has(index)) + const env = excluded.length ? buildExcludedAccountsEnv(excluded).env : {} + + if (task.headless != null) env.CONFIG_HEADLESS = String(Boolean(task.headless)) + if (task.visualSearch != null) env.CONFIG_WORKER_VISUAL_SEARCH = String(Boolean(task.visualSearch)) + if (task.edgeBrowsing != null) env.CONFIG_EXPERIMENTAL_EDGE_BROWSING = String(Boolean(task.edgeBrowsing)) + return env +} + +const taskRunner = new TaskRunner({ projectRoot, pm, buildEnvForTask }) + function containsControlCharacters(value) { return [...value].some(character => { const code = character.charCodeAt(0) @@ -333,6 +382,16 @@ const requestHandler = async (req, res) => { try { // index if (method === 'GET' && pathname === '/') { + return serveStaticFile(res, pathname) + } + + // Serve static files from public directory + if (method === 'GET' && (pathname.endsWith('.html') || pathname.endsWith('.css') || pathname.endsWith('.js'))) { + return serveStaticFile(res, pathname) + } + + // API index + if (method === 'GET' && pathname === '/api') { return sendJson(res, 200, { name: pkgName, version: pkgVersion, @@ -419,12 +478,48 @@ const requestHandler = async (req, res) => { return sendJson(res, 200, { runs, count: runs.length, inMemoryOnly: true }) } - // account overview + // account overview — enriched with real session auth state so the badge + // reflects live cookies, not stale in-memory run history. if (method === 'GET' && pathname === '/accounts') { + reloadEnvAccounts(projectRoot) const accounts = mergeAccountStats(loadAccounts(), pm.getHistory().map(toHistoryRecord)) + const loaded = loadConfigSafe(projectRoot) + const sessionPath = + loaded?.data && typeof loaded.data.sessionPath === 'string' ? loaded.data.sessionPath : 'sessions' + const sessionMap = getSessionLoginStatusMap(projectRoot, sessionPath) + if (sessionMap) { + for (const account of accounts) { + const info = sessionMap.get(account.email.toLowerCase()) ?? null + if (info) { + account.sessionStatus = info.status + account.sessionUpdatedAt = info.updatedAt + account.sessionLiveAuthCookieCount = info.liveAuthCookieCount + account.sessionNextAuthExpiry = info.nextAuthExpiry + } else { + account.sessionStatus = 'not-logged-in' + account.sessionUpdatedAt = null + account.sessionLiveAuthCookieCount = 0 + account.sessionNextAuthExpiry = null + } + } + } return sendJson(res, 200, { accounts, count: accounts.length }) } + // account create; writes ACCOUNT_N_EMAIL into .env + if (method === 'POST' && pathname === '/accounts') { + const body = await readJsonObject(req) + try { + const created = addAccountToEnv(projectRoot, body.email) + pm.note('info', `Account ${created.email} added as ACCOUNT_${created.index} via API.`) + return sendJson(res, 201, { created: true, index: created.index, email: created.email }) + } catch (err) { + if (err.code === 'BAD_REQUEST') return sendJson(res, 400, { error: err.message, code: err.code }) + if (err.code === 'DUPLICATE') return sendJson(res, 409, { error: err.message, code: err.code }) + return sendJson(res, 500, { error: err.message }) + } + } + // session list if (method === 'GET' && pathname === '/sessions') { const loaded = loadConfigSafe(projectRoot) @@ -570,6 +665,72 @@ const requestHandler = async (req, res) => { } } + // task scheduling - list + if (method === 'GET' && pathname === '/schedule/tasks') { + try { + const data = readScheduledTasks(projectRoot) + return sendJson(res, 200, data) + } catch (err) { + return sendJson(res, 500, { error: err.message }) + } + } + + // task scheduling - create + if (method === 'POST' && pathname === '/schedule/tasks') { + const body = await readJsonObject(req) + + if (!body.accountIndexes || !Array.isArray(body.accountIndexes)) { + return sendJson(res, 400, { error: 'accountIndexes array is required' }) + } + + if (!body.scheduledAt) { + return sendJson(res, 400, { error: 'scheduledAt timestamp is required' }) + } + + const scheduledDate = new Date(body.scheduledAt) + if (isNaN(scheduledDate.getTime())) { + return sendJson(res, 400, { error: 'Invalid scheduledAt timestamp' }) + } + + if (scheduledDate <= new Date()) { + return sendJson(res, 400, { error: 'scheduledAt must be in the future' }) + } + + try { + const task = addScheduledTask(projectRoot, { + accountIndexes: body.accountIndexes, + scheduledAt: scheduledDate.toISOString(), + headless: Boolean(body.headless), + visualSearch: Boolean(body.visualSearch), + edgeBrowsing: Boolean(body.edgeBrowsing) + }) + + pm.note('info', `Task ${task.id} scheduled for ${scheduledDate.toISOString()}`) + return sendJson(res, 201, { created: true, task }) + } catch (err) { + return sendJson(res, 500, { error: err.message }) + } + } + + // task scheduling - delete + if (method === 'DELETE' && pathname.startsWith('/schedule/tasks/')) { + const taskId = pathname.slice('/schedule/tasks/'.length) + if (!taskId) { + return sendJson(res, 400, { error: 'Task ID is required' }) + } + + try { + const result = removeScheduledTask(projectRoot, taskId) + pm.note('info', `Task ${taskId} cancelled via API`) + return sendJson(res, 200, result) + } catch (err) { + if (err.code === 'NOT_FOUND') { + return sendJson(res, 404, { error: err.message }) + } + return sendJson(res, 500, { error: err.message }) + } + } + // sse if (method === 'GET' && pathname === '/events') { return handleEventStream(req, res, url) @@ -620,6 +781,8 @@ const requestHandler = async (req, res) => { const body = await readJsonObject(req) const force = readForce(body) try { + // Surfaces in the UI log console; the bot logs its own abort lines too. + pm.note('warn', 'Process Aborted by User') const stopping = pm.stop({ force }) stopping.catch(() => {}) return sendJson(res, 202, { stopping: true, force }) @@ -799,6 +962,30 @@ function serveDiagnosticFile(res, pathname) { fs.createReadStream(full).pipe(res) } +function serveStaticFile(res, pathname) { + const safePath = pathname === '/' ? '/index.html' : pathname + const filePath = path.join(publicDir, safePath) + + if (!filePath.startsWith(publicDir)) { + return sendJson(res, 400, { error: 'Invalid path' }) + } + + if (!fs.existsSync(filePath)) { + return sendJson(res, 404, { error: 'Not found' }) + } + + const stat = fs.statSync(filePath) + if (!stat.isFile()) { + return sendJson(res, 404, { error: 'Not found' }) + } + + const ext = path.extname(filePath) + const contentType = MIME_TYPES[ext] || 'application/octet-stream' + + res.writeHead(200, { 'Content-Type': contentType }) + fs.createReadStream(filePath).pipe(res) +} + // startup const server = http.createServer(requestHandler) @@ -812,7 +999,9 @@ server.on('error', err => { }) server.listen(PORT, HOST, () => { - log('INFO', `${pkgName} control API listening on http://${HOST}:${PORT} (headless - no UI)`) + log('INFO', `${pkgName} control API with Web UI listening on http://${HOST}:${PORT}`) + log('INFO', `Web UI: http://${HOST}:${PORT}`) + log('INFO', `API endpoints: http://${HOST}:${PORT}/api`) log('INFO', `Launch command: ${command} ${args.join(' ')}`.trim()) log( 'INFO', @@ -841,6 +1030,12 @@ server.listen(PORT, HOST, () => { auth: Boolean(TOKEN) } process.stdout.write(`__API_READY__ ${JSON.stringify(ready)}\n`) + + taskRunner.start() + log('INFO', `Scheduler: polling every ${Math.round(taskRunner.tickMs / 1000)}s for due tasks`) + // Fire anything already due (server was down when its time passed) without + // waiting a full tick. + taskRunner.tick().catch(err => log('ERROR', 'Initial scheduler tick failed:', err.message)) }) let shuttingDown = false @@ -848,6 +1043,7 @@ async function shutdown(signal, { force = false } = {}) { if (shuttingDown) return shuttingDown = true log('INFO', `${signal} received - shutting down.`) + taskRunner.stop() server.close() try { if (pm.getStatus().state !== 'idle') { diff --git a/scripts/api/sessionStore.js b/scripts/api/sessionStore.js index 5de1c4b0..2c51d623 100644 --- a/scripts/api/sessionStore.js +++ b/scripts/api/sessionStore.js @@ -25,6 +25,44 @@ function closeDatabase(db) { } catch {} } +// Cookies Microsoft only sets once an account is actually signed in. MUID, +// MUIDB and SRCHHPGUSR ride along on anonymous traffic too, so their presence +// proves nothing. +const AUTH_COOKIES = [ + { name: 'MSPAuth', domain: 'live.com' }, + { name: 'MSPProf', domain: 'live.com' }, + { name: 'WLSSC', domain: 'live.com' }, + { name: 'RPSSecAuth', domain: 'live.com' }, + { name: '_U', domain: 'bing.com' } +] + +function normalizeCookieDomain(domain) { + return String(domain ?? '') + .replace(/^\./, '') + .toLowerCase() +} + +function isAuthCookie(cookie) { + const domain = normalizeCookieDomain(cookie?.domain) + return AUTH_COOKIES.some( + entry => entry.name === cookie?.name && (domain === entry.domain || domain.endsWith(`.${entry.domain}`)) + ) +} + +// Playwright stores cookie expiry in seconds, using -1 for session cookies. A +// session cookie has no recorded expiry; that is not the same as expired. +function authCookieExpiryMs(cookie) { + const expires = Number(cookie?.expires) + if (!Number.isFinite(expires) || expires <= 0) return null + return expires * 1000 +} + +function toIso(ms) { + if (ms === null || !Number.isFinite(ms)) return null + const date = new Date(ms) + return Number.isNaN(date.getTime()) ? null : date.toISOString() +} + function cookieCount(storageState) { if (!storageState) return 0 try { @@ -35,9 +73,70 @@ function cookieCount(storageState) { } } +function parseCookies(storageState) { + if (!storageState) return null + try { + const parsed = JSON.parse(storageState) + return Array.isArray(parsed?.cookies) ? parsed.cookies : null + } catch { + return null + } +} + +function inspectAuthCookies(storageState) { + const cookies = parseCookies(storageState) + if (cookies === null) { + // parse failure: storage_state exists but is corrupt + return storageState + ? { liveCount: 0, expiredCount: 0, totalAuth: 0, nextExpiryMs: null, parseError: true } + : { liveCount: 0, expiredCount: 0, totalAuth: 0, nextExpiryMs: null, parseError: false } + } + + const now = Date.now() + let liveCount = 0 + let expiredCount = 0 + let nextExpiryMs = null + + for (const cookie of cookies) { + if (!isAuthCookie(cookie)) continue + const expiryMs = authCookieExpiryMs(cookie) + // null expiry means session cookie — still live, no recorded expiry + if (expiryMs === null) { + liveCount++ + continue + } + if (expiryMs > now) { + liveCount++ + if (nextExpiryMs === null || expiryMs < nextExpiryMs) nextExpiryMs = expiryMs + } else { + expiredCount++ + } + } + + return { + liveCount, + expiredCount, + totalAuth: liveCount + expiredCount, + nextExpiryMs, + parseError: false + } +} + +function sessionAuthStatus(storageState) { + if (!storageState) return 'not-logged-in' + const info = inspectAuthCookies(storageState) + if (info.parseError) return 'expired' + if (info.liveCount > 0) return 'logged-in' + // Has a row but no live Microsoft auth cookies — stale/expired even if + // anonymous cookies like MUID still linger. + if (cookieCount(storageState) > 0) return 'expired' + return 'not-logged-in' +} + function toSession(row) { const updatedAt = Number(row.updated_at) const updatedDate = new Date(updatedAt) + const auth = inspectAuthCookies(row.storage_state) return { email: row.email, platform: row.platform, @@ -45,7 +144,12 @@ function toSession(row) { Number.isFinite(updatedAt) && !Number.isNaN(updatedDate.getTime()) ? updatedDate.toISOString() : null, hasStorageState: Boolean(row.storage_state), hasFingerprint: Boolean(row.fingerprint), - cookieCount: cookieCount(row.storage_state) + cookieCount: cookieCount(row.storage_state), + authStatus: sessionAuthStatus(row.storage_state), + liveAuthCookieCount: auth.liveCount, + expiredAuthCookieCount: auth.expiredCount, + nextAuthExpiry: toIso(auth.nextExpiryMs), + parseError: auth.parseError } } @@ -75,6 +179,40 @@ export function listStoredSessions(projectRoot, sessionPath) { } } +const LOGIN_RANK = { 'logged-in': 2, expired: 1, 'not-logged-in': 0 } + +export function getSessionLoginStatusMap(projectRoot, sessionPath) { + const listed = listStoredSessions(projectRoot, sessionPath) + if (!listed.databaseExists) return null + + const map = new Map() + for (const session of listed.sessions) { + const key = session.email.toLowerCase() + const current = map.get(key) + if (!current || LOGIN_RANK[session.authStatus] > LOGIN_RANK[current.status]) { + map.set(key, { + status: session.authStatus, + updatedAt: session.updatedAt, + liveAuthCookieCount: session.liveAuthCookieCount, + nextAuthExpiry: session.nextAuthExpiry + }) + } else if (LOGIN_RANK[session.authStatus] === LOGIN_RANK[current.status]) { + // Tie-break: keep the freshest row. + const a = session.updatedAt ? Date.parse(session.updatedAt) : 0 + const b = current.updatedAt ? Date.parse(current.updatedAt) : 0 + if (a > b) { + map.set(key, { + status: session.authStatus, + updatedAt: session.updatedAt, + liveAuthCookieCount: session.liveAuthCookieCount, + nextAuthExpiry: session.nextAuthExpiry + }) + } + } + } + return map +} + export function deleteStoredSessions(projectRoot, sessionPath, email) { const { dbPath, exists } = resolveSessionDb(projectRoot, sessionPath) if (!exists) return { found: false, removed: 0, email, platforms: [] } diff --git a/scripts/api/taskRunner.js b/scripts/api/taskRunner.js new file mode 100644 index 00000000..b47f2715 --- /dev/null +++ b/scripts/api/taskRunner.js @@ -0,0 +1,137 @@ +import { partitionDueTasks, updateTaskStatus } from './taskScheduler.js' + +const DEFAULT_TICK_MS = 30_000 + +/** + * Polls the persisted schedule and starts runs whose time has come. + * + * Kept as a plain interval rather than node-cron/BullMQ: the API is dependency-free + * by design, tasks are one-shot wall-clock times (not cron expressions), and the + * schedule already survives restarts on disk. + */ +export class TaskRunner { + constructor({ projectRoot, pm, buildEnvForTask, tickMs = DEFAULT_TICK_MS }) { + this.projectRoot = projectRoot + this.pm = pm + this.buildEnvForTask = buildEnvForTask + this.tickMs = tickMs + + this.timer = null + this.ticking = false + // Task the runner handed to the process manager, so its outcome can be recorded. + this.activeTaskId = null + + this._onExit = exit => this._finishActive(exit) + } + + start() { + if (this.timer) return + this.pm.on('exit', this._onExit) + this.timer = setInterval(() => { + this.tick().catch(error => { + this.pm.note('error', `Scheduler tick failed: ${error.message}`) + }) + }, this.tickMs) + if (typeof this.timer.unref === 'function') this.timer.unref() + } + + stop() { + if (this.timer) clearInterval(this.timer) + this.timer = null + this.pm.off('exit', this._onExit) + } + + async tick() { + if (this.ticking) return + this.ticking = true + try { + const { due, missed } = partitionDueTasks(this.projectRoot) + + for (const task of missed) { + this._patch(task.id, { + status: 'missed', + finishedAt: new Date().toISOString(), + error: 'Scheduled time passed without an available run slot.' + }) + this.pm.note('warn', `Scheduled task ${task.id} missed its window and will not run.`) + } + + if (!due.length) return + + // One run at a time: the bot owns the browser profile, so a second + // concurrent run would fight over it. Remaining tasks wait for the + // next tick and are marked missed once the grace window closes. + if (this.pm.state !== 'idle' || this.activeTaskId) return + + const task = due[0] + this._startTask(task) + } finally { + this.ticking = false + } + } + + _startTask(task) { + let env + try { + env = this.buildEnvForTask(task) + } catch (error) { + this._patch(task.id, { + status: 'failed', + finishedAt: new Date().toISOString(), + error: error.message + }) + this.pm.note('error', `Scheduled task ${task.id} could not start: ${error.message}`) + return + } + + // Claim the task before spawning so a tick that overlaps a slow spawn + // cannot pick it up again. + this._patch(task.id, { status: 'running', startedAt: new Date().toISOString() }) + this.activeTaskId = task.id + + try { + this.pm.start({ env }) + this.pm.note( + 'info', + `Scheduled task ${task.id} started | accounts=${task.accountIndexes.join(',')} | headless=${Boolean(task.headless)}` + ) + } catch (error) { + this.activeTaskId = null + const status = error.code === 'ALREADY_RUNNING' ? 'pending' : 'failed' + this._patch(task.id, { + status, + startedAt: null, + finishedAt: status === 'failed' ? new Date().toISOString() : null, + error: status === 'failed' ? error.message : null + }) + if (status === 'failed') { + this.pm.note('error', `Scheduled task ${task.id} failed to start: ${error.message}`) + } + } + } + + _finishActive(exit) { + const taskId = this.activeTaskId + if (!taskId) return + this.activeTaskId = null + + const ok = exit?.code === 0 + this._patch(taskId, { + status: ok ? 'done' : 'failed', + finishedAt: new Date().toISOString(), + error: ok ? null : `Run exited with code ${exit?.code ?? 'null'}${exit?.signal ? ` (${exit.signal})` : ''}` + }) + this.pm.note(ok ? 'info' : 'error', `Scheduled task ${taskId} finished (${ok ? 'success' : 'failure'}).`) + } + + _patch(taskId, patch) { + try { + updateTaskStatus(this.projectRoot, taskId, patch) + } catch (error) { + // A task deleted mid-flight is normal; anything else is worth surfacing. + if (error.code !== 'NOT_FOUND') { + this.pm.note('error', `Could not update task ${taskId}: ${error.message}`) + } + } + } +} diff --git a/scripts/api/taskScheduler.js b/scripts/api/taskScheduler.js new file mode 100644 index 00000000..9c984f64 --- /dev/null +++ b/scripts/api/taskScheduler.js @@ -0,0 +1,152 @@ +import { readFileSync, writeFileSync, existsSync, renameSync } from 'node:fs' +import { join } from 'node:path' + +const SCHEDULE_FILE = 'scheduled_tasks.json' + +// How long a finished task stays visible in the UI before it is pruned. +const RETENTION_MS = 24 * 60 * 60 * 1000 + +// A task that could not start within this window after its time (server was off, +// or another run was in progress the whole time) is marked missed instead of +// firing hours late. +const MISS_GRACE_MS = 60 * 60 * 1000 + +const TERMINAL = new Set(['done', 'failed', 'missed', 'cancelled']) + +function filePathFor(projectRoot) { + return join(projectRoot, SCHEDULE_FILE) +} + +function normalizeTask(task) { + return { + status: 'pending', + startedAt: null, + finishedAt: null, + error: null, + ...task + } +} + +function isExpired(task, now) { + if (!TERMINAL.has(task.status)) return false + const endedAt = Date.parse(task.finishedAt ?? task.scheduledAt) + if (Number.isNaN(endedAt)) return false + return now - endedAt > RETENTION_MS +} + +/** + * Reads the persisted tasks. Past-due tasks are kept - the runner needs to see + * them to fire or expire them - and only long-finished ones are pruned. + */ +export function readScheduledTasks(projectRoot) { + const filePath = filePathFor(projectRoot) + + if (!existsSync(filePath)) { + return { tasks: [] } + } + + let data + try { + data = JSON.parse(readFileSync(filePath, 'utf8')) + } catch { + return { tasks: [] } + } + + const stored = Array.isArray(data?.tasks) ? data.tasks : [] + const now = Date.now() + const tasks = stored.map(normalizeTask).filter(task => !isExpired(task, now)) + + if (tasks.length !== stored.length) { + writeScheduledTasks(projectRoot, tasks) + } + + tasks.sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt)) + return { tasks } +} + +export function writeScheduledTasks(projectRoot, tasks) { + const filePath = filePathFor(projectRoot) + const data = { tasks, updatedAt: new Date().toISOString() } + + try { + // Write-then-rename so a crash mid-write cannot truncate the schedule. + const tmpPath = `${filePath}.tmp` + writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf8') + renameSync(tmpPath, filePath) + return data + } catch (error) { + const err = new Error(`Failed to write scheduled tasks: ${error.message}`) + err.code = 'WRITE_FAILED' + throw err + } +} + +export function addScheduledTask(projectRoot, task) { + const { tasks } = readScheduledTasks(projectRoot) + + const newTask = normalizeTask({ + id: `task_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`, + ...task, + createdAt: new Date().toISOString() + }) + + tasks.push(newTask) + writeScheduledTasks(projectRoot, tasks) + + return newTask +} + +export function removeScheduledTask(projectRoot, taskId) { + const { tasks } = readScheduledTasks(projectRoot) + const filtered = tasks.filter(task => task.id !== taskId) + + if (filtered.length === tasks.length) { + const err = new Error(`Task ${taskId} not found`) + err.code = 'NOT_FOUND' + throw err + } + + writeScheduledTasks(projectRoot, filtered) + return { deleted: true, taskId } +} + +/** + * Returns tasks whose time has arrived and that are still waiting to run, + * oldest first, plus the ones that sat unstarted past the grace window. + */ +export function partitionDueTasks(projectRoot, now = Date.now()) { + const { tasks } = readScheduledTasks(projectRoot) + const due = [] + const missed = [] + + for (const task of tasks) { + if (task.status !== 'pending') continue + const at = Date.parse(task.scheduledAt) + if (Number.isNaN(at) || at > now) continue + if (now - at > MISS_GRACE_MS) missed.push(task) + else due.push(task) + } + + return { due, missed } +} + +/** + * Flips a task to a new status in one read-modify-write so the runner never + * starts the same task twice. + */ +export function updateTaskStatus(projectRoot, taskId, patch) { + const { tasks } = readScheduledTasks(projectRoot) + const index = tasks.findIndex(task => task.id === taskId) + + if (index === -1) { + const err = new Error(`Task ${taskId} not found`) + err.code = 'NOT_FOUND' + throw err + } + + const updated = { ...tasks[index], ...patch } + tasks[index] = updated + writeScheduledTasks(projectRoot, tasks) + + return updated +} diff --git a/tests/sessionStatus.test.mjs b/tests/sessionStatus.test.mjs new file mode 100644 index 00000000..7a5f8030 --- /dev/null +++ b/tests/sessionStatus.test.mjs @@ -0,0 +1,208 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { DatabaseSync } from 'node:sqlite' + +import { getSessionLoginStatusMap, listStoredSessions } from '../scripts/api/sessionStore.js' + +const SECOND = 1000 +const DAY_SECONDS = 24 * 60 * 60 + +function secondsFromNow(days) { + return Math.floor(Date.now() / SECOND) + days * DAY_SECONDS +} + +function storageState(cookies) { + return JSON.stringify({ cookies, origins: [] }) +} + +/** + * Builds a throwaway sessions.db that looks like the one the bot writes, then + * returns the (projectRoot, sessionPath) pair the API reader expects. + * + * sessionPath is randomized so resolveSessionDb cannot accidentally match the + * real ./sessions/sessions.db relative to the test process cwd. + */ +function makeSessionDb(rows) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mrs-session-test-')) + const sessionPath = `sessions-${path.basename(root)}` + const dir = path.join(root, sessionPath) + fs.mkdirSync(dir, { recursive: true }) + + const db = new DatabaseSync(path.join(dir, 'sessions.db')) + db.exec(` + CREATE TABLE sessions ( + email TEXT NOT NULL, + platform TEXT NOT NULL, + storage_state TEXT, + fingerprint TEXT, + updated_at INTEGER NOT NULL, + PRIMARY KEY (email, platform) + ) + `) + const insert = db.prepare( + 'INSERT INTO sessions (email, platform, storage_state, fingerprint, updated_at) VALUES (?, ?, ?, ?, ?)' + ) + for (const row of rows) { + insert.run( + row.email, + row.platform, + row.storageState ?? null, + row.fingerprint ?? null, + row.updatedAt ?? Date.now() + ) + } + db.close() + + return { root, sessionPath, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) } +} + +test('a live Microsoft auth cookie reports logged-in', () => { + const { root, sessionPath, cleanup } = makeSessionDb([ + { + email: 'live@example.com', + platform: 'desktop', + storageState: storageState([ + { name: 'MSPAuth', domain: '.live.com', expires: secondsFromNow(365) }, + { name: 'MUID', domain: '.bing.com', expires: secondsFromNow(365) } + ]) + } + ]) + + try { + const map = getSessionLoginStatusMap(root, sessionPath) + assert.equal(map.get('live@example.com').status, 'logged-in') + assert.equal(map.get('live@example.com').liveAuthCookieCount, 1) + } finally { + cleanup() + } +}) + +test('anonymous cookies alone are not proof of login', () => { + const { root, sessionPath, cleanup } = makeSessionDb([ + { + email: 'anon@example.com', + platform: 'desktop', + storageState: storageState([ + { name: 'MUID', domain: '.bing.com', expires: secondsFromNow(365) }, + { name: 'SRCHHPGUSR', domain: '.bing.com', expires: secondsFromNow(365) } + ]) + } + ]) + + try { + assert.equal(getSessionLoginStatusMap(root, sessionPath).get('anon@example.com').status, 'expired') + } finally { + cleanup() + } +}) + +test('an expired auth cookie reports expired, not logged-in', () => { + const { root, sessionPath, cleanup } = makeSessionDb([ + { + email: 'stale@example.com', + platform: 'desktop', + storageState: storageState([{ name: '_U', domain: '.bing.com', expires: secondsFromNow(-2) }]) + } + ]) + + try { + const info = getSessionLoginStatusMap(root, sessionPath).get('stale@example.com') + assert.equal(info.status, 'expired') + assert.equal(info.liveAuthCookieCount, 0) + } finally { + cleanup() + } +}) + +test('a session cookie (expires -1) still counts as live', () => { + const { root, sessionPath, cleanup } = makeSessionDb([ + { + email: 'session-cookie@example.com', + platform: 'mobile', + storageState: storageState([{ name: 'WLSSC', domain: '.live.com', expires: -1 }]) + } + ]) + + try { + assert.equal(getSessionLoginStatusMap(root, sessionPath).get('session-cookie@example.com').status, 'logged-in') + } finally { + cleanup() + } +}) + +test('a row with no storage state reports not-logged-in', () => { + const { root, sessionPath, cleanup } = makeSessionDb([ + { email: 'empty@example.com', platform: 'desktop', storageState: null } + ]) + + try { + assert.equal(getSessionLoginStatusMap(root, sessionPath).get('empty@example.com').status, 'not-logged-in') + } finally { + cleanup() + } +}) + +test('the best platform wins when desktop and mobile disagree', () => { + const { root, sessionPath, cleanup } = makeSessionDb([ + { + email: 'mixed@example.com', + platform: 'desktop', + storageState: storageState([{ name: 'MSPAuth', domain: '.live.com', expires: secondsFromNow(-1) }]), + updatedAt: Date.now() - 60_000 + }, + { + email: 'mixed@example.com', + platform: 'mobile', + storageState: storageState([{ name: 'MSPAuth', domain: '.live.com', expires: secondsFromNow(30) }]), + updatedAt: Date.now() + } + ]) + + try { + assert.equal(getSessionLoginStatusMap(root, sessionPath).get('mixed@example.com').status, 'logged-in') + } finally { + cleanup() + } +}) + +test('email lookups are case-insensitive', () => { + const { root, sessionPath, cleanup } = makeSessionDb([ + { + email: 'MixedCase@Example.com', + platform: 'desktop', + storageState: storageState([{ name: 'MSPProf', domain: '.live.com', expires: secondsFromNow(10) }]) + } + ]) + + try { + assert.equal(getSessionLoginStatusMap(root, sessionPath).get('mixedcase@example.com').status, 'logged-in') + } finally { + cleanup() + } +}) + +test('corrupt storage state is reported as expired rather than throwing', () => { + const { root, sessionPath, cleanup } = makeSessionDb([ + { email: 'corrupt@example.com', platform: 'desktop', storageState: '{not json' } + ]) + + try { + const listed = listStoredSessions(root, sessionPath) + assert.equal(listed.sessions[0].parseError, true) + assert.equal(getSessionLoginStatusMap(root, sessionPath).get('corrupt@example.com').status, 'expired') + } finally { + cleanup() + } +}) + +test('a missing database yields null so callers can leave status untouched', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mrs-session-missing-')) + try { + assert.equal(getSessionLoginStatusMap(root, `sessions-${path.basename(root)}`), null) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) From fa933757160dd32db43d437478784d37b6592bee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BD=20Tu=E1=BA=A5n=20C=C6=B0=E1=BB=9Dng?= Date: Sun, 13 Sep 2026 18:56:57 +0700 Subject: [PATCH 2/4] feat(stop): abort the run gracefully and close browsers A Stop from the dashboard previously just killed the process tree, which left Chromium instances to be reaped by the OS and could orphan them. - src/util/Abort.ts: run-wide AbortController plus a registry of live browsers, so an abort can force-close every Chromium it knows about. - Browser.ts registers each launched browser and unregisters on disconnect. - index.ts: SIGINT/SIGTERM now abort first (closing browsers) before flushing webhooks and exiting; the between-accounts delay and the 30-minute Edge browsing task are both interruptible, and the account loop stops rather than starting a fresh login. - processManager.js: writes an __ABORT__ sentinel to the child's stdin because Windows cannot deliver a real SIGTERM to a child process; the existing kill timer still escalates to a tree kill if the bot does not exit in time. Covered by tests/abort.test.mjs. Co-Authored-By: Claude Opus 5 --- scripts/api/processManager.js | 33 +++++++++- src/browser/Browser.ts | 5 ++ src/index.ts | 66 +++++++++++++++++--- src/util/Abort.ts | 65 +++++++++++++++++++ tests/abort.test.mjs | 114 ++++++++++++++++++++++++++++++++++ 5 files changed, 270 insertions(+), 13 deletions(-) create mode 100644 src/util/Abort.ts create mode 100644 tests/abort.test.mjs diff --git a/scripts/api/processManager.js b/scripts/api/processManager.js index 9560f7a0..589cb3e3 100644 --- a/scripts/api/processManager.js +++ b/scripts/api/processManager.js @@ -5,6 +5,12 @@ import { parseLogLine, createRunState, applyLogToRunState, summarizeRunState, se const IS_WIN = process.platform === 'win32' +// Written to the child's stdin to request a graceful abort. Windows has no real +// SIGTERM for child processes, so a signal would just be TerminateProcess and the +// bot could never close its browsers; this gives it a cross-platform way to hear +// "stop" and unwind before we escalate to killing the tree. +export const ABORT_SENTINEL = '__ABORT__' + const BLOCKED_ENV = new Set([ 'NODE_OPTIONS', 'NODE_PATH', @@ -82,7 +88,7 @@ export class ProcessManager extends EventEmitter { child = spawn(this.command, args, { cwd: this.cwd, env, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ['pipe', 'pipe', 'pipe'], detached: !IS_WIN, // own process group on POSIX so we can signal the whole tree windowsHide: true }) @@ -128,9 +134,17 @@ export class ProcessManager extends EventEmitter { const wasStopping = this.state === 'stopping' this.state = 'stopping' if (!wasStopping) { - this._controllerLog('warn', force ? 'Force-stopping run (SIGKILL)…' : 'Stopping run (SIGTERM)…') this._emitStatus('stopping') - this._killTree(force ? 'SIGKILL' : 'SIGTERM') + + if (force) { + this._controllerLog('warn', 'Force-stopping run - killing the browser process tree…') + this._killTree('SIGKILL') + } else { + // Ask the bot to abort and close its browsers itself; _killTimer + // escalates to a tree kill if it does not exit in time. + this._controllerLog('warn', 'Stopping run - asking the bot to abort and close browsers…') + this._requestAbort() + } this._killTimer = setTimeout(() => { if (this.state !== 'idle') { @@ -339,6 +353,19 @@ export class ProcessManager extends EventEmitter { this.emit('status', { reason, ...this.getStatus() }) } + /** + * Ask the bot to abort gracefully: sentinel on stdin (works on Windows) plus + * SIGTERM on POSIX, where the bot's own handler is the faster path. + */ + _requestAbort() { + try { + this.child?.stdin?.write(`${ABORT_SENTINEL}\n`) + } catch { + // stdin already closed - the signal / kill timer still covers us + } + if (!IS_WIN) this._killTree('SIGTERM') + } + _killTree(signal) { if (!this.child || this.pid == null) return try { diff --git a/src/browser/Browser.ts b/src/browser/Browser.ts index 37850359..a1a106a8 100644 --- a/src/browser/Browser.ts +++ b/src/browser/Browser.ts @@ -6,6 +6,7 @@ import type { MicrosoftRewardsBot } from '../index' import { loadSession, saveFingerprint } from '../util/SessionStore' import { fingerprintMatchesLocale } from '../util/Locale' import { formatBrowserProxyServer } from '../util/Proxy' +import { registerBrowser, unregisterBrowser } from '../util/Abort' import { UserAgentManager } from './UserAgent' import type { Account } from '../interface/Account' @@ -93,6 +94,10 @@ class Browser { ...(proxyConfig && { proxy: proxyConfig }), args: [...Browser.BROWSER_ARGS, ...sandboxArgs, ...certArgs] }) + + // Tracked so an abort can force-close it even mid-activity. + registerBrowser(browser) + browser.on('disconnected', () => unregisterBrowser(browser)) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) this.bot.logger.error(this.bot.isMobile, 'BROWSER', `Browser launch failed: ${errorMessage}`) diff --git a/src/index.ts b/src/index.ts index ae19c491..18429261 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import Utils, { isBrowserClosedError } from './util/Utils' import { loadAccounts, loadConfig } from './util/Load' import { closeSessionStore, loadResolvedRegion, saveResolvedRegion } from './util/SessionStore' import { checkNodeVersion } from './util/Validator' +import { abortRun, abortSignal, isAborted, isAbortError } from './util/Abort' import { normalizeCountry, resolveAccountLocale } from './util/Locale' import type { AccountLocale } from './util/Locale' @@ -410,6 +411,12 @@ export class MicrosoftRewardsBot { const accountStats: AccountStats[] = [] for (const [accountIndex, account] of accounts.entries()) { + // Stop between accounts so an abort does not start a fresh login. + if (isAborted()) { + this.logger.warn('main', 'ABORT', 'Process aborted by user - skipping remaining accounts') + break + } + if (accountIndex > 0) { await this.waitBeforeNextAccount(account.email) } @@ -439,6 +446,10 @@ export class MicrosoftRewardsBot { }) const result: AccountRunResult | undefined = await this.Main(account).catch(error => { + if (isAbortError(error) || isAborted()) { + void this.logger.warn('main', 'ABORT', `Process aborted by user during ${accountEmail}`) + return undefined + } void this.logger.error( true, 'FLOW', @@ -555,7 +566,18 @@ export class MicrosoftRewardsBot { nextEmail ? ` (${nextEmail})` : '' }` ) - await this.utils.wait(delayMs) + await new Promise(resolve => { + const timer = setTimeout(() => { + abortSignal.removeEventListener('abort', onAbort) + resolve() + }, delayMs) + // Otherwise a Stop during a multi-minute delay would look frozen. + const onAbort = (): void => { + clearTimeout(timer) + resolve() + } + abortSignal.addEventListener('abort', onAbort, { once: true }) + }) } async createDesktopSession(account: Account): Promise { @@ -596,6 +618,9 @@ export class MicrosoftRewardsBot { let mobileSession: BrowserSession | null = null let desktopSession: BrowserSession | null = null const edgeBrowsingController = new AbortController() + // A user abort must also cancel the 30-minute background task. + const onRunAbort = (): void => edgeBrowsingController.abort() + abortSignal.addEventListener('abort', onRunAbort, { once: true }) let edgeBrowsingTask: Promise | null = null let edgeBrowsingFinished = false @@ -933,6 +958,8 @@ export class MicrosoftRewardsBot { } }) } finally { + abortSignal.removeEventListener('abort', onRunAbort) + if (edgeBrowsingTask) { edgeBrowsingController.abort() await edgeBrowsingTask @@ -975,16 +1002,35 @@ async function main(): Promise { process.on('beforeExit', () => { void flushAllWebhooks() }) - process.on('SIGINT', async () => { - rewardsBot.logger.warn('main', 'PROCESS', 'SIGINT received, flushing and exiting...') - await flushAllWebhooks() - process.exit(130) - }) - process.on('SIGTERM', async () => { - rewardsBot.logger.warn('main', 'PROCESS', 'SIGTERM received, flushing and exiting...') + // Stop requests arrive as signals from the control API. Abort first so every + // tracked Chromium is closed, then flush and exit - otherwise the browsers + // only die because the OS reaps the process tree. + let aborting = false + const onStopSignal = async (signal: 'SIGINT' | 'SIGTERM', exitCode: number): Promise => { + if (aborting) return + aborting = true + + rewardsBot.logger.warn('main', 'ABORT', `Process aborted by user (${signal}) - closing browsers...`) + const closed = await abortRun() + rewardsBot.logger.warn('main', 'ABORT', `Process aborted by user | browsersClosed=${closed}`) + await flushAllWebhooks() - process.exit(143) - }) + process.exit(exitCode) + } + process.on('SIGINT', () => void onStopSignal('SIGINT', 130)) + process.on('SIGTERM', () => void onStopSignal('SIGTERM', 143)) + + // The control API writes this on stdin because Windows cannot deliver a real + // SIGTERM to a child; without it a stop would hard-kill and orphan Chromium. + if (!process.stdin.isTTY) { + process.stdin.setEncoding('utf8') + process.stdin.on('data', chunk => { + if (chunk.includes('__ABORT__')) void onStopSignal('SIGTERM', 143) + }) + process.stdin.on('error', () => undefined) + // Do not hold the event loop open just for this listener. + process.stdin.unref() + } process.on('uncaughtException', async error => { if (isBrowserClosedError(error)) { rewardsBot.logger.debug( diff --git a/src/util/Abort.ts b/src/util/Abort.ts new file mode 100644 index 00000000..66b1a034 --- /dev/null +++ b/src/util/Abort.ts @@ -0,0 +1,65 @@ +/** + * Run-wide cancellation. + * + * The API stops a run by signalling the process (SIGTERM, then SIGKILL on a + * timeout). Chromium is a child of this process, so the OS-level kill already + * prevents zombies. What the signal alone does NOT do is give the run a chance + * to close browsers cleanly, so this registry exists to: + * - expose an AbortSignal that long loops can poll to bail out early, and + * - close every live browser (saving nothing, just releasing the processes) + * before we exit, so no Chromium outlives us if it ever escaped the tree. + */ + +type Closable = { close: () => Promise } + +const controller = new AbortController() +const browsers = new Set() + +/** Signal that flips when the user aborts the run. */ +export const abortSignal: AbortSignal = controller.signal + +export function isAborted(): boolean { + return controller.signal.aborted +} + +/** Throws if the run was aborted. Call between steps to unwind promptly. */ +export function throwIfAborted(): void { + if (controller.signal.aborted) { + throw new AbortError() + } +} + +export class AbortError extends Error { + constructor() { + super('Process aborted by user') + this.name = 'AbortError' + } +} + +export function isAbortError(error: unknown): boolean { + return error instanceof AbortError || (error instanceof Error && error.name === 'AbortError') +} + +/** Track a launched browser so abort can force it closed. */ +export function registerBrowser(browser: Closable): void { + browsers.add(browser) +} + +export function unregisterBrowser(browser: Closable): void { + browsers.delete(browser) +} + +/** + * Flip the signal and force every tracked browser closed. Safe to call twice. + * Returns how many browsers it closed so the caller can log it. + */ +export async function abortRun(): Promise { + controller.abort() + + const pending = [...browsers] + browsers.clear() + + // close() rejects if the browser already died; that is the outcome we want anyway. + await Promise.allSettled(pending.map(browser => browser.close())) + return pending.length +} diff --git a/tests/abort.test.mjs b/tests/abort.test.mjs new file mode 100644 index 00000000..f5085f88 --- /dev/null +++ b/tests/abort.test.mjs @@ -0,0 +1,114 @@ +/** + * Self-check for the run-abort path. + * + * Run: node --test tests/abort.test.js + * + * Covers the two things that actually break in the wild: + * 1. abortRun() closes every registered browser, so none survive a Stop. + * 2. ProcessManager.stop() asks for a graceful abort before killing the tree, + * because a bare taskkill leaves the bot no chance to close Chromium. + */ + +import assert from 'node:assert/strict' +import test from 'node:test' + +import { ABORT_SENTINEL, ProcessManager } from '../scripts/api/processManager.js' + +test('abortRun closes every registered browser and flips the signal', async () => { + // Import through dist so this runs without a TS loader. + const { abortRun, abortSignal, isAborted, registerBrowser } = await import('../dist/util/Abort.js') + + const closed = [] + const fakeBrowser = name => ({ + close: async () => { + closed.push(name) + } + }) + + registerBrowser(fakeBrowser('mobile')) + registerBrowser(fakeBrowser('desktop')) + + assert.equal(isAborted(), false, 'signal should start un-aborted') + + const count = await abortRun() + + assert.equal(count, 2, 'should report both browsers closed') + assert.deepEqual(closed.sort(), ['desktop', 'mobile']) + assert.equal(abortSignal.aborted, true, 'signal must flip so loops bail out') + assert.equal(isAborted(), true) + + // Idempotent: a second Stop click must not throw or double-close. + assert.equal(await abortRun(), 0) +}) + +test('a browser that fails to close does not break the abort', async () => { + const { abortRun, registerBrowser } = await import('../dist/util/Abort.js') + + registerBrowser({ + close: async () => { + throw new Error('browser already gone') + } + }) + + // Must resolve, not reject - a dead browser is the outcome we wanted anyway. + assert.equal(await abortRun(), 1) +}) + +test('stop() requests a graceful abort before killing the tree', async () => { + const pm = new ProcessManager({ + command: process.execPath, + // Ignores stdin and stays alive, so we can observe the abort request. + args: ['-e', 'setTimeout(() => {}, 60_000)'], + cwd: process.cwd(), + stopTimeoutMs: 1500 + }) + + pm.start() + await new Promise(resolve => pm.once('status', resolve)) + + const written = [] + const realWrite = pm.child.stdin.write.bind(pm.child.stdin) + pm.child.stdin.write = chunk => { + written.push(String(chunk)) + return realWrite(chunk) + } + + const exit = pm.stop({ force: false }) + + assert.ok( + written.some(chunk => chunk.includes(ABORT_SENTINEL)), + 'stop() should write the abort sentinel to stdin' + ) + + // The child ignores the sentinel, so the kill timer must still finish the job. + await exit + assert.equal(pm.getStatus().state, 'idle', 'server must settle back to idle, not hang') +}) + +test('force stop skips the graceful path', async () => { + const pm = new ProcessManager({ + command: process.execPath, + args: ['-e', 'setTimeout(() => {}, 60_000)'], + cwd: process.cwd(), + stopTimeoutMs: 1500 + }) + + pm.start() + await new Promise(resolve => pm.once('status', resolve)) + + const written = [] + const realWrite = pm.child.stdin.write.bind(pm.child.stdin) + pm.child.stdin.write = chunk => { + written.push(String(chunk)) + return realWrite(chunk) + } + + await pm.stop({ force: true }) + + assert.equal( + written.some(chunk => chunk.includes(ABORT_SENTINEL)), + false, + 'force stop should kill immediately without asking' + ) + assert.equal(pm.getStatus().state, 'idle') +}) From 760b2f3bc5b76797eaa28b3f71f9e8096b8c912b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BD=20Tu=E1=BA=A5n=20C=C6=B0=E1=BB=9Dng?= Date: Sun, 13 Sep 2026 18:57:13 +0700 Subject: [PATCH 3/4] fix(config): apply CONFIG_* env overrides on every bot start Toggling Visual Search or 30-Minute Edge Browsing in the Web UI had no effect. The env vars reached the spawned bot correctly, but nothing in the child ever consumed them: applyEnvOverrides() was only called by the Docker entrypoint and the CLI, so loadConfig() read config.json verbatim and both flags stayed false. Add mergeEnvOverrides(), which applies the overrides to an already-parsed config object in memory, and call it from loadConfig() before validateConfig() - so overridden values pass the same schema checks as file values, and config.json is never rewritten. applyEnvOverrides() now preflights and delegates to the same merge, keeping Docker behaviour. Also widen boolean parsing to the shell-style words Load.ts already accepts (1/0, yes/no, on/off, case-insensitive) instead of only true/false, tolerate partial failure so one bad value no longer discards valid overrides, and mask secret values (webhook URLs, bot tokens) in the applied-override log. Co-Authored-By: Claude Opus 5 --- src/util/ConfigEnvOverrides.ts | 84 ++++++++++++++----- src/util/Load.ts | 27 ++++++ tests/configEnvOverrides.test.mjs | 133 ++++++++++++++++++++++++++++++ 3 files changed, 224 insertions(+), 20 deletions(-) create mode 100644 tests/configEnvOverrides.test.mjs diff --git a/src/util/ConfigEnvOverrides.ts b/src/util/ConfigEnvOverrides.ts index 10dc65e3..ec999d64 100644 --- a/src/util/ConfigEnvOverrides.ts +++ b/src/util/ConfigEnvOverrides.ts @@ -6,10 +6,12 @@ export interface EnvOverrideEntry { env: string path: string // dotted path into config.json type: OverrideType + secret?: boolean // never print the value; applied overrides are logged } export const ENV_OVERRIDES: EnvOverrideEntry[] = [ // General + { env: 'CONFIG_HEADLESS', path: 'headless', type: 'bool' }, { env: 'CONFIG_CLUSTERS', path: 'clusters', type: 'number' }, { env: 'CONFIG_DEBUG_LOGS', path: 'debugLogs', type: 'bool' }, { env: 'CONFIG_ERROR_DIAGNOSTICS', path: 'errorDiagnostics', type: 'bool' }, @@ -72,18 +74,18 @@ export const ENV_OVERRIDES: EnvOverrideEntry[] = [ // Discord webhook { env: 'CONFIG_DISCORD_ENABLED', path: 'webhook.discord.enabled', type: 'bool' }, - { env: 'CONFIG_DISCORD_URL', path: 'webhook.discord.url', type: 'string' }, + { env: 'CONFIG_DISCORD_URL', path: 'webhook.discord.url', type: 'string', secret: true }, // Telegram webhook { env: 'CONFIG_TELEGRAM_ENABLED', path: 'webhook.telegram.enabled', type: 'bool' }, - { env: 'CONFIG_TELEGRAM_BOTTOKEN', path: 'webhook.telegram.botToken', type: 'string' }, - { env: 'CONFIG_TELEGRAM_CHATID', path: 'webhook.telegram.chatId', type: 'string' }, + { env: 'CONFIG_TELEGRAM_BOTTOKEN', path: 'webhook.telegram.botToken', type: 'string', secret: true }, + { env: 'CONFIG_TELEGRAM_CHATID', path: 'webhook.telegram.chatId', type: 'string', secret: true }, // ntfy webhook (tags are comma-separated e.g. "bot,notify") { env: 'CONFIG_NTFY_ENABLED', path: 'webhook.ntfy.enabled', type: 'bool' }, - { env: 'CONFIG_NTFY_URL', path: 'webhook.ntfy.url', type: 'string' }, - { env: 'CONFIG_NTFY_TOPIC', path: 'webhook.ntfy.topic', type: 'string' }, - { env: 'CONFIG_NTFY_TOKEN', path: 'webhook.ntfy.token', type: 'string' }, + { env: 'CONFIG_NTFY_URL', path: 'webhook.ntfy.url', type: 'string', secret: true }, + { env: 'CONFIG_NTFY_TOPIC', path: 'webhook.ntfy.topic', type: 'string', secret: true }, + { env: 'CONFIG_NTFY_TOKEN', path: 'webhook.ntfy.token', type: 'string', secret: true }, { env: 'CONFIG_NTFY_TITLE', path: 'webhook.ntfy.title', type: 'string' }, { env: 'CONFIG_NTFY_PRIORITY', path: 'webhook.ntfy.priority', type: 'number' }, { env: 'CONFIG_NTFY_TAGS', path: 'webhook.ntfy.tags', type: 'array' }, @@ -95,7 +97,13 @@ export const ENV_OVERRIDES: EnvOverrideEntry[] = [ { env: 'CONFIG_WEBHOOK_LOG_FILTER_KEYWORDS', path: 'webhook.webhookLogFilter.keywords', type: 'array' } ] -const FORCED_OVERRIDES: { path: string; value: unknown }[] = [{ path: 'headless', value: true }] +// Docker images set FORCE_HEADLESS=1 because containers have no display, so +// headless is pinned there and CONFIG_HEADLESS cannot turn it off. +function forcedOverrides(env: NodeJS.ProcessEnv): { path: string; value: unknown }[] { + const raw = env.FORCE_HEADLESS?.trim().toLowerCase() + const forced = raw !== undefined && ['1', 'true', 'yes', 'on'].includes(raw) + return forced ? [{ path: 'headless', value: true }] : [] +} function setDeep(obj: Record, dottedPath: string, value: unknown): void { const parts = dottedPath.split('.') @@ -111,15 +119,21 @@ function setDeep(obj: Record, dottedPath: string, value: unknow cur[parts[parts.length - 1] as string] = value } +// Shells, compose files and CI runners all spell booleans differently, so accept +// the same set Load.ts accepts for its own env flags instead of only true/false. +const TRUE_WORDS = new Set(['1', 'true', 'yes', 'on']) +const FALSE_WORDS = new Set(['0', 'false', 'no', 'off']) + function coerceScalar(raw: string, type: 'bool' | 'number' | 'string', env: string): unknown { switch (type) { - case 'bool': - if (raw !== 'true' && raw !== 'false') { - throw new Error(`${env} expects true or false, got '${raw}'.`) - } - return raw === 'true' + case 'bool': { + const word = raw.trim().toLowerCase() + if (TRUE_WORDS.has(word)) return true + if (FALSE_WORDS.has(word)) return false + throw new Error(`${env} expects a boolean (true/false, 1/0, yes/no, on/off), got '${raw}'.`) + } case 'number': { - const n = Number(raw) + const n = Number(raw.trim()) if (!Number.isFinite(n)) throw new Error(`${env} expects a JSON number, got '${raw}'.`) return n } @@ -129,6 +143,13 @@ function coerceScalar(raw: string, type: 'bool' | 'number' | 'string', env: stri } } +const SECRET_ENVS = new Set(ENV_OVERRIDES.filter(entry => entry.secret).map(entry => entry.env)) + +// Applied overrides get logged, and some of them are webhook URLs and bot tokens. +export function describeOverrideValue(envName: string, value: unknown): string { + return SECRET_ENVS.has(envName) ? '***' : JSON.stringify(value) +} + export interface ComputedOverride { env: string path: string @@ -169,6 +190,26 @@ export function computeOverrides(env: NodeJS.ProcessEnv = process.env): { return { applied, errors } } +export interface MergeReport { + forced: { path: string; value: unknown }[] + applied: ComputedOverride[] + errors: OverrideError[] +} + +// Merges CONFIG_* overrides into an already-parsed config object. loadConfig() +// calls this on every start so a launcher (Web UI, scheduler, plain shell) only +// has to set the env var - nothing has to rewrite config.json first. +// Unlike applyEnvOverrides this is not all-or-nothing: valid overrides still +// land and the caller decides how loudly to report the rejected ones. +export function mergeEnvOverrides(config: Record, env: NodeJS.ProcessEnv = process.env): MergeReport { + const { applied, errors } = computeOverrides(env) + const forced = forcedOverrides(env) + // applied first, forced last: FORCE_HEADLESS pins headless on and CONFIG_HEADLESS cannot undo it + for (const { path: p, value } of applied) setDeep(config, p, value) + for (const { path: p, value } of forced) setDeep(config, p, value) + return { forced, applied, errors } +} + export interface ApplyReport { configPath: string forced: { path: string; value: unknown }[] @@ -177,18 +218,19 @@ export interface ApplyReport { } export function applyEnvOverrides(configPath: string, env: NodeJS.ProcessEnv = process.env): ApplyReport { - const { applied, errors } = computeOverrides(env) - if (errors.length > 0) { - return { configPath, forced: [], applied: [], errors } + // Preflight before touching the file: a single bad value must not leave a + // half-overridden config.json behind. + const { errors: rejected } = computeOverrides(env) + if (rejected.length > 0) { + return { configPath, forced: [], applied: [], errors: rejected } } const config = readJson(configPath) as Record - for (const { path: p, value } of FORCED_OVERRIDES) setDeep(config, p, value) - for (const { path: p, value } of applied) setDeep(config, p, value) + const { applied, forced } = mergeEnvOverrides(config, env) writeConfigAtomic(configPath, config, { backup: false }) - return { configPath, forced: FORCED_OVERRIDES, applied, errors: [] } + return { configPath, forced, applied, errors: [] } } // ── CLI entry point, used by entrypoint.sh ── @@ -269,7 +311,9 @@ Examples: process.exit(1) } report.forced.forEach(f => console.log(`[entrypoint] .${f.path} = ${f.value} (forced)`)) - report.applied.forEach(a => console.log(`[entrypoint] .${a.path} = ${JSON.stringify(a.value)}`)) + report.applied.forEach(a => + console.log(`[entrypoint] .${a.path} = ${describeOverrideValue(a.env, a.value)}`) + ) console.log(`[entrypoint] Applied ${report.applied.length} override(s).`) process.exit(0) } catch (err) { diff --git a/src/util/Load.ts b/src/util/Load.ts index 71b4400e..9b5edcfa 100644 --- a/src/util/Load.ts +++ b/src/util/Load.ts @@ -3,6 +3,7 @@ import path from 'path' import type { Account, AccountProxy, ConfigSaveFingerprint } from '../interface/Account' import type { Config } from '../interface/Config' +import { describeOverrideValue, mergeEnvOverrides } from './ConfigEnvOverrides' import { validateAccounts, validateConfig } from './Validator' let configCache: Config @@ -154,6 +155,24 @@ export function loadAccounts(): Account[] { } } +function applyConfigEnvOverrides(rawConfig: unknown): void { + if (typeof rawConfig !== 'object' || rawConfig === null || Array.isArray(rawConfig)) return + + const { applied, forced, errors } = mergeEnvOverrides(rawConfig as Record) + + // Loud on both sides: a silently ignored toggle is exactly the bug this fixes, + // and a rejected value would otherwise look identical to the feature being off. + for (const { env, path: dotted, value } of applied) { + console.log(`[Config] override: ${env} -> .${dotted} = ${describeOverrideValue(env, value)}`) + } + for (const { path: dotted, value } of forced) { + console.log(`[Config] override: .${dotted} = ${JSON.stringify(value)} (forced)`) + } + for (const { message } of errors) { + console.warn(`[Config] WARN: ignored invalid override - ${message}`) + } +} + export function loadConfig(): Config { try { if (configCache) { @@ -170,6 +189,14 @@ export function loadConfig(): Config { const config = fs.readFileSync(configPath, 'utf-8') const unverifiedConfig = JSON.parse(config) + + // CONFIG_* overrides are merged here rather than written to config.json, + // so every launcher (Web UI, scheduler, plain shell, Docker) gets them by + // setting an env var. Runs before validateConfig so overridden values go + // through the same schema checks as file values. + ensureEnvLoaded() + applyConfigEnvOverrides(unverifiedConfig) + const configData = validateConfig(unverifiedConfig) configCache = configData diff --git a/tests/configEnvOverrides.test.mjs b/tests/configEnvOverrides.test.mjs new file mode 100644 index 00000000..9245c259 --- /dev/null +++ b/tests/configEnvOverrides.test.mjs @@ -0,0 +1,133 @@ +/** + * Regression cover for CONFIG_* env overrides reaching the running bot. + * + * Run: node --test tests/configEnvOverrides.test.mjs + * + * The bug this guards: the Web UI toggles Visual Search / Edge Browsing by + * setting CONFIG_WORKER_VISUAL_SEARCH and CONFIG_EXPERIMENTAL_EDGE_BROWSING on + * the spawned run, but nothing merged those vars into the config the bot + * evaluated. Only the Docker entrypoint applied them (by rewriting config.json), + * so a locally launched run silently skipped both features. + */ + +import assert from 'node:assert/strict' +import test from 'node:test' + +// Import through dist so this runs without a TS loader. +const { mergeEnvOverrides, computeOverrides, describeOverrideValue } = + await import('../dist/util/ConfigEnvOverrides.js') + +function baseConfig() { + return { + headless: true, + clusters: 1, + workers: { doVisualSearch: false, doDailySet: true }, + experimental: { edgeBrowsing: false, apiSearch: false } + } +} + +test('the two toggled features land on the config the bot reads', () => { + const config = baseConfig() + // Exactly what public/app.js puts in the /start payload. + const report = mergeEnvOverrides(config, { + CONFIG_WORKER_VISUAL_SEARCH: 'true', + CONFIG_EXPERIMENTAL_EDGE_BROWSING: 'true' + }) + + assert.equal(config.workers.doVisualSearch, true) + assert.equal(config.experimental.edgeBrowsing, true) + assert.equal(report.errors.length, 0) + assert.deepEqual(report.applied.map(entry => entry.path).sort(), [ + 'experimental.edgeBrowsing', + 'workers.doVisualSearch' + ]) +}) + +test('toggling off overrides a config.json that has the feature on', () => { + const config = baseConfig() + config.workers.doVisualSearch = true + config.experimental.edgeBrowsing = true + + mergeEnvOverrides(config, { + CONFIG_WORKER_VISUAL_SEARCH: 'false', + CONFIG_EXPERIMENTAL_EDGE_BROWSING: 'false' + }) + + assert.equal(config.workers.doVisualSearch, false) + assert.equal(config.experimental.edgeBrowsing, false) +}) + +test('unset vars leave the file values alone', () => { + const config = baseConfig() + config.workers.doVisualSearch = true + + const report = mergeEnvOverrides(config, {}) + + assert.equal(config.workers.doVisualSearch, true) + assert.equal(report.applied.length, 0) +}) + +test('an empty string is treated as unset, not as false', () => { + const config = baseConfig() + config.workers.doVisualSearch = true + + mergeEnvOverrides(config, { CONFIG_WORKER_VISUAL_SEARCH: '' }) + + assert.equal(config.workers.doVisualSearch, true) +}) + +test('shell-style booleans are accepted, not just true/false', () => { + for (const raw of ['1', 'yes', 'on', 'TRUE', ' true ']) { + const config = baseConfig() + mergeEnvOverrides(config, { CONFIG_WORKER_VISUAL_SEARCH: raw }) + assert.equal(config.workers.doVisualSearch, true, `${JSON.stringify(raw)} should mean true`) + } + + for (const raw of ['0', 'no', 'off', 'FALSE']) { + const config = baseConfig() + config.workers.doVisualSearch = true + mergeEnvOverrides(config, { CONFIG_WORKER_VISUAL_SEARCH: raw }) + assert.equal(config.workers.doVisualSearch, false, `${JSON.stringify(raw)} should mean false`) + } +}) + +test('one bad value does not discard the valid overrides beside it', () => { + const config = baseConfig() + const report = mergeEnvOverrides(config, { + CONFIG_WORKER_VISUAL_SEARCH: 'true', + CONFIG_CLUSTERS: 'abc' + }) + + assert.equal(config.workers.doVisualSearch, true, 'valid override must still apply') + assert.equal(config.clusters, 1, 'rejected override must leave the file value') + assert.equal(report.errors.length, 1) + assert.equal(report.errors[0].env, 'CONFIG_CLUSTERS') +}) + +test('a rejected boolean is reported instead of silently passing', () => { + const { errors } = computeOverrides({ CONFIG_EXPERIMENTAL_EDGE_BROWSING: 'maybe' }) + assert.equal(errors.length, 1) + assert.match(errors[0].message, /boolean/) +}) + +test('FORCE_HEADLESS wins over CONFIG_HEADLESS, and is off by default', () => { + const forced = baseConfig() + mergeEnvOverrides(forced, { CONFIG_HEADLESS: 'false', FORCE_HEADLESS: '1' }) + assert.equal(forced.headless, true, 'containers have no display, so headless stays pinned') + + // Outside Docker the user keeps control of headless. + const free = baseConfig() + mergeEnvOverrides(free, { CONFIG_HEADLESS: 'false' }) + assert.equal(free.headless, false) +}) + +test('nested paths are created when config.json predates the key', () => { + const config = { headless: true } + mergeEnvOverrides(config, { CONFIG_EXPERIMENTAL_EDGE_BROWSING: 'true' }) + assert.equal(config.experimental.edgeBrowsing, true) +}) + +test('secret override values are masked in the applied-override log', () => { + assert.equal(describeOverrideValue('CONFIG_DISCORD_URL', 'https://discord.com/api/webhooks/secret'), '***') + assert.equal(describeOverrideValue('CONFIG_WORKER_VISUAL_SEARCH', true), 'true') +}) From 9938d4ee2119fb63137e8838cbb8ac274c0c3de2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BD=20Tu=E1=BA=A5n=20C=C6=B0=E1=BB=9Dng?= Date: Sun, 13 Sep 2026 18:57:41 +0700 Subject: [PATCH 4/4] feat(edge-browsing): drive a real browsing session during the 30-minute task The background Edge browsing activity only posted progress reports to the rewards API; no browser tab was actually browsing. EdgeLiveBrowsing opens a tab in the already-authenticated context and reads for the full session window: scrolls with human-shaped pauses, dwells at the end of an article, and occasionally follows a link instead of jumping back to a feed. Links are restricted to the current host or a bing/microsoft/msn/microsoftedge suffix so a session cannot wander onto arbitrary sites. The session honours the run's AbortSignal at every wait, so a Stop unwinds it instead of hanging, and failures are logged as a warning rather than failing the whole activity. Co-Authored-By: Claude Opus 5 --- .../activities/experimental/EdgeBrowsing.ts | 12 + .../experimental/EdgeLiveBrowsing.ts | 223 ++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 src/functions/activities/experimental/EdgeLiveBrowsing.ts diff --git a/src/functions/activities/experimental/EdgeBrowsing.ts b/src/functions/activities/experimental/EdgeBrowsing.ts index 2a687d50..407094d7 100644 --- a/src/functions/activities/experimental/EdgeBrowsing.ts +++ b/src/functions/activities/experimental/EdgeBrowsing.ts @@ -6,6 +6,7 @@ import type { AppDashboardData, Promotion } from '../../../interface/AppDashBoar import type { DashboardData } from '../../../interface/DashboardData' import { BaseActivity } from '../BaseActivity' import { EdgeBrowsingProgress, type EdgeBrowsingProgressSnapshot } from './EdgeBrowsingProgress' +import { EdgeLiveBrowsing } from './EdgeLiveBrowsing' const LOG_TAG = 'EDGE-BROWSING' const PROMOTION_NAME = 'edge_browsing_streak_flight' @@ -124,6 +125,15 @@ export class EdgeBrowsing extends BaseActivity { ` | estimatedDurationMinutes=${progress.estimatedDurationMinutes}` ) + // Real browsing session (scrolling, reading, clicking) alongside the report loop. + const liveBrowsing = new EdgeLiveBrowsing(this.bot).run(signal).catch(error => { + this.bot.logger.warn( + this.bot.isMobile, + LOG_TAG, + `Live browsing session failed | message=${error instanceof Error ? error.message : String(error)}` + ) + }) + for (let reportNumber = 1; reportNumber <= reportCount; reportNumber++) { const beforeReport = progress.snapshot(reportNumber - 1) @@ -202,6 +212,8 @@ export class EdgeBrowsing extends BaseActivity { serverComplete = await this.refreshServerCompletion(accessToken, reportsProcessed, reportCount) } + await liveBrowsing + const finished = progress.snapshot(reportsProcessed) const summary = `Finished background Edge browsing activity | reports=${reportsProcessed}` + diff --git a/src/functions/activities/experimental/EdgeLiveBrowsing.ts b/src/functions/activities/experimental/EdgeLiveBrowsing.ts new file mode 100644 index 00000000..6ba833ce --- /dev/null +++ b/src/functions/activities/experimental/EdgeLiveBrowsing.ts @@ -0,0 +1,223 @@ +import type { Page } from 'patchright' + +import type { MicrosoftRewardsBot } from '../../../index' + +const LOG_TAG = 'EDGE-BROWSING' +const SESSION_MINUTES = 30 +const READ_BUDGET_MIN_MS = 45_000 +const READ_BUDGET_MAX_MS = 120_000 + +// Hosts a browsing session may follow; anything else is treated as unsafe and skipped. +const SAFE_HOST_SUFFIXES = ['bing.com', 'microsoft.com', 'msn.com', 'microsoftedge.com'] + +const DEFAULT_FEED = 'https://www.msn.com/en-us/news' + +const FEED_URLS = [ + DEFAULT_FEED, + 'https://www.msn.com/en-us/money', + 'https://www.msn.com/en-us/lifestyle', + 'https://www.msn.com/en-us/entertainment', + 'https://www.bing.com/news/search?q=technology', + 'https://www.bing.com/news/search?q=science', + 'https://www.bing.com/news/search?q=sports', + 'https://www.bing.com/news/search?q=health' +] + +/** + * Keeps a real browser tab open for TARGET 30 minutes, scrolling and clicking like a reader. + * Runs in a tab of an already-authenticated context so fingerprints and cookies stay consistent. + */ +export class EdgeLiveBrowsing { + constructor(private readonly bot: MicrosoftRewardsBot) {} + + public async run(signal?: AbortSignal): Promise { + const startedAt = Date.now() + const deadline = startedAt + SESSION_MINUTES * 60_000 + + const page = await this.openPage(signal) + if (!page) { + this.bot.logger.info( + this.bot.isMobile, + LOG_TAG, + 'Live browsing skipped: no open browser context in this run' + ) + return + } + + let pagesRead = 0 + let linksClicked = 0 + + this.bot.logger.info( + this.bot.isMobile, + LOG_TAG, + `Started live Edge browsing session | minutes=${SESSION_MINUTES} | startUrl=${page.url()}` + ) + + try { + while (Date.now() < deadline && !signal?.aborted) { + await this.readPage(page, deadline, signal) + if (signal?.aborted || Date.now() >= deadline) break + pagesRead++ + + // Readers mostly finish a page and move on; sometimes they follow a link. + if (Math.random() < 0.35 && (await this.clickSafeLink(page))) { + linksClicked++ + } else { + await this.gotoFeed(page) + } + + await this.wait(this.bot.utils.randomDelay(2000, 6000), signal) + } + } catch (error) { + if (!signal?.aborted) { + this.bot.logger.warn( + this.bot.isMobile, + LOG_TAG, + `Live browsing interrupted | message=${error instanceof Error ? error.message : String(error)}` + ) + } + } finally { + const minutes = Math.round((Date.now() - startedAt) / 60_000) + this.bot.logger.info( + this.bot.isMobile, + LOG_TAG, + `Finished live Edge browsing session | minutes=${minutes} | pagesRead=${pagesRead} | linkClicks=${linksClicked}`, + 'green' + ) + await page.close().catch(() => undefined) + } + } + + private async openPage(signal?: AbortSignal): Promise { + // The mobile context lives for the whole run; the desktop one may close early. + const candidates = [this.bot.mainMobilePage, this.bot.mainDesktopPage] + + for (const source of candidates) { + if (signal?.aborted) return null + try { + if (!source || source.isClosed()) continue + const context = source.context() + if (context.isClosed()) continue + + const page = await context.newPage() + await page.goto(this.pickFeed(), { waitUntil: 'domcontentloaded', timeout: 30_000 }) + return page + } catch { + continue + } + } + + return null + } + + private async readPage(page: Page, deadline: number, signal?: AbortSignal): Promise { + const budget = Math.min( + this.bot.utils.randomDelay(READ_BUDGET_MIN_MS, READ_BUDGET_MAX_MS), + Math.max(5_000, deadline - Date.now()) + ) + const until = Date.now() + budget + + while (Date.now() < until && !signal?.aborted) { + let atBottom: boolean + try { + atBottom = await page.evaluate(() => { + const doc = document.documentElement + return window.scrollY + window.innerHeight >= doc.scrollHeight - 40 + }) + } catch { + return + } + if (atBottom) break + + await page.mouse.wheel(0, this.bot.utils.randomDelay(250, 700)).catch(() => undefined) + + // Mostly short reading pauses, occasionally a long one like a human re-reading a paragraph. + const longPause = Math.random() < 0.12 + const delay = longPause ? this.bot.utils.randomDelay(3000, 6000) : this.bot.utils.randomDelay(700, 2200) + if (!(await this.wait(delay, signal))) return + } + + // Dwell at the end of the article before moving on. + await this.wait(this.bot.utils.randomDelay(2000, 6000), signal) + } + + private async clickSafeLink(page: Page): Promise { + let hrefs: string[] + try { + hrefs = await page.$$eval('a[href]', anchors => + (anchors as HTMLAnchorElement[]).map(a => a.href).filter(h => /^https?:\/\//i.test(h)) + ) + } catch { + return false + } + + let currentHost = '' + try { + currentHost = new URL(page.url()).hostname + } catch { + return false + } + + const safe = hrefs.filter(href => { + try { + const host = new URL(href).hostname + return ( + host === currentHost || + SAFE_HOST_SUFFIXES.some(suffix => host === suffix || host.endsWith(`.${suffix}`)) + ) + } catch { + return false + } + }) + if (!safe.length) return false + + const target = safe[Math.floor(Math.random() * safe.length)] + if (!target) return false + + try { + await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 20_000 }) + this.bot.logger.debug( + this.bot.isMobile, + LOG_TAG, + `Live browsing followed a link | host=${new URL(target).hostname}` + ) + return true + } catch { + return false + } + } + + private async gotoFeed(page: Page): Promise { + try { + await page.goto(this.pickFeed(), { waitUntil: 'domcontentloaded', timeout: 25_000 }) + } catch { + this.bot.logger.debug( + this.bot.isMobile, + LOG_TAG, + 'Live browsing page load timed out; continuing on the current page' + ) + } + } + + private pickFeed(): string { + return FEED_URLS[Math.floor(Math.random() * FEED_URLS.length)] ?? DEFAULT_FEED + } + + private wait(delayMs: number, signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.resolve(false) + if (!signal) return this.bot.utils.wait(delayMs).then(() => true) + + return new Promise(resolve => { + const onAbort = () => { + clearTimeout(timeout) + resolve(false) + } + const timeout = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve(true) + }, delayMs) + + signal.addEventListener('abort', onAbort, { once: true }) + }) + } +}