545abeb515
- Add throttled large-operation handling for tab, group, and session commands. - Introduce performance profiles, audible-tab aware gentle mode, and job progress tracking. - Support background session restores with status/cancel commands and lazy placeholders. - Expose new perf and extension CLI groups plus matching Python SDK methods. - Preserve pinned tabs during session snapshots and debounce auto-save updates. - Bump browser-cli and extension versions to 0.10.0 and add pytest-cov to dev deps. - Add coverage for performance controls, background jobs, lazy restores, and tab metadata.
241 lines
8.0 KiB
TypeScript
241 lines
8.0 KiB
TypeScript
// @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 || {};
|
|
}
|