refactor(extension): class-based command registry + modular src layout
Testing / remote-protocol-compat (0.9.5) (push) Successful in 45s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 47s
Testing / test (push) Successful in 52s

Restructure the MV3 background worker from a monolithic core.ts/index.ts
into a class-based command architecture. Behavior is identical — the 83
registered commands dispatch byte-for-byte the same as before.

Structure
- One class per command group, each extending CommandGroup and exporting a
  `commands` map keyed by the full command id ("tabs.close"). Groups:
  Navigation, TabsMutation, TabsQuery, Groups, Windows, Dom (dom/extract/
  page), BrowserData (storage/cookies), Session (session/clients + autosave
  + lazy-tab activation), Perf (perf + jobs.status/cancel), Extension.
- CommandRegistry merges the group maps (throws on duplicate ids), routes
  background specs to JobManager and paginates array results via
  makePagedData. JobManager owns the job map + lifecycle. NativeConnection
  owns the native-port lifecycle and the inbound message router.
- index.ts is now thin wiring: JobManager -> ctx -> assembleRegistry ->
  onActivated -> NativeConnection.start().
- Infra classes live in classes/ (PascalCase, file = class name); command
  groups in commands/; shared helpers split out of core.ts into core/
  (errors, throttle, scripting, tab-helpers, group-helpers, storage); all
  types moved into types/ (json, jobs, session, tabs, messages,
  command-args) behind a barrel.

DRY cleanup
- resolveTabUrl(tabId) and assertScriptableUrl(url, action) collapse the
  tab/URL-guard boilerplate duplicated across dom.ts and browser-data.ts.
- processInBatches() centralizes the throttled, cancellable batch loop
  shared by tabs.close, group.close and tabs.merge_windows.
- captureCurrentSession() dedups the snapshot-and-signature block shared by
  session.save and the autosave path.
- DomArgs type alias replaces 21 inline ContentArgs & { tabId? } copies.
- Drop fetchTabHtml's redundant retry loop (executeScript already retries
  transient frame/tab errors), a dead tabInfo import, and two stale
  comments referencing a removed asArgs helper.

Type safety & tests
- Full noImplicitAny; no `any`/`unknown` annotations remain in src.
- JS unit-test harness using node --test + node:assert (zero new deps),
  bundled via the existing esbuild. Covers JobManager retention/lifecycle
  and the autosave listener-wiring/debounce with an in-memory chrome mock.
- The structural pytest checks track the new file homes and the centralized
  processInBatches helper.

