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
+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[];
}