From 2d0d18f0d936ce4beddbc11f0ab95193a0779452 Mon Sep 17 00:00:00 2001 From: leeguooooo Date: Wed, 4 Mar 2026 11:46:44 +0900 Subject: [PATCH] fix(extension): default to high-risk mode by disabling page bridge --- cli/Cargo.lock | 2 +- extensions/tab-group-cdp/content-script.js | 342 +++++ extensions/tab-group-cdp/manifest.json | 9 + extensions/tab-group-cdp/page-bridge.js | 133 ++ extensions/tab-group-cdp/service-worker.js | 1384 +++++++++++++++++++- extensions/tab-group-cdp/sidepanel.js | 1259 ++++++++++++++++-- 6 files changed, 2981 insertions(+), 148 deletions(-) create mode 100644 extensions/tab-group-cdp/page-bridge.js diff --git a/cli/Cargo.lock b/cli/Cargo.lock index f77c665..0a7dea7 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -45,7 +45,7 @@ dependencies = [ [[package]] name = "agent-browser-stealth" -version = "0.16.1-fork.0" +version = "0.16.1-fork.3" dependencies = [ "aes-gcm", "async-trait", diff --git a/extensions/tab-group-cdp/content-script.js b/extensions/tab-group-cdp/content-script.js index e9bf0f2..4c17c19 100644 --- a/extensions/tab-group-cdp/content-script.js +++ b/extensions/tab-group-cdp/content-script.js @@ -2,6 +2,316 @@ const REQUEST_TYPE = 'AB_TAB_GROUP_REQUEST'; const RESPONSE_TYPE = 'AB_TAB_GROUP_RESPONSE'; + const CONTENT_EVENT_TYPE = 'AB_CONTENT_EVENT'; + const CONTENT_EXECUTE_ACTION = 'AB_CONTENT_EXECUTE_ACTION'; + const CONTENT_GET_DOM_STATE = 'AB_CONTENT_GET_DOM_STATE'; + const CONTENT_PING = 'AB_CONTENT_PING'; + const PAGE_BRIDGE_EVENT = 'AB_PAGE_BRIDGE_EVENT'; + const STORAGE_OPTIONS_KEY = 'abExtensionOptionsV1'; + + const mutationState = { + total: 0, + recent: [], + observerReady: false, + }; + + function pushMutationSummary(entry) { + mutationState.total += 1; + mutationState.recent.push({ + ...entry, + timestamp: Date.now(), + }); + if (mutationState.recent.length > 40) { + mutationState.recent.splice(0, mutationState.recent.length - 40); + } + } + + function serializeValue(value, depth = 0) { + if (value === null || typeof value === 'undefined') return value; + if (typeof value === 'string') return value.slice(0, 300); + if (typeof value === 'number' || typeof value === 'boolean') return value; + if (value instanceof Error) return `${value.name}: ${value.message}`; + if (depth > 2) return '[depth-limit]'; + + if (Array.isArray(value)) { + return value.slice(0, 10).map((item) => serializeValue(item, depth + 1)); + } + + if (typeof value === 'object') { + const out = {}; + for (const [key, entry] of Object.entries(value).slice(0, 15)) { + out[key] = serializeValue(entry, depth + 1); + } + return out; + } + + return String(value).slice(0, 300); + } + + function sendRuntimeEvent(kind, payload) { + try { + chrome.runtime.sendMessage({ + type: CONTENT_EVENT_TYPE, + kind, + payload: serializeValue(payload), + url: window.location.href, + title: document.title, + timestamp: Date.now(), + }); + } catch { + // Ignore runtime channel errors. + } + } + + function getPageBridgeEnabled() { + return new Promise((resolve) => { + try { + chrome.storage.local.get([STORAGE_OPTIONS_KEY], (result) => { + if (chrome.runtime.lastError) { + resolve(false); + return; + } + const rawOptions = result?.[STORAGE_OPTIONS_KEY]; + resolve(Boolean(rawOptions && typeof rawOptions === 'object' && rawOptions.pageBridgeEnabled === true)); + }); + } catch { + resolve(false); + } + }); + } + + async function installPageBridge() { + // Receives events emitted by the injected page-world hook script. + const bridgeListener = (event) => { + if (event.source !== window) return; + const data = event.data; + if (!data || data.type !== PAGE_BRIDGE_EVENT) return; + sendRuntimeEvent(data.kind || 'page-event', data.payload || {}); + }; + + window.addEventListener('message', bridgeListener); + + const parent = document.documentElement || document.head || document.body; + if (!parent) return; + + if (!(await getPageBridgeEnabled())) { + sendRuntimeEvent('lifecycle', { + event: 'bridge-disabled-default', + }); + return; + } + + // Use external extension script instead of inline text to reduce CSP conflicts. + const script = document.createElement('script'); + script.src = chrome.runtime.getURL('page-bridge.js'); + script.async = false; + script.dataset.abBridgeEvent = PAGE_BRIDGE_EVENT; + script.onload = () => script.remove(); + script.onerror = () => { + sendRuntimeEvent('lifecycle', { + event: 'bridge-load-failed', + host: window.location.hostname, + }); + script.remove(); + }; + parent.appendChild(script); + } + + function ensureMutationObserver() { + if (mutationState.observerReady) return; + if (!document.documentElement) return; + + const observer = new MutationObserver((records) => { + const summary = { + records: records.length, + addedNodes: 0, + removedNodes: 0, + }; + + for (const record of records.slice(0, 40)) { + summary.addedNodes += record.addedNodes?.length || 0; + summary.removedNodes += record.removedNodes?.length || 0; + } + + pushMutationSummary(summary); + }); + + observer.observe(document.documentElement, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class', 'style', 'hidden', 'disabled', 'aria-hidden'], + }); + + mutationState.observerReady = true; + } + + function toSimpleNode(element) { + if (!element || typeof element !== 'object') return null; + const node = { + tag: element.tagName?.toLowerCase() || 'unknown', + id: element.id || undefined, + className: typeof element.className === 'string' ? element.className.slice(0, 120) : '', + role: element.getAttribute?.('role') || undefined, + name: + element.getAttribute?.('aria-label') || + element.getAttribute?.('name') || + element.getAttribute?.('placeholder') || + '', + text: (element.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 160), + disabled: element.disabled === true, + hidden: element.hidden === true, + }; + + return node; + } + + function collectInteractiveElements(root, limit = 80) { + const selector = [ + 'a[href]', + 'button', + 'input', + 'select', + 'textarea', + 'summary', + '[role="button"]', + '[role="link"]', + '[tabindex]' + ].join(','); + + const out = []; + const nodes = root.querySelectorAll(selector); + for (const element of nodes) { + if (out.length >= limit) break; + out.push(toSimpleNode(element)); + } + return out.filter(Boolean); + } + + function collectDomState(options = {}) { + const selector = typeof options.selector === 'string' ? options.selector.trim() : ''; + const root = selector ? document.querySelector(selector) : document.body || document.documentElement; + + if (!root) { + return { + ok: false, + error: selector ? `selector-not-found: ${selector}` : 'root-not-found', + }; + } + + const textPreview = (root.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 1000); + const interactiveOnly = options.interactiveOnly === true; + const interactiveElements = collectInteractiveElements(root, options.maxNodes || 80); + + const dom = { + href: window.location.href, + title: document.title, + readyState: document.readyState, + selector: selector || null, + rootTag: root.tagName?.toLowerCase() || 'unknown', + textPreview, + interactiveCount: interactiveElements.length, + interactiveElements, + mutation: { + total: mutationState.total, + recent: mutationState.recent.slice(-10), + }, + capturedAt: Date.now(), + }; + + if (interactiveOnly) { + dom.textPreview = ''; + } + + return { + ok: true, + state: dom, + }; + } + + function queryElement(selector) { + if (typeof selector !== 'string' || selector.trim().length === 0) { + throw new Error('selector is required'); + } + const element = document.querySelector(selector); + if (!element) { + throw new Error(`Element not found: ${selector}`); + } + return element; + } + + function focusElement(element) { + if (typeof element.focus === 'function') { + element.focus({ preventScroll: false }); + } + } + + function dispatchInputEvents(element) { + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); + } + + async function executeAction(command, args = {}) { + switch (command) { + case 'click': { + const element = queryElement(args.selector); + focusElement(element); + element.click(); + return { ok: true, action: command, selector: args.selector }; + } + case 'fill': { + const element = queryElement(args.selector); + if (!('value' in element)) { + throw new Error(`Element is not fillable: ${args.selector}`); + } + focusElement(element); + element.value = typeof args.value === 'string' ? args.value : String(args.value || ''); + dispatchInputEvents(element); + return { ok: true, action: command, selector: args.selector, valueLength: element.value.length }; + } + case 'press': { + const key = typeof args.key === 'string' && args.key.trim().length > 0 ? args.key.trim() : 'Enter'; + let target; + if (typeof args.selector === 'string' && args.selector.trim().length > 0) { + target = queryElement(args.selector); + focusElement(target); + } else { + target = document.activeElement || document.body; + } + + const down = new KeyboardEvent('keydown', { key, bubbles: true }); + const up = new KeyboardEvent('keyup', { key, bubbles: true }); + target.dispatchEvent(down); + target.dispatchEvent(up); + return { ok: true, action: command, key }; + } + case 'eval': { + if (typeof args.expression !== 'string' || args.expression.trim().length === 0) { + throw new Error('expression is required'); + } + const fn = new Function(`return (${args.expression});`); + const result = fn(); + return { ok: true, action: command, result: serializeValue(result) }; + } + case 'snapshot': { + return { + ok: true, + action: command, + ...collectDomState({ + selector: args.selector, + interactiveOnly: args.interactiveOnly === true, + maxNodes: args.maxNodes, + }), + }; + } + default: + throw new Error(`Unknown content action: ${command}`); + } + } + + ensureMutationObserver(); + installPageBridge(); + window.addEventListener('message', (event) => { if (event.source !== window) { return; @@ -80,4 +390,36 @@ ); } }); + + chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (!message || typeof message !== 'object') return; + + if (message.type === CONTENT_PING) { + sendResponse({ + ok: true, + href: window.location.href, + title: document.title, + readyState: document.readyState, + }); + return; + } + + if (message.type === CONTENT_GET_DOM_STATE) { + sendResponse(collectDomState(message.options || {})); + return; + } + + if (message.type === CONTENT_EXECUTE_ACTION) { + executeAction(message.command, message.args || {}) + .then((result) => sendResponse(result)) + .catch((error) => { + sendResponse({ + ok: false, + action: message.command, + error: error instanceof Error ? error.message : String(error), + }); + }); + return true; + } + }); })(); diff --git a/extensions/tab-group-cdp/manifest.json b/extensions/tab-group-cdp/manifest.json index a0c70bc..96bb1df 100644 --- a/extensions/tab-group-cdp/manifest.json +++ b/extensions/tab-group-cdp/manifest.json @@ -3,6 +3,9 @@ "name": "agent-browser-stealth", "version": "0.2.0", "description": "Session-aware tab grouping and coordination for CDP-driven agent-browser workflows.", + "icons": { + "128": "icons/icon.svg" + }, "permissions": ["tabs", "tabGroups", "downloads", "storage", "sidePanel", "alarms"], "host_permissions": [""], "background": { @@ -21,5 +24,11 @@ "run_at": "document_start", "match_about_blank": true } + ], + "web_accessible_resources": [ + { + "resources": ["page-bridge.js"], + "matches": [""] + } ] } diff --git a/extensions/tab-group-cdp/page-bridge.js b/extensions/tab-group-cdp/page-bridge.js new file mode 100644 index 0000000..477dc75 --- /dev/null +++ b/extensions/tab-group-cdp/page-bridge.js @@ -0,0 +1,133 @@ +(() => { + if (window.__AB_STEALTH_BRIDGE_INSTALLED__) return; + window.__AB_STEALTH_BRIDGE_INSTALLED__ = true; + + const currentScript = document.currentScript; + const TYPE = currentScript?.dataset?.abBridgeEvent || 'AB_PAGE_BRIDGE_EVENT'; + + const post = (kind, payload) => { + try { + window.postMessage({ type: TYPE, kind, payload, timestamp: Date.now() }, '*'); + } catch { + // Ignore post failures. + } + }; + + const serializeArg = (value, depth = 0) => { + if (value === null || typeof value === 'undefined') return value; + if (typeof value === 'string') return value.slice(0, 250); + if (typeof value === 'number' || typeof value === 'boolean') return value; + if (value instanceof Error) return `${value.name}: ${value.message}`; + if (depth > 2) return '[depth-limit]'; + if (Array.isArray(value)) return value.slice(0, 10).map((item) => serializeArg(item, depth + 1)); + if (typeof value === 'object') { + const out = {}; + const entries = Object.entries(value).slice(0, 12); + for (const [k, v] of entries) { + out[k] = serializeArg(v, depth + 1); + } + return out; + } + return String(value).slice(0, 250); + }; + + const patchConsoleMethod = (name) => { + const original = console[name]; + if (typeof original !== 'function') return; + console[name] = function patchedConsole(...args) { + post('console', { + level: name, + args: args.map((arg) => serializeArg(arg)), + }); + return original.apply(this, args); + }; + }; + + patchConsoleMethod('error'); + patchConsoleMethod('warn'); + + window.addEventListener('error', (event) => { + post('console', { + level: 'error', + message: event.message, + source: event.filename, + line: event.lineno, + column: event.colno, + }); + }); + + window.addEventListener('unhandledrejection', (event) => { + post('console', { + level: 'error', + message: 'Unhandled rejection', + reason: serializeArg(event.reason), + }); + }); + + if (typeof window.fetch === 'function') { + const originalFetch = window.fetch.bind(window); + window.fetch = async (...args) => { + const startedAt = Date.now(); + const requestInfo = args[0]; + const requestInit = args[1] || {}; + const method = requestInit.method || 'GET'; + const url = typeof requestInfo === 'string' ? requestInfo : requestInfo?.url || ''; + + try { + const response = await originalFetch(...args); + post('network', { + transport: 'fetch', + method, + url, + status: response.status, + ok: response.ok, + durationMs: Date.now() - startedAt, + }); + return response; + } catch (error) { + post('network', { + transport: 'fetch', + method, + url, + error: serializeArg(error), + durationMs: Date.now() - startedAt, + }); + throw error; + } + }; + } + + if (typeof window.XMLHttpRequest === 'function') { + const originalOpen = XMLHttpRequest.prototype.open; + const originalSend = XMLHttpRequest.prototype.send; + + XMLHttpRequest.prototype.open = function patchedOpen(method, url, ...rest) { + this.__abRequestMeta = { + method: typeof method === 'string' ? method : 'GET', + url: typeof url === 'string' ? url : String(url || ''), + startedAt: Date.now(), + }; + return originalOpen.call(this, method, url, ...rest); + }; + + XMLHttpRequest.prototype.send = function patchedSend(...args) { + this.addEventListener('loadend', () => { + const meta = this.__abRequestMeta || {}; + post('network', { + transport: 'xhr', + method: meta.method || 'GET', + url: meta.url || '', + status: this.status, + ok: this.status >= 200 && this.status < 400, + durationMs: Date.now() - (meta.startedAt || Date.now()), + }); + }); + return originalSend.apply(this, args); + }; + } + + post('lifecycle', { + event: 'bridge-installed', + href: location.href, + }); +})(); diff --git a/extensions/tab-group-cdp/service-worker.js b/extensions/tab-group-cdp/service-worker.js index b2c9cf3..72d5434 100644 --- a/extensions/tab-group-cdp/service-worker.js +++ b/extensions/tab-group-cdp/service-worker.js @@ -6,11 +6,36 @@ const PANEL_CLEAN_EMPTY_GROUPS = 'AB_PANEL_CLEAN_EMPTY_GROUPS'; const PANEL_SET_POLICY = 'AB_PANEL_SET_POLICY'; const PANEL_SET_OPTIONS = 'AB_PANEL_SET_OPTIONS'; +const PANEL_RUN_ACTION = 'AB_PANEL_RUN_ACTION'; +const PANEL_CLEAR_ACTIVITY = 'AB_PANEL_CLEAR_ACTIVITY'; +const PANEL_START_RECORDING = 'AB_PANEL_START_RECORDING'; +const PANEL_STOP_RECORDING = 'AB_PANEL_STOP_RECORDING'; +const PANEL_SAVE_RECORDING = 'AB_PANEL_SAVE_RECORDING'; +const PANEL_RUN_WORKFLOW = 'AB_PANEL_RUN_WORKFLOW'; +const PANEL_DELETE_WORKFLOW = 'AB_PANEL_DELETE_WORKFLOW'; +const PANEL_SET_SHORTCUT = 'AB_PANEL_SET_SHORTCUT'; +const PANEL_DELETE_SHORTCUT = 'AB_PANEL_DELETE_SHORTCUT'; +const PANEL_RUN_SHORTCUT = 'AB_PANEL_RUN_SHORTCUT'; +const PANEL_CREATE_SCHEDULE = 'AB_PANEL_CREATE_SCHEDULE'; +const PANEL_DELETE_SCHEDULE = 'AB_PANEL_DELETE_SCHEDULE'; +const PANEL_TOGGLE_SCHEDULE = 'AB_PANEL_TOGGLE_SCHEDULE'; + +const CONTENT_EVENT_TYPE = 'AB_CONTENT_EVENT'; +const CONTENT_EXECUTE_ACTION = 'AB_CONTENT_EXECUTE_ACTION'; +const CONTENT_GET_DOM_STATE = 'AB_CONTENT_GET_DOM_STATE'; +const CONTENT_PING = 'AB_CONTENT_PING'; + const DEFAULT_GROUP_TITLE = 'Agent Browser Stealth'; const DOWNLOAD_ARCHIVE_ROOT = 'agent-browser-stealth'; const STORAGE_POLICY_KEY = 'abSessionPoliciesV1'; const STORAGE_OPTIONS_KEY = 'abExtensionOptionsV1'; +const STORAGE_WORKFLOWS_KEY = 'abWorkflowsV1'; +const STORAGE_SHORTCUTS_KEY = 'abShortcutsV1'; +const STORAGE_SCHEDULES_KEY = 'abSchedulesV1'; + const CLEANUP_ALARM_NAME = 'ab-clean-empty-groups'; +const WORKFLOW_ALARM_PREFIX = 'ab-workflow-schedule:'; + const GROUP_COLORS = ['blue', 'green', 'pink', 'orange', 'purple', 'cyan', 'red', 'yellow']; const RISKY_TLDS = new Set(['zip', 'mov', 'click', 'top', 'gq', 'tk', 'country']); const RISKY_HOST_KEYWORDS = ['secure-login', 'account-verify', 'wallet-verify', 'airdrop-claim']; @@ -19,17 +44,49 @@ const DEFAULT_EXTENSION_OPTIONS = { strictWindowIsolation: true, suppressCrossWindowActivation: true, autoCleanEmptyGroups: true, + pageBridgeEnabled: false, }; +const MAX_ACTIVITY_EVENTS = 500; +const COMMAND_HISTORY_LIMIT = 120; + const sessionGroupCache = new Map(); +const sessionGroupTitleMap = new Map(); const sessionWindowMap = new Map(); const tabSessionMap = new Map(); const tabMetaById = new Map(); const downloadEvents = []; const sessionPolicies = new Map(); -let extensionOptions = { ...DEFAULT_EXTENSION_OPTIONS }; +const workflows = new Map(); +const shortcuts = new Map(); +const schedules = new Map(); +const activityEvents = []; +const commandHistory = []; +let latestDomState = null; + +let extensionOptions = { ...DEFAULT_EXTENSION_OPTIONS }; +// Transient workflow recorder state. Persisted only when saved as a workflow. +let recordingState = null; let bootstrapPromise = bootstrapState(); +let eventCounter = 0; + +function now() { + return Date.now(); +} + +function uid(prefix) { + const rand = Math.random().toString(36).slice(2, 10); + return `${prefix}-${Date.now().toString(36)}-${rand}`; +} + +function clampInt(value, min, max, fallback) { + const parsed = Number.parseInt(String(value ?? ''), 10); + if (!Number.isFinite(parsed)) return fallback; + if (parsed < min) return min; + if (parsed > max) return max; + return parsed; +} function normalizeSession(session) { if (typeof session !== 'string') return 'default'; @@ -51,6 +108,26 @@ function normalizeAllowedDomains(domains) { .slice(0, 256); } +function normalizeUrl(rawUrl) { + if (typeof rawUrl !== 'string') return null; + const trimmed = rawUrl.trim(); + if (!trimmed) return null; + + if (/^(https?|file|about|data|blob|chrome-extension):/i.test(trimmed)) { + return trimmed; + } + + return `https://${trimmed}`; +} + +function normalizeShortcutName(name) { + if (typeof name !== 'string') return null; + const trimmed = name.trim().toLowerCase(); + if (!trimmed) return null; + const normalized = trimmed.replace(/[^a-z0-9:_-]/g, '-').replace(/-+/g, '-').slice(0, 48); + return normalized || null; +} + function parseHostname(rawUrl) { if (typeof rawUrl !== 'string' || rawUrl.length === 0) return null; try { @@ -136,6 +213,50 @@ function shouldCollapseGroup(session) { return session !== 'default'; } +function defaultGroupTitleForSession(session) { + const normalized = normalizeSession(session); + if (normalized === 'default') { + return DEFAULT_GROUP_TITLE; + } + return normalizeGroupTitle(`${DEFAULT_GROUP_TITLE} • ${normalized}`); +} + +function getGroupTitleForSession(session) { + const normalized = normalizeSession(session); + return sessionGroupTitleMap.get(normalized) || defaultGroupTitleForSession(normalized); +} + +function createActivityEvent(kind, payload, meta = {}) { + return { + id: ++eventCounter, + kind, + payload, + tabId: typeof meta.tabId === 'number' ? meta.tabId : null, + session: typeof meta.session === 'string' ? meta.session : null, + url: typeof meta.url === 'string' ? meta.url : '', + title: typeof meta.title === 'string' ? meta.title : '', + source: typeof meta.source === 'string' ? meta.source : 'extension', + timestamp: now(), + }; +} + +function pushActivityEvent(kind, payload, meta = {}) { + activityEvents.push(createActivityEvent(kind, payload, meta)); + if (activityEvents.length > MAX_ACTIVITY_EVENTS) { + activityEvents.splice(0, activityEvents.length - MAX_ACTIVITY_EVENTS); + } +} + +function pushCommandHistory(entry) { + commandHistory.push({ + ...entry, + timestamp: now(), + }); + if (commandHistory.length > COMMAND_HISTORY_LIMIT) { + commandHistory.splice(0, commandHistory.length - COMMAND_HISTORY_LIMIT); + } +} + async function loadPolicies() { try { const result = await chrome.storage.local.get([STORAGE_POLICY_KEY]); @@ -159,6 +280,7 @@ function normalizeOptions(raw) { strictWindowIsolation: raw.strictWindowIsolation !== false, suppressCrossWindowActivation: raw.suppressCrossWindowActivation !== false, autoCleanEmptyGroups: raw.autoCleanEmptyGroups !== false, + pageBridgeEnabled: raw.pageBridgeEnabled === true, }; } @@ -176,19 +298,281 @@ async function persistOptions() { } async function setExtensionOptions(nextOptions) { - extensionOptions = { + const merged = { ...extensionOptions, - ...normalizeOptions(nextOptions), + ...(nextOptions && typeof nextOptions === 'object' ? nextOptions : {}), }; + extensionOptions = normalizeOptions(merged); await persistOptions(); await syncCleanupAlarm(); return extensionOptions; } +function normalizeWorkflowStep(rawStep) { + if (!rawStep || typeof rawStep !== 'object') return null; + if (typeof rawStep.action !== 'string' || rawStep.action.trim().length === 0) return null; + + const action = rawStep.action.trim(); + return { + id: typeof rawStep.id === 'string' ? rawStep.id : uid('step'), + action, + args: rawStep.args && typeof rawStep.args === 'object' ? rawStep.args : {}, + timeoutMs: clampInt(rawStep.timeoutMs, 0, 120_000, 0), + retries: clampInt(rawStep.retries, 0, 5, 0), + }; +} + +function normalizeWorkflow(rawWorkflow) { + if (!rawWorkflow || typeof rawWorkflow !== 'object') return null; + if (typeof rawWorkflow.id !== 'string' || rawWorkflow.id.length === 0) return null; + + const steps = Array.isArray(rawWorkflow.steps) + ? rawWorkflow.steps.map((step) => normalizeWorkflowStep(step)).filter(Boolean) + : []; + + return { + id: rawWorkflow.id, + name: + typeof rawWorkflow.name === 'string' && rawWorkflow.name.trim().length > 0 + ? rawWorkflow.name.trim().slice(0, 120) + : rawWorkflow.id, + steps, + createdAt: clampInt(rawWorkflow.createdAt, 0, Number.MAX_SAFE_INTEGER, now()), + updatedAt: clampInt(rawWorkflow.updatedAt, 0, Number.MAX_SAFE_INTEGER, now()), + }; +} + +function normalizeCadence(rawCadence) { + if (!rawCadence || typeof rawCadence !== 'object') { + return { + kind: 'daily', + hour: 9, + minute: 0, + }; + } + + const kind = + rawCadence.kind === 'weekly' || + rawCadence.kind === 'monthly' || + rawCadence.kind === 'yearly' || + rawCadence.kind === 'daily' + ? rawCadence.kind + : 'daily'; + + const cadence = { + kind, + hour: clampInt(rawCadence.hour, 0, 23, 9), + minute: clampInt(rawCadence.minute, 0, 59, 0), + }; + + if (kind === 'weekly') { + const weekdays = Array.isArray(rawCadence.weekdays) + ? rawCadence.weekdays.map((day) => clampInt(day, 0, 6, 1)) + : [clampInt(rawCadence.weekday, 0, 6, 1)]; + cadence.weekdays = [...new Set(weekdays)].slice(0, 7); + } + + if (kind === 'monthly') { + cadence.dayOfMonth = clampInt(rawCadence.dayOfMonth, 1, 31, 1); + } + + if (kind === 'yearly') { + cadence.month = clampInt(rawCadence.month, 1, 12, 1); + cadence.dayOfMonth = clampInt(rawCadence.dayOfMonth, 1, 31, 1); + } + + return cadence; +} + +function daysInMonth(year, monthIndex) { + return new Date(year, monthIndex + 1, 0).getDate(); +} + +function computeNextRun(cadence, fromTs = now()) { + const from = new Date(fromTs + 1000); + const hour = clampInt(cadence.hour, 0, 23, 9); + const minute = clampInt(cadence.minute, 0, 59, 0); + + if (cadence.kind === 'weekly') { + const weekdays = Array.isArray(cadence.weekdays) && cadence.weekdays.length > 0 + ? cadence.weekdays.map((day) => clampInt(day, 0, 6, 1)) + : [1]; + + for (let offset = 0; offset < 14; offset += 1) { + const candidate = new Date(from); + candidate.setDate(from.getDate() + offset); + candidate.setHours(hour, minute, 0, 0); + if (candidate <= from) continue; + if (weekdays.includes(candidate.getDay())) { + return candidate.getTime(); + } + } + } + + if (cadence.kind === 'monthly') { + const dayOfMonth = clampInt(cadence.dayOfMonth, 1, 31, 1); + const candidate = new Date(from); + + for (let i = 0; i < 24; i += 1) { + const monthDate = new Date(candidate.getFullYear(), candidate.getMonth() + i, 1); + const maxDay = daysInMonth(monthDate.getFullYear(), monthDate.getMonth()); + monthDate.setDate(Math.min(dayOfMonth, maxDay)); + monthDate.setHours(hour, minute, 0, 0); + if (monthDate > from) { + return monthDate.getTime(); + } + } + } + + if (cadence.kind === 'yearly') { + const month = clampInt(cadence.month, 1, 12, 1) - 1; + const dayOfMonth = clampInt(cadence.dayOfMonth, 1, 31, 1); + + for (let yearOffset = 0; yearOffset < 5; yearOffset += 1) { + const year = from.getFullYear() + yearOffset; + const maxDay = daysInMonth(year, month); + const candidate = new Date(year, month, Math.min(dayOfMonth, maxDay), hour, minute, 0, 0); + if (candidate > from) { + return candidate.getTime(); + } + } + } + + const dailyCandidate = new Date(from); + dailyCandidate.setHours(hour, minute, 0, 0); + if (dailyCandidate <= from) { + dailyCandidate.setDate(dailyCandidate.getDate() + 1); + } + return dailyCandidate.getTime(); +} + +function normalizeSchedule(rawSchedule) { + if (!rawSchedule || typeof rawSchedule !== 'object') return null; + if (typeof rawSchedule.id !== 'string' || rawSchedule.id.length === 0) return null; + if (typeof rawSchedule.workflowId !== 'string' || rawSchedule.workflowId.length === 0) return null; + + const cadence = normalizeCadence(rawSchedule.cadence); + + const nextRunAt = + typeof rawSchedule.nextRunAt === 'number' && Number.isFinite(rawSchedule.nextRunAt) + ? rawSchedule.nextRunAt + : computeNextRun(cadence); + + return { + id: rawSchedule.id, + name: + typeof rawSchedule.name === 'string' && rawSchedule.name.trim().length > 0 + ? rawSchedule.name.trim().slice(0, 120) + : rawSchedule.id, + workflowId: rawSchedule.workflowId, + cadence, + enabled: rawSchedule.enabled !== false, + createdAt: clampInt(rawSchedule.createdAt, 0, Number.MAX_SAFE_INTEGER, now()), + updatedAt: clampInt(rawSchedule.updatedAt, 0, Number.MAX_SAFE_INTEGER, now()), + lastRunAt: + typeof rawSchedule.lastRunAt === 'number' && Number.isFinite(rawSchedule.lastRunAt) + ? rawSchedule.lastRunAt + : null, + nextRunAt, + }; +} + +async function persistWorkflows() { + const payload = [...workflows.values()]; + await chrome.storage.local.set({ [STORAGE_WORKFLOWS_KEY]: payload }); +} + +async function persistShortcuts() { + const payload = Object.fromEntries(shortcuts.entries()); + await chrome.storage.local.set({ [STORAGE_SHORTCUTS_KEY]: payload }); +} + +async function persistSchedules() { + const payload = [...schedules.values()]; + await chrome.storage.local.set({ [STORAGE_SCHEDULES_KEY]: payload }); +} + +async function loadAutomationState() { + try { + const result = await chrome.storage.local.get([ + STORAGE_WORKFLOWS_KEY, + STORAGE_SHORTCUTS_KEY, + STORAGE_SCHEDULES_KEY, + ]); + + const workflowEntries = Array.isArray(result?.[STORAGE_WORKFLOWS_KEY]) + ? result[STORAGE_WORKFLOWS_KEY] + : []; + + workflows.clear(); + for (const entry of workflowEntries) { + const workflow = normalizeWorkflow(entry); + if (!workflow) continue; + workflows.set(workflow.id, workflow); + } + + const shortcutEntries = + result?.[STORAGE_SHORTCUTS_KEY] && typeof result[STORAGE_SHORTCUTS_KEY] === 'object' + ? result[STORAGE_SHORTCUTS_KEY] + : {}; + + shortcuts.clear(); + for (const [name, workflowId] of Object.entries(shortcutEntries)) { + const shortcutName = normalizeShortcutName(name); + if (!shortcutName) continue; + if (typeof workflowId !== 'string') continue; + if (!workflows.has(workflowId)) continue; + shortcuts.set(shortcutName, workflowId); + } + + const scheduleEntries = Array.isArray(result?.[STORAGE_SCHEDULES_KEY]) + ? result[STORAGE_SCHEDULES_KEY] + : []; + + schedules.clear(); + for (const entry of scheduleEntries) { + const schedule = normalizeSchedule(entry); + if (!schedule) continue; + if (!workflows.has(schedule.workflowId)) continue; + schedules.set(schedule.id, schedule); + } + } catch { + workflows.clear(); + shortcuts.clear(); + schedules.clear(); + } +} + +async function scheduleWorkflowAlarm(schedule) { + const alarmName = `${WORKFLOW_ALARM_PREFIX}${schedule.id}`; + await chrome.alarms.clear(alarmName); + + if (!schedule.enabled) { + return; + } + + if (typeof schedule.nextRunAt !== 'number' || !Number.isFinite(schedule.nextRunAt)) { + schedule.nextRunAt = computeNextRun(schedule.cadence); + schedule.updatedAt = now(); + } + + await chrome.alarms.create(alarmName, { + when: Math.max(schedule.nextRunAt, now() + 5_000), + }); +} + +async function syncAllWorkflowAlarms() { + for (const schedule of schedules.values()) { + await scheduleWorkflowAlarm(schedule); + } +} + async function bootstrapState() { await loadPolicies(); await loadOptions(); + await loadAutomationState(); await syncCleanupAlarm(); + await syncAllWorkflowAlarms(); } async function persistPolicies() { @@ -220,7 +604,7 @@ function updateTabMeta(tab) { title: typeof tab.title === 'string' ? tab.title : '', groupId: typeof tab.groupId === 'number' ? tab.groupId : -1, active: tab.active === true, - lastSeenAt: Date.now(), + lastSeenAt: now(), }); } @@ -232,7 +616,7 @@ function pruneDownloadEvents() { } function recordDownloadEvent(event) { - downloadEvents.push({ ...event, timestamp: Date.now() }); + downloadEvents.push({ ...event, timestamp: now() }); pruneDownloadEvents(); } @@ -342,6 +726,7 @@ async function ensureSessionGroup(tabId, windowId, session, groupTitle) { sessionGroupCache.set(key, groupId); sessionWindowMap.set(session, targetWindowId); + sessionGroupTitleMap.set(session, groupTitle); return { groupId, @@ -386,6 +771,34 @@ function getManagedSessionForTab(tabId) { return tabSessionMap.get(tabId); } +async function ensureManagedTab(tabId, sessionHint) { + if (typeof tabId !== 'number') return null; + + let tab; + try { + tab = await chrome.tabs.get(tabId); + } catch { + return null; + } + + if (typeof tab.windowId !== 'number') { + return null; + } + + const session = normalizeSession(sessionHint || getManagedSessionForTab(tabId) || 'default'); + const groupTitle = getGroupTitleForSession(session); + tabSessionMap.set(tabId, session); + updateTabMeta(tab); + + try { + const grouping = await ensureSessionGroup(tabId, tab.windowId, session, groupTitle); + return { session, groupTitle, grouping }; + } catch { + // Best-effort grouping: keep session mapping even if group APIs fail. + return { session, groupTitle, grouping: null }; + } +} + function collectSessionTabIds(session) { const result = []; for (const [tabId, tabSession] of tabSessionMap.entries()) { @@ -544,6 +957,68 @@ async function updateRiskBadge(tabId) { await chrome.action.setTitle({ title }).catch(() => {}); } +function buildWorkflowSummary() { + return [...workflows.values()].map((workflow) => ({ + id: workflow.id, + name: workflow.name, + stepCount: workflow.steps.length, + steps: workflow.steps, + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt, + })); +} + +function buildShortcutSummary() { + return [...shortcuts.entries()].map(([name, workflowId]) => ({ + name, + workflowId, + workflowName: workflows.get(workflowId)?.name || workflowId, + })); +} + +function buildScheduleSummary() { + return [...schedules.values()].map((schedule) => ({ + ...schedule, + workflowName: workflows.get(schedule.workflowId)?.name || schedule.workflowId, + })); +} + +function buildActivitySummary() { + const recent = activityEvents.slice(-200).reverse(); + return { + events: recent, + console: recent.filter((event) => event.kind === 'console').slice(0, 80), + network: recent.filter((event) => event.kind === 'network').slice(0, 120), + commandHistory: commandHistory.slice(-80).reverse(), + }; +} + +async function getControlState() { + const tabs = await chrome.tabs.query({ currentWindow: true }); + const activeTab = tabs.find((tab) => tab.active === true) || tabs[0] || null; + + return { + activeTab: + activeTab && typeof activeTab.id === 'number' + ? { + id: activeTab.id, + title: activeTab.title || '', + url: activeTab.url || '', + windowId: activeTab.windowId, + } + : null, + tabs: tabs.map((tab) => ({ + id: tab.id, + index: tab.index, + title: tab.title || '(Untitled)', + url: tab.url || '', + active: tab.active === true, + windowId: tab.windowId, + session: getManagedSessionForTab(tab.id), + })), + }; +} + async function buildPanelState() { const allTabs = await chrome.tabs.query({}); for (const tab of allTabs) { @@ -614,6 +1089,8 @@ async function buildPanelState() { sessions.sort((a, b) => a.session.localeCompare(b.session)); + const control = await getControlState(); + return { extensionId: chrome.runtime.id, options: { ...extensionOptions }, @@ -623,9 +1100,735 @@ async function buildPanelState() { }, sessions, downloads: downloadEvents.slice(-25).reverse(), + latestDomState, + control, + activity: buildActivitySummary(), + automation: { + recording: recordingState + ? { + id: recordingState.id, + name: recordingState.name, + startedAt: recordingState.startedAt, + stoppedAt: recordingState.stoppedAt, + stepCount: recordingState.steps.length, + steps: recordingState.steps, + } + : null, + workflows: buildWorkflowSummary(), + shortcuts: buildShortcutSummary(), + schedules: buildScheduleSummary(), + }, }; } +async function waitForTabSettled(tabId, timeoutMs = 15_000) { + return new Promise((resolve, reject) => { + const deadline = now() + timeoutMs; + + const timer = setTimeout(() => { + chrome.tabs.onUpdated.removeListener(onUpdated); + reject(new Error('tab-load-timeout')); + }, timeoutMs); + + const onUpdated = (updatedTabId, changeInfo) => { + if (updatedTabId !== tabId) return; + if (changeInfo.status === 'complete') { + clearTimeout(timer); + chrome.tabs.onUpdated.removeListener(onUpdated); + resolve(true); + } + }; + + chrome.tabs.onUpdated.addListener(onUpdated); + + chrome.tabs + .get(tabId) + .then((tab) => { + if (tab.status === 'complete') { + clearTimeout(timer); + chrome.tabs.onUpdated.removeListener(onUpdated); + resolve(true); + return; + } + + if (now() > deadline) { + clearTimeout(timer); + chrome.tabs.onUpdated.removeListener(onUpdated); + reject(new Error('tab-load-timeout')); + } + }) + .catch(() => { + clearTimeout(timer); + chrome.tabs.onUpdated.removeListener(onUpdated); + reject(new Error('tab-not-found')); + }); + }); +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function getOrCreateActionTab(tabId) { + if (typeof tabId === 'number') { + try { + const tab = await chrome.tabs.get(tabId); + return tab; + } catch { + // fall through + } + } + + const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (activeTab && typeof activeTab.id === 'number') { + return activeTab; + } + + return chrome.tabs.create({ url: 'about:blank', active: true }); +} + +async function sendContentCommand(tabId, message) { + try { + return await chrome.tabs.sendMessage(tabId, message); + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function shouldRecordAction(action) { + return ![ + 'tabs:list', + 'dom-state', + 'snapshot', + 'shortcut:run', + 'workflow:run', + ].includes(action); +} + +function recordWorkflowStep(action, args) { + if (!recordingState) return; + if (!shouldRecordAction(action)) return; + + recordingState.steps.push({ + id: uid('step'), + action, + args: args && typeof args === 'object' ? { ...args } : {}, + retries: 0, + timeoutMs: 0, + }); +} + +async function runShortcutByName(name, options = {}) { + const shortcutName = normalizeShortcutName(name); + if (!shortcutName) { + return { ok: false, error: 'shortcut-name-required' }; + } + + const workflowId = shortcuts.get(shortcutName); + if (!workflowId) { + return { ok: false, error: `shortcut-not-found:${shortcutName}` }; + } + + const runResult = await runWorkflowById(workflowId, { + source: options.source || 'shortcut', + tabId: options.tabId, + }); + + return { + ...runResult, + shortcut: shortcutName, + workflowId, + }; +} + +async function runActionInternal(request, options = {}) { + const action = typeof request.action === 'string' ? request.action.trim() : ''; + const args = request.args && typeof request.args === 'object' ? request.args : {}; + const source = options.source || 'panel'; + + if (!action) { + return { ok: false, error: 'action-required' }; + } + + if (action.startsWith('/')) { + return runShortcutByName(action.slice(1), { + source, + tabId: request.tabId, + }); + } + + let tab = null; + let tabId = typeof request.tabId === 'number' ? request.tabId : undefined; + let sourceSession = undefined; + + if (!['tabs:list'].includes(action)) { + tab = await getOrCreateActionTab(tabId); + tabId = tab.id; + const managed = await ensureManagedTab(tabId, getManagedSessionForTab(tabId)); + sourceSession = managed?.session; + } + + let result; + + switch (action) { + case 'open': { + const url = normalizeUrl(args.url || request.url); + if (!url) { + result = { ok: false, error: 'url-required' }; + break; + } + const updatedTab = await chrome.tabs.update(tabId, { url, active: true }); + await waitForTabSettled(updatedTab.id, clampInt(args.timeoutMs, 1000, 60_000, 12_000)).catch( + () => {} + ); + result = { + ok: true, + action, + tabId: updatedTab.id, + url, + }; + break; + } + + case 'back': { + if (typeof chrome.tabs.goBack === 'function') { + await chrome.tabs.goBack(tabId); + } else { + await sendContentCommand(tabId, { + type: CONTENT_EXECUTE_ACTION, + command: 'eval', + args: { expression: '(() => { history.back(); return true; })()' }, + }); + } + result = { ok: true, action, tabId }; + break; + } + + case 'forward': { + if (typeof chrome.tabs.goForward === 'function') { + await chrome.tabs.goForward(tabId); + } else { + await sendContentCommand(tabId, { + type: CONTENT_EXECUTE_ACTION, + command: 'eval', + args: { expression: '(() => { history.forward(); return true; })()' }, + }); + } + result = { ok: true, action, tabId }; + break; + } + + case 'reload': { + await chrome.tabs.reload(tabId); + await waitForTabSettled(tabId, clampInt(args.timeoutMs, 1000, 60_000, 10_000)).catch(() => {}); + result = { ok: true, action, tabId }; + break; + } + + case 'wait': { + const ms = clampInt(args.ms, 0, 120_000, 1000); + await delay(ms); + result = { ok: true, action, waitedMs: ms, tabId }; + break; + } + + case 'click': + case 'fill': + case 'press': + case 'eval': + case 'snapshot': { + // DOM-level commands are delegated to the page content-script. + const commandArgs = + action === 'eval' + ? { expression: args.expression } + : { + selector: args.selector, + value: args.value, + key: args.key, + interactiveOnly: args.interactiveOnly === true, + maxNodes: args.maxNodes, + }; + + const command = action === 'eval' ? 'eval' : action; + const response = await sendContentCommand(tabId, { + type: CONTENT_EXECUTE_ACTION, + command, + args: commandArgs, + }); + + result = { + ...(response && typeof response === 'object' ? response : { ok: false, error: 'invalid-response' }), + action, + tabId, + }; + break; + } + + case 'dom-state': { + const response = await sendContentCommand(tabId, { + type: CONTENT_GET_DOM_STATE, + options: { + selector: args.selector, + interactiveOnly: args.interactiveOnly === true, + maxNodes: args.maxNodes, + }, + }); + + result = { + ...(response && typeof response === 'object' ? response : { ok: false, error: 'invalid-response' }), + action, + tabId, + }; + break; + } + + case 'tabs:list': { + const tabs = await chrome.tabs.query({ currentWindow: true }); + result = { + ok: true, + action, + tabs: tabs.map((entry) => ({ + id: entry.id, + index: entry.index, + title: entry.title, + url: entry.url, + active: entry.active === true, + session: getManagedSessionForTab(entry.id), + })), + }; + break; + } + + case 'tabs:new': { + const url = normalizeUrl(args.url) || 'about:blank'; + const created = await chrome.tabs.create({ url, active: true }); + const managed = await ensureManagedTab(created.id, sourceSession || 'default'); + result = { + ok: true, + action, + tabId: created.id, + url, + session: managed?.session || null, + groupId: managed?.grouping?.groupId ?? null, + }; + break; + } + + case 'tabs:switch': { + if (typeof args.tabId === 'number') { + const switched = await chrome.tabs.update(args.tabId, { active: true }); + if (typeof switched.windowId === 'number') { + await chrome.windows.update(switched.windowId, { focused: true }).catch(() => {}); + } + const managed = await ensureManagedTab(switched.id, sourceSession || 'default'); + result = { + ok: true, + action, + tabId: switched.id, + session: managed?.session || null, + }; + break; + } + + const index = clampInt(args.index, 0, 500, 0); + const tabs = await chrome.tabs.query({ currentWindow: true }); + const target = tabs.find((item) => item.index === index); + if (!target || typeof target.id !== 'number') { + result = { ok: false, error: `tab-index-not-found:${index}` }; + break; + } + + const switched = await chrome.tabs.update(target.id, { active: true }); + const managed = await ensureManagedTab(switched.id, sourceSession || 'default'); + result = { + ok: true, + action, + tabId: switched.id, + session: managed?.session || null, + }; + break; + } + + case 'tabs:close': { + await chrome.tabs.remove(tabId); + result = { ok: true, action, tabId }; + break; + } + + case 'shortcut:run': { + result = await runShortcutByName(args.name, { + source, + tabId, + }); + break; + } + + default: + result = { ok: false, error: `unknown-action:${action}` }; + } + + pushCommandHistory({ + action, + args, + ok: result?.ok === true, + source, + error: result?.ok === true ? null : result?.error || 'unknown', + }); + + if (options.record !== false && result?.ok === true) { + recordWorkflowStep(action, args); + } + + if ( + (action === 'dom-state' || action === 'snapshot') && + result?.ok === true && + result?.state && + typeof result.state === 'object' + ) { + latestDomState = result.state; + } + + pushActivityEvent('command', { + action, + ok: result?.ok === true, + error: result?.ok === true ? null : result?.error || 'unknown', + }, { + source, + tabId, + session: typeof tabId === 'number' ? getManagedSessionForTab(tabId) : null, + url: tab?.url || '', + title: tab?.title || '', + }); + + return result; +} + +async function runWorkflowById(workflowId, options = {}) { + const workflow = workflows.get(workflowId); + if (!workflow) { + return { ok: false, error: `workflow-not-found:${workflowId}` }; + } + + let currentTabId = typeof options.tabId === 'number' ? options.tabId : undefined; + const results = []; + + for (const step of workflow.steps) { + // Retry each step with bounded backoff for transient page timing issues. + let stepResult = null; + const retries = clampInt(step.retries, 0, 5, 0); + + for (let attempt = 0; attempt <= retries; attempt += 1) { + stepResult = await runActionInternal( + { + action: step.action, + args: step.args, + tabId: currentTabId, + }, + { + source: options.source || 'workflow', + record: false, + } + ); + + if (stepResult?.ok === true) { + break; + } + + if (attempt < retries) { + await delay(300 * (attempt + 1)); + } + } + + results.push({ + action: step.action, + ok: stepResult?.ok === true, + error: stepResult?.ok === true ? null : stepResult?.error || 'unknown', + }); + + if (stepResult?.ok !== true) { + pushActivityEvent('workflow', { + workflowId: workflow.id, + workflowName: workflow.name, + ok: false, + failedAction: step.action, + error: stepResult?.error || 'unknown', + }, { + source: options.source || 'workflow', + }); + + return { + ok: false, + workflowId: workflow.id, + workflowName: workflow.name, + error: `workflow-step-failed:${step.action}`, + results, + }; + } + + if (typeof stepResult.tabId === 'number') { + currentTabId = stepResult.tabId; + } + + if (step.timeoutMs > 0) { + await delay(step.timeoutMs); + } + } + + pushActivityEvent('workflow', { + workflowId: workflow.id, + workflowName: workflow.name, + ok: true, + steps: workflow.steps.length, + }, { + source: options.source || 'workflow', + }); + + return { + ok: true, + workflowId: workflow.id, + workflowName: workflow.name, + results, + }; +} + +async function startRecording(name) { + const normalizedName = + typeof name === 'string' && name.trim().length > 0 ? name.trim().slice(0, 120) : 'Recorded Workflow'; + + recordingState = { + id: uid('recording'), + name: normalizedName, + startedAt: now(), + stoppedAt: null, + steps: [], + }; + + pushActivityEvent('recording', { + event: 'start', + name: normalizedName, + }, { + source: 'panel', + }); + + return { ok: true, recording: recordingState }; +} + +async function stopRecording() { + if (!recordingState) { + return { ok: false, error: 'recording-not-active' }; + } + + recordingState.stoppedAt = now(); + + pushActivityEvent('recording', { + event: 'stop', + steps: recordingState.steps.length, + name: recordingState.name, + }, { + source: 'panel', + }); + + return { ok: true, recording: recordingState }; +} + +async function saveRecordingAsWorkflow(name) { + if (!recordingState) { + return { ok: false, error: 'recording-not-active' }; + } + + if (recordingState.steps.length === 0) { + return { ok: false, error: 'recording-has-no-steps' }; + } + + const workflowName = + typeof name === 'string' && name.trim().length > 0 + ? name.trim().slice(0, 120) + : recordingState.name || 'Recorded Workflow'; + + const workflow = { + id: uid('workflow'), + name: workflowName, + steps: recordingState.steps.map((step) => normalizeWorkflowStep(step)).filter(Boolean), + createdAt: now(), + updatedAt: now(), + }; + + workflows.set(workflow.id, workflow); + await persistWorkflows(); + + pushActivityEvent('recording', { + event: 'saved', + workflowId: workflow.id, + workflowName: workflow.name, + steps: workflow.steps.length, + }, { + source: 'panel', + }); + + recordingState = null; + return { ok: true, workflow }; +} + +async function deleteWorkflow(workflowId) { + if (!workflows.has(workflowId)) { + return { ok: false, error: `workflow-not-found:${workflowId}` }; + } + + workflows.delete(workflowId); + + for (const [name, mappedWorkflowId] of [...shortcuts.entries()]) { + if (mappedWorkflowId === workflowId) { + shortcuts.delete(name); + } + } + + for (const [scheduleId, schedule] of [...schedules.entries()]) { + if (schedule.workflowId === workflowId) { + schedules.delete(scheduleId); + await chrome.alarms.clear(`${WORKFLOW_ALARM_PREFIX}${scheduleId}`); + } + } + + await persistWorkflows(); + await persistShortcuts(); + await persistSchedules(); + + return { ok: true }; +} + +async function setShortcut(name, workflowId) { + const shortcutName = normalizeShortcutName(name); + if (!shortcutName) { + return { ok: false, error: 'invalid-shortcut-name' }; + } + + if (!workflows.has(workflowId)) { + return { ok: false, error: `workflow-not-found:${workflowId}` }; + } + + shortcuts.set(shortcutName, workflowId); + await persistShortcuts(); + + return { + ok: true, + shortcut: { + name: shortcutName, + workflowId, + workflowName: workflows.get(workflowId)?.name || workflowId, + }, + }; +} + +async function deleteShortcut(name) { + const shortcutName = normalizeShortcutName(name); + if (!shortcutName) { + return { ok: false, error: 'invalid-shortcut-name' }; + } + + shortcuts.delete(shortcutName); + await persistShortcuts(); + return { ok: true }; +} + +async function createSchedule(input) { + if (!input || typeof input !== 'object') { + return { ok: false, error: 'schedule-input-required' }; + } + + if (typeof input.workflowId !== 'string' || !workflows.has(input.workflowId)) { + return { ok: false, error: 'invalid-workflow-id' }; + } + + const cadence = normalizeCadence(input.cadence); + const schedule = { + id: uid('schedule'), + name: + typeof input.name === 'string' && input.name.trim().length > 0 + ? input.name.trim().slice(0, 120) + : `Schedule ${workflows.get(input.workflowId)?.name || input.workflowId}`, + workflowId: input.workflowId, + cadence, + enabled: input.enabled !== false, + createdAt: now(), + updatedAt: now(), + lastRunAt: null, + nextRunAt: computeNextRun(cadence), + }; + + schedules.set(schedule.id, schedule); + await persistSchedules(); + await scheduleWorkflowAlarm(schedule); + + return { ok: true, schedule }; +} + +async function deleteSchedule(scheduleId) { + if (!schedules.has(scheduleId)) { + return { ok: false, error: `schedule-not-found:${scheduleId}` }; + } + + schedules.delete(scheduleId); + await chrome.alarms.clear(`${WORKFLOW_ALARM_PREFIX}${scheduleId}`); + await persistSchedules(); + return { ok: true }; +} + +async function toggleSchedule(scheduleId, enabled) { + const schedule = schedules.get(scheduleId); + if (!schedule) { + return { ok: false, error: `schedule-not-found:${scheduleId}` }; + } + + schedule.enabled = enabled !== false; + schedule.updatedAt = now(); + if (schedule.enabled && (!schedule.nextRunAt || schedule.nextRunAt <= now())) { + schedule.nextRunAt = computeNextRun(schedule.cadence); + } + + schedules.set(schedule.id, schedule); + await persistSchedules(); + await scheduleWorkflowAlarm(schedule); + + return { ok: true, schedule }; +} + +async function runSchedule(scheduleId) { + const schedule = schedules.get(scheduleId); + if (!schedule) { + return; + } + + if (!schedule.enabled) { + await scheduleWorkflowAlarm(schedule); + return; + } + + const runResult = await runWorkflowById(schedule.workflowId, { + source: `schedule:${schedule.id}`, + }); + + schedule.lastRunAt = now(); + schedule.updatedAt = now(); + schedule.nextRunAt = computeNextRun(schedule.cadence, schedule.lastRunAt); + schedules.set(schedule.id, schedule); + + await persistSchedules(); + await scheduleWorkflowAlarm(schedule); + + pushActivityEvent('schedule', { + scheduleId: schedule.id, + scheduleName: schedule.name, + workflowId: schedule.workflowId, + ok: runResult.ok === true, + error: runResult.ok === true ? null : runResult.error || 'unknown', + }, { + source: 'alarm', + }); +} + async function handleTabGroupRequest(message, sender) { await bootstrapPromise; @@ -709,6 +1912,23 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return true; } + if (type === CONTENT_EVENT_TYPE) { + const tabId = sender.tab?.id; + const session = typeof tabId === 'number' ? getManagedSessionForTab(tabId) : null; + const kind = typeof message.kind === 'string' ? message.kind : 'unknown'; + + pushActivityEvent(kind, message.payload || {}, { + source: 'content-script', + tabId, + session, + url: sender.tab?.url || message.url || '', + title: sender.tab?.title || message.title || '', + }); + + sendResponse({ ok: true }); + return; + } + if (type === PANEL_GET_STATE) { bootstrapPromise .then(() => buildPanelState()) @@ -720,6 +1940,142 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return true; } + if (type === PANEL_RUN_ACTION) { + bootstrapPromise + .then(() => + runActionInternal( + { + action: message.action, + args: message.args || {}, + tabId: message.tabId, + }, + { + source: 'panel', + record: true, + } + ) + ) + .then((result) => sendResponse(result)) + .catch((error) => { + sendResponse({ + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + }); + return true; + } + + if (type === PANEL_CLEAR_ACTIVITY) { + activityEvents.length = 0; + sendResponse({ ok: true }); + return; + } + + if (type === PANEL_START_RECORDING) { + startRecording(message.name) + .then((result) => sendResponse(result)) + .catch((error) => { + sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }); + }); + return true; + } + + if (type === PANEL_STOP_RECORDING) { + stopRecording() + .then((result) => sendResponse(result)) + .catch((error) => { + sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }); + }); + return true; + } + + if (type === PANEL_SAVE_RECORDING) { + saveRecordingAsWorkflow(message.name) + .then((result) => sendResponse(result)) + .catch((error) => { + sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }); + }); + return true; + } + + if (type === PANEL_RUN_WORKFLOW) { + runWorkflowById(message.workflowId, { + source: 'panel-workflow', + tabId: message.tabId, + }) + .then((result) => sendResponse(result)) + .catch((error) => { + sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }); + }); + return true; + } + + if (type === PANEL_DELETE_WORKFLOW) { + deleteWorkflow(message.workflowId) + .then((result) => sendResponse(result)) + .catch((error) => { + sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }); + }); + return true; + } + + if (type === PANEL_SET_SHORTCUT) { + setShortcut(message.name, message.workflowId) + .then((result) => sendResponse(result)) + .catch((error) => { + sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }); + }); + return true; + } + + if (type === PANEL_DELETE_SHORTCUT) { + deleteShortcut(message.name) + .then((result) => sendResponse(result)) + .catch((error) => { + sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }); + }); + return true; + } + + if (type === PANEL_RUN_SHORTCUT) { + runShortcutByName(message.name, { + source: 'panel-shortcut', + tabId: message.tabId, + }) + .then((result) => sendResponse(result)) + .catch((error) => { + sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }); + }); + return true; + } + + if (type === PANEL_CREATE_SCHEDULE) { + createSchedule(message.schedule) + .then((result) => sendResponse(result)) + .catch((error) => { + sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }); + }); + return true; + } + + if (type === PANEL_DELETE_SCHEDULE) { + deleteSchedule(message.scheduleId) + .then((result) => sendResponse(result)) + .catch((error) => { + sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }); + }); + return true; + } + + if (type === PANEL_TOGGLE_SCHEDULE) { + toggleSchedule(message.scheduleId, message.enabled) + .then((result) => sendResponse(result)) + .catch((error) => { + sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }); + }); + return true; + } + if (type === PANEL_CLOSE_OTHER_TABS) { closeOtherSessionTabs(message.session) .then((result) => sendResponse({ ok: true, result })) @@ -771,6 +2127,10 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { }); return true; } + + if (type === CONTENT_PING) { + sendResponse({ ok: true, extensionId: chrome.runtime.id }); + } }); chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { @@ -814,6 +2174,7 @@ chrome.tabs.onRemoved.addListener((tabId, removeInfo) => { const remaining = collectSessionTabIds(session); if (remaining.length === 0) { sessionWindowMap.delete(session); + sessionGroupTitleMap.delete(session); } cleanEmptyGroups().catch(() => {}); }); @@ -836,8 +2197,17 @@ chrome.windows.onRemoved.addListener((windowId) => { }); chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm?.name !== CLEANUP_ALARM_NAME) return; - cleanEmptyGroups().catch(() => {}); + if (!alarm || typeof alarm.name !== 'string') return; + + if (alarm.name === CLEANUP_ALARM_NAME) { + cleanEmptyGroups().catch(() => {}); + return; + } + + if (alarm.name.startsWith(WORKFLOW_ALARM_PREFIX)) { + const scheduleId = alarm.name.slice(WORKFLOW_ALARM_PREFIX.length); + runSchedule(scheduleId).catch(() => {}); + } }); chrome.downloads.onDeterminingFilename.addListener((item, suggest) => { diff --git a/extensions/tab-group-cdp/sidepanel.js b/extensions/tab-group-cdp/sidepanel.js index 553790a..4aefa1a 100644 --- a/extensions/tab-group-cdp/sidepanel.js +++ b/extensions/tab-group-cdp/sidepanel.js @@ -1,13 +1,149 @@ +const PANEL_GET_STATE = 'AB_PANEL_GET_STATE'; +const PANEL_CLEAN_EMPTY_GROUPS = 'AB_PANEL_CLEAN_EMPTY_GROUPS'; +const PANEL_SET_OPTIONS = 'AB_PANEL_SET_OPTIONS'; +const PANEL_SET_POLICY = 'AB_PANEL_SET_POLICY'; +const PANEL_CLOSE_OTHER_TABS = 'AB_PANEL_CLOSE_OTHER_SESSION_TABS'; +const PANEL_FOCUS_SESSION = 'AB_PANEL_FOCUS_SESSION'; +const PANEL_RUN_ACTION = 'AB_PANEL_RUN_ACTION'; +const PANEL_CLEAR_ACTIVITY = 'AB_PANEL_CLEAR_ACTIVITY'; +const PANEL_START_RECORDING = 'AB_PANEL_START_RECORDING'; +const PANEL_STOP_RECORDING = 'AB_PANEL_STOP_RECORDING'; +const PANEL_SAVE_RECORDING = 'AB_PANEL_SAVE_RECORDING'; +const PANEL_RUN_WORKFLOW = 'AB_PANEL_RUN_WORKFLOW'; +const PANEL_DELETE_WORKFLOW = 'AB_PANEL_DELETE_WORKFLOW'; +const PANEL_SET_SHORTCUT = 'AB_PANEL_SET_SHORTCUT'; +const PANEL_DELETE_SHORTCUT = 'AB_PANEL_DELETE_SHORTCUT'; +const PANEL_RUN_SHORTCUT = 'AB_PANEL_RUN_SHORTCUT'; +const PANEL_CREATE_SCHEDULE = 'AB_PANEL_CREATE_SCHEDULE'; +const PANEL_DELETE_SCHEDULE = 'AB_PANEL_DELETE_SCHEDULE'; +const PANEL_TOGGLE_SCHEDULE = 'AB_PANEL_TOGGLE_SCHEDULE'; + const summaryEl = document.getElementById('summary'); +const controlEl = document.getElementById('control'); +const automationEl = document.getElementById('automation'); +const developerEl = document.getElementById('developer'); const sessionsEl = document.getElementById('sessions'); const downloadsEl = document.getElementById('downloads'); +const statusLineEl = document.getElementById('status-line'); const refreshBtn = document.getElementById('refresh-btn'); const cleanupBtn = document.getElementById('cleanup-btn'); +const viewState = { + panelState: null, + lastDomState: null, +}; + async function send(message) { return chrome.runtime.sendMessage(message); } +function normalizePanelState(rawState) { + const state = rawState && typeof rawState === 'object' ? rawState : {}; + + return { + extensionId: typeof state.extensionId === 'string' ? state.extensionId : 'unknown', + latestDomState: + state.latestDomState && typeof state.latestDomState === 'object' ? state.latestDomState : null, + options: + state.options && typeof state.options === 'object' + ? { + strictWindowIsolation: state.options.strictWindowIsolation !== false, + suppressCrossWindowActivation: state.options.suppressCrossWindowActivation !== false, + autoCleanEmptyGroups: state.options.autoCleanEmptyGroups !== false, + pageBridgeEnabled: state.options.pageBridgeEnabled === true, + } + : { + strictWindowIsolation: true, + suppressCrossWindowActivation: true, + autoCleanEmptyGroups: true, + pageBridgeEnabled: false, + }, + totals: + state.totals && typeof state.totals === 'object' + ? { + sessions: Number.isFinite(state.totals.sessions) ? state.totals.sessions : 0, + tabs: Number.isFinite(state.totals.tabs) ? state.totals.tabs : 0, + } + : { + sessions: 0, + tabs: 0, + }, + sessions: Array.isArray(state.sessions) ? state.sessions : [], + downloads: Array.isArray(state.downloads) ? state.downloads : [], + control: + state.control && typeof state.control === 'object' + ? { + activeTab: + state.control.activeTab && typeof state.control.activeTab === 'object' + ? state.control.activeTab + : null, + tabs: Array.isArray(state.control.tabs) ? state.control.tabs : [], + } + : { + activeTab: null, + tabs: [], + }, + activity: + state.activity && typeof state.activity === 'object' + ? { + events: Array.isArray(state.activity.events) ? state.activity.events : [], + console: Array.isArray(state.activity.console) ? state.activity.console : [], + network: Array.isArray(state.activity.network) ? state.activity.network : [], + commandHistory: Array.isArray(state.activity.commandHistory) + ? state.activity.commandHistory + : [], + } + : { + events: [], + console: [], + network: [], + commandHistory: [], + }, + automation: + state.automation && typeof state.automation === 'object' + ? { + recording: + state.automation.recording && typeof state.automation.recording === 'object' + ? state.automation.recording + : null, + workflows: Array.isArray(state.automation.workflows) ? state.automation.workflows : [], + shortcuts: Array.isArray(state.automation.shortcuts) ? state.automation.shortcuts : [], + schedules: Array.isArray(state.automation.schedules) ? state.automation.schedules : [], + } + : { + recording: null, + workflows: [], + shortcuts: [], + schedules: [], + }, + }; +} + +function setStatus(text, tone = 'ok') { + statusLineEl.textContent = text || ''; + statusLineEl.className = `status-line ${tone}`; +} + +function escapeInline(value) { + return String(value || '') + .replace(/[\n\r\t]+/g, ' ') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\"/g, '"') + .replace(/'/g, ''') + .slice(0, 240); +} + +function formatTime(ts) { + if (!ts) return '-'; + try { + return new Date(ts).toLocaleString(); + } catch { + return String(ts); + } +} + function createTag(text) { const span = document.createElement('span'); span.className = 'tag'; @@ -15,90 +151,350 @@ function createTag(text) { return span; } -function renderSummary(state) { - summaryEl.innerHTML = '

Overview

'; - const options = state.options || {}; +function parseTimeInput(timeText) { + const match = String(timeText || '').trim().match(/^(\d{1,2}):(\d{2})$/); + if (!match) return null; + const hour = Number.parseInt(match[1], 10); + const minute = Number.parseInt(match[2], 10); + if (Number.isNaN(hour) || Number.isNaN(minute)) return null; + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null; + return { hour, minute }; +} - const idInfo = document.createElement('div'); - idInfo.style.marginBottom = '12px'; - idInfo.style.fontSize = '11px'; - idInfo.style.color = 'var(--text-muted)'; - idInfo.innerHTML = `Extension ID: ${state.extensionId}`; - summaryEl.appendChild(idInfo); +async function runAction(action, args = {}, tabId) { + const response = await send({ + type: PANEL_RUN_ACTION, + action, + args, + tabId, + }); + + if (!response || response.ok !== true) { + setStatus(`Action failed: ${response?.error || 'unknown error'}`, 'error'); + return response; + } + + if (response.state) { + viewState.lastDomState = response.state; + } + + setStatus(`Action succeeded: ${action}`, 'ok'); + return response; +} + +function renderSummary(state) { + summaryEl.innerHTML = ''; + + const title = document.createElement('h3'); + title.textContent = 'Overview'; + summaryEl.appendChild(title); + + const extensionInfo = document.createElement('div'); + extensionInfo.className = 'caption'; + extensionInfo.textContent = `Extension ID: ${state.extensionId}`; + summaryEl.appendChild(extensionInfo); const tags = document.createElement('div'); tags.className = 'tags'; tags.appendChild(createTag(`Sessions: ${state.totals.sessions}`)); tags.appendChild(createTag(`Tabs: ${state.totals.tabs}`)); tags.appendChild( - createTag(`Isolation: ${options.strictWindowIsolation === false ? 'Off' : 'On'}`) + createTag(`Isolation: ${state.options.strictWindowIsolation === false ? 'Off' : 'On'}`) ); tags.appendChild( - createTag(`Guard: ${options.suppressCrossWindowActivation === false ? 'Off' : 'On'}`) + createTag(`Guard: ${state.options.suppressCrossWindowActivation === false ? 'Off' : 'On'}`) ); - tags.appendChild( - createTag(`Auto-Clean: ${options.autoCleanEmptyGroups === false ? 'Off' : 'On'}`) - ); - - const optionActions = document.createElement('div'); - optionActions.className = 'row-actions'; - - const createOptionBtn = (text, active, onClick) => { - const btn = document.createElement('button'); - btn.type = 'button'; - btn.textContent = text; - if (active) btn.style.borderColor = 'var(--accent)'; - btn.addEventListener('click', onClick); - return btn; - }; - - optionActions.appendChild( - createOptionBtn('Strict Isolation', options.strictWindowIsolation !== false, async () => { - await send({ - type: 'AB_PANEL_SET_OPTIONS', - options: { ...options, strictWindowIsolation: options.strictWindowIsolation === false }, - }); - await refresh(); - }) - ); - - optionActions.appendChild( - createOptionBtn('Activation Guard', options.suppressCrossWindowActivation !== false, async () => { - await send({ - type: 'AB_PANEL_SET_OPTIONS', - options: { - ...options, - suppressCrossWindowActivation: options.suppressCrossWindowActivation === false, - }, - }); - await refresh(); - }) - ); - - optionActions.appendChild( - createOptionBtn('Auto-Clean', options.autoCleanEmptyGroups !== false, async () => { - await send({ - type: 'AB_PANEL_SET_OPTIONS', - options: { - ...options, - autoCleanEmptyGroups: options.autoCleanEmptyGroups === false, - }, - }); - await refresh(); - }) - ); - + tags.appendChild(createTag(`PageBridge: ${state.options.pageBridgeEnabled ? 'On' : 'Off'}`)); + tags.appendChild(createTag(`Workflows: ${state.automation.workflows.length}`)); + tags.appendChild(createTag(`Schedules: ${state.automation.schedules.length}`)); summaryEl.appendChild(tags); - summaryEl.appendChild(optionActions); + + const actions = document.createElement('div'); + actions.className = 'row wrap'; + + const isolationBtn = document.createElement('button'); + isolationBtn.textContent = 'Toggle Isolation'; + isolationBtn.addEventListener('click', async () => { + await send({ + type: PANEL_SET_OPTIONS, + options: { + ...state.options, + strictWindowIsolation: state.options.strictWindowIsolation === false, + }, + }); + await refresh(); + }); + + const guardBtn = document.createElement('button'); + guardBtn.textContent = 'Toggle Guard'; + guardBtn.addEventListener('click', async () => { + await send({ + type: PANEL_SET_OPTIONS, + options: { + ...state.options, + suppressCrossWindowActivation: state.options.suppressCrossWindowActivation === false, + }, + }); + await refresh(); + }); + + const cleanBtn = document.createElement('button'); + cleanBtn.textContent = 'Toggle Auto-Clean'; + cleanBtn.addEventListener('click', async () => { + await send({ + type: PANEL_SET_OPTIONS, + options: { + ...state.options, + autoCleanEmptyGroups: state.options.autoCleanEmptyGroups === false, + }, + }); + await refresh(); + }); + + actions.appendChild(isolationBtn); + actions.appendChild(guardBtn); + actions.appendChild(cleanBtn); + summaryEl.appendChild(actions); +} + +function renderControl(state) { + const control = state.control || { activeTab: null, tabs: [] }; + + controlEl.innerHTML = ` +

Browser Control

+
+ + +
+
+ + + + + +
+
+ + +
+
+ + + + +
+
+ + +
+
+
+

Tabs

+ +
+
+ `; + + const activeTab = control.activeTab; + const activeText = activeTab + ? `Active: #${activeTab.id} ${escapeInline(activeTab.title)}` + : 'No active tab'; + controlEl.querySelector('#ctl-active').textContent = activeText; + + const urlInput = controlEl.querySelector('#ctl-url'); + const selectorInput = controlEl.querySelector('#ctl-selector'); + const valueInput = controlEl.querySelector('#ctl-value'); + const keyInput = controlEl.querySelector('#ctl-key'); + const shortcutInput = controlEl.querySelector('#ctl-shortcut'); + + controlEl.querySelector('#ctl-open').addEventListener('click', async () => { + const url = urlInput.value.trim(); + if (!url) { + setStatus('Please enter a URL', 'warn'); + return; + } + await runAction('open', { url }, activeTab?.id); + await refresh(); + }); + + controlEl.querySelector('#ctl-back').addEventListener('click', async () => { + await runAction('back', {}, activeTab?.id); + await refresh(); + }); + + controlEl.querySelector('#ctl-forward').addEventListener('click', async () => { + await runAction('forward', {}, activeTab?.id); + await refresh(); + }); + + controlEl.querySelector('#ctl-reload').addEventListener('click', async () => { + await runAction('reload', {}, activeTab?.id); + await refresh(); + }); + + controlEl.querySelector('#ctl-snapshot').addEventListener('click', async () => { + const selector = selectorInput.value.trim(); + const response = await runAction( + 'snapshot', + { + selector: selector || undefined, + interactiveOnly: true, + maxNodes: 80, + }, + activeTab?.id + ); + if (response?.state) { + viewState.lastDomState = response.state; + renderDeveloper(state); + } + }); + + controlEl.querySelector('#ctl-dom').addEventListener('click', async () => { + const selector = selectorInput.value.trim(); + const response = await runAction( + 'dom-state', + { + selector: selector || undefined, + interactiveOnly: false, + maxNodes: 100, + }, + activeTab?.id + ); + if (response?.state) { + viewState.lastDomState = response.state; + renderDeveloper(state); + } + }); + + controlEl.querySelector('#ctl-click').addEventListener('click', async () => { + const selector = selectorInput.value.trim(); + if (!selector) { + setStatus('Selector is required for click', 'warn'); + return; + } + await runAction('click', { selector }, activeTab?.id); + await refresh(); + }); + + controlEl.querySelector('#ctl-fill').addEventListener('click', async () => { + const selector = selectorInput.value.trim(); + if (!selector) { + setStatus('Selector is required for fill', 'warn'); + return; + } + await runAction( + 'fill', + { + selector, + value: valueInput.value, + }, + activeTab?.id + ); + await refresh(); + }); + + controlEl.querySelector('#ctl-press').addEventListener('click', async () => { + await runAction( + 'press', + { + selector: selectorInput.value.trim() || undefined, + key: keyInput.value.trim() || 'Enter', + }, + activeTab?.id + ); + await refresh(); + }); + + controlEl.querySelector('#ctl-run-shortcut').addEventListener('click', async () => { + const raw = shortcutInput.value.trim(); + if (!raw) { + setStatus('Shortcut name is required', 'warn'); + return; + } + + const name = raw.startsWith('/') ? raw.slice(1) : raw; + const response = await send({ + type: PANEL_RUN_SHORTCUT, + name, + tabId: activeTab?.id, + }); + + if (!response || response.ok !== true) { + setStatus(`Shortcut failed: ${response?.error || 'unknown error'}`, 'error'); + return; + } + + setStatus(`Shortcut executed: /${name}`, 'ok'); + await refresh(); + }); + + const tabsEl = controlEl.querySelector('#ctl-tabs'); + if (!Array.isArray(control.tabs) || control.tabs.length === 0) { + const empty = document.createElement('div'); + empty.className = 'empty'; + empty.textContent = 'No tabs in current window.'; + tabsEl.appendChild(empty); + } else { + for (const tab of control.tabs.slice(0, 20)) { + const item = document.createElement('div'); + item.className = 'item'; + + const title = document.createElement('div'); + title.className = 'item-title'; + title.textContent = `#${tab.id} [${tab.index}] ${escapeInline(tab.title)}`; + + const url = document.createElement('div'); + url.className = 'item-url'; + url.textContent = tab.url || 'about:blank'; + + const row = document.createElement('div'); + row.className = 'row wrap'; + + const switchBtn = document.createElement('button'); + switchBtn.textContent = tab.active ? 'Active' : 'Switch'; + switchBtn.disabled = tab.active === true; + switchBtn.addEventListener('click', async () => { + await runAction('tabs:switch', { tabId: tab.id }); + await refresh(); + }); + + const closeBtn = document.createElement('button'); + closeBtn.className = 'danger'; + closeBtn.textContent = 'Close'; + closeBtn.addEventListener('click', async () => { + await runAction('tabs:close', {}, tab.id); + await refresh(); + }); + + row.appendChild(switchBtn); + row.appendChild(closeBtn); + + if (tab.session) { + const pill = document.createElement('span'); + pill.className = 'event-pill'; + pill.textContent = `session:${tab.session}`; + row.appendChild(pill); + } + + item.appendChild(title); + item.appendChild(url); + item.appendChild(row); + tabsEl.appendChild(item); + } + } } function renderSessions(state) { - sessionsEl.innerHTML = '

Active Sessions

'; + sessionsEl.innerHTML = ''; + + const heading = document.createElement('h3'); + heading.textContent = 'Managed Sessions'; + sessionsEl.appendChild(heading); if (!state.sessions || state.sessions.length === 0) { const empty = document.createElement('div'); empty.className = 'card empty'; - empty.textContent = 'No active sessions monitored.'; + empty.textContent = 'No active managed sessions found.'; sessionsEl.appendChild(empty); return; } @@ -108,73 +504,30 @@ function renderSessions(state) { card.className = 'card'; const titleRow = document.createElement('div'); - titleRow.className = 'session-title'; + titleRow.className = 'section-title'; - const titleLeft = document.createElement('h4'); - titleLeft.textContent = session.session; + const title = document.createElement('h4'); + title.textContent = session.session; + + const actionRow = document.createElement('div'); + actionRow.className = 'row wrap'; const focusBtn = document.createElement('button'); focusBtn.textContent = 'Focus'; focusBtn.addEventListener('click', async () => { - await send({ type: 'AB_PANEL_FOCUS_SESSION', session: session.session }); + await send({ type: PANEL_FOCUS_SESSION, session: session.session }); await refresh(); }); - titleRow.appendChild(titleLeft); - titleRow.appendChild(focusBtn); - - const tags = document.createElement('div'); - tags.className = 'tags'; - tags.appendChild(createTag(`Window: ${session.windowId ?? 'N/A'}`)); - tags.appendChild(createTag(`Tabs: ${session.tabs.length}`)); - - if (session.group) { - tags.appendChild(createTag(`G: ${session.group.title || 'Untitled'}`)); - } - - if (session.allowedDomains && session.allowedDomains.length > 0) { - tags.appendChild(createTag(`Allowlist: ${session.allowedDomains.length} domains`)); - } - - const list = document.createElement('div'); - list.className = 'list'; - for (const tab of session.tabs.slice(0, 10)) { - const item = document.createElement('div'); - item.className = 'item'; - - const t = document.createElement('div'); - t.className = 'item-title'; - if (tab.active) { - const dot = document.createElement('span'); - dot.textContent = '●'; - dot.style.color = 'var(--success)'; - dot.style.marginRight = '6px'; - dot.style.fontSize = '10px'; - t.appendChild(dot); - } - t.appendChild(document.createTextNode(tab.title || '(Untitled)')); - - const u = document.createElement('div'); - u.className = 'item-url'; - u.textContent = tab.url || 'about:blank'; - - item.appendChild(t); - item.appendChild(u); - list.appendChild(item); - } - - const footerActions = document.createElement('div'); - footerActions.className = 'row-actions'; - - const keepBtn = document.createElement('button'); - keepBtn.textContent = 'Isolate Session'; - keepBtn.addEventListener('click', async () => { - await send({ type: 'AB_PANEL_CLOSE_OTHER_SESSION_TABS', session: session.session }); + const isolateBtn = document.createElement('button'); + isolateBtn.textContent = 'Isolate'; + isolateBtn.addEventListener('click', async () => { + await send({ type: PANEL_CLOSE_OTHER_TABS, session: session.session }); await refresh(); }); const policyBtn = document.createElement('button'); - policyBtn.textContent = 'Config Policy'; + policyBtn.textContent = 'Policy'; policyBtn.addEventListener('click', async () => { const current = (session.allowedDomains || []).join(','); const input = window.prompt('Allowed domains (comma-separated)', current); @@ -183,17 +536,49 @@ function renderSessions(state) { .split(',') .map((item) => item.trim().toLowerCase()) .filter((item) => item.length > 0); - await send({ type: 'AB_PANEL_SET_POLICY', session: session.session, allowedDomains }); + await send({ type: PANEL_SET_POLICY, session: session.session, allowedDomains }); await refresh(); }); - footerActions.appendChild(keepBtn); - footerActions.appendChild(policyBtn); + actionRow.appendChild(focusBtn); + actionRow.appendChild(isolateBtn); + actionRow.appendChild(policyBtn); + + titleRow.appendChild(title); + titleRow.appendChild(actionRow); + + const tags = document.createElement('div'); + tags.className = 'tags'; + tags.appendChild(createTag(`Window ${session.windowId ?? 'N/A'}`)); + tags.appendChild(createTag(`${session.tabs.length} tabs`)); + + if (session.group?.title) { + tags.appendChild(createTag(`Group: ${session.group.title}`)); + } + + const list = document.createElement('div'); + list.className = 'list'; + + for (const tab of session.tabs.slice(0, 8)) { + const item = document.createElement('div'); + item.className = 'item'; + + const tabTitle = document.createElement('div'); + tabTitle.className = 'item-title'; + tabTitle.textContent = `${tab.active ? '● ' : ''}#${tab.id} ${escapeInline(tab.title || '(Untitled)')}`; + + const tabUrl = document.createElement('div'); + tabUrl.className = 'item-url'; + tabUrl.textContent = tab.url || 'about:blank'; + + item.appendChild(tabTitle); + item.appendChild(tabUrl); + list.appendChild(item); + } card.appendChild(titleRow); card.appendChild(tags); card.appendChild(list); - card.appendChild(footerActions); sessionsEl.appendChild(card); } } @@ -211,10 +596,25 @@ function renderDownloads(state) { empty.textContent = 'No download events yet.'; list.appendChild(empty); } else { - for (const entry of entries.slice(0, 8)) { + for (const entry of entries.slice(0, 10)) { const item = document.createElement('div'); item.className = 'item'; - item.innerHTML = `
#${entry.id} · ${entry.state || 'updated'}
${entry.filename || ''}
`; + + const title = document.createElement('div'); + title.className = 'item-title'; + title.textContent = `#${entry.id} · ${entry.state || 'updated'}`; + + const filename = document.createElement('div'); + filename.className = 'item-url'; + filename.textContent = entry.filename || '(no filename)'; + + const meta = document.createElement('div'); + meta.className = 'caption'; + meta.textContent = formatTime(entry.timestamp); + + item.appendChild(title); + item.appendChild(filename); + item.appendChild(meta); list.appendChild(item); } } @@ -222,25 +622,604 @@ function renderDownloads(state) { downloadsEl.appendChild(list); } -async function refresh() { - const response = await send({ type: 'AB_PANEL_GET_STATE' }); - if (!response || response.ok !== true || !response.state) { - summaryEl.textContent = response?.error || 'Failed to load extension state.'; - sessionsEl.innerHTML = ''; - downloadsEl.innerHTML = ''; +async function promptAndCreateSchedule(workflowId, workflowName) { + const cadenceKind = (window.prompt('Cadence: daily | weekly | monthly | yearly', 'daily') || '') + .trim() + .toLowerCase(); + + if (!cadenceKind) return; + if (!['daily', 'weekly', 'monthly', 'yearly'].includes(cadenceKind)) { + setStatus('Invalid cadence. Use daily/weekly/monthly/yearly.', 'warn'); return; } - renderSummary(response.state); - renderSessions(response.state); - renderDownloads(response.state); + const timeInput = parseTimeInput(window.prompt('Time (HH:MM, 24h)', '09:00')); + if (!timeInput) { + setStatus('Invalid time format.', 'warn'); + return; + } + + const cadence = { + kind: cadenceKind, + hour: timeInput.hour, + minute: timeInput.minute, + }; + + if (cadenceKind === 'weekly') { + const weekday = Number.parseInt( + window.prompt('Weekday (0=Sun .. 6=Sat)', String(new Date().getDay())) || '', + 10 + ); + if (Number.isNaN(weekday) || weekday < 0 || weekday > 6) { + setStatus('Invalid weekday.', 'warn'); + return; + } + cadence.weekdays = [weekday]; + } + + if (cadenceKind === 'monthly') { + const day = Number.parseInt(window.prompt('Day of month (1-31)', '1') || '', 10); + if (Number.isNaN(day) || day < 1 || day > 31) { + setStatus('Invalid day of month.', 'warn'); + return; + } + cadence.dayOfMonth = day; + } + + if (cadenceKind === 'yearly') { + const month = Number.parseInt(window.prompt('Month (1-12)', '1') || '', 10); + const day = Number.parseInt(window.prompt('Day of month (1-31)', '1') || '', 10); + if (Number.isNaN(month) || month < 1 || month > 12 || Number.isNaN(day) || day < 1 || day > 31) { + setStatus('Invalid month/day.', 'warn'); + return; + } + cadence.month = month; + cadence.dayOfMonth = day; + } + + const response = await send({ + type: PANEL_CREATE_SCHEDULE, + schedule: { + name: `Schedule ${workflowName}`, + workflowId, + cadence, + enabled: true, + }, + }); + + if (!response || response.ok !== true) { + setStatus(`Create schedule failed: ${response?.error || 'unknown error'}`, 'error'); + return; + } + + setStatus('Schedule created.', 'ok'); + await refresh(); } -refreshBtn.addEventListener('click', refresh); +function renderAutomation(state) { + const automation = state.automation; + const activeTabId = state.control?.activeTab?.id; + + automationEl.innerHTML = ` +

Automation

+
+

Recording

+ +
+
+ + + + +
+
+
+

Workflows

+
+
+
+
+

Shortcuts

+
+
+
+

Schedules

+
+
+
+ `; + + const recordingStateEl = automationEl.querySelector('#recording-state'); + const recordingStepsEl = automationEl.querySelector('#recording-steps'); + const recordNameInput = automationEl.querySelector('#record-name'); + const workflowListEl = automationEl.querySelector('#workflow-list'); + const shortcutListEl = automationEl.querySelector('#shortcut-list'); + const scheduleListEl = automationEl.querySelector('#schedule-list'); + + if (automation.recording) { + recordingStateEl.textContent = `ON · ${automation.recording.stepCount} steps`; + recordNameInput.value = automation.recording.name || ''; + + for (const step of automation.recording.steps) { + const item = document.createElement('div'); + item.className = 'item'; + item.innerHTML = `
${escapeInline(step.action)}
${escapeInline(JSON.stringify(step.args || {}))}
`; + recordingStepsEl.appendChild(item); + } + + if (automation.recording.steps.length === 0) { + const empty = document.createElement('div'); + empty.className = 'caption'; + empty.textContent = 'Recording is active. Perform actions from Browser Control.'; + recordingStepsEl.appendChild(empty); + } + } else { + recordingStateEl.textContent = 'OFF'; + const empty = document.createElement('div'); + empty.className = 'caption'; + empty.textContent = 'Start recording to capture actions into a reusable workflow.'; + recordingStepsEl.appendChild(empty); + } + + automationEl.querySelector('#record-start').addEventListener('click', async () => { + const result = await send({ + type: PANEL_START_RECORDING, + name: recordNameInput.value.trim() || 'Recorded Workflow', + }); + + if (!result || result.ok !== true) { + setStatus(`Start recording failed: ${result?.error || 'unknown error'}`, 'error'); + return; + } + + setStatus('Recording started.', 'ok'); + await refresh(); + }); + + automationEl.querySelector('#record-stop').addEventListener('click', async () => { + const result = await send({ type: PANEL_STOP_RECORDING }); + if (!result || result.ok !== true) { + setStatus(`Stop recording failed: ${result?.error || 'unknown error'}`, 'error'); + return; + } + + setStatus('Recording stopped.', 'ok'); + await refresh(); + }); + + automationEl.querySelector('#record-save').addEventListener('click', async () => { + const result = await send({ + type: PANEL_SAVE_RECORDING, + name: recordNameInput.value.trim() || undefined, + }); + + if (!result || result.ok !== true) { + setStatus(`Save recording failed: ${result?.error || 'unknown error'}`, 'error'); + return; + } + + setStatus(`Workflow saved: ${result.workflow?.name || ''}`, 'ok'); + await refresh(); + }); + + if (!automation.workflows || automation.workflows.length === 0) { + const empty = document.createElement('div'); + empty.className = 'empty'; + empty.textContent = 'No workflows saved yet.'; + workflowListEl.appendChild(empty); + } else { + for (const workflow of automation.workflows) { + const item = document.createElement('div'); + item.className = 'item'; + + const title = document.createElement('div'); + title.className = 'item-title'; + title.textContent = `${workflow.name} (${workflow.stepCount} steps)`; + + const meta = document.createElement('div'); + meta.className = 'caption'; + meta.textContent = `Updated: ${formatTime(workflow.updatedAt)}`; + + const row = document.createElement('div'); + row.className = 'row wrap'; + + const runBtn = document.createElement('button'); + runBtn.textContent = 'Run'; + runBtn.addEventListener('click', async () => { + const response = await send({ + type: PANEL_RUN_WORKFLOW, + workflowId: workflow.id, + tabId: activeTabId, + }); + + if (!response || response.ok !== true) { + setStatus(`Workflow failed: ${response?.error || 'unknown error'}`, 'error'); + return; + } + + setStatus(`Workflow executed: ${workflow.name}`, 'ok'); + await refresh(); + }); + + const shortcutBtn = document.createElement('button'); + shortcutBtn.textContent = 'Set Shortcut'; + shortcutBtn.addEventListener('click', async () => { + const defaultName = workflow.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 32); + const input = window.prompt('Shortcut name (without /)', defaultName || 'workflow'); + if (!input) return; + + const result = await send({ + type: PANEL_SET_SHORTCUT, + name: input, + workflowId: workflow.id, + }); + + if (!result || result.ok !== true) { + setStatus(`Set shortcut failed: ${result?.error || 'unknown error'}`, 'error'); + return; + } + + setStatus(`Shortcut saved: /${result.shortcut.name}`, 'ok'); + await refresh(); + }); + + const scheduleBtn = document.createElement('button'); + scheduleBtn.textContent = 'Schedule'; + scheduleBtn.addEventListener('click', async () => { + await promptAndCreateSchedule(workflow.id, workflow.name); + }); + + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'danger'; + deleteBtn.textContent = 'Delete'; + deleteBtn.addEventListener('click', async () => { + if (!window.confirm(`Delete workflow \"${workflow.name}\"?`)) return; + const result = await send({ type: PANEL_DELETE_WORKFLOW, workflowId: workflow.id }); + if (!result || result.ok !== true) { + setStatus(`Delete workflow failed: ${result?.error || 'unknown error'}`, 'error'); + return; + } + setStatus('Workflow deleted.', 'ok'); + await refresh(); + }); + + row.appendChild(runBtn); + row.appendChild(shortcutBtn); + row.appendChild(scheduleBtn); + row.appendChild(deleteBtn); + + item.appendChild(title); + item.appendChild(meta); + item.appendChild(row); + workflowListEl.appendChild(item); + } + } + + if (!automation.shortcuts || automation.shortcuts.length === 0) { + const empty = document.createElement('div'); + empty.className = 'empty'; + empty.textContent = 'No shortcuts configured.'; + shortcutListEl.appendChild(empty); + } else { + for (const shortcut of automation.shortcuts) { + const item = document.createElement('div'); + item.className = 'item'; + + const title = document.createElement('div'); + title.className = 'item-title'; + title.textContent = `/${shortcut.name}`; + + const desc = document.createElement('div'); + desc.className = 'item-url'; + desc.textContent = shortcut.workflowName; + + const row = document.createElement('div'); + row.className = 'row wrap'; + + const runBtn = document.createElement('button'); + runBtn.textContent = 'Run'; + runBtn.addEventListener('click', async () => { + const response = await send({ + type: PANEL_RUN_SHORTCUT, + name: shortcut.name, + tabId: activeTabId, + }); + + if (!response || response.ok !== true) { + setStatus(`Shortcut failed: ${response?.error || 'unknown error'}`, 'error'); + return; + } + + setStatus(`Shortcut executed: /${shortcut.name}`, 'ok'); + await refresh(); + }); + + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'danger'; + deleteBtn.textContent = 'Delete'; + deleteBtn.addEventListener('click', async () => { + const result = await send({ type: PANEL_DELETE_SHORTCUT, name: shortcut.name }); + if (!result || result.ok !== true) { + setStatus(`Delete shortcut failed: ${result?.error || 'unknown error'}`, 'error'); + return; + } + setStatus('Shortcut deleted.', 'ok'); + await refresh(); + }); + + row.appendChild(runBtn); + row.appendChild(deleteBtn); + + item.appendChild(title); + item.appendChild(desc); + item.appendChild(row); + shortcutListEl.appendChild(item); + } + } + + if (!automation.schedules || automation.schedules.length === 0) { + const empty = document.createElement('div'); + empty.className = 'empty'; + empty.textContent = 'No schedules configured.'; + scheduleListEl.appendChild(empty); + } else { + for (const schedule of automation.schedules) { + const item = document.createElement('div'); + item.className = 'item'; + + const title = document.createElement('div'); + title.className = 'item-title'; + title.textContent = schedule.name; + + const desc = document.createElement('div'); + desc.className = 'item-url'; + desc.textContent = `${schedule.workflowName} · ${schedule.cadence.kind}`; + + const time = document.createElement('div'); + time.className = 'caption'; + time.textContent = `Next: ${formatTime(schedule.nextRunAt)}`; + + const row = document.createElement('div'); + row.className = 'row wrap'; + + const toggleBtn = document.createElement('button'); + toggleBtn.textContent = schedule.enabled ? 'Disable' : 'Enable'; + toggleBtn.addEventListener('click', async () => { + const result = await send({ + type: PANEL_TOGGLE_SCHEDULE, + scheduleId: schedule.id, + enabled: !schedule.enabled, + }); + + if (!result || result.ok !== true) { + setStatus(`Toggle schedule failed: ${result?.error || 'unknown error'}`, 'error'); + return; + } + + setStatus('Schedule updated.', 'ok'); + await refresh(); + }); + + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'danger'; + deleteBtn.textContent = 'Delete'; + deleteBtn.addEventListener('click', async () => { + const result = await send({ + type: PANEL_DELETE_SCHEDULE, + scheduleId: schedule.id, + }); + + if (!result || result.ok !== true) { + setStatus(`Delete schedule failed: ${result?.error || 'unknown error'}`, 'error'); + return; + } + + setStatus('Schedule deleted.', 'ok'); + await refresh(); + }); + + row.appendChild(toggleBtn); + row.appendChild(deleteBtn); + + item.appendChild(title); + item.appendChild(desc); + item.appendChild(time); + item.appendChild(row); + scheduleListEl.appendChild(item); + } + } +} + +function renderDeveloper(state) { + const activity = state.activity; + const activeTabId = state.control?.activeTab?.id; + const domState = viewState.lastDomState || state.latestDomState || null; + + developerEl.innerHTML = ` +

Developer Signals

+
+ + + + Console: ${activity.console.length} · Network: ${activity.network.length} + Bridge: ${state.options.pageBridgeEnabled ? 'ON' : 'OFF (high-risk default)'} +
+
+ + +
+
+
+

DOM State

+

+      
+
+

Recent Commands

+
+
+
+
+
+
+

Console Events

+
+
+
+

Network Events

+
+
+
+ `; + + const domPre = developerEl.querySelector('#dev-dom-json'); + domPre.textContent = domState + ? JSON.stringify(domState, null, 2) + : 'No DOM state captured yet. Use Snapshot or DOM State in Browser Control.'; + + const commandList = developerEl.querySelector('#dev-command-list'); + if (!activity.commandHistory || activity.commandHistory.length === 0) { + const empty = document.createElement('div'); + empty.className = 'empty'; + empty.textContent = 'No commands yet.'; + commandList.appendChild(empty); + } else { + for (const entry of activity.commandHistory.slice(0, 8)) { + const item = document.createElement('div'); + item.className = 'item'; + item.innerHTML = `
${entry.ok ? 'OK' : 'FAIL'} · ${escapeInline(entry.action)}
${formatTime(entry.timestamp)}${entry.error ? ` · ${escapeInline(entry.error)}` : ''}
`; + commandList.appendChild(item); + } + } + + const consoleList = developerEl.querySelector('#dev-console-list'); + if (!activity.console || activity.console.length === 0) { + const empty = document.createElement('div'); + empty.className = 'empty'; + empty.textContent = 'No console events.'; + consoleList.appendChild(empty); + } else { + for (const event of activity.console.slice(0, 12)) { + const payload = event.payload || {}; + const level = payload.level || 'log'; + const text = payload.message || (Array.isArray(payload.args) ? payload.args.join(' ') : JSON.stringify(payload)); + const item = document.createElement('div'); + item.className = 'item'; + item.innerHTML = `
${escapeInline(level.toUpperCase())}
${escapeInline(text)}
${formatTime(event.timestamp)}
`; + consoleList.appendChild(item); + } + } + + const networkList = developerEl.querySelector('#dev-network-list'); + if (!activity.network || activity.network.length === 0) { + const empty = document.createElement('div'); + empty.className = 'empty'; + empty.textContent = 'No network events.'; + networkList.appendChild(empty); + } else { + for (const event of activity.network.slice(0, 12)) { + const payload = event.payload || {}; + const item = document.createElement('div'); + item.className = 'item'; + const line1 = `${payload.transport || 'net'} ${payload.method || ''} ${payload.status || ''}`.trim(); + const line2 = payload.url || payload.error || '(unknown)'; + const line3 = payload.durationMs ? `${payload.durationMs} ms` : ''; + item.innerHTML = `
${escapeInline(line1)}
${escapeInline(line2)}
${escapeInline(line3)} · ${formatTime(event.timestamp)}
`; + networkList.appendChild(item); + } + } + + developerEl.querySelector('#dev-refresh-dom').addEventListener('click', async () => { + const selector = developerEl.querySelector('#dev-selector').value.trim(); + const interactiveOnly = developerEl.querySelector('#dev-interactive-only').checked; + + const response = await runAction( + 'dom-state', + { + selector: selector || undefined, + interactiveOnly, + maxNodes: 120, + }, + activeTabId + ); + + if (response?.state) { + viewState.lastDomState = response.state; + renderDeveloper(state); + } + }); + + developerEl.querySelector('#dev-clear-activity').addEventListener('click', async () => { + await send({ type: PANEL_CLEAR_ACTIVITY }); + setStatus('Activity events cleared.', 'ok'); + await refresh(); + }); + + developerEl.querySelector('#dev-toggle-bridge').addEventListener('click', async () => { + const result = await send({ + type: PANEL_SET_OPTIONS, + options: { + ...state.options, + pageBridgeEnabled: !state.options.pageBridgeEnabled, + }, + }); + if (!result || result.ok !== true) { + setStatus(`Toggle Page Bridge failed: ${result?.error || 'unknown error'}`, 'error'); + return; + } + setStatus( + `Page Bridge ${!state.options.pageBridgeEnabled ? 'enabled' : 'disabled'} (reload page to apply).`, + 'ok' + ); + await refresh(); + }); +} + +async function refresh() { + const response = await send({ type: PANEL_GET_STATE }); + if (!response || response.ok !== true || !response.state) { + setStatus(response?.error || 'Failed to load extension state.', 'error'); + return; + } + + const normalizedState = normalizePanelState(response.state); + viewState.panelState = normalizedState; + if (normalizedState.latestDomState) { + viewState.lastDomState = normalizedState.latestDomState; + } + renderControl(normalizedState); + renderSummary(normalizedState); + renderAutomation(normalizedState); + renderDeveloper(normalizedState); + renderSessions(normalizedState); + renderDownloads(normalizedState); +} + +refreshBtn.addEventListener('click', async () => { + await refresh(); + setStatus('Panel refreshed.', 'ok'); +}); + cleanupBtn.addEventListener('click', async () => { - await send({ type: 'AB_PANEL_CLEAN_EMPTY_GROUPS' }); + const response = await send({ type: PANEL_CLEAN_EMPTY_GROUPS }); + if (!response || response.ok !== true) { + setStatus(`Clean failed: ${response?.error || 'unknown error'}`, 'error'); + return; + } + setStatus('Empty groups cleaned.', 'ok'); await refresh(); }); -refresh(); -setInterval(refresh, 5000); +refresh().catch((error) => { + setStatus(error instanceof Error ? error.message : String(error), 'error'); +}); + +setInterval(() => { + refresh().catch(() => {}); +}, 5000);