diff --git a/.gitignore b/.gitignore index fcbfeeb..0c4efd6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # TypeScript / Node extension/background.js +extension/test-dist/ node_modules/ dist/ @@ -8,4 +9,4 @@ __pycache__/ *.pyc # IDE -.vscode/ \ No newline at end of file +.vscode/ diff --git a/extension/src/classes/CommandGroup.ts b/extension/src/classes/CommandGroup.ts new file mode 100644 index 0000000..e291f9d --- /dev/null +++ b/extension/src/classes/CommandGroup.ts @@ -0,0 +1,21 @@ +import type { CommandArgs, Serializable } from '../types'; +import type { JobManager } from './JobManager'; + +export type CommandRun = (args: CommandArgs) => Promise | Serializable; +export interface CommandSpec { background?: boolean; run: CommandRun; } +export type CommandEntry = CommandRun | CommandSpec; + +/** Shared state handed to every command group at construction. */ +export interface CommandContext { jobs: JobManager; } + +/** + * A command group bundles a set of related subcommands. `commands` is keyed by + * the FULL command id (e.g. "tabs.close") so groups spanning multiple + * namespaces (dom/extract/page, storage/cookies, session/clients) register + * uniformly. `namespace` is documentation/grouping only. + */ +export abstract class CommandGroup { + constructor(protected readonly ctx: CommandContext) {} + abstract readonly namespace: string; + abstract readonly commands: Record; +} diff --git a/extension/src/classes/CommandRegistry.ts b/extension/src/classes/CommandRegistry.ts new file mode 100644 index 0000000..6466ad6 --- /dev/null +++ b/extension/src/classes/CommandRegistry.ts @@ -0,0 +1,94 @@ +import { CommandGroup } from './CommandGroup'; +import type { CommandContext, CommandEntry, CommandSpec } from './CommandGroup'; +import { NavigationCommands } from '../commands/navigation'; +import { TabsMutationCommands } from '../commands/tabs'; +import { TabsQueryCommands } from '../commands/tabs-query'; +import { GroupsCommands } from '../commands/groups'; +import { WindowsCommands } from '../commands/windows'; +import { DomCommands } from '../commands/dom'; +import { BrowserDataCommands } from '../commands/browser-data'; +import { SessionCommands } from '../commands/session'; +import { PerfCommands } from '../commands/perf'; +import { ExtensionCommands } from '../commands/extension'; +import type { CommandArgs, Serializable, DispatchArgs, PageRequest } from '../types'; + +function isCommandSpec(entry: CommandEntry): entry is CommandSpec { + return typeof entry !== "function"; +} + +export function makePagedData(items: Serializable[], page: PageRequest) { + const total = items.length; + const offset = Math.max(0, Number(page.offset) || 0); + const requestedLimit = Math.max(1, Number(page.limit) || 100); + const limit = Math.min(requestedLimit, 1000); + const end = Math.min(offset + limit, total); + return { + __browserCliPage: true, + items: items.slice(offset, end), + offset, + limit, + total, + nextOffset: end < total ? end : null, + }; +} + +export class CommandRegistry { + private readonly entries = new Map(); + + constructor(private readonly ctx: CommandContext) {} + + /** Flattens a group's full-id-keyed commands into the registry map. */ + register(group: CommandGroup): void { + for (const [command, entry] of Object.entries(group.commands)) { + if (this.entries.has(command)) { + throw new Error(`Duplicate command registration: ${command}`); + } + this.entries.set(command, entry); + } + } + + private resolve(command: string): CommandEntry { + const entry = this.entries.get(command); + if (!entry) throw new Error(`Unknown command: ${command}`); + return entry; + } + + async dispatch(command: string, args: DispatchArgs, opts: { background?: boolean; page?: PageRequest }): Promise { + const entry = this.resolve(command); + + if (isCommandSpec(entry) && entry.background && opts.background) { + // Narrow the dynamic IPC dict to the handler's declared arg type at the + // call boundary. + return this.ctx.jobs.start(command, args, jobArgs => Promise.resolve(entry.run(jobArgs as CommandArgs))); + } + + const run = isCommandSpec(entry) ? entry.run : entry; + let result = await run(args as CommandArgs); + if (opts.page && Array.isArray(result)) { + result = makePagedData(result, opts.page); + } + return result; + } +} + +/** + * Builds the registry and registers every command group. The SessionCommands + * instance is returned alongside because index.ts wires its lifecycle methods + * (chrome.tabs.onActivated → activateLazyTab) and NativeConnection references it + * for the clients.rename_profile reconnect side-effect. + */ +export function assembleRegistry(ctx: CommandContext): { registry: CommandRegistry; session: SessionCommands } { + const registry = new CommandRegistry(ctx); + const session = new SessionCommands(ctx); + registry.register(new NavigationCommands(ctx)); + registry.register(new TabsMutationCommands(ctx)); + registry.register(new TabsQueryCommands(ctx)); + registry.register(new GroupsCommands(ctx)); + registry.register(new WindowsCommands(ctx)); + registry.register(new DomCommands(ctx)); + registry.register(new BrowserDataCommands(ctx)); + registry.register(session); + registry.register(new PerfCommands(ctx)); + registry.register(new ExtensionCommands(ctx)); + return { registry, session }; +} diff --git a/extension/src/classes/JobManager.ts b/extension/src/classes/JobManager.ts new file mode 100644 index 0000000..860cebf --- /dev/null +++ b/extension/src/classes/JobManager.ts @@ -0,0 +1,160 @@ +/** + * Background-job retention helpers + the JobManager that owns the live job map. + * + * `pruneFinishedJobs` / `MAX_FINISHED_JOBS` are kept free of chrome.* / + * service-worker side effects so the retention logic (memory-leak guard) can be + * unit-tested in isolation. + */ + +import { getErrorMessage } from '../core'; +import type { Job, Serializable, ErrorLike, DispatchArgs } from '../types'; + +// Cap retained finished jobs so the in-memory Map cannot grow unbounded while +// the service worker is pinned alive by the native port. Kept small: finished +// jobs only need to survive long enough for the CLI to poll their result. +export const MAX_FINISHED_JOBS = 20; + +// Watchdog: if a runner never resolves/rejects (e.g. executeScript against a +// dead tab), finalize the job as an error so its persist interval stops instead +// of writing to chrome.storage.local every second forever. +export const JOB_TIMEOUT_MS = 5 * 60 * 1000; + +/** + * Evict the oldest *finished* jobs from `jobs` once their count exceeds `max`. + * Running jobs are always kept. Each evicted job's persistence timer (if any) + * is cleared via `clearTimer` so no interval is leaked. + * + * Returns the list of evicted job ids (useful for assertions/logging). + */ +export function pruneFinishedJobs( + jobs: Map, + max = MAX_FINISHED_JOBS, + clearTimer: (handle: NonNullable) => void = clearInterval, +): string[] { + const finished = [...jobs.values()].filter(job => job.status !== "running"); + const excess = finished.length - max; + if (excess <= 0) return []; + const evicted = finished + .sort((a, b) => (a.finishedAt || 0) - (b.finishedAt || 0)) + .slice(0, excess); + for (const job of evicted) { + if (job.__timer) clearTimer(job.__timer); + jobs.delete(job.id); + } + return evicted.map(job => job.id); +} + +/** Runs a background command, threading the live `__job` handle for progress. */ +export type JobRunner = (args: DispatchArgs) => Promise; + +/** Owns the live job map plus its persistence + retention lifecycle. */ +export class JobManager { + private readonly jobs = new Map(); + + /** Snapshot of live jobs (for perf.status etc.). */ + list(): Job[] { + return [...this.jobs.values()]; + } + + private async persistJobs() { + // Persist all running jobs plus the most recent finished ones. Running jobs + // must never be dropped by the cap, otherwise a burst of finished jobs could + // evict a still-active job from storage. Live timer handles are stripped — + // they are not meaningful once stored. + const all = [...this.jobs.values()]; + const running = all.filter(job => job.status === "running"); + const finished = all.filter(job => job.status !== "running").slice(-MAX_FINISHED_JOBS); + const recentJobs = [...running, ...finished].map(({ __timer, __watchdog, ...rest }) => rest); + await chrome.storage.local.set({ recentJobs }); + } + + // Evict the oldest finished jobs once their count exceeds the retention cap. + // Recent finished jobs remain queryable via chrome.storage.local (persistJobs) + // even after eviction from the in-memory Map. + private pruneJobs() { + pruneFinishedJobs(this.jobs, MAX_FINISHED_JOBS); + } + + async start(command: string, args: DispatchArgs, runner: JobRunner) { + const jobId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + const job: Job = { + id: jobId, + command, + status: "running", + phase: "queued", + current: 0, + total: null, + percent: 0, + cancelRequested: false, + startedAt: Date.now(), + updatedAt: Date.now(), + finishedAt: null, + result: null, + error: null, + }; + this.jobs.set(jobId, job); + job.__timer = setInterval(() => this.persistJobs(), 1000); + job.__watchdog = setTimeout(() => { + job.cancelRequested = true; + void this.finalize(job, "error", { error: `Job timed out after ${JOB_TIMEOUT_MS}ms` }); + }, JOB_TIMEOUT_MS); + await this.persistJobs(); + runner({ ...args, __job: job }) + .then(result => this.finalize(job, "done", { result })) + .catch((error: ErrorLike) => + this.finalize(job, job.cancelRequested ? "cancelled" : "error", { + error: getErrorMessage(error), + }), + ); + return { jobId, command, status: job.status }; + } + + /** + * Move a running job to a terminal state exactly once. Late settlements (a + * runner that resolves after the watchdog already fired) are ignored so a + * timed-out job cannot be resurrected. Clears both the persist interval and + * the watchdog so no timer outlives the job. + */ + private async finalize( + job: Job, + status: "done" | "error" | "cancelled", + { result = null, error = null }: { result?: Serializable; error?: string | null }, + ) { + if (job.status !== "running") return; + job.status = status; + job.phase = status; + if (status === "done") { + job.result = result; + job.current = job.total || job.current; + job.percent = 100; + } else { + job.error = error; + } + job.finishedAt = Date.now(); + job.updatedAt = Date.now(); + if (job.__timer) clearInterval(job.__timer); + if (job.__watchdog) clearTimeout(job.__watchdog); + job.__timer = null; + job.__watchdog = null; + this.pruneJobs(); + await this.persistJobs(); + } + + async status({ jobId }: { jobId?: string }) { + const job = this.jobs.get(jobId); + if (job) return { ...job }; + const { recentJobs } = await chrome.storage.local.get<{ recentJobs?: Job[] }>("recentJobs"); + const stored = (recentJobs || []).find(entry => entry.id === jobId); + if (!stored) throw new Error(`Job '${jobId}' not found`); + return stored; + } + + async cancel({ jobId }: { jobId?: string }) { + const job = this.jobs.get(jobId); + if (!job) throw new Error(`Job '${jobId}' not running`); + job.cancelRequested = true; + job.updatedAt = Date.now(); + await this.persistJobs(); + return { jobId, cancelled: true }; + } +} diff --git a/extension/src/classes/NativeConnection.ts b/extension/src/classes/NativeConnection.ts new file mode 100644 index 0000000..405078c --- /dev/null +++ b/extension/src/classes/NativeConnection.ts @@ -0,0 +1,147 @@ +/** + * Native-messaging port lifecycle: connect/keepalive/reconnect plus the inbound + * message router that hands commands to the CommandRegistry. + */ + +import { getErrorMessage, getProfileAlias } from '../core'; +import type { CommandRegistry } from './CommandRegistry'; +import type { SessionCommands } from '../commands/session'; +import type { ControlMessage, ResponseMessage, IncomingMessage, PageRequest, DispatchArgs, Serializable } from '../types'; + +const NATIVE_HOST = "com.browsercli.host"; + +export class NativeConnection { + private port: chrome.runtime.Port | null = null; + private keepaliveEnabled = true; + + constructor( + private readonly registry: CommandRegistry, + private readonly session: SessionCommands, + ) {} + + /** Registers all runtime listeners and opens the initial connection. */ + start() { + chrome.runtime.onInstalled.addListener(() => this.connect()); + chrome.runtime.onStartup.addListener(() => this.connect()); + chrome.runtime.onSuspend.addListener(() => { + this.disconnectPort({ sendBye: true }); + }); + chrome.windows.onCreated.addListener(() => { + this.keepaliveEnabled = true; + if (!this.port) this.connect(); + }); + chrome.windows.onRemoved.addListener(async () => { + const windows = await chrome.windows.getAll({}); + if (windows.length > 0) return; + + this.keepaliveEnabled = false; + this.disconnectPort({ sendBye: true }); + }); + + // Reconnect poll — wakes the worker to re-establish the native port if it + // dropped. 0.5 min is Chrome's minimum alarm period; lower values (e.g. 0.4) + // are silently clamped and log a warning, so we set it explicitly. + chrome.alarms.create("keepalive", { periodInMinutes: 0.5 }); + chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === "keepalive") { + if (!this.port && this.keepaliveEnabled) this.connect(); + } + }); + } + + private sendControlMessage(targetPort: chrome.runtime.Port | null, message: ControlMessage) { + if (!targetPort) return; + try { + targetPort.postMessage(message); + } catch (e) { + console.warn("[browser-cli] Failed to send control message:", e); + } + } + + private sendResponse(targetPort: chrome.runtime.Port | null, message: ResponseMessage) { + if (!targetPort) return; + try { + targetPort.postMessage(message); + } catch (e) { + console.warn("[browser-cli] Failed to send response:", e); + } + } + + private disconnectPort({ sendBye = false }: { sendBye?: boolean } = {}) { + const currentPort = this.port; + if (!currentPort) return; + + if (sendBye) this.sendControlMessage(currentPort, { type: "bye" }); + + if (this.port === currentPort) this.port = null; + + try { + currentPort.disconnect(); + } catch (e) { + console.warn("[browser-cli] Failed to disconnect native port:", e); + } + } + + private async connect() { + if (this.port || !this.keepaliveEnabled) return; + try { + const nativePort = chrome.runtime.connectNative(NATIVE_HOST); + this.port = nativePort; + nativePort.onMessage.addListener((msg: IncomingMessage) => this.onMessage(msg)); + nativePort.onDisconnect.addListener(() => { + if (this.port === nativePort) this.port = null; + const err = chrome.runtime.lastError; + if (err) console.warn("[browser-cli] Native host disconnected:", err.message); + }); + // Send hello so native host knows which profile/alias this is + const alias = await getProfileAlias(); + nativePort.postMessage({ type: "hello", alias }); + console.log("[browser-cli] Connected to native host as profile:", alias); + } catch (e) { + this.port = null; + console.error("[browser-cli] Failed to connect:", e); + } + } + + private async onMessage(msg: IncomingMessage) { + const { id, command, args } = msg; + if (!id || !command) return; + + // Capture the port that delivered this message. dispatch() is awaited, and + // during that await onDisconnect/rename can null out this.port — so we reply + // on the captured port and bail if it (or this.port) is already gone. + const replyPort = this.port; + + console.log("[browser-cli] ←", command, args); + + let data: Serializable, error: string | undefined; + try { + const { __page, __background, ...commandArgs } = args || {}; + data = await this.registry.dispatch(command, commandArgs as DispatchArgs, { + background: Boolean(__background), + page: __page as PageRequest | undefined, + }); + } catch (e) { + error = getErrorMessage(e); + } + + if (this.port !== replyPort) { + console.warn("[browser-cli] Port changed before reply, dropping:", command); + return; + } + + if (error !== undefined) { + console.log("[browser-cli] → ERROR", command, error); + this.sendResponse(replyPort, { id, success: false, error }); + } else { + console.log("[browser-cli] →", command, data); + this.sendResponse(replyPort, { id, success: true, data }); + } + + if (command === "clients.rename_profile" && error === undefined) { + this.disconnectPort({ sendBye: true }); + this.keepaliveEnabled = true; + await this.connect(); + } + } +} diff --git a/extension/src/commands/autosave.ts b/extension/src/commands/autosave.ts new file mode 100644 index 0000000..9bb61dd --- /dev/null +++ b/extension/src/commands/autosave.ts @@ -0,0 +1,84 @@ +import { getSessions, runLargeOperation } from '../core'; +import { captureCurrentSession } from './session-snapshot'; + +// Debounce window for autosave. A full-tab snapshot + storage write runs on +// every tab open/close/move; a longer window coalesces rapid bursts (e.g. +// opening or closing many tabs at once) into a single snapshot. +const AUTOSAVE_DEBOUNCE_MS = 3000; + +export class AutoSaveManager { + private autoSaveTimer: ReturnType | null = null; + private autoSaveInFlight = false; + private autoSavePending = false; + + // Bound handler references — stored so add/removeListener share identity + // (removeListener must use the SAME reference that was added). + readonly autoSaveHandler = async (): Promise => { + await this.scheduleAutoSave(); + }; + readonly autoSaveUpdatedHandler = async (_tabId: number, changeInfo: chrome.tabs.OnUpdatedInfo = {}): Promise => { + // Ignore noisy media/title/favicon/loading updates. Sessions only store URL and group/window structure. + if (!("url" in changeInfo)) return; + await this.scheduleAutoSave(); + }; + + async setEnabled(enabled: boolean) { + await chrome.storage.local.set({ autoSave: enabled }); + chrome.tabs.onCreated.removeListener(this.autoSaveHandler); + chrome.tabs.onRemoved.removeListener(this.autoSaveHandler); + chrome.tabs.onMoved.removeListener(this.autoSaveHandler); + chrome.tabs.onAttached.removeListener(this.autoSaveHandler); + chrome.tabs.onDetached.removeListener(this.autoSaveHandler); + chrome.tabs.onUpdated.removeListener(this.autoSaveUpdatedHandler); + if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.removeListener(this.autoSaveHandler); + if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer); + this.autoSaveTimer = null; + this.autoSavePending = false; + if (enabled) { + chrome.tabs.onCreated.addListener(this.autoSaveHandler); + chrome.tabs.onRemoved.addListener(this.autoSaveHandler); + chrome.tabs.onMoved.addListener(this.autoSaveHandler); + chrome.tabs.onAttached.addListener(this.autoSaveHandler); + chrome.tabs.onDetached.addListener(this.autoSaveHandler); + chrome.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler); + if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.addListener(this.autoSaveHandler); + } + return { enabled }; + } + + private async saveAutoSessionIfChanged() { + const { session, signature, tabCount } = await captureCurrentSession(); + const { autoSaveSignature } = await chrome.storage.local.get("autoSaveSignature"); + if (autoSaveSignature === signature) return { skipped: true, tabs: tabCount }; + + const sessions = await getSessions(); + sessions.__auto__ = session; + await chrome.storage.local.set({ sessions, autoSaveSignature: signature }); + return { skipped: false, tabs: tabCount }; + } + + private async runAutoSave() { + if (this.autoSaveInFlight) { + this.autoSavePending = true; + return; + } + this.autoSaveInFlight = true; + try { + const { autoSave } = await chrome.storage.local.get("autoSave"); + if (autoSave) await runLargeOperation("session.auto_save", () => this.saveAutoSessionIfChanged()); + } finally { + this.autoSaveInFlight = false; + if (this.autoSavePending) { + this.autoSavePending = false; + this.autoSaveTimer = setTimeout(() => this.runAutoSave(), AUTOSAVE_DEBOUNCE_MS); + } + } + } + + private async scheduleAutoSave(delayMs = AUTOSAVE_DEBOUNCE_MS) { + const { autoSave } = await chrome.storage.local.get("autoSave"); + if (!autoSave) return; + if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer); + this.autoSaveTimer = setTimeout(() => this.runAutoSave(), delayMs); + } +} diff --git a/extension/src/commands/browser-data.ts b/extension/src/commands/browser-data.ts index 6883271..75f9e76 100644 --- a/extension/src/commands/browser-data.ts +++ b/extension/src/commands/browser-data.ts @@ -1,62 +1,74 @@ -// @ts-nocheck -import { executeScript, getActiveTab, isScriptableUrl } from '../core'; -export async function storageGet({ key, type = "local", tabId } = {}) { - const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); - if (!isScriptableUrl(tab.url)) { - throw new Error(`Cannot access storage on ${tab.url} — navigate to a regular web page first`); +import { executeScript, isScriptableUrl, resolveTabUrl } from '../core'; +import { CommandGroup } from '../classes/CommandGroup'; +import type { CommandEntry } from '../classes/CommandGroup'; +import type { Json, StorageGetArgs, StorageSetArgs, CookiesListArgs, CookiesGetArgs, CookiesSetArgs } from '../types'; + +export class BrowserDataCommands extends CommandGroup { + readonly namespace = "storage"; + readonly commands: Record = { + "storage.get": (a: StorageGetArgs) => this.storageGet(a), + "storage.set": (a: StorageSetArgs) => this.storageSet(a), + "cookies.list": (a: CookiesListArgs) => this.cookiesList(a), + "cookies.get": (a: CookiesGetArgs) => this.cookiesGet(a), + "cookies.set": (a: CookiesSetArgs) => this.cookiesSet(a), + }; + + private async storageGet({ key, type = "local", tabId }: StorageGetArgs = {}) { + const { tab } = await resolveTabUrl(tabId); + if (!isScriptableUrl(tab.url)) { + throw new Error(`Cannot access storage on ${tab.url} — navigate to a regular web page first`); + } + const results = await executeScript({ + target: { tabId: tab.id }, + world: "MAIN", + func: (k: string | null, t: string) => { + const store = t === "session" ? sessionStorage : localStorage; + if (k) return store.getItem(k); + return Object.fromEntries(Object.keys(store).map(key => [key, store.getItem(key)])); + }, + args: [key || null, type], + }); + return results[0]?.result ?? null; } - const results = await executeScript({ - target: { tabId: tab.id }, - world: "MAIN", - func: (k, t) => { - const store = t === "session" ? sessionStorage : localStorage; - if (k) return store.getItem(k); - return Object.fromEntries(Object.keys(store).map(key => [key, store.getItem(key)])); - }, - args: [key || null, type], - }); - return results[0]?.result ?? null; -} -export async function storageSet({ key, value, type = "local", tabId } = {}) { - const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); - if (!isScriptableUrl(tab.url)) { - throw new Error(`Cannot access storage on ${tab.url} — navigate to a regular web page first`); + private async storageSet({ key, value, type = "local", tabId }: StorageSetArgs = {}) { + const { tab } = await resolveTabUrl(tabId); + if (!isScriptableUrl(tab.url)) { + throw new Error(`Cannot access storage on ${tab.url} — navigate to a regular web page first`); + } + const results = await executeScript({ + target: { tabId: tab.id }, + world: "MAIN", + func: (k: string, v: Json, t: string) => { + const store = t === "session" ? sessionStorage : localStorage; + store.setItem(k, typeof v === "string" ? v : JSON.stringify(v)); + return true; + }, + args: [key, value, type], + }); + return results[0]?.result ?? false; } - const results = await executeScript({ - target: { tabId: tab.id }, - world: "MAIN", - func: (k, v, t) => { - const store = t === "session" ? sessionStorage : localStorage; - store.setItem(k, typeof v === "string" ? v : JSON.stringify(v)); - return true; - }, - args: [key, value, type], - }); - return results[0]?.result ?? false; -} -export async function cookiesList({ url, domain, name } = {}) { - const details = {}; - if (url) details.url = url; - if (domain) details.domain = domain; - if (name) details.name = name; - return await chrome.cookies.getAll(details); -} + private async cookiesList({ url, domain, name }: CookiesListArgs = {}) { + const details: chrome.cookies.GetAllDetails = {}; + if (url) details.url = url; + if (domain) details.domain = domain; + if (name) details.name = name; + return await chrome.cookies.getAll(details); + } -export async function cookiesGet({ url, name }) { - return await chrome.cookies.get({ url, name }); -} + private async cookiesGet({ url, name }: CookiesGetArgs) { + return await chrome.cookies.get({ url, name }); + } -export async function cookiesSet({ url, name, value, domain, path, secure, httpOnly, expirationDate, sameSite } = {}) { - const details = { url, name, value }; - if (domain != null) details.domain = domain; - if (path != null) details.path = path; - if (secure != null) details.secure = secure; - if (httpOnly != null) details.httpOnly = httpOnly; - if (expirationDate != null) details.expirationDate = expirationDate; - if (sameSite != null) details.sameSite = sameSite; - return await chrome.cookies.set(details); + private async cookiesSet({ url, name, value, domain, path, secure, httpOnly, expirationDate, sameSite }: CookiesSetArgs = {}) { + const details: chrome.cookies.SetDetails = { url, name, value }; + if (domain != null) details.domain = domain; + if (path != null) details.path = path; + if (secure != null) details.secure = secure; + if (httpOnly != null) details.httpOnly = httpOnly; + if (expirationDate != null) details.expirationDate = expirationDate; + if (sameSite != null) details.sameSite = sameSite; + return await chrome.cookies.set(details); + } } - -// This function is serialized and injected into the page by chrome.scripting diff --git a/extension/src/commands/dom.ts b/extension/src/commands/dom.ts index c9bffae..8c6751a 100644 --- a/extension/src/commands/dom.ts +++ b/extension/src/commands/dom.ts @@ -1,8 +1,10 @@ -// @ts-nocheck -import { executeScript, getActiveTab, isBrowserErrorUrl, isErrorPageScriptError, isScriptableUrl } from '../core'; +import { assertScriptableUrl, executeScript, fetchTabHtml, isBrowserErrorUrl, isErrorPageScriptError, resolveTabUrl } from '../core'; import { contentDispatch } from './injected'; +import { CommandGroup } from '../classes/CommandGroup'; +import type { CommandEntry } from '../classes/CommandGroup'; +import type { DomArgs, DomEvalArgs, DomWaitForArgs, DomPollArgs, Serializable } from '../types'; -function fallbackForErrorPageDomOp(funcName, tab) { +function fallbackForErrorPageDomOp(funcName: string, tab: chrome.tabs.Tab): Serializable { switch (funcName) { case "domExists": return false; @@ -20,7 +22,7 @@ function fallbackForErrorPageDomOp(funcName, tab) { title: tab.title || "", url: tab.url || tab.pendingUrl || "", readyState: "error", - lang: null, + lang: null as string | null, meta: {}, }; default: @@ -28,113 +30,143 @@ function fallbackForErrorPageDomOp(funcName, tab) { } } -export async function domOp(funcName, args = {}) { - const tab = args?.tabId ? await chrome.tabs.get(args.tabId) : await getActiveTab(); - const tabUrl = tab.url || tab.pendingUrl || ""; - if (isBrowserErrorUrl(tabUrl)) { - const fallback = fallbackForErrorPageDomOp(funcName, tab); - if (fallback !== undefined) return fallback; - } - if (!isScriptableUrl(tabUrl)) { - throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`); - } - try { - const results = await executeScript({ - target: { tabId: tab.id }, - func: contentDispatch, - args: [funcName, args], - }); - return results[0]?.result; - } catch (e) { - if (isErrorPageScriptError(e)) { +export class DomCommands extends CommandGroup { + readonly namespace = "dom"; + + readonly commands: Record = { + // ── DOM ────────────────────────────────────────────────────────────── + "dom.query": (a: DomArgs) => this.domOp("domQuery", a), + "dom.click": (a: DomArgs) => this.domOp("domClick", a), + "dom.type": (a: DomArgs) => this.domOp("domType", a), + "dom.attr": (a: DomArgs) => this.domOp("domAttr", a), + "dom.text": (a: DomArgs) => this.domOp("domText", a), + "dom.exists": (a: DomArgs) => this.domOp("domExists", a), + "dom.scroll": (a: DomArgs) => this.domOp("domScroll", a), + "dom.select": (a: DomArgs) => this.domOp("domSelect", a), + "dom.key": (a: DomArgs) => this.domOp("domKey", a), + "dom.hover": (a: DomArgs) => this.domOp("domHover", a), + "dom.check": (a: DomArgs) => this.domOp("domCheck", { ...a, checked: true }), + "dom.uncheck": (a: DomArgs) => this.domOp("domCheck", { ...a, checked: false }), + "dom.clear": (a: DomArgs) => this.domOp("domClear", a), + "dom.focus": (a: DomArgs) => this.domOp("domFocus", a), + "dom.submit": (a: DomArgs) => this.domOp("domSubmit", a), + "dom.eval": (a: DomEvalArgs) => this.domEval(a), + "dom.wait_for": (a: DomWaitForArgs) => this.domWaitFor(a), + "dom.poll": (a: DomPollArgs) => this.domPoll(a), + + // ── Page ───────────────────────────────────────────────────────────── + "page.info": () => this.domOp("pageInfo", {}), + + // ── Extract ────────────────────────────────────────────────────────── + "extract.links": (a: DomArgs) => this.domOp("extractLinks", a), + "extract.images": (a: DomArgs) => this.domOp("extractImages", a), + "extract.text": (a: DomArgs) => this.domOp("extractText", a), + "extract.json": (a: DomArgs) => this.domOp("extractJson", a), + "extract.markdown": (a: DomArgs) => this.domOp("extractMarkdown", a), + "extract.html": () => fetchTabHtml(undefined), + }; + + private async domOp(funcName: string, args: DomArgs = {}) { + const { tab, url: tabUrl } = await resolveTabUrl(args?.tabId); + if (isBrowserErrorUrl(tabUrl)) { const fallback = fallbackForErrorPageDomOp(funcName, tab); if (fallback !== undefined) return fallback; } - throw e; - } -} - -export async function domEval({ code, tabId } = {}) { - const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); - const tabUrl = tab.url || tab.pendingUrl || ""; - if (!isScriptableUrl(tabUrl) || isBrowserErrorUrl(tabUrl)) { - throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`); - } - const results = await executeScript({ - target: { tabId: tab.id }, - world: "MAIN", - func: (c) => (0, eval)(c), - args: [code], - }); - return results[0]?.result ?? null; -} - -export async function domWaitFor({ selector, timeout = 10000, visible = false, hidden = false, tabId } = {}) { - const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); - const tabUrl = tab.url || tab.pendingUrl || ""; - if (isBrowserErrorUrl(tabUrl)) { - if (hidden) return { selector, found: false }; - throw new Error(`Cannot wait for DOM on browser error page ${tabUrl}`); - } - if (!isScriptableUrl(tabUrl)) { - throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`); - } - const deadline = Date.now() + timeout; - while (Date.now() < deadline) { + assertScriptableUrl(tabUrl, "run DOM commands on"); try { const results = await executeScript({ target: { tabId: tab.id }, - func: (sel, vis, hid) => { - const el = document.querySelector(sel); - if (hid) return !el || el.offsetParent === null; - if (!el) return false; - if (vis) { - const r = el.getBoundingClientRect(); - return r.width > 0 && r.height > 0; - } - return true; - }, - args: [selector, visible, hidden], + func: contentDispatch, + args: [funcName, args], }); - if (results[0]?.result) return { selector, found: !hidden }; + return results[0]?.result; } catch (e) { - if (hidden && isErrorPageScriptError(e)) return { selector, found: false }; - if (!isErrorPageScriptError(e)) throw e; + if (isErrorPageScriptError(e)) { + const fallback = fallbackForErrorPageDomOp(funcName, tab); + if (fallback !== undefined) return fallback; + } + throw e; } - await new Promise(r => setTimeout(r, 200)); } - throw new Error(`Selector '${selector}' condition not met within ${timeout}ms`); -} -export async function domPoll({ selector, pattern, attr, timeout = 30000, interval = 500, tabId } = {}) { - const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); - const tabUrl = tab.url || tab.pendingUrl || ""; - if (isBrowserErrorUrl(tabUrl)) { - throw new Error(`Cannot poll DOM on browser error page ${tabUrl}`); - } - if (!isScriptableUrl(tabUrl)) { - throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`); - } - const deadline = Date.now() + timeout; - const regex = new RegExp(pattern); - while (Date.now() < deadline) { - try { - const results = await executeScript({ - target: { tabId: tab.id }, - func: (sel, a) => { - const el = document.querySelector(sel); - if (!el) return null; - if (a) return el.getAttribute(a) ?? el[a] ?? null; - return el.value !== undefined ? el.value : el.textContent.trim(); - }, - args: [selector, attr || null], - }); - const value = results[0]?.result; - if (value != null && regex.test(String(value))) return { selector, value, pattern }; - } catch (e) { - if (!isErrorPageScriptError(e)) throw e; + private async domEval({ code, tabId }: DomEvalArgs = {}) { + const { tab, url: tabUrl } = await resolveTabUrl(tabId); + if (isBrowserErrorUrl(tabUrl)) { + throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`); } - await new Promise(r => setTimeout(r, interval)); + assertScriptableUrl(tabUrl, "run DOM commands on"); + const results = await executeScript({ + target: { tabId: tab.id }, + world: "MAIN", + func: (c: string) => (0, eval)(c), + args: [code], + }); + return results[0]?.result ?? null; + } + + private async domWaitFor({ selector, timeout = 10000, visible = false, hidden = false, tabId }: DomWaitForArgs = {}) { + const { tab, url: tabUrl } = await resolveTabUrl(tabId); + if (isBrowserErrorUrl(tabUrl)) { + if (hidden) return { selector, found: false }; + throw new Error(`Cannot wait for DOM on browser error page ${tabUrl}`); + } + assertScriptableUrl(tabUrl, "run DOM commands on"); + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + try { + const results = await executeScript({ + target: { tabId: tab.id }, + func: (sel: string, vis: boolean, hid: boolean) => { + const el = document.querySelector(sel) as HTMLElement | null; + if (hid) return !el || el.offsetParent === null; + if (!el) return false; + if (vis) { + const r = el.getBoundingClientRect(); + return r.width > 0 && r.height > 0; + } + return true; + }, + args: [selector, visible, hidden], + }); + if (results[0]?.result) return { selector, found: !hidden }; + } catch (e) { + if (hidden && isErrorPageScriptError(e)) return { selector, found: false }; + if (!isErrorPageScriptError(e)) throw e; + } + await new Promise(r => setTimeout(r, 200)); + } + throw new Error(`Selector '${selector}' condition not met within ${timeout}ms`); + } + + private async domPoll({ selector, pattern, attr, timeout = 30000, interval = 500, tabId }: DomPollArgs = {}) { + const { tab, url: tabUrl } = await resolveTabUrl(tabId); + if (isBrowserErrorUrl(tabUrl)) { + throw new Error(`Cannot poll DOM on browser error page ${tabUrl}`); + } + assertScriptableUrl(tabUrl, "run DOM commands on"); + const deadline = Date.now() + timeout; + const regex = new RegExp(pattern); + while (Date.now() < deadline) { + try { + const results = await executeScript({ + target: { tabId: tab.id }, + func: (sel: string, a: string | null) => { + const el = document.querySelector(sel); + if (!el) return null; + // Page elements are accessed dynamically (arbitrary attr / .value). + const dyn = el as Element & { value?: string } & Record; + if (a) return el.getAttribute(a) ?? dyn[a] ?? null; + return dyn.value !== undefined ? dyn.value : el.textContent.trim(); + }, + args: [selector, attr || null], + }); + const value = results[0]?.result; + if (value != null && regex.test(String(value))) return { selector, value, pattern }; + } catch (e) { + if (!isErrorPageScriptError(e)) throw e; + } + await new Promise(r => setTimeout(r, interval)); + } + throw new Error(`Selector '${selector}' did not match '${pattern}' within ${timeout}ms`); } - throw new Error(`Selector '${selector}' did not match '${pattern}' within ${timeout}ms`); } diff --git a/extension/src/commands/extension.ts b/extension/src/commands/extension.ts new file mode 100644 index 0000000..feea565 --- /dev/null +++ b/extension/src/commands/extension.ts @@ -0,0 +1,12 @@ +import { CommandGroup } from '../classes/CommandGroup'; +import type { CommandEntry } from '../classes/CommandGroup'; + +export class ExtensionCommands extends CommandGroup { + readonly namespace = "extension"; + readonly commands: Record = { + "extension.reload": () => { + setTimeout(() => chrome.runtime.reload(), 200); + return { reloading: true }; + }, + }; +} diff --git a/extension/src/commands/groups.ts b/extension/src/commands/groups.ts index 1eed4b8..a1d212a 100644 --- a/extension/src/commands/groups.ts +++ b/extension/src/commands/groups.ts @@ -1,105 +1,114 @@ -// @ts-nocheck -import { buildTabBlocks, getLargeOperationThrottle, resolveGroupId, runLargeOperation, tabInfo, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core'; -export async function groupList() { - const groups = await chrome.tabGroups.query({}); - const all = await chrome.tabs.query({}); - return groups.map(g => ({ - id: g.id, - title: g.title, - color: g.color, - collapsed: g.collapsed, - windowId: g.windowId, - tabCount: all.filter(t => t.groupId === g.id).length, - })); -} +import { asTabIds, buildTabBlocks, getLargeOperationThrottle, processInBatches, resolveGroupId, runLargeOperation, tabInfo } from '../core'; +import { CommandGroup } from '../classes/CommandGroup'; +import type { CommandEntry } from '../classes/CommandGroup'; +import type { GroupTabsArgs, GroupQueryArgs, GroupCloseArgs, GroupOpenArgs, GroupAddTabArgs, GroupMoveArgs } from '../types'; -export async function groupTabs({ groupId }) { - const all = await chrome.tabs.query({}); - return all.filter(t => t.groupId === groupId).map(tabInfo); -} +export class GroupsCommands extends CommandGroup { + readonly namespace = "group"; + readonly commands: Record = { + "group.list": () => this.groupList(), + "group.tabs": (a: GroupTabsArgs) => this.groupTabs(a), + "group.count": () => this.groupCount(), + "group.query": (a: GroupQueryArgs) => this.groupQuery(a), + "group.close": { background: true, run: (a: GroupCloseArgs) => this.groupClose(a) }, + "group.open": (a: GroupOpenArgs) => this.groupOpen(a), + "group.add_tab": (a: GroupAddTabArgs) => this.groupAddTab(a), + "group.move": (a: GroupMoveArgs) => this.groupMove(a), + }; -export async function groupCount() { - const groups = await chrome.tabGroups.query({}); - return groups.length; -} + private async groupList() { + const groups = await chrome.tabGroups.query({}); + const all = await chrome.tabs.query({}); + return groups.map(g => ({ + id: g.id, + title: g.title, + color: g.color, + collapsed: g.collapsed, + windowId: g.windowId, + tabCount: all.filter(t => t.groupId === g.id).length, + })); + } -export async function groupQuery({ search }) { - const q = search.toLowerCase(); - const groups = await chrome.tabGroups.query({}); - return groups.filter(g => g.title && g.title.toLowerCase().includes(q)); -} + private async groupTabs({ groupId }: GroupTabsArgs) { + const all = await chrome.tabs.query({}); + return all.filter(t => t.groupId === groupId).map(tabInfo); + } -export async function groupClose({ groupId, gentleMode, __job } = {}) { - return runLargeOperation("group.close", async () => { - const tabs = await chrome.tabs.query({}); - const groupTabs = tabs.filter(t => t.groupId === groupId); - const tabIds = groupTabs.map(t => t.id); - const throttle = await getLargeOperationThrottle(tabIds.length, gentleMode); - updateJobProgress(__job, { phase: "ungrouping tabs", current: 0, total: tabIds.length }); - for (let i = 0; i < tabIds.length; i += throttle.batchSize) { - throwIfJobCancelled(__job); - await chrome.tabs.ungroup(tabIds.slice(i, i + throttle.batchSize)); - updateJobProgress(__job, { phase: "ungrouping tabs", current: Math.min(i + throttle.batchSize, tabIds.length), total: tabIds.length }); - await yieldForLargeOperation(i + throttle.batchSize, throttle.batchSize, throttle.pauseMs); + private async groupCount() { + const groups = await chrome.tabGroups.query({}); + return groups.length; + } + + private async groupQuery({ search }: GroupQueryArgs) { + const q = search.toLowerCase(); + const groups = await chrome.tabGroups.query({}); + return groups.filter(g => g.title && g.title.toLowerCase().includes(q)); + } + + private async groupClose({ groupId, gentleMode, __job }: GroupCloseArgs = {}) { + return runLargeOperation("group.close", async () => { + const tabs = await chrome.tabs.query({}); + const groupTabs = tabs.filter(t => t.groupId === groupId); + const tabIds = groupTabs.map(t => t.id); + const throttle = await getLargeOperationThrottle(tabIds.length, gentleMode); + await processInBatches(tabIds, throttle, batch => chrome.tabs.ungroup(asTabIds(batch)), { job: __job, phase: "ungrouping tabs" }); + return { groupId, gentle: throttle.gentle, audible: throttle.audible }; + }); + } + + private async groupOpen({ name }: GroupOpenArgs) { + const tab = await chrome.tabs.create({ active: true }); + const groupId = await chrome.tabs.group({ tabIds: asTabIds([tab.id]) }); + await chrome.tabGroups.update(groupId, { title: name }); + return { id: groupId, name }; + } + + private async groupAddTab({ group, url }: GroupAddTabArgs) { + const groupId = await resolveGroupId(group); + const existingTabs = await chrome.tabs.query({ groupId }); + const tab = await chrome.tabs.create({ url: url || "chrome://newtab/", active: true }); + await chrome.tabs.group({ tabIds: asTabIds([tab.id]), groupId }); + // If a URL was provided, close any blank placeholder tabs left from group creation + if (url) { + const placeholders = existingTabs.filter(t => + t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/" + ); + if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id)); } - return { groupId, gentle: throttle.gentle, audible: throttle.audible }; - }); -} - -export async function groupOpen({ name }) { - const tab = await chrome.tabs.create({ active: true }); - const groupId = await chrome.tabs.group({ tabIds: [tab.id] }); - await chrome.tabGroups.update(groupId, { title: name }); - return { id: groupId, name }; -} - -export async function groupAddTab({ group, url }) { - const groupId = await resolveGroupId(group); - const existingTabs = await chrome.tabs.query({ groupId }); - const tab = await chrome.tabs.create({ url: url || "chrome://newtab/", active: true }); - await chrome.tabs.group({ tabIds: [tab.id], groupId }); - // If a URL was provided, close any blank placeholder tabs left from group creation - if (url) { - const placeholders = existingTabs.filter(t => - t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/" - ); - if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id)); - } - return { tabId: tab.id, groupId }; -} - -export async function groupMove({ group, forward, backward }) { - const groupId = await resolveGroupId(group); - const groupInfo = await chrome.tabGroups.get(groupId); - const allTabs = await chrome.tabs.query({ windowId: groupInfo.windowId }); - allTabs.sort((a, b) => a.index - b.index); - - const blocks = buildTabBlocks(allTabs); - const currentIdx = blocks.findIndex(block => block.groupId === groupId); - if (currentIdx === -1) throw new Error(`No tabs found in group '${group}'`); - - const currentBlock = blocks[currentIdx]; - const currentLength = currentBlock.tabIds.length; - - if (forward) { - const nextBlock = blocks[currentIdx + 1]; - if (!nextBlock) return { groupId, moved: false }; - const targetIndex = - nextBlock.groupId === null - ? currentBlock.startIndex + 1 - : nextBlock.endIndex - currentLength + 1; - await chrome.tabGroups.move(groupId, { index: targetIndex }); - } else if (backward) { - const previousBlock = blocks[currentIdx - 1]; - if (!previousBlock) return { groupId, moved: false }; - const targetIndex = - previousBlock.groupId === null - ? currentBlock.startIndex - 1 - : previousBlock.startIndex; - await chrome.tabGroups.move(groupId, { index: targetIndex }); + return { tabId: tab.id, groupId }; } - return { groupId, moved: true }; -} + private async groupMove({ group, forward, backward }: GroupMoveArgs) { + const groupId = await resolveGroupId(group); + const groupInfo = await chrome.tabGroups.get(groupId); + const allTabs = await chrome.tabs.query({ windowId: groupInfo.windowId }); + allTabs.sort((a, b) => a.index - b.index); -// ── Windows ─────────────────────────────────────────────────────────────────── + const blocks = buildTabBlocks(allTabs); + const currentIdx = blocks.findIndex(block => block.groupId === groupId); + if (currentIdx === -1) throw new Error(`No tabs found in group '${group}'`); + + const currentBlock = blocks[currentIdx]; + const currentLength = currentBlock.tabIds.length; + + if (forward) { + const nextBlock = blocks[currentIdx + 1]; + if (!nextBlock) return { groupId, moved: false }; + const targetIndex = + nextBlock.groupId === null + ? currentBlock.startIndex + 1 + : nextBlock.endIndex - currentLength + 1; + await chrome.tabGroups.move(groupId, { index: targetIndex }); + } else if (backward) { + const previousBlock = blocks[currentIdx - 1]; + if (!previousBlock) return { groupId, moved: false }; + const targetIndex = + previousBlock.groupId === null + ? currentBlock.startIndex - 1 + : previousBlock.startIndex; + await chrome.tabGroups.move(groupId, { index: targetIndex }); + } + + return { groupId, moved: true }; + } +} diff --git a/extension/src/commands/injected.ts b/extension/src/commands/injected.ts index 2c86b7a..694d865 100644 --- a/extension/src/commands/injected.ts +++ b/extension/src/commands/injected.ts @@ -1,20 +1,21 @@ -// @ts-nocheck -export function contentDispatch(funcName, args) { - function domQuery({ selector }) { +import type { ContentArgs } from '../types'; + +export function contentDispatch(funcName: string, args: ContentArgs) { + function domQuery({ selector }: ContentArgs) { return Array.from(document.querySelectorAll(selector)).map(el => ({ tag: el.tagName.toLowerCase(), text: el.textContent.trim().slice(0, 200), attrs: Object.fromEntries(Array.from(el.attributes).map(a => [a.name, a.value])), })); } - function domClick({ selector }) { - const el = document.querySelector(selector); + function domClick({ selector }: ContentArgs) { + const el = document.querySelector(selector) as HTMLElement | null; if (!el) throw new Error(`No element: ${selector}`); el.click(); return true; } - function domType({ selector, text }) { - const el = document.querySelector(selector); + function domType({ selector, text }: ContentArgs) { + const el = document.querySelector(selector) as HTMLInputElement | null; if (!el) throw new Error(`No element: ${selector}`); el.focus(); el.value = text; @@ -22,20 +23,20 @@ export function contentDispatch(funcName, args) { el.dispatchEvent(new Event("change", { bubbles: true })); return true; } - function domAttr({ selector, attr }) { + function domAttr({ selector, attr }: ContentArgs) { return Array.from(document.querySelectorAll(selector)) .map(el => el.getAttribute(attr)) .filter(v => v !== null); } - function domText({ selector }) { + function domText({ selector }: ContentArgs) { return Array.from(document.querySelectorAll(selector)) .map(el => el.textContent.trim()) .filter(Boolean); } - function domExists({ selector }) { + function domExists({ selector }: ContentArgs) { return document.querySelector(selector) !== null; } - function domKey({ selector, key }) { + function domKey({ selector, key }: ContentArgs) { const el = selector ? document.querySelector(selector) : document.activeElement; if (selector && !el) throw new Error(`No element: ${selector}`); const target = el || document.body; @@ -44,44 +45,44 @@ export function contentDispatch(funcName, args) { }); return true; } - function domHover({ selector }) { + function domHover({ selector }: ContentArgs) { const el = document.querySelector(selector); if (!el) throw new Error(`No element: ${selector}`); el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); el.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); return true; } - function domCheck({ selector, checked }) { - const el = document.querySelector(selector); + function domCheck({ selector, checked }: ContentArgs) { + const el = document.querySelector(selector) as HTMLInputElement | null; if (!el) throw new Error(`No element: ${selector}`); el.checked = checked; el.dispatchEvent(new Event("change", { bubbles: true })); return true; } - function domClear({ selector }) { - const el = document.querySelector(selector); + function domClear({ selector }: ContentArgs) { + const el = document.querySelector(selector) as HTMLInputElement | null; if (!el) throw new Error(`No element: ${selector}`); el.value = ""; el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); return true; } - function domFocus({ selector }) { - const el = document.querySelector(selector); + function domFocus({ selector }: ContentArgs) { + const el = document.querySelector(selector) as HTMLElement | null; if (!el) throw new Error(`No element: ${selector}`); el.focus(); return true; } - function domSubmit({ selector }) { + function domSubmit({ selector }: ContentArgs) { const el = document.querySelector(selector); if (!el) throw new Error(`No element: ${selector}`); - const form = el.tagName === "FORM" ? el : el.closest("form"); + const form = (el.tagName === "FORM" ? el : el.closest("form")) as HTMLFormElement | null; if (!form) throw new Error(`No form found for: ${selector}`); form.submit(); return true; } function pageInfo() { - const metas = {}; + const metas: Record = {}; document.querySelectorAll("meta[name], meta[property]").forEach(m => { const k = m.getAttribute("name") || m.getAttribute("property"); if (k) metas[k] = m.getAttribute("content") || ""; @@ -94,7 +95,7 @@ export function contentDispatch(funcName, args) { meta: metas, }; } - function domScroll({ selector, x, y }) { + function domScroll({ selector, x, y }: ContentArgs) { if (selector) { const el = document.querySelector(selector); if (!el) throw new Error(`No element: ${selector}`); @@ -104,8 +105,8 @@ export function contentDispatch(funcName, args) { window.scrollTo({ top: y || 0, left: x || 0, behavior: "smooth" }); return true; } - function domSelect({ selector, value }) { - const el = document.querySelector(selector); + function domSelect({ selector, value }: ContentArgs) { + const el = document.querySelector(selector) as HTMLSelectElement | null; if (!el) throw new Error(`No element: ${selector}`); el.value = value; el.dispatchEvent(new Event("change", { bubbles: true })); @@ -113,9 +114,9 @@ export function contentDispatch(funcName, args) { return true; } function extractLinks() { - const seen = new Set(); - return Array.from(document.querySelectorAll("a[href]")).reduce((links, a) => { - const href = a.href; + const seen = new Set(); + return Array.from(document.querySelectorAll("a[href]")).reduce>((links, a) => { + const href = (a as HTMLAnchorElement).href; if (!href || seen.has(href)) return links; seen.add(href); links.push({ @@ -126,8 +127,8 @@ export function contentDispatch(funcName, args) { }, []); } function extractImages() { - const seen = new Set(); - return Array.from(document.querySelectorAll("img")).reduce((images, img) => { + const seen = new Set(); + return Array.from(document.querySelectorAll("img")).reduce>((images, img) => { const src = img.src || img.getAttribute("data-src") || @@ -146,12 +147,12 @@ export function contentDispatch(funcName, args) { function extractText() { return document.body.innerText; } - function extractJson({ selector }) { + function extractJson({ selector }: ContentArgs) { const el = document.querySelector(selector); if (!el) throw new Error(`No element: ${selector}`); return JSON.parse(el.textContent); } - function extractMarkdown({ selector }) { + function extractMarkdown({ selector }: ContentArgs) { const BLOCKS = new Set([ "article", "aside", "blockquote", "body", "div", "dl", "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hr", @@ -185,11 +186,11 @@ export function contentDispatch(funcName, args) { "[data-testid*='action-button']", ].join(", "); - function normalizeText(value) { + function normalizeText(value: string) { return value.replace(/\s+/g, " ").trim(); } - function normalizeInline(value) { + function normalizeInline(value: string) { return value .replace(/[ \t]+\n/g, "\n") .replace(/\n[ \t]+/g, "\n") @@ -198,46 +199,47 @@ export function contentDispatch(funcName, args) { .trim(); } - function collapseBlankLines(value) { + function collapseBlankLines(value: string) { return value .replace(/[ \t]+\n/g, "\n") .replace(/\n{3,}/g, "\n\n") .trim(); } - function escapeMarkdown(text) { + function escapeMarkdown(text: string) { return text.replace(/([\\`[\]])/g, "\\$1"); } - function escapeTableCell(text) { + function escapeTableCell(text: string) { return text.replace(/\|/g, "\\|").replace(/\n+/g, " ").trim(); } - function absoluteUrl(attr, fallback) { + function absoluteUrl(attr: string | null | undefined, fallback?: string) { return attr || fallback || ""; } - function isNoiseElement(node) { + function isNoiseElement(node: Node | null): boolean { if (!node || node.nodeType !== Node.ELEMENT_NODE) return false; - const tag = node.tagName.toLowerCase(); + const el = node as Element; + const tag = el.tagName.toLowerCase(); if (["script", "style", "noscript", "template", "svg", "canvas", "iframe", "dialog"].includes(tag)) return true; if (["button", "input", "textarea", "select", "option", "form"].includes(tag)) return true; - if (node.hasAttribute("hidden")) return true; - if ((node.getAttribute("aria-hidden") || "").toLowerCase() === "true") return true; - if (node.matches(".sr-only, [class*='sr-only']")) return true; - if (node.matches("[class*='file-tile'], form[data-type='unified-composer'], .composer-btn, [data-composer-surface='true'], #thread-bottom-container")) return true; - if (node.matches("[data-testid*='action-button']")) return true; + if (el.hasAttribute("hidden")) return true; + if ((el.getAttribute("aria-hidden") || "").toLowerCase() === "true") return true; + if (el.matches(".sr-only, [class*='sr-only']")) return true; + if (el.matches("[class*='file-tile'], form[data-type='unified-composer'], .composer-btn, [data-composer-surface='true'], #thread-bottom-container")) return true; + if (el.matches("[data-testid*='action-button']")) return true; return false; } - function stripNoise(root) { - const clone = root.cloneNode(true); + function stripNoise(root: Element): Element { + const clone = root.cloneNode(true) as Element; clone.querySelectorAll(NOISE_SELECTOR).forEach(node => node.remove()); return clone; } - function candidateScore(node) { - const text = normalizeText(node.innerText || ""); + function candidateScore(node: Element) { + const text = normalizeText((node as HTMLElement).innerText || ""); if (!text) return -Infinity; const headings = node.querySelectorAll("h1, h2, h3, h4, h5, h6").length; @@ -276,73 +278,76 @@ export function contentDispatch(funcName, args) { const candidates = Array.from(document.querySelectorAll( "main, article, [role='main'], section, .markdown, .prose, [data-message-author-role]" )) - .filter(node => normalizeText(node.innerText || "").length > 0); + .filter(node => normalizeText((node as HTMLElement).innerText || "").length > 0); if (!candidates.length) return document.body; candidates.sort((a, b) => candidateScore(b) - candidateScore(a)); return candidates[0]; } - function inlineText(node) { + function inlineText(node: Node): string { if (node.nodeType === Node.TEXT_NODE) { return escapeMarkdown(node.textContent || ""); } if (node.nodeType !== Node.ELEMENT_NODE) return ""; if (isNoiseElement(node)) return ""; - const tag = node.tagName.toLowerCase(); + const el = node as HTMLElement; + const tag = el.tagName.toLowerCase(); if (tag === "br") return "\n"; if (tag === "img") { - const src = absoluteUrl(node.getAttribute("src"), node.src); + const img = el as HTMLImageElement; + const src = absoluteUrl(img.getAttribute("src"), img.src); if (!src) return ""; - const alt = normalizeText(node.getAttribute("alt") || ""); + const alt = normalizeText(img.getAttribute("alt") || ""); return alt ? `![${escapeMarkdown(alt)}](${src})` : `![](${src})`; } if (tag === "a") { - const text = normalizeInline(Array.from(node.childNodes).map(inlineText).join("")); - const href = absoluteUrl(node.getAttribute("href"), node.href); + const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join("")); + const href = absoluteUrl(el.getAttribute("href"), (el as HTMLAnchorElement).href); if (!href) return text; return `[${text || href}](${href})`; } if (tag === "code") { - const text = normalizeInline(Array.from(node.childNodes).map(inlineText).join("")); + const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join("")); return text ? `\`${text.replace(/`/g, "\\`")}\`` : ""; } if (tag === "strong" || tag === "b") { - const text = normalizeInline(Array.from(node.childNodes).map(inlineText).join("")); + const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join("")); return text ? `**${text}**` : ""; } if (tag === "em" || tag === "i") { - const text = normalizeInline(Array.from(node.childNodes).map(inlineText).join("")); + const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join("")); return text ? `*${text}*` : ""; } - const chunks = []; - for (const child of node.childNodes) { + const chunks: string[] = []; + for (const child of el.childNodes) { const rendered = inlineText(child); if (!rendered) continue; chunks.push(rendered); - if (child.nodeType === Node.ELEMENT_NODE && BLOCKS.has(child.tagName.toLowerCase())) { + if (child.nodeType === Node.ELEMENT_NODE && BLOCKS.has((child as Element).tagName.toLowerCase())) { chunks.push("\n"); } } return chunks.join(""); } - function textBlock(node) { + function textBlock(node: Node): string { return collapseBlankLines(normalizeInline(Array.from(node.childNodes).map(inlineText).join(""))); } - function preserveNodeText(node) { + function preserveNodeText(node: Node): string { if (node.nodeType === Node.TEXT_NODE) { return node.textContent || ""; } if (node.nodeType !== Node.ELEMENT_NODE) return ""; - const tag = node.tagName.toLowerCase(); + const el = node as HTMLElement; + const tag = el.tagName.toLowerCase(); if (tag === "br") return "\n"; - const parts = []; - for (const child of node.childNodes) { + const parts: string[] = []; + for (const child of el.childNodes) { const rendered = preserveNodeText(child); if (!rendered) continue; parts.push(rendered); @@ -354,7 +359,7 @@ export function contentDispatch(funcName, args) { return parts.join(""); } - function repairFlattenedDiagram(text) { + function repairFlattenedDiagram(text: string): string { if (text.includes("\n")) return text; const markerCount = (text.match(/[│▼├└]/g) || []).length; if (markerCount < 2) return text; @@ -372,8 +377,8 @@ export function contentDispatch(funcName, args) { .join("\n"); } - function convertDashListsToBranches(lines) { - const converted = []; + function convertDashListsToBranches(lines: string[]): string[] { + const converted: string[] = []; let index = 0; while (index < lines.length) { const match = lines[index].match(/^(\s*)-\s+(.*)$/); @@ -400,7 +405,7 @@ export function contentDispatch(funcName, args) { return converted; } - function normalizeCodeBlock(text) { + function normalizeCodeBlock(text: string): string { let lines = text.replace(/\r\n?/g, "\n").split("\n").map(line => line.replace(/\s+$/, "")); while (lines.length && !lines[0].trim()) lines.shift(); while (lines.length && !lines[lines.length - 1].trim()) lines.pop(); @@ -418,7 +423,7 @@ export function contentDispatch(funcName, args) { return lines.join("\n"); } - function tableToMarkdown(table) { + function tableToMarkdown(table: Element) { const rows = Array.from(table.querySelectorAll("tr")) .map(row => Array.from(row.children) .filter(cell => cell.tagName === "TD" || cell.tagName === "TH") @@ -461,19 +466,20 @@ export function contentDispatch(funcName, args) { return lines.join("\n"); } - function listToMarkdown(list, depth = 0) { + function listToMarkdown(list: Element, depth = 0): string { const ordered = list.tagName.toLowerCase() === "ol"; - const items = []; + const items: string[] = []; const children = Array.from(list.children).filter(child => child.tagName === "LI"); children.forEach((item, index) => { const marker = ordered ? `${index + 1}. ` : "- "; const indent = " ".repeat(depth); - const nested = []; - const content = []; + const nested: string[] = []; + const content: string[] = []; for (const child of item.childNodes) { - if (child.nodeType === Node.ELEMENT_NODE && (child.tagName === "UL" || child.tagName === "OL")) { - nested.push(listToMarkdown(child, depth + 1)); + const childEl = child as Element; + if (child.nodeType === Node.ELEMENT_NODE && (childEl.tagName === "UL" || childEl.tagName === "OL")) { + nested.push(listToMarkdown(childEl, depth + 1)); } else { content.push(inlineText(child)); } @@ -491,18 +497,19 @@ export function contentDispatch(funcName, args) { return items.join("\n"); } - function blockToMarkdown(node) { + function blockToMarkdown(node: Node): string { if (node.nodeType === Node.TEXT_NODE) { return normalizeText(node.textContent || ""); } if (node.nodeType !== Node.ELEMENT_NODE) return ""; if (isNoiseElement(node)) return ""; - const tag = node.tagName.toLowerCase(); - if (tag === "table") return tableToMarkdown(node); - if (tag === "ul" || tag === "ol") return listToMarkdown(node); - if (node.matches(".cm-editor[data-is-code-block-view='true']")) { - const lines = Array.from(node.querySelectorAll(".cm-line")).map(line => { + const el = node as HTMLElement; + const tag = el.tagName.toLowerCase(); + if (tag === "table") return tableToMarkdown(el); + if (tag === "ul" || tag === "ol") return listToMarkdown(el); + if (el.matches(".cm-editor[data-is-code-block-view='true']")) { + const lines = Array.from(el.querySelectorAll(".cm-line")).map(line => { const text = preserveNodeText(line); return text === "\n" ? "" : text.replace(/\n$/, ""); }); @@ -510,11 +517,11 @@ export function contentDispatch(funcName, args) { return code ? `\`\`\`\n${code}\n\`\`\`` : ""; } if (tag === "pre") { - const code = normalizeCodeBlock(preserveNodeText(node)); + const code = normalizeCodeBlock(preserveNodeText(el)); return code ? `\`\`\`\n${code}\n\`\`\`` : ""; } if (tag === "blockquote") { - const content = collapseBlankLines(Array.from(node.childNodes).map(blockToMarkdown).join("\n\n")); + const content = collapseBlankLines(Array.from(el.childNodes).map(blockToMarkdown).join("\n\n")); return content .split("\n") .map(line => line ? `> ${line}` : ">") @@ -522,20 +529,20 @@ export function contentDispatch(funcName, args) { } if (/^h[1-6]$/.test(tag)) { const level = Number(tag.slice(1)); - const text = textBlock(node); + const text = textBlock(el); return text ? `${"#".repeat(level)} ${text}` : ""; } if (tag === "p" || tag === "figcaption") { - return textBlock(node); + return textBlock(el); } if (tag === "hr") { return "---"; } if (tag === "img") { - return inlineText(node); + return inlineText(el); } - const childBlocks = Array.from(node.childNodes) + const childBlocks = Array.from(el.childNodes) .map(child => blockToMarkdown(child)) .filter(Boolean); if (childBlocks.length) return collapseBlankLines(childBlocks.join("\n\n")); @@ -552,7 +559,7 @@ export function contentDispatch(funcName, args) { domScroll, domSelect, domKey, domHover, domCheck, domClear, domFocus, domSubmit, pageInfo, extractLinks, extractImages, extractText, extractJson, extractMarkdown }; - const fn = fns[funcName]; + const fn = fns[funcName as keyof typeof fns]; if (!fn) throw new Error(`Unknown content function: ${funcName}`); return fn(args); } diff --git a/extension/src/commands/navigation.ts b/extension/src/commands/navigation.ts index 168c92c..dcb0e39 100644 --- a/extension/src/commands/navigation.ts +++ b/extension/src/commands/navigation.ts @@ -1,97 +1,113 @@ -// @ts-nocheck import { getActiveTab, getAliases, isBrowserErrorUrl, resolveGroupId, tabInfo } from '../core'; -export async function navOpen({ url, background, window: windowName, windowId: explicitWindowId, group: groupNameOrId }) { - let windowId; - if (explicitWindowId != null) { - windowId = explicitWindowId; - } else if (windowName) { - const aliases = await getAliases(); - const entry = Object.entries(aliases).find(([, v]) => v === windowName); - if (entry) windowId = parseInt(entry[0]); - } - const tab = await chrome.tabs.create({ url, active: !background, windowId }); - if (groupNameOrId != null) { - let groupId; - try { - groupId = await resolveGroupId(groupNameOrId); - // Close any blank placeholder tabs that were created when the group was made - const groupTabs = await chrome.tabs.query({ groupId }); - const placeholders = groupTabs.filter(t => - t.id !== tab.id && - (t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/") - ); - await chrome.tabs.group({ tabIds: [tab.id], groupId }); - if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id)); - } catch (e) { - if (!e.message.startsWith("No tab group found")) throw e; - // Group doesn't exist — create it with the tab already in it - groupId = await chrome.tabs.group({ tabIds: [tab.id] }); - await chrome.tabGroups.update(groupId, { title: String(groupNameOrId) }); +import { CommandGroup } from '../classes/CommandGroup'; +import type { CommandEntry } from '../classes/CommandGroup'; +import type { NavOpenArgs, NavToArgs, NavTabArgs, NavFocusArgs, NavWaitArgs, NavOpenWaitArgs } from '../types'; + +export class NavigationCommands extends CommandGroup { + readonly namespace = "navigate"; + readonly commands: Record = { + "navigate.open": (a: NavOpenArgs) => this.navOpen(a), + "navigate.to": (a: NavToArgs) => this.navTo(a), + "navigate.reload": (a: NavTabArgs) => this.navReload(a, false), + "navigate.hard_reload": (a: NavTabArgs) => this.navReload(a, true), + "navigate.back": (a: NavTabArgs) => this.navBack(a), + "navigate.forward": (a: NavTabArgs) => this.navForward(a), + "navigate.focus": (a: NavFocusArgs) => this.navFocus(a), + "navigate.wait": (a: NavWaitArgs) => this.navWait(a), + "navigate.open_wait": (a: NavOpenWaitArgs) => this.navOpenWait(a), + }; + + private async navOpen({ url, background, window: windowName, windowId: explicitWindowId, group: groupNameOrId }: NavOpenArgs) { + let windowId: number | undefined; + if (explicitWindowId != null) { + windowId = explicitWindowId; + } else if (windowName) { + const aliases = await getAliases(); + const entry = Object.entries(aliases).find(([, v]) => v === windowName); + if (entry) windowId = parseInt(entry[0]); } - } - return { id: tab.id, url: tab.url }; -} - -export async function navTo({ tabId, url }) { - const tab = await chrome.tabs.update(tabId, { url }); - return { id: tab.id, url: tab.url || url }; -} - -export async function navReload({ tabId }, bypassCache) { - const tab = tabId ? { id: tabId } : await getActiveTab(); - await chrome.tabs.reload(tab.id, { bypassCache }); - return { tabId: tab.id }; -} - -export async function navBack({ tabId }) { - const tab = tabId ? { id: tabId } : await getActiveTab(); - await chrome.tabs.goBack(tab.id); - return { tabId: tab.id }; -} - -export async function navForward({ tabId }) { - const tab = tabId ? { id: tabId } : await getActiveTab(); - await chrome.tabs.goForward(tab.id); - return { tabId: tab.id }; -} - -export async function navFocus({ pattern }) { - // If pattern is a plain integer, treat it as a tab ID - const asInt = parseInt(pattern); - let match; - if (!isNaN(asInt) && String(asInt) === String(pattern)) { - match = await chrome.tabs.get(asInt); - } else { - const all = await chrome.tabs.query({}); - match = all.find(t => (t.url && t.url.includes(pattern)) || (t.pendingUrl && t.pendingUrl.includes(pattern))); - } - if (!match) return null; - await chrome.windows.update(match.windowId, { focused: true }); - await chrome.tabs.update(match.id, { active: true }); - return { id: match.id, url: match.url || match.pendingUrl, title: match.title }; -} - -export async function navWait({ tabId, timeout = 30000, readyState = "complete" } = {}) { - const tab = tabId ? { id: tabId } : await getActiveTab(); - const deadline = Date.now() + timeout; - const interval = 200; - while (Date.now() < deadline) { - const t = await chrome.tabs.get(tab.id); - const currentUrl = t.url || t.pendingUrl || ""; - if (isBrowserErrorUrl(currentUrl)) { - throw new Error(`Tab ${tab.id} is showing an error page while waiting for load (${currentUrl})`); + const tab = await chrome.tabs.create({ url, active: !background, windowId }); + if (groupNameOrId != null) { + let groupId; + try { + groupId = await resolveGroupId(groupNameOrId); + // Close any blank placeholder tabs that were created when the group was made + const groupTabs = await chrome.tabs.query({ groupId }); + const placeholders = groupTabs.filter(t => + t.id !== tab.id && + (t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/") + ); + await chrome.tabs.group({ tabIds: [tab.id], groupId }); + if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id)); + } catch (e) { + if (!(e instanceof Error) || !e.message.startsWith("No tab group found")) throw e; + // Group doesn't exist — create it with the tab already in it + groupId = await chrome.tabs.group({ tabIds: [tab.id] }); + await chrome.tabGroups.update(groupId, { title: String(groupNameOrId) }); + } } - if (readyState === "complete" ? t.status === "complete" : t.status !== "loading") { - return tabInfo(t); - } - await new Promise(r => setTimeout(r, interval)); + return { id: tab.id, url: tab.url }; } - throw new Error(`Tab ${tab.id} did not reach status '${readyState}' within ${timeout}ms`); -} -export async function navOpenWait({ url, timeout = 30000, background, window: windowName, group } = {}) { - const opened = await navOpen({ url, background, window: windowName, group }); - return await navWait({ tabId: opened.id, timeout }); -} + private async navTo({ tabId, url }: NavToArgs) { + const tab = await chrome.tabs.update(tabId, { url }); + return { id: tab.id, url: tab.url || url }; + } -// ── Tabs ────────────────────────────────────────────────────────────────────── + private async navReload({ tabId }: NavTabArgs, bypassCache: boolean) { + const tab = tabId ? { id: tabId } : await getActiveTab(); + await chrome.tabs.reload(tab.id, { bypassCache }); + return { tabId: tab.id }; + } + + private async navBack({ tabId }: NavTabArgs) { + const tab = tabId ? { id: tabId } : await getActiveTab(); + await chrome.tabs.goBack(tab.id); + return { tabId: tab.id }; + } + + private async navForward({ tabId }: NavTabArgs) { + const tab = tabId ? { id: tabId } : await getActiveTab(); + await chrome.tabs.goForward(tab.id); + return { tabId: tab.id }; + } + + private async navFocus({ pattern }: NavFocusArgs) { + // If pattern is a plain integer, treat it as a tab ID + const asInt = parseInt(pattern); + let match: chrome.tabs.Tab | undefined; + if (!isNaN(asInt) && String(asInt) === String(pattern)) { + match = await chrome.tabs.get(asInt); + } else { + const all = await chrome.tabs.query({}); + match = all.find(t => (t.url && t.url.includes(pattern)) || (t.pendingUrl && t.pendingUrl.includes(pattern))); + } + if (!match) return null; + await chrome.windows.update(match.windowId, { focused: true }); + await chrome.tabs.update(match.id, { active: true }); + return { id: match.id, url: match.url || match.pendingUrl, title: match.title }; + } + + private async navWait({ tabId, timeout = 30000, readyState = "complete" }: NavWaitArgs = {}) { + const tab = tabId ? { id: tabId } : await getActiveTab(); + const deadline = Date.now() + timeout; + const interval = 200; + while (Date.now() < deadline) { + const t = await chrome.tabs.get(tab.id); + const currentUrl = t.url || t.pendingUrl || ""; + if (isBrowserErrorUrl(currentUrl)) { + throw new Error(`Tab ${tab.id} is showing an error page while waiting for load (${currentUrl})`); + } + if (readyState === "complete" ? t.status === "complete" : t.status !== "loading") { + return tabInfo(t); + } + await new Promise(r => setTimeout(r, interval)); + } + throw new Error(`Tab ${tab.id} did not reach status '${readyState}' within ${timeout}ms`); + } + + private async navOpenWait({ url, timeout = 30000, background, window: windowName, group }: NavOpenWaitArgs = {}) { + const opened = await this.navOpen({ url, background, window: windowName, group }); + return await this.navWait({ tabId: opened.id, timeout }); + } +} diff --git a/extension/src/commands/perf.ts b/extension/src/commands/perf.ts new file mode 100644 index 0000000..ea86e84 --- /dev/null +++ b/extension/src/commands/perf.ts @@ -0,0 +1,38 @@ +import { getLargeOperationThrottle, getPerformanceProfile, hasAudibleTabs, setPerformanceProfile } from '../core'; +import { CommandGroup } from '../classes/CommandGroup'; +import type { CommandEntry } from '../classes/CommandGroup'; +import type { PerfSetProfileArgs, JobIdArgs } from '../types'; + +// PerfCommands also owns the jobs.* status/cancel queries: they read the same +// JobManager (ctx.jobs) that perf.status reports, and there is no dedicated +// jobs command group. +export class PerfCommands extends CommandGroup { + readonly namespace = "perf"; + readonly commands: Record = { + "perf.status": () => this.perfStatus(), + "perf.set_profile": (a: PerfSetProfileArgs) => setPerformanceProfile(typeof a.profile === "string" ? a.profile : undefined), + "jobs.status": (a: JobIdArgs) => this.ctx.jobs.status(a), + "jobs.cancel": (a: JobIdArgs) => this.ctx.jobs.cancel(a), + }; + + private async perfStatus() { + const profile = await getPerformanceProfile(); + const audible = await hasAudibleTabs(); + const throttle = await getLargeOperationThrottle(0, "auto"); + return { + performanceProfile: profile, + audible, + throttle, + jobs: this.ctx.jobs.list().map(job => ({ + id: job.id, + command: job.command, + status: job.status, + phase: job.phase, + current: job.current, + total: job.total, + percent: job.percent, + cancelRequested: job.cancelRequested, + })), + }; + } +} diff --git a/extension/src/commands/session-snapshot.ts b/extension/src/commands/session-snapshot.ts new file mode 100644 index 0000000..b21d1cc --- /dev/null +++ b/extension/src/commands/session-snapshot.ts @@ -0,0 +1,54 @@ +import { normalizeGroupColor } from '../core'; +import type { SessionTab, StoredSession } from '../types'; + +export function buildSessionSnapshot(tabs: chrome.tabs.Tab[], groups: chrome.tabGroups.TabGroup[]): SessionTab[] { + const groupById = new Map(groups.map(group => [group.id, group])); + return tabs + .filter(tab => Boolean(tab.url || tab.pendingUrl)) + .sort((a, b) => (a.windowId - b.windowId) || (a.index - b.index)) + .map(tab => { + const entry: SessionTab = { url: tab.url || tab.pendingUrl }; + if (tab.pinned) entry.pinned = true; + if (tab.groupId >= 0) { + const group = groupById.get(tab.groupId); + entry.group = { + key: `${tab.windowId}:${tab.groupId}`, + title: group?.title || "", + color: normalizeGroupColor(group?.color), + collapsed: Boolean(group?.collapsed), + }; + } + return entry; + }); +} + +/** + * Snapshot the current windows/tabs/groups into a ready-to-store session plus + * its change-detection signature. Shared by session.save and the autosave path. + */ +export async function captureCurrentSession(): Promise<{ session: StoredSession; signature: string; tabCount: number }> { + const tabs = await chrome.tabs.query({}); + const groups = await chrome.tabGroups.query({}); + const sessionTabs = buildSessionSnapshot(tabs, groups); + const signature = sessionSignature(sessionTabs); + const session: StoredSession = { + tabs: sessionTabs, + urls: sessionTabs.map(tab => tab.url), + savedAt: Date.now(), + signature, + }; + return { session, signature, tabCount: sessionTabs.length }; +} + +export function sessionSignature(sessionTabs: SessionTab[]) { + return JSON.stringify(sessionTabs.map(tab => ({ + url: tab.url, + pinned: Boolean(tab.pinned), + group: tab.group ? { + key: tab.group.key || "", + title: tab.group.title || "", + color: normalizeGroupColor(tab.group.color), + collapsed: Boolean(tab.group.collapsed), + } : null, + }))); +} diff --git a/extension/src/commands/session.ts b/extension/src/commands/session.ts index 6d9edff..78806f6 100644 --- a/extension/src/commands/session.ts +++ b/extension/src/commands/session.ts @@ -1,255 +1,150 @@ -// @ts-nocheck import { getLargeOperationThrottle, getProfileAlias, getSessionTabs, getSessions, normalizeGroupColor, runLargeOperation, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core'; +import { CommandGroup } from '../classes/CommandGroup'; +import { AutoSaveManager } from './autosave'; +import { captureCurrentSession } from './session-snapshot'; +import type { CommandEntry } from '../classes/CommandGroup'; +import type { LazySessionMap, SessionSaveArgs, SessionRemoveArgs, SessionDiffArgs, SessionLoadArgs, SessionAutoSaveArgs, ClientsRenameProfileArgs } from '../types'; -function buildSessionSnapshot(tabs, groups) { - const groupById = new Map(groups.map(group => [group.id, group])); - return tabs - .filter(tab => Boolean(tab.url || tab.pendingUrl)) - .sort((a, b) => (a.windowId - b.windowId) || (a.index - b.index)) - .map(tab => { - const entry = { url: tab.url || tab.pendingUrl }; - if (tab.pinned) entry.pinned = true; - if (tab.groupId >= 0) { - const group = groupById.get(tab.groupId); - entry.group = { - key: `${tab.windowId}:${tab.groupId}`, - title: group?.title || "", - color: normalizeGroupColor(group?.color), - collapsed: Boolean(group?.collapsed), - }; - } - return entry; - }); -} - -function sessionSignature(sessionTabs) { - return JSON.stringify(sessionTabs.map(tab => ({ - url: tab.url, - pinned: Boolean(tab.pinned), - group: tab.group ? { - key: tab.group.key || "", - title: tab.group.title || "", - color: normalizeGroupColor(tab.group.color), - collapsed: Boolean(tab.group.collapsed), - } : null, - }))); -} - -export async function sessionSave({ name }) { - const tabs = await chrome.tabs.query({}); - const groups = await chrome.tabGroups.query({}); - const sessionTabs = buildSessionSnapshot(tabs, groups); - const signature = sessionSignature(sessionTabs); - const sessions = await getSessions(); - sessions[name] = { - tabs: sessionTabs, - urls: sessionTabs.map(tab => tab.url), - savedAt: Date.now(), - signature, - }; - await chrome.storage.local.set({ sessions }); - return { name, tabs: sessionTabs.length }; -} - -function lazyPlaceholderUrl(url) { +function lazyPlaceholderUrl(url: string) { const escaped = String(url).replace(/[&<>"']/g, ch => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[ch])); const html = `Lazy tab