Verification: npm run check:extension green (tsc + esbuild 84.5kb +
node --check + 18 JS tests); uv run pytest -q -> 409 passed, 105 skipped.
No version bump.
This commit is contained in:
2026-06-11 00:33:00 +02:00
parent d2f2a99f3d
commit ba01be1c5d
41 changed files with 2522 additions and 1589 deletions
+2 -1
View File
@@ -1,5 +1,6 @@
# TypeScript / Node
extension/background.js
extension/test-dist/
node_modules/
dist/
@@ -8,4 +9,4 @@ __pycache__/
*.pyc
# IDE
.vscode/
.vscode/
+21
View File
@@ -0,0 +1,21 @@
import type { CommandArgs, Serializable } from '../types';
import type { JobManager } from './JobManager';
export type CommandRun = (args: CommandArgs) => Promise<Serializable> | 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<string, CommandEntry>;
}
+94
View File
@@ -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<string, CommandEntry>();
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<Serializable> {
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 };
}
+160
View File
@@ -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<string, Job>,
max = MAX_FINISHED_JOBS,
clearTimer: (handle: NonNullable<Job["__timer"]>) => 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<Serializable>;
/** Owns the live job map plus its persistence + retention lifecycle. */
export class JobManager {
private readonly jobs = new Map<string, Job>();
/** 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 };
}
}
+147
View File
@@ -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();
}
}
}
+84
View File
@@ -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<typeof setTimeout> | 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<void> => {
await this.scheduleAutoSave();
};
readonly autoSaveUpdatedHandler = async (_tabId: number, changeInfo: chrome.tabs.OnUpdatedInfo = {}): Promise<void> => {
// 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);
}
}
+67 -55
View File
@@ -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<string, CommandEntry> = {
"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
+132 -100
View File
@@ -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<string, CommandEntry> = {
// ── 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<string, string | null | undefined>;
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`);
}
+12
View File
@@ -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<string, CommandEntry> = {
"extension.reload": () => {
setTimeout(() => chrome.runtime.reload(), 200);
return { reloading: true };
},
};
}
+106 -97
View File
@@ -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<string, CommandEntry> = {
"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 };
}
}
+97 -90
View File
@@ -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<string, string> = {};
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<string>();
return Array.from(document.querySelectorAll("a[href]")).reduce<Array<{ text: string; href: string }>>((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<string>();
return Array.from(document.querySelectorAll("img")).reduce<Array<{ alt: string; src: string }>>((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);
}
+107 -91
View File
@@ -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<string, CommandEntry> = {
"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 });
}
}
+38
View File
@@ -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<string, CommandEntry> = {
"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,
})),
};
}
}
@@ -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,
})));
}
+129 -234
View File
@@ -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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[ch]));
const html = `<!doctype html><title>Lazy tab</title><body style="font-family:sans-serif;padding:2rem"><h1>Lazy tab</h1><p>This tab will load when selected.</p><p><a href="${escaped}">${escaped}</a></p></body>`;
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<string, CommandEntry> = {
"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 };
}
+95
View File
@@ -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<string, CommandEntry> = {
"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}')`);
}
}
+159 -255
View File
@@ -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<string, CommandEntry> = {
"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<chrome.tabs.MoveProperties> = {};
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 };
}
}
+40 -29
View File
@@ -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<string, CommandEntry> = {
"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 };
}
}
-240
View File
@@ -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 || {};
}
+35
View File
@@ -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);
}
+15
View File
@@ -0,0 +1,15 @@
// Tab-group resolution and normalization helpers.
export async function resolveGroupId(nameOrId: string | number): Promise<number> {
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;
}
+6
View File
@@ -0,0 +1,6 @@
export * from './errors';
export * from './throttle';
export * from './scripting';
export * from './tab-helpers';
export * from './group-helpers';
export * from './storage';
+23
View File
@@ -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<Args extends Serializable[], Result>(
options: chrome.scripting.ScriptInjection<Args, Result>,
retries = 3,
): Promise<chrome.scripting.InjectionResult<chrome.scripting.Awaited<Result>>[]> {
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");
}
+30
View File
@@ -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<string> {
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<Record<string, string>> {
const { windowAliases } = await chrome.storage.local.get<{ windowAliases?: Record<string, string> }>("windowAliases");
return windowAliases || {};
}
export async function getSessions(): Promise<Record<string, StoredSession>> {
const { sessions } = await chrome.storage.local.get<{ sessions?: Record<string, StoredSession> }>("sessions");
return sessions || {};
}
+123
View File
@@ -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<chrome.tabs.Tab> {
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<string> {
// 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;
}
}
+112
View File
@@ -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<void> {
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<void> = Promise.resolve();
export async function runLargeOperation<T>(name: string, fn: () => Promise<T>): Promise<T> {
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<T>(
items: T[],
throttle: { batchSize: number; pauseMs: number },
handler: (batch: T[]) => Promise<unknown>,
progress: { job?: Job | null; phase: string; total?: number; baseCurrent?: number },
): Promise<number> {
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;
}
+11 -353
View File
@@ -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();
+106
View File
@@ -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; }
+6
View File
@@ -0,0 +1,6 @@
export * from './json';
export * from './jobs';
export * from './session';
export * from './tabs';
export * from './messages';
export * from './command-args';
+28
View File
@@ -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<typeof setInterval> | null;
/** Handle of the watchdog timeout; clears the persist timer if the runner hangs. */
__watchdog?: ReturnType<typeof setTimeout> | null;
}
export interface JobProgressUpdate {
phase?: string;
current?: number;
total?: number | null;
}
+23
View File
@@ -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;
+27
View File
@@ -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 };
+21
View File
@@ -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<SessionTab | string>;
urls?: string[];
savedAt?: number;
signature?: string;
}
export interface LazyTabEntry { url: string; createdAt: number; }
export type LazySessionMap = Record<string, LazyTabEntry>;
+6
View File
@@ -0,0 +1,6 @@
export interface TabBlock {
groupId: number | null;
startIndex: number;
endIndex: number;
tabIds: number[];
}
+127
View File
@@ -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);
});
});
+55
View File
@@ -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<string, any> = {};
const listeners = new Map<string, Set<Function>>();
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<string, any> = {};
for (const k of keys) if (k in store) out[k] = store[k];
return out;
}),
set: mock.fn(async (obj: Record<string, any>) => {
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,
};
}
+146
View File
@@ -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();
});
+3 -3
View File
@@ -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": {
+3 -1
View File
@@ -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",
+71 -40
View File
@@ -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():
+1
View File
@@ -7,6 +7,7 @@
"types": ["chrome"],
"allowJs": false,
"strict": false,
"noImplicitAny": true,
"noEmit": true,
"skipLibCheck": true
},