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 `
+
+
+
+
${escapeHtml(account.email)}
+
+ Runs: ${account.runs}
+ ${account.lastRunAt ? `Last: ${formatDate(account.lastRunAt)}` : ''}
+
+
+
${account.points} pts
+
${formatStatus(account.status)}
+
+
+ `
+ })
+ .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 = `
+
+ `
+ 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
+
+
+
+
+
+
+
+
+
+
No accounts configured. Add an account above to get started.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/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/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/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/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 })
+ })
+ }
+}
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/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/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')
+})
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')
+})
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 })
+ }
+})