refactor(extension): class-based command registry + modular src layout
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:
+2
-1
@@ -1,5 +1,6 @@
|
||||
# TypeScript / Node
|
||||
extension/background.js
|
||||
extension/test-dist/
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
@@ -8,4 +9,4 @@ __pycache__/
|
||||
*.pyc
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.vscode/
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ? `` : ``;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,255 +1,150 @@
|
||||
// @ts-nocheck
|
||||
import { getLargeOperationThrottle, getProfileAlias, getSessionTabs, getSessions, normalizeGroupColor, runLargeOperation, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
|
||||
import { CommandGroup } from '../classes/CommandGroup';
|
||||
import { AutoSaveManager } from './autosave';
|
||||
import { captureCurrentSession } from './session-snapshot';
|
||||
import type { CommandEntry } from '../classes/CommandGroup';
|
||||
import type { LazySessionMap, SessionSaveArgs, SessionRemoveArgs, SessionDiffArgs, SessionLoadArgs, SessionAutoSaveArgs, ClientsRenameProfileArgs } from '../types';
|
||||
|
||||
function buildSessionSnapshot(tabs, groups) {
|
||||
const groupById = new Map(groups.map(group => [group.id, group]));
|
||||
return tabs
|
||||
.filter(tab => Boolean(tab.url || tab.pendingUrl))
|
||||
.sort((a, b) => (a.windowId - b.windowId) || (a.index - b.index))
|
||||
.map(tab => {
|
||||
const entry = { url: tab.url || tab.pendingUrl };
|
||||
if (tab.pinned) entry.pinned = true;
|
||||
if (tab.groupId >= 0) {
|
||||
const group = groupById.get(tab.groupId);
|
||||
entry.group = {
|
||||
key: `${tab.windowId}:${tab.groupId}`,
|
||||
title: group?.title || "",
|
||||
color: normalizeGroupColor(group?.color),
|
||||
collapsed: Boolean(group?.collapsed),
|
||||
};
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
|
||||
function sessionSignature(sessionTabs) {
|
||||
return JSON.stringify(sessionTabs.map(tab => ({
|
||||
url: tab.url,
|
||||
pinned: Boolean(tab.pinned),
|
||||
group: tab.group ? {
|
||||
key: tab.group.key || "",
|
||||
title: tab.group.title || "",
|
||||
color: normalizeGroupColor(tab.group.color),
|
||||
collapsed: Boolean(tab.group.collapsed),
|
||||
} : null,
|
||||
})));
|
||||
}
|
||||
|
||||
export async function sessionSave({ name }) {
|
||||
const tabs = await chrome.tabs.query({});
|
||||
const groups = await chrome.tabGroups.query({});
|
||||
const sessionTabs = buildSessionSnapshot(tabs, groups);
|
||||
const signature = sessionSignature(sessionTabs);
|
||||
const sessions = await getSessions();
|
||||
sessions[name] = {
|
||||
tabs: sessionTabs,
|
||||
urls: sessionTabs.map(tab => tab.url),
|
||||
savedAt: Date.now(),
|
||||
signature,
|
||||
};
|
||||
await chrome.storage.local.set({ sessions });
|
||||
return { name, tabs: sessionTabs.length };
|
||||
}
|
||||
|
||||
function lazyPlaceholderUrl(url) {
|
||||
function lazyPlaceholderUrl(url: string) {
|
||||
const escaped = String(url).replace(/[&<>"']/g, ch => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[ch]));
|
||||
const html = `<!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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 || {};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './errors';
|
||||
export * from './throttle';
|
||||
export * from './scripting';
|
||||
export * from './tab-helpers';
|
||||
export * from './group-helpers';
|
||||
export * from './storage';
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 || {};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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; }
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './json';
|
||||
export * from './jobs';
|
||||
export * from './session';
|
||||
export * from './tabs';
|
||||
export * from './messages';
|
||||
export * from './command-args';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface TabBlock {
|
||||
groupId: number | null;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
tabIds: number[];
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// @ts-nocheck
|
||||
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeChromeMock } from './chrome-mock';
|
||||
import { AutoSaveManager } from '../src/commands/autosave';
|
||||
|
||||
let chromeMock;
|
||||
let session;
|
||||
|
||||
// Flush pending microtasks/promise chains after advancing fake timers.
|
||||
// setImmediate stays real (only setTimeout is mocked), so it yields a
|
||||
// macrotask boundary that drains the microtask queue between iterations.
|
||||
async function drain() {
|
||||
for (let i = 0; i < 10; i++) await new Promise(r => setImmediate(r));
|
||||
}
|
||||
async function tick(ms) {
|
||||
mock.timers.tick(ms);
|
||||
await drain();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
chromeMock = makeChromeMock();
|
||||
globalThis.chrome = chromeMock;
|
||||
mock.timers.enable({ apis: ['setTimeout'] });
|
||||
session = new AutoSaveManager();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clear any pending debounce timer + instance state before tearing down timers.
|
||||
await session.setEnabled(false);
|
||||
await drain();
|
||||
mock.timers.reset();
|
||||
delete globalThis.chrome;
|
||||
});
|
||||
|
||||
const TAB_EVENTS = ['onCreated', 'onRemoved', 'onMoved', 'onAttached', 'onDetached'];
|
||||
|
||||
describe('sessionAutoSave listener wiring', () => {
|
||||
it('registers a handler on every tab mutation event + tabGroups when enabled', async () => {
|
||||
await session.setEnabled(true);
|
||||
for (const ev of TAB_EVENTS) {
|
||||
assert.equal(chromeMock.tabs[ev]._size(), 1, `tabs.${ev}`);
|
||||
}
|
||||
assert.equal(chromeMock.tabs.onUpdated._size(), 1);
|
||||
assert.equal(chromeMock.tabGroups.onUpdated._size(), 1);
|
||||
});
|
||||
|
||||
it('removes all listeners when disabled (no leaked subscriptions)', async () => {
|
||||
await session.setEnabled(true);
|
||||
await session.setEnabled(false);
|
||||
for (const ev of TAB_EVENTS) {
|
||||
assert.equal(chromeMock.tabs[ev]._size(), 0, `tabs.${ev}`);
|
||||
}
|
||||
assert.equal(chromeMock.tabs.onUpdated._size(), 0);
|
||||
assert.equal(chromeMock.tabGroups.onUpdated._size(), 0);
|
||||
});
|
||||
|
||||
it('does not double-register when enabled twice', async () => {
|
||||
await session.setEnabled(true);
|
||||
await session.setEnabled(true);
|
||||
for (const ev of TAB_EVENTS) {
|
||||
assert.equal(chromeMock.tabs[ev]._size(), 1, `tabs.${ev}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('autosave debounce / coalescing', () => {
|
||||
it('collapses a burst of tab events into a single snapshot+save', async () => {
|
||||
await session.setEnabled(true);
|
||||
chromeMock.tabs.query.mock.resetCalls();
|
||||
|
||||
// Simulate rapidly opening/closing several tabs.
|
||||
await session.autoSaveHandler();
|
||||
await session.autoSaveHandler();
|
||||
await session.autoSaveHandler();
|
||||
await session.autoSaveHandler();
|
||||
|
||||
// Nothing should have run yet — still inside the debounce window.
|
||||
assert.equal(chromeMock.tabs.query.mock.callCount(), 0);
|
||||
|
||||
await tick(3000);
|
||||
|
||||
// The whole burst produced exactly one snapshot read.
|
||||
assert.equal(chromeMock.tabs.query.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
it('does not fire before the (raised) 3s debounce window elapses', async () => {
|
||||
await session.setEnabled(true);
|
||||
chromeMock.tabs.query.mock.resetCalls();
|
||||
|
||||
await session.autoSaveHandler();
|
||||
await tick(2999);
|
||||
assert.equal(chromeMock.tabs.query.mock.callCount(), 0);
|
||||
|
||||
await tick(1);
|
||||
assert.equal(chromeMock.tabs.query.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
it('ignores tab updates that do not change the URL (no save scheduled)', async () => {
|
||||
await session.setEnabled(true);
|
||||
chromeMock.tabs.query.mock.resetCalls();
|
||||
|
||||
// status/favicon/title churn — not a URL change.
|
||||
await session.autoSaveUpdatedHandler(1, { status: 'loading' });
|
||||
await session.autoSaveUpdatedHandler(1, { favIconUrl: 'x' });
|
||||
await session.autoSaveUpdatedHandler(1, { title: 'y' });
|
||||
|
||||
await tick(3000);
|
||||
assert.equal(chromeMock.tabs.query.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
it('schedules a save when a URL change is observed', async () => {
|
||||
await session.setEnabled(true);
|
||||
chromeMock.tabs.query.mock.resetCalls();
|
||||
|
||||
await session.autoSaveUpdatedHandler(1, { url: 'https://example.com' });
|
||||
await tick(3000);
|
||||
assert.equal(chromeMock.tabs.query.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
it('does nothing when autosave is disabled even if a handler fires', async () => {
|
||||
// Never enabled: storage has no autoSave flag.
|
||||
await session.autoSaveHandler();
|
||||
await tick(3000);
|
||||
assert.equal(chromeMock.tabs.query.mock.callCount(), 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
// @ts-nocheck
|
||||
import { mock } from 'node:test';
|
||||
|
||||
/**
|
||||
* Minimal in-memory chrome.* stub for service-worker unit tests.
|
||||
* Only the surface the tested modules touch is implemented.
|
||||
* Mock functions use node:test's `mock.fn` (callCount/resetCalls via `.mock`).
|
||||
*/
|
||||
export function makeChromeMock() {
|
||||
const store: Record<string, any> = {};
|
||||
const listeners = new Map<string, Set<Function>>();
|
||||
|
||||
function event(name: string) {
|
||||
if (!listeners.has(name)) listeners.set(name, new Set());
|
||||
const set = listeners.get(name)!;
|
||||
return {
|
||||
addListener: (fn: Function) => set.add(fn),
|
||||
removeListener: (fn: Function) => set.delete(fn),
|
||||
hasListener: (fn: Function) => set.has(fn),
|
||||
// test helper: count registered listeners
|
||||
_size: () => set.size,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
storage: {
|
||||
local: {
|
||||
get: mock.fn(async (key: string | string[]) => {
|
||||
const keys = Array.isArray(key) ? key : [key];
|
||||
const out: Record<string, any> = {};
|
||||
for (const k of keys) if (k in store) out[k] = store[k];
|
||||
return out;
|
||||
}),
|
||||
set: mock.fn(async (obj: Record<string, any>) => {
|
||||
Object.assign(store, obj);
|
||||
}),
|
||||
_store: store,
|
||||
},
|
||||
},
|
||||
tabs: {
|
||||
query: mock.fn(async () => []),
|
||||
onCreated: event('tabs.onCreated'),
|
||||
onRemoved: event('tabs.onRemoved'),
|
||||
onMoved: event('tabs.onMoved'),
|
||||
onAttached: event('tabs.onAttached'),
|
||||
onDetached: event('tabs.onDetached'),
|
||||
onUpdated: event('tabs.onUpdated'),
|
||||
},
|
||||
tabGroups: {
|
||||
query: mock.fn(async () => []),
|
||||
onUpdated: event('tabGroups.onUpdated'),
|
||||
},
|
||||
_event: event,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// @ts-nocheck
|
||||
import { test, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { JobManager, JOB_TIMEOUT_MS, MAX_FINISHED_JOBS, pruneFinishedJobs } from '../src/classes/JobManager';
|
||||
import { makeChromeMock } from './chrome-mock';
|
||||
|
||||
// Drain pending microtasks (finalize() chains several awaits). setImmediate is
|
||||
// never mocked here, so it fires after the current microtask queue is empty.
|
||||
const flush = () => new Promise(r => setImmediate(r));
|
||||
|
||||
function makeJobs(specs) {
|
||||
const map = new Map();
|
||||
for (const s of specs) map.set(s.id, { __timer: null, ...s });
|
||||
return map;
|
||||
}
|
||||
|
||||
test('pruneFinishedJobs: keeps everything when under the cap', () => {
|
||||
const jobs = makeJobs([
|
||||
{ id: 'a', status: 'done', finishedAt: 1 },
|
||||
{ id: 'b', status: 'done', finishedAt: 2 },
|
||||
]);
|
||||
const evicted = pruneFinishedJobs(jobs, 5);
|
||||
assert.deepEqual(evicted, []);
|
||||
assert.equal(jobs.size, 2);
|
||||
});
|
||||
|
||||
test('pruneFinishedJobs: evicts the oldest finished jobs beyond the cap', () => {
|
||||
const jobs = makeJobs([
|
||||
{ id: 'old', status: 'done', finishedAt: 10 },
|
||||
{ id: 'mid', status: 'error', finishedAt: 20 },
|
||||
{ id: 'new', status: 'done', finishedAt: 30 },
|
||||
]);
|
||||
const evicted = pruneFinishedJobs(jobs, 2);
|
||||
assert.deepEqual(evicted, ['old']);
|
||||
assert.deepEqual([...jobs.keys()], ['mid', 'new']);
|
||||
});
|
||||
|
||||
test('pruneFinishedJobs: never evicts running jobs, even past the cap', () => {
|
||||
const jobs = makeJobs([
|
||||
{ id: 'run1', status: 'running' },
|
||||
{ id: 'run2', status: 'running' },
|
||||
{ id: 'f1', status: 'done', finishedAt: 1 },
|
||||
{ id: 'f2', status: 'done', finishedAt: 2 },
|
||||
{ id: 'f3', status: 'done', finishedAt: 3 },
|
||||
]);
|
||||
pruneFinishedJobs(jobs, 1);
|
||||
// both running kept; only newest finished (f3) kept
|
||||
assert.deepEqual([...jobs.keys()].sort(), ['f3', 'run1', 'run2']);
|
||||
});
|
||||
|
||||
test('pruneFinishedJobs: clears the timer of every evicted job (no interval leak)', () => {
|
||||
const clear = mock.fn();
|
||||
const jobs = makeJobs([
|
||||
{ id: 'a', status: 'done', finishedAt: 1, __timer: 101 },
|
||||
{ id: 'b', status: 'done', finishedAt: 2, __timer: 102 },
|
||||
{ id: 'c', status: 'done', finishedAt: 3, __timer: 103 },
|
||||
]);
|
||||
pruneFinishedJobs(jobs, 1, clear);
|
||||
const clearedArgs = clear.mock.calls.map(c => c.arguments[0]);
|
||||
assert.ok(clearedArgs.includes(101));
|
||||
assert.ok(clearedArgs.includes(102));
|
||||
assert.ok(!clearedArgs.includes(103)); // surviving job's timer untouched
|
||||
});
|
||||
|
||||
test('pruneFinishedJobs: treats missing finishedAt as oldest (evicted first)', () => {
|
||||
const jobs = makeJobs([
|
||||
{ id: 'nofin', status: 'done' },
|
||||
{ id: 'has', status: 'done', finishedAt: 5 },
|
||||
]);
|
||||
pruneFinishedJobs(jobs, 1);
|
||||
assert.deepEqual([...jobs.keys()], ['has']);
|
||||
});
|
||||
|
||||
test('pruneFinishedJobs: defaults to MAX_FINISHED_JOBS and bounds the retained set', () => {
|
||||
const specs = Array.from({ length: MAX_FINISHED_JOBS + 25 }, (_, i) => ({
|
||||
id: `j${i}`, status: 'done', finishedAt: i,
|
||||
}));
|
||||
const jobs = makeJobs(specs);
|
||||
pruneFinishedJobs(jobs);
|
||||
assert.equal(jobs.size, MAX_FINISHED_JOBS);
|
||||
// the 25 oldest (j0..j24) are gone; newest survive
|
||||
assert.equal(jobs.has('j0'), false);
|
||||
assert.equal(jobs.has('j24'), false);
|
||||
assert.equal(jobs.has('j25'), true);
|
||||
});
|
||||
|
||||
test('JobManager: a resolving runner finishes the job and clears its timers', async () => {
|
||||
globalThis.chrome = makeChromeMock();
|
||||
const mgr = new JobManager();
|
||||
const { jobId } = await mgr.start('demo', {}, async () => ({ ok: 1 }));
|
||||
await flush();
|
||||
const job = await mgr.status({ jobId });
|
||||
assert.equal(job.status, 'done');
|
||||
assert.deepEqual(job.result, { ok: 1 });
|
||||
assert.equal(job.percent, 100);
|
||||
assert.equal(job.__timer ?? null, null);
|
||||
assert.equal(job.__watchdog ?? null, null);
|
||||
});
|
||||
|
||||
test('JobManager: watchdog finalizes a hung runner and stops the persist timer', async () => {
|
||||
mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
|
||||
globalThis.chrome = makeChromeMock();
|
||||
const mgr = new JobManager();
|
||||
// Runner never settles — only the watchdog can finish this job.
|
||||
const { jobId } = await mgr.start('hang', {}, () => new Promise(() => {}));
|
||||
mock.timers.tick(JOB_TIMEOUT_MS);
|
||||
await flush();
|
||||
const job = await mgr.status({ jobId });
|
||||
assert.equal(job.status, 'error');
|
||||
assert.match(job.error, /timed out/);
|
||||
assert.equal(job.cancelRequested, true);
|
||||
assert.equal(job.__timer ?? null, null);
|
||||
mock.timers.reset();
|
||||
});
|
||||
|
||||
test('JobManager: a runner that settles after the watchdog cannot resurrect the job', async () => {
|
||||
mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
|
||||
globalThis.chrome = makeChromeMock();
|
||||
const mgr = new JobManager();
|
||||
let settle;
|
||||
const { jobId } = await mgr.start('late', {}, () => new Promise(r => { settle = r; }));
|
||||
mock.timers.tick(JOB_TIMEOUT_MS);
|
||||
await flush();
|
||||
settle({ tooLate: true }); // runner resolves only after the watchdog fired
|
||||
await flush();
|
||||
const job = await mgr.status({ jobId });
|
||||
assert.equal(job.status, 'error'); // stayed error — finalize() ran exactly once
|
||||
assert.equal(job.result, null);
|
||||
mock.timers.reset();
|
||||
});
|
||||
|
||||
test('JobManager: persisted set keeps running jobs even past the finished cap', async () => {
|
||||
mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
|
||||
globalThis.chrome = makeChromeMock();
|
||||
const mgr = new JobManager();
|
||||
await mgr.start('runner', {}, () => new Promise(() => {})); // stays running
|
||||
for (let i = 0; i < MAX_FINISHED_JOBS + 5; i++) {
|
||||
await mgr.start(`done${i}`, {}, async () => i);
|
||||
}
|
||||
await flush();
|
||||
const stored = globalThis.chrome.storage.local._store.recentJobs;
|
||||
const running = stored.filter(j => j.status === 'running');
|
||||
assert.equal(running.length, 1, 'the running job must never be evicted from storage');
|
||||
assert.ok(stored.length <= MAX_FINISHED_JOBS + 1, 'finished jobs stay capped');
|
||||
mock.timers.reset();
|
||||
});
|
||||
Generated
+3
-3
@@ -454,9 +454,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chrome": {
|
||||
"version": "0.1.40",
|
||||
"resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.1.40.tgz",
|
||||
"integrity": "sha512-UnfyRAe8ORu9HSuTH0EqyOEUin3JrWW9Nl/gDXezNfTUrfIoxw+WRZgKOxGz0t5BnjbfXBnS2eCYfW2PxH1wcA==",
|
||||
"version": "0.1.43",
|
||||
"resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.1.43.tgz",
|
||||
"integrity": "sha512-ukH/HhmR6ht+UTX3PLUWJxgJ/RQcK2Foj4lBzsF24SIWsXgqhGuXqjd8FFuwioPP7d/JUKLM4g8GZxw3F4HTcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+3
-1
@@ -4,7 +4,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:extension": "esbuild extension/src/index.ts --bundle --format=iife --target=chrome120 --outfile=extension/background.js",
|
||||
"check:extension": "tsc -p tsconfig.json --noEmit && npm run build:extension && node --check extension/background.js"
|
||||
"build:tests": "esbuild extension/test/*.test.ts --bundle --format=esm --platform=node --outdir=extension/test-dist --out-extension:.js=.mjs",
|
||||
"test:extension": "npm run build:tests && node --test extension/test-dist/*.test.mjs",
|
||||
"check:extension": "tsc -p tsconfig.json --noEmit && npm run build:extension && node --check extension/background.js && npm run test:extension"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.1.40",
|
||||
|
||||
@@ -3,16 +3,20 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
def test_extension_retries_error_page_script_injection_before_failing():
|
||||
core = (ROOT / "extension" / "src" / "core.ts").read_text()
|
||||
# core.ts was split into a core/ subfolder during the structure refactor:
|
||||
# the URL/error classifiers live in core/errors.ts and the executeScript
|
||||
# retry wrapper (which calls isTransientScriptError) lives in core/scripting.ts.
|
||||
errors = (ROOT / "extension" / "src" / "core" / "errors.ts").read_text()
|
||||
scripting = (ROOT / "extension" / "src" / "core" / "scripting.ts").read_text()
|
||||
|
||||
assert "isBrowserErrorUrl" in core
|
||||
assert "isErrorPageScriptError" in core
|
||||
assert "chrome-error://" in core
|
||||
assert "edge-error://" in core
|
||||
assert "brave-error://" in core
|
||||
assert "about:neterror" in core
|
||||
assert "about:certerror" in core
|
||||
assert "isTransientScriptError(e)" in core
|
||||
assert "isBrowserErrorUrl" in errors
|
||||
assert "isErrorPageScriptError" in errors
|
||||
assert "chrome-error://" in errors
|
||||
assert "edge-error://" in errors
|
||||
assert "brave-error://" in errors
|
||||
assert "about:neterror" in errors
|
||||
assert "about:certerror" in errors
|
||||
assert "isTransientScriptError(e)" in scripting
|
||||
|
||||
def test_read_only_dom_commands_have_error_page_fallbacks():
|
||||
dom = (ROOT / "extension" / "src" / "commands" / "dom.ts").read_text()
|
||||
@@ -26,7 +30,9 @@ def test_read_only_dom_commands_have_error_page_fallbacks():
|
||||
assert "isErrorPageScriptError(e)" in dom
|
||||
|
||||
def test_navigation_and_tabs_report_browser_error_pages():
|
||||
tabs = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text()
|
||||
# tabs.watch_url (which reports tab error pages) moved into the read-only
|
||||
# TabsQueryCommands class in tabs-query.ts during the structure refactor.
|
||||
tabs = (ROOT / "extension" / "src" / "commands" / "tabs-query.ts").read_text()
|
||||
navigation = (ROOT / "extension" / "src" / "commands" / "navigation.ts").read_text()
|
||||
|
||||
assert "lastUrl" in tabs
|
||||
@@ -37,7 +43,12 @@ def test_navigation_and_tabs_report_browser_error_pages():
|
||||
assert "showing an error page while waiting for load" in navigation
|
||||
|
||||
def test_large_extension_operations_yield_between_batches():
|
||||
core = (ROOT / "extension" / "src" / "core.ts").read_text()
|
||||
# The large-operation throttling/queue helpers moved from core.ts into
|
||||
# core/throttle.ts when core was split into a subfolder. The slice-based
|
||||
# batch loop (cancel-check -> batch call -> progress -> yield) was then
|
||||
# centralized into the processInBatches() helper in core/throttle.ts, so the
|
||||
# command modules call that instead of inlining the loop.
|
||||
core = (ROOT / "extension" / "src" / "core" / "throttle.ts").read_text()
|
||||
tabs = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text()
|
||||
groups = (ROOT / "extension" / "src" / "commands" / "groups.ts").read_text()
|
||||
session = (ROOT / "extension" / "src" / "commands" / "session.ts").read_text()
|
||||
@@ -56,14 +67,19 @@ def test_large_extension_operations_yield_between_batches():
|
||||
assert "itemCount >= 300" in core
|
||||
assert "itemCount >= 100" in core
|
||||
assert "chrome.tabs.query({ audible: true })" in core
|
||||
# The centralized batch loop drives cancellation + progress + throttled yield.
|
||||
assert "processInBatches" in core
|
||||
assert "throwIfJobCancelled(progress.job)" in core
|
||||
assert "yieldForLargeOperation(done" in core
|
||||
# tabs.close / tabs.merge_windows now batch via processInBatches; tabs.sort
|
||||
# still runs its own per-item move loop with yieldForLargeOperation.
|
||||
assert "processInBatches(toClose" in tabs
|
||||
assert "processInBatches(ids" in tabs
|
||||
assert "yieldForLargeOperation" in tabs
|
||||
assert "toClose.slice" in tabs
|
||||
assert "ids.slice" in tabs
|
||||
assert "w.tabs.every" in tabs
|
||||
assert "getLargeOperationThrottle" in tabs
|
||||
assert "runLargeOperation(\"tabs.sort\"" in tabs
|
||||
assert "yieldForLargeOperation" in groups
|
||||
assert "tabIds.slice" in groups
|
||||
assert "processInBatches(tabIds" in groups
|
||||
assert "getLargeOperationThrottle" in groups
|
||||
assert "runLargeOperation(\"group.close\"" in groups
|
||||
assert "yieldForLargeOperation(createdTabs.length" in session
|
||||
@@ -76,35 +92,50 @@ def test_large_extension_operations_yield_between_batches():
|
||||
assert "throwIfJobCancelled" in session
|
||||
assert "updateJobProgress" in session
|
||||
|
||||
index = (ROOT / "extension" / "src" / "index.ts").read_text()
|
||||
assert "BACKGROUND_COMMANDS" in index
|
||||
assert "startBackgroundJob" in index
|
||||
assert "persistJobs" in index
|
||||
assert "recentJobs" in index
|
||||
assert "jobs.status" in index
|
||||
assert "jobs.cancel" in index
|
||||
assert "perf.status" in index
|
||||
assert "perf.set_profile" in index
|
||||
assert "__background" in index
|
||||
# The background-job machinery moved out of index.ts into the JobManager
|
||||
# (classes/JobManager.ts), the message router (classes/NativeConnection.ts)
|
||||
# and the command registry/groups during the class-based refactor. The
|
||||
# behavior is identical; assert the defining patterns still exist in their
|
||||
# new homes.
|
||||
jobs_module = (ROOT / "extension" / "src" / "classes" / "JobManager.ts").read_text()
|
||||
connection = (ROOT / "extension" / "src" / "classes" / "NativeConnection.ts").read_text()
|
||||
perf = (ROOT / "extension" / "src" / "commands" / "perf.ts").read_text()
|
||||
tabs_module = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text()
|
||||
|
||||
assert "background: true" in tabs_module # background command flag (was BACKGROUND_COMMANDS)
|
||||
assert "persistJobs" in jobs_module
|
||||
assert "recentJobs" in jobs_module
|
||||
assert "jobs.status" in perf
|
||||
assert "jobs.cancel" in perf
|
||||
assert "perf.status" in perf
|
||||
assert "perf.set_profile" in perf
|
||||
assert "__background" in connection
|
||||
|
||||
|
||||
def test_session_autosave_is_debounced_and_non_overlapping():
|
||||
session = (ROOT / "extension" / "src" / "commands" / "session.ts").read_text()
|
||||
# The autosave lifecycle moved out of session.ts into a dedicated
|
||||
# AutoSaveManager (autosave.ts) during the structure refactor; the shared
|
||||
# snapshot/signature helpers live in session-snapshot.ts. Behavior is
|
||||
# identical — the defining patterns just live in their new homes.
|
||||
autosave = (ROOT / "extension" / "src" / "commands" / "autosave.ts").read_text()
|
||||
snapshot = (ROOT / "extension" / "src" / "commands" / "session-snapshot.ts").read_text()
|
||||
|
||||
assert "autoSaveTimer" in session
|
||||
assert "autoSaveInFlight" in session
|
||||
assert "autoSavePending" in session
|
||||
assert "scheduleAutoSave" in session
|
||||
assert "autoSaveUpdatedHandler" in session
|
||||
assert "saveAutoSessionIfChanged" in session
|
||||
assert "sessionSignature" in session
|
||||
assert "autoSaveSignature" in session
|
||||
assert "chrome.tabs.onUpdated.addListener(autoSaveUpdatedHandler)" in session
|
||||
assert "chrome.tabs.onCreated.addListener(autoSaveHandler)" in session
|
||||
assert "chrome.tabs.onMoved.addListener(autoSaveHandler)" in session
|
||||
assert "if (!(\"url\" in changeInfo)) return;" in session
|
||||
assert "setTimeout(runAutoSave, delayMs)" in session
|
||||
assert "clearTimeout(autoSaveTimer)" in session
|
||||
assert "autoSaveTimer" in autosave
|
||||
assert "autoSaveInFlight" in autosave
|
||||
assert "autoSavePending" in autosave
|
||||
assert "scheduleAutoSave" in autosave
|
||||
assert "autoSaveUpdatedHandler" in autosave
|
||||
assert "saveAutoSessionIfChanged" in autosave
|
||||
assert "sessionSignature" in snapshot
|
||||
assert "autoSaveSignature" in autosave
|
||||
# AutoSaveManager binds the handlers as instance fields (this.*), so the
|
||||
# add/removeListener references stay identity-stable across enable/disable.
|
||||
assert "chrome.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler)" in autosave
|
||||
assert "chrome.tabs.onCreated.addListener(this.autoSaveHandler)" in autosave
|
||||
assert "chrome.tabs.onMoved.addListener(this.autoSaveHandler)" in autosave
|
||||
assert "if (!(\"url\" in changeInfo)) return;" in autosave
|
||||
assert "setTimeout(() => this.runAutoSave(), delayMs)" in autosave
|
||||
assert "clearTimeout(this.autoSaveTimer)" in autosave
|
||||
|
||||
|
||||
def test_cli_and_sdk_expose_gentle_restore_controls():
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"types": ["chrome"],
|
||||
"allowJs": false,
|
||||
"strict": false,
|
||||
"noImplicitAny": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user