Lazy tab

This tab will load when selected.

${escaped}

`; return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; } -export async function activateLazyTab(tabId) { - const { lazySessionTabs } = await chrome.storage.local.get("lazySessionTabs"); - const entry = lazySessionTabs?.[tabId]; - if (!entry?.url) return false; - delete lazySessionTabs[tabId]; - await chrome.storage.local.set({ lazySessionTabs }); - await chrome.tabs.update(Number(tabId), { url: entry.url }); - return true; -} +export class SessionCommands extends CommandGroup { + readonly namespace = "session"; + readonly commands: Record = { + "session.save": (a: SessionSaveArgs) => this.sessionSave(a), + "session.load": { background: true, run: (a: SessionLoadArgs) => this.sessionLoad(a) }, + "session.list": () => this.sessionList(), + "session.remove": (a: SessionRemoveArgs) => this.sessionRemove(a), + "session.diff": (a: SessionDiffArgs) => this.sessionDiff(a), + "session.auto_save": (a: SessionAutoSaveArgs) => this.autoSaveManager.setEnabled(Boolean(a.enabled)), + "clients.list": () => this.clientsList(), + "clients.rename_profile": (a: ClientsRenameProfileArgs) => this.clientsRenameProfile(a), + }; -export async function sessionLoad({ name, gentleMode, discardBackgroundTabs = false, lazy = false, eagerTabs = 10, __job } = {}) { - return runLargeOperation("session.load", async () => { + private readonly autoSaveManager = new AutoSaveManager(); + + private async sessionSave({ name }: SessionSaveArgs) { + const { session, tabCount } = await captureCurrentSession(); const sessions = await getSessions(); - const session = sessions[name]; - if (!session) throw new Error(`Session '${name}' not found`); + sessions[name] = session; + await chrome.storage.local.set({ sessions }); + return { name, tabs: tabCount }; + } - const sessionTabs = getSessionTabs(session).sort((a, b) => Number(Boolean(b.pinned)) - Number(Boolean(a.pinned))); - const createdTabs = []; - const throttle = await getLargeOperationThrottle(sessionTabs.length, gentleMode); - const createBatchSize = Math.max(1, Math.min(10, throttle.batchSize)); - const eagerLimit = lazy ? Math.max(0, Number(eagerTabs) || 0) : sessionTabs.length; - const { lazySessionTabs } = await chrome.storage.local.get("lazySessionTabs"); - const lazyMap = lazySessionTabs || {}; - updateJobProgress(__job, { phase: "opening", current: 0, total: sessionTabs.length }); + // Public: invoked from index.ts on chrome.tabs.onActivated. + async activateLazyTab(tabId: number | string) { + const { lazySessionTabs } = await chrome.storage.local.get<{ lazySessionTabs?: LazySessionMap }>("lazySessionTabs"); + const entry = lazySessionTabs?.[tabId]; + if (!entry?.url) return false; + delete lazySessionTabs[tabId]; + await chrome.storage.local.set({ lazySessionTabs }); + await chrome.tabs.update(Number(tabId), { url: entry.url }); + return true; + } - for (const [idx, entry] of sessionTabs.entries()) { - throwIfJobCancelled(__job); - const shouldLazy = lazy && idx >= eagerLimit; - const tab = await chrome.tabs.create({ url: shouldLazy ? lazyPlaceholderUrl(entry.url) : entry.url, active: false, pinned: Boolean(entry.pinned) }); - createdTabs.push({ tabId: tab.id, entry }); - if (shouldLazy) { - lazyMap[String(tab.id)] = { url: entry.url, createdAt: Date.now() }; - } else if (discardBackgroundTabs && !entry.pinned && chrome.tabs.discard) { - try { await chrome.tabs.discard(tab.id); } catch (_) {} + private async sessionLoad({ name, gentleMode, discardBackgroundTabs = false, lazy = false, eagerTabs = 10, __job }: SessionLoadArgs = {}) { + return runLargeOperation("session.load", async () => { + const sessions = await getSessions(); + const session = sessions[name]; + if (!session) throw new Error(`Session '${name}' not found`); + + const sessionTabs = getSessionTabs(session).sort((a, b) => Number(Boolean(b.pinned)) - Number(Boolean(a.pinned))); + const createdTabs = []; + const throttle = await getLargeOperationThrottle(sessionTabs.length, gentleMode); + const createBatchSize = Math.max(1, Math.min(10, throttle.batchSize)); + const eagerLimit = lazy ? Math.max(0, Number(eagerTabs) || 0) : sessionTabs.length; + const { lazySessionTabs } = await chrome.storage.local.get<{ lazySessionTabs?: LazySessionMap }>("lazySessionTabs"); + const lazyMap: LazySessionMap = lazySessionTabs || {}; + updateJobProgress(__job, { phase: "opening", current: 0, total: sessionTabs.length }); + + for (const [idx, entry] of sessionTabs.entries()) { + throwIfJobCancelled(__job); + const shouldLazy = lazy && idx >= eagerLimit; + const tab = await chrome.tabs.create({ url: shouldLazy ? lazyPlaceholderUrl(entry.url) : entry.url, active: false, pinned: Boolean(entry.pinned) }); + createdTabs.push({ tabId: tab.id, entry }); + if (shouldLazy) { + lazyMap[String(tab.id)] = { url: entry.url, createdAt: Date.now() }; + } else if (discardBackgroundTabs && !entry.pinned && chrome.tabs.discard) { + try { await chrome.tabs.discard(tab.id); } catch (_) {} + } + updateJobProgress(__job, { phase: shouldLazy ? "creating lazy placeholders" : "opening", current: createdTabs.length, total: sessionTabs.length }); + await yieldForLargeOperation(createdTabs.length, createBatchSize, Math.max(50, throttle.pauseMs)); } - updateJobProgress(__job, { phase: shouldLazy ? "creating lazy placeholders" : "opening", current: createdTabs.length, total: sessionTabs.length }); - await yieldForLargeOperation(createdTabs.length, createBatchSize, Math.max(50, throttle.pauseMs)); - } - if (lazy) await chrome.storage.local.set({ lazySessionTabs: lazyMap }); + if (lazy) await chrome.storage.local.set({ lazySessionTabs: lazyMap }); - const groups = new Map(); - for (const { tabId, entry } of createdTabs) { - if (!entry.group) continue; - const key = entry.group.key || `${entry.group.title || "group"}:${groups.size}`; - if (!groups.has(key)) { - groups.set(key, { meta: entry.group, tabIds: [] }); + const groups = new Map(); + for (const { tabId, entry } of createdTabs) { + if (!entry.group) continue; + const key = entry.group.key || `${entry.group.title || "group"}:${groups.size}`; + if (!groups.has(key)) { + groups.set(key, { meta: entry.group, tabIds: [] }); + } + groups.get(key).tabIds.push(tabId); } - groups.get(key).tabIds.push(tabId); - } - let restoredGroups = 0; - updateJobProgress(__job, { phase: "restoring groups", current: 0, total: groups.size }); - for (const { meta, tabIds } of groups.values()) { - throwIfJobCancelled(__job); - const restoredGroupId = await chrome.tabs.group({ tabIds }); - await chrome.tabGroups.update(restoredGroupId, { - title: meta.title || "", - color: normalizeGroupColor(meta.color), - collapsed: Boolean(meta.collapsed), - }); - restoredGroups++; - updateJobProgress(__job, { phase: "restoring groups", current: restoredGroups, total: groups.size }); - await yieldForLargeOperation(restoredGroups, 5, Math.max(50, throttle.pauseMs)); - } + let restoredGroups = 0; + updateJobProgress(__job, { phase: "restoring groups", current: 0, total: groups.size }); + for (const { meta, tabIds } of groups.values()) { + throwIfJobCancelled(__job); + const restoredGroupId = await chrome.tabs.group({ tabIds }); + await chrome.tabGroups.update(restoredGroupId, { + title: meta.title || "", + color: normalizeGroupColor(meta.color), + collapsed: Boolean(meta.collapsed), + }); + restoredGroups++; + updateJobProgress(__job, { phase: "restoring groups", current: restoredGroups, total: groups.size }); + await yieldForLargeOperation(restoredGroups, 5, Math.max(50, throttle.pauseMs)); + } - return { name, tabs: sessionTabs.length, gentle: throttle.gentle, audible: throttle.audible, discarded: Boolean(discardBackgroundTabs), lazy: Boolean(lazy), eagerTabs: eagerLimit }; - }); -} - -export async function sessionList() { - const sessions = await getSessions(); - return Object.entries(sessions).map(([name, s]) => ({ - name, - tabs: getSessionTabs(s).length, - savedAt: s.savedAt || null, - })); -} - -export async function sessionRemove({ name }) { - const sessions = await getSessions(); - if (!(name in sessions)) throw new Error(`Session '${name}' not found`); - delete sessions[name]; - await chrome.storage.local.set({ sessions }); - return { name }; -} - -export async function sessionDiff({ nameA, nameB }) { - const sessions = await getSessions(); - const a = new Set(getSessionTabs(sessions[nameA]).map(tab => tab.url)); - const b = new Set(getSessionTabs(sessions[nameB]).map(tab => tab.url)); - return { - added: [...b].filter(u => !a.has(u)), - removed: [...a].filter(u => !b.has(u)), - }; -} - -let autoSaveTimer = null; -let autoSaveInFlight = false; -let autoSavePending = false; - -export async function sessionAutoSave({ enabled }) { - await chrome.storage.local.set({ autoSave: enabled }); - chrome.tabs.onCreated.removeListener(autoSaveHandler); - chrome.tabs.onRemoved.removeListener(autoSaveHandler); - chrome.tabs.onMoved.removeListener(autoSaveHandler); - chrome.tabs.onAttached.removeListener(autoSaveHandler); - chrome.tabs.onDetached.removeListener(autoSaveHandler); - chrome.tabs.onUpdated.removeListener(autoSaveUpdatedHandler); - if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.removeListener(autoSaveHandler); - if (autoSaveTimer) clearTimeout(autoSaveTimer); - autoSaveTimer = null; - autoSavePending = false; - if (enabled) { - chrome.tabs.onCreated.addListener(autoSaveHandler); - chrome.tabs.onRemoved.addListener(autoSaveHandler); - chrome.tabs.onMoved.addListener(autoSaveHandler); - chrome.tabs.onAttached.addListener(autoSaveHandler); - chrome.tabs.onDetached.addListener(autoSaveHandler); - chrome.tabs.onUpdated.addListener(autoSaveUpdatedHandler); - if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.addListener(autoSaveHandler); + return { name, tabs: sessionTabs.length, gentle: throttle.gentle, audible: throttle.audible, discarded: Boolean(discardBackgroundTabs), lazy: Boolean(lazy), eagerTabs: eagerLimit }; + }); } - return { enabled }; -} -async function saveAutoSessionIfChanged() { - const tabs = await chrome.tabs.query({}); - const groups = await chrome.tabGroups.query({}); - const sessionTabs = buildSessionSnapshot(tabs, groups); - const signature = sessionSignature(sessionTabs); - const { autoSaveSignature } = await chrome.storage.local.get("autoSaveSignature"); - if (autoSaveSignature === signature) return { skipped: true, tabs: sessionTabs.length }; - - const sessions = await getSessions(); - sessions.__auto__ = { - tabs: sessionTabs, - urls: sessionTabs.map(tab => tab.url), - savedAt: Date.now(), - signature, - }; - await chrome.storage.local.set({ sessions, autoSaveSignature: signature }); - return { skipped: false, tabs: sessionTabs.length }; -} - -async function runAutoSave() { - if (autoSaveInFlight) { - autoSavePending = true; - return; + private async sessionList() { + const sessions = await getSessions(); + return Object.entries(sessions).map(([name, s]) => ({ + name, + tabs: getSessionTabs(s).length, + savedAt: s.savedAt || null, + })); } - autoSaveInFlight = true; - try { - const { autoSave } = await chrome.storage.local.get("autoSave"); - if (autoSave) await runLargeOperation("session.auto_save", saveAutoSessionIfChanged); - } finally { - autoSaveInFlight = false; - if (autoSavePending) { - autoSavePending = false; - autoSaveTimer = setTimeout(runAutoSave, 1000); - } + + private async sessionRemove({ name }: SessionRemoveArgs) { + const sessions = await getSessions(); + if (!(name in sessions)) throw new Error(`Session '${name}' not found`); + delete sessions[name]; + await chrome.storage.local.set({ sessions }); + return { name }; + } + + private async sessionDiff({ nameA, nameB }: SessionDiffArgs) { + const sessions = await getSessions(); + const a = new Set(getSessionTabs(sessions[nameA]).map(tab => tab.url)); + const b = new Set(getSessionTabs(sessions[nameB]).map(tab => tab.url)); + return { + added: [...b].filter(u => !a.has(u)), + removed: [...a].filter(u => !b.has(u)), + }; + } + + private async clientsList() { + const manifest = chrome.runtime.getManifest(); + const alias = await getProfileAlias(); + return [{ + name: "Chrome", + version: navigator.userAgent.match(/Chrome\/([\d.]+)/)?.[1] || "unknown", + platform: navigator.platform, + extensionVersion: manifest.version, + profile: alias, + }]; + } + + private async clientsRenameProfile({ alias }: ClientsRenameProfileArgs) { + await chrome.storage.local.set({ profileAlias: alias }); + return { alias }; } } - -async function scheduleAutoSave(delayMs = 1000) { - const { autoSave } = await chrome.storage.local.get("autoSave"); - if (!autoSave) return; - if (autoSaveTimer) clearTimeout(autoSaveTimer); - autoSaveTimer = setTimeout(runAutoSave, delayMs); -} - -export async function autoSaveHandler() { - await scheduleAutoSave(); -} - -export async function autoSaveUpdatedHandler(_tabId, changeInfo = {}) { - // Ignore noisy media/title/favicon/loading updates. Sessions only store URL and group/window structure. - if (!("url" in changeInfo)) return; - await scheduleAutoSave(); -} - -// ── Misc ────────────────────────────────────────────────────────────────────── - -export async function clientsList() { - const manifest = chrome.runtime.getManifest(); - const alias = await getProfileAlias(); - return [{ - name: "Chrome", - version: navigator.userAgent.match(/Chrome\/([\d.]+)/)?.[1] || "unknown", - platform: navigator.platform, - extensionVersion: manifest.version, - profile: alias, - }]; -} - -export async function clientsRenameProfile({ alias }) { - await chrome.storage.local.set({ profileAlias: alias }); - return { alias }; -} diff --git a/extension/src/commands/tabs-query.ts b/extension/src/commands/tabs-query.ts new file mode 100644 index 0000000..1203c58 --- /dev/null +++ b/extension/src/commands/tabs-query.ts @@ -0,0 +1,95 @@ +import { fetchTabHtml, getActiveTab, getAliases, isBrowserErrorUrl, tabInfo } from '../core'; +import { CommandGroup } from '../classes/CommandGroup'; +import type { CommandEntry } from '../classes/CommandGroup'; +import type { TabIdArgs, TabsActiveInWindowArgs, TabsPatternArgs, TabsQueryArgs, TabsWatchUrlArgs } from '../types'; + +export class TabsQueryCommands extends CommandGroup { + readonly namespace = "tabs"; + readonly commands: Record = { + "tabs.list": () => this.tabsList(), + "tabs.active_in_window": (a: TabsActiveInWindowArgs) => this.tabsActiveInWindow(a), + "tabs.status": (a: TabIdArgs) => this.tabsStatus(a), + "tabs.filter": (a: TabsPatternArgs) => this.tabsFilter(a), + "tabs.count": (a: TabsPatternArgs) => this.tabsCount(a), + "tabs.query": (a: TabsQueryArgs) => this.tabsQuery(a), + "tabs.html": (a: TabIdArgs) => fetchTabHtml(a.tabId), + "tabs.watch_url": (a: TabsWatchUrlArgs) => this.tabsWatchUrl(a), + }; + + private async tabsList() { + const windows = await chrome.windows.getAll({ populate: true }); + const aliases = await getAliases(); + const tabs = []; + for (const w of windows) { + for (const t of w.tabs) { + tabs.push({ + ...tabInfo(t), + windowAlias: aliases[t.windowId] || null, + pinned: t.pinned, + favIconUrl: t.favIconUrl, + }); + } + } + return tabs; + } + + private async tabsActiveInWindow({ windowId }: TabsActiveInWindowArgs) { + const activeTabs = await chrome.tabs.query({ windowId, active: true }); + const tab = activeTabs[0]; + if (!tab) { + throw new Error(`No active tab found for window ${windowId}`); + } + return tabInfo(tab); + } + + private async tabsStatus({ tabId }: TabIdArgs) { + const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); + return tabInfo(tab); + } + + private async tabsFilter({ pattern }: TabsPatternArgs) { + const all = await chrome.tabs.query({}); + return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo); + } + + private async tabsCount({ pattern }: TabsPatternArgs) { + const all = await chrome.tabs.query({}); + if (pattern) return all.filter(t => t.url && t.url.includes(pattern)).length; + return all.length; + } + + private async tabsQuery({ search }: TabsQueryArgs) { + const q = search.toLowerCase(); + const all = await chrome.tabs.query({}); + return all.filter(t => + (t.url && t.url.toLowerCase().includes(q)) || + (t.title && t.title.toLowerCase().includes(q)) + ).map(tabInfo); + } + + private async tabsWatchUrl({ pattern, timeout = 30000, tabId }: TabsWatchUrlArgs = {}) { + const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); + const deadline = Date.now() + timeout; + const regex = new RegExp(pattern); + let lastUrl = tab.url || tab.pendingUrl || ""; + let lastStatus = tab.status || "unknown"; + + const matches = (url: string) => { + regex.lastIndex = 0; + return Boolean(url && regex.test(url)); + }; + if (matches(lastUrl)) return tabInfo(tab); + + while (Date.now() < deadline) { + const t = await chrome.tabs.get(tab.id); + lastUrl = t.url || t.pendingUrl || ""; + lastStatus = t.status || "unknown"; + if (matches(t.pendingUrl || "") || matches(t.url || "")) return tabInfo(t); + if (isBrowserErrorUrl(t.url || "")) { + throw new Error(`Tab ${tab.id} is showing an error page while waiting for URL to match '${pattern}'`); + } + await new Promise(r => setTimeout(r, 200)); + } + throw new Error(`Tab ${tab.id} URL did not match '${pattern}' within ${timeout}ms (last URL: '${lastUrl}', status: '${lastStatus}')`); + } +} diff --git a/extension/src/commands/tabs.ts b/extension/src/commands/tabs.ts index 3d8be7e..78789b5 100644 --- a/extension/src/commands/tabs.ts +++ b/extension/src/commands/tabs.ts @@ -1,262 +1,166 @@ -// @ts-nocheck -import { executeScript, getActiveTab, getAliases, getLargeOperationThrottle, isBrowserErrorUrl, isErrorPageScriptError, isScriptableUrl, resolveTabForDirectAction, runLargeOperation, tabInfo, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core'; -export async function tabsList() { - const windows = await chrome.windows.getAll({ populate: true }); - const aliases = await getAliases(); - const tabs = []; - for (const w of windows) { - for (const t of w.tabs) { - tabs.push({ - ...tabInfo(t), - windowAlias: aliases[t.windowId] || null, - pinned: t.pinned, - favIconUrl: t.favIconUrl, - }); - } - } - return tabs; -} +import { asTabIds, getActiveTab, getLargeOperationThrottle, processInBatches, resolveTabForDirectAction, runLargeOperation, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core'; +import { CommandGroup } from '../classes/CommandGroup'; +import type { CommandEntry } from '../classes/CommandGroup'; +import type { TabsCloseArgs, TabsMoveArgs, TabIdArgs, TabsSortArgs, TabsMergeWindowsArgs, TabsScreenshotArgs } from '../types'; -export async function tabsClose({ tabId, inactive, duplicates, gentleMode, __job } = {}) { - return runLargeOperation("tabs.close", async () => { - let toClose = []; - if (duplicates) { - const all = await chrome.tabs.query({}); - const seen = new Set(); - for (const t of all) { - if (!t.url) continue; - if (seen.has(t.url)) toClose.push(t.id); - else seen.add(t.url); - } - } else if (inactive) { - const all = await chrome.tabs.query({}); - toClose = all.filter(t => !t.active).map(t => t.id); - } else if (tabId) { - toClose = [tabId]; - } - const throttle = await getLargeOperationThrottle(toClose.length, gentleMode); - updateJobProgress(__job, { phase: "closing tabs", current: 0, total: toClose.length }); - for (let i = 0; i < toClose.length; i += throttle.batchSize) { - throwIfJobCancelled(__job); - await chrome.tabs.remove(toClose.slice(i, i + throttle.batchSize)); - updateJobProgress(__job, { phase: "closing tabs", current: Math.min(i + throttle.batchSize, toClose.length), total: toClose.length }); - await yieldForLargeOperation(i + throttle.batchSize, throttle.batchSize, throttle.pauseMs); - } - return { closed: toClose.length, gentle: throttle.gentle, audible: throttle.audible }; - }); -} - -export async function tabsMove({ tabId, groupId, windowId, index, forward, backward }) { - const moveProps = {}; - if (windowId != null) moveProps.windowId = windowId; - - if (forward || backward) { - const tab = await chrome.tabs.get(tabId); - if (forward) moveProps.index = tab.index + 2; // +2 because Chrome shifts after removal - else moveProps.index = Math.max(0, tab.index - 1); - } else if (index != null) { - moveProps.index = index; - } else { - moveProps.index = -1; - } - - await chrome.tabs.move(tabId, moveProps); - if (groupId != null) { - await chrome.tabs.group({ tabIds: [tabId], groupId }); - } - return { tabId }; -} - -export async function tabsActive({ tabId }) { - const tab = await chrome.tabs.get(tabId); - await chrome.windows.update(tab.windowId, { focused: true }); - await chrome.tabs.update(tabId, { active: true }); - return { tabId }; -} - -export async function tabsActiveInWindow({ windowId }) { - const activeTabs = await chrome.tabs.query({ windowId, active: true }); - const tab = activeTabs[0]; - if (!tab) { - throw new Error(`No active tab found for window ${windowId}`); - } - return tabInfo(tab); -} - -export async function tabsStatus({ tabId }) { - const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); - return tabInfo(tab); -} - -export async function tabsFilter({ pattern }) { - const all = await chrome.tabs.query({}); - return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo); -} - -export async function tabsCount({ pattern }) { - const all = await chrome.tabs.query({}); - if (pattern) return all.filter(t => t.url && t.url.includes(pattern)).length; - return all.length; -} - -export async function tabsQuery({ search }) { - const q = search.toLowerCase(); - const all = await chrome.tabs.query({}); - return all.filter(t => - (t.url && t.url.toLowerCase().includes(q)) || - (t.title && t.title.toLowerCase().includes(q)) - ).map(tabInfo); -} - -export async function tabsHtml({ tabId }) { - for (let i = 0; i < 3; i++) { - const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); - const tabUrl = tab.url || tab.pendingUrl || ""; - if (isBrowserErrorUrl(tabUrl)) { - return ""; - } - if (!isScriptableUrl(tabUrl)) { - throw new Error(`Cannot get HTML of ${tabUrl} — navigate to a regular web page first`); - } - try { - const results = await executeScript({ - target: { tabId: tab.id }, - func: () => document.documentElement.outerHTML, - }); - return results[0]?.result || ""; - } catch (e) { - if (isErrorPageScriptError(e)) return ""; - const transient = e.message && (e.message.includes("Frame with ID") || e.message.includes("No tab with id")); - if (i < 2 && transient) { - await new Promise(r => setTimeout(r, 300)); - continue; - } - throw e; - } - } -} - -export async function tabsDedupe(args = {}) { - return tabsClose({ ...args, duplicates: true }); -} - -export async function tabsSort({ by, gentleMode, __job } = {}) { - return runLargeOperation("tabs.sort", async () => { - const windows = await chrome.windows.getAll({ populate: true }); - let moved = 0; - const totalTabs = windows.reduce((sum, w) => sum + (w.tabs?.length || 0), 0); - updateJobProgress(__job, { phase: "sorting tabs", current: 0, total: totalTabs }); - for (const w of windows) { - const sorted = [...w.tabs].sort((a, b) => { - if (by === "title") return (a.title || "").localeCompare(b.title || ""); - if (by === "time") return a.id - b.id; // lower id = opened earlier - // domain (default) - const da = new URL(a.url || a.pendingUrl || "about:blank").hostname; - const db = new URL(b.url || b.pendingUrl || "about:blank").hostname; - return da.localeCompare(db); - }); - if (w.tabs.every((tab, index) => tab.id === sorted[index]?.id)) continue; - const throttle = await getLargeOperationThrottle(sorted.length, gentleMode); - const moveBatchSize = Math.max(1, Math.min(10, throttle.batchSize)); - for (let i = 0; i < sorted.length; i++) { - throwIfJobCancelled(__job); - await chrome.tabs.move(sorted[i].id, { index: i }); - moved++; - updateJobProgress(__job, { phase: "sorting tabs", current: moved, total: totalTabs }); - await yieldForLargeOperation(moved, moveBatchSize, throttle.pauseMs); - } - } - return { moved }; - }); -} - -export async function tabsMergeWindows({ gentleMode, __job } = {}) { - return runLargeOperation("tabs.merge_windows", async () => { - const current = await chrome.windows.getCurrent(); - const all = await chrome.windows.getAll({ populate: true }); - let moved = 0; - const totalTabs = all.filter(w => w.id !== current.id).reduce((sum, w) => sum + (w.tabs?.length || 0), 0); - updateJobProgress(__job, { phase: "merging windows", current: 0, total: totalTabs }); - for (const w of all) { - if (w.id === current.id) continue; - const ids = w.tabs.map(t => t.id); - const throttle = await getLargeOperationThrottle(ids.length, gentleMode); - for (let i = 0; i < ids.length; i += throttle.batchSize) { - throwIfJobCancelled(__job); - const chunk = ids.slice(i, i + throttle.batchSize); - await chrome.tabs.move(chunk, { windowId: current.id, index: -1 }); - moved += chunk.length; - updateJobProgress(__job, { phase: "merging windows", current: moved, total: totalTabs }); - await yieldForLargeOperation(moved, throttle.batchSize, throttle.pauseMs); - } - } - return { moved }; - }); -} - -export async function tabsPin({ tabId }) { - const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); - await chrome.tabs.update(tab.id, { pinned: true }); - return { tabId: tab.id, pinned: true }; -} - -export async function tabsUnpin({ tabId }) { - const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); - await chrome.tabs.update(tab.id, { pinned: false }); - return { tabId: tab.id, pinned: false }; -} - -export async function tabsScreenshot({ tabId, format = "png", quality } = {}) { - let windowId; - if (tabId) { - const tab = await chrome.tabs.get(tabId); - await chrome.tabs.update(tabId, { active: true }); - windowId = tab.windowId; - } else { - const tab = await getActiveTab(); - windowId = tab.windowId; - } - const opts = { format }; - if (format === "jpeg" && quality != null) opts.quality = quality; - const dataUrl = await chrome.tabs.captureVisibleTab(windowId, opts); - return { dataUrl, format }; -} - -export async function tabsWatchUrl({ pattern, timeout = 30000, tabId } = {}) { - const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); - const deadline = Date.now() + timeout; - const regex = new RegExp(pattern); - let lastUrl = tab.url || tab.pendingUrl || ""; - let lastStatus = tab.status || "unknown"; - - const matches = (url) => { - regex.lastIndex = 0; - return Boolean(url && regex.test(url)); +export class TabsMutationCommands extends CommandGroup { + readonly namespace = "tabs"; + readonly commands: Record = { + "tabs.close": { background: true, run: (a: TabsCloseArgs) => this.tabsClose(a) }, + "tabs.move": (a: TabsMoveArgs) => this.tabsMove(a), + "tabs.active": (a: TabIdArgs) => this.tabsActive(a), + "tabs.dedupe": { background: true, run: (a: TabsCloseArgs) => this.tabsDedupe(a) }, + "tabs.sort": { background: true, run: (a: TabsSortArgs) => this.tabsSort(a) }, + "tabs.merge_windows": { background: true, run: (a: TabsMergeWindowsArgs) => this.tabsMergeWindows(a) }, + "tabs.mute": (a: TabIdArgs) => this.tabsMute(a), + "tabs.unmute": (a: TabIdArgs) => this.tabsUnmute(a), + "tabs.pin": (a: TabIdArgs) => this.tabsPin(a), + "tabs.unpin": (a: TabIdArgs) => this.tabsUnpin(a), + "tabs.screenshot": (a: TabsScreenshotArgs) => this.tabsScreenshot(a), }; - if (matches(lastUrl)) return tabInfo(tab); - while (Date.now() < deadline) { - const t = await chrome.tabs.get(tab.id); - lastUrl = t.url || t.pendingUrl || ""; - lastStatus = t.status || "unknown"; - if (matches(t.pendingUrl || "") || matches(t.url || "")) return tabInfo(t); - if (isBrowserErrorUrl(t.url || "")) { - throw new Error(`Tab ${tab.id} is showing an error page while waiting for URL to match '${pattern}'`); - } - await new Promise(r => setTimeout(r, 200)); + private async tabsClose({ tabId, inactive, duplicates, gentleMode, __job }: TabsCloseArgs = {}) { + return runLargeOperation("tabs.close", async () => { + let toClose: number[] = []; + if (duplicates) { + const all = await chrome.tabs.query({}); + const seen = new Set(); + for (const t of all) { + if (!t.url) continue; + if (seen.has(t.url)) toClose.push(t.id); + else seen.add(t.url); + } + } else if (inactive) { + const all = await chrome.tabs.query({}); + toClose = all.filter(t => !t.active).map(t => t.id); + } else if (tabId) { + toClose = [tabId]; + } + const throttle = await getLargeOperationThrottle(toClose.length, gentleMode); + await processInBatches(toClose, throttle, batch => chrome.tabs.remove(batch), { job: __job, phase: "closing tabs" }); + return { closed: toClose.length, gentle: throttle.gentle, audible: throttle.audible }; + }); } - throw new Error(`Tab ${tab.id} URL did not match '${pattern}' within ${timeout}ms (last URL: '${lastUrl}', status: '${lastStatus}')`); -} -export async function tabsMute({ tabId }) { - const tab = await resolveTabForDirectAction(tabId, "mute"); - await chrome.tabs.update(tab.id, { muted: true }); - return { tabId: tab.id, muted: true }; -} + private async tabsMove({ tabId, groupId, windowId, index, forward, backward }: TabsMoveArgs) { + const moveProps: Partial = {}; + if (windowId != null) moveProps.windowId = windowId; -export async function tabsUnmute({ tabId }) { - const tab = await resolveTabForDirectAction(tabId, "unmute"); - await chrome.tabs.update(tab.id, { muted: false }); - return { tabId: tab.id, muted: false }; -} + if (forward || backward) { + const tab = await chrome.tabs.get(tabId); + if (forward) moveProps.index = tab.index + 2; // +2 because Chrome shifts after removal + else moveProps.index = Math.max(0, tab.index - 1); + } else if (index != null) { + moveProps.index = index; + } else { + moveProps.index = -1; + } + // `index` is always assigned by one of the branches above before this call. + await chrome.tabs.move(tabId, moveProps as chrome.tabs.MoveProperties); + if (groupId != null) { + await chrome.tabs.group({ tabIds: asTabIds([tabId]), groupId }); + } + return { tabId }; + } + + private async tabsActive({ tabId }: TabIdArgs) { + const tab = await chrome.tabs.get(tabId); + await chrome.windows.update(tab.windowId, { focused: true }); + await chrome.tabs.update(tabId, { active: true }); + return { tabId }; + } + + private async tabsDedupe(args: TabsCloseArgs = {}) { + return this.tabsClose({ ...args, duplicates: true }); + } + + private async tabsSort({ by, gentleMode, __job }: TabsSortArgs = {}) { + return runLargeOperation("tabs.sort", async () => { + const windows = await chrome.windows.getAll({ populate: true }); + let moved = 0; + const totalTabs = windows.reduce((sum, w) => sum + (w.tabs?.length || 0), 0); + updateJobProgress(__job, { phase: "sorting tabs", current: 0, total: totalTabs }); + for (const w of windows) { + const sorted = [...w.tabs].sort((a, b) => { + if (by === "title") return (a.title || "").localeCompare(b.title || ""); + if (by === "time") return a.id - b.id; // lower id = opened earlier + // domain (default) + const da = new URL(a.url || a.pendingUrl || "about:blank").hostname; + const db = new URL(b.url || b.pendingUrl || "about:blank").hostname; + return da.localeCompare(db); + }); + if (w.tabs.every((tab, index) => tab.id === sorted[index]?.id)) continue; + const throttle = await getLargeOperationThrottle(sorted.length, gentleMode); + const moveBatchSize = Math.max(1, Math.min(10, throttle.batchSize)); + for (let i = 0; i < sorted.length; i++) { + throwIfJobCancelled(__job); + await chrome.tabs.move(sorted[i].id, { index: i }); + moved++; + updateJobProgress(__job, { phase: "sorting tabs", current: moved, total: totalTabs }); + await yieldForLargeOperation(moved, moveBatchSize, throttle.pauseMs); + } + } + return { moved }; + }); + } + + private async tabsMergeWindows({ gentleMode, __job }: TabsMergeWindowsArgs = {}) { + return runLargeOperation("tabs.merge_windows", async () => { + const current = await chrome.windows.getCurrent(); + const all = await chrome.windows.getAll({ populate: true }); + let moved = 0; + const totalTabs = all.filter(w => w.id !== current.id).reduce((sum, w) => sum + (w.tabs?.length || 0), 0); + updateJobProgress(__job, { phase: "merging windows", current: 0, total: totalTabs }); + for (const w of all) { + if (w.id === current.id) continue; + const ids = w.tabs.map(t => t.id); + const throttle = await getLargeOperationThrottle(ids.length, gentleMode); + moved = await processInBatches(ids, throttle, + batch => chrome.tabs.move(batch, { windowId: current.id, index: -1 }), + { job: __job, phase: "merging windows", total: totalTabs, baseCurrent: moved }); + } + return { moved }; + }); + } + + private async tabsPin({ tabId }: TabIdArgs) { + const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); + await chrome.tabs.update(tab.id, { pinned: true }); + return { tabId: tab.id, pinned: true }; + } + + private async tabsUnpin({ tabId }: TabIdArgs) { + const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); + await chrome.tabs.update(tab.id, { pinned: false }); + return { tabId: tab.id, pinned: false }; + } + + private async tabsScreenshot({ tabId, format = "png", quality }: TabsScreenshotArgs = {}) { + let windowId: number | undefined; + if (tabId) { + const tab = await chrome.tabs.get(tabId); + await chrome.tabs.update(tabId, { active: true }); + windowId = tab.windowId; + } else { + const tab = await getActiveTab(); + windowId = tab.windowId; + } + const opts: chrome.extensionTypes.ImageDetails = { format: format as chrome.extensionTypes.ImageFormat }; + if (format === "jpeg" && quality != null) opts.quality = quality; + const dataUrl = await chrome.tabs.captureVisibleTab(windowId, opts); + return { dataUrl, format }; + } + + private async tabsMute({ tabId }: TabIdArgs) { + const tab = await resolveTabForDirectAction(tabId, "mute"); + await chrome.tabs.update(tab.id, { muted: true }); + return { tabId: tab.id, muted: true }; + } + + private async tabsUnmute({ tabId }: TabIdArgs) { + const tab = await resolveTabForDirectAction(tabId, "unmute"); + await chrome.tabs.update(tab.id, { muted: false }); + return { tabId: tab.id, muted: false }; + } +} diff --git a/extension/src/commands/windows.ts b/extension/src/commands/windows.ts index 6be245a..a2cbbcb 100644 --- a/extension/src/commands/windows.ts +++ b/extension/src/commands/windows.ts @@ -1,34 +1,45 @@ -// @ts-nocheck import { getAliases } from '../core'; -export async function windowsList() { - const windows = await chrome.windows.getAll({ populate: true }); - const aliases = await getAliases(); - return windows.map(w => ({ - id: w.id, - alias: aliases[w.id] || null, - focused: w.focused, - state: w.state, - tabCount: (w.tabs || []).length, - })); -} +import { CommandGroup } from '../classes/CommandGroup'; +import type { CommandEntry } from '../classes/CommandGroup'; +import type { WindowsRenameArgs, WindowsCloseArgs, WindowsOpenArgs } from '../types'; -export async function windowsRename({ windowId, name }) { - const aliases = await getAliases(); - aliases[windowId] = name; - await chrome.storage.local.set({ windowAliases: aliases }); - return { windowId, name }; -} +export class WindowsCommands extends CommandGroup { + readonly namespace = "windows"; + readonly commands: Record = { + "windows.list": () => this.windowsList(), + "windows.rename": (a: WindowsRenameArgs) => this.windowsRename(a), + "windows.close": (a: WindowsCloseArgs) => this.windowsClose(a), + "windows.open": (a: WindowsOpenArgs) => this.windowsOpen(a), + }; -export async function windowsClose({ windowId }) { - await chrome.windows.remove(windowId); - return { windowId }; -} + private async windowsList() { + const windows = await chrome.windows.getAll({ populate: true }); + const aliases = await getAliases(); + return windows.map(w => ({ + id: w.id, + alias: aliases[w.id] || null, + focused: w.focused, + state: w.state, + tabCount: (w.tabs || []).length, + })); + } -export async function windowsOpen({ url }) { - const createData = { focused: true }; - if (url) createData.url = url; - const w = await chrome.windows.create(createData); - return { id: w.id }; -} + private async windowsRename({ windowId, name }: WindowsRenameArgs) { + const aliases = await getAliases(); + aliases[windowId] = name; + await chrome.storage.local.set({ windowAliases: aliases }); + return { windowId, name }; + } -// ── DOM / Extract ───────────────────────────────────────────────────────────── + private async windowsClose({ windowId }: WindowsCloseArgs) { + await chrome.windows.remove(windowId); + return { windowId }; + } + + private async windowsOpen({ url }: WindowsOpenArgs) { + const createData: chrome.windows.CreateData = { focused: true }; + if (url) createData.url = url; + const w = await chrome.windows.create(createData); + return { id: w.id }; + } +} diff --git a/extension/src/core.ts b/extension/src/core.ts deleted file mode 100644 index 9ec813c..0000000 --- a/extension/src/core.ts +++ /dev/null @@ -1,240 +0,0 @@ -// @ts-nocheck -// Shared helpers for browser-cli extension command handlers. -export async function getProfileAlias() { - const { profileAlias } = await chrome.storage.local.get("profileAlias"); - return profileAlias || "default"; -} - -export function isBrowserErrorUrl(url) { - const value = String(url || "").toLowerCase(); - return value.startsWith("chrome-error://") || - value.startsWith("edge-error://") || - value.startsWith("brave-error://") || - value.startsWith("about:neterror") || - value.startsWith("about:certerror") || - value.startsWith("about:blocked") || - value.startsWith("about:tabcrashed"); -} - -export function isErrorPageScriptError(error) { - const message = String(error?.message || error || "").toLowerCase(); - return message.includes("error page") || - message.includes("chrome-error://") || - message.includes("edge-error://") || - message.includes("brave-error://") || - message.includes("about:neterror") || - message.includes("about:certerror") || - message.includes("about:tabcrashed"); -} - -export function isTransientScriptError(error) { - const message = String(error?.message || error || ""); - return message.includes("Frame with ID") || message.includes("No tab with id") || isErrorPageScriptError(error); -} - -export function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -export const LARGE_OPERATION_BATCH_SIZE = 25; -export const LARGE_OPERATION_PAUSE_MS = 25; -export const GENTLE_OPERATION_BATCH_SIZE = 8; -export const GENTLE_OPERATION_PAUSE_MS = 100; - -export async function hasAudibleTabs() { - const audibleTabs = await chrome.tabs.query({ audible: true }); - return audibleTabs.some(tab => !(tab.mutedInfo && tab.mutedInfo.muted)); -} - -let largeOperationQueue = Promise.resolve(); - -export async function runLargeOperation(name, fn) { - const run = largeOperationQueue.then(async () => { - console.log(`[browser-cli] large operation start: ${name}`); - try { - return await fn(); - } finally { - console.log(`[browser-cli] large operation done: ${name}`); - } - }); - largeOperationQueue = run.catch(() => {}); - return run; -} - -export async function getPerformanceProfile() { - const { performanceProfile } = await chrome.storage.local.get("performanceProfile"); - return performanceProfile || "auto"; -} - -export async function setPerformanceProfile(profile) { - const allowed = new Set(["auto", "normal", "gentle", "ultra"]); - const performanceProfile = allowed.has(profile) ? profile : "auto"; - await chrome.storage.local.set({ performanceProfile }); - return { performanceProfile }; -} - -export async function getLargeOperationThrottle(itemCount = 0, mode = "auto") { - const audible = await hasAudibleTabs(); - const storedProfile = await getPerformanceProfile(); - const configuredMode = mode && mode !== "auto" ? mode : storedProfile; - const gentle = configuredMode === "gentle" || configuredMode === "ultra" || (configuredMode === "auto" && audible); - let batchSize = gentle ? GENTLE_OPERATION_BATCH_SIZE : LARGE_OPERATION_BATCH_SIZE; - let pauseMs = gentle ? GENTLE_OPERATION_PAUSE_MS : LARGE_OPERATION_PAUSE_MS; - - if (configuredMode === "ultra" || itemCount >= 300) { - batchSize = Math.max(3, Math.floor(batchSize / 2)); - pauseMs *= 2; - } else if (itemCount >= 100) { - batchSize = Math.max(5, Math.floor(batchSize * 0.75)); - pauseMs = Math.max(pauseMs, 75); - } - - return { batchSize, pauseMs, gentle, audible, itemCount, mode: configuredMode }; -} - -export function updateJobProgress(job, { phase, current, total } = {}) { - if (!job) return; - if (phase) job.phase = phase; - if (total != null) job.total = total; - if (current != null) job.current = current; - if (job.total) job.percent = Math.min(100, Math.round((job.current || 0) * 100 / job.total)); - job.updatedAt = Date.now(); -} - -export function throwIfJobCancelled(job) { - if (job?.cancelRequested) { - throw new Error(`Job '${job.id}' cancelled`); - } -} - -export async function yieldForLargeOperation(processed, batchSize = LARGE_OPERATION_BATCH_SIZE, pauseMs = LARGE_OPERATION_PAUSE_MS) { - if (processed > 0 && processed % batchSize === 0) { - await sleep(pauseMs); - } -} - -export async function executeScript(options, retries = 3) { - for (let i = 0; i < retries; i++) { - try { - return await chrome.scripting.executeScript(options); - } catch (e) { - if (i < retries - 1 && isTransientScriptError(e)) { - await sleep(300); - continue; - } - throw e; - } - } -} - -export function tabInfo(t) { - return { - id: t.id, - windowId: t.windowId, - active: t.active, - muted: Boolean(t.mutedInfo && t.mutedInfo.muted), - groupId: t.groupId >= 0 ? t.groupId : null, - title: t.title, - url: t.url || t.pendingUrl || "", - }; -} - -// ── Groups ──────────────────────────────────────────────────────────────────── - -export function isScriptableUrl(url) { - if (!url) return false; - return !url.startsWith("chrome://") && - !url.startsWith("brave://") && - !url.startsWith("about:") && - !url.startsWith("edge://") && - !url.startsWith("chrome-extension://"); -} - -export async function getActiveTab() { - const activeTabs = await chrome.tabs.query({ active: true }); - if (!activeTabs.length) throw new Error("No active tab found"); - - const windows = await chrome.windows.getAll({ populate: false }); - const focusedWindowIds = new Set(windows.filter(window => window.focused).map(window => window.id)); - - const chooseTab = (predicate) => activeTabs.find(predicate); - const byFocusAndScriptable = tab => focusedWindowIds.has(tab.windowId) && isScriptableUrl(tab.url || tab.pendingUrl || ""); - const byScriptable = tab => isScriptableUrl(tab.url || tab.pendingUrl || ""); - const byFocus = tab => focusedWindowIds.has(tab.windowId); - - return chooseTab(byFocusAndScriptable) - || chooseTab(byScriptable) - || chooseTab(byFocus) - || activeTabs[0]; -} - -export async function resolveTabForDirectAction(tabId, actionName) { - if (tabId != null) { - return chrome.tabs.get(tabId); - } - const allTabs = await chrome.tabs.query({}); - if (allTabs.length !== 1) { - throw new Error( - `Refusing to ${actionName} without explicit tab ID when ${allTabs.length} tabs are open` - ); - } - return allTabs[0]; -} - -export async function resolveGroupId(nameOrId) { - const asInt = parseInt(nameOrId); - if (!isNaN(asInt)) return asInt; - const groups = await chrome.tabGroups.query({}); - const match = groups.find(g => g.title && g.title.toLowerCase() === String(nameOrId).toLowerCase()); - if (!match) throw new Error(`No tab group found with name '${nameOrId}'`); - return match.id; -} - -export function buildTabBlocks(tabs) { - const blocks = []; - for (const tab of tabs) { - const normalizedGroupId = tab.groupId >= 0 ? tab.groupId : null; - const lastBlock = blocks[blocks.length - 1]; - if (lastBlock?.groupId === normalizedGroupId) { - lastBlock.tabIds.push(tab.id); - lastBlock.endIndex = tab.index; - continue; - } - - blocks.push({ - groupId: normalizedGroupId, - startIndex: tab.index, - endIndex: tab.index, - tabIds: [tab.id], - }); - } - return blocks; -} - -export function getSessionTabs(session) { - if (!session) return []; - if (Array.isArray(session.tabs)) { - return session.tabs - .map(entry => typeof entry === "string" ? { url: entry } : entry) - .filter(entry => entry?.url); - } - if (Array.isArray(session.urls)) { - return session.urls.filter(Boolean).map(url => ({ url })); - } - return []; -} - -export function normalizeGroupColor(color) { - const allowed = new Set(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]); - return allowed.has(color) ? color : "grey"; -} - -export async function getAliases() { - const { windowAliases } = await chrome.storage.local.get("windowAliases"); - return windowAliases || {}; -} - -export async function getSessions() { - const { sessions } = await chrome.storage.local.get("sessions"); - return sessions || {}; -} diff --git a/extension/src/core/errors.ts b/extension/src/core/errors.ts new file mode 100644 index 0000000..38cafff --- /dev/null +++ b/extension/src/core/errors.ts @@ -0,0 +1,35 @@ +// Shared error helpers for browser-cli extension command handlers. +import type { ErrorLike } from '../types'; + +/** Extract a human-readable message from a caught/rejected value. */ +export function getErrorMessage(error: ErrorLike): string { + if (typeof error === "string") return error; + return (error && error.message) || String(error); +} + +export function isBrowserErrorUrl(url: string | null | undefined): boolean { + const value = String(url || "").toLowerCase(); + return value.startsWith("chrome-error://") || + value.startsWith("edge-error://") || + value.startsWith("brave-error://") || + value.startsWith("about:neterror") || + value.startsWith("about:certerror") || + value.startsWith("about:blocked") || + value.startsWith("about:tabcrashed"); +} + +export function isErrorPageScriptError(error: ErrorLike): boolean { + const message = getErrorMessage(error).toLowerCase(); + return message.includes("error page") || + message.includes("chrome-error://") || + message.includes("edge-error://") || + message.includes("brave-error://") || + message.includes("about:neterror") || + message.includes("about:certerror") || + message.includes("about:tabcrashed"); +} + +export function isTransientScriptError(error: ErrorLike): boolean { + const message = getErrorMessage(error); + return message.includes("Frame with ID") || message.includes("No tab with id") || isErrorPageScriptError(error); +} diff --git a/extension/src/core/group-helpers.ts b/extension/src/core/group-helpers.ts new file mode 100644 index 0000000..22fdccb --- /dev/null +++ b/extension/src/core/group-helpers.ts @@ -0,0 +1,15 @@ +// Tab-group resolution and normalization helpers. + +export async function resolveGroupId(nameOrId: string | number): Promise { + const asInt = parseInt(String(nameOrId)); + if (!isNaN(asInt)) return asInt; + const groups = await chrome.tabGroups.query({}); + const match = groups.find(g => g.title && g.title.toLowerCase() === String(nameOrId).toLowerCase()); + if (!match) throw new Error(`No tab group found with name '${nameOrId}'`); + return match.id; +} + +export function normalizeGroupColor(color: string | undefined): chrome.tabGroups.Color { + const allowed = new Set(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]); + return (allowed.has(color as string) ? color : "grey") as chrome.tabGroups.Color; +} diff --git a/extension/src/core/index.ts b/extension/src/core/index.ts new file mode 100644 index 0000000..6889c97 --- /dev/null +++ b/extension/src/core/index.ts @@ -0,0 +1,6 @@ +export * from './errors'; +export * from './throttle'; +export * from './scripting'; +export * from './tab-helpers'; +export * from './group-helpers'; +export * from './storage'; diff --git a/extension/src/core/scripting.ts b/extension/src/core/scripting.ts new file mode 100644 index 0000000..c5f73e2 --- /dev/null +++ b/extension/src/core/scripting.ts @@ -0,0 +1,23 @@ +// chrome.scripting.executeScript wrapper with transient-error retry. +import { isTransientScriptError } from './errors'; +import { sleep } from './throttle'; +import type { Serializable } from '../types'; + +export async function executeScript( + options: chrome.scripting.ScriptInjection, + retries = 3, +): Promise>[]> { + for (let i = 0; i < retries; i++) { + try { + return await chrome.scripting.executeScript(options); + } catch (e) { + if (i < retries - 1 && isTransientScriptError(e)) { + await sleep(300); + continue; + } + throw e; + } + } + // Unreachable: the loop either returns or throws on the final iteration. + throw new Error("executeScript: exhausted retries"); +} diff --git a/extension/src/core/storage.ts b/extension/src/core/storage.ts new file mode 100644 index 0000000..e1a9fc5 --- /dev/null +++ b/extension/src/core/storage.ts @@ -0,0 +1,30 @@ +// chrome.storage.local accessors for profile alias, window aliases, and sessions. +import type { SessionTab, StoredSession } from '../types'; + +export async function getProfileAlias(): Promise { + const { profileAlias } = await chrome.storage.local.get<{ profileAlias?: string }>("profileAlias"); + return profileAlias || "default"; +} + +export function getSessionTabs(session: StoredSession | undefined | null): SessionTab[] { + if (!session) return []; + if (Array.isArray(session.tabs)) { + return session.tabs + .map(entry => typeof entry === "string" ? { url: entry } : entry) + .filter(entry => entry?.url); + } + if (Array.isArray(session.urls)) { + return session.urls.filter(Boolean).map(url => ({ url })); + } + return []; +} + +export async function getAliases(): Promise> { + const { windowAliases } = await chrome.storage.local.get<{ windowAliases?: Record }>("windowAliases"); + return windowAliases || {}; +} + +export async function getSessions(): Promise> { + const { sessions } = await chrome.storage.local.get<{ sessions?: Record }>("sessions"); + return sessions || {}; +} diff --git a/extension/src/core/tab-helpers.ts b/extension/src/core/tab-helpers.ts new file mode 100644 index 0000000..5cca827 --- /dev/null +++ b/extension/src/core/tab-helpers.ts @@ -0,0 +1,123 @@ +// Tab-related shared helpers: info shaping, scriptable-url checks, active-tab +// resolution, and HTML fetching. +import { isBrowserErrorUrl, isErrorPageScriptError } from './errors'; +import { executeScript } from './scripting'; +import type { TabBlock } from '../types'; + +/** + * Narrow a plain id array to the non-empty-tuple shape that chrome.tabs.group / + * chrome.tabs.ungroup declare. The runtime happily accepts any array (including + * a single element); the published @types/chrome just over-constrain the param + * to `[number, ...number[]]`. Callers guarantee non-emptiness before calling. + */ +export function asTabIds(ids: number[]): [number, ...number[]] { + return ids as [number, ...number[]]; +} + +export function tabInfo(t: chrome.tabs.Tab) { + return { + id: t.id, + windowId: t.windowId, + active: t.active, + muted: Boolean(t.mutedInfo && t.mutedInfo.muted), + groupId: t.groupId >= 0 ? t.groupId : null, + title: t.title, + url: t.url || t.pendingUrl || "", + }; +} + +export function isScriptableUrl(url: string | undefined | null): boolean { + if (!url) return false; + return !url.startsWith("chrome://") && + !url.startsWith("brave://") && + !url.startsWith("about:") && + !url.startsWith("edge://") && + !url.startsWith("chrome-extension://"); +} + +export async function getActiveTab() { + const activeTabs = await chrome.tabs.query({ active: true }); + if (!activeTabs.length) throw new Error("No active tab found"); + + const windows = await chrome.windows.getAll({ populate: false }); + const focusedWindowIds = new Set(windows.filter(window => window.focused).map(window => window.id)); + + const chooseTab = (predicate: (tab: chrome.tabs.Tab) => boolean) => activeTabs.find(predicate); + const byFocusAndScriptable = (tab: chrome.tabs.Tab) => focusedWindowIds.has(tab.windowId) && isScriptableUrl(tab.url || tab.pendingUrl || ""); + const byScriptable = (tab: chrome.tabs.Tab) => isScriptableUrl(tab.url || tab.pendingUrl || ""); + const byFocus = (tab: chrome.tabs.Tab) => focusedWindowIds.has(tab.windowId); + + return chooseTab(byFocusAndScriptable) + || chooseTab(byScriptable) + || chooseTab(byFocus) + || activeTabs[0]; +} + +/** Resolve the target tab (explicit id or the active tab) and its current URL. */ +export async function resolveTabUrl(tabId?: number | null): Promise<{ tab: chrome.tabs.Tab; url: string }> { + const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); + return { tab, url: tab.url || tab.pendingUrl || "" }; +} + +/** + * Throw the standard "navigate to a regular web page first" error if `url` is + * not scriptable. `action` is the verb phrase incl. preposition, e.g. + * "run DOM commands on" or "get HTML of". + */ +export function assertScriptableUrl(url: string, action: string): void { + if (!isScriptableUrl(url)) { + throw new Error(`Cannot ${action} ${url} — navigate to a regular web page first`); + } +} + +export async function resolveTabForDirectAction(tabId: number | undefined | null, actionName: string): Promise { + if (tabId != null) { + return chrome.tabs.get(tabId); + } + const allTabs = await chrome.tabs.query({}); + if (allTabs.length !== 1) { + throw new Error( + `Refusing to ${actionName} without explicit tab ID when ${allTabs.length} tabs are open` + ); + } + return allTabs[0]; +} + +export function buildTabBlocks(tabs: chrome.tabs.Tab[]): TabBlock[] { + const blocks: TabBlock[] = []; + for (const tab of tabs) { + const normalizedGroupId = tab.groupId >= 0 ? tab.groupId : null; + const lastBlock = blocks[blocks.length - 1]; + if (lastBlock?.groupId === normalizedGroupId) { + lastBlock.tabIds.push(tab.id); + lastBlock.endIndex = tab.index; + continue; + } + + blocks.push({ + groupId: normalizedGroupId, + startIndex: tab.index, + endIndex: tab.index, + tabIds: [tab.id], + }); + } + return blocks; +} + +export async function fetchTabHtml(tabId?: number): Promise { + // executeScript already retries transient frame/tab errors internally, so a + // single attempt here is enough — we only add error-page handling on top. + const { tab, url } = await resolveTabUrl(tabId); + if (isBrowserErrorUrl(url)) return ""; + assertScriptableUrl(url, "get HTML of"); + try { + const results = await executeScript({ + target: { tabId: tab.id }, + func: () => document.documentElement.outerHTML, + }); + return results[0]?.result || ""; + } catch (e) { + if (isErrorPageScriptError(e)) return ""; + throw e; + } +} diff --git a/extension/src/core/throttle.ts b/extension/src/core/throttle.ts new file mode 100644 index 0000000..4576b36 --- /dev/null +++ b/extension/src/core/throttle.ts @@ -0,0 +1,112 @@ +// Large-operation throttling, performance profile, and job-progress helpers. +import type { Job, JobProgressUpdate } from '../types'; + +export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export const LARGE_OPERATION_BATCH_SIZE = 25; +export const LARGE_OPERATION_PAUSE_MS = 25; +export const GENTLE_OPERATION_BATCH_SIZE = 8; +export const GENTLE_OPERATION_PAUSE_MS = 100; + +export async function hasAudibleTabs() { + const audibleTabs = await chrome.tabs.query({ audible: true }); + return audibleTabs.some(tab => !(tab.mutedInfo && tab.mutedInfo.muted)); +} + +let largeOperationQueue: Promise = Promise.resolve(); + +export async function runLargeOperation(name: string, fn: () => Promise): Promise { + const run = largeOperationQueue.then(async () => { + console.log(`[browser-cli] large operation start: ${name}`); + try { + return await fn(); + } finally { + console.log(`[browser-cli] large operation done: ${name}`); + } + }); + largeOperationQueue = run.then(() => {}, () => {}); + return run; +} + +export async function getPerformanceProfile() { + const { performanceProfile } = await chrome.storage.local.get<{ performanceProfile?: string }>("performanceProfile"); + return performanceProfile || "auto"; +} + +export async function setPerformanceProfile(profile: string) { + const allowed = new Set(["auto", "normal", "gentle", "ultra"]); + const performanceProfile = allowed.has(profile) ? profile : "auto"; + await chrome.storage.local.set({ performanceProfile }); + return { performanceProfile }; +} + +export async function getLargeOperationThrottle(itemCount = 0, mode: string = "auto") { + const audible = await hasAudibleTabs(); + const storedProfile = await getPerformanceProfile(); + const configuredMode = mode && mode !== "auto" ? mode : storedProfile; + const gentle = configuredMode === "gentle" || configuredMode === "ultra" || (configuredMode === "auto" && audible); + let batchSize = gentle ? GENTLE_OPERATION_BATCH_SIZE : LARGE_OPERATION_BATCH_SIZE; + let pauseMs = gentle ? GENTLE_OPERATION_PAUSE_MS : LARGE_OPERATION_PAUSE_MS; + + if (configuredMode === "ultra" || itemCount >= 300) { + batchSize = Math.max(3, Math.floor(batchSize / 2)); + pauseMs *= 2; + } else if (itemCount >= 100) { + batchSize = Math.max(5, Math.floor(batchSize * 0.75)); + pauseMs = Math.max(pauseMs, 75); + } + + return { batchSize, pauseMs, gentle, audible, itemCount, mode: configuredMode }; +} + +export function updateJobProgress(job: Job | undefined | null, { phase, current, total }: JobProgressUpdate = {}) { + if (!job) return; + if (phase) job.phase = phase; + if (total != null) job.total = total; + if (current != null) job.current = current; + if (job.total) job.percent = Math.min(100, Math.round((job.current || 0) * 100 / job.total)); + job.updatedAt = Date.now(); +} + +export function throwIfJobCancelled(job: Job | undefined | null) { + if (job?.cancelRequested) { + throw new Error(`Job '${job.id}' cancelled`); + } +} + +export async function yieldForLargeOperation(processed: number, batchSize = LARGE_OPERATION_BATCH_SIZE, pauseMs = LARGE_OPERATION_PAUSE_MS) { + if (processed > 0 && processed % batchSize === 0) { + await sleep(pauseMs); + } +} + +/** + * Run `handler` over `items` in throttle-sized slices, threading job progress + * and cancellation. Each slice is one `handler` call (so chrome batch APIs like + * tabs.remove get an array); between slices it reports progress and yields. + * + * `total`/`baseCurrent` let callers report against a fixed total accumulated + * across several calls (e.g. merging tabs from many windows). Returns the + * running processed count so the next call can continue from it. + */ +export async function processInBatches( + items: T[], + throttle: { batchSize: number; pauseMs: number }, + handler: (batch: T[]) => Promise, + progress: { job?: Job | null; phase: string; total?: number; baseCurrent?: number }, +): Promise { + const total = progress.total ?? items.length; + let done = progress.baseCurrent ?? 0; + updateJobProgress(progress.job, { phase: progress.phase, current: done, total }); + for (let i = 0; i < items.length; i += throttle.batchSize) { + throwIfJobCancelled(progress.job); + const batch = items.slice(i, i + throttle.batchSize); + await handler(batch); + done += batch.length; + updateJobProgress(progress.job, { phase: progress.phase, current: done, total }); + await yieldForLargeOperation(done, throttle.batchSize, throttle.pauseMs); + } + return done; +} diff --git a/extension/src/index.ts b/extension/src/index.ts index 904ffc8..36f5c13 100644 --- a/extension/src/index.ts +++ b/extension/src/index.ts @@ -1,365 +1,23 @@ -// @ts-nocheck /** * browser-cli Extension — Background Service Worker * * Connects to the native host (com.browsercli.host) via Native Messaging. + * Thin wiring entry point: build the job manager + command registry, then start + * the native connection. */ -import { getLargeOperationThrottle, getPerformanceProfile, hasAudibleTabs, setPerformanceProfile, getProfileAlias } from './core'; -import * as nav from './commands/navigation'; -import * as tabs from './commands/tabs'; -import * as groups from './commands/groups'; -import * as windowsCmd from './commands/windows'; -import * as dom from './commands/dom'; -import * as browserData from './commands/browser-data'; -import * as session from './commands/session'; +import { JobManager } from './classes/JobManager'; +import { assembleRegistry } from './classes/CommandRegistry'; +import { NativeConnection } from './classes/NativeConnection'; +import type { CommandContext } from './classes/CommandGroup'; -const NATIVE_HOST = "com.browsercli.host"; -let port = null; -let keepaliveEnabled = true; -const jobs = new Map(); -const BACKGROUND_COMMANDS = new Set([ - "session.load", - "tabs.close", - "tabs.dedupe", - "tabs.sort", - "tabs.merge_windows", - "group.close", -]); - -// ── Connection management ───────────────────────────────────────────────────── -function sendControlMessage(targetPort, message) { - if (!targetPort) return; - try { - targetPort.postMessage(message); - } catch (e) { - console.warn("[browser-cli] Failed to send control message:", e); - } -} - -function disconnectPort({ sendBye = false } = {}) { - const currentPort = port; - if (!currentPort) return; - - if (sendBye) sendControlMessage(currentPort, { type: "bye" }); - - if (port === currentPort) port = null; - - try { - currentPort.disconnect(); - } catch (e) { - console.warn("[browser-cli] Failed to disconnect native port:", e); - } -} -async function connect() { - if (port || !keepaliveEnabled) return; - try { - const nativePort = chrome.runtime.connectNative(NATIVE_HOST); - port = nativePort; - nativePort.onMessage.addListener(onMessage); - nativePort.onDisconnect.addListener(() => { - if (port === nativePort) port = null; - const err = chrome.runtime.lastError; - if (err) console.warn("[browser-cli] Native host disconnected:", err.message); - }); - // Send hello so native host knows which profile/alias this is - const alias = await getProfileAlias(); - nativePort.postMessage({ type: "hello", alias }); - console.log("[browser-cli] Connected to native host as profile:", alias); - } catch (e) { - port = null; - console.error("[browser-cli] Failed to connect:", e); - } -} - -chrome.runtime.onInstalled.addListener(connect); -chrome.runtime.onStartup.addListener(connect); -chrome.runtime.onSuspend.addListener(() => { - disconnectPort({ sendBye: true }); -}); -chrome.windows.onCreated.addListener(() => { - keepaliveEnabled = true; - if (!port) connect(); -}); -chrome.windows.onRemoved.addListener(async () => { - const windows = await chrome.windows.getAll({}); - if (windows.length > 0) return; - - keepaliveEnabled = false; - disconnectPort({ sendBye: true }); -}); - -// Keepalive alarm — prevents service worker suspension and reconnects if needed -chrome.alarms.create("keepalive", { periodInMinutes: 0.4 }); -chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name === "keepalive") { - if (!port && keepaliveEnabled) connect(); - } -}); +const jobs = new JobManager(); +const ctx: CommandContext = { jobs }; +const { registry, session } = assembleRegistry(ctx); chrome.tabs.onActivated.addListener(async ({ tabId }) => { await session.activateLazyTab(tabId); }); -// ── Message dispatcher ──────────────────────────────────────────────────────── - -async function onMessage(msg) { - const { id, command, args } = msg; - if (!id || !command) return; - - console.log("[browser-cli] ←", command, args); - - let data, error; - try { - const { __page, __background, ...commandArgs } = args || {}; - if (__background && BACKGROUND_COMMANDS.has(command)) { - data = await startBackgroundJob(command, commandArgs); - } else { - data = await dispatch(command, commandArgs); - if (__page && Array.isArray(data)) { - data = makePagedData(data, __page); - } - } - } catch (e) { - error = e.message || String(e); - } - - if (error !== undefined) { - console.log("[browser-cli] → ERROR", command, error); - port.postMessage({ id, success: false, error }); - } else { - console.log("[browser-cli] →", command, data); - port.postMessage({ id, success: true, data }); - } - - if (command === "clients.rename_profile" && error === undefined) { - disconnectPort({ sendBye: true }); - keepaliveEnabled = true; - await connect(); - } -} -async function persistJobs() { - const recentJobs = [...jobs.values()].slice(-50).map(job => ({ ...job, __timer: undefined })); - await chrome.storage.local.set({ recentJobs }); -} - -async function startBackgroundJob(command, args) { - const jobId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; - const job = { - id: jobId, - command, - status: "running", - phase: "queued", - current: 0, - total: null, - percent: 0, - cancelRequested: false, - startedAt: Date.now(), - updatedAt: Date.now(), - finishedAt: null, - result: null, - error: null, - }; - jobs.set(jobId, job); - job.__timer = setInterval(persistJobs, 1000); - await persistJobs(); - dispatch(command, { ...args, __job: job }) - .then(async result => { - job.status = "done"; - job.phase = "done"; - job.result = result; - job.current = job.total || job.current; - job.percent = 100; - job.finishedAt = Date.now(); - job.updatedAt = Date.now(); - if (job.__timer) clearInterval(job.__timer); - await persistJobs(); - }) - .catch(async error => { - job.status = job.cancelRequested ? "cancelled" : "error"; - job.phase = job.status; - job.error = error?.message || String(error); - job.finishedAt = Date.now(); - job.updatedAt = Date.now(); - if (job.__timer) clearInterval(job.__timer); - await persistJobs(); - }); - return { jobId, command, status: job.status }; -} - -async function jobStatus({ jobId }) { - const job = jobs.get(jobId); - if (job) return { ...job }; - const { recentJobs } = await chrome.storage.local.get("recentJobs"); - const stored = (recentJobs || []).find(entry => entry.id === jobId); - if (!stored) throw new Error(`Job '${jobId}' not found`); - return stored; -} - -async function jobCancel({ jobId }) { - const job = jobs.get(jobId); - if (!job) throw new Error(`Job '${jobId}' not running`); - job.cancelRequested = true; - job.updatedAt = Date.now(); - await persistJobs(); - return { jobId, cancelled: true }; -} - -async function perfStatus() { - const profile = await getPerformanceProfile(); - const audible = await hasAudibleTabs(); - const throttle = await getLargeOperationThrottle(0, "auto"); - return { - performanceProfile: profile, - audible, - throttle, - jobs: [...jobs.values()].map(job => ({ - id: job.id, - command: job.command, - status: job.status, - phase: job.phase, - current: job.current, - total: job.total, - percent: job.percent, - cancelRequested: job.cancelRequested, - })), - }; -} - -function makePagedData(items, page) { - const total = items.length; - const offset = Math.max(0, Number(page.offset) || 0); - const requestedLimit = Math.max(1, Number(page.limit) || 100); - const limit = Math.min(requestedLimit, 1000); - const end = Math.min(offset + limit, total); - return { - __browserCliPage: true, - items: items.slice(offset, end), - offset, - limit, - total, - nextOffset: end < total ? end : null, - }; -} -async function dispatch(command, args) { - switch (command) { - // ── Navigation ──────────────────────────────────────────────────────── - case "navigate.open": return nav.navOpen(args); - case "navigate.to": return nav.navTo(args); - case "navigate.reload": return nav.navReload(args, false); - case "navigate.hard_reload": return nav.navReload(args, true); - case "navigate.back": return nav.navBack(args); - case "navigate.forward": return nav.navForward(args); - case "navigate.focus": return nav.navFocus(args); - case "navigate.wait": return nav.navWait(args); - case "navigate.open_wait": return nav.navOpenWait(args); - - // ── Tabs ────────────────────────────────────────────────────────────── - case "tabs.list": return tabs.tabsList(); - case "tabs.close": return tabs.tabsClose(args); - case "tabs.move": return tabs.tabsMove(args); - case "tabs.active": return tabs.tabsActive(args); - case "tabs.active_in_window": return tabs.tabsActiveInWindow(args); - case "tabs.status": return tabs.tabsStatus(args); - case "tabs.filter": return tabs.tabsFilter(args); - case "tabs.count": return tabs.tabsCount(args); - case "tabs.query": return tabs.tabsQuery(args); - case "tabs.html": return tabs.tabsHtml(args); - case "tabs.dedupe": return tabs.tabsDedupe(args); - case "tabs.sort": return tabs.tabsSort(args); - case "tabs.merge_windows": return tabs.tabsMergeWindows(args); - case "tabs.mute": return tabs.tabsMute(args); - case "tabs.unmute": return tabs.tabsUnmute(args); - case "tabs.pin": return tabs.tabsPin(args); - case "tabs.unpin": return tabs.tabsUnpin(args); - case "tabs.screenshot": return tabs.tabsScreenshot(args); - case "tabs.watch_url": return tabs.tabsWatchUrl(args); - - // ── Groups ──────────────────────────────────────────────────────────── - case "group.list": return groups.groupList(); - case "group.tabs": return groups.groupTabs(args); - case "group.count": return groups.groupCount(); - case "group.query": return groups.groupQuery(args); - case "group.close": return groups.groupClose(args); - case "group.open": return groups.groupOpen(args); - case "group.add_tab": return groups.groupAddTab(args); - case "group.move": return groups.groupMove(args); - - // ── Windows ─────────────────────────────────────────────────────────── - case "windows.list": return windowsCmd.windowsList(); - case "windows.rename": return windowsCmd.windowsRename(args); - case "windows.close": return windowsCmd.windowsClose(args); - case "windows.open": return windowsCmd.windowsOpen(args); - - // ── DOM ─────────────────────────────────────────────────────────────── - case "dom.query": return dom.domOp("domQuery", args); - case "dom.click": return dom.domOp("domClick", args); - case "dom.type": return dom.domOp("domType", args); - case "dom.attr": return dom.domOp("domAttr", args); - case "dom.text": return dom.domOp("domText", args); - case "dom.exists": return dom.domOp("domExists", args); - case "dom.scroll": return dom.domOp("domScroll", args); - case "dom.select": return dom.domOp("domSelect", args); - case "dom.key": return dom.domOp("domKey", args); - case "dom.hover": return dom.domOp("domHover", args); - case "dom.check": return dom.domOp("domCheck", { ...args, checked: true }); - case "dom.uncheck": return dom.domOp("domCheck", { ...args, checked: false }); - case "dom.clear": return dom.domOp("domClear", args); - case "dom.focus": return dom.domOp("domFocus", args); - case "dom.submit": return dom.domOp("domSubmit", args); - case "dom.eval": return dom.domEval(args); - case "dom.wait_for": return dom.domWaitFor(args); - case "dom.poll": return dom.domPoll(args); - - // ── Page ────────────────────────────────────────────────────────────── - case "page.info": return dom.domOp("pageInfo", {}); - - // ── Storage ─────────────────────────────────────────────────────────── - case "storage.get": return browserData.storageGet(args); - case "storage.set": return browserData.storageSet(args); - - // ── Cookies ─────────────────────────────────────────────────────────── - case "cookies.list": return browserData.cookiesList(args); - case "cookies.get": return browserData.cookiesGet(args); - case "cookies.set": return browserData.cookiesSet(args); - - // ── Extract ─────────────────────────────────────────────────────────── - case "extract.links": return dom.domOp("extractLinks", args); - case "extract.images": return dom.domOp("extractImages", args); - case "extract.text": return dom.domOp("extractText", args); - case "extract.json": return dom.domOp("extractJson", args); - case "extract.markdown": return dom.domOp("extractMarkdown", args); - case "extract.html": return tabs.tabsHtml({}); - - // ── Session ─────────────────────────────────────────────────────────── - case "session.save": return session.sessionSave(args); - case "session.load": return session.sessionLoad(args); - case "session.list": return session.sessionList(); - case "session.remove": return session.sessionRemove(args); - case "session.diff": return session.sessionDiff(args); - case "session.auto_save": return session.sessionAutoSave(args); - - // ── Jobs ────────────────────────────────────────────────────────────── - case "jobs.status": return jobStatus(args); - case "jobs.cancel": return jobCancel(args); - - // ── Performance ─────────────────────────────────────────────────────── - case "perf.status": return perfStatus(); - case "perf.set_profile": return setPerformanceProfile(args.profile); - - // ── Extension ───────────────────────────────────────────────────────── - case "extension.reload": { - setTimeout(() => chrome.runtime.reload(), 200); - return { reloading: true }; - } - - // ── Misc ────────────────────────────────────────────────────────────── - case "clients.list": return session.clientsList(); - case "clients.rename_profile": return session.clientsRenameProfile(args); - - default: - throw new Error(`Unknown command: ${command}`); - } -} - -// ── Navigation ──────────────────────────────────────────────────────────────── +const connection = new NativeConnection(registry, session); +connection.start(); diff --git a/extension/src/types/command-args.ts b/extension/src/types/command-args.ts new file mode 100644 index 0000000..1ed9e1e --- /dev/null +++ b/extension/src/types/command-args.ts @@ -0,0 +1,106 @@ +import type { Json } from './json'; +import type { Job } from './jobs'; + +// ── Navigation ──────────────────────────────────────────────────────────────── +export interface NavOpenArgs { + url?: string; + background?: boolean; + window?: string; + windowId?: number; + group?: string | number; +} + +export interface NavToArgs { tabId?: number; url?: string; } +export interface NavTabArgs { tabId?: number; } +export interface NavFocusArgs { pattern?: string; } +export interface NavWaitArgs { tabId?: number; timeout?: number; readyState?: string; } +export interface NavOpenWaitArgs { + url?: string; + timeout?: number; + background?: boolean; + window?: string; + group?: string | number; +} + +// ── Tabs ────────────────────────────────────────────────────────────────────── +export interface TabsCloseArgs { tabId?: number; inactive?: boolean; duplicates?: boolean; gentleMode?: string; __job?: Job; } +export interface TabsMoveArgs { tabId?: number; groupId?: number; windowId?: number; index?: number; forward?: boolean; backward?: boolean; } +export interface TabIdArgs { tabId?: number; } +export interface TabsActiveInWindowArgs { windowId?: number; } +export interface TabsPatternArgs { pattern?: string; } +export interface TabsQueryArgs { search?: string; } +export interface TabsSortArgs { by?: string; gentleMode?: string; __job?: Job; } +export interface TabsMergeWindowsArgs { gentleMode?: string; __job?: Job; } +export interface TabsScreenshotArgs { tabId?: number; format?: string; quality?: number; } +export interface TabsWatchUrlArgs { pattern?: string; timeout?: number; tabId?: number; } + +// ── Groups ────────────────────────────────────────────────────────────────────── +export interface GroupTabsArgs { groupId?: number; } +export interface GroupQueryArgs { search?: string; } +export interface GroupCloseArgs { groupId?: number; gentleMode?: string; __job?: Job; } +export interface GroupOpenArgs { name?: string; } +export interface GroupAddTabArgs { group?: string | number; url?: string; } +export interface GroupMoveArgs { group?: string | number; forward?: boolean; backward?: boolean; } + +// ── Windows ───────────────────────────────────────────────────────────────────── +export interface WindowsRenameArgs { windowId?: number; name?: string; } +export interface WindowsCloseArgs { windowId?: number; } +export interface WindowsOpenArgs { url?: string; } + +// ── DOM ───────────────────────────────────────────────────────────────────────── +export interface DomEvalArgs { code?: string; tabId?: number; } +export interface DomWaitForArgs { selector?: string; timeout?: number; visible?: boolean; hidden?: boolean; tabId?: number; } +export interface DomPollArgs { selector?: string; pattern?: string; attr?: string; timeout?: number; interval?: number; tabId?: number; } + +/** Arguments forwarded to the in-page content functions over chrome.scripting. */ +export interface ContentArgs { + selector?: string; + text?: string; + attr?: string; + key?: string; + checked?: boolean; + value?: string; + x?: number; + y?: number; +} + +/** A content-function arg set targeting a specific tab (defaults to active). */ +export type DomArgs = ContentArgs & { tabId?: number }; + +// ── Browser data ──────────────────────────────────────────────────────────────── +export interface StorageGetArgs { key?: string; type?: string; tabId?: number; } +export interface StorageSetArgs { key?: string; value?: Json; type?: string; tabId?: number; } +export interface CookiesListArgs { url?: string; domain?: string; name?: string; } +export interface CookiesGetArgs { url?: string; name?: string; } +export interface CookiesSetArgs { + url?: string; + name?: string; + value?: string; + domain?: string; + path?: string; + secure?: boolean; + httpOnly?: boolean; + expirationDate?: number; + sameSite?: `${chrome.cookies.SameSiteStatus}`; +} + +// ── Session ───────────────────────────────────────────────────────────────────── +export interface SessionSaveArgs { name?: string; } +export interface SessionRemoveArgs { name?: string; } +export interface SessionDiffArgs { nameA?: string; nameB?: string; } +export interface SessionLoadArgs { + name?: string; + gentleMode?: string; + discardBackgroundTabs?: boolean; + lazy?: boolean; + eagerTabs?: number; + __job?: Job; +} +export interface SessionAutoSaveArgs { enabled?: boolean; } +export interface ClientsRenameProfileArgs { alias?: string; } + +// ── Jobs ────────────────────────────────────────────────────────────────────── +export interface JobIdArgs { jobId?: string; } + +// ── Performance ─────────────────────────────────────────────────────────────── +export interface PerfSetProfileArgs { profile?: Json; } diff --git a/extension/src/types/index.ts b/extension/src/types/index.ts new file mode 100644 index 0000000..6564de8 --- /dev/null +++ b/extension/src/types/index.ts @@ -0,0 +1,6 @@ +export * from './json'; +export * from './jobs'; +export * from './session'; +export * from './tabs'; +export * from './messages'; +export * from './command-args'; diff --git a/extension/src/types/jobs.ts b/extension/src/types/jobs.ts new file mode 100644 index 0000000..d33cee6 --- /dev/null +++ b/extension/src/types/jobs.ts @@ -0,0 +1,28 @@ +import type { Serializable } from './json'; + +/** A background job tracked by the service worker. */ +export interface Job { + id: string; + command: string; + status: string; + phase: string; + current: number; + total: number | null; + percent: number; + cancelRequested: boolean; + startedAt: number; + updatedAt: number; + finishedAt: number | null; + result: Serializable; + error: string | null; + /** Handle of the persistence interval; cleared when the job finishes/evicts. */ + __timer?: ReturnType | null; + /** Handle of the watchdog timeout; clears the persist timer if the runner hangs. */ + __watchdog?: ReturnType | null; +} + +export interface JobProgressUpdate { + phase?: string; + current?: number; + total?: number | null; +} diff --git a/extension/src/types/json.ts b/extension/src/types/json.ts new file mode 100644 index 0000000..11f0bc0 --- /dev/null +++ b/extension/src/types/json.ts @@ -0,0 +1,23 @@ +// Core JSON / IPC value types shared across the extension. + +/** Any JSON value as sent/received over the native-messaging IPC. */ +export type Json = string | number | boolean | null | Json[] | { [key: string]: Json }; + +/** + * The deserialized IPC argument dictionary. Concrete index-signature type + * (no `any`): individual handlers narrow it to their own arg interface at the + * dispatch boundary. + */ +export type CommandArgs = { [key: string]: Json | undefined }; + +/** + * Anything a command handler returns or that is passed as a script argument: + * a JSON primitive or some object/array. `object` (not `any`) is deliberate — + * it accepts arrays and named interfaces (e.g. chrome's Cookie/TabGroup, Job) + * which a recursive index-signature type cannot, while still excluding + * primitives-as-properties and forbidding unchecked property access. + */ +export type Serializable = string | number | boolean | null | undefined | object; + +/** Realistic shapes a thrown value can take (JS can throw anything). */ +export type ErrorLike = Error | string | { message?: string } | null | undefined; diff --git a/extension/src/types/messages.ts b/extension/src/types/messages.ts new file mode 100644 index 0000000..1ccedfb --- /dev/null +++ b/extension/src/types/messages.ts @@ -0,0 +1,27 @@ +import type { Json, CommandArgs, Serializable } from './json'; +import type { Job } from './jobs'; + +/** Control message sent over the native port (hello/bye/etc.). */ +export interface ControlMessage { type: string; alias?: string } + +/** Reply to a command, correlated to the request via `id`. */ +export interface ResponseMessage { + id: string; + success: boolean; + data?: Serializable; + error?: string; +} + +/** A message received from the native host over the port. */ +export interface IncomingMessage { + id?: string; + command?: string; + args?: CommandArgs; +} + +/** Pagination request threaded through via the internal `__page` arg. */ +export interface PageRequest { offset?: number; limit?: number; } + +// Args as seen by dispatch: the deserialized IPC dictionary, plus the optional +// internal `__job` handle that background commands thread through for progress. +export type DispatchArgs = { [key: string]: Json | Job | undefined; __job?: Job }; diff --git a/extension/src/types/session.ts b/extension/src/types/session.ts new file mode 100644 index 0000000..ed71e5f --- /dev/null +++ b/extension/src/types/session.ts @@ -0,0 +1,21 @@ +/** A tab entry stored inside a saved session. */ +export interface SessionTabGroup { + key?: string; + title?: string; + color?: string; + collapsed?: boolean; +} +export interface SessionTab { + url: string; + pinned?: boolean; + group?: SessionTabGroup; +} +export interface StoredSession { + tabs?: Array; + urls?: string[]; + savedAt?: number; + signature?: string; +} + +export interface LazyTabEntry { url: string; createdAt: number; } +export type LazySessionMap = Record; diff --git a/extension/src/types/tabs.ts b/extension/src/types/tabs.ts new file mode 100644 index 0000000..c3d1f4c --- /dev/null +++ b/extension/src/types/tabs.ts @@ -0,0 +1,6 @@ +export interface TabBlock { + groupId: number | null; + startIndex: number; + endIndex: number; + tabIds: number[]; +} diff --git a/extension/test/autosave.test.ts b/extension/test/autosave.test.ts new file mode 100644 index 0000000..9b01da7 --- /dev/null +++ b/extension/test/autosave.test.ts @@ -0,0 +1,127 @@ +// @ts-nocheck +import { describe, it, beforeEach, afterEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeChromeMock } from './chrome-mock'; +import { AutoSaveManager } from '../src/commands/autosave'; + +let chromeMock; +let session; + +// Flush pending microtasks/promise chains after advancing fake timers. +// setImmediate stays real (only setTimeout is mocked), so it yields a +// macrotask boundary that drains the microtask queue between iterations. +async function drain() { + for (let i = 0; i < 10; i++) await new Promise(r => setImmediate(r)); +} +async function tick(ms) { + mock.timers.tick(ms); + await drain(); +} + +beforeEach(() => { + chromeMock = makeChromeMock(); + globalThis.chrome = chromeMock; + mock.timers.enable({ apis: ['setTimeout'] }); + session = new AutoSaveManager(); +}); + +afterEach(async () => { + // Clear any pending debounce timer + instance state before tearing down timers. + await session.setEnabled(false); + await drain(); + mock.timers.reset(); + delete globalThis.chrome; +}); + +const TAB_EVENTS = ['onCreated', 'onRemoved', 'onMoved', 'onAttached', 'onDetached']; + +describe('sessionAutoSave listener wiring', () => { + it('registers a handler on every tab mutation event + tabGroups when enabled', async () => { + await session.setEnabled(true); + for (const ev of TAB_EVENTS) { + assert.equal(chromeMock.tabs[ev]._size(), 1, `tabs.${ev}`); + } + assert.equal(chromeMock.tabs.onUpdated._size(), 1); + assert.equal(chromeMock.tabGroups.onUpdated._size(), 1); + }); + + it('removes all listeners when disabled (no leaked subscriptions)', async () => { + await session.setEnabled(true); + await session.setEnabled(false); + for (const ev of TAB_EVENTS) { + assert.equal(chromeMock.tabs[ev]._size(), 0, `tabs.${ev}`); + } + assert.equal(chromeMock.tabs.onUpdated._size(), 0); + assert.equal(chromeMock.tabGroups.onUpdated._size(), 0); + }); + + it('does not double-register when enabled twice', async () => { + await session.setEnabled(true); + await session.setEnabled(true); + for (const ev of TAB_EVENTS) { + assert.equal(chromeMock.tabs[ev]._size(), 1, `tabs.${ev}`); + } + }); +}); + +describe('autosave debounce / coalescing', () => { + it('collapses a burst of tab events into a single snapshot+save', async () => { + await session.setEnabled(true); + chromeMock.tabs.query.mock.resetCalls(); + + // Simulate rapidly opening/closing several tabs. + await session.autoSaveHandler(); + await session.autoSaveHandler(); + await session.autoSaveHandler(); + await session.autoSaveHandler(); + + // Nothing should have run yet — still inside the debounce window. + assert.equal(chromeMock.tabs.query.mock.callCount(), 0); + + await tick(3000); + + // The whole burst produced exactly one snapshot read. + assert.equal(chromeMock.tabs.query.mock.callCount(), 1); + }); + + it('does not fire before the (raised) 3s debounce window elapses', async () => { + await session.setEnabled(true); + chromeMock.tabs.query.mock.resetCalls(); + + await session.autoSaveHandler(); + await tick(2999); + assert.equal(chromeMock.tabs.query.mock.callCount(), 0); + + await tick(1); + assert.equal(chromeMock.tabs.query.mock.callCount(), 1); + }); + + it('ignores tab updates that do not change the URL (no save scheduled)', async () => { + await session.setEnabled(true); + chromeMock.tabs.query.mock.resetCalls(); + + // status/favicon/title churn — not a URL change. + await session.autoSaveUpdatedHandler(1, { status: 'loading' }); + await session.autoSaveUpdatedHandler(1, { favIconUrl: 'x' }); + await session.autoSaveUpdatedHandler(1, { title: 'y' }); + + await tick(3000); + assert.equal(chromeMock.tabs.query.mock.callCount(), 0); + }); + + it('schedules a save when a URL change is observed', async () => { + await session.setEnabled(true); + chromeMock.tabs.query.mock.resetCalls(); + + await session.autoSaveUpdatedHandler(1, { url: 'https://example.com' }); + await tick(3000); + assert.equal(chromeMock.tabs.query.mock.callCount(), 1); + }); + + it('does nothing when autosave is disabled even if a handler fires', async () => { + // Never enabled: storage has no autoSave flag. + await session.autoSaveHandler(); + await tick(3000); + assert.equal(chromeMock.tabs.query.mock.callCount(), 0); + }); +}); diff --git a/extension/test/chrome-mock.ts b/extension/test/chrome-mock.ts new file mode 100644 index 0000000..61bd2bc --- /dev/null +++ b/extension/test/chrome-mock.ts @@ -0,0 +1,55 @@ +// @ts-nocheck +import { mock } from 'node:test'; + +/** + * Minimal in-memory chrome.* stub for service-worker unit tests. + * Only the surface the tested modules touch is implemented. + * Mock functions use node:test's `mock.fn` (callCount/resetCalls via `.mock`). + */ +export function makeChromeMock() { + const store: Record = {}; + const listeners = new Map>(); + + function event(name: string) { + if (!listeners.has(name)) listeners.set(name, new Set()); + const set = listeners.get(name)!; + return { + addListener: (fn: Function) => set.add(fn), + removeListener: (fn: Function) => set.delete(fn), + hasListener: (fn: Function) => set.has(fn), + // test helper: count registered listeners + _size: () => set.size, + }; + } + + return { + storage: { + local: { + get: mock.fn(async (key: string | string[]) => { + const keys = Array.isArray(key) ? key : [key]; + const out: Record = {}; + for (const k of keys) if (k in store) out[k] = store[k]; + return out; + }), + set: mock.fn(async (obj: Record) => { + Object.assign(store, obj); + }), + _store: store, + }, + }, + tabs: { + query: mock.fn(async () => []), + onCreated: event('tabs.onCreated'), + onRemoved: event('tabs.onRemoved'), + onMoved: event('tabs.onMoved'), + onAttached: event('tabs.onAttached'), + onDetached: event('tabs.onDetached'), + onUpdated: event('tabs.onUpdated'), + }, + tabGroups: { + query: mock.fn(async () => []), + onUpdated: event('tabGroups.onUpdated'), + }, + _event: event, + }; +} diff --git a/extension/test/jobs.test.ts b/extension/test/jobs.test.ts new file mode 100644 index 0000000..f5b8dac --- /dev/null +++ b/extension/test/jobs.test.ts @@ -0,0 +1,146 @@ +// @ts-nocheck +import { test, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { JobManager, JOB_TIMEOUT_MS, MAX_FINISHED_JOBS, pruneFinishedJobs } from '../src/classes/JobManager'; +import { makeChromeMock } from './chrome-mock'; + +// Drain pending microtasks (finalize() chains several awaits). setImmediate is +// never mocked here, so it fires after the current microtask queue is empty. +const flush = () => new Promise(r => setImmediate(r)); + +function makeJobs(specs) { + const map = new Map(); + for (const s of specs) map.set(s.id, { __timer: null, ...s }); + return map; +} + +test('pruneFinishedJobs: keeps everything when under the cap', () => { + const jobs = makeJobs([ + { id: 'a', status: 'done', finishedAt: 1 }, + { id: 'b', status: 'done', finishedAt: 2 }, + ]); + const evicted = pruneFinishedJobs(jobs, 5); + assert.deepEqual(evicted, []); + assert.equal(jobs.size, 2); +}); + +test('pruneFinishedJobs: evicts the oldest finished jobs beyond the cap', () => { + const jobs = makeJobs([ + { id: 'old', status: 'done', finishedAt: 10 }, + { id: 'mid', status: 'error', finishedAt: 20 }, + { id: 'new', status: 'done', finishedAt: 30 }, + ]); + const evicted = pruneFinishedJobs(jobs, 2); + assert.deepEqual(evicted, ['old']); + assert.deepEqual([...jobs.keys()], ['mid', 'new']); +}); + +test('pruneFinishedJobs: never evicts running jobs, even past the cap', () => { + const jobs = makeJobs([ + { id: 'run1', status: 'running' }, + { id: 'run2', status: 'running' }, + { id: 'f1', status: 'done', finishedAt: 1 }, + { id: 'f2', status: 'done', finishedAt: 2 }, + { id: 'f3', status: 'done', finishedAt: 3 }, + ]); + pruneFinishedJobs(jobs, 1); + // both running kept; only newest finished (f3) kept + assert.deepEqual([...jobs.keys()].sort(), ['f3', 'run1', 'run2']); +}); + +test('pruneFinishedJobs: clears the timer of every evicted job (no interval leak)', () => { + const clear = mock.fn(); + const jobs = makeJobs([ + { id: 'a', status: 'done', finishedAt: 1, __timer: 101 }, + { id: 'b', status: 'done', finishedAt: 2, __timer: 102 }, + { id: 'c', status: 'done', finishedAt: 3, __timer: 103 }, + ]); + pruneFinishedJobs(jobs, 1, clear); + const clearedArgs = clear.mock.calls.map(c => c.arguments[0]); + assert.ok(clearedArgs.includes(101)); + assert.ok(clearedArgs.includes(102)); + assert.ok(!clearedArgs.includes(103)); // surviving job's timer untouched +}); + +test('pruneFinishedJobs: treats missing finishedAt as oldest (evicted first)', () => { + const jobs = makeJobs([ + { id: 'nofin', status: 'done' }, + { id: 'has', status: 'done', finishedAt: 5 }, + ]); + pruneFinishedJobs(jobs, 1); + assert.deepEqual([...jobs.keys()], ['has']); +}); + +test('pruneFinishedJobs: defaults to MAX_FINISHED_JOBS and bounds the retained set', () => { + const specs = Array.from({ length: MAX_FINISHED_JOBS + 25 }, (_, i) => ({ + id: `j${i}`, status: 'done', finishedAt: i, + })); + const jobs = makeJobs(specs); + pruneFinishedJobs(jobs); + assert.equal(jobs.size, MAX_FINISHED_JOBS); + // the 25 oldest (j0..j24) are gone; newest survive + assert.equal(jobs.has('j0'), false); + assert.equal(jobs.has('j24'), false); + assert.equal(jobs.has('j25'), true); +}); + +test('JobManager: a resolving runner finishes the job and clears its timers', async () => { + globalThis.chrome = makeChromeMock(); + const mgr = new JobManager(); + const { jobId } = await mgr.start('demo', {}, async () => ({ ok: 1 })); + await flush(); + const job = await mgr.status({ jobId }); + assert.equal(job.status, 'done'); + assert.deepEqual(job.result, { ok: 1 }); + assert.equal(job.percent, 100); + assert.equal(job.__timer ?? null, null); + assert.equal(job.__watchdog ?? null, null); +}); + +test('JobManager: watchdog finalizes a hung runner and stops the persist timer', async () => { + mock.timers.enable({ apis: ['setInterval', 'setTimeout'] }); + globalThis.chrome = makeChromeMock(); + const mgr = new JobManager(); + // Runner never settles — only the watchdog can finish this job. + const { jobId } = await mgr.start('hang', {}, () => new Promise(() => {})); + mock.timers.tick(JOB_TIMEOUT_MS); + await flush(); + const job = await mgr.status({ jobId }); + assert.equal(job.status, 'error'); + assert.match(job.error, /timed out/); + assert.equal(job.cancelRequested, true); + assert.equal(job.__timer ?? null, null); + mock.timers.reset(); +}); + +test('JobManager: a runner that settles after the watchdog cannot resurrect the job', async () => { + mock.timers.enable({ apis: ['setInterval', 'setTimeout'] }); + globalThis.chrome = makeChromeMock(); + const mgr = new JobManager(); + let settle; + const { jobId } = await mgr.start('late', {}, () => new Promise(r => { settle = r; })); + mock.timers.tick(JOB_TIMEOUT_MS); + await flush(); + settle({ tooLate: true }); // runner resolves only after the watchdog fired + await flush(); + const job = await mgr.status({ jobId }); + assert.equal(job.status, 'error'); // stayed error — finalize() ran exactly once + assert.equal(job.result, null); + mock.timers.reset(); +}); + +test('JobManager: persisted set keeps running jobs even past the finished cap', async () => { + mock.timers.enable({ apis: ['setInterval', 'setTimeout'] }); + globalThis.chrome = makeChromeMock(); + const mgr = new JobManager(); + await mgr.start('runner', {}, () => new Promise(() => {})); // stays running + for (let i = 0; i < MAX_FINISHED_JOBS + 5; i++) { + await mgr.start(`done${i}`, {}, async () => i); + } + await flush(); + const stored = globalThis.chrome.storage.local._store.recentJobs; + const running = stored.filter(j => j.status === 'running'); + assert.equal(running.length, 1, 'the running job must never be evicted from storage'); + assert.ok(stored.length <= MAX_FINISHED_JOBS + 1, 'finished jobs stay capped'); + mock.timers.reset(); +}); diff --git a/package-lock.json b/package-lock.json index d21b4ad..815de9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -454,9 +454,9 @@ } }, "node_modules/@types/chrome": { - "version": "0.1.40", - "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.1.40.tgz", - "integrity": "sha512-UnfyRAe8ORu9HSuTH0EqyOEUin3JrWW9Nl/gDXezNfTUrfIoxw+WRZgKOxGz0t5BnjbfXBnS2eCYfW2PxH1wcA==", + "version": "0.1.43", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.1.43.tgz", + "integrity": "sha512-ukH/HhmR6ht+UTX3PLUWJxgJ/RQcK2Foj4lBzsF24SIWsXgqhGuXqjd8FFuwioPP7d/JUKLM4g8GZxw3F4HTcA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index ed9ef4e..9d6da2f 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,9 @@ "type": "module", "scripts": { "build:extension": "esbuild extension/src/index.ts --bundle --format=iife --target=chrome120 --outfile=extension/background.js", - "check:extension": "tsc -p tsconfig.json --noEmit && npm run build:extension && node --check extension/background.js" + "build:tests": "esbuild extension/test/*.test.ts --bundle --format=esm --platform=node --outdir=extension/test-dist --out-extension:.js=.mjs", + "test:extension": "npm run build:tests && node --test extension/test-dist/*.test.mjs", + "check:extension": "tsc -p tsconfig.json --noEmit && npm run build:extension && node --check extension/background.js && npm run test:extension" }, "devDependencies": { "@types/chrome": "^0.1.40", diff --git a/tests/test_extension_error_page_handling.py b/tests/test_extension_error_page_handling.py index f6f1ca2..195e4e4 100644 --- a/tests/test_extension_error_page_handling.py +++ b/tests/test_extension_error_page_handling.py @@ -3,16 +3,20 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] def test_extension_retries_error_page_script_injection_before_failing(): - core = (ROOT / "extension" / "src" / "core.ts").read_text() + # core.ts was split into a core/ subfolder during the structure refactor: + # the URL/error classifiers live in core/errors.ts and the executeScript + # retry wrapper (which calls isTransientScriptError) lives in core/scripting.ts. + errors = (ROOT / "extension" / "src" / "core" / "errors.ts").read_text() + scripting = (ROOT / "extension" / "src" / "core" / "scripting.ts").read_text() - assert "isBrowserErrorUrl" in core - assert "isErrorPageScriptError" in core - assert "chrome-error://" in core - assert "edge-error://" in core - assert "brave-error://" in core - assert "about:neterror" in core - assert "about:certerror" in core - assert "isTransientScriptError(e)" in core + assert "isBrowserErrorUrl" in errors + assert "isErrorPageScriptError" in errors + assert "chrome-error://" in errors + assert "edge-error://" in errors + assert "brave-error://" in errors + assert "about:neterror" in errors + assert "about:certerror" in errors + assert "isTransientScriptError(e)" in scripting def test_read_only_dom_commands_have_error_page_fallbacks(): dom = (ROOT / "extension" / "src" / "commands" / "dom.ts").read_text() @@ -26,7 +30,9 @@ def test_read_only_dom_commands_have_error_page_fallbacks(): assert "isErrorPageScriptError(e)" in dom def test_navigation_and_tabs_report_browser_error_pages(): - tabs = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text() + # tabs.watch_url (which reports tab error pages) moved into the read-only + # TabsQueryCommands class in tabs-query.ts during the structure refactor. + tabs = (ROOT / "extension" / "src" / "commands" / "tabs-query.ts").read_text() navigation = (ROOT / "extension" / "src" / "commands" / "navigation.ts").read_text() assert "lastUrl" in tabs @@ -37,7 +43,12 @@ def test_navigation_and_tabs_report_browser_error_pages(): assert "showing an error page while waiting for load" in navigation def test_large_extension_operations_yield_between_batches(): - core = (ROOT / "extension" / "src" / "core.ts").read_text() + # The large-operation throttling/queue helpers moved from core.ts into + # core/throttle.ts when core was split into a subfolder. The slice-based + # batch loop (cancel-check -> batch call -> progress -> yield) was then + # centralized into the processInBatches() helper in core/throttle.ts, so the + # command modules call that instead of inlining the loop. + core = (ROOT / "extension" / "src" / "core" / "throttle.ts").read_text() tabs = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text() groups = (ROOT / "extension" / "src" / "commands" / "groups.ts").read_text() session = (ROOT / "extension" / "src" / "commands" / "session.ts").read_text() @@ -56,14 +67,19 @@ def test_large_extension_operations_yield_between_batches(): assert "itemCount >= 300" in core assert "itemCount >= 100" in core assert "chrome.tabs.query({ audible: true })" in core + # The centralized batch loop drives cancellation + progress + throttled yield. + assert "processInBatches" in core + assert "throwIfJobCancelled(progress.job)" in core + assert "yieldForLargeOperation(done" in core + # tabs.close / tabs.merge_windows now batch via processInBatches; tabs.sort + # still runs its own per-item move loop with yieldForLargeOperation. + assert "processInBatches(toClose" in tabs + assert "processInBatches(ids" in tabs assert "yieldForLargeOperation" in tabs - assert "toClose.slice" in tabs - assert "ids.slice" in tabs assert "w.tabs.every" in tabs assert "getLargeOperationThrottle" in tabs assert "runLargeOperation(\"tabs.sort\"" in tabs - assert "yieldForLargeOperation" in groups - assert "tabIds.slice" in groups + assert "processInBatches(tabIds" in groups assert "getLargeOperationThrottle" in groups assert "runLargeOperation(\"group.close\"" in groups assert "yieldForLargeOperation(createdTabs.length" in session @@ -76,35 +92,50 @@ def test_large_extension_operations_yield_between_batches(): assert "throwIfJobCancelled" in session assert "updateJobProgress" in session - index = (ROOT / "extension" / "src" / "index.ts").read_text() - assert "BACKGROUND_COMMANDS" in index - assert "startBackgroundJob" in index - assert "persistJobs" in index - assert "recentJobs" in index - assert "jobs.status" in index - assert "jobs.cancel" in index - assert "perf.status" in index - assert "perf.set_profile" in index - assert "__background" in index + # The background-job machinery moved out of index.ts into the JobManager + # (classes/JobManager.ts), the message router (classes/NativeConnection.ts) + # and the command registry/groups during the class-based refactor. The + # behavior is identical; assert the defining patterns still exist in their + # new homes. + jobs_module = (ROOT / "extension" / "src" / "classes" / "JobManager.ts").read_text() + connection = (ROOT / "extension" / "src" / "classes" / "NativeConnection.ts").read_text() + perf = (ROOT / "extension" / "src" / "commands" / "perf.ts").read_text() + tabs_module = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text() + + assert "background: true" in tabs_module # background command flag (was BACKGROUND_COMMANDS) + assert "persistJobs" in jobs_module + assert "recentJobs" in jobs_module + assert "jobs.status" in perf + assert "jobs.cancel" in perf + assert "perf.status" in perf + assert "perf.set_profile" in perf + assert "__background" in connection def test_session_autosave_is_debounced_and_non_overlapping(): - session = (ROOT / "extension" / "src" / "commands" / "session.ts").read_text() + # The autosave lifecycle moved out of session.ts into a dedicated + # AutoSaveManager (autosave.ts) during the structure refactor; the shared + # snapshot/signature helpers live in session-snapshot.ts. Behavior is + # identical — the defining patterns just live in their new homes. + autosave = (ROOT / "extension" / "src" / "commands" / "autosave.ts").read_text() + snapshot = (ROOT / "extension" / "src" / "commands" / "session-snapshot.ts").read_text() - assert "autoSaveTimer" in session - assert "autoSaveInFlight" in session - assert "autoSavePending" in session - assert "scheduleAutoSave" in session - assert "autoSaveUpdatedHandler" in session - assert "saveAutoSessionIfChanged" in session - assert "sessionSignature" in session - assert "autoSaveSignature" in session - assert "chrome.tabs.onUpdated.addListener(autoSaveUpdatedHandler)" in session - assert "chrome.tabs.onCreated.addListener(autoSaveHandler)" in session - assert "chrome.tabs.onMoved.addListener(autoSaveHandler)" in session - assert "if (!(\"url\" in changeInfo)) return;" in session - assert "setTimeout(runAutoSave, delayMs)" in session - assert "clearTimeout(autoSaveTimer)" in session + assert "autoSaveTimer" in autosave + assert "autoSaveInFlight" in autosave + assert "autoSavePending" in autosave + assert "scheduleAutoSave" in autosave + assert "autoSaveUpdatedHandler" in autosave + assert "saveAutoSessionIfChanged" in autosave + assert "sessionSignature" in snapshot + assert "autoSaveSignature" in autosave + # AutoSaveManager binds the handlers as instance fields (this.*), so the + # add/removeListener references stay identity-stable across enable/disable. + assert "chrome.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler)" in autosave + assert "chrome.tabs.onCreated.addListener(this.autoSaveHandler)" in autosave + assert "chrome.tabs.onMoved.addListener(this.autoSaveHandler)" in autosave + assert "if (!(\"url\" in changeInfo)) return;" in autosave + assert "setTimeout(() => this.runAutoSave(), delayMs)" in autosave + assert "clearTimeout(this.autoSaveTimer)" in autosave def test_cli_and_sdk_expose_gentle_restore_controls(): diff --git a/tsconfig.json b/tsconfig.json index 42c45db..b5e6610 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ "types": ["chrome"], "allowJs": false, "strict": false, + "noImplicitAny": true, "noEmit": true, "skipLibCheck": true },