feat: [WIP]quick panel

This commit is contained in:
hangerye
2026-01-04 10:19:02 +08:00
parent 8b7879d927
commit b99938277b
137 changed files with 20766 additions and 599 deletions
File diff suppressed because it is too large Load Diff
@@ -10,10 +10,18 @@ import { initWebEditorListeners } from './web-editor';
import { initQuickPanelAgentHandler } from './quick-panel/agent-handler';
import { initQuickPanelBookmarksHandler } from './quick-panel/bookmarks-handler';
import { initQuickPanelCommands } from './quick-panel/commands';
import { initQuickPanelContentHandler } from './quick-panel/content-handler';
import { initQuickPanelClipboardHandler } from './quick-panel/clipboard-handler';
import { initQuickPanelFocusHandler } from './quick-panel/focus-handler';
import { initQuickPanelNotesHandler } from './quick-panel/notes-handler';
import { initQuickPanelHistoryHandler } from './quick-panel/history-handler';
import { initQuickPanelMonitorHandler } from './quick-panel/monitor-handler';
import { initQuickPanelApiDetectiveHandler } from './quick-panel/api-detective-handler';
import { initQuickPanelAuditHandler } from './quick-panel/audit-handler';
import { initQuickPanelPageCommandsHandler } from './quick-panel/page-commands-handler';
import { initQuickPanelTabsHandler } from './quick-panel/tabs-handler';
import { initQuickPanelUsageHistoryHandler } from './quick-panel/usage-history-handler';
import { initQuickPanelWorkspacesHandler } from './quick-panel/workspaces-handler';
// Record-Replay V3
import { bootstrapV3 } from './record-replay-v3/bootstrap';
@@ -59,10 +67,26 @@ export default defineBackground(() => {
initQuickPanelBookmarksHandler();
// Quick Panel: history search bridge
initQuickPanelHistoryHandler();
// Quick Panel: content search bridge (cached readable text)
initQuickPanelContentHandler();
// Quick Panel: clipboard history (records Quick Panel copy actions)
initQuickPanelClipboardHandler();
// Quick Panel: focus mode (Pomodoro / Focus)
initQuickPanelFocusHandler();
// Quick Panel: quick notes (local-first)
initQuickPanelNotesHandler();
// Quick Panel: web monitor / price track (optional)
initQuickPanelMonitorHandler();
// Quick Panel: usage history (frecency) store - IndexedDB backend
initQuickPanelUsageHistoryHandler();
// Quick Panel: navigation and page commands
initQuickPanelPageCommandsHandler();
// Quick Panel: API Detective (diagnostics)
initQuickPanelApiDetectiveHandler();
// Quick Panel: audit log (Agent Mode)
initQuickPanelAuditHandler();
// Quick Panel: workspaces (session snapshots)
initQuickPanelWorkspacesHandler();
// Quick Panel: keyboard shortcut handler
initQuickPanelCommands();
@@ -359,7 +359,7 @@ export function connectNativeHost(port: number = NATIVE_HOST.DEFAULT_PORT): bool
} else if (message.type === NativeMessageType.CALL_TOOL && message.requestId) {
const requestId = message.requestId;
try {
const result = await handleCallTool(message.payload);
const result = await handleCallTool(message.payload, { source: 'native_host' });
nativePort?.postMessage({
responseToRequestId: requestId,
payload: {
@@ -490,7 +490,7 @@ export const initNativeHostListener = () => {
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
// Allow UI to call tools directly
if (message && message.type === 'call_tool' && message.name) {
handleCallTool({ name: message.name, args: message.args })
handleCallTool({ name: message.name, args: message.args }, { source: 'extension_ui' })
.then((res) => sendResponse({ success: true, result: res }))
.catch((err) =>
sendResponse({ success: false, error: err instanceof Error ? err.message : String(err) }),
@@ -0,0 +1,465 @@
/**
* Quick Panel API Detective Handler
*
* Background service worker bridge for "API Detective" diagnostics:
* - Start/stop a short-lived network capture session
* - List captured requests and fetch request details
* - Replay a captured request in the originating tab context
*
* Notes:
* - Uses KeepaliveManager to reduce MV3 service worker eviction during capture sessions.
* - Stores the last capture in-memory per tab (best-effort). Reopening the browser or SW restart clears it.
*/
import {
BACKGROUND_MESSAGE_TYPES,
type QuickPanelApiDetectiveBackend,
type QuickPanelApiDetectiveGetRequestMessage,
type QuickPanelApiDetectiveGetRequestResponse,
type QuickPanelApiDetectiveListMessage,
type QuickPanelApiDetectiveListResponse,
type QuickPanelApiDetectiveReplayRequestMessage,
type QuickPanelApiDetectiveReplayRequestResponse,
type QuickPanelApiDetectiveStartMessage,
type QuickPanelApiDetectiveStartResponse,
type QuickPanelApiDetectiveStatusMessage,
type QuickPanelApiDetectiveStatusResponse,
type QuickPanelApiDetectiveStopMessage,
type QuickPanelApiDetectiveStopResponse,
type QuickPanelApiDetectiveRequestDetail,
type QuickPanelApiDetectiveRequestSummary,
} from '@/common/message-types';
import { acquireKeepalive } from '@/entrypoints/background/keepalive-manager';
import { getFirstTextContent } from './devtools-export';
const LOG_PREFIX = '[QuickPanelApiDetective]';
// ============================================================
// Types
// ============================================================
interface ApiDetectiveSession {
backend: QuickPanelApiDetectiveBackend;
startedAt: number;
keepaliveRelease: () => void;
}
interface ApiDetectiveCapture {
backend: QuickPanelApiDetectiveBackend;
capturedAt: number;
tabUrl: string;
items: QuickPanelApiDetectiveRequestSummary[];
byId: Map<string, QuickPanelApiDetectiveRequestDetail>;
}
// ============================================================
// State (best-effort, in-memory)
// ============================================================
const sessionsByTabId = new Map<number, ApiDetectiveSession>();
const lastCaptureByTabId = new Map<number, ApiDetectiveCapture>();
// ============================================================
// Helpers
// ============================================================
function normalizeString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function normalizeBoolean(value: unknown): boolean {
return value === true;
}
function normalizeInt(value: unknown, fallback: number, min: number, max: number): number {
const n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, Math.floor(n)));
}
function safeErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message || String(err);
return String(err);
}
function getTabIdOrError(
sender: chrome.runtime.MessageSender,
): { ok: true; tabId: number } | { ok: false; error: string } {
const tabId = sender.tab?.id;
if (typeof tabId === 'number' && Number.isFinite(tabId) && tabId > 0) return { ok: true, tabId };
return { ok: false, error: 'Quick Panel request must originate from a tab.' };
}
function getRequestBodyPreview(value: unknown, maxLen: number): string | undefined {
if (typeof value !== 'string') return undefined;
const oneLine = value.replace(/\s+/g, ' ').trim();
if (!oneLine) return undefined;
if (oneLine.length <= maxLen) return oneLine;
return `${oneLine.slice(0, Math.max(0, maxLen - 1))}\u2026`;
}
function normalizeHeaders(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object') return {};
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
const key = normalizeString(k).trim();
const val = normalizeString(v).trim();
if (!key || !val) continue;
out[key] = val;
}
return out;
}
function parseNetworkCaptureStopPayload(text: string):
| {
ok: true;
backend: QuickPanelApiDetectiveBackend;
tabUrl: string;
requests: Array<{
requestId: string;
method: string;
url: string;
type?: string;
status?: number;
mimeType?: string;
requestBody?: string;
specificRequestHeaders?: Record<string, string>;
}>;
}
| { ok: false; error: string } {
try {
const parsed = JSON.parse(text) as any;
if (parsed?.success !== true) {
return { ok: false, error: normalizeString(parsed?.message) || 'Network capture failed' };
}
const backendRaw = normalizeString(parsed?.backend);
const backend: QuickPanelApiDetectiveBackend =
backendRaw === 'debugger' ? 'debugger' : 'webRequest';
const tabUrl = normalizeString(parsed?.tabUrl) || normalizeString(parsed?.url) || '';
const requestsRaw = Array.isArray(parsed?.requests) ? parsed.requests : [];
const requests = requestsRaw
.map((r: any) => ({
requestId: normalizeString(r?.requestId),
method: normalizeString(r?.method).toUpperCase() || 'GET',
url: normalizeString(r?.url),
type: normalizeString(r?.type) || undefined,
status:
typeof r?.status === 'number' && Number.isFinite(r.status)
? r.status
: typeof r?.statusCode === 'number' && Number.isFinite(r.statusCode)
? r.statusCode
: undefined,
mimeType: normalizeString(r?.mimeType) || undefined,
requestBody: typeof r?.requestBody === 'string' ? r.requestBody : undefined,
specificRequestHeaders: normalizeHeaders(r?.specificRequestHeaders),
}))
.filter((r: any) => r.requestId && r.url);
return { ok: true, backend, tabUrl, requests };
} catch (err) {
return { ok: false, error: safeErrorMessage(err) || 'Failed to parse network capture output' };
}
}
// ============================================================
// Handlers
// ============================================================
async function handleStatus(
_message: QuickPanelApiDetectiveStatusMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelApiDetectiveStatusResponse> {
const tab = getTabIdOrError(sender);
if (!tab.ok) return { success: false, error: tab.error };
const session = sessionsByTabId.get(tab.tabId) ?? null;
const last = lastCaptureByTabId.get(tab.tabId) ?? null;
return {
success: true,
active: !!session,
backend: session?.backend ?? null,
startedAt: session?.startedAt ?? null,
lastCaptureAt: last?.capturedAt ?? null,
lastRequestCount: last?.items?.length ?? 0,
};
}
async function handleStart(
message: QuickPanelApiDetectiveStartMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelApiDetectiveStartResponse> {
const tab = getTabIdOrError(sender);
if (!tab.ok) return { success: false, error: tab.error };
if (sessionsByTabId.has(tab.tabId)) {
return { success: false, error: 'API Detective is already active for this tab.' };
}
const needResponseBody = normalizeBoolean(message.payload?.needResponseBody);
const includeStatic = normalizeBoolean(message.payload?.includeStatic);
const maxCaptureTimeMs = normalizeInt(message.payload?.maxCaptureTimeMs, 180_000, 1_000, 600_000);
const backend: QuickPanelApiDetectiveBackend = needResponseBody ? 'debugger' : 'webRequest';
const releaseKeepalive = acquireKeepalive('quick-panel-api-detective');
const startedAt = Date.now();
try {
const { networkCaptureTool } = await import('../tools/browser');
const res = await networkCaptureTool.execute({
action: 'start',
tabId: tab.tabId,
needResponseBody,
maxCaptureTime: maxCaptureTimeMs,
inactivityTimeout: 0,
includeStatic,
});
if (res?.isError === true) {
releaseKeepalive();
return { success: false, error: getFirstTextContent(res) || 'Failed to start capture.' };
}
sessionsByTabId.set(tab.tabId, { backend, startedAt, keepaliveRelease: releaseKeepalive });
return { success: true, active: true, backend, startedAt };
} catch (err) {
releaseKeepalive();
return { success: false, error: safeErrorMessage(err) || 'Failed to start capture.' };
}
}
async function handleStop(
_message: QuickPanelApiDetectiveStopMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelApiDetectiveStopResponse> {
const tab = getTabIdOrError(sender);
if (!tab.ok) return { success: false, error: tab.error };
const session = sessionsByTabId.get(tab.tabId);
if (!session) return { success: false, error: 'API Detective is not active for this tab.' };
const capturedAt = Date.now();
try {
const { networkCaptureTool } = await import('../tools/browser');
const stopRes = await networkCaptureTool.execute({
action: 'stop',
tabId: tab.tabId,
needResponseBody: session.backend === 'debugger',
});
if (stopRes?.isError === true) {
return { success: false, error: getFirstTextContent(stopRes) || 'Failed to stop capture.' };
}
const text = getFirstTextContent(stopRes);
if (!text) return { success: false, error: 'Network capture returned no output.' };
const parsed = parseNetworkCaptureStopPayload(text);
if (!parsed.ok) return { success: false, error: parsed.error };
const items: QuickPanelApiDetectiveRequestSummary[] = [];
const byId = new Map<string, QuickPanelApiDetectiveRequestDetail>();
for (const req of parsed.requests) {
const requestId = req.requestId;
const summary: QuickPanelApiDetectiveRequestSummary = {
requestId,
method: req.method,
url: req.url,
type: req.type,
status: req.status,
mimeType: req.mimeType,
requestBodyPreview: getRequestBodyPreview(req.requestBody, 160),
};
items.push(summary);
byId.set(requestId, {
requestId,
method: req.method,
url: req.url,
type: req.type,
status: req.status,
mimeType: req.mimeType,
requestHeaders: req.specificRequestHeaders ?? {},
requestBody: req.requestBody,
});
}
lastCaptureByTabId.set(tab.tabId, {
backend: session.backend,
capturedAt,
tabUrl: parsed.tabUrl,
items,
byId,
});
return {
success: true,
active: false,
backend: session.backend,
capturedAt,
requestCount: items.length,
};
} catch (err) {
return { success: false, error: safeErrorMessage(err) || 'Failed to stop capture.' };
} finally {
try {
session.keepaliveRelease();
} catch {
// Best-effort
}
sessionsByTabId.delete(tab.tabId);
}
}
async function handleList(
message: QuickPanelApiDetectiveListMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelApiDetectiveListResponse> {
const tab = getTabIdOrError(sender);
if (!tab.ok) return { success: false, error: tab.error };
const session = sessionsByTabId.get(tab.tabId) ?? null;
const last = lastCaptureByTabId.get(tab.tabId) ?? null;
const active = !!session;
const backend = session?.backend ?? last?.backend ?? null;
const capturedAt = last?.capturedAt ?? null;
const tabUrl = last?.tabUrl ?? null;
const rawQuery = normalizeString(message.payload?.query).trim().toLowerCase();
const maxResults = normalizeInt(message.payload?.maxResults, 50, 1, 200);
let items = last?.items ?? [];
if (rawQuery) {
items = items.filter((it) => {
const haystack = `${it.method} ${it.url} ${it.type || ''} ${it.mimeType || ''}`.toLowerCase();
return haystack.includes(rawQuery);
});
}
return {
success: true,
active,
backend,
capturedAt,
tabUrl,
items: items.slice(0, maxResults),
};
}
async function handleGetRequest(
message: QuickPanelApiDetectiveGetRequestMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelApiDetectiveGetRequestResponse> {
const tab = getTabIdOrError(sender);
if (!tab.ok) return { success: false, error: tab.error };
const requestId = normalizeString(message.payload?.requestId).trim();
if (!requestId) return { success: false, error: 'Invalid requestId' };
const last = lastCaptureByTabId.get(tab.tabId);
if (!last) return { success: false, error: 'No captured requests found for this tab.' };
const req = last.byId.get(requestId);
if (!req) return { success: false, error: 'Request not found in last capture.' };
return { success: true, request: req };
}
async function handleReplayRequest(
message: QuickPanelApiDetectiveReplayRequestMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelApiDetectiveReplayRequestResponse> {
const tab = getTabIdOrError(sender);
if (!tab.ok) return { success: false, error: tab.error };
const requestId = normalizeString(message.payload?.requestId).trim();
if (!requestId) return { success: false, error: 'Invalid requestId' };
const last = lastCaptureByTabId.get(tab.tabId);
if (!last) return { success: false, error: 'No captured requests found for this tab.' };
const req = last.byId.get(requestId);
if (!req) return { success: false, error: 'Request not found in last capture.' };
try {
const timeoutMs = normalizeInt(message.payload?.timeoutMs, 30_000, 1_000, 120_000);
const { networkRequestTool } = await import('../tools/browser');
const res = await networkRequestTool.execute({
tabId: tab.tabId,
url: req.url,
method: req.method,
headers: req.requestHeaders,
body: req.requestBody,
timeout: timeoutMs,
});
if (res?.isError === true) {
return { success: false, error: getFirstTextContent(res) || 'Request replay failed.' };
}
const text = getFirstTextContent(res);
if (!text) return { success: true, result: null };
try {
return { success: true, result: JSON.parse(text) };
} catch {
return { success: true, result: text };
}
} catch (err) {
return { success: false, error: safeErrorMessage(err) || 'Request replay failed.' };
}
}
// ============================================================
// Initialization
// ============================================================
let initialized = false;
export function initQuickPanelApiDetectiveHandler(): void {
if (initialized) return;
initialized = true;
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_STATUS) {
handleStatus(message as QuickPanelApiDetectiveStatusMessage, sender).then(sendResponse);
return true;
}
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_START) {
handleStart(message as QuickPanelApiDetectiveStartMessage, sender).then(sendResponse);
return true;
}
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_STOP) {
handleStop(message as QuickPanelApiDetectiveStopMessage, sender).then(sendResponse);
return true;
}
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_LIST) {
handleList(message as QuickPanelApiDetectiveListMessage, sender).then(sendResponse);
return true;
}
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_GET_REQUEST) {
handleGetRequest(message as QuickPanelApiDetectiveGetRequestMessage, sender).then(
sendResponse,
);
return true;
}
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_REPLAY_REQUEST) {
handleReplayRequest(message as QuickPanelApiDetectiveReplayRequestMessage, sender).then(
sendResponse,
);
return true;
}
return false;
});
console.debug(`${LOG_PREFIX} Initialized`);
}
@@ -0,0 +1,87 @@
import {
BACKGROUND_MESSAGE_TYPES,
type QuickPanelAuditLogClearMessage,
type QuickPanelAuditLogClearResponse,
type QuickPanelAuditLogEntry,
type QuickPanelAuditLogListMessage,
type QuickPanelAuditLogListResponse,
} from '@/common/message-types';
import {
clearToolActionLog,
listToolActionLogEntries,
} from '@/entrypoints/background/tools/tool-action-log';
let initialized = false;
function safeErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message || String(err);
return String(err);
}
function mapEntry(entry: any): QuickPanelAuditLogEntry {
return {
id: entry.id,
toolName: entry.toolName,
toolDescription: entry.toolDescription || undefined,
riskLevel: entry.risk?.level || 'high',
riskCategories: Array.isArray(entry.risk?.categories) ? entry.risk.categories : [],
source: entry.source,
incognito: entry.incognito === true,
status: entry.status,
startedAt: entry.startedAt,
finishedAt: entry.finishedAt,
durationMs: entry.durationMs,
argsSummary: entry.argsSummary,
resultSummary: entry.resultSummary,
};
}
async function handleList(
message: QuickPanelAuditLogListMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelAuditLogListResponse> {
try {
// TS types do not currently expose sender.incognito, but Chrome provides it at runtime.
const incognito = (sender as any)?.incognito === true;
const query = typeof message.payload?.query === 'string' ? message.payload.query : undefined;
const maxResults =
typeof message.payload?.maxResults === 'number' ? message.payload.maxResults : undefined;
const entries = await listToolActionLogEntries({ incognito, query, maxResults });
return { success: true, entries: entries.map(mapEntry) };
} catch (err) {
return { success: false, error: safeErrorMessage(err) || 'Failed to list audit log' };
}
}
async function handleClear(
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelAuditLogClearResponse> {
try {
// TS types do not currently expose sender.incognito, but Chrome provides it at runtime.
const incognito = (sender as any)?.incognito === true;
await clearToolActionLog({ incognito });
return { success: true };
} catch (err) {
return { success: false, error: safeErrorMessage(err) || 'Failed to clear audit log' };
}
}
export function initQuickPanelAuditHandler(): void {
if (initialized) return;
initialized = true;
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_AUDIT_LOG_LIST) {
handleList(message as QuickPanelAuditLogListMessage, sender).then(sendResponse);
return true;
}
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_AUDIT_LOG_CLEAR) {
handleClear(sender).then(sendResponse);
return true;
}
return false;
});
}
@@ -7,6 +7,8 @@
import {
BACKGROUND_MESSAGE_TYPES,
type QuickPanelBookmarkRemoveMessage,
type QuickPanelBookmarkRemoveResponse,
type QuickPanelBookmarksQueryMessage,
type QuickPanelBookmarksQueryResponse,
type QuickPanelBookmarkSummary,
@@ -83,6 +85,36 @@ async function handleBookmarksQuery(
}
}
async function handleBookmarkRemove(
message: QuickPanelBookmarkRemoveMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelBookmarkRemoveResponse> {
try {
// Validate sender
if (!sender.tab?.id) {
return { success: false, error: 'Quick Panel request must originate from a tab.' };
}
const bookmarkId = normalizeString(message.payload?.bookmarkId).trim();
if (!bookmarkId) {
return { success: false, error: 'Invalid bookmarkId' };
}
// Safety: only allow deleting URL bookmarks (not folders).
const nodes = await chrome.bookmarks.get(bookmarkId);
const node = Array.isArray(nodes) ? nodes[0] : null;
if (!node || typeof node.url !== 'string' || !node.url.trim()) {
return { success: false, error: 'Bookmark not found or not a URL bookmark' };
}
await chrome.bookmarks.remove(bookmarkId);
return { success: true };
} catch (err) {
console.warn(`${LOG_PREFIX} Error removing bookmark:`, err);
return { success: false, error: safeErrorMessage(err) || 'Failed to remove bookmark' };
}
}
// ============================================================
// Initialization
// ============================================================
@@ -102,6 +134,10 @@ export function initQuickPanelBookmarksHandler(): void {
handleBookmarksQuery(message as QuickPanelBookmarksQueryMessage, sender).then(sendResponse);
return true; // Will respond asynchronously
}
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_BOOKMARK_REMOVE) {
handleBookmarkRemove(message as QuickPanelBookmarkRemoveMessage, sender).then(sendResponse);
return true; // Will respond asynchronously
}
return false;
});
@@ -0,0 +1,590 @@
/**
* Quick Panel Clipboard History Handler
*
* Background service worker bridge for Clipboard History (Phase 15.1).
*
* Scope:
* - Records ONLY clipboard writes initiated by Quick Panel actions (best-effort).
* - Does NOT read clipboard contents proactively.
*
* Design:
* - Uses chrome.storage.local with a versioned schema.
* - Enforces incognito boundary (no cross-context reads/writes).
* - Applies storage caps to avoid hitting extension storage limits.
*/
import {
BACKGROUND_MESSAGE_TYPES,
type QuickPanelClipboardDeleteMessage,
type QuickPanelClipboardDeleteResponse,
type QuickPanelClipboardGetMessage,
type QuickPanelClipboardGetResponse,
type QuickPanelClipboardItemDetail,
type QuickPanelClipboardItemSummary,
type QuickPanelClipboardListMessage,
type QuickPanelClipboardListResponse,
type QuickPanelClipboardRecordMessage,
type QuickPanelClipboardRecordResponse,
type QuickPanelClipboardSetPinnedMessage,
type QuickPanelClipboardSetPinnedResponse,
} from '@/common/message-types';
const LOG_PREFIX = '[QuickPanelClipboard]';
// ============================================================
// Storage Schema
// ============================================================
const STORAGE_KEY = 'quick_panel_clipboard_v1';
const SCHEMA_VERSION = 1 as const;
// Safety caps (aligned with storage.local practical limits; keep headroom for other features).
const MAX_ITEMS = 200;
const MAX_TOTAL_STORED_BYTES = 3_500_000; // ~3.5MB best-effort budget
const MAX_ITEM_STORED_BYTES = 80_000; // Store up to ~80KB per item
const MAX_ITEM_CHARS_FOR_ENCODING = 200_000; // Avoid huge TextEncoder allocations
const PREVIEW_MAX_LEN = 220;
interface ClipboardItemV1 {
id: string;
createdAt: number;
updatedAt: number;
incognito: boolean;
pinned: boolean;
copyCount: number;
// Metadata (best-effort)
source?: string;
label?: string;
originUrl?: string;
originTitle?: string;
// Content
preview: string;
byteLength: number; // original UTF-8 size (best-effort, may be approximate when oversized)
stored: boolean;
value: string | null; // null when not stored (e.g. too large)
storedByteLength: number; // value byte length when stored
}
interface ClipboardStoreV1 {
version: typeof SCHEMA_VERSION;
updatedAt: number;
items: ClipboardItemV1[];
}
// ============================================================
// Helpers
// ============================================================
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function normalizeString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function normalizeBoolean(value: unknown): boolean {
return value === true;
}
function toFiniteNumber(value: unknown, fallback: number): number {
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : fallback;
}
function clampInt(value: unknown, fallback: number, min: number, max: number): number {
const n = Math.floor(toFiniteNumber(value, fallback));
return Math.max(min, Math.min(max, n));
}
function safeErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message || String(err);
return String(err);
}
function createClipboardId(): string {
try {
const id = crypto?.randomUUID?.();
if (id) return id;
} catch {
// Fallback for environments without crypto.randomUUID
}
return `clip_${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
function isValidTabId(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0;
}
async function resolveSenderIncognito(sender: chrome.runtime.MessageSender): Promise<boolean> {
const tabId = sender.tab?.id;
if (!isValidTabId(tabId)) return false;
try {
const tab = await chrome.tabs.get(tabId);
return tab?.incognito === true;
} catch {
return false;
}
}
function buildPreview(text: string): string {
const oneLine = String(text ?? '')
.replace(/\s+/g, ' ')
.trim();
if (!oneLine) return '';
if (oneLine.length <= PREVIEW_MAX_LEN) return oneLine;
return `${oneLine.slice(0, Math.max(0, PREVIEW_MAX_LEN - 1))}\u2026`;
}
function utf8ByteLength(text: string): number {
try {
return new TextEncoder().encode(text).length;
} catch {
// Fallback approximation: JS strings are UTF-16.
return Math.min(Number.MAX_SAFE_INTEGER, text.length * 2);
}
}
function estimateStoredBytes(item: ClipboardItemV1): number {
// Estimate bytes that materially affect storage usage.
// The exact storage representation is implementation-defined; this is a best-effort guard.
const meta =
utf8ByteLength(item.preview) +
utf8ByteLength(item.source || '') +
utf8ByteLength(item.label || '') +
utf8ByteLength(item.originUrl || '') +
utf8ByteLength(item.originTitle || '');
return item.storedByteLength + meta + 200; // small fixed overhead per item
}
function parseStore(raw: unknown): ClipboardStoreV1 {
if (!isRecord(raw) || raw.version !== SCHEMA_VERSION || !Array.isArray(raw.items)) {
return { version: SCHEMA_VERSION, updatedAt: 0, items: [] };
}
const updatedAt = Math.max(0, toFiniteNumber(raw.updatedAt, 0));
const seen = new Set<string>();
const items: ClipboardItemV1[] = [];
for (const v of raw.items) {
if (!isRecord(v)) continue;
const id = normalizeString(v.id).trim();
if (!id || seen.has(id)) continue;
const createdAt = Math.max(0, toFiniteNumber(v.createdAt, 0));
const itemUpdatedAt = Math.max(0, toFiniteNumber(v.updatedAt, 0));
const incognito = normalizeBoolean(v.incognito);
const pinned = normalizeBoolean(v.pinned);
const copyCount = clampInt(v.copyCount, 1, 1, 10_000);
const preview = buildPreview(normalizeString(v.preview));
if (!preview) continue;
const byteLength = Math.max(0, toFiniteNumber(v.byteLength, preview.length));
const stored = normalizeBoolean(v.stored);
const valueRaw = stored ? normalizeString(v.value) : '';
const value = stored && valueRaw ? valueRaw : null;
const storedByteLength =
stored && value ? Math.max(0, toFiniteNumber(v.storedByteLength, utf8ByteLength(value))) : 0;
items.push({
id,
createdAt: createdAt || itemUpdatedAt || updatedAt || Date.now(),
updatedAt: itemUpdatedAt || createdAt || updatedAt || Date.now(),
incognito,
pinned,
copyCount,
source: normalizeString(v.source).trim() || undefined,
label: normalizeString(v.label).trim() || undefined,
originUrl: normalizeString(v.originUrl).trim() || undefined,
originTitle: normalizeString(v.originTitle).trim() || undefined,
preview,
byteLength,
stored: stored && !!value,
value,
storedByteLength,
});
seen.add(id);
if (items.length >= MAX_ITEMS * 2) break; // safety
}
// Keep pinned first, then most-recent.
items.sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt;
return b.createdAt - a.createdAt;
});
// Clamp size.
if (items.length > MAX_ITEMS) items.length = MAX_ITEMS;
return { version: SCHEMA_VERSION, updatedAt, items };
}
async function readStore(): Promise<ClipboardStoreV1> {
try {
const res = await chrome.storage.local.get([STORAGE_KEY]);
const raw = (res as Record<string, unknown> | undefined)?.[STORAGE_KEY];
return parseStore(raw);
} catch {
return { version: SCHEMA_VERSION, updatedAt: 0, items: [] };
}
}
async function writeStore(store: ClipboardStoreV1): Promise<void> {
await chrome.storage.local.set({ [STORAGE_KEY]: store });
}
let storeMutex: Promise<void> = Promise.resolve();
async function withStoreLock<T>(fn: () => Promise<T>): Promise<T> {
const prev = storeMutex;
let release: (() => void) | null = null;
storeMutex = new Promise<void>((r) => {
release = r;
});
await prev;
try {
return await fn();
} finally {
try {
release?.();
} catch {
// Best-effort
}
}
}
function toSummary(item: ClipboardItemV1): QuickPanelClipboardItemSummary {
return {
id: item.id,
preview: item.preview,
pinned: item.pinned,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
incognito: item.incognito,
source: item.source,
label: item.label,
originUrl: item.originUrl,
originTitle: item.originTitle,
byteLength: item.byteLength,
stored: item.stored,
copyCount: item.copyCount,
};
}
function toDetail(item: ClipboardItemV1): QuickPanelClipboardItemDetail {
return { ...toSummary(item), value: item.value };
}
function matchesQuery(item: ClipboardItemV1, queryLower: string): boolean {
if (!queryLower) return true;
const hay = [
item.preview,
item.source || '',
item.label || '',
item.originUrl || '',
item.originTitle || '',
item.value || '',
]
.join(' ')
.toLowerCase();
return hay.includes(queryLower);
}
function enforceCaps(items: ClipboardItemV1[]): void {
if (items.length > MAX_ITEMS) items.length = MAX_ITEMS;
// Evict oldest unpinned first until within byte budget.
let total = items.reduce((sum, it) => sum + estimateStoredBytes(it), 0);
if (total <= MAX_TOTAL_STORED_BYTES) return;
for (let i = items.length - 1; i >= 0 && total > MAX_TOTAL_STORED_BYTES; i--) {
if (items[i]?.pinned) continue;
total -= estimateStoredBytes(items[i]);
items.splice(i, 1);
}
// If still above budget (e.g., too many pinned or large items), evict from the end.
for (let i = items.length - 1; i >= 0 && total > MAX_TOTAL_STORED_BYTES; i--) {
total -= estimateStoredBytes(items[i]);
items.splice(i, 1);
}
}
function buildStoredValue(text: string): {
stored: boolean;
value: string | null;
byteLength: number;
storedByteLength: number;
} {
const raw = String(text ?? '');
if (!raw.trim()) {
return { stored: false, value: null, byteLength: 0, storedByteLength: 0 };
}
// Avoid allocating very large buffers for obviously large values.
if (raw.length > MAX_ITEM_CHARS_FOR_ENCODING) {
return {
stored: false,
value: null,
byteLength: raw.length, // best-effort
storedByteLength: 0,
};
}
const bytes = utf8ByteLength(raw);
if (bytes > MAX_ITEM_STORED_BYTES) {
return { stored: false, value: null, byteLength: bytes, storedByteLength: 0 };
}
return { stored: true, value: raw, byteLength: bytes, storedByteLength: bytes };
}
// ============================================================
// Handlers
// ============================================================
async function handleRecord(
message: QuickPanelClipboardRecordMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelClipboardRecordResponse> {
try {
const text = normalizeString(message.payload?.text);
if (!text.trim()) return { success: false, error: 'Invalid text' };
const incognito = await resolveSenderIncognito(sender);
const now = Date.now();
return await withStoreLock(async () => {
const store = await readStore();
const source = normalizeString(message.payload?.source).trim() || undefined;
const label = normalizeString(message.payload?.label).trim() || undefined;
const originUrl = normalizeString(message.payload?.originUrl).trim() || undefined;
const originTitle = normalizeString(message.payload?.originTitle).trim() || undefined;
const preview = buildPreview(text);
const built = buildStoredValue(text);
// Dedupe only when full value is stored.
if (built.stored && built.value) {
const existing = store.items.find(
(it) => it.incognito === incognito && it.stored === true && it.value === built.value,
);
if (existing) {
existing.updatedAt = now;
existing.copyCount = clampInt(existing.copyCount + 1, 1, 1, 10_000);
existing.preview = preview;
existing.byteLength = built.byteLength;
existing.storedByteLength = built.storedByteLength;
existing.source = source ?? existing.source;
existing.label = label ?? existing.label;
existing.originUrl = originUrl ?? existing.originUrl;
existing.originTitle = originTitle ?? existing.originTitle;
} else {
store.items.unshift({
id: createClipboardId(),
createdAt: now,
updatedAt: now,
incognito,
pinned: false,
copyCount: 1,
source,
label,
originUrl,
originTitle,
preview,
byteLength: built.byteLength,
stored: true,
value: built.value,
storedByteLength: built.storedByteLength,
});
}
} else {
// Record metadata + preview even when full value cannot be stored.
store.items.unshift({
id: createClipboardId(),
createdAt: now,
updatedAt: now,
incognito,
pinned: false,
copyCount: 1,
source,
label,
originUrl,
originTitle,
preview,
byteLength: built.byteLength || utf8ByteLength(preview),
stored: false,
value: null,
storedByteLength: 0,
});
}
// Normalize ordering + caps.
store.items.sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt;
return b.createdAt - a.createdAt;
});
enforceCaps(store.items);
store.updatedAt = now;
await writeStore(store);
return { success: true };
});
} catch (err) {
return { success: false, error: safeErrorMessage(err) || 'Failed to record clipboard entry' };
}
}
async function handleList(
message: QuickPanelClipboardListMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelClipboardListResponse> {
try {
const incognito = await resolveSenderIncognito(sender);
const rawQuery = normalizeString(message.payload?.query).trim().toLowerCase();
const maxResults = clampInt(message.payload?.maxResults, 50, 1, 200);
const store = await readStore();
const items = store.items
.filter((it) => it.incognito === incognito)
.filter((it) => matchesQuery(it, rawQuery))
.slice(0, maxResults)
.map(toSummary);
return { success: true, items };
} catch (err) {
return { success: false, error: safeErrorMessage(err) || 'Failed to list clipboard history' };
}
}
async function handleGet(
message: QuickPanelClipboardGetMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelClipboardGetResponse> {
try {
const id = normalizeString(message.payload?.id).trim();
if (!id) return { success: false, error: 'Invalid id' };
const incognito = await resolveSenderIncognito(sender);
const store = await readStore();
const item = store.items.find((it) => it.id === id && it.incognito === incognito);
if (!item) return { success: false, error: 'Clipboard item not found' };
return { success: true, item: toDetail(item) };
} catch (err) {
return { success: false, error: safeErrorMessage(err) || 'Failed to get clipboard item' };
}
}
async function handleSetPinned(
message: QuickPanelClipboardSetPinnedMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelClipboardSetPinnedResponse> {
try {
const id = normalizeString(message.payload?.id).trim();
if (!id) return { success: false, error: 'Invalid id' };
const pinned = normalizeBoolean(message.payload?.pinned);
const incognito = await resolveSenderIncognito(sender);
const now = Date.now();
return await withStoreLock(async () => {
const store = await readStore();
const item = store.items.find((it) => it.id === id && it.incognito === incognito);
if (!item) return { success: false, error: 'Clipboard item not found' };
item.pinned = pinned;
item.updatedAt = now;
store.items.sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt;
return b.createdAt - a.createdAt;
});
store.updatedAt = now;
await writeStore(store);
return { success: true };
});
} catch (err) {
return { success: false, error: safeErrorMessage(err) || 'Failed to update pin state' };
}
}
async function handleDelete(
message: QuickPanelClipboardDeleteMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelClipboardDeleteResponse> {
try {
const id = normalizeString(message.payload?.id).trim();
if (!id) return { success: false, error: 'Invalid id' };
const incognito = await resolveSenderIncognito(sender);
const now = Date.now();
return await withStoreLock(async () => {
const store = await readStore();
const before = store.items.length;
store.items = store.items.filter((it) => !(it.id === id && it.incognito === incognito));
if (store.items.length === before)
return { success: false, error: 'Clipboard item not found' };
store.updatedAt = now;
await writeStore(store);
return { success: true };
});
} catch (err) {
return { success: false, error: safeErrorMessage(err) || 'Failed to delete clipboard item' };
}
}
// ============================================================
// Initialization
// ============================================================
let initialized = false;
export function initQuickPanelClipboardHandler(): void {
if (initialized) return;
initialized = true;
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CLIPBOARD_RECORD) {
handleRecord(message as QuickPanelClipboardRecordMessage, sender).then(sendResponse);
return true;
}
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CLIPBOARD_LIST) {
handleList(message as QuickPanelClipboardListMessage, sender).then(sendResponse);
return true;
}
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CLIPBOARD_GET) {
handleGet(message as QuickPanelClipboardGetMessage, sender).then(sendResponse);
return true;
}
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CLIPBOARD_SET_PINNED) {
handleSetPinned(message as QuickPanelClipboardSetPinnedMessage, sender).then(sendResponse);
return true;
}
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CLIPBOARD_DELETE) {
handleDelete(message as QuickPanelClipboardDeleteMessage, sender).then(sendResponse);
return true;
}
return false;
});
console.debug(`${LOG_PREFIX} Initialized`);
}
@@ -0,0 +1,586 @@
/**
* Quick Panel Content Search Handler
*
* Background service worker bridge for "Content" provider:
* - Maintains an in-memory cache of extracted readable text for open tabs
* - Updates cache on page load completion and SPA route changes (best-effort)
* - Serves token-based content search results to content scripts via messaging
*
* Extraction is implemented by reusing the existing injected helper:
* `inject-scripts/web-fetcher-helper.js` (Readability-based text extraction).
*/
import {
BACKGROUND_MESSAGE_TYPES,
TOOL_MESSAGE_TYPES,
type QuickPanelContentMatchSummary,
type QuickPanelContentQueryMessage,
type QuickPanelContentQueryResponse,
} from '@/common/message-types';
import {
createContentSnippet,
scoreTokensAgainstNormalizedText,
} from '@/shared/quick-panel/core/content-search';
import { normalizeSearchQuery } from '@/shared/quick-panel/core/types';
import { normalizeText, normalizeUrl } from '@/shared/quick-panel/core/text-score';
const LOG_PREFIX = '[QuickPanelContent]';
// ============================================================
// Config
// ============================================================
/** Maximum number of results returned for a single query. */
const MAX_RESULTS_LIMIT = 200;
/** Maximum cached content size per tab (characters). */
const MAX_CONTENT_CHARS = 50 * 1024; // 50KB target, best-effort
/** Minimum content length to keep (avoid caching empty/noisy extractions). */
const MIN_CONTENT_CHARS = 20;
/** Max cached tab entries to avoid unbounded memory usage. */
const MAX_CACHE_ENTRIES = 200;
/** Delay before indexing after a navigation signal (ms). */
const INDEX_DEBOUNCE_MS = 1200;
/** Limit concurrent extraction work to avoid overwhelming the page/CPU. */
const INDEX_CONCURRENCY = 2;
/** Session storage key (best-effort persistence within browser session). */
const SESSION_STORAGE_KEY = 'quick_panel_content_cache_v1';
// ============================================================
// Types
// ============================================================
interface StoredContentEntry {
tabId: number;
windowId: number;
url: string;
title: string;
favIconUrl?: string;
content: string;
updatedAt: number;
lastAccessed?: number;
}
interface ContentEntry extends StoredContentEntry {
normalizedTitle: string;
normalizedUrl: string;
normalizedContent: string;
}
// ============================================================
// Helpers
// ============================================================
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function safeErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message || String(err);
return String(err);
}
function normalizeString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function normalizeInt(value: unknown, fallback: number, max: number): number {
const num = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.min(max, Math.max(0, Math.floor(num)));
}
function isValidTabId(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0;
}
function isValidWindowId(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0;
}
function getLastAccessed(tab: chrome.tabs.Tab): number | undefined {
const anyTab = tab as unknown as { lastAccessed?: unknown };
const value = anyTab.lastAccessed;
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function isIndexableUrl(url: string): boolean {
const trimmed = String(url ?? '').trim();
if (!trimmed) return false;
// Only allow http(s) to keep behavior predictable and avoid restricted schemes.
try {
const parsed = new URL(trimmed);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
function truncateContent(text: string): string {
const value = String(text ?? '');
if (value.length <= MAX_CONTENT_CHARS) return value;
return value.slice(0, MAX_CONTENT_CHARS);
}
function toContentEntry(stored: StoredContentEntry): ContentEntry {
return {
...stored,
normalizedTitle: normalizeText(stored.title),
normalizedUrl: normalizeUrl(stored.url),
normalizedContent: normalizeText(stored.content),
};
}
// (scoring + snippet helpers are shared in core/content-search.ts)
// ============================================================
// Cache + Persistence (best-effort)
// ============================================================
const cache = new Map<number, ContentEntry>();
let sessionLoaded = false;
let sessionLoadPromise: Promise<void> | null = null;
let sessionPersistDisabled = false;
let persistTimer: ReturnType<typeof setTimeout> | null = null;
let persistInFlight: Promise<void> | null = null;
let persistDirty = false;
async function ensureSessionLoaded(): Promise<void> {
if (sessionLoaded) return;
if (sessionLoadPromise) return sessionLoadPromise;
sessionLoadPromise = (async () => {
try {
if (!chrome.storage?.session) {
sessionLoaded = true;
return;
}
const stored = (await chrome.storage.session.get([SESSION_STORAGE_KEY])) as Record<
string,
unknown
>;
const rawValue = stored?.[SESSION_STORAGE_KEY];
if (!isRecord(rawValue)) {
sessionLoaded = true;
return;
}
const entries: ContentEntry[] = [];
for (const value of Object.values(rawValue)) {
if (!isRecord(value)) continue;
const tabId = Number((value as any).tabId);
const windowId = Number((value as any).windowId);
const url = normalizeString((value as any).url).trim();
const title = normalizeString((value as any).title).trim();
const content = normalizeString((value as any).content);
const updatedAt = Number((value as any).updatedAt);
if (!isValidTabId(tabId)) continue;
if (!isValidWindowId(windowId)) continue;
if (!url) continue;
if (!content) continue;
if (!Number.isFinite(updatedAt) || updatedAt <= 0) continue;
entries.push(
toContentEntry({
tabId,
windowId,
url,
title,
content: truncateContent(content),
updatedAt,
favIconUrl:
typeof (value as any).favIconUrl === 'string' ? (value as any).favIconUrl : undefined,
lastAccessed:
typeof (value as any).lastAccessed === 'number'
? (value as any).lastAccessed
: undefined,
}),
);
}
// Remove entries for tabs that are no longer open (best-effort).
const openTabs = await chrome.tabs.query({});
const openIds = new Set(openTabs.map((t) => t.id).filter(isValidTabId));
cache.clear();
for (const e of entries) {
if (!openIds.has(e.tabId)) continue;
cache.set(e.tabId, e);
}
} catch (err) {
console.warn(`${LOG_PREFIX} Failed to load session cache:`, err);
} finally {
sessionLoaded = true;
}
})().finally(() => {
sessionLoadPromise = null;
});
return sessionLoadPromise;
}
function scheduleSessionPersist(): void {
if (sessionPersistDisabled) return;
if (!chrome.storage?.session) return;
persistDirty = true;
if (persistTimer) return;
persistTimer = setTimeout(() => {
persistTimer = null;
void flushSessionPersist();
}, 500);
}
async function flushSessionPersist(): Promise<void> {
if (sessionPersistDisabled) return;
if (!chrome.storage?.session) return;
if (persistInFlight) {
await persistInFlight.catch(() => {});
}
if (!persistDirty) return;
persistDirty = false;
const snapshot: Record<string, StoredContentEntry> = {};
for (const e of cache.values()) {
snapshot[String(e.tabId)] = {
tabId: e.tabId,
windowId: e.windowId,
url: e.url,
title: e.title,
favIconUrl: e.favIconUrl,
content: e.content,
updatedAt: e.updatedAt,
lastAccessed: e.lastAccessed,
};
}
persistInFlight = chrome.storage.session
.set({ [SESSION_STORAGE_KEY]: snapshot })
.catch((err) => {
// Avoid noisy logs if storage quota is exceeded; disable further attempts.
sessionPersistDisabled = true;
console.warn(`${LOG_PREFIX} Disabled session persistence due to error:`, err);
})
.finally(() => {
persistInFlight = null;
if (persistDirty) scheduleSessionPersist();
});
await persistInFlight.catch(() => {});
}
function upsertCacheEntry(entry: ContentEntry): void {
cache.set(entry.tabId, entry);
// Enforce max cache size (evict oldest updatedAt).
if (cache.size > MAX_CACHE_ENTRIES) {
let oldest: ContentEntry | null = null;
for (const e of cache.values()) {
if (!oldest || e.updatedAt < oldest.updatedAt) oldest = e;
}
if (oldest) cache.delete(oldest.tabId);
}
scheduleSessionPersist();
}
function removeCacheEntry(tabId: number): void {
if (!cache.delete(tabId)) return;
scheduleSessionPersist();
}
// ============================================================
// Extraction + Indexing Queue
// ============================================================
const inFlightByTabId = new Map<number, Promise<void>>();
const scheduledTimersByTabId = new Map<number, ReturnType<typeof setTimeout>>();
const pendingReindexByTabId = new Set<number>();
let runningIndexTasks = 0;
const indexQueue: Array<() => void> = [];
function pumpIndexQueue(): void {
while (runningIndexTasks < INDEX_CONCURRENCY && indexQueue.length > 0) {
const run = indexQueue.shift();
run?.();
}
}
function enqueueIndexTask(task: () => Promise<void>): Promise<void> {
return new Promise((resolve) => {
indexQueue.push(() => {
runningIndexTasks += 1;
task()
.catch(() => {
// Best-effort: errors are handled inside task
})
.finally(() => {
runningIndexTasks -= 1;
resolve();
pumpIndexQueue();
});
});
pumpIndexQueue();
});
}
async function ensureWebFetcherHelper(tabId: number): Promise<void> {
// Try a lightweight ping first.
try {
const resp = await chrome.tabs.sendMessage(tabId, { action: 'search_tabs_content_ping' });
if (resp && (resp as any).status === 'pong') return;
} catch {
// Fall through to injection
}
await chrome.scripting.executeScript({
target: { tabId },
files: ['inject-scripts/web-fetcher-helper.js'],
});
}
async function extractReadableText(tabId: number): Promise<string | null> {
try {
await ensureWebFetcherHelper(tabId);
const resp = await chrome.tabs.sendMessage(tabId, {
action: TOOL_MESSAGE_TYPES.WEB_FETCHER_GET_TEXT_CONTENT,
});
if (resp && (resp as any).success === true) {
const text = normalizeString((resp as any).textContent);
const truncated = text ? truncateContent(text) : '';
return truncated;
}
return null;
} catch (err) {
// Common for restricted pages; treat as non-indexable.
return null;
}
}
async function indexTab(tabId: number): Promise<void> {
try {
const tab = await chrome.tabs.get(tabId);
if (!tab?.id || !isValidTabId(tab.id)) return;
const url = normalizeString(tab.url).trim();
const title = normalizeString(tab.title).trim();
if (!url || !isIndexableUrl(url)) {
removeCacheEntry(tabId);
return;
}
const content = await extractReadableText(tabId);
if (content === null) {
// Extraction failed: clear stale cache for this URL to avoid wrong matches.
const existing = cache.get(tabId);
if (existing && existing.url !== url) removeCacheEntry(tabId);
return;
}
// Avoid caching empty/noisy content.
if (content.trim().length < MIN_CONTENT_CHARS) {
removeCacheEntry(tabId);
return;
}
const entry: ContentEntry = toContentEntry({
tabId,
windowId: isValidWindowId(tab.windowId) ? tab.windowId : 0,
url,
title,
favIconUrl: typeof tab.favIconUrl === 'string' ? tab.favIconUrl : undefined,
content,
updatedAt: Date.now(),
lastAccessed: getLastAccessed(tab),
});
// If we somehow can't determine a valid windowId, skip caching.
if (!isValidWindowId(entry.windowId)) return;
upsertCacheEntry(entry);
} catch (err) {
console.warn(`${LOG_PREFIX} Failed to index tab ${tabId}:`, err);
}
}
function scheduleIndex(tabId: number, delayMs: number = INDEX_DEBOUNCE_MS): void {
if (!isValidTabId(tabId)) return;
const existingTimer = scheduledTimersByTabId.get(tabId);
if (existingTimer) clearTimeout(existingTimer);
const t = setTimeout(
() => {
scheduledTimersByTabId.delete(tabId);
if (inFlightByTabId.has(tabId)) {
// Don't drop updates while an index job is running; reindex once it finishes.
pendingReindexByTabId.add(tabId);
return;
}
const promise = enqueueIndexTask(() => indexTab(tabId)).finally(() => {
inFlightByTabId.delete(tabId);
if (pendingReindexByTabId.has(tabId)) {
pendingReindexByTabId.delete(tabId);
scheduleIndex(tabId, 200);
}
});
inFlightByTabId.set(tabId, promise);
},
Math.max(0, delayMs),
);
scheduledTimersByTabId.set(tabId, t);
}
// ============================================================
// Query Handler
// ============================================================
async function handleContentQuery(
message: QuickPanelContentQueryMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelContentQueryResponse> {
try {
const senderTabId = sender.tab?.id;
const senderWindowId = sender.tab?.windowId;
if (!isValidTabId(senderTabId)) {
return { success: false, error: 'Quick Panel request must originate from a tab.' };
}
await ensureSessionLoaded();
const query = normalizeString(message.payload?.query).trim();
const maxResults = normalizeInt(message.payload?.maxResults, 20, MAX_RESULTS_LIMIT);
const normalized = normalizeSearchQuery(query);
if (normalized.tokens.length === 0) {
return { success: true, items: [] };
}
const tokens = normalized.tokens;
const now = Date.now();
const currentTabId = senderTabId;
const currentWindow = isValidWindowId(senderWindowId) ? senderWindowId : null;
const scored: Array<{ entry: ContentEntry; score: number }> = [];
for (const entry of cache.values()) {
const contentScore = scoreTokensAgainstNormalizedText(entry.normalizedContent, tokens);
if (contentScore <= 0) continue;
const titleScore = scoreTokensAgainstNormalizedText(entry.normalizedTitle, tokens);
const urlScore = scoreTokensAgainstNormalizedText(entry.normalizedUrl, tokens);
let score = contentScore;
score += titleScore * 0.08; // up to +8
score += urlScore * 0.04; // up to +4
// Recency boost (freshly indexed pages get a small bump)
const refTs =
typeof entry.lastAccessed === 'number' && Number.isFinite(entry.lastAccessed)
? entry.lastAccessed
: entry.updatedAt;
const ageMs = Math.max(0, now - refTs);
const ageHours = ageMs / (1000 * 60 * 60);
const recencyBoost = Math.max(0, Math.min(6, 6 - ageHours));
score += recencyBoost;
// Context boosts (similar to Tabs provider)
if (currentWindow !== null && entry.windowId === currentWindow) score += 10;
if (entry.tabId === currentTabId) score += 15;
scored.push({ entry, score });
}
scored.sort((a, b) => b.score - a.score);
const top = scored.slice(0, maxResults);
const items: QuickPanelContentMatchSummary[] = top.map(({ entry, score }) => ({
tabId: entry.tabId,
windowId: entry.windowId,
url: entry.url,
title: entry.title || entry.url,
favIconUrl: entry.favIconUrl,
snippet: createContentSnippet(entry.content, tokens),
score,
}));
return { success: true, items };
} catch (err) {
console.warn(`${LOG_PREFIX} Error querying content:`, err);
return { success: false, error: safeErrorMessage(err) || 'Failed to query content' };
}
}
// ============================================================
// Initialization
// ============================================================
let initialized = false;
export function initQuickPanelContentHandler(): void {
if (initialized) return;
initialized = true;
// Load session cache in background to reduce first-query latency.
void ensureSessionLoaded();
// Index active tab (best-effort warmup).
chrome.tabs
.query({ active: true, currentWindow: true })
.then((tabs) => {
const tabId = tabs?.[0]?.id;
if (isValidTabId(tabId)) scheduleIndex(tabId, 200);
})
.catch(() => {});
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
if (changeInfo?.status === 'complete') {
scheduleIndex(tabId);
}
});
if (chrome.webNavigation?.onHistoryStateUpdated) {
chrome.webNavigation.onHistoryStateUpdated.addListener((details) => {
// Only top-frame navigations.
if (details?.frameId !== 0) return;
scheduleIndex(details.tabId, 600);
});
}
chrome.tabs.onRemoved.addListener((tabId) => {
const timer = scheduledTimersByTabId.get(tabId);
if (timer) {
clearTimeout(timer);
scheduledTimersByTabId.delete(tabId);
}
pendingReindexByTabId.delete(tabId);
removeCacheEntry(tabId);
});
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CONTENT_QUERY) {
handleContentQuery(message as QuickPanelContentQueryMessage, sender).then(sendResponse);
return true; // Async response
}
return false;
});
console.debug(`${LOG_PREFIX} Initialized`);
}
@@ -0,0 +1,626 @@
/**
* Quick Panel Debug Bundle
*
* Orchestrates a "one-click" diagnostic bundle collection for the current tab.
*
* Output format:
* - A Downloads folder: `quick_panel_debug_bundle_<timestamp>/`
* - Individual artifacts (screenshot/console/network/performance/read_page)
* - A `manifest.json` describing steps, errors, and saved filenames
*
* Notes:
* - Uses KeepaliveManager to reduce MV3 service worker eviction during the run.
* - Implements cancellation via `AbortController` keyed by tabId.
*/
import { acquireKeepalive } from '@/entrypoints/background/keepalive-manager';
import {
getFirstTextContent,
saveBase64ToDownloadsPath,
saveTextToDownloadsPath,
type QuickPanelDownloadInfo,
} from './devtools-export';
const LOG_PREFIX = '[QuickPanelDebugBundle]';
const NETWORK_CAPTURE_DURATION_MS = 10_000;
const PERFORMANCE_TRACE_DURATION_MS = 5_000;
const PERFORMANCE_TRACE_STOP_BUFFER_MS = 500;
class DebugBundleCancelledError extends Error {
constructor(message = 'Debug bundle cancelled') {
super(message);
this.name = 'DebugBundleCancelledError';
}
}
interface DebugBundleStepRecord {
name: string;
success: boolean;
startedAt: number;
endedAt: number;
error?: string;
download?: QuickPanelDownloadInfo;
}
interface DebugBundleSession {
abortController: AbortController;
startedAt: number;
folder: string;
keepaliveRelease: () => void;
}
const sessionsByTabId = new Map<number, DebugBundleSession>();
function normalizeString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function safeErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message || String(err);
return String(err);
}
function createFolderName(now: number): string {
const ts = new Date(now).toISOString().replace(/[:.]/g, '-');
return `quick_panel_debug_bundle_${ts}`;
}
function throwIfAborted(signal: AbortSignal): void {
if (signal.aborted) throw new DebugBundleCancelledError();
}
function sleep(ms: number, signal: AbortSignal): Promise<void> {
const n = typeof ms === 'number' && Number.isFinite(ms) ? Math.max(0, ms) : 0;
if (n === 0) return Promise.resolve();
return new Promise((resolve, reject) => {
const onAbort = () => {
clearTimeout(timer);
reject(new DebugBundleCancelledError());
};
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort);
resolve();
}, n);
if (signal.aborted) {
clearTimeout(timer);
signal.removeEventListener('abort', onAbort);
reject(new DebugBundleCancelledError());
return;
}
signal.addEventListener('abort', onAbort, { once: true });
});
}
function guessImageExtension(mimeType: string): 'png' | 'jpg' | 'webp' | 'bin' {
const mt = mimeType.toLowerCase();
if (mt.includes('png')) return 'png';
if (mt.includes('jpeg') || mt.includes('jpg')) return 'jpg';
if (mt.includes('webp')) return 'webp';
return 'bin';
}
function parseScreenshotBase64(text: string): { base64Data: string; mimeType: string } | null {
try {
const parsed = JSON.parse(text) as { base64Data?: unknown; mimeType?: unknown };
const base64Data = normalizeString(parsed?.base64Data).trim();
const mimeType = normalizeString(parsed?.mimeType).trim() || 'image/jpeg';
if (!base64Data) return null;
return { base64Data, mimeType };
} catch {
return null;
}
}
function parsePerformanceStopDownload(text: string): QuickPanelDownloadInfo | null {
try {
const parsed = JSON.parse(text) as {
success?: unknown;
message?: unknown;
saved?: { downloadId?: unknown; filename?: unknown; fullPath?: unknown };
};
if (parsed?.success !== true) return null;
if (!parsed?.saved) return null;
return {
downloadId: typeof parsed.saved.downloadId === 'number' ? parsed.saved.downloadId : undefined,
filename: typeof parsed.saved.filename === 'string' ? parsed.saved.filename : undefined,
fullPath: typeof parsed.saved.fullPath === 'string' ? parsed.saved.fullPath : undefined,
};
} catch {
return null;
}
}
export function cancelQuickPanelDebugBundle(
tabId: number,
): { success: true } | { success: false; error: string } {
const session = sessionsByTabId.get(tabId);
if (!session) return { success: false, error: 'No active debug bundle for this tab.' };
try {
session.abortController.abort();
return { success: true };
} catch (err) {
return { success: false, error: safeErrorMessage(err) || 'Failed to cancel debug bundle.' };
}
}
export async function createQuickPanelDebugBundle(tab: {
tabId: number;
tabUrl: string;
tabTitle: string;
}): Promise<{
folder: string;
manifest: QuickPanelDownloadInfo;
steps: DebugBundleStepRecord[];
}> {
const existing = sessionsByTabId.get(tab.tabId);
if (existing) {
throw new Error('A debug bundle is already running for this tab.');
}
const startedAt = Date.now();
const folder = createFolderName(startedAt);
const keepaliveRelease = acquireKeepalive('quick-panel-debug-bundle');
const abortController = new AbortController();
sessionsByTabId.set(tab.tabId, { abortController, startedAt, folder, keepaliveRelease });
const steps: DebugBundleStepRecord[] = [];
let networkStarted = false;
let perfStarted = false;
let networkCaptureStartedAt: number | null = null;
let perfStartedAt: number | null = null;
try {
const signal = abortController.signal;
// Allow the Quick Panel overlay to close before collecting UI-dependent artifacts.
await sleep(0, signal);
throwIfAborted(signal);
const {
consoleTool,
networkCaptureTool,
performanceStartTraceTool,
performanceStopTraceTool,
readPageTool,
screenshotTool,
} = await import('../tools/browser');
// 1) Start network capture (webRequest backend).
{
const step: DebugBundleStepRecord = {
name: 'network_capture_start',
success: false,
startedAt: Date.now(),
endedAt: Date.now(),
};
try {
const res = await networkCaptureTool.execute({
action: 'start',
tabId: tab.tabId,
needResponseBody: false,
maxCaptureTime: 0,
inactivityTimeout: 0,
includeStatic: false,
});
if (res?.isError === true) {
throw new Error(getFirstTextContent(res) || 'Failed to start network capture.');
}
networkStarted = true;
networkCaptureStartedAt = Date.now();
step.success = true;
} catch (err) {
step.error = safeErrorMessage(err) || 'Failed to start network capture.';
} finally {
step.endedAt = Date.now();
steps.push(step);
}
}
// 2) Start performance trace (auto-stop).
{
const step: DebugBundleStepRecord = {
name: 'performance_trace_start',
success: false,
startedAt: Date.now(),
endedAt: Date.now(),
};
try {
const res = await performanceStartTraceTool.execute({
tabId: tab.tabId,
autoStop: true,
durationMs: PERFORMANCE_TRACE_DURATION_MS,
});
if (res?.isError === true) {
throw new Error(getFirstTextContent(res) || 'Failed to start performance trace.');
}
const text = getFirstTextContent(res);
if (!text) {
throw new Error('Performance trace start returned no output.');
}
try {
const parsed = JSON.parse(text) as { success?: unknown; message?: unknown };
if (parsed?.success !== true) {
throw new Error(
typeof parsed?.message === 'string'
? parsed.message
: 'Failed to start performance trace.',
);
}
} catch (err) {
throw err instanceof Error ? err : new Error(text);
}
perfStarted = true;
perfStartedAt = Date.now();
step.success = true;
} catch (err) {
step.error = safeErrorMessage(err) || 'Failed to start performance trace.';
} finally {
step.endedAt = Date.now();
steps.push(step);
}
}
// 3) Screenshot (base64) -> Downloads.
{
const step: DebugBundleStepRecord = {
name: 'screenshot',
success: false,
startedAt: Date.now(),
endedAt: Date.now(),
};
try {
throwIfAborted(signal);
const res = await screenshotTool.execute({
tabId: tab.tabId,
fullPage: false,
savePng: false,
storeBase64: true,
background: false,
});
if (res?.isError === true) {
throw new Error(getFirstTextContent(res) || 'Failed to capture screenshot.');
}
const text = getFirstTextContent(res);
if (!text) throw new Error('Screenshot returned no output.');
const parsed = parseScreenshotBase64(text);
if (!parsed) throw new Error('Failed to parse screenshot output.');
const ext = guessImageExtension(parsed.mimeType);
const download = await saveBase64ToDownloadsPath({
base64Data: parsed.base64Data,
filename: `${folder}/screenshot.${ext}`,
mimeType: parsed.mimeType,
});
step.download = download;
step.success = true;
} catch (err) {
step.error = safeErrorMessage(err) || 'Failed to capture screenshot.';
} finally {
step.endedAt = Date.now();
steps.push(step);
}
}
// 4) Console snapshot -> Downloads.
{
const step: DebugBundleStepRecord = {
name: 'console_snapshot',
success: false,
startedAt: Date.now(),
endedAt: Date.now(),
};
try {
throwIfAborted(signal);
const res = await consoleTool.execute({
tabId: tab.tabId,
mode: 'snapshot',
includeExceptions: true,
maxMessages: 200,
});
if (res?.isError === true) {
throw new Error(getFirstTextContent(res) || 'Failed to capture console snapshot.');
}
const text = getFirstTextContent(res);
if (!text) throw new Error('Console tool returned no output.');
const download = await saveTextToDownloadsPath({
text,
filename: `${folder}/console_snapshot.json`,
mimeType: 'application/json',
});
step.download = download;
step.success = true;
} catch (err) {
step.error = safeErrorMessage(err) || 'Failed to capture console snapshot.';
} finally {
step.endedAt = Date.now();
steps.push(step);
}
}
// 5) Console errors -> Downloads.
{
const step: DebugBundleStepRecord = {
name: 'console_errors',
success: false,
startedAt: Date.now(),
endedAt: Date.now(),
};
try {
throwIfAborted(signal);
const res = await consoleTool.execute({
tabId: tab.tabId,
mode: 'snapshot',
includeExceptions: true,
maxMessages: 200,
onlyErrors: true,
});
if (res?.isError === true) {
throw new Error(getFirstTextContent(res) || 'Failed to capture console errors.');
}
const text = getFirstTextContent(res);
if (!text) throw new Error('Console tool returned no output.');
const download = await saveTextToDownloadsPath({
text,
filename: `${folder}/console_errors.json`,
mimeType: 'application/json',
});
step.download = download;
step.success = true;
} catch (err) {
step.error = safeErrorMessage(err) || 'Failed to capture console errors.';
} finally {
step.endedAt = Date.now();
steps.push(step);
}
}
// 6) read_page (interactive) -> Downloads.
{
const step: DebugBundleStepRecord = {
name: 'read_page_interactive',
success: false,
startedAt: Date.now(),
endedAt: Date.now(),
};
try {
throwIfAborted(signal);
const res = await readPageTool.execute({ tabId: tab.tabId, filter: 'interactive' });
if (res?.isError === true) {
throw new Error(getFirstTextContent(res) || 'Failed to read page.');
}
const text = getFirstTextContent(res);
if (!text) throw new Error('read_page returned no output.');
const download = await saveTextToDownloadsPath({
text,
filename: `${folder}/read_page.json`,
mimeType: 'application/json',
});
step.download = download;
step.success = true;
} catch (err) {
step.error = safeErrorMessage(err) || 'Failed to read page.';
} finally {
step.endedAt = Date.now();
steps.push(step);
}
}
// 7) Wait for performance auto-stop window, then stop+save trace to Downloads.
{
const step: DebugBundleStepRecord = {
name: 'performance_trace_stop',
success: false,
startedAt: Date.now(),
endedAt: Date.now(),
};
try {
if (!perfStarted || perfStartedAt === null) {
throw new Error('Skipped: performance trace was not started.');
}
const elapsed = Date.now() - perfStartedAt;
const minWait = PERFORMANCE_TRACE_DURATION_MS + PERFORMANCE_TRACE_STOP_BUFFER_MS;
if (elapsed < minWait) await sleep(minWait - elapsed, signal);
throwIfAborted(signal);
const res = await performanceStopTraceTool.execute({
tabId: tab.tabId,
saveToDownloads: true,
filenamePrefix: `${folder}/performance_trace`,
});
if (res?.isError === true) {
throw new Error(getFirstTextContent(res) || 'Failed to stop performance trace.');
}
const text = getFirstTextContent(res);
if (!text) throw new Error('Performance trace stop returned no output.');
const download = parsePerformanceStopDownload(text);
if (download) step.download = download;
perfStarted = false;
step.success = true;
} catch (err) {
step.error = safeErrorMessage(err) || 'Failed to stop performance trace.';
} finally {
step.endedAt = Date.now();
steps.push(step);
}
}
// 8) Wait for network capture duration, then stop+save.
{
const step: DebugBundleStepRecord = {
name: 'network_capture_stop',
success: false,
startedAt: Date.now(),
endedAt: Date.now(),
};
try {
if (!networkStarted || networkCaptureStartedAt === null) {
throw new Error('Skipped: network capture was not started.');
}
const elapsed = Date.now() - networkCaptureStartedAt;
if (elapsed < NETWORK_CAPTURE_DURATION_MS)
await sleep(NETWORK_CAPTURE_DURATION_MS - elapsed, signal);
throwIfAborted(signal);
const res = await networkCaptureTool.execute({
action: 'stop',
tabId: tab.tabId,
needResponseBody: false,
});
if (res?.isError === true) {
throw new Error(getFirstTextContent(res) || 'Failed to stop network capture.');
}
const text = getFirstTextContent(res);
if (!text) throw new Error('Network capture returned no output.');
const download = await saveTextToDownloadsPath({
text,
filename: `${folder}/network_capture.json`,
mimeType: 'application/json',
});
networkStarted = false;
step.download = download;
step.success = true;
} catch (err) {
step.error = safeErrorMessage(err) || 'Failed to stop network capture.';
} finally {
step.endedAt = Date.now();
steps.push(step);
}
}
// 9) Save manifest as the entry point to the bundle.
{
const step: DebugBundleStepRecord = {
name: 'manifest',
success: false,
startedAt: Date.now(),
endedAt: Date.now(),
};
try {
throwIfAborted(signal);
const manifest = {
schemaVersion: 1,
createdAt: startedAt,
folder,
tabId: tab.tabId,
tabUrl: tab.tabUrl,
tabTitle: tab.tabTitle,
extensionVersion: chrome.runtime?.getManifest?.()?.version,
steps,
};
const text = JSON.stringify(manifest, null, 2);
const download = await saveTextToDownloadsPath({
text,
filename: `${folder}/manifest.json`,
mimeType: 'application/json',
});
step.download = download;
step.success = true;
} catch (err) {
step.error = safeErrorMessage(err) || 'Failed to save manifest.';
throw err;
} finally {
step.endedAt = Date.now();
steps.push(step);
}
}
let manifestStep: DebugBundleStepRecord | undefined;
for (let i = steps.length - 1; i >= 0; i--) {
if (steps[i]?.name === 'manifest') {
manifestStep = steps[i];
break;
}
}
if (!manifestStep?.download) {
throw new Error('Debug bundle completed but manifest download is missing.');
}
return { folder, manifest: manifestStep.download, steps };
} catch (err) {
if (err instanceof DebugBundleCancelledError) {
console.warn(`${LOG_PREFIX} Cancelled for tab ${tab.tabId}`);
throw err;
}
console.warn(`${LOG_PREFIX} Failed for tab ${tab.tabId}:`, err);
throw err;
} finally {
const session = sessionsByTabId.get(tab.tabId);
sessionsByTabId.delete(tab.tabId);
// Best-effort cleanup of long-lived captures.
try {
const { networkCaptureTool } = await import('../tools/browser');
if (networkStarted) {
await networkCaptureTool.execute({
action: 'stop',
tabId: tab.tabId,
needResponseBody: false,
});
}
} catch {
// Best-effort
}
try {
const { performanceStopTraceTool } = await import('../tools/browser');
if (perfStarted) {
await performanceStopTraceTool.execute({
tabId: tab.tabId,
saveToDownloads: false,
filenamePrefix: `${folder}/performance_trace`,
});
}
} catch {
// Best-effort
}
try {
session?.keepaliveRelease?.();
} catch {
// Best-effort
}
}
}
@@ -0,0 +1,124 @@
import type { ToolResult } from '@/common/tool-handler';
export interface QuickPanelDownloadInfo {
downloadId?: number;
filename?: string;
fullPath?: string;
}
function normalizeString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function sanitizeFilenameSegment(value: string): string {
const trimmed = normalizeString(value).trim();
if (!trimmed) return 'quick_panel';
return trimmed.replace(/[^a-z0-9_-]/gi, '_');
}
function sanitizePathSegment(value: string): string {
const trimmed = normalizeString(value).trim();
if (!trimmed || trimmed === '.' || trimmed === '..') return '';
// Allow dots for common file extensions (e.g., ".json", ".png").
return trimmed.replace(/[^a-z0-9._-]/gi, '_');
}
function sanitizeDownloadsRelativePath(value: string): string {
const raw = normalizeString(value).trim().replace(/^\/+/, '');
if (!raw) return 'quick_panel.txt';
const parts = raw
.split('/')
.map((p) => sanitizePathSegment(p))
.filter((p) => p);
// Ensure we always return a file-like path.
if (parts.length === 0) return 'quick_panel.txt';
return parts.join('/');
}
function formatFilename(prefix: string, extension: string): string {
const safePrefix = sanitizeFilenameSegment(prefix);
const safeExt = sanitizeFilenameSegment(extension).replace(/^_+/, '').toLowerCase();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
return `${safePrefix}_${timestamp}.${safeExt || 'txt'}`;
}
export function getFirstTextContent(result: ToolResult | null | undefined): string | null {
const first = result?.content?.[0];
if (!first || first.type !== 'text') return null;
const text = normalizeString(first.text).trim();
return text ? text : null;
}
export async function saveTextToDownloads(options: {
text: string;
filenamePrefix: string;
extension: string;
mimeType: string;
}): Promise<QuickPanelDownloadInfo> {
if (!chrome?.downloads?.download) {
throw new Error('chrome.downloads.download is not available');
}
const text = normalizeString(options.text);
const filename = formatFilename(options.filenamePrefix, options.extension);
const mimeType = normalizeString(options.mimeType).trim() || 'text/plain';
return saveTextToDownloadsPath({ text, filename, mimeType });
}
export async function saveTextToDownloadsPath(options: {
text: string;
filename: string;
mimeType: string;
}): Promise<QuickPanelDownloadInfo> {
if (!chrome?.downloads?.download) {
throw new Error('chrome.downloads.download is not available');
}
const text = normalizeString(options.text);
const filename = sanitizeDownloadsRelativePath(options.filename);
const mimeType = normalizeString(options.mimeType).trim() || 'text/plain';
// Using data URL keeps the flow MV3-friendly (no DOM / no filesystem APIs in service worker).
// 这里选择 data URL 是为了避免引入额外的 offscreen/文件系统依赖,保持 Quick Panel 命令链路简单可审计。
const base64 = btoa(unescape(encodeURIComponent(text)));
const dataUrl = `data:${mimeType};base64,${base64}`;
const downloadId = await chrome.downloads.download({ url: dataUrl, filename, saveAs: false });
try {
await new Promise((r) => setTimeout(r, 100));
const [item] = await chrome.downloads.search({ id: downloadId });
return { downloadId, filename, fullPath: item?.filename };
} catch {
return { downloadId, filename };
}
}
export async function saveBase64ToDownloadsPath(options: {
base64Data: string;
filename: string;
mimeType: string;
}): Promise<QuickPanelDownloadInfo> {
if (!chrome?.downloads?.download) {
throw new Error('chrome.downloads.download is not available');
}
const base64Data = normalizeString(options.base64Data).trim();
const filename = sanitizeDownloadsRelativePath(options.filename);
const mimeType = normalizeString(options.mimeType).trim() || 'application/octet-stream';
const dataUrl = `data:${mimeType};base64,${base64Data}`;
const downloadId = await chrome.downloads.download({ url: dataUrl, filename, saveAs: false });
try {
await new Promise((r) => setTimeout(r, 100));
const [item] = await chrome.downloads.search({ id: downloadId });
return { downloadId, filename, fullPath: item?.filename };
} catch {
return { downloadId, filename };
}
}
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,8 @@
import {
BACKGROUND_MESSAGE_TYPES,
type QuickPanelHistoryDeleteMessage,
type QuickPanelHistoryDeleteResponse,
type QuickPanelHistoryQueryMessage,
type QuickPanelHistoryQueryResponse,
type QuickPanelHistorySummary,
@@ -87,6 +89,29 @@ async function handleHistoryQuery(
}
}
async function handleHistoryDelete(
message: QuickPanelHistoryDeleteMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelHistoryDeleteResponse> {
try {
// Validate sender
if (!sender.tab?.id) {
return { success: false, error: 'Quick Panel request must originate from a tab.' };
}
const url = normalizeString(message.payload?.url).trim();
if (!url) {
return { success: false, error: 'Invalid url' };
}
await chrome.history.deleteUrl({ url });
return { success: true };
} catch (err) {
console.warn(`${LOG_PREFIX} Error deleting history entry:`, err);
return { success: false, error: safeErrorMessage(err) || 'Failed to delete history entry' };
}
}
// ============================================================
// Initialization
// ============================================================
@@ -106,6 +131,10 @@ export function initQuickPanelHistoryHandler(): void {
handleHistoryQuery(message as QuickPanelHistoryQueryMessage, sender).then(sendResponse);
return true; // Will respond asynchronously
}
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_HISTORY_DELETE) {
handleHistoryDelete(message as QuickPanelHistoryDeleteMessage, sender).then(sendResponse);
return true; // Will respond asynchronously
}
return false;
});
@@ -0,0 +1,984 @@
/**
* Quick Panel Web Monitor / Price Track Handler (optional)
*
* Background bridge that manages periodic checks for a (url, selector) target.
*
* Design:
* - Uses chrome.storage.local with a versioned schema.
* - Uses chrome.alarms (periodInMinutes) to schedule checks per monitor.
* - Uses an offscreen document to run fetch + DOMParser extraction.
* - Enforces incognito boundary (no cross-context list/read/write).
* - Surfaces changes as stored alerts (no notifications permission).
*/
import {
BACKGROUND_MESSAGE_TYPES,
MessageTarget,
OFFSCREEN_MESSAGE_TYPES,
type QuickPanelMonitorAlert,
type QuickPanelMonitorAlertDeleteMessage,
type QuickPanelMonitorAlertDeleteResponse,
type QuickPanelMonitorAlertMarkReadMessage,
type QuickPanelMonitorAlertMarkReadResponse,
type QuickPanelMonitorCreateMessage,
type QuickPanelMonitorCreateResponse,
type QuickPanelMonitorExtractorKind,
type QuickPanelMonitorCheckNowMessage,
type QuickPanelMonitorCheckNowResponse,
type QuickPanelMonitorDeleteMessage,
type QuickPanelMonitorDeleteResponse,
type QuickPanelMonitorListMessage,
type QuickPanelMonitorListResponse,
type QuickPanelMonitorSetEnabledMessage,
type QuickPanelMonitorSetEnabledResponse,
type QuickPanelMonitorSummary,
} from '@/common/message-types';
import { offscreenManager } from '@/utils/offscreen-manager';
const LOG_PREFIX = '[QuickPanelMonitor]';
// ============================================================
// Storage Schema
// ============================================================
const STORAGE_KEY = 'quick_panel_monitor_v1';
const SCHEMA_VERSION = 1 as const;
const MAX_MONITORS = 120;
const MAX_ALERTS = 600;
const MIN_INTERVAL_MINUTES = 1;
const MAX_INTERVAL_MINUTES = 7 * 24 * 60; // 7 days guardrail
const MAX_URL_LEN = 2000;
const MAX_SELECTOR_LEN = 500;
const MAX_VALUE_LEN = 50_000;
const PREVIEW_MAX_LEN = 220;
interface MonitorV1 {
id: string;
url: string;
extractor: QuickPanelMonitorExtractorKind;
selector: string;
attribute?: string;
intervalMinutes: number;
enabled: boolean;
incognito: boolean;
createdAt: number;
updatedAt: number;
lastCheckedAt: number;
lastChangedAt: number;
lastValue: string | null;
lastError: string | null;
}
interface AlertV1 {
id: string;
monitorId: string;
incognito: boolean;
createdAt: number;
url: string;
selector: string;
oldValue: string | null;
newValue: string | null;
read: boolean;
}
interface MonitorStoreV1 {
version: typeof SCHEMA_VERSION;
updatedAt: number;
monitors: MonitorV1[];
alerts: AlertV1[];
}
function createEmptyStore(now: number): MonitorStoreV1 {
return { version: SCHEMA_VERSION, updatedAt: now, monitors: [], alerts: [] };
}
// ============================================================
// Helpers
// ============================================================
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function normalizeString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function normalizeBoolean(value: unknown): boolean {
return value === true;
}
function toFiniteNumber(value: unknown, fallback: number): number {
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : fallback;
}
function clampInt(value: unknown, fallback: number, min: number, max: number): number {
const n = Math.floor(toFiniteNumber(value, fallback));
return Math.max(min, Math.min(max, n));
}
function safeErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message || String(err);
return String(err);
}
function createId(prefix: string): string {
try {
const id = crypto?.randomUUID?.();
if (id) return `${prefix}_${id}`;
} catch {
// ignore
}
return `${prefix}_${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
function normalizeUrl(input: unknown): string {
const raw = normalizeString(input).trim();
if (!raw) throw new Error('url is required');
if (raw.length > MAX_URL_LEN) throw new Error('url is too long');
let url: URL;
try {
url = new URL(raw);
} catch {
// Allow hostname without scheme.
url = new URL(`https://${raw}`);
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Only http(s) URLs are supported');
}
url.hash = '';
return url.toString();
}
function normalizeSelector(input: unknown): string {
const selector = normalizeString(input).trim();
if (!selector) throw new Error('selector is required');
if (selector.length > MAX_SELECTOR_LEN) throw new Error('selector is too long');
return selector;
}
function normalizeExtractor(input: unknown): QuickPanelMonitorExtractorKind {
return input === 'selector_attr' ? 'selector_attr' : 'selector_text';
}
function normalizeAttribute(input: unknown): string | undefined {
const attr = normalizeString(input).trim();
if (!attr) return undefined;
if (attr.length > 100) throw new Error('attribute is too long');
return attr;
}
function normalizeIntervalMinutes(input: unknown): number {
return clampInt(input, 15, MIN_INTERVAL_MINUTES, MAX_INTERVAL_MINUTES);
}
function collapseWhitespace(value: string): string {
return String(value ?? '')
.replace(/\s+/g, ' ')
.trim();
}
function buildPreview(text: string): string {
const oneLine = collapseWhitespace(text);
if (!oneLine) return '';
if (oneLine.length <= PREVIEW_MAX_LEN) return oneLine;
return `${oneLine.slice(0, Math.max(0, PREVIEW_MAX_LEN - 1))}\u2026`;
}
function parseStore(raw: unknown, now: number): MonitorStoreV1 {
if (!isRecord(raw) || raw.version !== SCHEMA_VERSION) return createEmptyStore(now);
const updatedAt = Math.max(0, toFiniteNumber(raw.updatedAt, now));
const monitors: MonitorV1[] = [];
const alerts: AlertV1[] = [];
const rawMonitors = Array.isArray(raw.monitors) ? raw.monitors : [];
const seenMonitors = new Set<string>();
for (const m of rawMonitors) {
if (!isRecord(m)) continue;
const id = normalizeString(m.id).trim();
if (!id || seenMonitors.has(id)) continue;
const url = normalizeString(m.url).trim();
const selector = normalizeString(m.selector).trim();
if (!url || !selector) continue;
const extractor = normalizeExtractor(m.extractor);
const attributeRaw = normalizeString(m.attribute).trim();
const attribute = attributeRaw && attributeRaw.length <= 100 ? attributeRaw : undefined;
if (extractor === 'selector_attr' && !attribute) continue;
const intervalMinutes = normalizeIntervalMinutes(m.intervalMinutes);
const enabled = normalizeBoolean(m.enabled);
const incognito = normalizeBoolean(m.incognito);
const createdAt = Math.max(0, toFiniteNumber(m.createdAt, 0)) || updatedAt || now;
const itemUpdatedAt = Math.max(0, toFiniteNumber(m.updatedAt, 0)) || createdAt;
const lastCheckedAt = Math.max(0, toFiniteNumber(m.lastCheckedAt, 0));
const lastChangedAt = Math.max(0, toFiniteNumber(m.lastChangedAt, 0));
const lastValueRaw = normalizeString(m.lastValue);
const lastValue = lastValueRaw ? lastValueRaw.slice(0, MAX_VALUE_LEN) : null;
const lastErrorRaw = normalizeString(m.lastError);
const lastError = lastErrorRaw ? lastErrorRaw.slice(0, 500) : null;
monitors.push({
id,
url,
extractor,
selector,
attribute,
intervalMinutes,
enabled,
incognito,
createdAt,
updatedAt: itemUpdatedAt,
lastCheckedAt,
lastChangedAt,
lastValue,
lastError,
});
seenMonitors.add(id);
if (monitors.length >= MAX_MONITORS * 2) break;
}
const rawAlerts = Array.isArray(raw.alerts) ? raw.alerts : [];
const seenAlerts = new Set<string>();
for (const a of rawAlerts) {
if (!isRecord(a)) continue;
const id = normalizeString(a.id).trim();
if (!id || seenAlerts.has(id)) continue;
const monitorId = normalizeString(a.monitorId).trim();
const url = normalizeString(a.url).trim();
const selector = normalizeString(a.selector).trim();
if (!monitorId || !url || !selector) continue;
const incognito = normalizeBoolean(a.incognito);
const createdAt = Math.max(0, toFiniteNumber(a.createdAt, 0)) || updatedAt || now;
const oldValue = normalizeString(a.oldValue).slice(0, MAX_VALUE_LEN) || null;
const newValue = normalizeString(a.newValue).slice(0, MAX_VALUE_LEN) || null;
const read = normalizeBoolean(a.read);
alerts.push({
id,
monitorId,
incognito,
createdAt,
url,
selector,
oldValue,
newValue,
read,
});
seenAlerts.add(id);
if (alerts.length >= MAX_ALERTS * 2) break;
}
// Trim to caps (keep newest alerts)
alerts.sort((a, b) => b.createdAt - a.createdAt);
if (alerts.length > MAX_ALERTS) alerts.length = MAX_ALERTS;
// Keep monitors stable ordering by updatedAt desc.
monitors.sort((a, b) => b.updatedAt - a.updatedAt);
if (monitors.length > MAX_MONITORS) monitors.length = MAX_MONITORS;
return { version: SCHEMA_VERSION, updatedAt, monitors, alerts };
}
async function readStore(now: number): Promise<MonitorStoreV1> {
try {
const res = await chrome.storage.local.get([STORAGE_KEY]);
const raw = (res as Record<string, unknown> | undefined)?.[STORAGE_KEY];
return parseStore(raw, now);
} catch {
return createEmptyStore(now);
}
}
async function writeStore(store: MonitorStoreV1): Promise<void> {
await chrome.storage.local.set({ [STORAGE_KEY]: store });
}
let storeMutex: Promise<void> = Promise.resolve();
async function withStoreLock<T>(fn: () => Promise<T>): Promise<T> {
const prev = storeMutex;
let release: (() => void) | null = null;
storeMutex = new Promise<void>((r) => {
release = r;
});
await prev;
try {
return await fn();
} finally {
try {
release?.();
} catch {
// Best-effort
}
}
}
function computeUnreadCount(alerts: AlertV1[], incognito: boolean): number {
return alerts.filter((a) => a.incognito === incognito && a.read !== true).length;
}
function computeMonitorUnread(alerts: AlertV1[], monitorId: string, incognito: boolean): number {
let count = 0;
for (const a of alerts) {
if (a.incognito !== incognito) continue;
if (a.monitorId !== monitorId) continue;
if (a.read !== true) count++;
}
return count;
}
function toAlert(alert: AlertV1): QuickPanelMonitorAlert {
return {
id: alert.id,
monitorId: alert.monitorId,
incognito: alert.incognito,
createdAt: alert.createdAt,
url: alert.url,
selector: alert.selector,
oldValue: alert.oldValue,
newValue: alert.newValue,
read: alert.read === true,
};
}
function toMonitorSummary(store: MonitorStoreV1, monitor: MonitorV1): QuickPanelMonitorSummary {
const preview = monitor.lastValue ? buildPreview(monitor.lastValue) : undefined;
const unreadAlerts = computeMonitorUnread(store.alerts, monitor.id, monitor.incognito);
return {
id: monitor.id,
url: monitor.url,
extractor: monitor.extractor,
selector: monitor.selector,
attribute: monitor.attribute,
intervalMinutes: monitor.intervalMinutes,
enabled: monitor.enabled === true,
incognito: monitor.incognito === true,
createdAt: monitor.createdAt,
updatedAt: monitor.updatedAt,
lastCheckedAt: monitor.lastCheckedAt,
lastChangedAt: monitor.lastChangedAt,
lastValuePreview: preview,
lastError: monitor.lastError || undefined,
unreadAlerts,
};
}
function isValidTabId(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0;
}
async function resolveSenderIncognito(sender: chrome.runtime.MessageSender): Promise<boolean> {
const tabId = sender.tab?.id;
if (!isValidTabId(tabId)) return false;
try {
const tab = await chrome.tabs.get(tabId);
return tab?.incognito === true;
} catch {
return false;
}
}
// ============================================================
// Alarms
// ============================================================
const ALARM_PREFIX = 'qp_monitor_';
function alarmName(monitorId: string): string {
return `${ALARM_PREFIX}${monitorId}`;
}
function parseMonitorIdFromAlarm(name: string): string | null {
if (!name?.startsWith(ALARM_PREFIX)) return null;
const id = name.slice(ALARM_PREFIX.length);
return id ? id : null;
}
async function scheduleMonitorAlarm(monitor: MonitorV1): Promise<void> {
if (!chrome.alarms?.create) return;
if (!monitor.enabled) return;
const periodInMinutes = normalizeIntervalMinutes(monitor.intervalMinutes);
try {
await Promise.resolve(
chrome.alarms.create(alarmName(monitor.id), {
delayInMinutes: periodInMinutes,
periodInMinutes,
}),
);
} catch (err) {
console.warn(`${LOG_PREFIX} alarms.create failed:`, err);
}
}
async function clearMonitorAlarm(monitorId: string): Promise<void> {
if (!chrome.alarms?.clear) return;
try {
await Promise.resolve(chrome.alarms.clear(alarmName(monitorId)));
} catch {
// Best-effort
}
}
async function clearStaleMonitorAlarms(validIds: Set<string>): Promise<void> {
if (!chrome.alarms?.getAll || !chrome.alarms?.clear) return;
try {
const alarms = await Promise.resolve(chrome.alarms.getAll());
const list = Array.isArray(alarms) ? alarms : [];
await Promise.all(
list
.filter((a) => typeof a?.name === 'string' && a.name.startsWith(ALARM_PREFIX))
.map(async (a) => {
const id = parseMonitorIdFromAlarm(a.name);
if (!id) return;
if (validIds.has(id)) return;
await clearMonitorAlarm(id);
}),
);
} catch {
// Best-effort
}
}
// ============================================================
// Offscreen extraction
// ============================================================
interface OffscreenExtractResponse {
success: boolean;
error?: string;
extracted?: string | null;
title?: string | null;
url?: string;
status?: number;
byteLength?: number;
}
async function sendToOffscreen(payload: {
url: string;
extractor: QuickPanelMonitorExtractorKind;
selector: string;
attribute?: string;
}): Promise<OffscreenExtractResponse> {
await offscreenManager.ensureOffscreenDocument();
const resp = (await chrome.runtime.sendMessage({
target: MessageTarget.Offscreen,
type: OFFSCREEN_MESSAGE_TYPES.WEB_MONITOR_FETCH_EXTRACT,
url: payload.url,
extractor: payload.extractor,
selector: payload.selector,
attribute: payload.attribute,
timeoutMs: 12_000,
maxBytes: 2_000_000,
})) as OffscreenExtractResponse | undefined;
return resp ?? { success: false, error: 'No response from offscreen document' };
}
// ============================================================
// Badge (best-effort, non-incognito only)
// ============================================================
async function updateBadgeFromStore(store: MonitorStoreV1): Promise<void> {
if (!chrome.action?.setBadgeText) return;
const unread = store.alerts.filter((a) => a.incognito !== true && a.read !== true).length;
try {
await Promise.resolve(chrome.action.setBadgeBackgroundColor?.({ color: '#DC2626' }));
} catch {
// ignore
}
try {
await Promise.resolve(chrome.action.setBadgeText({ text: unread > 0 ? String(unread) : '' }));
} catch {
// ignore
}
}
// ============================================================
// Check execution (de-duped)
// ============================================================
type CheckOutcome = { monitor: QuickPanelMonitorSummary; alertCreated?: QuickPanelMonitorAlert };
const inFlightChecks = new Map<string, Promise<CheckOutcome>>();
async function runMonitorCheck(monitorId: string): Promise<CheckOutcome> {
const existing = inFlightChecks.get(monitorId);
if (existing) return existing;
const promise = (async (): Promise<CheckOutcome> => {
const now = Date.now();
const snapshot = await withStoreLock(async () => {
const store = await readStore(now);
const monitor = store.monitors.find((m) => m.id === monitorId);
if (!monitor) throw new Error('Monitor not found');
if (!monitor.enabled) throw new Error('Monitor is disabled');
return {
url: monitor.url,
extractor: monitor.extractor,
selector: monitor.selector,
attribute: monitor.attribute,
incognito: monitor.incognito,
previous: monitor.lastValue,
};
});
const extractedResp = await sendToOffscreen({
url: snapshot.url,
extractor: snapshot.extractor,
selector: snapshot.selector,
attribute: snapshot.attribute,
});
const updated = await withStoreLock(async () => {
const store = await readStore(now);
const monitor = store.monitors.find((m) => m.id === monitorId);
if (!monitor) throw new Error('Monitor not found');
if (!monitor.enabled) throw new Error('Monitor is disabled');
monitor.lastCheckedAt = now;
monitor.updatedAt = now;
let alertCreated: AlertV1 | null = null;
if (!extractedResp.success) {
monitor.lastError = extractedResp.error || 'Fetch/extract failed';
} else {
const raw = normalizeString(extractedResp.extracted);
const value = raw ? collapseWhitespace(raw).slice(0, MAX_VALUE_LEN) : '';
if (!value) {
monitor.lastError = 'Extracted value is empty';
} else {
monitor.lastError = null;
const prev = monitor.lastValue;
if (prev === null) {
// Baseline.
monitor.lastValue = value;
} else if (prev !== value) {
// Changed.
monitor.lastValue = value;
monitor.lastChangedAt = now;
alertCreated = {
id: createId('mon_alert'),
monitorId: monitor.id,
incognito: monitor.incognito,
createdAt: now,
url: monitor.url,
selector: monitor.selector,
oldValue: prev,
newValue: value,
read: false,
};
store.alerts.unshift(alertCreated);
}
}
}
// Enforce caps (keep newest alerts).
if (store.alerts.length > MAX_ALERTS) store.alerts.length = MAX_ALERTS;
store.updatedAt = now;
await writeStore(store);
await updateBadgeFromStore(store);
return { store, monitor, alertCreated };
});
return {
monitor: toMonitorSummary(updated.store, updated.monitor),
alertCreated: updated.alertCreated ? toAlert(updated.alertCreated) : undefined,
};
})().finally(() => {
inFlightChecks.delete(monitorId);
});
inFlightChecks.set(monitorId, promise);
return promise;
}
// ============================================================
// Message Handlers
// ============================================================
async function handleList(
message: QuickPanelMonitorListMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelMonitorListResponse> {
try {
const incognito = await resolveSenderIncognito(sender);
const now = Date.now();
const query = normalizeString(message.payload?.query).trim().toLowerCase();
const maxMonitors = clampInt(message.payload?.maxMonitors, 50, 1, 200);
const maxAlerts = clampInt(message.payload?.maxAlerts, 50, 0, 200);
const store = await readStore(now);
const monitors = store.monitors
.filter((m) => m.incognito === incognito)
.filter((m) => {
if (!query) return true;
const hay = `${m.url} ${m.selector} ${m.attribute || ''}`.toLowerCase();
return hay.includes(query);
})
.sort((a, b) => b.updatedAt - a.updatedAt)
.slice(0, maxMonitors)
.map((m) => toMonitorSummary(store, m));
const alerts = store.alerts
.filter((a) => a.incognito === incognito)
.filter((a) => {
if (!query) return true;
const hay = `${a.url} ${a.selector} ${(a.newValue || '').slice(0, 200)}`.toLowerCase();
return hay.includes(query);
})
.sort((a, b) => {
if (a.read !== b.read) return a.read ? 1 : -1;
return b.createdAt - a.createdAt;
})
.slice(0, maxAlerts)
.map(toAlert);
const unreadCount = computeUnreadCount(store.alerts, incognito);
return { success: true, monitors, alerts, unreadCount };
} catch (err) {
return { success: false, error: safeErrorMessage(err) || 'Failed to list monitors' };
}
}
async function handleCreate(
message: QuickPanelMonitorCreateMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelMonitorCreateResponse> {
try {
const incognito = await resolveSenderIncognito(sender);
const now = Date.now();
const url = normalizeUrl(message.payload?.url);
const selector = normalizeSelector(message.payload?.selector);
const extractor = normalizeExtractor(message.payload?.extractor);
const attribute = normalizeAttribute(message.payload?.attribute);
const intervalMinutes = normalizeIntervalMinutes(message.payload?.intervalMinutes);
const fetchNow = message.payload?.fetchNow !== false;
if (extractor === 'selector_attr' && !attribute) {
throw new Error('attribute is required for selector_attr');
}
const monitor: MonitorV1 = {
id: createId('mon'),
url,
extractor,
selector,
attribute,
intervalMinutes,
enabled: true,
incognito,
createdAt: now,
updatedAt: now,
lastCheckedAt: 0,
lastChangedAt: 0,
lastValue: null,
lastError: null,
};
await withStoreLock(async () => {
const store = await readStore(now);
// Prevent duplicates within the same context.
const exists = store.monitors.some(
(m) =>
m.incognito === incognito &&
m.url === monitor.url &&
m.selector === monitor.selector &&
m.extractor === monitor.extractor &&
(m.attribute || '') === (monitor.attribute || ''),
);
if (exists) {
throw new Error('Monitor already exists for this target');
}
if (store.monitors.length >= MAX_MONITORS) {
throw new Error('Too many monitors (limit reached)');
}
store.monitors.unshift(monitor);
store.updatedAt = now;
await writeStore(store);
await scheduleMonitorAlarm(monitor);
});
// Optional baseline fetch.
let createdSummary: QuickPanelMonitorSummary;
if (fetchNow) {
createdSummary = (await runMonitorCheck(monitor.id)).monitor;
} else {
const store = await readStore(now);
createdSummary = toMonitorSummary(store, monitor);
}
return { success: true, monitor: createdSummary };
} catch (err) {
return { success: false, error: safeErrorMessage(err) || 'Failed to create monitor' };
}
}
async function handleDelete(
message: QuickPanelMonitorDeleteMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelMonitorDeleteResponse> {
try {
const incognito = await resolveSenderIncognito(sender);
const now = Date.now();
const id = normalizeString(message.payload?.id).trim();
if (!id) return { success: false, error: 'Invalid id' };
await withStoreLock(async () => {
const store = await readStore(now);
const before = store.monitors.length;
store.monitors = store.monitors.filter((m) => !(m.id === id && m.incognito === incognito));
if (store.monitors.length === before) throw new Error('Monitor not found');
store.alerts = store.alerts.filter((a) => !(a.monitorId === id && a.incognito === incognito));
store.updatedAt = now;
await writeStore(store);
await updateBadgeFromStore(store);
});
await clearMonitorAlarm(id);
return { success: true };
} catch (err) {
return { success: false, error: safeErrorMessage(err) || 'Failed to delete monitor' };
}
}
async function handleSetEnabled(
message: QuickPanelMonitorSetEnabledMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelMonitorSetEnabledResponse> {
try {
const incognito = await resolveSenderIncognito(sender);
const now = Date.now();
const id = normalizeString(message.payload?.id).trim();
if (!id) return { success: false, error: 'Invalid id' };
const enabled = normalizeBoolean(message.payload?.enabled);
const { monitor } = await withStoreLock(async () => {
const store = await readStore(now);
const monitor = store.monitors.find((m) => m.id === id && m.incognito === incognito);
if (!monitor) throw new Error('Monitor not found');
monitor.enabled = enabled;
monitor.updatedAt = now;
store.updatedAt = now;
await writeStore(store);
await updateBadgeFromStore(store);
return { monitor };
});
if (enabled) {
await scheduleMonitorAlarm(monitor);
} else {
await clearMonitorAlarm(id);
}
const storeAfter = await readStore(now);
const updatedMonitor = storeAfter.monitors.find(
(m) => m.id === id && m.incognito === incognito,
);
if (!updatedMonitor) throw new Error('Monitor not found');
return { success: true, monitor: toMonitorSummary(storeAfter, updatedMonitor) };
} catch (err) {
return { success: false, error: safeErrorMessage(err) || 'Failed to update monitor' };
}
}
async function handleCheckNow(
message: QuickPanelMonitorCheckNowMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelMonitorCheckNowResponse> {
try {
const incognito = await resolveSenderIncognito(sender);
const id = normalizeString(message.payload?.id).trim();
if (!id) return { success: false, error: 'Invalid id' };
// Ensure the monitor exists in the same incognito context before running.
await withStoreLock(async () => {
const store = await readStore(Date.now());
const found = store.monitors.some((m) => m.id === id && m.incognito === incognito);
if (!found) throw new Error('Monitor not found');
});
const outcome = await runMonitorCheck(id);
return { success: true, monitor: outcome.monitor, alertCreated: outcome.alertCreated };
} catch (err) {
return { success: false, error: safeErrorMessage(err) || 'Failed to check monitor' };
}
}
async function handleAlertMarkRead(
message: QuickPanelMonitorAlertMarkReadMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelMonitorAlertMarkReadResponse> {
try {
const incognito = await resolveSenderIncognito(sender);
const now = Date.now();
const id = normalizeString(message.payload?.id).trim();
if (!id) return { success: false, error: 'Invalid id' };
const read = normalizeBoolean(message.payload?.read);
const unreadCount = await withStoreLock(async () => {
const store = await readStore(now);
const alert = store.alerts.find((a) => a.id === id && a.incognito === incognito);
if (!alert) throw new Error('Alert not found');
alert.read = read;
store.updatedAt = now;
await writeStore(store);
await updateBadgeFromStore(store);
return computeUnreadCount(store.alerts, incognito);
});
return { success: true, unreadCount };
} catch (err) {
return { success: false, error: safeErrorMessage(err) || 'Failed to update alert' };
}
}
async function handleAlertDelete(
message: QuickPanelMonitorAlertDeleteMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelMonitorAlertDeleteResponse> {
try {
const incognito = await resolveSenderIncognito(sender);
const now = Date.now();
const id = normalizeString(message.payload?.id).trim();
if (!id) return { success: false, error: 'Invalid id' };
const unreadCount = await withStoreLock(async () => {
const store = await readStore(now);
const before = store.alerts.length;
store.alerts = store.alerts.filter((a) => !(a.id === id && a.incognito === incognito));
if (store.alerts.length === before) throw new Error('Alert not found');
store.updatedAt = now;
await writeStore(store);
await updateBadgeFromStore(store);
return computeUnreadCount(store.alerts, incognito);
});
return { success: true, unreadCount };
} catch (err) {
return { success: false, error: safeErrorMessage(err) || 'Failed to delete alert' };
}
}
// ============================================================
// Startup restore
// ============================================================
async function restoreOnStartup(): Promise<void> {
const now = Date.now();
const store = await readStore(now);
const enabledIds = new Set<string>();
for (const m of store.monitors) {
if (!m.enabled) continue;
enabledIds.add(m.id);
await scheduleMonitorAlarm(m);
}
await clearStaleMonitorAlarms(enabledIds);
await updateBadgeFromStore(store);
}
// ============================================================
// Initialization
// ============================================================
let initialized = false;
export function initQuickPanelMonitorHandler(): void {
if (initialized) return;
initialized = true;
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_LIST) {
handleList(message as QuickPanelMonitorListMessage, sender).then(sendResponse);
return true;
}
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_CREATE) {
handleCreate(message as QuickPanelMonitorCreateMessage, sender).then(sendResponse);
return true;
}
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_DELETE) {
handleDelete(message as QuickPanelMonitorDeleteMessage, sender).then(sendResponse);
return true;
}
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_SET_ENABLED) {
handleSetEnabled(message as QuickPanelMonitorSetEnabledMessage, sender).then(sendResponse);
return true;
}
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_CHECK_NOW) {
handleCheckNow(message as QuickPanelMonitorCheckNowMessage, sender).then(sendResponse);
return true;
}
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_ALERT_MARK_READ) {
handleAlertMarkRead(message as QuickPanelMonitorAlertMarkReadMessage, sender).then(
sendResponse,
);
return true;
}
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_ALERT_DELETE) {
handleAlertDelete(message as QuickPanelMonitorAlertDeleteMessage, sender).then(sendResponse);
return true;
}
return false;
});
if (chrome.alarms?.onAlarm?.addListener) {
chrome.alarms.onAlarm.addListener((alarm) => {
const monitorId = parseMonitorIdFromAlarm(alarm?.name ?? '');
if (!monitorId) return;
void runMonitorCheck(monitorId).catch((err) => {
console.debug(`${LOG_PREFIX} monitor check failed:`, err);
});
});
}
void restoreOnStartup();
console.debug(`${LOG_PREFIX} Initialized`);
}
@@ -0,0 +1,384 @@
/**
* Quick Panel Notes Handler
*
* Background service worker bridge for Quick Notes (Phase 15.2).
*
* Design:
* - Uses chrome.storage.local with a versioned schema.
* - Enforces incognito boundary (no cross-context reads/writes).
* - Provides list/get/create/delete operations.
*/
import {
BACKGROUND_MESSAGE_TYPES,
type QuickPanelNoteDetail,
type QuickPanelNoteSummary,
type QuickPanelNotesCreateMessage,
type QuickPanelNotesCreateResponse,
type QuickPanelNotesDeleteMessage,
type QuickPanelNotesDeleteResponse,
type QuickPanelNotesGetMessage,
type QuickPanelNotesGetResponse,
type QuickPanelNotesListMessage,
type QuickPanelNotesListResponse,
} from '@/common/message-types';
const LOG_PREFIX = '[QuickPanelNotes]';
// ============================================================
// Storage Schema
// ============================================================
const STORAGE_KEY = 'quick_panel_notes_v1';
const SCHEMA_VERSION = 1 as const;
// Keep caps conservative to reduce risk of hitting storage.local limits.
const MAX_NOTES = 500;
const MAX_NOTE_BYTES = 50_000; // ~50KB per note
const PREVIEW_MAX_LEN = 240;
interface NoteV1 {
id: string;
title: string;
content: string;
preview: string;
createdAt: number;
updatedAt: number;
incognito: boolean;
}
interface NotesStoreV1 {
version: typeof SCHEMA_VERSION;
updatedAt: number;
items: NoteV1[];
}
// ============================================================
// Helpers
// ============================================================
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function normalizeString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function toFiniteNumber(value: unknown, fallback: number): number {
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : fallback;
}
function safeErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message || String(err);
return String(err);
}
function clampInt(value: unknown, fallback: number, min: number, max: number): number {
const n = Math.floor(toFiniteNumber(value, fallback));
return Math.max(min, Math.min(max, n));
}
function utf8ByteLength(text: string): number {
try {
return new TextEncoder().encode(text).length;
} catch {
return Math.min(Number.MAX_SAFE_INTEGER, text.length * 2);
}
}
function buildPreview(text: string): string {
const oneLine = String(text ?? '')
.replace(/\s+/g, ' ')
.trim();
if (!oneLine) return '';
if (oneLine.length <= PREVIEW_MAX_LEN) return oneLine;
return `${oneLine.slice(0, Math.max(0, PREVIEW_MAX_LEN - 1))}\u2026`;
}
function createNoteId(): string {
try {
const id = crypto?.randomUUID?.();
if (id) return id;
} catch {
// ignore
}
return `note_${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
function isValidTabId(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0;
}
async function resolveSenderIncognito(sender: chrome.runtime.MessageSender): Promise<boolean> {
const tabId = sender.tab?.id;
if (!isValidTabId(tabId)) return false;
try {
const tab = await chrome.tabs.get(tabId);
return tab?.incognito === true;
} catch {
return false;
}
}
function normalizeTitle(value: unknown, fallbackFromContent: string, now: number): string {
const raw = normalizeString(value).trim().replace(/\s+/g, ' ');
if (raw) return raw.slice(0, 80);
const fromContent = buildPreview(fallbackFromContent);
if (fromContent) return fromContent.slice(0, 80);
const d = new Date(now);
const pad2 = (n: number) => String(n).padStart(2, '0');
return `Note ${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
}
function toSummary(note: NoteV1): QuickPanelNoteSummary {
return {
id: note.id,
title: note.title,
preview: note.preview,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
incognito: note.incognito,
};
}
function toDetail(note: NoteV1): QuickPanelNoteDetail {
return { ...toSummary(note), content: note.content };
}
function parseStore(raw: unknown): NotesStoreV1 {
if (!isRecord(raw) || raw.version !== SCHEMA_VERSION || !Array.isArray(raw.items)) {
return { version: SCHEMA_VERSION, updatedAt: 0, items: [] };
}
const updatedAt = Math.max(0, toFiniteNumber(raw.updatedAt, 0));
const items: NoteV1[] = [];
const seen = new Set<string>();
for (const v of raw.items) {
if (!isRecord(v)) continue;
const id = normalizeString(v.id).trim();
if (!id || seen.has(id)) continue;
const title = normalizeString(v.title).trim();
const content = normalizeString(v.content);
const preview = buildPreview(normalizeString(v.preview) || content || title);
if (!title || !preview) continue;
const createdAt = Math.max(0, toFiniteNumber(v.createdAt, 0));
const itemUpdatedAt = Math.max(0, toFiniteNumber(v.updatedAt, 0));
const incognito = v.incognito === true;
items.push({
id,
title: title.slice(0, 80),
content,
preview,
createdAt: createdAt || itemUpdatedAt || updatedAt || Date.now(),
updatedAt: itemUpdatedAt || createdAt || updatedAt || Date.now(),
incognito,
});
seen.add(id);
if (items.length >= MAX_NOTES * 2) break;
}
items.sort((a, b) => b.updatedAt - a.updatedAt);
if (items.length > MAX_NOTES) items.length = MAX_NOTES;
return { version: SCHEMA_VERSION, updatedAt, items };
}
async function readStore(): Promise<NotesStoreV1> {
try {
const res = await chrome.storage.local.get([STORAGE_KEY]);
const raw = (res as Record<string, unknown> | undefined)?.[STORAGE_KEY];
return parseStore(raw);
} catch {
return { version: SCHEMA_VERSION, updatedAt: 0, items: [] };
}
}
async function writeStore(store: NotesStoreV1): Promise<void> {
await chrome.storage.local.set({ [STORAGE_KEY]: store });
}
let storeMutex: Promise<void> = Promise.resolve();
async function withStoreLock<T>(fn: () => Promise<T>): Promise<T> {
const prev = storeMutex;
let release: (() => void) | null = null;
storeMutex = new Promise<void>((r) => {
release = r;
});
await prev;
try {
return await fn();
} finally {
try {
release?.();
} catch {
// Best-effort
}
}
}
function matchesQuery(note: NoteV1, queryLower: string): boolean {
if (!queryLower) return true;
const hay = `${note.title} ${note.preview} ${note.content}`.toLowerCase();
return hay.includes(queryLower);
}
// ============================================================
// Handlers
// ============================================================
async function handleList(
message: QuickPanelNotesListMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelNotesListResponse> {
try {
const incognito = await resolveSenderIncognito(sender);
const rawQuery = normalizeString(message.payload?.query).trim().toLowerCase();
const maxResults = clampInt(message.payload?.maxResults, 50, 1, 200);
const store = await readStore();
const items = store.items
.filter((n) => n.incognito === incognito)
.filter((n) => matchesQuery(n, rawQuery))
.slice(0, maxResults)
.map(toSummary);
return { success: true, items };
} catch (err) {
return { success: false, error: safeErrorMessage(err) || 'Failed to list notes' };
}
}
async function handleGet(
message: QuickPanelNotesGetMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelNotesGetResponse> {
try {
const id = normalizeString(message.payload?.id).trim();
if (!id) return { success: false, error: 'Invalid id' };
const incognito = await resolveSenderIncognito(sender);
const store = await readStore();
const note = store.items.find((n) => n.id === id && n.incognito === incognito);
if (!note) return { success: false, error: 'Note not found' };
return { success: true, note: toDetail(note) };
} catch (err) {
return { success: false, error: safeErrorMessage(err) || 'Failed to get note' };
}
}
async function handleCreate(
message: QuickPanelNotesCreateMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelNotesCreateResponse> {
try {
const content = normalizeString(message.payload?.content);
if (!content.trim()) return { success: false, error: 'Invalid content' };
const incognito = await resolveSenderIncognito(sender);
const now = Date.now();
const bytes = utf8ByteLength(content);
if (bytes > MAX_NOTE_BYTES) {
return { success: false, error: `Note is too large (${bytes} bytes).` };
}
const title = normalizeTitle(message.payload?.title, content, now);
const preview = buildPreview(content);
return await withStoreLock(async () => {
const store = await readStore();
const note: NoteV1 = {
id: createNoteId(),
title,
content,
preview,
createdAt: now,
updatedAt: now,
incognito,
};
store.items.unshift(note);
store.items.sort((a, b) => b.updatedAt - a.updatedAt);
if (store.items.length > MAX_NOTES) store.items.length = MAX_NOTES;
store.updatedAt = now;
await writeStore(store);
return { success: true, note: toSummary(note) };
});
} catch (err) {
return { success: false, error: safeErrorMessage(err) || 'Failed to create note' };
}
}
async function handleDelete(
message: QuickPanelNotesDeleteMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelNotesDeleteResponse> {
try {
const id = normalizeString(message.payload?.id).trim();
if (!id) return { success: false, error: 'Invalid id' };
const incognito = await resolveSenderIncognito(sender);
const now = Date.now();
return await withStoreLock(async () => {
const store = await readStore();
const before = store.items.length;
store.items = store.items.filter((n) => !(n.id === id && n.incognito === incognito));
if (store.items.length === before) return { success: false, error: 'Note not found' };
store.updatedAt = now;
await writeStore(store);
return { success: true };
});
} catch (err) {
return { success: false, error: safeErrorMessage(err) || 'Failed to delete note' };
}
}
// ============================================================
// Initialization
// ============================================================
let initialized = false;
export function initQuickPanelNotesHandler(): void {
if (initialized) return;
initialized = true;
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_NOTES_LIST) {
handleList(message as QuickPanelNotesListMessage, sender).then(sendResponse);
return true;
}
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_NOTES_GET) {
handleGet(message as QuickPanelNotesGetMessage, sender).then(sendResponse);
return true;
}
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_NOTES_CREATE) {
handleCreate(message as QuickPanelNotesCreateMessage, sender).then(sendResponse);
return true;
}
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_NOTES_DELETE) {
handleDelete(message as QuickPanelNotesDeleteMessage, sender).then(sendResponse);
return true;
}
return false;
});
console.debug(`${LOG_PREFIX} Initialized`);
}
@@ -15,6 +15,19 @@ import {
type QuickPanelPageCommandMessage,
type QuickPanelPageCommandResponse,
} from '@/common/message-types';
import { getFirstTextContent, saveTextToDownloads } from './devtools-export';
import {
applyQuickPanelPageSkin,
clearQuickPanelPageSkin,
initQuickPanelPageSkinsLifecycle,
} from './page-skins';
import {
toggleQuickPanelAllowCopy,
toggleQuickPanelForceDark,
toggleQuickPanelPrivacyCurtain,
toggleQuickPanelReaderMode,
toggleQuickPanelZenMode,
} from './page-tools';
const LOG_PREFIX = '[QuickPanelPageCommands]';
@@ -39,11 +52,20 @@ function isValidWindowId(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0;
}
function normalizeBoolean(value: unknown): boolean {
return value === true;
}
function normalizeDisposition(value: unknown): QuickPanelOpenUrlDisposition {
if (value === 'new_tab' || value === 'background_tab' || value === 'current_tab') return value;
return 'current_tab';
}
function delay(ms: number): Promise<void> {
const n = typeof ms === 'number' && Number.isFinite(ms) ? Math.max(0, ms) : 0;
return new Promise((resolve) => setTimeout(resolve, n));
}
/**
* Validate URL scheme for security.
* Only allows safe schemes: http, https, chrome, chrome-extension, file.
@@ -149,6 +171,11 @@ async function handlePageCommand(
return { success: true };
}
if (command === 'new_incognito_window') {
await chrome.windows.create({ incognito: true });
return { success: true };
}
// Commands that require a sender tab
if (!isValidTabId(senderTabId)) {
return { success: false, error: 'Quick Panel request must originate from a tab.' };
@@ -180,6 +207,376 @@ async function handlePageCommand(
}
return { success: true };
case 'screenshot': {
try {
const { screenshotTool } = await import('../tools/browser');
const res = await screenshotTool.execute({
name: 'quick_panel',
tabId: senderTabId,
fullPage: false,
savePng: true,
storeBase64: false,
background: false,
});
if (res?.isError === true) {
const text = (res as any)?.content?.[0]?.text;
const msg =
typeof text === 'string' && text.trim() ? text : 'Failed to capture screenshot';
return { success: false, error: msg };
}
return { success: true };
} catch (err) {
console.warn(`${LOG_PREFIX} Error capturing screenshot:`, err);
return { success: false, error: safeErrorMessage(err) || 'Failed to capture screenshot' };
}
}
case 'dev_console_snapshot_export': {
try {
const { consoleTool } = await import('../tools/browser');
const res = await consoleTool.execute({
tabId: senderTabId,
mode: 'snapshot',
includeExceptions: true,
maxMessages: 200,
});
if (res?.isError === true) {
return {
success: false,
error: getFirstTextContent(res) || 'Failed to capture console.',
};
}
const text = getFirstTextContent(res);
if (!text) return { success: false, error: 'Console tool returned no output.' };
const download = await saveTextToDownloads({
text,
filenamePrefix: 'quick_panel_console_snapshot',
extension: 'json',
mimeType: 'application/json',
});
return {
success: true,
info: { message: 'Console snapshot exported to Downloads.', download },
};
} catch (err) {
console.warn(`${LOG_PREFIX} Error exporting console snapshot:`, err);
return { success: false, error: safeErrorMessage(err) || 'Failed to export console.' };
}
}
case 'dev_console_errors_export': {
try {
const { consoleTool } = await import('../tools/browser');
const res = await consoleTool.execute({
tabId: senderTabId,
mode: 'snapshot',
includeExceptions: true,
maxMessages: 200,
onlyErrors: true,
});
if (res?.isError === true) {
return {
success: false,
error: getFirstTextContent(res) || 'Failed to capture console errors.',
};
}
const text = getFirstTextContent(res);
if (!text) return { success: false, error: 'Console tool returned no output.' };
const download = await saveTextToDownloads({
text,
filenamePrefix: 'quick_panel_console_errors',
extension: 'json',
mimeType: 'application/json',
});
return {
success: true,
info: { message: 'Console errors exported to Downloads.', download },
};
} catch (err) {
console.warn(`${LOG_PREFIX} Error exporting console errors:`, err);
return {
success: false,
error: safeErrorMessage(err) || 'Failed to export console errors.',
};
}
}
case 'dev_read_page_export': {
try {
const { readPageTool } = await import('../tools/browser');
const res = await readPageTool.execute({ tabId: senderTabId, filter: 'interactive' });
if (res?.isError === true) {
return { success: false, error: getFirstTextContent(res) || 'Failed to read page.' };
}
const text = getFirstTextContent(res);
if (!text) return { success: false, error: 'read_page returned no output.' };
const download = await saveTextToDownloads({
text,
filenamePrefix: 'quick_panel_read_page',
extension: 'json',
mimeType: 'application/json',
});
return {
success: true,
info: { message: 'read_page exported to Downloads.', download },
};
} catch (err) {
console.warn(`${LOG_PREFIX} Error exporting read_page:`, err);
return { success: false, error: safeErrorMessage(err) || 'Failed to export read_page.' };
}
}
case 'dev_network_capture_10s_export': {
let started = false;
try {
const { networkCaptureTool } = await import('../tools/browser');
const startRes = await networkCaptureTool.execute({
action: 'start',
needResponseBody: false,
maxCaptureTime: 0,
inactivityTimeout: 0,
includeStatic: false,
});
if (startRes?.isError === true) {
return {
success: false,
error: getFirstTextContent(startRes) || 'Failed to start network capture.',
};
}
started = true;
await delay(10_000);
const stopRes = await networkCaptureTool.execute({
action: 'stop',
needResponseBody: false,
});
started = false;
if (stopRes?.isError === true) {
return {
success: false,
error: getFirstTextContent(stopRes) || 'Failed to stop network capture.',
};
}
const text = getFirstTextContent(stopRes);
if (!text) return { success: false, error: 'Network capture returned no output.' };
const download = await saveTextToDownloads({
text,
filenamePrefix: 'quick_panel_network_capture',
extension: 'json',
mimeType: 'application/json',
});
return {
success: true,
info: { message: 'Network capture exported to Downloads.', download },
};
} catch (err) {
console.warn(`${LOG_PREFIX} Error exporting network capture:`, err);
return {
success: false,
error: safeErrorMessage(err) || 'Failed to export network capture.',
};
} finally {
if (started) {
try {
const { networkCaptureTool } = await import('../tools/browser');
await networkCaptureTool.execute({ action: 'stop', needResponseBody: false });
} catch {
// Best-effort cleanup.
}
}
}
}
case 'dev_performance_trace_5s_export': {
let started = false;
try {
const { performanceStartTraceTool, performanceStopTraceTool } =
await import('../tools/browser');
const startRes = await performanceStartTraceTool.execute({
tabId: senderTabId,
reload: false,
autoStop: true,
durationMs: 5000,
});
if (startRes?.isError === true) {
return {
success: false,
error: getFirstTextContent(startRes) || 'Failed to start performance trace.',
};
}
{
const text = getFirstTextContent(startRes);
if (!text)
return { success: false, error: 'Performance trace start returned no output.' };
try {
const parsed = JSON.parse(text) as { success?: unknown; message?: unknown };
if (parsed?.success !== true) {
return {
success: false,
error:
typeof parsed?.message === 'string'
? parsed.message
: 'Failed to start performance trace.',
};
}
} catch {
return { success: false, error: text };
}
}
started = true;
// Allow a small buffer after autoStop.
await delay(5_500);
const stopRes = await performanceStopTraceTool.execute({
tabId: senderTabId,
saveToDownloads: true,
filenamePrefix: 'quick_panel_performance_trace',
});
if (stopRes?.isError === true) {
return {
success: false,
error: getFirstTextContent(stopRes) || 'Failed to stop performance trace.',
};
}
const stopText = getFirstTextContent(stopRes);
if (!stopText)
return { success: false, error: 'Performance trace stop returned no output.' };
let download: { downloadId?: number; filename?: string; fullPath?: string } | undefined;
try {
const parsed = JSON.parse(stopText) as {
success?: unknown;
message?: unknown;
saved?: { downloadId?: unknown; filename?: unknown; fullPath?: unknown };
};
if (parsed?.success !== true) {
return {
success: false,
error:
typeof parsed?.message === 'string'
? parsed.message
: 'Failed to stop performance trace.',
};
}
if (parsed?.saved) {
download = {
downloadId:
typeof parsed.saved.downloadId === 'number' ? parsed.saved.downloadId : undefined,
filename:
typeof parsed.saved.filename === 'string' ? parsed.saved.filename : undefined,
fullPath:
typeof parsed.saved.fullPath === 'string' ? parsed.saved.fullPath : undefined,
};
}
} catch {
return { success: false, error: stopText };
}
started = false;
return {
success: true,
info: { message: 'Performance trace exported to Downloads.', download },
};
} catch (err) {
console.warn(`${LOG_PREFIX} Error exporting performance trace:`, err);
return {
success: false,
error: safeErrorMessage(err) || 'Failed to export performance trace.',
};
} finally {
if (started) {
try {
const { performanceStopTraceTool } = await import('../tools/browser');
await performanceStopTraceTool.execute({
tabId: senderTabId,
saveToDownloads: false,
filenamePrefix: 'quick_panel_performance_trace',
});
} catch {
// Best-effort cleanup.
}
}
}
}
case 'dev_debug_bundle_create': {
try {
const tab = await chrome.tabs.get(senderTabId);
const tabUrl = normalizeString(tab?.url).trim();
const tabTitle = normalizeString(tab?.title).trim();
const { createQuickPanelDebugBundle } = await import('./debug-bundle');
const result = await createQuickPanelDebugBundle({
tabId: senderTabId,
tabUrl,
tabTitle: tabTitle || tabUrl || `Tab ${senderTabId}`,
});
return {
success: true,
info: {
message: `Debug bundle saved to Downloads/${result.folder}/ (manifest.json).`,
download: result.manifest,
},
};
} catch (err) {
if (err instanceof Error && err.name === 'DebugBundleCancelledError') {
return { success: false, error: 'Debug bundle cancelled.' };
}
console.warn(`${LOG_PREFIX} Error creating debug bundle:`, err);
return {
success: false,
error: safeErrorMessage(err) || 'Failed to create debug bundle.',
};
}
}
case 'dev_debug_bundle_cancel': {
try {
const { cancelQuickPanelDebugBundle } = await import('./debug-bundle');
const res = cancelQuickPanelDebugBundle(senderTabId);
if (!res.success) return { success: false, error: res.error };
return { success: true, info: { message: 'Debug bundle cancelled.' } };
} catch (err) {
console.warn(`${LOG_PREFIX} Error cancelling debug bundle:`, err);
return {
success: false,
error: safeErrorMessage(err) || 'Failed to cancel debug bundle.',
};
}
}
case 'close_tab':
await chrome.tabs.remove(senderTabId);
return { success: true };
@@ -202,6 +599,158 @@ async function handlePageCommand(
return { success: true };
}
case 'close_other_tabs': {
if (!isValidWindowId(senderWindowId)) {
return { success: false, error: 'Invalid sender windowId' };
}
const tabs = await chrome.tabs.query({ windowId: senderWindowId });
const toClose = (Array.isArray(tabs) ? tabs : [])
.filter((t) => {
if (!isValidTabId(t.id)) return false;
if (t.id === senderTabId) return false;
return normalizeBoolean(t.pinned) === false;
})
.map((t) => t.id as number);
if (toClose.length > 0) {
await chrome.tabs.remove(toClose);
}
return { success: true };
}
case 'close_tabs_to_right': {
if (!isValidWindowId(senderWindowId)) {
return { success: false, error: 'Invalid sender windowId' };
}
const current = await chrome.tabs.get(senderTabId);
const currentIndex =
typeof current?.index === 'number' && Number.isFinite(current.index)
? current.index
: null;
if (currentIndex === null) {
return { success: false, error: 'Failed to determine current tab index' };
}
const tabs = await chrome.tabs.query({ windowId: senderWindowId });
const toClose = (Array.isArray(tabs) ? tabs : [])
.filter((t) => {
if (!isValidTabId(t.id)) return false;
if (normalizeBoolean(t.pinned)) return false;
const idx = typeof t.index === 'number' && Number.isFinite(t.index) ? t.index : -1;
return idx > currentIndex;
})
.map((t) => t.id as number);
if (toClose.length > 0) {
await chrome.tabs.remove(toClose);
}
return { success: true };
}
case 'discard_inactive_tabs': {
if (!isValidWindowId(senderWindowId)) {
return { success: false, error: 'Invalid sender windowId' };
}
const tabs = await chrome.tabs.query({ windowId: senderWindowId });
const toDiscard = (Array.isArray(tabs) ? tabs : [])
.filter((t) => {
if (!isValidTabId(t.id)) return false;
if (t.id === senderTabId) return false;
if (normalizeBoolean(t.active)) return false;
if (normalizeBoolean(t.pinned)) return false;
return true;
})
.map((t) => t.id as number);
if (toDiscard.length === 0) {
return { success: true };
}
await Promise.allSettled(
toDiscard.map(async (tabId) => {
try {
await chrome.tabs.discard(tabId);
} catch {
// Best-effort
}
}),
);
return { success: true };
}
case 'merge_all_windows': {
if (!isValidWindowId(senderWindowId)) {
return { success: false, error: 'Invalid sender windowId' };
}
const senderTab = await chrome.tabs.get(senderTabId);
const isIncognito = normalizeBoolean(senderTab?.incognito);
const allTabs = await chrome.tabs.query({});
const byWindow = new Map<number, Array<{ tabId: number; index: number }>>();
for (const t of Array.isArray(allTabs) ? allTabs : []) {
if (!isValidTabId(t.id)) continue;
if (!isValidWindowId(t.windowId)) continue;
if (t.windowId === senderWindowId) continue;
if (normalizeBoolean(t.incognito) !== isIncognito) continue;
const idx = typeof t.index === 'number' && Number.isFinite(t.index) ? t.index : 0;
const list = byWindow.get(t.windowId) ?? [];
list.push({ tabId: t.id, index: idx });
byWindow.set(t.windowId, list);
}
for (const [, list] of byWindow) {
list.sort((a, b) => a.index - b.index);
const tabIds = list.map((x) => x.tabId);
if (tabIds.length === 0) continue;
try {
await chrome.tabs.move(tabIds, { windowId: senderWindowId, index: -1 });
} catch {
// Best-effort: continue with other windows.
}
}
return { success: true };
}
case 'skin_vscode':
return applyQuickPanelPageSkin(senderTabId, 'vscode');
case 'skin_terminal':
return applyQuickPanelPageSkin(senderTabId, 'terminal');
case 'skin_retro':
return applyQuickPanelPageSkin(senderTabId, 'retro');
case 'skin_paper':
return applyQuickPanelPageSkin(senderTabId, 'paper');
case 'skin_off':
return clearQuickPanelPageSkin(senderTabId);
case 'zen_mode_toggle':
return toggleQuickPanelZenMode(senderTabId);
case 'force_dark_toggle':
return toggleQuickPanelForceDark(senderTabId);
case 'allow_copy_toggle':
return toggleQuickPanelAllowCopy(senderTabId);
case 'privacy_curtain_toggle':
return toggleQuickPanelPrivacyCurtain(senderTabId);
case 'reader_mode_toggle':
return toggleQuickPanelReaderMode(senderTabId);
default:
return { success: false, error: `Unsupported command: ${command}` };
}
@@ -225,6 +774,8 @@ export function initQuickPanelPageCommandsHandler(): void {
if (initialized) return;
initialized = true;
initQuickPanelPageSkinsLifecycle();
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_OPEN_URL) {
handleOpenUrl(message as QuickPanelOpenUrlMessage, sender).then(sendResponse);
@@ -0,0 +1,463 @@
/**
* Quick Panel Page Skins
*
* Implements "Page Skins" as a fun visual effect that can be toggled via Quick Panel commands.
*
* Design goals:
* - Runs entirely locally (no network) and does not rewrite page content.
* - Applies styles only to `body` subtree so the Quick Panel overlay (attached to `documentElement`)
* remains readable and unaffected.
* - Always shows a visible "Skin mode" watermark to avoid ambiguous "disguise" behavior.
* - Best-effort persistence within the current browser session via `chrome.storage.session`.
*/
import type { QuickPanelPageCommandResponse } from '@/common/message-types';
const LOG_PREFIX = '[QuickPanelPageSkins]';
// ============================================================
// Types
// ============================================================
export type QuickPanelPageSkinId = 'vscode' | 'terminal' | 'retro' | 'paper';
// ============================================================
// CSS
// ============================================================
const PAGE_SKINS_CSS = /* css */ `
/* Quick Panel Page Skins (best-effort) */
body[data-mcp-qp-skin] {
transition: filter 160ms ease, background-color 160ms ease, color 160ms ease;
}
/* ----------------------------
* VS Code-inspired (dark mono)
* ---------------------------- */
body[data-mcp-qp-skin='vscode'] {
background: #1e1e1e !important;
color: #d4d4d4 !important;
}
body[data-mcp-qp-skin='vscode'] * {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace !important;
}
body[data-mcp-qp-skin='vscode'] a {
color: #4fc1ff !important;
}
body[data-mcp-qp-skin='vscode'] img,
body[data-mcp-qp-skin='vscode'] video,
body[data-mcp-qp-skin='vscode'] svg,
body[data-mcp-qp-skin='vscode'] canvas {
filter: saturate(0.95) contrast(1.03) !important;
}
/* ----------------------------
* Terminal (green on black)
* ---------------------------- */
body[data-mcp-qp-skin='terminal'] {
background: #050505 !important;
color: #00ff9a !important;
}
body[data-mcp-qp-skin='terminal'] * {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace !important;
color: #00ff9a !important;
}
body[data-mcp-qp-skin='terminal'] a {
color: #7cffc6 !important;
text-decoration: underline !important;
}
body[data-mcp-qp-skin='terminal']::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 2147483646;
background: repeating-linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 0px,
rgba(0, 0, 0, 0) 2px,
rgba(0, 0, 0, 0.16) 3px
);
mix-blend-mode: multiply;
opacity: 0.55;
}
/* ----------------------------
* Retro (warm CRT-ish)
* ---------------------------- */
body[data-mcp-qp-skin='retro'] {
filter: sepia(0.55) contrast(1.08) saturate(0.9);
}
body[data-mcp-qp-skin='retro']::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 2147483646;
background:
radial-gradient(circle at 50% 20%, rgba(255, 255, 255, 0.08), rgba(0, 0, 0, 0) 55%),
repeating-linear-gradient(
to bottom,
rgba(0, 0, 0, 0.06) 0px,
rgba(0, 0, 0, 0.06) 1px,
rgba(0, 0, 0, 0) 3px
);
opacity: 0.6;
}
/* ----------------------------
* Paper (light serif)
* ---------------------------- */
body[data-mcp-qp-skin='paper'] {
background: #f6f1e6 !important;
color: #1c1b18 !important;
}
body[data-mcp-qp-skin='paper'] * {
font-family: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif !important;
}
body[data-mcp-qp-skin='paper'] a {
color: #1f5fbf !important;
}
`;
// ============================================================
// Watermark (Shadow DOM)
// ============================================================
const WATERMARK_HOST_ID = '__mcp_qp_skin_watermark_host__';
const BODY_ATTR = 'data-mcp-qp-skin';
function watermarkLabelForSkinId(skinId: QuickPanelPageSkinId): string {
switch (skinId) {
case 'vscode':
return 'VS Code';
case 'terminal':
return 'Terminal';
case 'retro':
return 'Retro';
case 'paper':
return 'Paper';
}
}
// ============================================================
// Session Persistence (best-effort)
// ============================================================
const SESSION_STORAGE_KEY = 'quick_panel_page_skins_by_tab_v1';
const skinByTabId = new Map<number, QuickPanelPageSkinId>();
let sessionLoaded = false;
let sessionLoadPromise: Promise<void> | null = null;
let persistTimer: ReturnType<typeof setTimeout> | null = null;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function isValidTabId(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0;
}
function safeErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message || String(err);
return String(err);
}
function normalizeSkinId(value: unknown): QuickPanelPageSkinId | null {
if (value === 'vscode' || value === 'terminal' || value === 'retro' || value === 'paper') {
return value;
}
return null;
}
async function ensureSessionLoaded(): Promise<void> {
if (sessionLoaded) return;
if (sessionLoadPromise) return sessionLoadPromise;
sessionLoadPromise = (async () => {
try {
if (!chrome.storage?.session) {
sessionLoaded = true;
return;
}
const stored = (await chrome.storage.session.get([SESSION_STORAGE_KEY])) as Record<
string,
unknown
>;
const raw = stored?.[SESSION_STORAGE_KEY];
if (!isRecord(raw)) {
sessionLoaded = true;
return;
}
const openTabs = await chrome.tabs.query({});
const openIds = new Set(openTabs.map((t) => t.id).filter(isValidTabId));
skinByTabId.clear();
for (const [k, v] of Object.entries(raw)) {
const tabId = Number(k);
if (!isValidTabId(tabId)) continue;
if (!openIds.has(tabId)) continue;
const skinId = normalizeSkinId(v);
if (!skinId) continue;
skinByTabId.set(tabId, skinId);
}
} catch (err) {
console.warn(`${LOG_PREFIX} Failed to load session state:`, err);
} finally {
sessionLoaded = true;
}
})().finally(() => {
sessionLoadPromise = null;
});
return sessionLoadPromise;
}
function scheduleSessionPersist(): void {
if (!chrome.storage?.session) return;
if (persistTimer) return;
persistTimer = setTimeout(() => {
persistTimer = null;
void flushSessionPersist();
}, 250);
}
async function flushSessionPersist(): Promise<void> {
if (!chrome.storage?.session) return;
const snapshot: Record<string, QuickPanelPageSkinId> = {};
for (const [tabId, skinId] of skinByTabId.entries()) {
snapshot[String(tabId)] = skinId;
}
try {
await chrome.storage.session.set({ [SESSION_STORAGE_KEY]: snapshot });
} catch (err) {
console.warn(`${LOG_PREFIX} Failed to persist session state:`, err);
}
}
// ============================================================
// Injection Helpers
// ============================================================
async function ensureCssInjected(tabId: number): Promise<void> {
await chrome.scripting.insertCSS({
target: { tabId },
css: PAGE_SKINS_CSS,
});
}
async function setBodySkinAttr(tabId: number, skinId: QuickPanelPageSkinId): Promise<void> {
await chrome.scripting.executeScript({
target: { tabId },
func: (attr: string, value: string) => {
try {
if (!document.body) return;
document.body.setAttribute(attr, value);
} catch {
// Best-effort
}
},
args: [BODY_ATTR, skinId],
});
}
async function clearBodySkinAttr(tabId: number): Promise<void> {
await chrome.scripting.executeScript({
target: { tabId },
func: (attr: string) => {
try {
if (!document.body) return;
document.body.removeAttribute(attr);
} catch {
// Best-effort
}
},
args: [BODY_ATTR],
});
}
async function ensureWatermark(tabId: number, skinId: QuickPanelPageSkinId): Promise<void> {
const label = watermarkLabelForSkinId(skinId);
await chrome.scripting.executeScript({
target: { tabId },
func: (hostId: string, title: string) => {
try {
const root = document.documentElement;
if (!root) return;
let host = document.getElementById(hostId) as HTMLElement | null;
if (!host) {
host = document.createElement('div');
host.id = hostId;
root.appendChild(host);
}
// Ensure host is outside page layout and above body effects.
host.style.position = 'fixed';
host.style.top = '12px';
host.style.right = '12px';
host.style.zIndex = '2147483646'; // below Quick Panel host (2147483647)
host.style.pointerEvents = 'none';
const shadow = host.shadowRoot ?? host.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
.wm {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-size: 12px;
line-height: 1;
letter-spacing: 0.2px;
color: rgba(255, 255, 255, 0.92);
background: rgba(17, 24, 39, 0.72);
border: 1px solid rgba(255, 255, 255, 0.16);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
border-radius: 999px;
padding: 8px 10px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
user-select: none;
white-space: nowrap;
}
.wm strong {
font-weight: 600;
margin-right: 6px;
}
</style>
<div class="wm" aria-label="Quick Panel skin mode watermark">
<strong>Skin mode</strong>${title}
</div>
`;
} catch {
// Best-effort
}
},
args: [WATERMARK_HOST_ID, label],
});
}
async function removeWatermark(tabId: number): Promise<void> {
await chrome.scripting.executeScript({
target: { tabId },
func: (hostId: string) => {
try {
const el = document.getElementById(hostId);
el?.remove();
} catch {
// Best-effort
}
},
args: [WATERMARK_HOST_ID],
});
}
// ============================================================
// Public API (called by page-commands-handler)
// ============================================================
export async function applyQuickPanelPageSkin(
tabId: number,
skinId: QuickPanelPageSkinId,
): Promise<QuickPanelPageCommandResponse> {
try {
await ensureSessionLoaded();
await ensureCssInjected(tabId);
await setBodySkinAttr(tabId, skinId);
await ensureWatermark(tabId, skinId);
skinByTabId.set(tabId, skinId);
scheduleSessionPersist();
return { success: true };
} catch (err) {
console.warn(`${LOG_PREFIX} Failed to apply skin:`, err);
return { success: false, error: safeErrorMessage(err) || 'Failed to apply skin' };
}
}
export async function clearQuickPanelPageSkin(
tabId: number,
): Promise<QuickPanelPageCommandResponse> {
try {
await ensureSessionLoaded();
await clearBodySkinAttr(tabId);
await removeWatermark(tabId);
skinByTabId.delete(tabId);
scheduleSessionPersist();
return { success: true };
} catch (err) {
console.warn(`${LOG_PREFIX} Failed to clear skin:`, err);
return { success: false, error: safeErrorMessage(err) || 'Failed to clear skin' };
}
}
// ============================================================
// Lifecycle hooks (called by page-commands-handler init)
// ============================================================
let lifecycleInitialized = false;
export function initQuickPanelPageSkinsLifecycle(): void {
// Avoid duplicate listener registration if module is initialized multiple times.
// We intentionally tie this to the page-commands-handler init lifecycle.
if (lifecycleInitialized) return;
lifecycleInitialized = true;
// Reapply skin on full navigations (content is per-document).
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
if (changeInfo?.status !== 'complete') return;
void (async () => {
try {
await ensureSessionLoaded();
const skinId = skinByTabId.get(tabId);
if (!skinId) return;
await ensureCssInjected(tabId);
await setBodySkinAttr(tabId, skinId);
await ensureWatermark(tabId, skinId);
} catch {
// Best-effort
}
})();
});
chrome.tabs.onRemoved.addListener((tabId) => {
if (skinByTabId.delete(tabId)) {
scheduleSessionPersist();
}
});
chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => {
const existing = skinByTabId.get(removedTabId);
if (!existing) return;
skinByTabId.delete(removedTabId);
skinByTabId.set(addedTabId, existing);
scheduleSessionPersist();
});
console.debug(`${LOG_PREFIX} Initialized lifecycle`);
}
@@ -0,0 +1,727 @@
/**
* Quick Panel Page Tools
*
* Implements Phase 10 "page tools" as best-effort, reversible toggles:
* - Reader Mode (overlay, no page DOM rewrite)
* - Zen Mode (hide common distractions via CSS)
* - Force Dark (simple invert-based darkening)
* - Allow Copy (override user-select + stop common blocking events)
* - Privacy Curtain (full-page masking overlay for screen sharing)
*
* 中文说明(设计理由):
* 这些能力采用“overlay + attribute + injected CSS”的策略,避免直接重写原页面 DOM。
* 这样可以做到可逆(关闭即恢复)、低侵入(不污染页面布局),并在受限页面上保持 best-effort 失败隔离。
*/
import { TOOL_MESSAGE_TYPES, type QuickPanelPageCommandResponse } from '@/common/message-types';
const LOG_PREFIX = '[QuickPanelPageTools]';
// ============================================================
// Constants
// ============================================================
// Keep below Quick Panel overlay (2147483647).
const PAGE_TOOL_Z_INDEX = '2147483646';
const READER_HOST_ID = '__mcp_qp_reader_host__';
const PRIVACY_CURTAIN_HOST_ID = '__mcp_qp_privacy_curtain_host__';
const ZEN_ATTR = 'data-mcp-qp-zen';
const FORCE_DARK_ATTR = 'data-mcp-qp-force-dark';
const ALLOW_COPY_ATTR = 'data-mcp-qp-allow-copy';
const ZEN_CSS = /* css */ `
/* Quick Panel Zen Mode (best-effort) */
body[${ZEN_ATTR}] header,
body[${ZEN_ATTR}] nav,
body[${ZEN_ATTR}] footer,
body[${ZEN_ATTR}] aside,
body[${ZEN_ATTR}] [role='banner'],
body[${ZEN_ATTR}] [role='navigation'],
body[${ZEN_ATTR}] [role='complementary'],
body[${ZEN_ATTR}] [aria-label*='cookie' i],
body[${ZEN_ATTR}] [id*='cookie' i],
body[${ZEN_ATTR}] [class*='cookie' i],
body[${ZEN_ATTR}] [class*='advert' i],
body[${ZEN_ATTR}] [id*='advert' i],
body[${ZEN_ATTR}] [class*='ads' i],
body[${ZEN_ATTR}] [id*='ads' i],
body[${ZEN_ATTR}] [class*='sidebar' i],
body[${ZEN_ATTR}] [id*='sidebar' i] {
display: none !important;
}
body[${ZEN_ATTR}] {
scroll-behavior: auto !important;
}
`;
const FORCE_DARK_CSS = /* css */ `
/* Quick Panel Force Dark (best-effort) */
body[${FORCE_DARK_ATTR}] {
filter: invert(1) hue-rotate(180deg) !important;
background: #0b0b0b !important;
}
/* Re-invert media so images/videos look natural-ish. */
body[${FORCE_DARK_ATTR}] img,
body[${FORCE_DARK_ATTR}] video,
body[${FORCE_DARK_ATTR}] svg,
body[${FORCE_DARK_ATTR}] canvas,
body[${FORCE_DARK_ATTR}] picture {
filter: invert(1) hue-rotate(180deg) !important;
}
`;
const ALLOW_COPY_CSS = /* css */ `
/* Quick Panel Allow Copy (best-effort) */
body[${ALLOW_COPY_ATTR}] * {
user-select: text !important;
-webkit-user-select: text !important;
-webkit-touch-callout: default !important;
}
`;
// ============================================================
// Helpers
// ============================================================
function safeErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message || String(err);
return String(err);
}
function normalizeBoolean(value: unknown): boolean {
return value === true;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
async function toggleBodyAttribute(tabId: number, attr: string, css: string): Promise<void> {
await chrome.scripting.insertCSS({ target: { tabId }, css });
await chrome.scripting.executeScript({
target: { tabId },
func: (a: string) => {
try {
const body = document.body;
if (!body) return;
if (body.hasAttribute(a)) body.removeAttribute(a);
else body.setAttribute(a, '');
} catch {
// Best-effort
}
},
args: [attr],
});
}
async function ensureWebFetcherHelper(tabId: number): Promise<void> {
// Try a lightweight ping first.
try {
const resp = await chrome.tabs.sendMessage(tabId, { action: 'search_tabs_content_ping' });
if (resp && isRecord(resp) && resp.status === 'pong') return;
} catch {
// Fall through to injection
}
await chrome.scripting.executeScript({
target: { tabId },
files: ['inject-scripts/web-fetcher-helper.js'],
});
}
async function closeOverlayHost(tabId: number, hostId: string, stateKey: string): Promise<void> {
await chrome.scripting.executeScript({
target: { tabId },
func: (id: string, key: string) => {
try {
const win = window as any;
const state = win[key];
if (state && typeof state.close === 'function') {
state.close();
return;
}
const existing = document.getElementById(id);
existing?.remove();
} catch {
// Best-effort
}
},
args: [hostId, stateKey],
});
}
// ============================================================
// Public API
// ============================================================
export async function toggleQuickPanelZenMode(
tabId: number,
): Promise<QuickPanelPageCommandResponse> {
try {
await toggleBodyAttribute(tabId, ZEN_ATTR, ZEN_CSS);
return { success: true };
} catch (err) {
console.warn(`${LOG_PREFIX} Failed to toggle zen mode:`, err);
return { success: false, error: safeErrorMessage(err) || 'Failed to toggle zen mode' };
}
}
export async function toggleQuickPanelForceDark(
tabId: number,
): Promise<QuickPanelPageCommandResponse> {
try {
await toggleBodyAttribute(tabId, FORCE_DARK_ATTR, FORCE_DARK_CSS);
return { success: true };
} catch (err) {
console.warn(`${LOG_PREFIX} Failed to toggle force dark:`, err);
return { success: false, error: safeErrorMessage(err) || 'Failed to toggle force dark' };
}
}
export async function toggleQuickPanelAllowCopy(
tabId: number,
): Promise<QuickPanelPageCommandResponse> {
const stateKey = '__mcp_qp_allow_copy_state__';
try {
await chrome.scripting.insertCSS({ target: { tabId }, css: ALLOW_COPY_CSS });
await chrome.scripting.executeScript({
target: { tabId },
func: (attr: string, key: string) => {
try {
const body = document.body;
if (!body) return;
const win = window as any;
const enabled = body.hasAttribute(attr);
if (enabled) {
body.removeAttribute(attr);
const state = win[key];
if (state && typeof state.handler === 'function' && Array.isArray(state.types)) {
for (const t of state.types) {
try {
document.removeEventListener(t, state.handler, true);
} catch {
// Best-effort
}
}
}
delete win[key];
return;
}
body.setAttribute(attr, '');
const handler = (event: Event) => {
try {
event.stopImmediatePropagation();
} catch {
// Best-effort
}
};
const types = ['copy', 'cut', 'contextmenu', 'selectstart', 'dragstart'];
for (const t of types) {
try {
document.addEventListener(t, handler, true);
} catch {
// Best-effort
}
}
win[key] = { handler, types };
} catch {
// Best-effort
}
},
args: [ALLOW_COPY_ATTR, stateKey],
});
return { success: true };
} catch (err) {
console.warn(`${LOG_PREFIX} Failed to toggle allow copy:`, err);
return { success: false, error: safeErrorMessage(err) || 'Failed to toggle allow copy' };
}
}
export async function toggleQuickPanelPrivacyCurtain(
tabId: number,
): Promise<QuickPanelPageCommandResponse> {
const stateKey = '__mcp_qp_privacy_curtain_state__';
try {
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId },
func: (hostId: string) => Boolean(document.getElementById(hostId)),
args: [PRIVACY_CURTAIN_HOST_ID],
});
if (normalizeBoolean(result)) {
await closeOverlayHost(tabId, PRIVACY_CURTAIN_HOST_ID, stateKey);
return { success: true };
}
await chrome.scripting.executeScript({
target: { tabId },
func: (hostId: string, key: string, zIndex: string) => {
try {
const root = document.documentElement;
if (!root) return;
if (document.getElementById(hostId)) return;
const host = document.createElement('div');
host.id = hostId;
host.style.position = 'fixed';
host.style.inset = '0';
host.style.zIndex = zIndex;
host.style.pointerEvents = 'auto';
root.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = `
:host { all: initial; }
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.78);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.panel {
max-width: 560px;
width: 100%;
color: rgba(255, 255, 255, 0.92);
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,
'Apple Color Emoji', 'Segoe UI Emoji';
background: rgba(17, 24, 39, 0.72);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 14px;
padding: 18px 18px 14px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
}
.titleRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.title {
font-size: 14px;
font-weight: 700;
letter-spacing: 0.2px;
}
.btn {
all: unset;
cursor: pointer;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.08);
font-size: 12px;
}
.hint {
font-size: 12px;
opacity: 0.88;
line-height: 1.5;
}
.kbd {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-size: 12px;
padding: 2px 6px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(0, 0, 0, 0.18);
}
`;
const backdrop = document.createElement('div');
backdrop.className = 'backdrop';
const panel = document.createElement('div');
panel.className = 'panel';
const titleRow = document.createElement('div');
titleRow.className = 'titleRow';
const title = document.createElement('div');
title.className = 'title';
title.textContent = 'Privacy curtain';
const closeBtn = document.createElement('button');
closeBtn.className = 'btn';
closeBtn.type = 'button';
closeBtn.textContent = 'Hide';
closeBtn.setAttribute('aria-label', 'Hide privacy curtain');
titleRow.append(title, closeBtn);
const hint = document.createElement('div');
hint.className = 'hint';
hint.innerHTML = `Screen sharing safe mode is active. Press <span class="kbd">Esc</span> to hide.`;
panel.append(titleRow, hint);
backdrop.append(panel);
shadow.append(style, backdrop);
const close = () => {
try {
const win = window as any;
const state = win[key];
if (state && typeof state.onKeyDown === 'function') {
window.removeEventListener('keydown', state.onKeyDown, true);
}
delete win[key];
} catch {
// Best-effort
}
host.remove();
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopPropagation();
close();
};
try {
window.addEventListener('keydown', onKeyDown, true);
(window as any)[key] = { onKeyDown, close };
} catch {
// Best-effort
}
closeBtn.addEventListener('click', close);
// Click outside panel closes too
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) close();
});
// Focus the close button for keyboard users
try {
closeBtn.focus();
} catch {
// Best-effort
}
} catch {
// Best-effort
}
},
args: [PRIVACY_CURTAIN_HOST_ID, stateKey, PAGE_TOOL_Z_INDEX],
});
return { success: true };
} catch (err) {
console.warn(`${LOG_PREFIX} Failed to toggle privacy curtain:`, err);
return {
success: false,
error: safeErrorMessage(err) || 'Failed to toggle privacy curtain',
};
}
}
export async function toggleQuickPanelReaderMode(
tabId: number,
): Promise<QuickPanelPageCommandResponse> {
const stateKey = '__mcp_qp_reader_state__';
try {
const [{ result: isOpen }] = await chrome.scripting.executeScript({
target: { tabId },
func: (hostId: string) => Boolean(document.getElementById(hostId)),
args: [READER_HOST_ID],
});
if (normalizeBoolean(isOpen)) {
await closeOverlayHost(tabId, READER_HOST_ID, stateKey);
return { success: true };
}
await ensureWebFetcherHelper(tabId);
const resp = await chrome.tabs.sendMessage(tabId, {
action: TOOL_MESSAGE_TYPES.WEB_FETCHER_GET_TEXT_CONTENT,
});
if (!resp || !isRecord(resp) || resp.success !== true) {
const err = isRecord(resp) && typeof resp.error === 'string' ? resp.error : undefined;
return { success: false, error: err || 'Failed to extract readable content' };
}
const article = isRecord(resp.article) ? resp.article : null;
const metadata = isRecord(resp.metadata) ? resp.metadata : null;
const title =
(article && typeof article.title === 'string' && article.title.trim()) ||
(metadata && typeof metadata.title === 'string' && metadata.title.trim()) ||
'';
const byline = article && typeof article.byline === 'string' ? article.byline : '';
const siteName =
(article && typeof article.siteName === 'string' && article.siteName.trim()) ||
(metadata && typeof metadata.siteName === 'string' && metadata.siteName.trim()) ||
'';
const excerpt = article && typeof article.excerpt === 'string' ? article.excerpt : '';
const htmlContent = article && typeof article.content === 'string' ? article.content : '';
const textFallback = typeof resp.textContent === 'string' ? resp.textContent : '';
await chrome.scripting.executeScript({
target: { tabId },
func: (
hostId: string,
key: string,
zIndex: string,
payload: {
title: string;
byline: string;
siteName: string;
excerpt: string;
html: string;
text: string;
},
) => {
try {
const root = document.documentElement;
if (!root) return;
if (document.getElementById(hostId)) return;
const host = document.createElement('div');
host.id = hostId;
host.style.position = 'fixed';
host.style.inset = '0';
host.style.zIndex = zIndex;
host.style.pointerEvents = 'auto';
root.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = `
:host { all: initial; }
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.62);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
overflow: auto;
overscroll-behavior: contain;
padding: 28px 18px;
box-sizing: border-box;
}
.sheet {
max-width: 860px;
margin: 0 auto;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.92);
color: rgba(0, 0, 0, 0.88);
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.5);
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,
'Apple Color Emoji', 'Segoe UI Emoji';
}
@media (prefers-color-scheme: dark) {
.sheet {
background: rgba(17, 24, 39, 0.92);
color: rgba(255, 255, 255, 0.9);
border-color: rgba(255, 255, 255, 0.12);
}
}
.header {
padding: 18px 20px 12px;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
@media (prefers-color-scheme: dark) {
.header { border-bottom-color: rgba(255, 255, 255, 0.1); }
}
.title {
font-size: 18px;
font-weight: 750;
line-height: 1.25;
letter-spacing: 0.2px;
margin: 0;
}
.meta {
margin-top: 6px;
font-size: 12px;
opacity: 0.78;
line-height: 1.4;
}
.btn {
all: unset;
cursor: pointer;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(0, 0, 0, 0.12);
background: rgba(0, 0, 0, 0.04);
font-size: 12px;
user-select: none;
white-space: nowrap;
}
@media (prefers-color-scheme: dark) {
.btn {
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.08);
}
}
.content {
padding: 16px 20px 20px;
}
.article :where(p, ul, ol, pre, blockquote) {
margin: 12px 0;
}
.article :where(h1, h2, h3) {
margin: 18px 0 10px;
line-height: 1.25;
}
.article :where(img, video) {
max-width: 100%;
height: auto;
border-radius: 12px;
}
.article :where(pre, code) {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-size: 13px;
}
.article :where(pre) {
padding: 12px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.05);
overflow: auto;
}
@media (prefers-color-scheme: dark) {
.article :where(pre) { background: rgba(255, 255, 255, 0.06); }
}
`;
const backdrop = document.createElement('div');
backdrop.className = 'backdrop';
const sheet = document.createElement('div');
sheet.className = 'sheet';
sheet.setAttribute('role', 'dialog');
sheet.setAttribute('aria-modal', 'true');
sheet.setAttribute('aria-label', 'Reader mode');
const header = document.createElement('div');
header.className = 'header';
const left = document.createElement('div');
const hTitle = document.createElement('h1');
hTitle.className = 'title';
hTitle.textContent = payload.title || document.title || 'Reader';
const meta = document.createElement('div');
meta.className = 'meta';
const metaParts = [payload.siteName, payload.byline, payload.excerpt].filter(Boolean);
meta.textContent = metaParts.join(' \u00B7 ');
left.append(hTitle, meta);
const closeBtn = document.createElement('button');
closeBtn.className = 'btn';
closeBtn.type = 'button';
closeBtn.textContent = 'Close';
closeBtn.setAttribute('aria-label', 'Close reader mode');
header.append(left, closeBtn);
const content = document.createElement('div');
content.className = 'content';
const article = document.createElement('article');
article.className = 'article';
if (payload.html && payload.html.trim()) {
article.innerHTML = payload.html;
} else {
const pre = document.createElement('pre');
pre.textContent = payload.text || '';
article.append(pre);
}
content.append(article);
sheet.append(header, content);
backdrop.append(sheet);
shadow.append(style, backdrop);
const close = () => {
try {
const win = window as any;
const state = win[key];
if (state && typeof state.onKeyDown === 'function') {
window.removeEventListener('keydown', state.onKeyDown, true);
}
delete win[key];
} catch {
// Best-effort
}
host.remove();
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopPropagation();
close();
};
try {
window.addEventListener('keydown', onKeyDown, true);
(window as any)[key] = { onKeyDown, close };
} catch {
// Best-effort
}
closeBtn.addEventListener('click', close);
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) close();
});
try {
closeBtn.focus();
} catch {
// Best-effort
}
} catch {
// Best-effort
}
},
args: [
READER_HOST_ID,
stateKey,
PAGE_TOOL_Z_INDEX,
{ title, byline, siteName, excerpt, html: htmlContent, text: textFallback },
],
});
return { success: true };
} catch (err) {
console.warn(`${LOG_PREFIX} Failed to toggle reader mode:`, err);
return { success: false, error: safeErrorMessage(err) || 'Failed to toggle reader mode' };
}
}
@@ -0,0 +1,586 @@
/**
* Quick Panel Workspaces Handler
*
* Background service worker bridge for WorkspacesProvider.
*
* Features:
* - Save current window tabs as a named snapshot (workspace)
* - List saved snapshots (scoped to incognito boundary)
* - Open a snapshot in current window or a new window
* - Delete a snapshot
*
* Design principles:
* - Local-only storage (`chrome.storage.local`) with a versioned schema
* - Incognito boundary enforced (no cross-context restore)
* - Best-effort operations with safety caps to avoid runaway tab creation
*/
import {
BACKGROUND_MESSAGE_TYPES,
type QuickPanelWorkspaceSummary,
type QuickPanelWorkspacesDeleteMessage,
type QuickPanelWorkspacesDeleteResponse,
type QuickPanelWorkspacesListMessage,
type QuickPanelWorkspacesListResponse,
type QuickPanelWorkspacesOpenMessage,
type QuickPanelWorkspacesOpenResponse,
type QuickPanelWorkspacesOpenTarget,
type QuickPanelWorkspacesSaveMessage,
type QuickPanelWorkspacesSaveResponse,
} from '@/common/message-types';
const LOG_PREFIX = '[QuickPanelWorkspaces]';
// ============================================================
// Storage Schema
// ============================================================
const STORAGE_KEY = 'quick_panel_workspaces_v1';
const SCHEMA_VERSION = 1 as const;
/** Keep workspaces bounded to avoid unbounded growth. */
const MAX_WORKSPACES = 200;
/** Safety cap to avoid opening/saving extremely large sessions. */
const MAX_TABS_PER_WORKSPACE = 200;
interface WorkspaceTabV1 {
url: string;
title: string;
pinned: boolean;
}
interface WorkspaceSnapshotV1 {
id: string;
name: string;
createdAt: number;
updatedAt: number;
incognito: boolean;
activeIndex: number;
tabs: WorkspaceTabV1[];
}
interface WorkspaceStoreV1 {
version: typeof SCHEMA_VERSION;
updatedAt: number;
items: WorkspaceSnapshotV1[];
}
// ============================================================
// Helpers
// ============================================================
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function normalizeString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function normalizeBoolean(value: unknown): boolean {
return value === true;
}
function toFiniteNumber(value: unknown, fallback: number): number {
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : fallback;
}
function clampInt(value: unknown, fallback: number, min: number, max: number): number {
const n = Math.floor(toFiniteNumber(value, fallback));
return Math.max(min, Math.min(max, n));
}
function safeErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message || String(err);
return String(err);
}
function createWorkspaceId(): string {
try {
const id = crypto?.randomUUID?.();
if (id) return id;
} catch {
// Fallback for environments without crypto.randomUUID
}
return `ws_${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
function pad2(n: number): string {
return String(n).padStart(2, '0');
}
function defaultWorkspaceName(now: number): string {
const d = new Date(now);
return `Session ${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(
d.getHours(),
)}:${pad2(d.getMinutes())}`;
}
function normalizeWorkspaceName(value: unknown, now: number): string {
const raw = normalizeString(value).trim().replace(/\s+/g, ' ');
const name = raw || defaultWorkspaceName(now);
return name.slice(0, 80);
}
function isAllowedWorkspaceUrl(url: string): boolean {
const trimmed = String(url ?? '').trim();
if (!trimmed) return false;
try {
const u = new URL(trimmed);
return u.protocol === 'http:' || u.protocol === 'https:' || u.protocol === 'file:';
} catch {
// Best-effort: reject obvious dangerous schemes.
const lower = trimmed.toLowerCase();
if (lower.startsWith('javascript:')) return false;
if (lower.startsWith('data:')) return false;
return false;
}
}
function toSummary(snapshot: WorkspaceSnapshotV1): QuickPanelWorkspaceSummary {
return {
id: snapshot.id,
name: snapshot.name,
tabCount: snapshot.tabs.length,
createdAt: snapshot.createdAt,
updatedAt: snapshot.updatedAt,
incognito: snapshot.incognito,
};
}
function parseStore(raw: unknown): WorkspaceStoreV1 {
if (!isRecord(raw) || raw.version !== SCHEMA_VERSION || !Array.isArray(raw.items)) {
return { version: SCHEMA_VERSION, updatedAt: 0, items: [] };
}
const updatedAt = Math.max(0, toFiniteNumber(raw.updatedAt, 0));
const seenIds = new Set<string>();
const items: WorkspaceSnapshotV1[] = [];
for (const v of raw.items) {
if (!isRecord(v)) continue;
const id = normalizeString(v.id).trim();
if (!id || seenIds.has(id)) continue;
const name = normalizeString(v.name).trim().replace(/\s+/g, ' ');
if (!name) continue;
const createdAt = Math.max(0, toFiniteNumber(v.createdAt, 0));
const itemUpdatedAt = Math.max(0, toFiniteNumber(v.updatedAt, 0));
const incognito = normalizeBoolean(v.incognito);
const tabsRaw = Array.isArray(v.tabs) ? v.tabs : [];
const tabs: WorkspaceTabV1[] = [];
for (const t of tabsRaw) {
if (!isRecord(t)) continue;
const url = normalizeString(t.url).trim();
if (!isAllowedWorkspaceUrl(url)) continue;
const title = normalizeString(t.title).trim();
const pinned = normalizeBoolean(t.pinned);
tabs.push({ url, title, pinned });
if (tabs.length >= MAX_TABS_PER_WORKSPACE) break;
}
if (tabs.length === 0) continue;
const activeIndex = clampInt(v.activeIndex, 0, 0, Math.max(0, tabs.length - 1));
seenIds.add(id);
items.push({
id,
name: name.slice(0, 80),
createdAt: createdAt || itemUpdatedAt || updatedAt || Date.now(),
updatedAt: itemUpdatedAt || createdAt || updatedAt || Date.now(),
incognito,
activeIndex,
tabs,
});
}
// Sort by updatedAt desc and clamp
items.sort((a, b) => b.updatedAt - a.updatedAt);
if (items.length > MAX_WORKSPACES) {
items.length = MAX_WORKSPACES;
}
return { version: SCHEMA_VERSION, updatedAt, items };
}
async function readStore(): Promise<WorkspaceStoreV1> {
try {
const res = await chrome.storage.local.get([STORAGE_KEY]);
const raw = (res as Record<string, unknown> | undefined)?.[STORAGE_KEY];
return parseStore(raw);
} catch {
return { version: SCHEMA_VERSION, updatedAt: 0, items: [] };
}
}
async function writeStore(store: WorkspaceStoreV1): Promise<void> {
await chrome.storage.local.set({
[STORAGE_KEY]: {
version: store.version,
updatedAt: store.updatedAt,
items: store.items,
},
});
}
let writeLock: Promise<void> = Promise.resolve();
async function withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
const prev = writeLock;
let release: (() => void) | null = null;
writeLock = new Promise<void>((resolve) => {
release = resolve;
});
await prev.catch(() => {});
try {
return await fn();
} finally {
release?.();
}
}
function isValidTabId(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0;
}
function isValidWindowId(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0;
}
function normalizeOpenTarget(value: unknown): QuickPanelWorkspacesOpenTarget {
return value === 'current_window' || value === 'new_window' ? value : 'new_window';
}
// ============================================================
// Message Handlers
// ============================================================
async function handleList(
message: QuickPanelWorkspacesListMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelWorkspacesListResponse> {
try {
if (!sender.tab?.id) {
return { success: false, error: 'Quick Panel request must originate from a tab.' };
}
const incognito = normalizeBoolean(sender.tab.incognito);
const query = normalizeString(message.payload?.query).trim().toLowerCase();
const maxResults = clampInt(message.payload?.maxResults, 50, 0, MAX_WORKSPACES);
const store = await readStore();
let items = store.items.filter((w) => w.incognito === incognito);
if (query) {
items = items.filter((w) => w.name.toLowerCase().includes(query));
}
items.sort((a, b) => b.updatedAt - a.updatedAt);
if (items.length > maxResults) items = items.slice(0, maxResults);
return { success: true, items: items.map(toSummary) };
} catch (err) {
console.warn(`${LOG_PREFIX} Error listing workspaces:`, err);
return { success: false, error: safeErrorMessage(err) || 'Failed to list workspaces' };
}
}
async function handleSave(
message: QuickPanelWorkspacesSaveMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelWorkspacesSaveResponse> {
try {
const senderTabId = sender.tab?.id;
const senderWindowId = sender.tab?.windowId;
if (!isValidTabId(senderTabId) || !isValidWindowId(senderWindowId)) {
return { success: false, error: 'Quick Panel request must originate from a tab/window.' };
}
const now = Date.now();
const incognito = normalizeBoolean(sender.tab?.incognito);
const name = normalizeWorkspaceName(message.payload?.name, now);
const tabs = await chrome.tabs.query({ windowId: senderWindowId });
const savedTabs: WorkspaceTabV1[] = [];
let activeIndex = 0;
for (const t of Array.isArray(tabs) ? tabs : []) {
const url = normalizeString(t.url).trim();
if (!isAllowedWorkspaceUrl(url)) continue;
const title = normalizeString(t.title).trim();
const pinned = normalizeBoolean(t.pinned);
const active = normalizeBoolean(t.active);
if (active) {
activeIndex = savedTabs.length;
}
savedTabs.push({ url, title, pinned });
if (savedTabs.length >= MAX_TABS_PER_WORKSPACE) break;
}
if (savedTabs.length === 0) {
return { success: false, error: 'No savable tabs found in the current window.' };
}
const normalizedNameKey = name.toLowerCase();
const saved = await withWriteLock(async () => {
const store = await readStore();
// Update existing snapshot with the same name (within incognito boundary), otherwise create a new one.
const existingIdx = store.items.findIndex(
(w) => w.incognito === incognito && w.name.toLowerCase() === normalizedNameKey,
);
let snapshot: WorkspaceSnapshotV1;
if (existingIdx >= 0) {
const existing = store.items[existingIdx]!;
snapshot = {
...existing,
name,
updatedAt: now,
tabs: savedTabs,
activeIndex: clampInt(activeIndex, 0, 0, savedTabs.length - 1),
};
store.items.splice(existingIdx, 1);
} else {
snapshot = {
id: createWorkspaceId(),
name,
createdAt: now,
updatedAt: now,
incognito,
activeIndex: clampInt(activeIndex, 0, 0, savedTabs.length - 1),
tabs: savedTabs,
};
}
store.updatedAt = now;
store.items.unshift(snapshot);
// Clamp store size by evicting oldest.
store.items.sort((a, b) => b.updatedAt - a.updatedAt);
if (store.items.length > MAX_WORKSPACES) {
store.items.length = MAX_WORKSPACES;
}
await writeStore(store);
return snapshot;
});
return { success: true, workspace: toSummary(saved) };
} catch (err) {
console.warn(`${LOG_PREFIX} Error saving workspace:`, err);
return { success: false, error: safeErrorMessage(err) || 'Failed to save workspace' };
}
}
async function handleDelete(
message: QuickPanelWorkspacesDeleteMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelWorkspacesDeleteResponse> {
try {
if (!sender.tab?.id) {
return { success: false, error: 'Quick Panel request must originate from a tab.' };
}
const incognito = normalizeBoolean(sender.tab.incognito);
const workspaceId = normalizeString(message.payload?.workspaceId).trim();
if (!workspaceId) {
return { success: false, error: 'Invalid workspaceId' };
}
const removed = await withWriteLock(async () => {
const store = await readStore();
const before = store.items.length;
store.items = store.items.filter((w) => !(w.incognito === incognito && w.id === workspaceId));
if (store.items.length === before) return false;
store.updatedAt = Date.now();
await writeStore(store);
return true;
});
return removed ? { success: true } : { success: false, error: 'Workspace not found' };
} catch (err) {
console.warn(`${LOG_PREFIX} Error deleting workspace:`, err);
return { success: false, error: safeErrorMessage(err) || 'Failed to delete workspace' };
}
}
async function openTabsInWindow(options: {
windowId: number;
tabs: WorkspaceTabV1[];
activeIndex: number;
}): Promise<{ openedCount: number; totalCount: number }> {
const totalCount = options.tabs.length;
let openedCount = 0;
const createdIds: number[] = [];
for (let i = 0; i < options.tabs.length; i++) {
const t = options.tabs[i]!;
try {
const created = await chrome.tabs.create({
windowId: options.windowId,
url: t.url,
active: false,
pinned: t.pinned,
});
if (isValidTabId(created.id)) {
createdIds[i] = created.id;
}
openedCount += 1;
} catch {
// Best-effort: skip failed URLs.
}
}
const idx = clampInt(options.activeIndex, 0, 0, Math.max(0, options.tabs.length - 1));
const toActivate = createdIds[idx];
if (isValidTabId(toActivate)) {
try {
await chrome.tabs.update(toActivate, { active: true });
} catch {
// Best-effort
}
}
return { openedCount, totalCount };
}
async function handleOpen(
message: QuickPanelWorkspacesOpenMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelWorkspacesOpenResponse> {
try {
const senderTabId = sender.tab?.id;
const senderWindowId = sender.tab?.windowId;
if (!isValidTabId(senderTabId) || !isValidWindowId(senderWindowId)) {
return { success: false, error: 'Quick Panel request must originate from a tab/window.' };
}
const incognito = normalizeBoolean(sender.tab?.incognito);
const workspaceId = normalizeString(message.payload?.workspaceId).trim();
const target = normalizeOpenTarget(message.payload?.target);
if (!workspaceId) {
return { success: false, error: 'Invalid workspaceId' };
}
const store = await readStore();
const snapshot = store.items.find((w) => w.id === workspaceId && w.incognito === incognito);
if (!snapshot) {
return { success: false, error: 'Workspace not found (or incognito boundary mismatch)' };
}
const tabs = snapshot.tabs.slice(0, MAX_TABS_PER_WORKSPACE);
if (tabs.length === 0) {
return { success: false, error: 'Workspace has no tabs' };
}
if (target === 'current_window') {
const res = await openTabsInWindow({
windowId: senderWindowId,
tabs,
activeIndex: snapshot.activeIndex,
});
return { success: true, openedCount: res.openedCount, totalCount: res.totalCount };
}
// new_window
const firstUrl = tabs[0]?.url;
if (!firstUrl) {
return { success: false, error: 'Workspace has no openable tabs' };
}
const createdWindow = await chrome.windows.create({
url: firstUrl,
focused: true,
incognito,
});
const windowId = createdWindow?.id;
if (!isValidWindowId(windowId)) {
return { success: false, error: 'Failed to create target window' };
}
// For the rest tabs, open them in the created window.
const rest = tabs.slice(1);
const res = await openTabsInWindow({
windowId,
tabs: rest,
activeIndex: Math.max(0, snapshot.activeIndex - 1),
});
// Account for the first tab created with the window.
const totalCount = tabs.length;
const openedCount = 1 + res.openedCount;
// Best-effort: pin the first tab if needed and activate correct tab if activeIndex is 0.
try {
const firstTabId = createdWindow.tabs?.[0]?.id;
if (isValidTabId(firstTabId) && tabs[0]?.pinned) {
await chrome.tabs.update(firstTabId, { pinned: true });
}
if (isValidTabId(firstTabId) && snapshot.activeIndex === 0) {
await chrome.tabs.update(firstTabId, { active: true });
}
} catch {
// Best-effort
}
return { success: true, openedCount, totalCount, windowId };
} catch (err) {
console.warn(`${LOG_PREFIX} Error opening workspace:`, err);
return { success: false, error: safeErrorMessage(err) || 'Failed to open workspace' };
}
}
// ============================================================
// Initialization
// ============================================================
let initialized = false;
export function initQuickPanelWorkspacesHandler(): void {
if (initialized) return;
initialized = true;
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_WORKSPACES_LIST) {
handleList(message as QuickPanelWorkspacesListMessage, sender).then(sendResponse);
return true;
}
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_WORKSPACES_SAVE) {
handleSave(message as QuickPanelWorkspacesSaveMessage, sender).then(sendResponse);
return true;
}
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_WORKSPACES_OPEN) {
handleOpen(message as QuickPanelWorkspacesOpenMessage, sender).then(sendResponse);
return true;
}
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_WORKSPACES_DELETE) {
handleDelete(message as QuickPanelWorkspacesDeleteMessage, sender).then(sendResponse);
return true;
}
return false;
});
console.debug(`${LOG_PREFIX} Initialized`);
}
@@ -140,6 +140,73 @@ function deepClone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function jsonValueEquals(a: JsonValue, b: JsonValue): boolean {
if (a === b) return true;
const aIsArray = Array.isArray(a);
const bIsArray = Array.isArray(b);
if (aIsArray || bIsArray) {
if (!aIsArray || !bIsArray) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!jsonValueEquals(a[i] as JsonValue, b[i] as JsonValue)) return false;
}
return true;
}
const aIsObj = isRecord(a);
const bIsObj = isRecord(b);
if (aIsObj || bIsObj) {
if (!aIsObj || !bIsObj) return false;
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
for (const k of aKeys) {
if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
if (!jsonValueEquals(a[k] as JsonValue, (b as Record<string, unknown>)[k] as JsonValue))
return false;
}
return true;
}
return false;
}
function diffVarsPatch(
from: Record<string, JsonValue>,
to: Record<string, JsonValue>,
): VarsPatchOp[] {
const keys = Array.from(new Set([...Object.keys(from), ...Object.keys(to)])).sort();
const patch: VarsPatchOp[] = [];
for (const key of keys) {
const fromHas = Object.prototype.hasOwnProperty.call(from, key);
const toHas = Object.prototype.hasOwnProperty.call(to, key);
if (!toHas) {
if (fromHas) patch.push({ op: 'delete', name: key });
continue;
}
const toVal = to[key];
if (!fromHas) {
patch.push({ op: 'set', name: key, value: toVal });
continue;
}
const fromVal = from[key];
if (!jsonValueEquals(fromVal, toVal)) {
patch.push({ op: 'set', name: key, value: toVal });
}
}
return patch;
}
function errorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
if (err && typeof err === 'object' && 'message' in err)
@@ -1625,7 +1692,24 @@ class StorageBackedRunRunner implements RunRunner {
// Restore vars if isolated mode was activated
if (varsModified) {
const isolatedVars = this.state.vars;
this.state.vars = savedVars;
// Best-effort: keep vars.patch event stream consistent with runtime state.
const revertPatch = diffVarsPatch(isolatedVars, savedVars);
if (revertPatch.length > 0) {
try {
await this.queue.run(() =>
this.env.events.append({
runId: this.runId,
type: 'vars.patch',
patch: revertPatch,
} as RunEventInput),
);
} catch {
// Avoid overriding the original control/node error.
}
}
}
}
}
@@ -5,11 +5,11 @@
* This enables V3 to execute flows that use V2 action types.
*/
import { createReplayActionRegistry } from '@/entrypoints/background/record-replay/actions/handlers';
import { createReplayActionRegistry } from '@/entrypoints/background/replay-actions';
import type {
ActionHandler,
ExecutableActionType,
} from '@/entrypoints/background/record-replay/actions/types';
} from '@/entrypoints/background/replay-actions/types';
import type { PluginRegistry } from './registry';
import {
@@ -23,7 +23,7 @@ import type {
ValidationResult,
Action,
ControlDirective as V2ControlDirective,
} from '@/entrypoints/background/record-replay/actions/types';
} from '@/entrypoints/background/replay-actions/types';
import type { ControlDirectiveV3 } from '../../domain/control';
@@ -13,7 +13,7 @@
import type { ExecCtx, ExecResult } from '../nodes/types';
import type { Step } from '../types';
import type { ActionRegistry } from './registry';
import type { ActionRegistry } from '@/entrypoints/background/replay-actions/registry';
import type {
ActionExecutionContext,
ActionExecutionResult,
@@ -22,7 +22,7 @@ import type {
ExecutableActionType,
ExecutionFlags,
VariableStore,
} from './types';
} from '@/entrypoints/background/replay-actions/types';
// ================================
// Type Mapping
@@ -67,11 +67,13 @@ const STEP_TYPE_TO_ACTION_TYPE: Record<string, ExecutableActionType> = {
while: 'while',
switchFrame: 'switchFrame',
// TODO: Add when handlers are implemented
// triggerEvent: 'triggerEvent',
// setAttribute: 'setAttribute',
// loopElements: 'loopElements',
// executeFlow: 'executeFlow',
// DOM utilities
triggerEvent: 'triggerEvent',
setAttribute: 'setAttribute',
// Advanced control flow
loopElements: 'loopElements',
executeFlow: 'executeFlow',
};
// ================================
@@ -14,7 +14,7 @@
import type { Step } from '../../types';
import type { ExecCtx, ExecResult } from '../../nodes/types';
import { executeStep as legacyExecuteStep } from '../../nodes';
import type { ActionRegistry } from '../../actions/registry';
import type { ActionRegistry } from '@/entrypoints/background/replay-actions/registry';
import {
createStepExecutor,
isActionSupported,
@@ -16,8 +16,8 @@ import {
maybeQuickWaitForNav,
ensureReadPageIfWeb,
waitForNetworkIdle,
} from '../policies/wait';
import { ENGINE_CONSTANTS } from '../constants';
} from '@/entrypoints/background/replay-actions/engine/policies/wait';
import { ENGINE_CONSTANTS } from '@/entrypoints/background/replay-actions/engine/constants';
import { AfterScriptQueue } from './after-script-queue';
import { PluginManager } from '../plugins/manager';
import type { HookControl } from '../plugins/types';
@@ -7,7 +7,7 @@ import { PluginManager } from '../plugins/manager';
import { mapDagNodeToStep } from '../../rr-utils';
import type { Edge, NodeBase, Step } from '../../types';
import { StepRunner } from './step-runner';
import { ENGINE_CONSTANTS } from '../constants';
import { ENGINE_CONSTANTS } from '@/entrypoints/background/replay-actions/engine/constants';
export interface SubflowEnv {
runId: string;
@@ -20,7 +20,10 @@ import { AfterScriptQueue } from './runners/after-script-queue';
import { StepRunner } from './runners/step-runner';
import { ControlFlowRunner } from './runners/control-flow-runner';
import { SubflowRunner } from './runners/subflow-runner';
import { ENGINE_CONSTANTS, LOG_STEP_IDS } from './constants';
import {
ENGINE_CONSTANTS,
LOG_STEP_IDS,
} from '@/entrypoints/background/replay-actions/engine/constants';
import {
DEFAULT_EXECUTION_MODE_CONFIG,
createActionsOnlyConfig,
@@ -29,7 +32,7 @@ import {
type ExecutionModeConfig,
} from './execution-mode';
import { createExecutor, type StepExecutorInterface } from './runners/step-executor';
import { createReplayActionRegistry } from '../actions/handlers';
import { createReplayActionRegistry } from '@/entrypoints/background/replay-actions';
export interface RunOptions {
tabTarget?: 'current' | 'new';
@@ -9,7 +9,7 @@
* 2. Recording pipeline that still produces Step[] output
* 3. Legacy node handlers in nodes/ directory
*
* New code should use the Action type system from ./actions/types.ts instead.
* New code should use the Action type system from @/entrypoints/background/replay-actions/types.ts instead.
*
* Migration status: P4 phase 1 - types extracted, re-exported from types.ts
*/
@@ -1,5 +1,5 @@
import type { ExecCtx, ExecResult, NodeRuntime } from './types';
import { ENGINE_CONSTANTS } from '../engine/constants';
import { ENGINE_CONSTANTS } from '@/entrypoints/background/replay-actions/engine/constants';
export const foreachNode: NodeRuntime<any> = {
validate: (step) => {
@@ -99,137 +99,10 @@ export async function ensureTab(options: {
return { tabId: tabId!, url };
}
export async function waitForNetworkIdle(totalTimeoutMs: number, idleThresholdMs: number) {
const deadline = Date.now() + Math.max(500, totalTimeoutMs);
const threshold = Math.max(200, idleThresholdMs);
while (Date.now() < deadline) {
await handleCallTool({
name: TOOL_NAMES.BROWSER.NETWORK_CAPTURE_START,
args: {
includeStatic: false,
// Ensure capture remains active until we explicitly stop it
maxCaptureTime: Math.min(60_000, Math.max(threshold + 500, 2_000)),
inactivityTimeout: 0,
},
});
await new Promise((r) => setTimeout(r, threshold + 200));
const stopRes = await handleCallTool({
name: TOOL_NAMES.BROWSER.NETWORK_CAPTURE_STOP,
args: {},
});
const text = (stopRes as any)?.content?.find((c: any) => c.type === 'text')?.text;
try {
const json = text ? JSON.parse(text) : null;
const captureEnd = Number(json?.captureEndTime) || Date.now();
const reqs: any[] = Array.isArray(json?.requests) ? json.requests : [];
const lastActivity = reqs.reduce(
(acc, r) => {
const t = Number(r.responseTime || r.requestTime || 0);
return t > acc ? t : acc;
},
Number(json?.captureStartTime || 0),
);
if (captureEnd - lastActivity >= threshold) return; // idle reached
} catch {
// ignore parse errors
}
await new Promise((r) => setTimeout(r, Math.min(500, threshold)));
}
throw new Error('wait for network idle timed out');
}
// Event-driven navigation wait helper
// Waits for top-frame navigation completion or SPA history updates on active tab.
// Falls back to short network idle on timeout.
export async function waitForNavigation(timeoutMs?: number, prevUrl?: string): Promise<void> {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs?.[0]?.id;
if (typeof tabId !== 'number') throw new Error('Active tab not found');
const timeout = Math.max(1000, Math.min(timeoutMs || 15000, 30000));
const startedAt = Date.now();
await new Promise<void>((resolve, reject) => {
let done = false;
let timer: any = null;
const cleanup = () => {
try {
chrome.webNavigation.onCommitted.removeListener(onCommitted);
} catch {}
try {
chrome.webNavigation.onCompleted.removeListener(onCompleted);
} catch {}
try {
(chrome.webNavigation as any).onHistoryStateUpdated?.removeListener?.(
onHistoryStateUpdated,
);
} catch {}
try {
chrome.tabs.onUpdated.removeListener(onTabUpdated);
} catch {}
if (timer) {
try {
clearTimeout(timer);
} catch {}
}
};
const finish = () => {
if (done) return;
done = true;
cleanup();
resolve();
};
const onCommitted = (details: any) => {
if (
details &&
details.tabId === tabId &&
details.frameId === 0 &&
details.timeStamp >= startedAt
) {
// committed observed; we'll wait for completion or SPA fallback
}
};
const onCompleted = (details: any) => {
if (
details &&
details.tabId === tabId &&
details.frameId === 0 &&
details.timeStamp >= startedAt
)
finish();
};
const onHistoryStateUpdated = (details: any) => {
if (
details &&
details.tabId === tabId &&
details.frameId === 0 &&
details.timeStamp >= startedAt
)
finish();
};
const onTabUpdated = (updatedTabId: number, changeInfo: chrome.tabs.TabChangeInfo) => {
if (updatedTabId !== tabId) return;
if (changeInfo.status === 'complete') finish();
if (typeof changeInfo.url === 'string' && (!prevUrl || changeInfo.url !== prevUrl)) finish();
};
const onTimeout = async () => {
cleanup();
try {
await waitForNetworkIdle(2000, 800);
resolve();
} catch {
reject(new Error('navigation timeout'));
}
};
chrome.webNavigation.onCommitted.addListener(onCommitted);
chrome.webNavigation.onCompleted.addListener(onCompleted);
try {
(chrome.webNavigation as any).onHistoryStateUpdated?.addListener?.(onHistoryStateUpdated);
} catch {}
chrome.tabs.onUpdated.addListener(onTabUpdated);
timer = setTimeout(onTimeout, timeout);
});
}
export {
waitForNavigation,
waitForNetworkIdle,
} from '@/entrypoints/background/replay-actions/engine/utils/wait';
export function topoOrder(nodes: DagNode[], edges: DagEdge[]): DagNode[] {
return sharedTopoOrder(nodes, edges as any);
@@ -7,7 +7,7 @@
*
* Type system architecture:
* - Legacy types (./legacy-types.ts): Step-based execution model (being phased out)
* - Action types (./actions/types.ts): DAG-based execution model (new standard)
* - Action types (@/entrypoints/background/replay-actions/types.ts): DAG-based execution model (new standard)
* - Core types (this file): Flow, Node, Edge, Run records (shared by both)
*/
@@ -1,12 +1,12 @@
// engine/policies/wait.ts — wrappers around rr-utils navigation/network waits
// engine/policies/wait.ts — wrappers around navigation/network wait utilities
// Keep logic centralized to avoid duplication in schedulers and nodes
import { handleCallTool } from '@/entrypoints/background/tools';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { waitForNavigation as rrWaitForNavigation, waitForNetworkIdle } from '../../rr-utils';
import { waitForNavigation, waitForNetworkIdle } from '../utils/wait';
export async function waitForNavigationDone(prevUrl: string, timeoutMs?: number) {
await rrWaitForNavigation(timeoutMs, prevUrl);
await waitForNavigation(timeoutMs, prevUrl);
}
export async function ensureReadPageIfWeb() {
@@ -54,7 +54,7 @@ export async function maybeQuickWaitForNav(prevUrl: string, timeoutMs?: number)
cleanup();
if (seen) {
try {
await rrWaitForNavigation(
await waitForNavigation(
prevUrl ? Math.min(timeoutMs || 15000, 30000) : undefined,
prevUrl,
);
@@ -0,0 +1,157 @@
/**
* @fileoverview Navigation/network wait utilities
* @description Shared, event-driven wait helpers for replay execution.
*
* These utilities are intentionally version-neutral so both legacy RR-V2 code paths and RR-V3 adapters
* can share the same behavior without importing each other.
*/
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { handleCallTool } from '@/entrypoints/background/tools';
export async function waitForNetworkIdle(totalTimeoutMs: number, idleThresholdMs: number) {
const deadline = Date.now() + Math.max(500, totalTimeoutMs);
const threshold = Math.max(200, idleThresholdMs);
while (Date.now() < deadline) {
await handleCallTool({
name: TOOL_NAMES.BROWSER.NETWORK_CAPTURE_START,
args: {
includeStatic: false,
// Ensure capture remains active until we explicitly stop it
maxCaptureTime: Math.min(60_000, Math.max(threshold + 500, 2_000)),
inactivityTimeout: 0,
},
});
await new Promise((r) => setTimeout(r, threshold + 200));
const stopRes = await handleCallTool({
name: TOOL_NAMES.BROWSER.NETWORK_CAPTURE_STOP,
args: {},
});
const text = (stopRes as { content?: Array<{ type?: string; text?: string }> })?.content?.find(
(c) => c.type === 'text',
)?.text;
try {
const json = text ? JSON.parse(text) : null;
const captureEnd = Number(json?.captureEndTime) || Date.now();
const reqs: unknown[] = Array.isArray(json?.requests) ? json.requests : [];
const lastActivity = reqs.reduce(
(acc: number, r: unknown) => {
const rec = r as { responseTime?: unknown; requestTime?: unknown };
const t = Number(rec.responseTime || rec.requestTime || 0);
return t > acc ? t : acc;
},
Number(json?.captureStartTime || 0),
);
if (captureEnd - lastActivity >= threshold) return; // idle reached
} catch {
// ignore parse errors
}
await new Promise((r) => setTimeout(r, Math.min(500, threshold)));
}
throw new Error('wait for network idle timed out');
}
/**
* Event-driven navigation wait helper.
*
* Waits for top-frame navigation completion or SPA history updates on the active tab.
* Falls back to a short network-idle check on timeout.
*/
export async function waitForNavigation(timeoutMs?: number, prevUrl?: string): Promise<void> {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs?.[0]?.id;
if (typeof tabId !== 'number') throw new Error('Active tab not found');
const timeout = Math.max(1000, Math.min(timeoutMs || 15000, 30000));
const startedAt = Date.now();
await new Promise<void>((resolve, reject) => {
let done = false;
const timer: ReturnType<typeof setTimeout> | undefined = undefined;
const cleanup = () => {
try {
chrome.webNavigation.onCommitted.removeListener(onCommitted);
} catch {}
try {
chrome.webNavigation.onCompleted.removeListener(onCompleted);
} catch {}
try {
(
chrome.webNavigation as unknown as { onHistoryStateUpdated?: chrome.events.Event }
).onHistoryStateUpdated?.removeListener?.(onHistoryStateUpdated);
} catch {}
try {
chrome.tabs.onUpdated.removeListener(onTabUpdated);
} catch {}
if (timer) {
try {
clearTimeout(timer);
} catch {}
}
};
const finish = () => {
if (done) return;
done = true;
cleanup();
resolve();
};
const onCommitted = (details: unknown) => {
const d = details as { tabId?: unknown; frameId?: unknown; timeStamp?: unknown };
if (
d &&
d.tabId === tabId &&
d.frameId === 0 &&
typeof d.timeStamp === 'number' &&
d.timeStamp >= startedAt
) {
// committed observed; we'll wait for completion or SPA fallback
}
};
const onCompleted = (details: unknown) => {
const d = details as { tabId?: unknown; frameId?: unknown; timeStamp?: unknown };
if (
d &&
d.tabId === tabId &&
d.frameId === 0 &&
typeof d.timeStamp === 'number' &&
d.timeStamp >= startedAt
)
finish();
};
const onHistoryStateUpdated = (details: unknown) => {
const d = details as { tabId?: unknown; frameId?: unknown; timeStamp?: unknown };
if (
d &&
d.tabId === tabId &&
d.frameId === 0 &&
typeof d.timeStamp === 'number' &&
d.timeStamp >= startedAt
)
finish();
};
const onTabUpdated = (updatedTabId: number, changeInfo: chrome.tabs.TabChangeInfo) => {
if (updatedTabId !== tabId) return;
if (changeInfo.status === 'complete') finish();
if (typeof changeInfo.url === 'string' && (!prevUrl || changeInfo.url !== prevUrl)) finish();
};
const onTimeout = async () => {
cleanup();
try {
await waitForNetworkIdle(2000, 800);
resolve();
} catch {
reject(new Error('navigation timeout'));
}
};
chrome.webNavigation.onCommitted.addListener(onCommitted);
chrome.webNavigation.onCompleted.addListener(onCompleted);
try {
(
chrome.webNavigation as unknown as { onHistoryStateUpdated?: chrome.events.Event }
).onHistoryStateUpdated?.addListener?.(onHistoryStateUpdated);
} catch {}
chrome.tabs.onUpdated.addListener(onTabUpdated);
timer = setTimeout(onTimeout, timeout);
});
}
@@ -10,12 +10,12 @@
import { handleCallTool } from '@/entrypoints/background/tools';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { ENGINE_CONSTANTS } from '../../engine/constants';
import { ENGINE_CONSTANTS } from '../engine/constants';
import {
maybeQuickWaitForNav,
waitForNavigationDone,
waitForNetworkIdle,
} from '../../engine/policies/wait';
} from '../engine/policies/wait';
import { failed, invalid, ok } from '../registry';
import type {
Action,
@@ -9,8 +9,8 @@
import { handleCallTool } from '@/entrypoints/background/tools';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { ENGINE_CONSTANTS } from '../../engine/constants';
import { ensureReadPageIfWeb, waitForNavigationDone } from '../../engine/policies/wait';
import { ENGINE_CONSTANTS } from '../engine/constants';
import { ensureReadPageIfWeb, waitForNavigationDone } from '../engine/policies/wait';
import { failed, invalid, ok } from '../registry';
import type { ActionHandler } from '../types';
import { clampInt, readTabUrl, resolveString } from './common';
@@ -9,8 +9,8 @@
* - Selector visible/hidden
*/
import { ENGINE_CONSTANTS } from '../../engine/constants';
import { waitForNavigation, waitForNetworkIdle } from '../../rr-utils';
import { ENGINE_CONSTANTS } from '../engine/constants';
import { waitForNavigation, waitForNetworkIdle } from '../engine/utils/wait';
import { failed, invalid, ok, tryResolveNumber } from '../registry';
import type { ActionHandler } from '../types';
import { clampInt, resolveString, sendMessageToTab } from './common';
@@ -23,17 +23,6 @@ export {
type ActionRegistryHooks,
} from './registry';
// 适配器导出
export {
execCtxToActionCtx,
stepToAction,
actionResultToExecResult,
createStepExecutor,
isActionSupported,
getActionType,
type StepExecutionAttempt,
} from './adapter';
// Handler 工厂导出
export {
createReplayActionRegistry,
@@ -6,6 +6,8 @@ import { NETWORK_FILTERS } from '@/common/constants';
interface NetworkDebuggerStartToolParams {
url?: string; // URL to navigate to or focus. If not provided, uses active tab.
tabId?: number; // Target existing tab id. When provided, overrides url/active tab selection.
windowId?: number; // When no tabId and no url, pick active tab from this window.
maxCaptureTime?: number;
inactivityTimeout?: number; // Inactivity timeout (milliseconds)
includeStatic?: boolean; // if include static resources
@@ -46,7 +48,7 @@ const DEFAULT_INACTIVITY_TIMEOUT_MS = 60 * 1000; // 1 minute
*/
class NetworkDebuggerStartTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_START;
private captureData: Map<number, any> = new Map(); // tabId -> capture data
public captureData: Map<number, any> = new Map(); // tabId -> capture data
private captureTimers: Map<number, NodeJS.Timeout> = new Map(); // tabId -> max capture timer
private inactivityTimers: Map<number, NodeJS.Timeout> = new Map(); // tabId -> inactivity timer
private lastActivityTime: Map<number, number> = new Map(); // tabId -> timestamp of last network activity
@@ -767,6 +769,8 @@ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor {
async execute(args: NetworkDebuggerStartToolParams): Promise<ToolResult> {
const {
url: targetUrl,
tabId,
windowId,
maxCaptureTime = DEFAULT_MAX_CAPTURE_TIME_MS,
inactivityTimeout = DEFAULT_INACTIVITY_TIMEOUT_MS,
includeStatic = false,
@@ -779,7 +783,9 @@ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor {
let tabToOperateOn: chrome.tabs.Tab | undefined;
try {
if (targetUrl) {
if (typeof tabId === 'number' && Number.isFinite(tabId) && tabId > 0) {
tabToOperateOn = await chrome.tabs.get(tabId);
} else if (targetUrl) {
const existingTabs = await chrome.tabs.query({
url: targetUrl.startsWith('http') ? targetUrl : `*://*/*${targetUrl}*`,
}); // More specific query
@@ -795,7 +801,14 @@ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor {
await new Promise((resolve) => setTimeout(resolve, 500)); // Short delay
}
} else {
const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true });
const resolvedWindowId =
typeof windowId === 'number' && Number.isFinite(windowId) && windowId > 0
? windowId
: undefined;
const activeTabs =
typeof resolvedWindowId === 'number'
? await chrome.tabs.query({ active: true, windowId: resolvedWindowId })
: await chrome.tabs.query({ active: true, currentWindow: true });
if (activeTabs.length > 0 && activeTabs[0]?.id) {
tabToOperateOn = activeTabs[0];
} else {
@@ -871,7 +884,7 @@ class NetworkDebuggerStopTool extends BaseBrowserToolExecutor {
NetworkDebuggerStopTool.instance = this;
}
async execute(): Promise<ToolResult> {
async execute(args?: { tabId?: number; windowId?: number }): Promise<ToolResult> {
console.log(`NetworkDebuggerStopTool: Executing command.`);
const startTool = NetworkDebuggerStartTool.instance;
@@ -882,7 +895,7 @@ class NetworkDebuggerStopTool extends BaseBrowserToolExecutor {
}
// Get all tabs currently capturing
const ongoingCaptures = Array.from(startTool['captureData'].keys());
const ongoingCaptures = Array.from(startTool.captureData.keys());
console.log(
`NetworkDebuggerStopTool: Found ${ongoingCaptures.length} ongoing captures: ${ongoingCaptures.join(', ')}`,
);
@@ -891,14 +904,35 @@ class NetworkDebuggerStopTool extends BaseBrowserToolExecutor {
return createErrorResponse('No active network captures found in any tab.');
}
const requestedTabId =
typeof args?.tabId === 'number' && Number.isFinite(args.tabId) && args.tabId > 0
? args.tabId
: null;
// If caller specifies a tabId, stop ONLY that tab (do not affect other captures).
if (requestedTabId !== null) {
if (!startTool.captureData.has(requestedTabId)) {
return createErrorResponse(`No active network capture found for tab ${requestedTabId}.`);
}
return this.performStop(startTool, requestedTabId);
}
// Get current active tab
const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true });
const resolvedWindowId =
typeof args?.windowId === 'number' && Number.isFinite(args.windowId) && args.windowId > 0
? args.windowId
: undefined;
const activeTabs =
typeof resolvedWindowId === 'number'
? await chrome.tabs.query({ active: true, windowId: resolvedWindowId })
: await chrome.tabs.query({ active: true, currentWindow: true });
const activeTabId = activeTabs[0]?.id;
// Determine the primary tab to stop
let primaryTabId: number;
if (activeTabId && startTool['captureData'].has(activeTabId)) {
if (activeTabId && startTool.captureData.has(activeTabId)) {
// If current active tab is capturing, prioritize stopping it
primaryTabId = activeTabId;
console.log(
@@ -957,7 +991,7 @@ class NetworkDebuggerStopTool extends BaseBrowserToolExecutor {
const resultData = stopResult.data || {};
// Get all tabs still capturing (there might be other tabs still capturing after stopping)
const remainingCaptures = Array.from(startTool['captureData'].keys());
const remainingCaptures = Array.from(startTool.captureData.keys());
// Sort requests by time
if (resultData.requests && Array.isArray(resultData.requests)) {
@@ -47,6 +47,8 @@ const AD_ANALYTICS_DOMAINS = NETWORK_FILTERS.EXCLUDED_DOMAINS;
interface NetworkCaptureStartToolParams {
url?: string; // URL to navigate to or focus. If not provided, uses active tab.
tabId?: number; // Target existing tab id. When provided, overrides url/active tab selection.
windowId?: number; // When no tabId and no url, pick active tab from this window.
maxCaptureTime?: number; // Maximum capture time (milliseconds)
inactivityTimeout?: number; // Inactivity timeout (milliseconds)
includeStatic?: boolean; // Whether to include static resources
@@ -789,6 +791,8 @@ class NetworkCaptureStartTool extends BaseBrowserToolExecutor {
async execute(args: NetworkCaptureStartToolParams): Promise<ToolResult> {
const {
url: targetUrl,
tabId,
windowId,
maxCaptureTime = 3 * 60 * 1000, // Default 3 minutes
inactivityTimeout = 60 * 1000, // Default 1 minute of inactivity before auto-stop
includeStatic = false, // Default: don't include static resources
@@ -800,7 +804,9 @@ class NetworkCaptureStartTool extends BaseBrowserToolExecutor {
// Get current tab or create new tab
let tabToOperateOn: chrome.tabs.Tab;
if (targetUrl) {
if (typeof tabId === 'number' && Number.isFinite(tabId) && tabId > 0) {
tabToOperateOn = await chrome.tabs.get(tabId);
} else if (targetUrl) {
// Find tabs matching the URL
const matchingTabs = await chrome.tabs.query({ url: targetUrl });
@@ -818,7 +824,14 @@ class NetworkCaptureStartTool extends BaseBrowserToolExecutor {
}
} else {
// Use current active tab
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const resolvedWindowId =
typeof windowId === 'number' && Number.isFinite(windowId) && windowId > 0
? windowId
: undefined;
const tabs =
typeof resolvedWindowId === 'number'
? await chrome.tabs.query({ active: true, windowId: resolvedWindowId })
: await chrome.tabs.query({ active: true, currentWindow: true });
if (!tabs[0]) {
return createErrorResponse('No active tab found');
}
@@ -884,7 +897,7 @@ class NetworkCaptureStopTool extends BaseBrowserToolExecutor {
NetworkCaptureStopTool.instance = this;
}
async execute(): Promise<ToolResult> {
async execute(args?: { tabId?: number; windowId?: number }): Promise<ToolResult> {
console.log(`NetworkCaptureStopTool: Executing`);
try {
@@ -904,8 +917,61 @@ class NetworkCaptureStopTool extends BaseBrowserToolExecutor {
return createErrorResponse('No active network captures found in any tab.');
}
const requestedTabId =
typeof args?.tabId === 'number' && Number.isFinite(args.tabId) && args.tabId > 0
? args.tabId
: null;
// If caller specifies a tabId, stop ONLY that tab (do not affect other captures).
if (requestedTabId !== null) {
if (!startTool.captureData.has(requestedTabId)) {
return createErrorResponse(`No active network capture found for tab ${requestedTabId}.`);
}
const stopResult = await startTool.stopCapture(requestedTabId);
if (!stopResult.success) {
return createErrorResponse(
stopResult.message || `Failed to stop network capture for tab ${requestedTabId}`,
);
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
message: `Capture complete. ${stopResult.data?.requestCount || 0} requests captured.`,
tabId: requestedTabId,
tabUrl: stopResult.data?.tabUrl || 'N/A',
tabTitle: stopResult.data?.tabTitle || 'Unknown Tab',
requestCount: stopResult.data?.requestCount || 0,
commonRequestHeaders: stopResult.data?.commonRequestHeaders || {},
commonResponseHeaders: stopResult.data?.commonResponseHeaders || {},
requests: stopResult.data?.requests || [],
captureStartTime: stopResult.data?.captureStartTime,
captureEndTime: stopResult.data?.captureEndTime,
totalDurationMs: stopResult.data?.totalDurationMs,
settingsUsed: stopResult.data?.settingsUsed || {},
totalRequestsReceived: stopResult.data?.totalRequestsReceived || 0,
requestLimitReached: stopResult.data?.requestLimitReached || false,
remainingCaptures: Array.from(startTool.captureData.keys()),
}),
},
],
isError: false,
};
}
// Get current active tab
const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true });
const resolvedWindowId =
typeof args?.windowId === 'number' && Number.isFinite(args.windowId) && args.windowId > 0
? args.windowId
: undefined;
const activeTabs =
typeof resolvedWindowId === 'number'
? await chrome.tabs.query({ active: true, windowId: resolvedWindowId })
: await chrome.tabs.query({ active: true, currentWindow: true });
const activeTabId = activeTabs[0]?.id;
// Determine the primary tab to stop
@@ -10,6 +10,8 @@ interface NetworkCaptureToolParams {
action: 'start' | 'stop';
needResponseBody?: boolean;
url?: string;
tabId?: number;
windowId?: number;
maxCaptureTime?: number;
inactivityTimeout?: number;
includeStatic?: boolean;
@@ -48,10 +50,7 @@ function decorateJsonResult(result: ToolResult, extra: Record<string, unknown>):
* Check if debugger-based capture is active
*/
function isDebuggerCaptureActive(): boolean {
const captureData = (
networkDebuggerStartTool as unknown as { captureData?: Map<number, unknown> }
).captureData;
return captureData instanceof Map && captureData.size > 0;
return networkDebuggerStartTool.captureData.size > 0;
}
/**
@@ -108,6 +107,8 @@ class NetworkCaptureTool extends BaseBrowserToolExecutor {
const result = await delegate.execute({
url: args.url,
tabId: args.tabId,
windowId: args.windowId,
maxCaptureTime: args.maxCaptureTime,
inactivityTimeout: args.inactivityTimeout,
includeStatic: args.includeStatic,
@@ -146,7 +147,7 @@ class NetworkCaptureTool extends BaseBrowserToolExecutor {
const delegateStop =
backendToStop === 'debugger' ? networkDebuggerStopTool : networkCaptureStopTool;
const result = await delegateStop.execute();
const result = await delegateStop.execute({ tabId: args.tabId, windowId: args.windowId });
return decorateJsonResult(result, {
backend: backendToStop,
@@ -11,12 +11,22 @@ interface NetworkRequestToolParams {
headers?: Record<string, string>; // User-provided headers
body?: any; // User-provided body
timeout?: number; // Timeout for the network request itself
tabId?: number; // Optional target existing tab id
windowId?: number; // When no tabId, pick active tab from this window
// Optional multipart/form-data descriptor. When provided, overrides body and lets the helper build FormData.
// Shape: { fields?: Record<string, string|number|boolean>, files?: Array<{ name: string, fileUrl?: string, filePath?: string, base64Data?: string, filename?: string, contentType?: string }> }
// Or a compact array: [ [name, fileSpec, filename?], ... ] where fileSpec can be 'url:...', 'file:/abs/path', 'base64:...'
formData?: any;
}
function isValidTabId(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0;
}
function isValidWindowId(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0;
}
/**
* NetworkRequestTool - Sends network requests based on provided parameters.
*/
@@ -30,6 +40,8 @@ class NetworkRequestTool extends BaseBrowserToolExecutor {
headers = {},
body,
timeout = DEFAULT_NETWORK_REQUEST_TIMEOUT,
tabId,
windowId,
} = args;
console.log(`NetworkRequestTool: Executing with options:`, args);
@@ -39,20 +51,36 @@ class NetworkRequestTool extends BaseBrowserToolExecutor {
}
try {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tabs[0]?.id) {
return createErrorResponse('No active tab found or tab has no ID.');
let targetTabId: number | null = null;
if (isValidTabId(tabId)) {
try {
const t = await chrome.tabs.get(tabId);
if (t?.id) targetTabId = t.id;
} catch {
// ignore
}
} else {
const resolvedWindowId = isValidWindowId(windowId) ? windowId : undefined;
const tabs =
typeof resolvedWindowId === 'number'
? await chrome.tabs.query({ active: true, windowId: resolvedWindowId })
: await chrome.tabs.query({ active: true, currentWindow: true });
if (tabs[0]?.id) targetTabId = tabs[0].id;
}
if (!targetTabId) {
return createErrorResponse('No target tab found.');
}
const activeTabId = tabs[0].id;
// Ensure content script is available in the target tab
await this.injectContentScript(activeTabId, ['inject-scripts/network-helper.js']);
await this.injectContentScript(targetTabId, ['inject-scripts/network-helper.js']);
console.log(
`NetworkRequestTool: Sending to content script: URL=${url}, Method=${method}, Headers=${Object.keys(headers).join(',')}, BodyType=${typeof body}`,
);
const resultFromContentScript = await this.sendMessageToTab(activeTabId, {
const resultFromContentScript = await this.sendMessageToTab(targetTabId, {
action: TOOL_MESSAGE_TYPES.NETWORK_SEND_REQUEST,
url: url,
method: method,
@@ -6,17 +6,23 @@ import { cdpSessionManager } from '@/utils/cdp-session-manager';
type OwnerTag = 'performance';
interface StartTraceParams {
tabId?: number; // target existing tab id
windowId?: number; // when no tabId, pick active tab from this window
reload?: boolean; // whether to reload the page after starting trace
autoStop?: boolean; // whether to auto stop after a short duration
durationMs?: number; // custom duration when autoStop is true (default 5000)
}
interface StopTraceParams {
tabId?: number; // target existing tab id
windowId?: number; // when no tabId, pick active tab from this window
saveToDownloads?: boolean; // save trace to Downloads as JSON (default true)
filenamePrefix?: string; // filename prefix (default 'performance_trace')
}
interface AnalyzeInsightParams {
tabId?: number; // target existing tab id
windowId?: number; // when no tabId, pick active tab from this window
insightName?: string; // placeholder for future deep insights
}
@@ -45,6 +51,34 @@ const LAST_RESULTS = new Map<
}
>();
function isValidTabId(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0;
}
function isValidWindowId(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0;
}
async function resolveTargetTab(options: {
tabId?: unknown;
windowId?: unknown;
}): Promise<chrome.tabs.Tab | null> {
if (isValidTabId(options.tabId)) {
try {
return await chrome.tabs.get(options.tabId);
} catch {
return null;
}
}
const windowId = isValidWindowId(options.windowId) ? options.windowId : undefined;
const [activeTab] =
typeof windowId === 'number'
? await chrome.tabs.query({ active: true, windowId })
: await chrome.tabs.query({ active: true, currentWindow: true });
return activeTab ?? null;
}
function tracingCategories(): string[] {
// Keep broadly consistent with other project
return [
@@ -221,15 +255,15 @@ class PerformanceStartTraceTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.PERFORMANCE_START_TRACE;
async execute(args: StartTraceParams): Promise<ToolResult> {
const { reload = false, autoStop = false, durationMs = 5000 } = args || {};
const { tabId, windowId, reload = false, autoStop = false, durationMs = 5000 } = args || {};
try {
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!activeTab?.id) {
const targetTab = await resolveTargetTab({ tabId, windowId });
if (!targetTab?.id) {
return createErrorResponse('No active tab found');
}
const tabId = activeTab.id;
const existed = sessions.get(tabId);
const targetTabId = targetTab.id;
const existed = sessions.get(targetTabId);
if (existed?.recording) {
return {
content: [{ type: 'text', text: 'Error: a performance trace is already running.' }],
@@ -237,15 +271,15 @@ class PerformanceStartTraceTool extends BaseBrowserToolExecutor {
};
}
await cdpSessionManager.attach(tabId, 'performance');
await cdpSessionManager.attach(targetTabId, 'performance');
const state: TraceSessionState = {
recording: true,
events: [],
startedAt: Date.now(),
pageUrl: activeTab.url || '',
pageUrl: targetTab.url || '',
listener: (source, method, params) => {
if (source.tabId !== tabId) return;
if (source.tabId !== targetTabId) return;
if (method === 'Tracing.dataCollected' && params?.value) {
try {
state.events.push(...(params.value as any[]));
@@ -259,11 +293,11 @@ class PerformanceStartTraceTool extends BaseBrowserToolExecutor {
},
};
chrome.debugger.onEvent.addListener(state.listener);
sessions.set(tabId, state);
sessions.set(targetTabId, state);
// Start tracing with categories
const cats = tracingCategories().join(',');
await cdpSessionManager.sendCommand(tabId, 'Tracing.start', {
await cdpSessionManager.sendCommand(targetTabId, 'Tracing.start', {
categories: cats,
options: 'record-as-much-as-possible',
transferMode: 'ReportEvents',
@@ -271,7 +305,7 @@ class PerformanceStartTraceTool extends BaseBrowserToolExecutor {
if (reload) {
try {
await cdpSessionManager.sendCommand(tabId, 'Page.reload', { ignoreCache: true });
await cdpSessionManager.sendCommand(targetTabId, 'Page.reload', { ignoreCache: true });
} catch {
// best effort; ignore if fails
}
@@ -281,7 +315,7 @@ class PerformanceStartTraceTool extends BaseBrowserToolExecutor {
setTimeout(
async () => {
try {
await cdpSessionManager.sendCommand(tabId, 'Tracing.end');
await cdpSessionManager.sendCommand(targetTabId, 'Tracing.end');
} catch {
// ignore
}
@@ -317,12 +351,12 @@ class PerformanceStopTraceTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.PERFORMANCE_STOP_TRACE;
async execute(args: StopTraceParams): Promise<ToolResult> {
const { saveToDownloads = true, filenamePrefix } = args || {};
const { tabId, windowId, saveToDownloads = true, filenamePrefix } = args || {};
try {
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!activeTab?.id) return createErrorResponse('No active tab found');
const tabId = activeTab.id;
const session = sessions.get(tabId);
const targetTab = await resolveTargetTab({ tabId, windowId });
if (!targetTab?.id) return createErrorResponse('No active tab found');
const targetTabId = targetTab.id;
const session = sessions.get(targetTabId);
if (!session) {
return {
content: [
@@ -335,7 +369,7 @@ class PerformanceStopTraceTool extends BaseBrowserToolExecutor {
let stopResult: { completed: boolean } = { completed: false };
if (session.recording) {
// End tracing and wait for completion signal
await cdpSessionManager.sendCommand(tabId, 'Tracing.end');
await cdpSessionManager.sendCommand(targetTabId, 'Tracing.end');
await getOrCreateStopPromise(session);
stopResult = await session.stopPromise!;
} else {
@@ -343,7 +377,7 @@ class PerformanceStopTraceTool extends BaseBrowserToolExecutor {
stopResult = { completed: true };
}
// Fetch metrics before detach
const metrics = await enablePerformanceMetrics(tabId);
const metrics = await enablePerformanceMetrics(targetTabId);
// Cleanup event listener and detach
try {
@@ -352,7 +386,7 @@ class PerformanceStopTraceTool extends BaseBrowserToolExecutor {
// ignore
}
try {
await cdpSessionManager.detach(tabId, 'performance');
await cdpSessionManager.detach(targetTabId, 'performance');
} catch {
// ignore
}
@@ -372,7 +406,7 @@ class PerformanceStopTraceTool extends BaseBrowserToolExecutor {
}
}
LAST_RESULTS.set(tabId, {
LAST_RESULTS.set(targetTabId, {
events: session.events,
startedAt: session.startedAt,
endedAt,
@@ -381,7 +415,7 @@ class PerformanceStopTraceTool extends BaseBrowserToolExecutor {
metrics,
});
sessions.delete(tabId);
sessions.delete(targetTabId);
return {
content: [
@@ -418,12 +452,12 @@ class PerformanceAnalyzeInsightTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.PERFORMANCE_ANALYZE_INSIGHT;
async execute(args: AnalyzeInsightParams & { timeoutMs?: number }): Promise<ToolResult> {
const { insightName } = args || {};
const { tabId, windowId, insightName } = args || {};
try {
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!activeTab?.id) return createErrorResponse('No active tab found');
const tabId = activeTab.id;
const result = LAST_RESULTS.get(tabId);
const targetTab = await resolveTargetTab({ tabId, windowId });
if (!targetTab?.id) return createErrorResponse('No active tab found');
const targetTabId = targetTab.id;
const result = LAST_RESULTS.get(targetTabId);
if (!result) {
return {
content: [
@@ -2,6 +2,14 @@ import { createErrorResponse } from '@/common/tool-handler';
import { ERROR_MESSAGES } from '@/common/constants';
import * as browserTools from './browser';
import { flowRunTool, listPublishedFlowsTool } from './record-replay';
import { assessToolRisk, getToolDescription } from './tool-risk';
import { requestToolApproval } from './tool-approval';
import {
appendToolActionLogEntry,
formatToolArgsSummary,
formatToolResultSummary,
type ToolCallSource,
} from './tool-action-log';
const tools = { ...browserTools, flowRunTool, listPublishedFlowsTool } as any;
const toolsMap = new Map(Object.values(tools).map((tool: any) => [tool.name, tool]));
@@ -14,21 +22,135 @@ export interface ToolCallParam {
args: any;
}
export interface ToolCallContext {
source?: ToolCallSource;
}
async function resolveIncognitoHint(args: any): Promise<boolean> {
try {
const tabId = typeof args?.tabId === 'number' ? args.tabId : null;
if (tabId !== null) {
const tab = await chrome.tabs.get(tabId);
return tab.incognito === true;
}
const windowId = typeof args?.windowId === 'number' ? args.windowId : null;
if (windowId !== null) {
const tabs = await chrome.tabs.query({ active: true, windowId });
return tabs[0]?.incognito === true;
}
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
return tabs[0]?.incognito === true;
} catch {
return false;
}
}
/**
* Handle tool execution
*/
export const handleCallTool = async (param: ToolCallParam) => {
const tool = toolsMap.get(param.name);
export const handleCallTool = async (param: ToolCallParam, ctx?: ToolCallContext) => {
const source: ToolCallSource = ctx?.source ?? 'internal';
const startedAt = Date.now();
const toolName = String(param?.name ?? '').trim();
const args = param?.args;
const toolDescription = getToolDescription(toolName);
const risk = assessToolRisk(toolName, args);
const incognito = await resolveIncognitoHint(args);
const argsSummary = formatToolArgsSummary(args);
const tool = toolsMap.get(toolName);
if (!tool) {
return createErrorResponse(`Tool ${param.name} not found`);
const finishedAt = Date.now();
await appendToolActionLogEntry({
toolName,
toolDescription,
risk,
source,
incognito,
status: 'error',
startedAt,
finishedAt,
durationMs: Math.max(0, finishedAt - startedAt),
argsSummary,
resultSummary: `Tool ${toolName} not found`,
});
return createErrorResponse(`Tool ${toolName} not found`);
}
// Agent Mode guard: require explicit user approval for risky tool calls initiated by the native host.
if (source === 'native_host' && risk.requiresConfirmation) {
const approval = await requestToolApproval({
toolName,
toolDescription,
risk,
argsSummary,
tabId: typeof args?.tabId === 'number' ? args.tabId : undefined,
windowId: typeof args?.windowId === 'number' ? args.windowId : undefined,
});
if (!approval.approved) {
const finishedAt = Date.now();
await appendToolActionLogEntry({
toolName,
toolDescription,
risk,
source,
incognito,
status: 'denied',
startedAt,
finishedAt,
durationMs: Math.max(0, finishedAt - startedAt),
argsSummary,
resultSummary: `Denied (${approval.reason})`,
});
return createErrorResponse('Tool call denied by user');
}
}
try {
return await tool.execute(param.args);
const result = await tool.execute(args);
const finishedAt = Date.now();
await appendToolActionLogEntry({
toolName,
toolDescription,
risk,
source,
incognito,
status: result?.isError === true ? 'error' : 'success',
startedAt,
finishedAt,
durationMs: Math.max(0, finishedAt - startedAt),
argsSummary,
resultSummary: formatToolResultSummary(result),
});
return result;
} catch (error) {
console.error(`Tool execution failed for ${param.name}:`, error);
return createErrorResponse(
error instanceof Error ? error.message : ERROR_MESSAGES.TOOL_EXECUTION_FAILED,
);
const finishedAt = Date.now();
const errorMessage =
error instanceof Error
? error.message
: error
? String(error)
: ERROR_MESSAGES.TOOL_EXECUTION_FAILED;
await appendToolActionLogEntry({
toolName,
toolDescription,
risk,
source,
incognito,
status: 'error',
startedAt,
finishedAt,
durationMs: Math.max(0, finishedAt - startedAt),
argsSummary,
resultSummary: `Error: ${errorMessage}`,
});
return createErrorResponse(errorMessage || ERROR_MESSAGES.TOOL_EXECUTION_FAILED);
}
};
@@ -0,0 +1,263 @@
import type { ToolResult } from '@/common/tool-handler';
import type { ToolRiskAssessment } from './tool-risk';
export type ToolCallSource = 'native_host' | 'extension_ui' | 'internal';
export type ToolActionLogStatus = 'success' | 'error' | 'denied';
export interface ToolActionLogEntryV1 {
version: 1;
id: string;
toolName: string;
toolDescription?: string | null;
risk: ToolRiskAssessment;
source: ToolCallSource;
incognito: boolean;
status: ToolActionLogStatus;
startedAt: number;
finishedAt: number;
durationMs: number;
argsSummary: string;
resultSummary: string;
}
interface ToolActionLogStateV1 {
version: 1;
updatedAt: number;
entries: ToolActionLogEntryV1[];
}
const STORAGE_KEY = 'tool_action_log_v1';
const MAX_ENTRIES = 200;
const MAX_SUMMARY_CHARS = 800;
let cachedState: ToolActionLogStateV1 | null = null;
let loadOnce: Promise<ToolActionLogStateV1> | null = null;
let writeChain: Promise<void> = Promise.resolve();
function safeErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message || String(err);
return String(err);
}
function createId(): string {
try {
const id = crypto?.randomUUID?.();
if (id) return id;
} catch {
// Ignore
}
return `log_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
}
function truncate(text: string, maxChars: number): string {
const s = typeof text === 'string' ? text : String(text);
if (s.length <= maxChars) return s;
return s.slice(0, Math.max(0, maxChars - 1)) + '\u2026';
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== 'object') return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
function sanitizeValue(value: unknown, depth: number, keyHint?: string): unknown {
if (depth <= 0) return '[truncated]';
const key = typeof keyHint === 'string' ? keyHint.toLowerCase() : '';
if (
key === 'base64data' ||
key === 'script' ||
key === 'body' ||
key === 'htmlcontent' ||
key === 'textcontent' ||
key === 'pagecontent' ||
key === 'content'
) {
if (typeof value === 'string') return `[redacted:${value.length}]`;
return '[redacted]';
}
if (typeof value === 'string') {
return truncate(value, 200);
}
if (typeof value === 'number' || typeof value === 'boolean' || value === null) return value;
if (Array.isArray(value)) {
const out: unknown[] = [];
for (let i = 0; i < Math.min(value.length, 20); i++) {
out.push(sanitizeValue(value[i], depth - 1));
}
if (value.length > 20) out.push(`[+${value.length - 20} more]`);
return out;
}
if (isPlainObject(value)) {
const out: Record<string, unknown> = {};
const entries = Object.entries(value);
for (let i = 0; i < Math.min(entries.length, 50); i++) {
const [k, v] = entries[i];
out[k] = sanitizeValue(v, depth - 1, k);
}
if (entries.length > 50) out.__truncated__ = `[+${entries.length - 50} keys]`;
return out;
}
// Fallback for non-plain objects (e.g., DOM handles)
return `[${Object.prototype.toString.call(value)}]`;
}
export function formatToolArgsSummary(args: unknown): string {
if (args === undefined) return '(no args)';
try {
const sanitized = sanitizeValue(args, 4);
return truncate(JSON.stringify(sanitized, null, 2), MAX_SUMMARY_CHARS);
} catch (err) {
return truncate(`(unserializable args) ${safeErrorMessage(err)}`, MAX_SUMMARY_CHARS);
}
}
function getFirstText(result: ToolResult | null | undefined): string | null {
const first = result?.content?.[0];
if (!first || first.type !== 'text') return null;
const text = typeof first.text === 'string' ? first.text.trim() : '';
return text ? text : null;
}
export function formatToolResultSummary(result: ToolResult | null | undefined): string {
if (!result) return '(no result)';
if (result.isError === true) {
const txt = getFirstText(result);
return truncate(txt ? `Error: ${txt}` : 'Error', MAX_SUMMARY_CHARS);
}
const txt = getFirstText(result);
if (!txt) return '(non-text result)';
// Many tools return JSON payloads. Try to summarize without storing large fields.
const trimmed = txt.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed);
const sanitized = sanitizeValue(parsed, 3);
return truncate(JSON.stringify(sanitized, null, 2), MAX_SUMMARY_CHARS);
} catch {
// Fall back to raw truncation.
}
}
return truncate(trimmed, MAX_SUMMARY_CHARS);
}
function defaultState(): ToolActionLogStateV1 {
return { version: 1, updatedAt: Date.now(), entries: [] };
}
async function loadState(): Promise<ToolActionLogStateV1> {
if (cachedState) return cachedState;
if (loadOnce) return loadOnce;
loadOnce = (async () => {
try {
const stored = await chrome.storage.local.get([STORAGE_KEY]);
const raw = stored[STORAGE_KEY] as Partial<ToolActionLogStateV1> | undefined;
if (raw && raw.version === 1 && Array.isArray(raw.entries)) {
const entries = raw.entries.filter(
(e) => e && (e as any).version === 1,
) as ToolActionLogEntryV1[];
cachedState = { version: 1, updatedAt: raw.updatedAt ?? Date.now(), entries };
return cachedState;
}
} catch {
// Ignore
}
cachedState = defaultState();
return cachedState;
})().finally(() => {
loadOnce = null;
});
return loadOnce;
}
async function persistState(state: ToolActionLogStateV1): Promise<void> {
try {
await chrome.storage.local.set({ [STORAGE_KEY]: state });
} catch {
// Ignore storage errors (quota/incognito restrictions)
}
}
export async function appendToolActionLogEntry(
entry: Omit<ToolActionLogEntryV1, 'version' | 'id'> & { id?: string },
): Promise<void> {
const state = await loadState();
const id = typeof entry.id === 'string' && entry.id.trim() ? entry.id.trim() : createId();
const normalized: ToolActionLogEntryV1 = {
version: 1,
id,
toolName: entry.toolName,
toolDescription: entry.toolDescription ?? null,
risk: entry.risk,
source: entry.source,
incognito: entry.incognito,
status: entry.status,
startedAt: entry.startedAt,
finishedAt: entry.finishedAt,
durationMs: entry.durationMs,
argsSummary: truncate(entry.argsSummary, MAX_SUMMARY_CHARS),
resultSummary: truncate(entry.resultSummary, MAX_SUMMARY_CHARS),
};
state.entries.unshift(normalized);
if (state.entries.length > MAX_ENTRIES) {
state.entries.splice(MAX_ENTRIES);
}
state.updatedAt = Date.now();
cachedState = state;
writeChain = writeChain.then(
() => persistState(state),
() => persistState(state),
);
await writeChain;
}
export async function listToolActionLogEntries(options: {
incognito: boolean;
query?: string;
maxResults?: number;
}): Promise<ToolActionLogEntryV1[]> {
const state = await loadState();
const max =
typeof options.maxResults === 'number' && options.maxResults > 0
? Math.floor(options.maxResults)
: 50;
const q = typeof options.query === 'string' ? options.query.trim().toLowerCase() : '';
const filtered = state.entries.filter((e) => e.incognito === options.incognito);
if (!q) return filtered.slice(0, max);
return filtered
.filter((e) => {
const haystack = `${e.toolName} ${e.argsSummary} ${e.resultSummary}`.toLowerCase();
return haystack.includes(q);
})
.slice(0, max);
}
export async function clearToolActionLog(options: { incognito: boolean }): Promise<void> {
const state = await loadState();
const kept = state.entries.filter((e) => e.incognito !== options.incognito);
state.entries = kept;
state.updatedAt = Date.now();
cachedState = state;
writeChain = writeChain.then(
() => persistState(state),
() => persistState(state),
);
await writeChain;
}
@@ -0,0 +1,215 @@
import { BACKGROUND_MESSAGE_TYPES, TOOL_MESSAGE_TYPES } from '@/common/message-types';
import { BaseBrowserToolExecutor } from './base-browser';
import type { ToolRiskAssessment } from './tool-risk';
export interface ToolApprovalRequest {
toolName: string;
toolDescription?: string | null;
risk: ToolRiskAssessment;
argsSummary: string;
tabId?: number;
windowId?: number;
timeoutMs?: number;
}
export interface ToolApprovalResult {
approved: boolean;
reason: 'approved' | 'denied' | 'timeout' | 'ui_unavailable';
}
interface ToolApprovalUiEventMessage {
type: typeof BACKGROUND_MESSAGE_TYPES.TOOL_APPROVAL_UI_EVENT;
sessionId: string;
event: 'approve' | 'deny';
}
const DEFAULT_TIMEOUT_MS = 2 * 60 * 1000;
const MIN_TIMEOUT_MS = 5 * 1000;
const MAX_TIMEOUT_MS = 10 * 60 * 1000;
function normalizeTimeoutMs(value: unknown): number {
const n = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN;
if (!Number.isFinite(n)) return DEFAULT_TIMEOUT_MS;
return Math.min(Math.max(Math.floor(n), MIN_TIMEOUT_MS), MAX_TIMEOUT_MS);
}
function safeErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message || String(err);
return String(err);
}
function createSessionId(): string {
return `ta_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
}
class ToolApprovalHelper extends BaseBrowserToolExecutor {
name = 'tool_approval';
// Not used; required by abstract base.
async execute(): Promise<any> {
throw new Error('Not implemented');
}
tryGetTabPublic(tabId?: number): Promise<chrome.tabs.Tab | null> {
return this.tryGetTab(tabId);
}
getActiveTabOrThrowInWindowPublic(windowId?: number): Promise<chrome.tabs.Tab> {
return this.getActiveTabOrThrowInWindow(windowId);
}
ensureFocusPublic(
tab: chrome.tabs.Tab,
options: { activate?: boolean; focusWindow?: boolean },
): Promise<void> {
return this.ensureFocus(tab, options);
}
injectContentScriptPublic(
tabId: number,
files: string[],
injectImmediately = false,
world: 'MAIN' | 'ISOLATED' = 'ISOLATED',
): Promise<void> {
return this.injectContentScript(tabId, files, injectImmediately, world);
}
sendMessageToTabPublic(tabId: number, message: any, frameId?: number): Promise<any> {
return this.sendMessageToTab(tabId, message, frameId);
}
}
const helper = new ToolApprovalHelper();
// Serialize approvals to avoid multiple overlapping prompts.
let approvalQueue: Promise<unknown> = Promise.resolve();
async function resolveTab(tabId?: number, windowId?: number): Promise<chrome.tabs.Tab> {
const explicit = await helper.tryGetTabPublic(tabId);
if (explicit && explicit.id) return explicit;
return await helper.getActiveTabOrThrowInWindowPublic(windowId);
}
async function showPrompt(
tab: chrome.tabs.Tab,
payload: Record<string, unknown>,
): Promise<boolean> {
if (!tab.id) return false;
try {
await helper.injectContentScriptPublic(
tab.id,
['inject-scripts/tool-approval.js'],
false,
'ISOLATED',
);
await helper.sendMessageToTabPublic(
tab.id,
{
action: TOOL_MESSAGE_TYPES.TOOL_APPROVAL_SHOW,
...payload,
},
0,
);
return true;
} catch {
return false;
}
}
async function hidePrompt(tab: chrome.tabs.Tab, sessionId: string): Promise<void> {
if (!tab.id) return;
try {
await helper.sendMessageToTabPublic(
tab.id,
{
action: TOOL_MESSAGE_TYPES.TOOL_APPROVAL_HIDE,
sessionId,
},
0,
);
} catch {
// Best-effort
}
}
async function requestToolApprovalImpl(req: ToolApprovalRequest): Promise<ToolApprovalResult> {
const sessionId = createSessionId();
const timeoutMs = normalizeTimeoutMs(req.timeoutMs);
const deadlineTs = Date.now() + timeoutMs;
let tab: chrome.tabs.Tab;
try {
tab = await resolveTab(req.tabId, req.windowId);
} catch (err) {
console.warn('[ToolApproval] Failed to resolve tab:', safeErrorMessage(err));
return { approved: false, reason: 'ui_unavailable' };
}
// Best-effort: focus the target tab so the user can see the prompt.
try {
await helper.ensureFocusPublic(tab, { activate: true, focusWindow: true });
} catch {
// Ignore focus errors
}
const ok = await showPrompt(tab, {
sessionId,
deadlineTs,
toolName: req.toolName,
toolDescription: req.toolDescription || undefined,
risk: {
level: req.risk.level,
categories: req.risk.categories,
reasons: req.risk.reasons,
},
argsSummary: req.argsSummary,
});
if (!ok) {
return { approved: false, reason: 'ui_unavailable' };
}
return await new Promise<ToolApprovalResult>((resolve) => {
let done = false;
const timer = setTimeout(() => {
if (done) return;
done = true;
chrome.runtime.onMessage.removeListener(onMessage);
void hidePrompt(tab, sessionId);
resolve({ approved: false, reason: 'timeout' });
}, timeoutMs);
const onMessage = (
message: unknown,
sender: chrome.runtime.MessageSender,
sendResponse: (response?: unknown) => void,
) => {
const msg = message as Partial<ToolApprovalUiEventMessage> | undefined;
if (!msg || msg.type !== BACKGROUND_MESSAGE_TYPES.TOOL_APPROVAL_UI_EVENT) return;
if (msg.sessionId !== sessionId) return;
if (sender?.tab?.id !== tab.id) return;
sendResponse({ success: true });
if (done) return;
done = true;
clearTimeout(timer);
chrome.runtime.onMessage.removeListener(onMessage);
void hidePrompt(tab, sessionId);
const approved = msg.event === 'approve';
resolve({ approved, reason: approved ? 'approved' : 'denied' });
};
chrome.runtime.onMessage.addListener(onMessage);
});
}
export async function requestToolApproval(req: ToolApprovalRequest): Promise<ToolApprovalResult> {
const run = async () => requestToolApprovalImpl(req);
const chained = approvalQueue.then(run, run);
approvalQueue = chained.then(
() => undefined,
() => undefined,
);
return chained;
}
@@ -0,0 +1,226 @@
import { TOOL_NAMES, TOOL_SCHEMAS } from 'chrome-mcp-shared';
export type ToolRiskCategory =
| 'read'
| 'write'
| 'destructive'
| 'external_network'
| 'local_file'
| 'debugger'
| 'code_execution';
export type ToolRiskLevel = 'low' | 'medium' | 'high';
export interface ToolRiskAssessment {
level: ToolRiskLevel;
categories: ToolRiskCategory[];
reasons: string[];
requiresConfirmation: boolean;
}
function uniq<T>(values: readonly T[]): T[] {
return Array.from(new Set(values));
}
function normalizeToolName(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
export function getToolDescription(toolName: string): string | null {
const name = normalizeToolName(toolName);
if (!name) return null;
const schema = TOOL_SCHEMAS.find((t) => t.name === name);
const desc = schema?.description;
return typeof desc === 'string' && desc.trim() ? desc.trim() : null;
}
function hasTruthyFlag(args: unknown, key: string): boolean {
if (!args || typeof args !== 'object') return false;
return (args as Record<string, unknown>)[key] === true;
}
function normalizeHttpMethod(value: unknown): string {
return typeof value === 'string' ? value.trim().toUpperCase() : '';
}
/**
* Assess risk level for a tool call.
*
* Notes:
* - This is used for extension-side "Agent Mode" safeguards (Phase 14).
* - Risk categories are best-effort and intentionally conservative for unknown tools.
*/
export function assessToolRisk(toolName: string, args: unknown): ToolRiskAssessment {
const name = normalizeToolName(toolName);
// Default: unknown tool is treated as high risk.
if (!name) {
return {
level: 'high',
categories: ['code_execution'],
reasons: ['Unknown tool name'],
requiresConfirmation: true,
};
}
// ----------------------------
// Read-only / low risk
// ----------------------------
if (
name === TOOL_NAMES.BROWSER.GET_WINDOWS_AND_TABS ||
name === TOOL_NAMES.BROWSER.SEARCH_TABS_CONTENT ||
name === TOOL_NAMES.BROWSER.HISTORY ||
name === TOOL_NAMES.BROWSER.BOOKMARK_SEARCH ||
name === TOOL_NAMES.BROWSER.READ_PAGE
) {
return {
level: 'low',
categories: ['read'],
reasons: [],
requiresConfirmation: false,
};
}
// Web content fetcher may navigate when url is provided and not open.
if (name === TOOL_NAMES.BROWSER.WEB_FETCHER) {
const url = typeof (args as any)?.url === 'string' ? String((args as any).url).trim() : '';
if (url) {
return {
level: 'medium',
categories: ['read', 'external_network', 'write'],
reasons: ['May open a URL in a new tab'],
requiresConfirmation: true,
};
}
return {
level: 'low',
categories: ['read'],
reasons: [],
requiresConfirmation: false,
};
}
// ----------------------------
// Medium / high risk (requires confirmation)
// ----------------------------
if (name === TOOL_NAMES.BROWSER.NETWORK_REQUEST) {
const method = normalizeHttpMethod((args as any)?.method) || 'GET';
const isWrite = method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS';
return {
level: isWrite ? 'high' : 'medium',
categories: uniq(['external_network', isWrite ? 'destructive' : 'write']),
reasons: ['Sends an outbound network request'],
requiresConfirmation: true,
};
}
if (name === TOOL_NAMES.BROWSER.NETWORK_CAPTURE) {
const needResponseBody = hasTruthyFlag(args, 'needResponseBody');
return needResponseBody
? {
level: 'high',
categories: ['read', 'debugger'],
reasons: ['Captures response bodies via debugger backend'],
requiresConfirmation: true,
}
: {
level: 'medium',
categories: ['read'],
reasons: ['Captures network request metadata'],
requiresConfirmation: true,
};
}
if (
name === TOOL_NAMES.BROWSER.SCREENSHOT ||
name === TOOL_NAMES.BROWSER.PERFORMANCE_START_TRACE ||
name === TOOL_NAMES.BROWSER.PERFORMANCE_STOP_TRACE ||
name === TOOL_NAMES.BROWSER.PERFORMANCE_ANALYZE_INSIGHT ||
name === TOOL_NAMES.BROWSER.GIF_RECORDER
) {
// Most of these tools either write artifacts (downloads) or capture sensitive data.
return {
level: 'medium',
categories: ['read', 'local_file'],
reasons: ['May capture and/or export diagnostic artifacts'],
requiresConfirmation: true,
};
}
if (
name === TOOL_NAMES.BROWSER.JAVASCRIPT ||
name === TOOL_NAMES.BROWSER.INJECT_SCRIPT ||
name === TOOL_NAMES.BROWSER.SEND_COMMAND_TO_INJECT_SCRIPT ||
name === TOOL_NAMES.BROWSER.USERSCRIPT
) {
return {
level: 'high',
categories: ['code_execution', 'write'],
reasons: ['Executes or injects code into a page'],
requiresConfirmation: true,
};
}
if (name === TOOL_NAMES.BROWSER.FILE_UPLOAD) {
return {
level: 'high',
categories: ['local_file', 'write'],
reasons: ['Uploads local or remote files into a page'],
requiresConfirmation: true,
};
}
if (
name === TOOL_NAMES.BROWSER.CLICK ||
name === TOOL_NAMES.BROWSER.FILL ||
name === TOOL_NAMES.BROWSER.KEYBOARD ||
name === TOOL_NAMES.BROWSER.COMPUTER ||
name === TOOL_NAMES.BROWSER.NAVIGATE ||
name === TOOL_NAMES.BROWSER.CLOSE_TABS ||
name === TOOL_NAMES.BROWSER.SWITCH_TAB ||
name === TOOL_NAMES.BROWSER.HANDLE_DIALOG
) {
return {
level: name === TOOL_NAMES.BROWSER.CLOSE_TABS ? 'high' : 'medium',
categories: name === TOOL_NAMES.BROWSER.CLOSE_TABS ? ['destructive', 'write'] : ['write'],
reasons: ['Directly changes browser or page state'],
requiresConfirmation: true,
};
}
if (name === TOOL_NAMES.BROWSER.BOOKMARK_ADD || name === TOOL_NAMES.BROWSER.BOOKMARK_DELETE) {
return {
level: 'high',
categories: ['destructive', 'write'],
reasons: ['Modifies bookmarks'],
requiresConfirmation: true,
};
}
if (name === TOOL_NAMES.RECORD_REPLAY.FLOW_RUN) {
return {
level: 'high',
categories: ['write', 'code_execution'],
reasons: ['Runs an automated flow that may change page state'],
requiresConfirmation: true,
};
}
if (name === TOOL_NAMES.RECORD_REPLAY.LIST_PUBLISHED) {
return {
level: 'low',
categories: ['read'],
reasons: [],
requiresConfirmation: false,
};
}
// Default fallback: treat as high risk and require confirmation.
return {
level: 'high',
categories: ['code_execution'],
reasons: ['Unclassified tool'],
requiresConfirmation: true,
};
}
@@ -310,6 +310,8 @@ function toggleTheme() {
}
const store = useBuilderStore();
const preferredRunTabId = ref<number | null>(null);
// V3 RPC client
const rpc = useRRV3Rpc({
autoConnect: true,
@@ -348,6 +350,12 @@ function getQuery(): Record<string, string> {
async function bootstrap() {
const q = getQuery();
if (q.tabId) {
const parsed = Number(q.tabId);
if (Number.isSafeInteger(parsed) && parsed > 0) {
preferredRunTabId.value = parsed;
}
}
if (q.flowId) {
try {
await rpc.ensureConnected();
@@ -785,6 +793,7 @@ async function runFromSelected() {
await rpc.request('rr_v3.enqueueRun', {
flowId: saved.id as FlowId,
...(startNodeId ? { startNodeId: startNodeId as NodeId } : {}),
...(preferredRunTabId.value ? { tabId: preferredRunTabId.value } : {}),
});
} catch (e) {
pushToast(`运行失败:${e instanceof Error ? e.message : String(e)}`, 'error');
@@ -799,7 +808,10 @@ async function runAll() {
if (!saved) return;
await rpc.ensureConnected();
await rpc.request('rr_v3.enqueueRun', { flowId: saved.id as FlowId });
await rpc.request('rr_v3.enqueueRun', {
flowId: saved.id as FlowId,
...(preferredRunTabId.value ? { tabId: preferredRunTabId.value } : {}),
});
} catch (e) {
pushToast(`运行失败:${e instanceof Error ? e.message : String(e)}`, 'error');
}
@@ -7,6 +7,7 @@ import {
} from '@/common/message-types';
import { handleGifMessage } from './gif-encoder';
import { initKeepalive } from './rr-keepalive';
import { handleWebMonitorMessage } from './web-monitor';
// 初始化 RR V3 Keepalive
initKeepalive();
@@ -72,6 +73,11 @@ chrome.runtime.onMessage.addListener(
return true;
}
// Web Monitor fetch + extract (DOMParser)
if (handleWebMonitorMessage(message, sendResponse)) {
return true;
}
try {
switch (message.type) {
case SendMessageType.SimilarityEngineInit:
@@ -0,0 +1,246 @@
/**
* Web Monitor helper (Offscreen Document)
*
* Runs network fetch + DOM extraction in an offscreen document so we can use DOMParser.
* This keeps the background service worker free of DOM dependencies.
*/
import { MessageTarget, OFFSCREEN_MESSAGE_TYPES } from '@/common/message-types';
// ============================================================
// Types
// ============================================================
type ExtractorKind = 'selector_text' | 'selector_attr';
interface WebMonitorFetchExtractMessage {
target: MessageTarget;
type: typeof OFFSCREEN_MESSAGE_TYPES.WEB_MONITOR_FETCH_EXTRACT;
url: string;
extractor: ExtractorKind;
selector: string;
attribute?: string;
timeoutMs?: number;
maxBytes?: number;
}
interface WebMonitorFetchExtractResponse {
success: boolean;
error?: string;
url?: string;
status?: number;
extracted?: string | null;
title?: string | null;
byteLength?: number;
}
// ============================================================
// Helpers
// ============================================================
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function normalizeString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function clampInt(value: unknown, fallback: number, min: number, max: number): number {
const n = typeof value === 'number' ? value : Number(value);
const int = Number.isFinite(n) ? Math.floor(n) : fallback;
return Math.max(min, Math.min(max, int));
}
function safeErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message || String(err);
return String(err);
}
function normalizeUrl(input: string): string {
const raw = String(input ?? '').trim();
if (!raw) throw new Error('url is required');
let url: URL;
try {
url = new URL(raw);
} catch {
// Allow hostnames without scheme (assume https).
url = new URL(`https://${raw}`);
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Only http(s) URLs are supported');
}
// Avoid fragment noise for monitoring.
url.hash = '';
return url.toString();
}
function normalizeSelector(selector: string): string {
const s = String(selector ?? '').trim();
if (!s) throw new Error('selector is required');
if (s.length > 500) throw new Error('selector is too long');
return s;
}
function normalizeExtractor(kind: unknown): ExtractorKind {
return kind === 'selector_attr' ? 'selector_attr' : 'selector_text';
}
function normalizeAttribute(attr: unknown): string | null {
const a = String(attr ?? '').trim();
if (!a) return null;
if (a.length > 100) throw new Error('attribute is too long');
return a;
}
async function readTextWithCap(
resp: Response,
maxBytes: number,
): Promise<{ text: string; byteLength: number }> {
const contentLength = resp.headers.get('content-length');
const declared = contentLength ? Number(contentLength) : NaN;
if (Number.isFinite(declared) && declared > maxBytes) {
throw new Error(`Response is too large (${Math.floor(declared)} bytes)`);
}
const body = resp.body;
if (!body) {
const text = await resp.text();
const byteLength = new TextEncoder().encode(text).length;
if (byteLength > maxBytes) throw new Error(`Response is too large (${byteLength} bytes)`);
return { text, byteLength };
}
const reader = body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
total += value.byteLength;
if (total > maxBytes) {
try {
reader.cancel();
} catch {
// ignore
}
throw new Error(`Response is too large (> ${maxBytes} bytes)`);
}
chunks.push(value);
}
}
const all = new Uint8Array(total);
let offset = 0;
for (const c of chunks) {
all.set(c, offset);
offset += c.byteLength;
}
const text = new TextDecoder('utf-8', { fatal: false }).decode(all);
return { text, byteLength: total };
}
function collapseWhitespace(value: string): string {
return String(value ?? '')
.replace(/\s+/g, ' ')
.trim();
}
function extractFromHtml(options: {
html: string;
extractor: ExtractorKind;
selector: string;
attribute: string | null;
}): { extracted: string; title: string | null } {
if (typeof DOMParser === 'undefined') {
throw new Error('DOMParser is not available in offscreen document');
}
const parser = new DOMParser();
const doc = parser.parseFromString(options.html, 'text/html');
const title = collapseWhitespace(doc.querySelector('title')?.textContent || '') || null;
const el = doc.querySelector(options.selector);
if (!el) {
throw new Error(`Selector not found: ${options.selector}`);
}
if (options.extractor === 'selector_attr') {
const attr = options.attribute;
if (!attr) throw new Error('attribute is required for selector_attr');
const v = el.getAttribute(attr);
if (v == null) throw new Error(`Attribute "${attr}" not found`);
return { extracted: collapseWhitespace(v), title };
}
return { extracted: collapseWhitespace(el.textContent || ''), title };
}
async function fetchAndExtract(
message: WebMonitorFetchExtractMessage,
): Promise<WebMonitorFetchExtractResponse> {
const url = normalizeUrl(message.url);
const extractor = normalizeExtractor(message.extractor);
const selector = normalizeSelector(message.selector);
const attribute = normalizeAttribute(message.attribute);
const timeoutMs = clampInt(message.timeoutMs, 10_000, 1_000, 60_000);
const maxBytes = clampInt(message.maxBytes, 2_000_000, 100_000, 10_000_000);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const resp = await fetch(url, {
method: 'GET',
redirect: 'follow',
cache: 'no-store',
credentials: 'omit',
signal: controller.signal,
});
const { text: html, byteLength } = await readTextWithCap(resp, maxBytes);
const { extracted, title } = extractFromHtml({ html, extractor, selector, attribute });
return {
success: true,
url: resp.url || url,
status: resp.status,
extracted,
title,
byteLength,
};
} catch (err) {
return { success: false, error: safeErrorMessage(err) };
} finally {
clearTimeout(timeout);
}
}
function isWebMonitorMessage(message: unknown): message is WebMonitorFetchExtractMessage {
if (!isRecord(message)) return false;
if (message.target !== MessageTarget.Offscreen) return false;
return message.type === OFFSCREEN_MESSAGE_TYPES.WEB_MONITOR_FETCH_EXTRACT;
}
// ============================================================
// Message Handler
// ============================================================
export function handleWebMonitorMessage(
message: unknown,
sendResponse: (response: WebMonitorFetchExtractResponse) => void,
): boolean {
if (!isWebMonitorMessage(message)) return false;
void fetchAndExtract(message).then(sendResponse);
return true;
}
+20 -2
View File
@@ -511,9 +511,21 @@ const refreshRecordingStatus = async () => {
const runFlow = async (flowId: string) => {
try {
await rrRpc.ensureConnected();
let tabId: number | undefined;
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (typeof tab?.id === 'number' && Number.isSafeInteger(tab.id) && tab.id > 0) {
tabId = tab.id;
}
} catch {
// Best-effort: allow runs without tabId (runner will allocate an ephemeral tab)
}
// V3 enqueueRun - fire-and-forget (run executes asynchronously in background)
// Popup doesn't wait for completion; use sidepanel/builder for detailed results
const res = await rrRpc.request<{ runId: string }>('rr_v3.enqueueRun', { flowId });
const res = await rrRpc.request<{ runId: string }>('rr_v3.enqueueRun', {
flowId,
...(tabId ? { tabId } : {}),
});
if (!res || !res.runId) {
console.warn('[Popup] Failed to enqueue run');
return;
@@ -706,10 +718,16 @@ async function openTroubleshooting() {
}
}
function openBuilderWindow(flowId?: string, focusNodeId?: string) {
async function openBuilderWindow(flowId?: string, focusNodeId?: string) {
const url = new URL(chrome.runtime.getURL('builder.html'));
if (flowId) url.searchParams.set('flowId', flowId);
if (focusNodeId) url.searchParams.set('focus', focusNodeId);
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab?.id) url.searchParams.set('tabId', String(tab.id));
} catch {
// Best-effort: builder can still run without a preferred tabId
}
chrome.windows.create({ url: url.toString(), type: 'popup', width: 1280, height: 800 });
}
@@ -27,6 +27,19 @@
@edit-trigger="editTrigger"
@remove-trigger="removeTrigger"
/>
<TriggerEditorModal
:open="triggerEditorOpen"
:mode="triggerEditorMode"
:flows="flows"
:trigger="triggerEditorTrigger"
:saving="triggerEditorSaving"
:error="triggerEditorError"
:default-url="currentUrl"
@close="closeTriggerEditor"
@submit="handleTriggerEditorSubmit"
@open-builder="openBuilder({ flowId: $event })"
/>
</div>
<!-- Agent Chat Tab -->
@@ -290,9 +303,10 @@
import { computed, onMounted, ref, onUnmounted, watch } from 'vue';
import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';
import type { ElementMarker, UpsertMarkerRequest } from '@/common/element-marker-types';
import type { TriggerSpec } from '@/entrypoints/background/record-replay-v3/domain/triggers';
import AgentChat from './components/AgentChat.vue';
import SidepanelNavigator from './components/SidepanelNavigator.vue';
import { WorkflowsView } from './components/workflows';
import { TriggerEditorModal, WorkflowsView } from './components/workflows';
import { useAgentTheme } from './composables/useAgentTheme';
import { useWorkflowsV3, type FlowLite } from './composables/useWorkflowsV3';
@@ -320,6 +334,12 @@ const search = ref('');
const currentUrl = ref('');
const openRunId = ref<string | null>(null);
const triggerEditorOpen = ref(false);
const triggerEditorMode = ref<'create' | 'edit'>('create');
const triggerEditorTrigger = ref<TriggerSpec | null>(null);
const triggerEditorSaving = ref(false);
const triggerEditorError = ref<string | null>(null);
// Element markers state
const currentPageUrl = ref('');
const markers = ref<ElementMarker[]>([]);
@@ -436,36 +456,97 @@ async function exportFlow(id: string) {
}
function createTrigger() {
// V3 Trigger management not yet implemented
alert('V3 Trigger 管理尚未实现,暂时无法创建触发器');
triggerEditorError.value = null;
triggerEditorTrigger.value = null;
triggerEditorMode.value = 'create';
triggerEditorOpen.value = true;
}
function editTrigger(_id: string) {
// V3 Trigger management not yet implemented
alert('V3 Trigger 管理尚未实现,暂时无法编辑触发器');
async function editTrigger(id: string) {
triggerEditorError.value = null;
triggerEditorMode.value = 'edit';
triggerEditorOpen.value = true;
const fromList = triggers.value.find((t) => t.id === id) as unknown as TriggerSpec | undefined;
if (fromList) {
triggerEditorTrigger.value = fromList;
return;
}
const fetched = await workflowsV3.getTriggerById(id);
triggerEditorTrigger.value = fetched;
}
async function removeTrigger(id: string) {
await workflowsV3.deleteTrigger(id);
}
function closeTriggerEditor() {
triggerEditorOpen.value = false;
triggerEditorMode.value = 'create';
triggerEditorTrigger.value = null;
triggerEditorSaving.value = false;
triggerEditorError.value = null;
}
async function handleTriggerEditorSubmit(
payload:
| { mode: 'create'; trigger: Omit<TriggerSpec, 'id'> & { id?: string } }
| { mode: 'edit'; trigger: TriggerSpec },
) {
if (triggerEditorSaving.value) return;
triggerEditorSaving.value = true;
triggerEditorError.value = null;
try {
if (payload.mode === 'create') {
const created = await workflowsV3.createTrigger(payload.trigger);
if (!created) {
triggerEditorError.value = workflowsV3.error.value || 'Failed to create trigger';
return;
}
closeTriggerEditor();
return;
}
const updated = await workflowsV3.updateTrigger(payload.trigger);
if (!updated) {
triggerEditorError.value = workflowsV3.error.value || 'Failed to update trigger';
return;
}
closeTriggerEditor();
} finally {
triggerEditorSaving.value = false;
}
}
function toggleRun(id: string) {
openRunId.value = openRunId.value === id ? null : id;
}
async function run(id: string) {
try {
const result = await workflowsV3.runFlow(id);
let tabId: number | undefined;
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (typeof tab?.id === 'number' && Number.isSafeInteger(tab.id) && tab.id > 0) {
tabId = tab.id;
}
} catch {
// Best-effort: allow runs without tabId (runner will allocate an ephemeral tab)
}
const result = await workflowsV3.runFlow(id, { tabId });
if (!result) console.warn('回放失败');
} catch {}
}
function edit(id: string) {
openBuilder({ flowId: id });
void openBuilder({ flowId: id });
}
function createFlow() {
openBuilder({ newFlow: true });
void openBuilder({ newFlow: true });
}
async function remove(id: string) {
@@ -476,11 +557,17 @@ async function remove(id: string) {
} catch {}
}
function openBuilder(opts: { flowId?: string; newFlow?: boolean }) {
async function openBuilder(opts: { flowId?: string; newFlow?: boolean }) {
// Open dedicated builder window for better UX
const url = new URL(chrome.runtime.getURL('builder.html'));
if (opts.flowId) url.searchParams.set('flowId', opts.flowId);
if (opts.newFlow) url.searchParams.set('new', '1');
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab?.id) url.searchParams.set('tabId', String(tab.id));
} catch {
// Best-effort: builder can still run without a preferred tabId
}
chrome.windows.create({ url: url.toString(), type: 'popup', width: 1280, height: 800 });
}
@@ -0,0 +1,881 @@
<template>
<div v-if="open" class="tr-modal-overlay" @click.self="emit('close')">
<div class="tr-modal">
<div class="tr-modal-header">
<div class="tr-modal-title">
{{ mode === 'create' ? 'Add Trigger' : 'Edit Trigger' }}
</div>
<button class="tr-icon-btn" type="button" title="Close" @click="emit('close')">
<svg viewBox="0 0 20 20" width="18" height="18">
<path
fill="currentColor"
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
<div class="tr-modal-body">
<div v-if="localError || error" class="tr-alert tr-alert-danger">
{{ localError || error }}
</div>
<div v-if="isNodeManaged" class="tr-alert tr-alert-warn">
<div class="tr-alert-title">Managed trigger</div>
<div class="tr-alert-text">
This trigger is generated by a Trigger node in the workflow. Edit it in Builder.
</div>
<button
class="tr-btn tr-btn-ghost"
type="button"
:disabled="saving"
@click="emit('open-builder', form.flowId)"
>
Open Builder
</button>
</div>
<div class="tr-grid">
<div class="tr-field">
<label class="tr-label">Workflow</label>
<select
v-model="form.flowId"
class="tr-select"
:disabled="mode === 'edit' || saving || isNodeManaged"
>
<option value="" disabled>Select a workflow</option>
<option v-for="f in flows" :key="f.id" :value="f.id">
{{ f.name || f.id }}
</option>
</select>
</div>
<div class="tr-field">
<label class="tr-label">Type</label>
<select
v-model="form.kind"
class="tr-select"
:disabled="mode === 'edit' || saving || isNodeManaged"
>
<option value="manual">manual</option>
<option value="interval">interval</option>
<option value="once">once</option>
<option value="url">url</option>
<option value="cron">cron</option>
<option value="command">command</option>
<option value="contextMenu">contextMenu</option>
<option value="dom">dom</option>
</select>
</div>
</div>
<div class="tr-field tr-field-inline">
<label class="tr-checkbox">
<input
v-model="form.enabled"
type="checkbox"
:disabled="saving || isNodeManaged"
class="tr-checkbox-input"
/>
<span>Enabled</span>
</label>
</div>
<div v-if="mode === 'edit'" class="tr-field">
<label class="tr-label">Trigger ID</label>
<input class="tr-input" :value="form.id || ''" disabled />
</div>
<!-- Kind-specific fields -->
<div v-if="form.kind === 'interval'" class="tr-field">
<label class="tr-label">Interval (minutes)</label>
<input
v-model.number="form.periodMinutes"
class="tr-input"
type="number"
min="1"
step="1"
:disabled="saving || isNodeManaged"
/>
<div class="tr-hint">Repeats via chrome.alarms.periodInMinutes.</div>
</div>
<div v-else-if="form.kind === 'once'" class="tr-field">
<label class="tr-label">Trigger time</label>
<input
v-model="form.whenLocal"
class="tr-input"
type="datetime-local"
:disabled="saving || isNodeManaged"
/>
<div class="tr-hint">Will auto-disable after firing. Local timezone.</div>
</div>
<div v-else-if="form.kind === 'url'" class="tr-field">
<label class="tr-label">URL match rules</label>
<div class="tr-list">
<div v-for="(r, i) in form.urlMatch" :key="i" class="tr-list-row">
<select
v-model="r.kind"
class="tr-select tr-select-sm"
:disabled="saving || isNodeManaged"
>
<option value="url">url (prefix)</option>
<option value="domain">domain (contains)</option>
<option value="path">path (prefix)</option>
</select>
<input
v-model="r.value"
class="tr-input tr-input-sm"
type="text"
placeholder="https://example.com/app"
:disabled="saving || isNodeManaged"
/>
<button
class="tr-btn tr-btn-icon"
type="button"
title="Move up"
:disabled="saving || isNodeManaged || i === 0"
@click="move(form.urlMatch, i, -1)"
>
</button>
<button
class="tr-btn tr-btn-icon"
type="button"
title="Move down"
:disabled="saving || isNodeManaged || i === form.urlMatch.length - 1"
@click="move(form.urlMatch, i, 1)"
>
</button>
<button
class="tr-btn tr-btn-icon tr-btn-danger"
type="button"
title="Remove"
:disabled="saving || isNodeManaged"
@click="form.urlMatch.splice(i, 1)"
>
×
</button>
</div>
</div>
<button
class="tr-btn tr-btn-ghost"
type="button"
:disabled="saving || isNodeManaged"
@click="form.urlMatch.push({ kind: 'url', value: '' })"
>
+ Add rule
</button>
</div>
<div v-else-if="form.kind === 'cron'" class="tr-grid">
<div class="tr-field">
<label class="tr-label">Cron</label>
<input
v-model="form.cron"
class="tr-input"
type="text"
placeholder="0 9 * * *"
:disabled="saving || isNodeManaged"
/>
</div>
<div class="tr-field">
<label class="tr-label">Timezone (optional)</label>
<input
v-model="form.timezone"
class="tr-input"
type="text"
placeholder="America/Los_Angeles"
:disabled="saving || isNodeManaged"
/>
</div>
</div>
<div v-else-if="form.kind === 'command'" class="tr-field">
<label class="tr-label">Command key</label>
<input
v-model="form.commandKey"
class="tr-input"
type="text"
placeholder="run_quick_trigger_1"
:disabled="saving || isNodeManaged"
/>
<div class="tr-hint">
Command triggers must be declared in the extension manifest and cannot be added at
runtime.
</div>
</div>
<div v-else-if="form.kind === 'contextMenu'" class="tr-field">
<label class="tr-label">Menu title</label>
<input
v-model="form.contextMenuTitle"
class="tr-input"
type="text"
placeholder="Run workflow"
:disabled="saving || isNodeManaged"
/>
<label class="tr-label tr-label-spaced">Contexts</label>
<div class="tr-checkbox-grid">
<label v-for="c in menuContexts" :key="c" class="tr-checkbox">
<input
v-model="form.contextMenuContexts"
type="checkbox"
:value="c"
:disabled="saving || isNodeManaged"
class="tr-checkbox-input"
/>
<span>{{ c }}</span>
</label>
</div>
</div>
<div v-else-if="form.kind === 'dom'" class="tr-field">
<label class="tr-label">Selector</label>
<input
v-model="form.domSelector"
class="tr-input"
type="text"
placeholder="#app .item"
:disabled="saving || isNodeManaged"
/>
<div class="tr-grid">
<div class="tr-field tr-field-inline">
<label class="tr-checkbox">
<input
v-model="form.domAppear"
type="checkbox"
:disabled="saving || isNodeManaged"
class="tr-checkbox-input"
/>
<span>Fire when appears</span>
</label>
</div>
<div class="tr-field tr-field-inline">
<label class="tr-checkbox">
<input
v-model="form.domOnce"
type="checkbox"
:disabled="saving || isNodeManaged"
class="tr-checkbox-input"
/>
<span>Once</span>
</label>
</div>
</div>
<div class="tr-field">
<label class="tr-label">Debounce (ms)</label>
<input
v-model.number="form.domDebounceMs"
class="tr-input"
type="number"
min="0"
step="50"
:disabled="saving || isNodeManaged"
/>
</div>
</div>
<div class="tr-field">
<label class="tr-label">Args (JSON, optional)</label>
<textarea
v-model="form.argsText"
class="tr-textarea"
rows="4"
placeholder='{\n "foo": "bar"\n}'
:disabled="saving || isNodeManaged"
></textarea>
<div class="tr-hint">Passed as trigger args and merged into flow vars for the run.</div>
</div>
</div>
<div class="tr-modal-footer">
<button class="tr-btn tr-btn-ghost" type="button" :disabled="saving" @click="emit('close')">
Cancel
</button>
<button
class="tr-btn tr-btn-primary"
type="button"
:disabled="saving || isNodeManaged || !canSubmit"
@click="submit"
>
{{ saving ? 'Saving…' : 'Save' }}
</button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import type {
TriggerSpec,
UrlMatchRule,
} from '@/entrypoints/background/record-replay-v3/domain/triggers';
interface FlowLite {
id: string;
name: string;
}
type TriggerSpecCreate = Omit<TriggerSpec, 'id'> & { id?: string };
const props = defineProps<{
open: boolean;
mode: 'create' | 'edit';
flows: FlowLite[];
trigger?: TriggerSpec | null;
saving?: boolean;
error?: string | null;
defaultUrl?: string;
}>();
const emit = defineEmits<{
(e: 'close'): void;
(
e: 'submit',
payload:
| { mode: 'create'; trigger: TriggerSpecCreate }
| { mode: 'edit'; trigger: TriggerSpec },
): void;
(e: 'open-builder', flowId: string): void;
}>();
const saving = computed(() => props.saving === true);
const menuContexts = ['all', 'page', 'selection', 'image', 'link', 'video', 'audio'] as const;
function unixMsToDatetimeLocal(ms: number): string {
const d = new Date(ms);
const pad = (n: number) => String(n).padStart(2, '0');
const year = d.getFullYear();
const month = pad(d.getMonth() + 1);
const day = pad(d.getDate());
const hour = pad(d.getHours());
const minute = pad(d.getMinutes());
return `${year}-${month}-${day}T${hour}:${minute}`;
}
function datetimeLocalToUnixMs(value: string): number | null {
if (!value || typeof value !== 'string') return null;
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/);
if (!match) return null;
const [, yearStr, monthStr, dayStr, hourStr, minuteStr, secondStr] = match;
const d = new Date(
Number(yearStr),
Number(monthStr) - 1,
Number(dayStr),
Number(hourStr),
Number(minuteStr),
Number(secondStr || 0),
0,
);
const ms = d.getTime();
return Number.isFinite(ms) ? ms : null;
}
function move<T>(arr: T[], i: number, d: number): void {
const j = i + d;
if (j < 0 || j >= arr.length) return;
const tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
const form = reactive({
id: '' as string,
kind: 'interval' as TriggerSpec['kind'],
flowId: '' as string,
enabled: true,
// Kind-specific
periodMinutes: 5,
whenLocal: unixMsToDatetimeLocal(Date.now() + 5 * 60 * 1000),
urlMatch: [] as UrlMatchRule[],
cron: '',
timezone: '',
commandKey: '',
contextMenuTitle: 'Run workflow',
contextMenuContexts: ['all'] as string[],
domSelector: '',
domAppear: true,
domOnce: true,
domDebounceMs: 800,
// Common optional field
argsText: '',
});
const localError = ref<string | null>(null);
const isNodeManaged = computed(() => {
if (props.mode !== 'edit') return false;
const trigger = props.trigger;
if (!trigger) return false;
const flowId = String(trigger.flowId || '');
const trigPrefix = `trg_${flowId}_`;
const schPrefix = `sch_${flowId}_`;
return trigger.id.startsWith(trigPrefix) || trigger.id.startsWith(schPrefix);
});
function resetForCreate(): void {
form.id = '';
form.kind = 'interval';
form.flowId = props.flows[0]?.id ?? '';
form.enabled = true;
form.periodMinutes = 5;
form.whenLocal = unixMsToDatetimeLocal(Date.now() + 5 * 60 * 1000);
form.urlMatch = [{ kind: 'url', value: props.defaultUrl ?? '' }];
form.cron = '';
form.timezone = '';
form.commandKey = '';
form.contextMenuTitle = 'Run workflow';
form.contextMenuContexts = ['all'];
form.domSelector = '';
form.domAppear = true;
form.domOnce = true;
form.domDebounceMs = 800;
form.argsText = '';
localError.value = null;
}
function resetForEdit(trigger: TriggerSpec): void {
form.id = trigger.id;
form.kind = trigger.kind;
form.flowId = trigger.flowId;
form.enabled = !!trigger.enabled;
form.periodMinutes = trigger.kind === 'interval' ? Number(trigger.periodMinutes) || 1 : 5;
form.whenLocal =
trigger.kind === 'once'
? unixMsToDatetimeLocal(Number(trigger.whenMs))
: unixMsToDatetimeLocal(Date.now() + 5 * 60 * 1000);
form.urlMatch = trigger.kind === 'url' ? [...(trigger.match || [])] : [];
form.cron = trigger.kind === 'cron' ? String(trigger.cron || '') : '';
form.timezone = trigger.kind === 'cron' ? String(trigger.timezone || '') : '';
form.commandKey = trigger.kind === 'command' ? String(trigger.commandKey || '') : '';
form.contextMenuTitle =
trigger.kind === 'contextMenu' ? String(trigger.title || '') : 'Run workflow';
form.contextMenuContexts =
trigger.kind === 'contextMenu' ? [...(trigger.contexts || ['all'])] : ['all'];
form.domSelector = trigger.kind === 'dom' ? String(trigger.selector || '') : '';
form.domAppear = trigger.kind === 'dom' ? trigger.appear !== false : true;
form.domOnce = trigger.kind === 'dom' ? trigger.once !== false : true;
form.domDebounceMs = trigger.kind === 'dom' ? Number(trigger.debounceMs ?? 800) : 800;
form.argsText = trigger.args ? JSON.stringify(trigger.args, null, 2) : '';
localError.value = null;
}
watch(
() => [props.open, props.mode, props.trigger] as const,
() => {
if (!props.open) return;
if (props.mode === 'create') {
resetForCreate();
return;
}
if (props.trigger) {
resetForEdit(props.trigger);
return;
}
resetForCreate();
},
{ immediate: true },
);
const canSubmit = computed(() => {
if (!form.flowId.trim()) return false;
if (localError.value) return false;
if (form.kind === 'interval')
return Number.isFinite(form.periodMinutes) && form.periodMinutes >= 1;
if (form.kind === 'once') return !!datetimeLocalToUnixMs(form.whenLocal);
if (form.kind === 'url') return form.urlMatch.some((r) => String(r.value || '').trim());
if (form.kind === 'cron') return !!form.cron.trim();
if (form.kind === 'command') return !!form.commandKey.trim();
if (form.kind === 'contextMenu') return !!form.contextMenuTitle.trim();
if (form.kind === 'dom') return !!form.domSelector.trim();
return true;
});
function parseArgs(): Record<string, unknown> | undefined {
const text = (form.argsText || '').trim();
if (!text) return undefined;
try {
const parsed = JSON.parse(text) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
localError.value = 'Args must be a JSON object.';
return undefined;
}
return parsed as Record<string, unknown>;
} catch (e) {
localError.value = e instanceof Error ? e.message : 'Invalid JSON in args.';
return undefined;
}
}
function buildTrigger():
| { mode: 'create'; trigger: TriggerSpecCreate }
| { mode: 'edit'; trigger: TriggerSpec } {
const args = parseArgs();
const base = {
id: form.id || undefined,
kind: form.kind,
enabled: !!form.enabled,
flowId: form.flowId,
args,
};
switch (form.kind) {
case 'manual':
return props.mode === 'create'
? { mode: 'create', trigger: base as TriggerSpecCreate }
: { mode: 'edit', trigger: base as TriggerSpec };
case 'interval': {
const trigger = { ...base, periodMinutes: Math.max(1, Math.floor(form.periodMinutes)) };
return props.mode === 'create'
? { mode: 'create', trigger: trigger as TriggerSpecCreate }
: { mode: 'edit', trigger: trigger as TriggerSpec };
}
case 'once': {
const whenMs = datetimeLocalToUnixMs(form.whenLocal);
const trigger = { ...base, whenMs: whenMs ?? Date.now() };
return props.mode === 'create'
? { mode: 'create', trigger: trigger as TriggerSpecCreate }
: { mode: 'edit', trigger: trigger as TriggerSpec };
}
case 'url': {
const match = form.urlMatch
.map((r) => ({ kind: r.kind, value: String(r.value || '').trim() }))
.filter((r) => r.value);
const trigger = { ...base, match };
return props.mode === 'create'
? { mode: 'create', trigger: trigger as TriggerSpecCreate }
: { mode: 'edit', trigger: trigger as TriggerSpec };
}
case 'cron': {
const trigger = {
...base,
cron: form.cron.trim(),
timezone: form.timezone.trim() || undefined,
};
return props.mode === 'create'
? { mode: 'create', trigger: trigger as TriggerSpecCreate }
: { mode: 'edit', trigger: trigger as TriggerSpec };
}
case 'command': {
const trigger = { ...base, commandKey: form.commandKey.trim() };
return props.mode === 'create'
? { mode: 'create', trigger: trigger as TriggerSpecCreate }
: { mode: 'edit', trigger: trigger as TriggerSpec };
}
case 'contextMenu': {
const contexts = [...new Set(form.contextMenuContexts)].filter(Boolean);
const trigger = { ...base, title: form.contextMenuTitle.trim(), contexts };
return props.mode === 'create'
? { mode: 'create', trigger: trigger as TriggerSpecCreate }
: { mode: 'edit', trigger: trigger as TriggerSpec };
}
case 'dom': {
const trigger = {
...base,
selector: form.domSelector.trim(),
appear: !!form.domAppear,
once: !!form.domOnce,
debounceMs: Math.max(0, Math.floor(form.domDebounceMs)),
};
return props.mode === 'create'
? { mode: 'create', trigger: trigger as TriggerSpecCreate }
: { mode: 'edit', trigger: trigger as TriggerSpec };
}
default:
return props.mode === 'create'
? { mode: 'create', trigger: base as TriggerSpecCreate }
: { mode: 'edit', trigger: base as TriggerSpec };
}
}
function submit(): void {
localError.value = null;
if (isNodeManaged.value) return;
if (!canSubmit.value) return;
const payload = buildTrigger();
if (localError.value) return;
emit('submit', payload);
}
</script>
<style scoped>
.tr-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.42);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 24px 12px;
z-index: 1000;
}
.tr-modal {
width: 100%;
max-width: 520px;
border-radius: 12px;
background: var(--ac-surface, #ffffff);
border: 1px solid var(--ac-border, #e5e5e5);
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.24);
overflow: hidden;
}
.tr-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid var(--ac-border, #e5e5e5);
}
.tr-modal-title {
font-size: 14px;
font-weight: 600;
color: var(--ac-text, #111827);
}
.tr-icon-btn {
border: 0;
background: transparent;
color: var(--ac-text-muted, #6b7280);
padding: 6px;
border-radius: 8px;
cursor: pointer;
}
.tr-icon-btn:hover {
background: var(--ac-hover-bg, #f3f4f6);
color: var(--ac-text, #111827);
}
.tr-modal-body {
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.tr-modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 12px 14px;
border-top: 1px solid var(--ac-border, #e5e5e5);
background: var(--ac-surface, #ffffff);
}
.tr-alert {
border-radius: 10px;
padding: 10px 12px;
border: 1px solid var(--ac-border, #e5e5e5);
background: var(--ac-surface-muted, #f8fafc);
color: var(--ac-text, #111827);
}
.tr-alert-danger {
border-color: rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.08);
color: var(--ac-danger, #ef4444);
}
.tr-alert-warn {
border-color: rgba(245, 158, 11, 0.35);
background: rgba(245, 158, 11, 0.08);
color: var(--ac-text, #111827);
}
.tr-alert-title {
font-weight: 600;
margin-bottom: 2px;
}
.tr-alert-text {
font-size: 12px;
color: var(--ac-text-muted, #6b7280);
margin-bottom: 10px;
}
.tr-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.tr-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.tr-field-inline {
flex-direction: row;
align-items: center;
}
.tr-label {
font-size: 12px;
color: var(--ac-text-muted, #6b7280);
font-weight: 600;
}
.tr-label-spaced {
margin-top: 6px;
}
.tr-input,
.tr-select,
.tr-textarea {
width: 100%;
border: 1px solid var(--ac-border, #e5e5e5);
border-radius: 10px;
padding: 9px 10px;
font-size: 13px;
background: var(--ac-surface, #ffffff);
color: var(--ac-text, #111827);
}
.tr-textarea {
font-family: var(
--ac-font-mono,
ui-monospace,
SFMono-Regular,
Menlo,
Monaco,
Consolas,
monospace
);
font-size: 12px;
}
.tr-input:disabled,
.tr-select:disabled,
.tr-textarea:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.tr-select-sm {
padding: 7px 8px;
border-radius: 9px;
font-size: 12px;
}
.tr-input-sm {
padding: 7px 8px;
border-radius: 9px;
font-size: 12px;
}
.tr-hint {
font-size: 11px;
color: var(--ac-text-subtle, #9ca3af);
line-height: 1.25;
}
.tr-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.tr-list-row {
display: grid;
grid-template-columns: 150px 1fr auto auto auto;
gap: 8px;
align-items: center;
}
.tr-checkbox-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.tr-checkbox {
display: flex;
gap: 8px;
align-items: center;
font-size: 12px;
color: var(--ac-text, #111827);
}
.tr-checkbox-input {
width: 14px;
height: 14px;
}
.tr-btn {
border-radius: 10px;
padding: 9px 12px;
font-size: 13px;
border: 1px solid var(--ac-border, #e5e5e5);
background: var(--ac-surface, #ffffff);
color: var(--ac-text, #111827);
cursor: pointer;
}
.tr-btn:hover {
background: var(--ac-hover-bg, #f3f4f6);
}
.tr-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.tr-btn-primary {
border-color: rgba(59, 130, 246, 0.45);
background: var(--ac-primary, #3b82f6);
color: #fff;
}
.tr-btn-primary:hover {
background: var(--ac-primary-strong, #2563eb);
}
.tr-btn-ghost {
background: transparent;
}
.tr-btn-icon {
padding: 6px 8px;
border-radius: 9px;
font-size: 12px;
}
.tr-btn-danger {
border-color: rgba(239, 68, 68, 0.35);
color: var(--ac-danger, #ef4444);
}
@media (max-width: 480px) {
.tr-grid {
grid-template-columns: 1fr;
}
.tr-list-row {
grid-template-columns: 1fr;
}
.tr-checkbox-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
@@ -1,2 +1,3 @@
export { default as WorkflowsView } from './WorkflowsView.vue';
export { default as WorkflowListItem } from './WorkflowListItem.vue';
export { default as TriggerEditorModal } from './TriggerEditorModal.vue';
@@ -142,14 +142,19 @@ export interface UseWorkflowsV3Return {
refreshFlows: () => Promise<void>;
refreshRuns: () => Promise<void>;
refreshTriggers: () => Promise<void>;
runFlow: (flowId: string) => Promise<{ runId: string } | null>;
runFlow: (flowId: string, options?: { tabId?: number }) => Promise<{ runId: string } | null>;
deleteFlow: (flowId: string) => Promise<boolean>;
exportFlow: (flowId: string) => Promise<FlowV3 | null>;
deleteTrigger: (triggerId: string) => Promise<boolean>;
createTrigger: (
trigger: Omit<TriggerSpec, 'id'> & { id?: string },
) => Promise<TriggerSpec | null>;
updateTrigger: (trigger: TriggerSpec) => Promise<TriggerSpec | null>;
// V3-specific
getFlowById: (flowId: string) => Promise<FlowV3 | null>;
getRunEvents: (runId: string) => Promise<unknown[]>;
getTriggerById: (triggerId: string) => Promise<TriggerSpec | null>;
}
/**
@@ -217,10 +222,14 @@ export function useWorkflowsV3(options: UseWorkflowsV3Options = {}): UseWorkflow
}
}
async function runFlow(flowId: string): Promise<{ runId: string } | null> {
async function runFlow(
flowId: string,
options?: { tabId?: number },
): Promise<{ runId: string } | null> {
try {
const result = (await rpc.request('rr_v3.enqueueRun', {
flowId: flowId as FlowId,
...(options?.tabId ? { tabId: options.tabId } : {}),
})) as { runId: RunId; position: number } | null;
// Refresh runs to show the new run
void refreshRuns();
@@ -271,6 +280,36 @@ export function useWorkflowsV3(options: UseWorkflowsV3Options = {}): UseWorkflow
}
}
async function createTrigger(
trigger: Omit<TriggerSpec, 'id'> & { id?: string },
): Promise<TriggerSpec | null> {
try {
const created = (await rpc.request('rr_v3.createTrigger', {
trigger,
})) as TriggerSpec | null;
void refreshTriggers();
return created;
} catch (e) {
console.warn('[useWorkflowsV3] Failed to create trigger:', e);
error.value = e instanceof Error ? e.message : String(e);
return null;
}
}
async function updateTrigger(trigger: TriggerSpec): Promise<TriggerSpec | null> {
try {
const updated = (await rpc.request('rr_v3.updateTrigger', {
trigger,
})) as TriggerSpec | null;
void refreshTriggers();
return updated;
} catch (e) {
console.warn('[useWorkflowsV3] Failed to update trigger:', e);
error.value = e instanceof Error ? e.message : String(e);
return null;
}
}
async function getFlowById(flowId: string): Promise<FlowV3 | null> {
try {
return (await rpc.request('rr_v3.getFlow', {
@@ -293,6 +332,17 @@ export function useWorkflowsV3(options: UseWorkflowsV3Options = {}): UseWorkflow
}
}
async function getTriggerById(triggerId: string): Promise<TriggerSpec | null> {
try {
return (await rpc.request('rr_v3.getTrigger', {
triggerId,
})) as TriggerSpec | null;
} catch (e) {
console.warn('[useWorkflowsV3] Failed to get trigger:', e);
return null;
}
}
// ==================== Lifecycle ====================
onMounted(async () => {
@@ -358,7 +408,10 @@ export function useWorkflowsV3(options: UseWorkflowsV3Options = {}): UseWorkflow
deleteFlow,
exportFlow,
deleteTrigger,
createTrigger,
updateTrigger,
getFlowById,
getRunEvents,
getTriggerById,
};
}
@@ -0,0 +1,337 @@
/* eslint-disable */
/**
* Tool Approval Inject Script
*
* A lightweight, human-in-the-loop confirmation UI used by Phase 14 "Agent Mode"
* to gate risky MCP tool calls before execution.
*
* Design goals:
* - Single-purpose, self-contained overlay UI (Shadow DOM isolation)
* - Best-effort: if UI cannot render, background will deny by timeout
* - Minimal page interference (captured events stay inside overlay)
*/
(function () {
'use strict';
if (window.__MCP_TOOL_APPROVAL_INITIALIZED__) return;
window.__MCP_TOOL_APPROVAL_INITIALIZED__ = true;
const UI_HOST_ID = '__mcp_tool_approval_host__';
const STATE = {
sessionId: null,
deadlineTs: null,
keydownAttached: false,
};
function normalizeString(v) {
return typeof v === 'string' ? v : '';
}
function removeHost() {
const existing = document.getElementById(UI_HOST_ID);
if (existing) existing.remove();
}
function sendDecision(decision) {
if (!STATE.sessionId) return;
try {
chrome.runtime.sendMessage({
type: 'tool_approval_ui_event',
sessionId: STATE.sessionId,
event: decision === 'approve' ? 'approve' : 'deny',
});
} catch {
// Best effort
}
}
function formatTimeLeft(deadlineTs) {
const now = Date.now();
const remaining = Math.max(0, (deadlineTs || 0) - now);
const sec = Math.ceil(remaining / 1000);
if (sec < 60) return `${sec}s`;
const min = Math.floor(sec / 60);
const s = sec % 60;
return `${min}m ${s}s`;
}
function render(payload) {
removeHost();
const host = document.createElement('div');
host.id = UI_HOST_ID;
host.style.position = 'fixed';
host.style.inset = '0';
host.style.zIndex = '2147483647';
host.style.pointerEvents = 'auto';
const shadow = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = `
:host { all: initial; }
.backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.35);
display: flex;
align-items: center;
justify-content: center;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
}
.card {
width: min(620px, calc(100vw - 32px));
background: #111827;
color: #f9fafb;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 14px;
box-shadow: 0 20px 60px rgba(0,0,0,0.45);
overflow: hidden;
}
.header {
padding: 14px 16px;
border-bottom: 1px solid rgba(255,255,255,0.08);
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.title {
font-weight: 700;
font-size: 14px;
letter-spacing: 0.02em;
}
.meta {
font-size: 12px;
color: rgba(255,255,255,0.72);
}
.body {
padding: 14px 16px;
display: grid;
gap: 10px;
}
.rowLabel {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(255,255,255,0.55);
}
.monoBox {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 10px;
padding: 10px 12px;
max-height: 220px;
overflow: auto;
}
.footer {
padding: 14px 16px;
border-top: 1px solid rgba(255,255,255,0.08);
display: flex;
justify-content: flex-end;
gap: 10px;
}
.btn {
border: 1px solid rgba(255,255,255,0.12);
background: rgba(255,255,255,0.08);
color: #f9fafb;
border-radius: 10px;
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.btn:hover { background: rgba(255,255,255,0.12); }
.btn:active { transform: translateY(1px); }
.btnDanger {
background: #ef4444;
border-color: rgba(239,68,68,0.85);
}
.btnDanger:hover { background: #dc2626; }
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
padding: 4px 8px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.82);
}
.pillHigh { border-color: rgba(239,68,68,0.55); }
.pillMedium { border-color: rgba(245,158,11,0.55); }
.pillLow { border-color: rgba(34,197,94,0.45); }
.desc { color: rgba(255,255,255,0.72); font-size: 12px; line-height: 1.4; }
`;
const backdrop = document.createElement('div');
backdrop.className = 'backdrop';
const card = document.createElement('div');
card.className = 'card';
card.setAttribute('role', 'dialog');
card.setAttribute('aria-modal', 'true');
card.setAttribute('aria-label', 'Tool approval');
const header = document.createElement('div');
header.className = 'header';
const toolName = normalizeString(payload.toolName).trim() || 'Unknown tool';
const risk = payload.risk || {};
const level = normalizeString(risk.level).trim().toLowerCase();
const left = document.createElement('div');
const title = document.createElement('div');
title.className = 'title';
title.textContent = `Approve tool call: ${toolName}`;
const desc = document.createElement('div');
desc.className = 'desc';
const toolDescription = normalizeString(payload.toolDescription).trim();
desc.textContent = toolDescription || 'This action was requested by the agent.';
left.append(title, desc);
const right = document.createElement('div');
const pill = document.createElement('div');
pill.className = `pill ${level === 'high' ? 'pillHigh' : level === 'medium' ? 'pillMedium' : 'pillLow'}`;
pill.textContent = `Risk: ${level || 'unknown'}`;
const meta = document.createElement('div');
meta.className = 'meta';
const deadlineTs = typeof payload.deadlineTs === 'number' ? payload.deadlineTs : null;
if (deadlineTs) meta.textContent = `Auto-deny in ${formatTimeLeft(deadlineTs)}`;
right.append(pill, meta);
header.append(left, right);
const body = document.createElement('div');
body.className = 'body';
const argsLabel = document.createElement('div');
argsLabel.className = 'rowLabel';
argsLabel.textContent = 'Arguments';
const argsBox = document.createElement('div');
argsBox.className = 'monoBox';
argsBox.textContent = normalizeString(payload.argsSummary) || '(no arguments)';
body.append(argsLabel, argsBox);
const footer = document.createElement('div');
footer.className = 'footer';
const cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'btn';
cancelBtn.textContent = 'Deny (Esc)';
cancelBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
sendDecision('deny');
removeHost();
});
const approveBtn = document.createElement('button');
approveBtn.type = 'button';
approveBtn.className = 'btn btnDanger';
approveBtn.textContent = 'Approve';
approveBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
sendDecision('approve');
removeHost();
});
footer.append(cancelBtn, approveBtn);
card.append(header, body, footer);
backdrop.append(card);
shadow.append(style, backdrop);
backdrop.addEventListener('click', (e) => {
// Clicking the backdrop denies by default.
if (e.target !== backdrop) return;
e.preventDefault();
e.stopPropagation();
sendDecision('deny');
removeHost();
});
if (!STATE.keydownAttached) {
STATE.keydownAttached = true;
window.addEventListener(
'keydown',
(ev) => {
if (!STATE.sessionId) return;
if (ev.key === 'Escape') {
ev.preventDefault();
ev.stopPropagation();
sendDecision('deny');
removeHost();
}
},
true,
);
}
// Focus for immediate keyboard use
setTimeout(() => {
try {
approveBtn.focus();
} catch {
// Ignore
}
}, 0);
document.documentElement.appendChild(host);
}
function hide(sessionId) {
if (sessionId && STATE.sessionId && sessionId !== STATE.sessionId) return;
STATE.sessionId = null;
STATE.deadlineTs = null;
removeHost();
}
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
try {
// Ping used by BaseBrowserToolExecutor.injectContentScript
if (request && request.action === 'tool_approval_ping') {
sendResponse({ status: 'pong' });
return;
}
if (request && request.action === 'toolApprovalShow') {
const sessionId = normalizeString(request.sessionId).trim();
if (!sessionId) {
sendResponse({ success: false, error: 'sessionId is required' });
return;
}
STATE.sessionId = sessionId;
STATE.deadlineTs = typeof request.deadlineTs === 'number' ? request.deadlineTs : null;
render(request);
sendResponse({ success: true });
return;
}
if (request && request.action === 'toolApprovalHide') {
hide(normalizeString(request.sessionId).trim());
sendResponse({ success: true });
return;
}
} catch (e) {
try {
sendResponse({ success: false, error: String(e) });
} catch {}
}
});
})();
@@ -0,0 +1,99 @@
export interface ApiDetectiveRequestForSnippet {
method: string;
url: string;
headers?: Record<string, string>;
body?: string;
}
function normalizeString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function normalizeHeaders(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object') return {};
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
const key = normalizeString(k).trim();
const val = normalizeString(v).trim();
if (!key || !val) continue;
out[key] = val;
}
return out;
}
function shellSingleQuote(value: string): string {
// POSIX-safe single-quote escaping:
// abc'd -> 'abc'"'"'d'
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
}
function shouldOmitHeader(name: string): boolean {
const lower = name.toLowerCase().trim();
// Avoid headers that are typically injected by the user agent or can break replay when copied verbatim.
return (
lower === 'host' ||
lower === 'content-length' ||
lower === 'connection' ||
lower === 'accept-encoding'
);
}
export function toCurlCommand(input: ApiDetectiveRequestForSnippet): string {
const method = normalizeString(input.method).trim().toUpperCase() || 'GET';
const url = normalizeString(input.url).trim();
const headers = normalizeHeaders(input.headers);
const body = typeof input.body === 'string' ? input.body : undefined;
if (!url) return 'curl';
const parts: string[] = ['curl'];
if (method !== 'GET') {
parts.push('-X', shellSingleQuote(method));
}
const headerKeys = Object.keys(headers)
.filter((k) => k && !shouldOmitHeader(k))
.sort((a, b) => a.localeCompare(b));
for (const key of headerKeys) {
parts.push('-H', shellSingleQuote(`${key}: ${headers[key]}`));
}
if (typeof body === 'string' && body.length > 0 && method !== 'GET' && method !== 'HEAD') {
parts.push('--data-raw', shellSingleQuote(body));
}
parts.push(shellSingleQuote(url));
return parts.join(' ');
}
export function toFetchSnippet(input: ApiDetectiveRequestForSnippet): string {
const method = normalizeString(input.method).trim().toUpperCase() || 'GET';
const url = normalizeString(input.url).trim();
const headers = normalizeHeaders(input.headers);
const body = typeof input.body === 'string' ? input.body : undefined;
if (!url) return `await fetch(${JSON.stringify('')});`;
const lines: string[] = [];
lines.push(`await fetch(${JSON.stringify(url)}, {`);
lines.push(` method: ${JSON.stringify(method)},`);
const headerKeys = Object.keys(headers)
.filter((k) => k && !shouldOmitHeader(k))
.sort((a, b) => a.localeCompare(b));
if (headerKeys.length > 0) {
lines.push(' headers: {');
for (const key of headerKeys) {
lines.push(` ${JSON.stringify(key)}: ${JSON.stringify(headers[key])},`);
}
lines.push(' },');
}
if (typeof body === 'string' && body.length > 0 && method !== 'GET' && method !== 'HEAD') {
lines.push(` body: ${JSON.stringify(body)},`);
}
lines.push('});');
return lines.join('\n');
}
@@ -0,0 +1,88 @@
/**
* Quick Panel Clean URL
*
* Pure helpers for removing common tracking parameters from URLs.
* Intended for the `> Clean URL` command and any future share/sanitize flows.
*/
// ============================================================
// Types
// ============================================================
export interface CleanUrlResult {
original: string;
cleaned: string;
changed: boolean;
removedParams: string[];
}
// ============================================================
// Config
// ============================================================
const EXACT_TRACKING_PARAMS = new Set([
'fbclid',
'gclid',
'dclid',
'msclkid',
'igshid',
'yclid',
'gbraid',
'wbraid',
'srsltid',
'mc_cid',
'mc_eid',
'mkt_tok',
]);
// ============================================================
// Public API
// ============================================================
/**
* Remove common tracking parameters from a URL.
*
* Rules:
* - Only operates on http(s) URLs.
* - Removes any param with `utm_` prefix (case-insensitive).
* - Removes a curated set of common tracking params (fbclid/gclid/etc).
* - Preserves hash fragment by default (anchors are often meaningful).
*/
export function cleanUrl(input: string): CleanUrlResult {
const original = String(input ?? '').trim();
if (!original) {
return { original, cleaned: '', changed: false, removedParams: [] };
}
let url: URL;
try {
url = new URL(original);
} catch {
// Best-effort: non-parseable URLs are returned as-is.
return { original, cleaned: original, changed: false, removedParams: [] };
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return { original, cleaned: original, changed: false, removedParams: [] };
}
const removedParams: string[] = [];
const keysToDelete: string[] = [];
for (const [key] of url.searchParams) {
const lower = key.toLowerCase();
if (lower.startsWith('utm_') || EXACT_TRACKING_PARAMS.has(lower)) {
keysToDelete.push(key);
}
}
for (const key of keysToDelete) {
if (url.searchParams.has(key)) {
url.searchParams.delete(key);
removedParams.push(key);
}
}
const cleaned = url.toString();
return { original, cleaned, changed: cleaned !== original, removedParams };
}
@@ -0,0 +1,72 @@
/**
* Quick Panel Content Search Utilities
*
* Pure helpers shared by the background content cache and tests:
* - Token scoring against already-normalized text (AND semantics)
* - Snippet extraction around the first matched token
*/
import { scoreToken } from './text-score';
export function scoreTokensAgainstNormalizedText(
haystack: string,
tokens: readonly string[],
): number {
if (!haystack || tokens.length === 0) return 0;
let total = 0;
for (const t of tokens) {
const s = scoreToken(haystack, t);
if (s <= 0) return 0; // AND semantics
total += s;
}
return (total / tokens.length) * 100;
}
export interface CreateContentSnippetOptions {
beforeChars?: number;
afterChars?: number;
maxLen?: number;
}
export function createContentSnippet(
content: string,
tokens: readonly string[],
options: CreateContentSnippetOptions = {},
): string {
const raw = String(content ?? '').trim();
if (!raw) return '';
const lower = raw.toLowerCase();
let bestIdx = -1;
let bestToken = '';
for (const t of tokens) {
if (!t) continue;
const idx = lower.indexOf(t);
if (idx >= 0 && (bestIdx === -1 || idx < bestIdx)) {
bestIdx = idx;
bestToken = t;
}
}
const targetIdx = bestIdx >= 0 ? bestIdx : 0;
const targetLen = bestIdx >= 0 ? bestToken.length : 0;
const before = typeof options.beforeChars === 'number' ? Math.max(0, options.beforeChars) : 64;
const after = typeof options.afterChars === 'number' ? Math.max(0, options.afterChars) : 140;
const maxLen = typeof options.maxLen === 'number' ? Math.max(40, options.maxLen) : 220;
const start = Math.max(0, targetIdx - before);
const end = Math.min(raw.length, targetIdx + targetLen + after);
let snippet = raw.slice(start, end).replace(/\s+/g, ' ').trim();
if (start > 0) snippet = `${snippet}`;
if (end < raw.length) snippet = `${snippet}`;
if (snippet.length > maxLen) {
snippet = `${snippet.slice(0, maxLen - 1).trimEnd()}`;
}
return snippet;
}
@@ -187,6 +187,25 @@ export function createKeyboardController(
return false;
}
/**
* Best-effort selection state for text inputs.
* Used to decide when ArrowLeft/ArrowRight should act as panel navigation without breaking typing UX.
*/
function getTextInputSelection(
target: EventTarget | null,
): { valueLength: number; start: number; end: number } | null {
if (!target) return null;
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
const start = target.selectionStart;
const end = target.selectionEnd;
if (typeof start !== 'number' || typeof end !== 'number') return null;
return { valueLength: target.value.length, start, end };
}
return null;
}
/**
* Check if we should intercept this key event
*/
@@ -195,13 +214,32 @@ export function createKeyboardController(
const isInput = isInputElement(event.target);
const actionPanelOpen = options.isActionPanelOpen?.() ?? false;
// ArrowLeft/ArrowRight: only intercept when action panel is open
// Otherwise let the input handle cursor movement
// ArrowLeft/ArrowRight: avoid breaking text cursor movement in inputs.
// - When action panel is open: always intercept for panel navigation.
// - In inputs: intercept only when cursor is at boundary (start/end), so Arrow keys can be used as panel shortcuts.
if (key === 'ArrowLeft' || key === 'ArrowRight') {
if (isInput && !actionPanelOpen) {
return false; // Let input handle cursor movement
if (actionPanelOpen) {
return true;
}
return actionPanelOpen; // Only intercept if action panel is open
if (isInput) {
const sel = getTextInputSelection(event.target);
if (!sel) return false;
const inputEmpty = options.isInputEmpty?.() ?? sel.valueLength === 0;
const collapsed = sel.start === sel.end;
if (key === 'ArrowLeft') {
// Treat ArrowLeft as "back" only when input is empty and cursor is at start.
return inputEmpty && collapsed && sel.start === 0;
}
// Treat ArrowRight as "open actions" only when cursor is at end.
return collapsed && sel.end === sel.valueLength;
}
// Not in an input: allow ArrowLeft/ArrowRight as panel navigation shortcuts.
return true;
}
// Always intercept certain keys even in input fields
@@ -321,6 +359,17 @@ export function createKeyboardController(
return;
}
// Arrow Left - Back (when input is empty)
if (key === 'ArrowLeft') {
const inputEmpty = options.isInputEmpty?.() ?? false;
if (inputEmpty) {
event.preventDefault();
event.stopPropagation();
options.onBack?.();
}
return;
}
// Backspace - Back (when input is empty)
if (key === 'Backspace') {
const inputEmpty = options.isInputEmpty?.() ?? false;
@@ -0,0 +1,195 @@
/**
* Quick Panel Text Scoring Utilities
*
* Pure (DOM-free) helpers shared across providers and background handlers:
* - Text/URL normalization
* - Token scoring (exact/prefix/substring/subsequence)
* - Weighted multi-field scoring
*/
// ============================================================
// Text Normalization
// ============================================================
/**
* Normalize text for comparison:
* - Trim whitespace
* - Convert to lowercase
* - Collapse multiple spaces
*/
export function normalizeText(value: unknown): string {
return String(value ?? '')
.trim()
.toLowerCase()
.replace(/\s+/g, ' ');
}
/**
* Normalize URL for comparison:
* - Remove protocol (http://, https://)
* - Remove www prefix
* - URL decode
* - Apply text normalization
*/
export function normalizeUrl(value: unknown): string {
const raw = String(value ?? '').trim();
if (!raw) return '';
// Remove protocol and www prefix for cleaner matching
let text = raw.replace(/^https?:\/\//i, '').replace(/^www\./i, '');
// Attempt URL decode
try {
text = decodeURIComponent(text);
} catch {
// Best-effort
}
return normalizeText(text);
}
// ============================================================
// Token Scoring
// ============================================================
/**
* Check if needle is a subsequence of haystack.
*/
function isSubsequence(needle: string, haystack: string): boolean {
if (!needle) return true;
let i = 0;
for (const ch of haystack) {
if (ch === needle[i]) i++;
if (i >= needle.length) return true;
}
return false;
}
/** Minimum token length for subsequence matching (to avoid over-matching). */
const MIN_SUBSEQUENCE_TOKEN_LENGTH = 3;
/**
* Check if character is a word boundary.
*/
function isBoundaryChar(ch: string): boolean {
return (
ch === '' ||
ch === ' ' ||
ch === '/' ||
ch === '-' ||
ch === '_' ||
ch === '.' ||
ch === ':' ||
ch === '#' ||
ch === '?' ||
ch === '&'
);
}
/**
* Score a single token against a haystack string.
* Returns 0 if no match, higher values for better matches.
*
* Scoring:
* - Exact match: 1.0
* - Prefix match: 0.95
* - Substring match: 0.55-0.95 (depends on position and boundary)
* - Subsequence match: 0.4 (only for tokens >= 3 chars)
*/
export function scoreToken(haystack: string, token: string): number {
if (!haystack || !token) return 0;
// Exact match
if (haystack === token) return 1;
// Prefix match
if (haystack.startsWith(token)) return 0.95;
// Substring match
const idx = haystack.indexOf(token);
if (idx >= 0) {
const prev = idx > 0 ? haystack[idx - 1] : '';
const boundaryBoost = isBoundaryChar(prev) ? 0.15 : 0;
const positionPenalty = idx / Math.max(1, haystack.length);
return Math.max(0.55, 0.8 + boundaryBoost - positionPenalty * 0.2);
}
// Subsequence match (fuzzy) - only for tokens >= MIN_SUBSEQUENCE_TOKEN_LENGTH
if (token.length >= MIN_SUBSEQUENCE_TOKEN_LENGTH && isSubsequence(token, haystack)) {
return 0.4;
}
return 0;
}
// ============================================================
// Weighted Field Scoring
// ============================================================
/**
* Field mode for normalization.
*/
export type WeightedFieldMode = 'text' | 'url';
/**
* A field to score against with its weight.
*/
export interface WeightedField {
/** The field value */
value: string;
/** Weight of this field in scoring (should be positive) */
weight: number;
/** Normalization mode. Default: 'text' */
mode?: WeightedFieldMode;
}
/**
* Compute a weighted score for multiple fields against query tokens.
*
* For each token, scores against all fields and combines using weights.
* Returns 0 if any token has no match (AND semantics).
*
* @param fields - Array of fields to score against
* @param tokens - Query tokens to match
* @returns Score from 0-100
*/
export function computeWeightedTokenScore(
fields: readonly WeightedField[],
tokens: readonly string[],
): number {
if (tokens.length === 0) return 0;
if (fields.length === 0) return 0;
// Normalize fields and filter invalid ones
const normalized = fields
.map((f) => {
const weight = Number.isFinite(f.weight) ? Math.max(0, f.weight) : 0;
const mode = f.mode ?? 'text';
const text = mode === 'url' ? normalizeUrl(f.value) : normalizeText(f.value);
return { weight, text };
})
.filter((f) => f.weight > 0 && f.text.length > 0);
if (normalized.length === 0) return 0;
const weightSum = normalized.reduce((sum, f) => sum + f.weight, 0) || 1;
let total = 0;
for (const token of tokens) {
let best = 0;
let weighted = 0;
for (const f of normalized) {
const s = scoreToken(f.text, token);
if (s > best) best = s;
weighted += s * f.weight;
}
// If no field matched this token, reject entirely (AND semantics)
if (best <= 0) return 0;
total += weighted / weightSum;
}
return (total / tokens.length) * 100;
}
@@ -0,0 +1,127 @@
/**
* Quick Panel Toolbox - Base64
*
* Notes:
* - `btoa/atob` operate on Latin1 "binary strings". We explicitly convert UTF-8 <-> bytes.
* - We accept Base64URL input (JWT) and normalize padding.
*/
import { err, ok, safeErrorMessage, type ToolboxResult } from './result';
interface NodeBufferLike extends Uint8Array {
toString: (encoding: string) => string;
}
interface NodeBufferConstructorLike {
from: (data: string | ArrayBufferView, encoding?: string) => NodeBufferLike;
}
function hasBrowserBase64(): boolean {
return typeof globalThis.btoa === 'function' && typeof globalThis.atob === 'function';
}
function bytesToBinaryString(bytes: Uint8Array): string {
let out = '';
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
out += String.fromCharCode(...chunk);
}
return out;
}
function binaryStringToBytes(bin: string): Uint8Array {
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) {
bytes[i] = bin.charCodeAt(i) & 0xff;
}
return bytes;
}
function encodeBase64Bytes(bytes: Uint8Array): ToolboxResult<string> {
try {
if (hasBrowserBase64()) {
return ok(globalThis.btoa(bytesToBinaryString(bytes)));
}
const anyGlobal = globalThis as unknown as { Buffer?: NodeBufferConstructorLike };
if (typeof anyGlobal.Buffer?.from === 'function') {
return ok(anyGlobal.Buffer.from(bytes).toString('base64'));
}
return err('Base64 encoder is not available in this environment');
} catch (e) {
return err(safeErrorMessage(e));
}
}
function decodeBase64ToBytes(base64: string): ToolboxResult<Uint8Array> {
try {
const normalized = normalizeBase64Input(base64);
if (!normalized.ok) return normalized;
if (hasBrowserBase64()) {
const bin = globalThis.atob(normalized.value);
return ok(binaryStringToBytes(bin));
}
const anyGlobal = globalThis as unknown as { Buffer?: NodeBufferConstructorLike };
if (typeof anyGlobal.Buffer?.from === 'function') {
return ok(anyGlobal.Buffer.from(normalized.value, 'base64'));
}
return err('Base64 decoder is not available in this environment');
} catch (e) {
return err(safeErrorMessage(e));
}
}
/**
* Normalize Base64/Base64URL inputs:
* - trims whitespace
* - removes internal whitespace
* - converts base64url to base64
* - fixes missing padding
*/
export function normalizeBase64Input(input: string): ToolboxResult<string> {
const raw = String(input ?? '').trim();
if (!raw) return err('Base64 input is required');
const compact = raw.replace(/\s+/g, '');
const standard = compact.replace(/-/g, '+').replace(/_/g, '/');
const mod = standard.length % 4;
if (mod === 1) return err('Invalid Base64 length');
if (mod === 2) return ok(`${standard}==`);
if (mod === 3) return ok(`${standard}=`);
return ok(standard);
}
export function base64EncodeUtf8(input: string): ToolboxResult<string> {
const text = String(input ?? '');
if (!text.trim()) return err('Input is required');
try {
const enc = new TextEncoder();
const bytes = enc.encode(text);
return encodeBase64Bytes(bytes);
} catch (e) {
return err(safeErrorMessage(e));
}
}
export function base64DecodeUtf8(input: string): ToolboxResult<string> {
const decoded = decodeBase64ToBytes(input);
if (!decoded.ok) return decoded;
try {
const dec = new TextDecoder();
return ok(dec.decode(decoded.value));
} catch (e) {
return err(safeErrorMessage(e));
}
}
export function base64UrlDecodeUtf8(input: string): ToolboxResult<string> {
return base64DecodeUtf8(input);
}
@@ -0,0 +1,7 @@
export { base64DecodeUtf8, base64EncodeUtf8, base64UrlDecodeUtf8 } from './base64';
export { formatJson, type JsonFormats } from './json';
export { convertUnixTimestamp, type TimestampConversion } from './timestamp';
export { urlDecode, urlEncode } from './url';
export { decodeJwt, type DecodedJwt } from './jwt';
export { generateUuidV4 } from './uuid';
export { type ToolboxResult } from './result';
@@ -0,0 +1,24 @@
/**
* Quick Panel Toolbox - JSON
*/
import { err, ok, safeErrorMessage, type ToolboxResult } from './result';
export interface JsonFormats {
pretty: string;
minified: string;
}
export function formatJson(input: string): ToolboxResult<JsonFormats> {
const raw = String(input ?? '').trim();
if (!raw) return err('JSON input is required');
try {
const parsed = JSON.parse(raw) as unknown;
const pretty = JSON.stringify(parsed, null, 2);
const minified = JSON.stringify(parsed);
return ok({ pretty, minified });
} catch (e) {
return err(safeErrorMessage(e));
}
}
@@ -0,0 +1,46 @@
/**
* Quick Panel Toolbox - JWT Decode (no verification)
*/
import { base64UrlDecodeUtf8 } from './base64';
import { err, ok, safeErrorMessage, type ToolboxResult } from './result';
export interface DecodedJwt {
header: unknown;
payload: unknown;
signature: string;
}
function safeJsonParse(text: string): ToolboxResult<unknown> {
try {
return ok(JSON.parse(text) as unknown);
} catch (e) {
return err(safeErrorMessage(e));
}
}
export function decodeJwt(input: string): ToolboxResult<DecodedJwt> {
const raw = String(input ?? '').trim();
if (!raw) return err('JWT token is required');
const parts = raw.split('.');
if (parts.length !== 3) return err('JWT must have 3 dot-separated parts');
const headerText = base64UrlDecodeUtf8(parts[0] ?? '');
if (!headerText.ok) return err(`Invalid JWT header: ${headerText.error}`);
const payloadText = base64UrlDecodeUtf8(parts[1] ?? '');
if (!payloadText.ok) return err(`Invalid JWT payload: ${payloadText.error}`);
const header = safeJsonParse(headerText.value);
if (!header.ok) return err(`Invalid JWT header JSON: ${header.error}`);
const payload = safeJsonParse(payloadText.value);
if (!payload.ok) return err(`Invalid JWT payload JSON: ${payload.error}`);
return ok({
header: header.value,
payload: payload.value,
signature: parts[2] ?? '',
});
}
@@ -0,0 +1,20 @@
/**
* Quick Panel Toolbox Result
*
* A small, serializable result type for pure utility functions.
*/
export type ToolboxResult<T> = { ok: true; value: T } | { ok: false; error: string };
export function ok<T>(value: T): ToolboxResult<T> {
return { ok: true, value };
}
export function err<T = never>(error: string): ToolboxResult<T> {
return { ok: false, error };
}
export function safeErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message || String(error);
return String(error);
}
@@ -0,0 +1,50 @@
/**
* Quick Panel Toolbox - Timestamp
*/
import { err, ok, type ToolboxResult } from './result';
export interface TimestampConversion {
seconds: number;
milliseconds: number;
iso: string;
}
function isValidDate(d: Date): boolean {
return Number.isFinite(d.getTime());
}
function parseTimestampNumber(raw: string): number | null {
const cleaned = raw.trim();
if (!cleaned) return null;
if (!/^-?\d+(\.\d+)?$/.test(cleaned)) return null;
const n = Number(cleaned);
return Number.isFinite(n) ? n : null;
}
function toMilliseconds(n: number): number {
const abs = Math.abs(n);
// Heuristics:
// - 13+ digits -> ms
// - 10 digits -> seconds
// - otherwise: treat values < 1e11 as seconds (covers modern unix seconds),
// and >= 1e11 as ms (covers modern unix ms).
const digits = Math.floor(abs).toString().length;
if (digits >= 13) return Math.round(n);
if (digits <= 10) return Math.round(n * 1000);
return abs < 1e11 ? Math.round(n * 1000) : Math.round(n);
}
export function convertUnixTimestamp(input: string): ToolboxResult<TimestampConversion> {
const raw = String(input ?? '');
const n = parseTimestampNumber(raw);
if (n === null) return err('Timestamp must be a number (seconds or milliseconds)');
const ms = toMilliseconds(n);
const date = new Date(ms);
if (!isValidDate(date)) return err('Invalid timestamp');
const seconds = Math.floor(ms / 1000);
return ok({ seconds, milliseconds: ms, iso: date.toISOString() });
}
@@ -0,0 +1,25 @@
/**
* Quick Panel Toolbox - URL Encode/Decode
*/
import { err, ok, safeErrorMessage, type ToolboxResult } from './result';
export function urlEncode(input: string): ToolboxResult<string> {
const raw = String(input ?? '');
if (!raw.trim()) return err('Input is required');
try {
return ok(encodeURIComponent(raw));
} catch (e) {
return err(safeErrorMessage(e));
}
}
export function urlDecode(input: string): ToolboxResult<string> {
const raw = String(input ?? '');
if (!raw.trim()) return err('Input is required');
try {
return ok(decodeURIComponent(raw));
} catch (e) {
return err(safeErrorMessage(e));
}
}
@@ -0,0 +1,43 @@
/**
* Quick Panel Toolbox - UUID
*/
function randomHex(bytes: Uint8Array): string {
let out = '';
for (const b of bytes) out += b.toString(16).padStart(2, '0');
return out;
}
export function generateUuidV4(): string {
try {
if (typeof crypto?.randomUUID === 'function') {
return crypto.randomUUID();
}
} catch {
// Fall back to getRandomValues/Math.random.
}
let bytes: Uint8Array | null = null;
try {
if (typeof crypto?.getRandomValues === 'function') {
bytes = crypto.getRandomValues(new Uint8Array(16));
}
} catch {
bytes = null;
}
if (!bytes) {
bytes = new Uint8Array(16);
for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
}
// RFC 4122 version 4
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = randomHex(bytes);
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(
16,
20,
)}-${hex.slice(20)}`;
}
@@ -12,7 +12,24 @@
/**
* Available search scopes in Quick Panel
*/
export type QuickPanelScope = 'all' | 'tabs' | 'bookmarks' | 'history' | 'content' | 'commands';
export type QuickPanelScope =
| 'all'
| 'tabs'
| 'bookmarks'
| 'history'
| 'content'
| 'commands'
| 'workspaces'
| 'clipboard'
| 'notes'
| 'focus'
| 'monitor'
| 'audit'
| 'web_google'
| 'web_github'
| 'web_npm'
| 'web_stackoverflow'
| 'web_mdn';
/**
* Scope definition with display properties
@@ -23,7 +40,7 @@ export interface QuickPanelScopeDefinition {
icon: string;
/**
* Scope prefix for search input recognition.
* - Space-terminated prefixes: "t ", "b ", "h ", "c "
* - Space-terminated prefixes: "t ", "b ", "h ", "c ", "g ", "gh ", "npm ", "so ", "mdn "
* - Command mode prefix: ">"
* - null for 'all' scope (no prefix)
*/
@@ -41,6 +58,22 @@ export const QUICK_PANEL_SCOPES: Readonly<Record<QuickPanelScope, QuickPanelScop
history: { id: 'history', label: 'History', icon: '\uD83D\uDD50', prefix: 'h ' },
content: { id: 'content', label: 'Content', icon: '\uD83D\uDCC4', prefix: 'c ' },
commands: { id: 'commands', label: 'Commands', icon: '>', prefix: '>' },
workspaces: { id: 'workspaces', label: 'Workspaces', icon: '\uD83D\uDDC3\uFE0F', prefix: 'ws ' },
clipboard: { id: 'clipboard', label: 'Clipboard', icon: '\uD83D\uDCCB', prefix: 'clip ' },
notes: { id: 'notes', label: 'Notes', icon: '\uD83D\uDCDD', prefix: 'note ' },
focus: { id: 'focus', label: 'Focus', icon: '\uD83C\uDF45', prefix: 'focus ' },
monitor: { id: 'monitor', label: 'Monitor', icon: '\uD83D\uDC40', prefix: 'mon ' },
audit: { id: 'audit', label: 'Audit', icon: '\uD83E\uDDFE', prefix: 'audit ' },
web_google: { id: 'web_google', label: 'Google', icon: '\uD83D\uDD0D', prefix: 'g ' },
web_github: { id: 'web_github', label: 'GitHub', icon: '\uD83D\uDC19', prefix: 'gh ' },
web_npm: { id: 'web_npm', label: 'NPM', icon: '\uD83D\uDCE6', prefix: 'npm ' },
web_stackoverflow: {
id: 'web_stackoverflow',
label: 'Stack Overflow',
icon: '\uD83D\uDCA1',
prefix: 'so ',
},
web_mdn: { id: 'web_mdn', label: 'MDN', icon: '\uD83D\uDCDA', prefix: 'mdn ' },
} as const;
/**
@@ -85,6 +118,11 @@ export interface ParsedScopeQuery {
* - `b foo` -> scope=bookmarks, query="foo"
* - `h foo` -> scope=history, query="foo"
* - `c foo` -> scope=content, query="foo"
* - `g foo` -> scope=web_google, query="foo"
* - `gh foo` -> scope=web_github, query="foo"
* - `npm foo` -> scope=web_npm, query="foo"
* - `so foo` -> scope=web_stackoverflow, query="foo"
* - `mdn foo` -> scope=web_mdn, query="foo"
*
* Only checks the beginning of the string (after trimming leading whitespace).
*/
@@ -105,22 +143,20 @@ export function parseScopePrefixedQuery(
};
}
// Check for space-terminated scope prefixes (t, b, h, c)
const match = leadingTrimmed.match(/^([tbhc])\s+(.*)$/s);
if (match) {
const prefix = match[1];
const rest = (match[2] ?? '').trimStart();
// Check for space-terminated scope prefixes (including multi-letter ones like "gh ")
const lower = leadingTrimmed.toLowerCase();
const prefixCandidates = Object.values(QUICK_PANEL_SCOPES)
.filter(
(def): def is QuickPanelScopeDefinition & { prefix: string } =>
typeof def.prefix === 'string' && def.prefix !== '>' && def.prefix.endsWith(' '),
)
.map((def) => ({ scope: def.id, prefix: def.prefix.toLowerCase() }))
.sort((a, b) => b.prefix.length - a.prefix.length); // Prefer longer matches ("gh " over "g ")
const scopeMap: Record<string, QuickPanelScope> = {
t: 'tabs',
b: 'bookmarks',
h: 'history',
c: 'content',
};
const scope = scopeMap[prefix] ?? defaultScope;
return { raw, scope, query: rest, consumedPrefix: true };
for (const candidate of prefixCandidates) {
if (!lower.startsWith(candidate.prefix)) continue;
const rest = leadingTrimmed.slice(candidate.prefix.length).trimStart();
return { raw, scope: candidate.scope, query: rest, consumedPrefix: true };
}
// No prefix detected
@@ -0,0 +1,79 @@
/**
* Quick Panel URL Template Utilities
*
* Pure (DOM-free) helpers for building URLs from templates.
*
* Supported placeholders:
* - `{query}`: normalized query (trimmed + collapsed whitespace), URL-encoded
* - `{rawQuery}`: raw query (trimmed only), URL-encoded
*
* This module is intentionally small and testable, used by web-search providers
* and any future "open by template" features.
*/
// ============================================================
// Types
// ============================================================
export interface UrlTemplateEngine {
template: string;
}
// ============================================================
// Helpers
// ============================================================
function normalizeQuery(value: string): string {
return String(value ?? '')
.trim()
.replace(/\s+/g, ' ');
}
function trimOnly(value: string): string {
return String(value ?? '').trim();
}
function hasPlaceholders(template: string): boolean {
return template.includes('{query}') || template.includes('{rawQuery}');
}
function replaceAllLiteral(input: string, needle: string, replacement: string): string {
if (!needle) return input;
if (!input.includes(needle)) return input;
return input.split(needle).join(replacement);
}
// ============================================================
// Public API
// ============================================================
/**
* Build a URL by filling a template with a query string.
*
* @example
* ```ts
* buildSearchUrl({ template: 'https://www.google.com/search?q={query}' }, 'react hooks')
* // -> "https://www.google.com/search?q=react%20hooks"
* ```
*/
export function buildSearchUrl(engine: UrlTemplateEngine, query: string): string {
const template = String(engine?.template ?? '').trim();
if (!template) {
throw new Error('URL template is required');
}
if (!hasPlaceholders(template)) {
throw new Error('URL template must include {query} or {rawQuery}');
}
const rawQuery = trimOnly(query);
const normalizedQuery = normalizeQuery(query);
const encodedQuery = encodeURIComponent(normalizedQuery);
const encodedRawQuery = encodeURIComponent(rawQuery);
// Replace placeholders. Order doesn't matter because tokens do not overlap.
let url = template;
url = replaceAllLiteral(url, '{query}', encodedQuery);
url = replaceAllLiteral(url, '{rawQuery}', encodedRawQuery);
return url;
}
@@ -50,13 +50,22 @@ import {
} from './core/keyboard-controller';
import { HistoryTracker } from './core/history-tracker';
import { SearchEngine } from './core/search-engine';
import type { QuickPanelView, SearchProvider, SearchResult } from './core/types';
import type { ActionContext, QuickPanelView, SearchProvider, SearchResult } from './core/types';
import { computeUsageKey } from './core/usage-key';
import {
createApiDetectiveProvider,
createBookmarksProvider,
createClipboardProvider,
createCommandsProvider,
createContentProvider,
createAuditProvider,
createFocusProvider,
createHistoryProvider,
createMonitorProvider,
createNotesProvider,
createTabsProvider,
createWebSearchProvider,
createWorkspacesProvider,
} from './providers';
import {
mountQuickPanelShadowHost,
@@ -212,7 +221,16 @@ export function createQuickPanelController(
searchEngine.registerProvider(createTabsProvider() as SearchProvider);
searchEngine.registerProvider(createBookmarksProvider() as SearchProvider);
searchEngine.registerProvider(createHistoryProvider() as SearchProvider);
searchEngine.registerProvider(createContentProvider() as SearchProvider);
searchEngine.registerProvider(createCommandsProvider() as SearchProvider);
searchEngine.registerProvider(createApiDetectiveProvider() as SearchProvider);
searchEngine.registerProvider(createWebSearchProvider() as SearchProvider);
searchEngine.registerProvider(createWorkspacesProvider() as SearchProvider);
searchEngine.registerProvider(createClipboardProvider() as SearchProvider);
searchEngine.registerProvider(createNotesProvider() as SearchProvider);
searchEngine.registerProvider(createFocusProvider() as SearchProvider);
searchEngine.registerProvider(createMonitorProvider() as SearchProvider);
searchEngine.registerProvider(createAuditProvider() as SearchProvider);
}
return searchEngine;
}
@@ -314,9 +332,29 @@ export function createQuickPanelController(
}
/**
* Handle result selection from search view
* Get the currently selected result from search view state.
*/
function handleResultSelect(result: SearchResult): void {
function getSelectedResult(): SearchResult | null {
const s = searchView?.getState();
if (!s) return null;
if (s.selectedIndex < 0 || s.selectedIndex >= s.results.length) return null;
return s.results[s.selectedIndex] ?? null;
}
/**
* Check if a SearchResult represents the AI Assistant entry.
* This is used for keyboard shortcuts that bypass SearchView's internal selection handling.
*/
function isAiEntry(result: SearchResult): boolean {
if (result.provider !== 'system') return false;
const data = result.data as unknown as Record<string, unknown> | null;
return typeof data === 'object' && data !== null && data.type === 'ai-entry';
}
/**
* Execute the provider's default action for a result.
*/
function executeDefaultAction(result: SearchResult, openMode?: ActionContext['openMode']): void {
if (disposed) return;
// Look up the provider to get actions
@@ -336,26 +374,47 @@ export function createQuickPanelController(
return;
}
// Get actions from the provider
const actions = provider.getActions(result);
if (actions && actions.length > 0) {
const defaultAction = actions[0];
const ctx = { result, openMode } as ActionContext;
// Get actions from the provider (best-effort honor isAvailable)
const actions = (provider.getActions(result) ?? []).filter((a) => {
if (!a.isAvailable) return true;
try {
// Execute with proper context
void Promise.resolve(defaultAction.execute({ result })).catch((err) => {
console.warn(`${LOG_PREFIX} Error executing action:`, err);
});
// Record usage after successful execution initiation
recordResultUsage(result);
} catch (err) {
console.warn(`${LOG_PREFIX} Error executing action:`, err);
return a.isAvailable(ctx);
} catch {
// Best-effort: treat as available if predicate throws
return true;
}
});
const defaultAction = actions[0];
if (!defaultAction) {
hide();
return;
}
try {
// Execute with proper context
void Promise.resolve(defaultAction.execute(ctx)).catch((err) => {
console.warn(`${LOG_PREFIX} Error executing action:`, err);
});
// Record usage after successful execution initiation
recordResultUsage(result);
} catch (err) {
console.warn(`${LOG_PREFIX} Error executing action:`, err);
}
// Hide panel after action
hide();
}
/**
* Handle result selection from search view
*/
function handleResultSelect(result: SearchResult): void {
executeDefaultAction(result, 'current_tab');
}
/**
* Mount Search View into Shell's search mount points
*/
@@ -380,9 +439,9 @@ export function createQuickPanelController(
},
searchEngine: engine,
historyTracker: ensureHistoryTracker(),
placeholder: 'Search tabs, bookmarks, commands...',
placeholder: 'Search tabs, bookmarks, history, content, commands...',
autoFocus: false, // We'll focus after view is set
availableScopes: ['all', 'tabs', 'bookmarks', 'history', 'commands'],
availableScopes: ['all', 'tabs', 'bookmarks', 'history', 'content', 'commands'],
onResultSelect: handleResultSelect,
onAiSelect: () => {
// Switch to chat view when AI entry is selected
@@ -534,10 +593,14 @@ export function createQuickPanelController(
}
},
onSelectInNewTab: () => {
// TODO: Implement open in new tab action
// For now, just execute the default action
if (currentView === 'search') {
searchView?.executeSelected();
const selected = getSelectedResult();
if (!selected) return;
if (isAiEntry(selected)) {
setView('chat');
return;
}
executeDefaultAction(selected, 'new_tab');
}
},
onOpenActionPanel: () => {
@@ -572,8 +635,10 @@ export function createQuickPanelController(
// Backspace when input empty: go back to previous view or clear
if (currentView === 'chat') {
setView('search');
return;
}
// In search view with empty input, could close panel or do nothing
// In search view with empty input: close the panel
hide();
},
onClose: () => {
hide();
@@ -0,0 +1,465 @@
/**
* API Detective Provider (Quick Panel)
*
* Scope:
* - Commands-only provider, activated by typing `> api ...` (or `> detective ...`)
*
* Features:
* - Start/stop a short-lived network capture session (background-managed)
* - List captured requests from the last session
* - Copy a request as `curl` / `fetch` snippet
* - Replay a captured request (dangerous: may trigger side effects)
*/
import {
BACKGROUND_MESSAGE_TYPES,
type QuickPanelApiDetectiveBackend,
type QuickPanelApiDetectiveGetRequestResponse,
type QuickPanelApiDetectiveListResponse,
type QuickPanelApiDetectiveReplayRequestResponse,
type QuickPanelApiDetectiveStartResponse,
type QuickPanelApiDetectiveStatusResponse,
type QuickPanelApiDetectiveStopResponse,
} from '@/common/message-types';
import type { Action, SearchProvider, SearchProviderContext, SearchResult } from '../core/types';
import { toCurlCommand, toFetchSnippet } from '../core/api-detective-snippets';
import { computeWeightedTokenScore, writeToClipboard } from './provider-utils';
// ============================================================
// Types
// ============================================================
export type ApiDetectiveResultData =
| {
kind: 'command';
command: 'status' | 'start' | 'start_body' | 'stop';
}
| {
kind: 'request';
requestId: string;
method: string;
url: string;
type?: string;
status?: number;
mimeType?: string;
backend: QuickPanelApiDetectiveBackend | null;
capturedAt: number | null;
tabUrl: string | null;
};
interface ApiDetectiveClient {
status: () => Promise<QuickPanelApiDetectiveStatusResponse>;
start: (options: { needResponseBody: boolean }) => Promise<QuickPanelApiDetectiveStartResponse>;
stop: () => Promise<QuickPanelApiDetectiveStopResponse>;
list: (options: {
query?: string;
maxResults: number;
signal: AbortSignal;
}) => Promise<QuickPanelApiDetectiveListResponse>;
getRequest: (options: { requestId: string }) => Promise<QuickPanelApiDetectiveGetRequestResponse>;
replay: (options: { requestId: string }) => Promise<QuickPanelApiDetectiveReplayRequestResponse>;
}
// ============================================================
// Helpers
// ============================================================
function safeErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message || String(err);
return String(err);
}
function parseActivation(
tokens: readonly string[],
raw: string,
): { active: boolean; query: string; queryTokens: string[] } {
const first = (tokens[0] || '').toLowerCase();
if (first !== 'api' && first !== 'detective')
return { active: false, query: '', queryTokens: [] };
const trimmed = String(raw ?? '').trim();
const query = trimmed.replace(/^(api|detective)\b/i, '').trim();
const queryTokens = tokens.slice(1);
return { active: true, query, queryTokens };
}
function formatHostPath(url: string): string {
try {
const u = new URL(url);
const path = `${u.pathname || '/'}${u.search || ''}`;
return `${u.hostname}${path}`;
} catch {
return url;
}
}
function formatStatus(status?: number): string {
if (typeof status === 'number' && Number.isFinite(status)) return String(status);
return '';
}
function createRuntimeClient(): ApiDetectiveClient {
async function send<T>(type: string, payload: unknown): Promise<T> {
if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
throw new Error('chrome.runtime.sendMessage is not available');
}
return (await chrome.runtime.sendMessage({ type, payload })) as T;
}
return {
status: () =>
send<QuickPanelApiDetectiveStatusResponse>(
BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_STATUS,
{},
),
start: (options) =>
send<QuickPanelApiDetectiveStartResponse>(
BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_START,
{
needResponseBody: options.needResponseBody,
includeStatic: false,
maxCaptureTimeMs: 180_000,
},
),
stop: () =>
send<QuickPanelApiDetectiveStopResponse>(
BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_STOP,
{},
),
list: (options) =>
options.signal.aborted
? Promise.reject(new Error('aborted'))
: send<QuickPanelApiDetectiveListResponse>(
BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_LIST,
{
query: options.query || undefined,
maxResults: options.maxResults,
},
),
getRequest: (options) =>
send<QuickPanelApiDetectiveGetRequestResponse>(
BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_GET_REQUEST,
{ requestId: options.requestId },
),
replay: (options) =>
send<QuickPanelApiDetectiveReplayRequestResponse>(
BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_REPLAY_REQUEST,
{ requestId: options.requestId, timeoutMs: 30_000 },
),
};
}
// ============================================================
// Provider Factory
// ============================================================
export function createApiDetectiveProvider(): SearchProvider<ApiDetectiveResultData> {
const id = 'api_detective';
const name = 'API Detective';
const icon = '\uD83D\uDD75\uFE0F'; // 🕵️
const client = createRuntimeClient();
function getActions(
item: SearchResult<ApiDetectiveResultData>,
): Action<ApiDetectiveResultData>[] {
const data = item.data;
if (data.kind === 'command') {
const cmd = data.command;
if (cmd === 'start') {
return [
{
id: 'api_detective.start',
title: 'Start capture',
hotkeyHint: 'Enter',
execute: async () => {
const resp = await client.start({ needResponseBody: false });
if (!resp || resp.success !== true) {
const err = (resp as any)?.error;
throw new Error(typeof err === 'string' ? err : 'Failed to start capture');
}
},
},
];
}
if (cmd === 'start_body') {
return [
{
id: 'api_detective.start_body',
title: 'Start capture (include response bodies)',
tone: 'danger',
hotkeyHint: 'Enter',
execute: async () => {
const resp = await client.start({ needResponseBody: true });
if (!resp || resp.success !== true) {
const err = (resp as any)?.error;
throw new Error(typeof err === 'string' ? err : 'Failed to start capture');
}
},
},
];
}
if (cmd === 'stop') {
return [
{
id: 'api_detective.stop',
title: 'Stop capture',
hotkeyHint: 'Enter',
execute: async () => {
const resp = await client.stop();
if (!resp || resp.success !== true) {
const err = (resp as any)?.error;
throw new Error(typeof err === 'string' ? err : 'Failed to stop capture');
}
},
},
];
}
return [
{
id: 'api_detective.status',
title: 'Refresh status',
hotkeyHint: 'Enter',
execute: async () => {
const resp = await client.status();
if (!resp || resp.success !== true) {
const err = (resp as any)?.error;
throw new Error(typeof err === 'string' ? err : 'Failed to get status');
}
},
},
];
}
const requestId = data.requestId;
return [
{
id: 'api_detective.copy_curl',
title: 'Copy as curl',
hotkeyHint: 'Enter',
execute: async () => {
const resp = await client.getRequest({ requestId });
if (!resp || resp.success !== true) {
const err = (resp as any)?.error;
throw new Error(typeof err === 'string' ? err : 'Failed to get request details');
}
const req = resp.request;
const curl = toCurlCommand({
method: req.method,
url: req.url,
headers: req.requestHeaders,
body: req.requestBody,
});
await writeToClipboard(curl, {
source: 'api_detective.copy.curl',
label: `${req.method} ${req.url}`,
});
},
},
{
id: 'api_detective.copy_fetch',
title: 'Copy as fetch',
execute: async () => {
const resp = await client.getRequest({ requestId });
if (!resp || resp.success !== true) {
const err = (resp as any)?.error;
throw new Error(typeof err === 'string' ? err : 'Failed to get request details');
}
const req = resp.request;
const snippet = toFetchSnippet({
method: req.method,
url: req.url,
headers: req.requestHeaders,
body: req.requestBody,
});
await writeToClipboard(snippet, {
source: 'api_detective.copy.fetch',
label: `${req.method} ${req.url}`,
});
},
},
{
id: 'api_detective.replay',
title: 'Replay request',
subtitle: 'May cause side effects on the server',
tone: 'danger',
execute: async () => {
const resp = await client.replay({ requestId });
if (!resp || resp.success !== true) {
const err = (resp as any)?.error;
throw new Error(typeof err === 'string' ? err : 'Request replay failed');
}
},
},
];
}
async function search(
ctx: SearchProviderContext,
): Promise<SearchResult<ApiDetectiveResultData>[]> {
if (ctx.signal.aborted) return [];
if (ctx.requestedScope !== 'commands') return [];
if (ctx.query.tokens.length === 0) return [];
const activation = parseActivation(ctx.query.tokens, ctx.query.raw);
if (!activation.active) return [];
const results: SearchResult<ApiDetectiveResultData>[] = [];
const [statusOut, listOut] = await Promise.allSettled([
client.status(),
client.list({
query: activation.query || undefined,
maxResults: Math.max(10, ctx.limit * 5),
signal: ctx.signal,
}),
]);
const status =
statusOut.status === 'fulfilled' && statusOut.value && statusOut.value.success === true
? statusOut.value
: null;
const list =
listOut.status === 'fulfilled' && listOut.value && listOut.value.success === true
? listOut.value
: null;
const active = status?.active === true || list?.active === true;
const backend = status?.backend ?? list?.backend ?? null;
const capturedAt = list?.capturedAt ?? status?.lastCaptureAt ?? null;
const tabUrl = list?.tabUrl ?? null;
// Status entry (always visible when activated)
{
const subtitleParts: string[] = [];
subtitleParts.push(active ? 'Active' : 'Inactive');
if (backend) subtitleParts.push(`backend: ${backend}`);
if (capturedAt) subtitleParts.push(`last: ${new Date(capturedAt).toLocaleTimeString()}`);
if (status?.lastRequestCount) subtitleParts.push(`${status.lastRequestCount} requests`);
results.push({
id: 'api_detective.status',
provider: id,
title: 'API Detective',
subtitle: subtitleParts.join(' \u00B7 '),
icon,
data: { kind: 'command', command: 'status' },
score: 1000,
});
}
// Start/Stop entries
results.push({
id: 'api_detective.start',
provider: id,
title: 'Start capture',
subtitle: 'Capture request metadata (no response body)',
icon: '\u25B6\uFE0F', // ▶️
data: { kind: 'command', command: 'start' },
score: active ? 200 : 900,
});
results.push({
id: 'api_detective.start_body',
provider: id,
title: 'Start capture (include response bodies)',
subtitle: 'High risk: uses debugger backend and may capture sensitive data',
icon: '\u26A0\uFE0F', // ⚠️
data: { kind: 'command', command: 'start_body' },
score: active ? 150 : 850,
});
results.push({
id: 'api_detective.stop',
provider: id,
title: 'Stop capture',
subtitle: active ? 'Stop capture and save as last session' : 'No active capture for this tab',
icon: '\u23F9\uFE0F', // ⏹️
data: { kind: 'command', command: 'stop' },
score: active ? 880 : 50,
});
// Request list entries (last capture)
const items = Array.isArray(list?.items) ? list!.items : [];
const filterTokens = activation.queryTokens;
for (let idx = 0; idx < items.length; idx++) {
const it = items[idx];
const title = `${it.method} ${formatHostPath(it.url)}`;
const statusText = formatStatus(it.status);
const subtitleParts = [statusText, it.type || '', it.mimeType || ''].filter((s) => s);
const subtitle = subtitleParts.join(' \u00B7 ');
const scoreBase =
filterTokens.length === 0
? 200 - idx
: computeWeightedTokenScore(
[
{ value: `${it.method} ${it.url}`, weight: 0.9, mode: 'text' },
{ value: `${it.type || ''} ${it.mimeType || ''}`, weight: 0.1, mode: 'text' },
],
filterTokens,
);
if (scoreBase <= 0) continue;
results.push({
id: `api_detective.req.${it.requestId}`,
provider: id,
title,
subtitle,
icon: '\uD83D\uDCE1', // 📡
data: {
kind: 'request',
requestId: it.requestId,
method: it.method,
url: it.url,
type: it.type,
status: it.status,
mimeType: it.mimeType,
backend,
capturedAt,
tabUrl,
},
score: scoreBase,
});
}
// Surface transport errors as a single result to keep UI discoverable.
if (statusOut.status === 'rejected' || listOut.status === 'rejected') {
const err = safeErrorMessage(
statusOut.status === 'rejected'
? statusOut.reason
: listOut.status === 'rejected'
? listOut.reason
: '',
);
results.push({
id: 'api_detective.error',
provider: id,
title: 'API Detective error',
subtitle: err || 'Failed to communicate with background',
icon: '\u26A0\uFE0F', // ⚠️
data: { kind: 'command', command: 'status' },
score: 1,
});
}
return results.slice(0, ctx.limit);
}
return {
id,
name,
icon,
scopes: ['commands'],
includeInAll: false,
priority: 5,
maxResults: 50,
supportsEmptyQuery: false,
search,
getActions,
};
}
@@ -0,0 +1,255 @@
/**
* Audit Provider (Quick Panel)
*
* Exposes a lightweight, local audit log for Agent Mode tool calls.
*
* Scope:
* - Prefix-only scope: `audit `
* - Not included in 'all' scope (privacy + noise)
*/
import {
BACKGROUND_MESSAGE_TYPES,
type QuickPanelAuditLogClearResponse,
type QuickPanelAuditLogEntry,
type QuickPanelAuditLogListResponse,
} from '@/common/message-types';
import type { Action, SearchProvider, SearchProviderContext, SearchResult } from '../core/types';
import { computeWeightedTokenScore, writeToClipboard } from './provider-utils';
// ============================================================
// Types
// ============================================================
export type AuditResultData =
| { kind: 'entry'; entry: QuickPanelAuditLogEntry }
| { kind: 'command'; command: 'clear' };
interface AuditClient {
list: (options: {
query?: string;
maxResults: number;
signal: AbortSignal;
}) => Promise<QuickPanelAuditLogEntry[]>;
clear: () => Promise<void>;
}
// ============================================================
// Helpers
// ============================================================
function truncate(text: string, maxChars: number): string {
const s = typeof text === 'string' ? text : String(text);
if (s.length <= maxChars) return s;
return s.slice(0, Math.max(0, maxChars - 1)) + '\u2026';
}
function formatTime(ts: number): string {
try {
return new Date(ts).toLocaleTimeString();
} catch {
return String(ts);
}
}
function computeRecencyBoost(ts: number, now: number): number {
const ageMs = Math.max(0, now - ts);
const ageHours = ageMs / (1000 * 60 * 60);
// 0..15 boost over ~24 hours
return Math.max(0, Math.min(15, 15 - ageHours * 0.6));
}
function buildSubtitle(entry: QuickPanelAuditLogEntry): string {
const parts: string[] = [];
parts.push(entry.status);
parts.push(entry.riskLevel);
parts.push(formatTime(entry.finishedAt || entry.startedAt));
return parts.join(' \u00B7 ');
}
function createRuntimeClient(): AuditClient {
async function send<T>(type: string, payload: unknown): Promise<T> {
if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
throw new Error('chrome.runtime.sendMessage is not available');
}
return (await chrome.runtime.sendMessage({ type, payload })) as T;
}
async function list(options: {
query?: string;
maxResults: number;
signal: AbortSignal;
}): Promise<QuickPanelAuditLogEntry[]> {
if (options.signal.aborted) throw new Error('aborted');
const resp = await send<QuickPanelAuditLogListResponse>(
BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_AUDIT_LOG_LIST,
{ query: options.query, maxResults: options.maxResults },
);
if (!resp || resp.success !== true) {
const err = (resp as { error?: unknown })?.error;
throw new Error(typeof err === 'string' ? err : 'Failed to list audit log');
}
return Array.isArray(resp.entries) ? resp.entries : [];
}
async function clear(): Promise<void> {
const resp = await send<QuickPanelAuditLogClearResponse>(
BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_AUDIT_LOG_CLEAR,
{},
);
if (!resp || resp.success !== true) {
const err = (resp as { error?: unknown })?.error;
throw new Error(typeof err === 'string' ? err : 'Failed to clear audit log');
}
}
return { list, clear };
}
// ============================================================
// Provider Factory
// ============================================================
export function createAuditProvider(): SearchProvider<AuditResultData> {
const id = 'audit';
const name = 'Audit';
const icon = '\uD83E\uDDFE'; // 🧾
const client = createRuntimeClient();
function getActions(item: SearchResult<AuditResultData>): Action<AuditResultData>[] {
const data = item.data;
if (data.kind === 'command' && data.command === 'clear') {
return [
{
id: 'audit.clear',
title: 'Clear audit log',
tone: 'danger',
hotkeyHint: 'Enter',
execute: async () => {
await client.clear();
},
},
];
}
if (data.kind !== 'entry') return [];
const entry = data.entry;
const copyJson: Action<AuditResultData> = {
id: 'audit.copyJson',
title: 'Copy details (JSON)',
hotkeyHint: 'Enter',
execute: async () => {
await writeToClipboard(JSON.stringify(entry, null, 2), {
source: 'audit.log.copy.json',
label: `audit:${entry.toolName}`,
});
},
};
const copyArgs: Action<AuditResultData> = {
id: 'audit.copyArgs',
title: 'Copy args summary',
execute: async () => {
await writeToClipboard(entry.argsSummary || '', {
source: 'audit.log.copy.args',
label: `args:${entry.toolName}`,
});
},
};
const copyResult: Action<AuditResultData> = {
id: 'audit.copyResult',
title: 'Copy result summary',
execute: async () => {
await writeToClipboard(entry.resultSummary || '', {
source: 'audit.log.copy.result',
label: `result:${entry.toolName}`,
});
},
};
return [copyJson, copyArgs, copyResult];
}
async function search(ctx: SearchProviderContext): Promise<SearchResult<AuditResultData>[]> {
if (ctx.signal.aborted) return [];
if (ctx.requestedScope !== 'audit') return [];
const q = ctx.query.text;
const tokens = ctx.query.tokens;
const results: SearchResult<AuditResultData>[] = [];
// Special command: clear
const wantsClear = tokens.includes('clear') || tokens.includes('reset');
if (wantsClear) {
results.push({
id: 'audit.clear',
provider: id,
title: 'Clear audit log',
subtitle: 'Delete recent tool action entries for this context',
icon: '\u26A0\uFE0F', // ⚠️
data: { kind: 'command', command: 'clear' },
score: 1000,
});
}
const entries = await client.list({
query: q,
maxResults: Math.max(50, ctx.limit),
signal: ctx.signal,
});
for (const entry of entries) {
const haystack = `${entry.toolName} ${entry.argsSummary} ${entry.resultSummary}`;
const matchScore =
tokens.length === 0
? 1
: computeWeightedTokenScore(
[
{ value: entry.toolName, weight: 0.5, mode: 'text' },
{ value: haystack, weight: 0.5, mode: 'text' },
],
tokens,
);
if (tokens.length > 0 && matchScore <= 0) continue;
const score =
matchScore * 200 + computeRecencyBoost(entry.finishedAt || entry.startedAt, ctx.now);
results.push({
id: `audit.${entry.id}`,
provider: id,
title: entry.toolName,
subtitle:
buildSubtitle(entry) +
(entry.resultSummary ? ` \u00B7 ${truncate(entry.resultSummary, 80)}` : ''),
icon,
data: { kind: 'entry', entry },
score,
});
}
// Keep best results
return results.sort((a, b) => b.score - a.score).slice(0, ctx.limit);
}
return {
id,
name,
icon,
scopes: ['audit'],
includeInAll: false,
supportsEmptyQuery: true,
search,
getActions,
};
}
@@ -9,11 +9,16 @@
import {
BACKGROUND_MESSAGE_TYPES,
type QuickPanelBookmarkSummary,
type QuickPanelBookmarkRemoveResponse,
type QuickPanelBookmarksQueryResponse,
type QuickPanelOpenUrlResponse,
} from '@/common/message-types';
import type { Action, SearchProvider, SearchProviderContext, SearchResult } from '../core/types';
import { computeWeightedTokenScore, formatMarkdownLink, writeToClipboard } from './provider-utils';
import {
computeWeightedTokenScore,
formatMarkdownLink,
openUrl,
writeToClipboard,
} from './provider-utils';
// ============================================================
// Types
@@ -40,10 +45,7 @@ interface BookmarksClient {
maxResults: number;
signal: AbortSignal;
}) => Promise<QuickPanelBookmarkSummary[]>;
openUrl: (options: {
url: string;
disposition: 'current_tab' | 'new_tab' | 'background_tab';
}) => Promise<void>;
removeBookmark: (bookmarkId: string) => Promise<void>;
}
function createRuntimeBookmarksClient(): BookmarksClient {
@@ -72,26 +74,28 @@ function createRuntimeBookmarksClient(): BookmarksClient {
return Array.isArray(resp.bookmarks) ? resp.bookmarks : [];
}
async function openUrl(options: {
url: string;
disposition: 'current_tab' | 'new_tab' | 'background_tab';
}): Promise<void> {
async function removeBookmark(bookmarkId: string): Promise<void> {
if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
throw new Error('chrome.runtime.sendMessage is not available');
}
const id = String(bookmarkId ?? '').trim();
if (!id) {
throw new Error('bookmarkId is required');
}
const resp = (await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_OPEN_URL,
payload: { url: options.url, disposition: options.disposition },
})) as QuickPanelOpenUrlResponse;
type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_BOOKMARK_REMOVE,
payload: { bookmarkId: id },
})) as QuickPanelBookmarkRemoveResponse;
if (!resp || resp.success !== true) {
const err = (resp as { error?: unknown })?.error;
throw new Error(typeof err === 'string' ? err : 'Failed to open url');
throw new Error(typeof err === 'string' ? err : 'Failed to remove bookmark');
}
}
return { query, openUrl };
return { query, removeBookmark };
}
// ============================================================
@@ -156,7 +160,7 @@ export function createBookmarksProvider(): SearchProvider<BookmarksSearchResultD
function getActions(
item: SearchResult<BookmarksSearchResultData>,
): Action<BookmarksSearchResultData>[] {
const { url, title } = item.data;
const { bookmarkId, url, title } = item.data;
return [
// Primary action: Open in current tab
@@ -164,16 +168,17 @@ export function createBookmarksProvider(): SearchProvider<BookmarksSearchResultD
id: 'bookmarks.open',
title: 'Open',
hotkeyHint: 'Enter',
execute: async () => {
await client.openUrl({ url, disposition: 'current_tab' });
execute: async (ctx) => {
await openUrl({ url, disposition: ctx.openMode ?? 'current_tab' });
},
},
// Open in new tab
{
id: 'bookmarks.openNewTab',
title: 'Open in new tab',
hotkeyHint: 'Cmd/Ctrl+Enter',
execute: async () => {
await client.openUrl({ url, disposition: 'new_tab' });
await openUrl({ url, disposition: 'new_tab' });
},
},
// Copy URL
@@ -182,7 +187,7 @@ export function createBookmarksProvider(): SearchProvider<BookmarksSearchResultD
title: 'Copy URL',
hotkeyHint: 'Cmd+C',
execute: async () => {
await writeToClipboard(url);
await writeToClipboard(url, { source: 'bookmarks.copy.url', label: title });
},
},
// Copy as Markdown link
@@ -191,7 +196,19 @@ export function createBookmarksProvider(): SearchProvider<BookmarksSearchResultD
title: 'Copy as Markdown',
hotkeyHint: 'Cmd+Shift+C',
execute: async () => {
await writeToClipboard(formatMarkdownLink(title, url));
await writeToClipboard(formatMarkdownLink(title, url), {
source: 'bookmarks.copy.markdown',
label: title,
});
},
},
// Delete bookmark (danger)
{
id: 'bookmarks.delete',
title: 'Delete bookmark',
tone: 'danger',
execute: async () => {
await client.removeBookmark(bookmarkId);
},
},
];
@@ -0,0 +1,325 @@
/**
* Clipboard Provider (Quick Panel)
*
* Provides clipboard history recorded from Quick Panel copy actions.
*
* Scope:
* - Prefix-only scope: `clip `
* - Not included in 'all' scope (privacy + noise)
*/
import {
BACKGROUND_MESSAGE_TYPES,
type QuickPanelClipboardDeleteResponse,
type QuickPanelClipboardGetResponse,
type QuickPanelClipboardItemSummary,
type QuickPanelClipboardListResponse,
type QuickPanelClipboardSetPinnedResponse,
} from '@/common/message-types';
import type { Action, SearchProvider, SearchProviderContext, SearchResult } from '../core/types';
import { computeWeightedTokenScore, writeToClipboard } from './provider-utils';
// ============================================================
// Types
// ============================================================
export type ClipboardResultData =
| {
kind: 'clipboard';
id: string;
preview: string;
pinned: boolean;
createdAt: number;
updatedAt: number;
incognito: boolean;
source?: string;
label?: string;
originUrl?: string;
originTitle?: string;
byteLength: number;
stored: boolean;
copyCount: number;
}
| {
kind: 'empty';
};
interface ClipboardClient {
list: (options: {
query?: string;
maxResults: number;
signal: AbortSignal;
}) => Promise<QuickPanelClipboardItemSummary[]>;
get: (options: { id: string }) => Promise<{ value: string | null; stored: boolean }>;
setPinned: (options: { id: string; pinned: boolean }) => Promise<void>;
delete: (options: { id: string }) => Promise<void>;
}
// ============================================================
// Helpers
// ============================================================
function formatBytes(bytes: number): string {
const n = typeof bytes === 'number' && Number.isFinite(bytes) ? Math.max(0, bytes) : 0;
if (n < 1024) return `${n}B`;
if (n < 1024 * 1024) return `${Math.round((n / 1024) * 10) / 10}KB`;
return `${Math.round((n / (1024 * 1024)) * 10) / 10}MB`;
}
function formatHost(url: string | undefined): string | null {
if (!url) return null;
try {
return new URL(url).hostname || null;
} catch {
return null;
}
}
function computeRecencyScore(updatedAt: number, now: number): number {
const ageMs = Math.max(0, now - (updatedAt || 0));
const ageHours = ageMs / (1000 * 60 * 60);
return Math.max(0, Math.min(20, 20 - ageHours / 24));
}
function buildSubtitle(item: QuickPanelClipboardItemSummary): string {
const parts: string[] = [];
if (item.pinned) parts.push('Pinned');
if (item.source) parts.push(item.source);
const host = formatHost(item.originUrl);
if (host) parts.push(host);
parts.push(formatBytes(item.byteLength));
if (!item.stored) parts.push('Not stored');
return parts.filter((p) => p).join(' \u00B7 ');
}
function createRuntimeClient(): ClipboardClient {
async function send<T>(type: string, payload: unknown): Promise<T> {
if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
throw new Error('chrome.runtime.sendMessage is not available');
}
return (await chrome.runtime.sendMessage({ type, payload })) as T;
}
async function list(options: {
query?: string;
maxResults: number;
signal: AbortSignal;
}): Promise<QuickPanelClipboardItemSummary[]> {
if (options.signal.aborted) throw new Error('aborted');
const resp = await send<QuickPanelClipboardListResponse>(
BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CLIPBOARD_LIST,
{ query: options.query, maxResults: options.maxResults },
);
if (!resp || resp.success !== true) {
const err = (resp as { error?: unknown })?.error;
throw new Error(typeof err === 'string' ? err : 'Failed to list clipboard history');
}
return Array.isArray(resp.items) ? resp.items : [];
}
async function get(options: { id: string }): Promise<{ value: string | null; stored: boolean }> {
const id = String(options.id ?? '').trim();
if (!id) throw new Error('id is required');
const resp = await send<QuickPanelClipboardGetResponse>(
BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CLIPBOARD_GET,
{ id },
);
if (!resp || resp.success !== true) {
const err = (resp as { error?: unknown })?.error;
throw new Error(typeof err === 'string' ? err : 'Failed to get clipboard item');
}
return { value: resp.item.value ?? null, stored: resp.item.stored === true };
}
async function setPinned(options: { id: string; pinned: boolean }): Promise<void> {
const id = String(options.id ?? '').trim();
if (!id) throw new Error('id is required');
const resp = await send<QuickPanelClipboardSetPinnedResponse>(
BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CLIPBOARD_SET_PINNED,
{ id, pinned: options.pinned === true },
);
if (!resp || resp.success !== true) {
const err = (resp as { error?: unknown })?.error;
throw new Error(typeof err === 'string' ? err : 'Failed to update pin state');
}
}
async function del(options: { id: string }): Promise<void> {
const id = String(options.id ?? '').trim();
if (!id) throw new Error('id is required');
const resp = await send<QuickPanelClipboardDeleteResponse>(
BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CLIPBOARD_DELETE,
{ id },
);
if (!resp || resp.success !== true) {
const err = (resp as { error?: unknown })?.error;
throw new Error(typeof err === 'string' ? err : 'Failed to delete clipboard item');
}
}
return { list, get, setPinned, delete: del };
}
// ============================================================
// Provider Factory
// ============================================================
export function createClipboardProvider(): SearchProvider<ClipboardResultData> {
const id = 'clipboard';
const name = 'Clipboard';
const icon = '\uD83D\uDCCB'; // 📋
const client = createRuntimeClient();
function getActions(item: SearchResult<ClipboardResultData>): Action<ClipboardResultData>[] {
const data = item.data;
if (data.kind !== 'clipboard') return [];
const primaryCopy: Action<ClipboardResultData> = {
id: 'clipboard.copy',
title: 'Copy',
hotkeyHint: 'Enter',
execute: async () => {
const detail = await client.get({ id: data.id });
if (!detail.stored || !detail.value) {
throw new Error(
'This item was not stored (too large). Copy it again from the original source.',
);
}
await writeToClipboard(detail.value, {
source: 'clipboard.history.copy',
label: data.label,
});
},
};
const pinAction: Action<ClipboardResultData> = {
id: 'clipboard.togglePin',
title: data.pinned ? 'Unpin' : 'Pin',
execute: async () => {
await client.setPinned({ id: data.id, pinned: !data.pinned });
},
};
const deleteAction: Action<ClipboardResultData> = {
id: 'clipboard.delete',
title: 'Delete',
tone: 'danger',
execute: async () => {
await client.delete({ id: data.id });
},
};
return [primaryCopy, pinAction, deleteAction];
}
async function search(ctx: SearchProviderContext): Promise<SearchResult<ClipboardResultData>[]> {
if (ctx.signal.aborted) return [];
if (ctx.requestedScope !== 'clipboard') return [];
const raw = String(ctx.query.raw ?? '');
const trimmed = raw.trim();
const tokens = ctx.query.tokens;
const maxResults = Math.min(200, Math.max(20, ctx.limit * 8));
const items = await client.list({
query: trimmed || undefined,
maxResults,
signal: ctx.signal,
});
if (ctx.signal.aborted) return [];
const now = ctx.now;
const results: Array<{ result: SearchResult<ClipboardResultData>; score: number }> = [];
for (const it of items) {
const baseScore =
tokens.length === 0
? 200 + computeRecencyScore(it.updatedAt, now) + (it.pinned ? 50 : 0)
: computeWeightedTokenScore(
[
{ value: it.preview, weight: 0.8, mode: 'text' },
{ value: it.label || '', weight: 0.15, mode: 'text' },
{ value: it.source || '', weight: 0.05, mode: 'text' },
],
tokens,
) + (it.pinned ? 10 : 0);
if (baseScore <= 0) continue;
results.push({
score: baseScore,
result: {
id: `clip.${it.id}`,
provider: id,
title: it.preview,
subtitle: buildSubtitle(it),
icon,
data: {
kind: 'clipboard',
id: it.id,
preview: it.preview,
pinned: it.pinned,
createdAt: it.createdAt,
updatedAt: it.updatedAt,
incognito: it.incognito,
source: it.source,
label: it.label,
originUrl: it.originUrl,
originTitle: it.originTitle,
byteLength: it.byteLength,
stored: it.stored,
copyCount: it.copyCount,
},
score: baseScore,
},
});
}
results.sort((a, b) => b.score - a.score);
// Provide a helpful empty state entry for first-time users.
if (results.length === 0 && tokens.length === 0) {
results.push({
score: 1,
result: {
id: 'clip.empty',
provider: id,
title: 'No clipboard history yet',
subtitle: 'Copy something via Quick Panel to start building history',
icon,
data: { kind: 'empty' },
score: 1,
},
});
}
return results.map((r) => r.result).slice(0, ctx.limit);
}
return {
id,
name,
icon,
scopes: ['clipboard'],
includeInAll: false,
priority: 5,
maxResults: 50,
supportsEmptyQuery: true,
search,
getActions,
dispose: () => {
// No-op
},
};
}
@@ -1,7 +1,7 @@
/**
* Commands Search Provider (Quick Panel)
*
* Provides static, no-args page/tab commands.
* Provides static page/tab commands and argument-based toolbox utilities.
* Commands are executed via background service worker for tab operations,
* or directly in content script for clipboard operations.
*
@@ -15,7 +15,23 @@ import {
type QuickPanelPageCommandResponse,
} from '@/common/message-types';
import type { Action, SearchProvider, SearchProviderContext, SearchResult } from '../core/types';
import { computeWeightedTokenScore, formatMarkdownLink, writeToClipboard } from './provider-utils';
import { cleanUrl } from '../core/clean-url';
import {
base64DecodeUtf8,
base64EncodeUtf8,
convertUnixTimestamp,
decodeJwt,
formatJson,
generateUuidV4,
urlDecode,
urlEncode,
} from '../core/toolbox';
import {
computeWeightedTokenScore,
formatMarkdownLink,
openUrl,
writeToClipboard,
} from './provider-utils';
// ============================================================
// Types
@@ -29,20 +45,60 @@ export type QuickPanelCommandId =
| 'page.back'
| 'page.forward'
| 'page.stop'
| 'page.screenshot'
| 'dev.consoleSnapshot'
| 'dev.consoleErrors'
| 'dev.readPage'
| 'dev.networkCapture10s'
| 'dev.performanceTrace5s'
| 'dev.debugBundle'
| 'dev.debugBundleCancel'
| 'page.readerMode'
| 'page.zenMode'
| 'page.forceDark'
| 'page.allowCopy'
| 'page.privacyCurtain'
| 'page.cleanUrl'
| 'page.pictureInPicture'
| 'page.skinVscode'
| 'page.skinTerminal'
| 'page.skinRetro'
| 'page.skinPaper'
| 'page.skinOff'
| 'tab.close'
| 'tab.duplicate'
| 'tab.togglePin'
| 'tab.toggleMute'
| 'tab.closeOtherTabs'
| 'tab.closeTabsToRight'
| 'tab.discardInactiveTabs'
| 'copy.url'
| 'copy.markdown'
| 'window.newTab'
| 'window.newWindow';
| 'window.newWindow'
| 'window.newIncognitoWindow'
| 'window.mergeAllWindows';
/**
* Data associated with a command search result.
*/
export interface CommandsSearchResultData {
commandId: QuickPanelCommandId;
export type CommandsSearchResultData =
| { commandId: QuickPanelCommandId | string }
| ToolboxSearchResultData;
export type ToolboxToolId = 'json' | 'base64' | 'url' | 'ts' | 'uuid' | 'jwt';
export interface ToolboxOutputItem {
id: string;
title: string;
value: string;
hotkeyHint?: string;
}
export interface ToolboxSearchResultData {
kind: 'toolbox';
tool: ToolboxToolId;
outputs: ToolboxOutputItem[];
}
// ============================================================
@@ -89,6 +145,164 @@ const COMMANDS: readonly CommandDef[] = [
icon: '\u23F9\uFE0F', // ⏹️
keywords: ['stop', 'cancel', 'loading'],
},
{
id: 'page.screenshot',
title: 'Screenshot',
subtitle: 'Capture visible page to Downloads',
icon: '\uD83D\uDCF8', // 📸
keywords: ['screenshot', 'capture', 'screen', 'image', 'png'],
},
{
id: 'dev.consoleSnapshot',
title: 'Export console (snapshot)',
subtitle: 'Capture console logs and save JSON to Downloads',
icon: '\uD83D\uDCDC', // 📜
keywords: ['dev', 'developer', 'console', 'logs', 'export', 'download', 'json'],
},
{
id: 'dev.consoleErrors',
title: 'Export console (errors)',
subtitle: 'Capture console errors and save JSON to Downloads',
icon: '\uD83D\uDEA8', // 🚨
keywords: ['dev', 'developer', 'console', 'errors', 'export', 'download', 'json'],
},
{
id: 'dev.readPage',
title: 'Export read_page (interactive)',
subtitle: 'Export visible interactive elements (accessibility tree) to Downloads',
icon: '\u267F\uFE0F', // ♿️
keywords: [
'dev',
'developer',
'read',
'page',
'accessibility',
'interactive',
'export',
'download',
],
},
{
id: 'dev.networkCapture10s',
title: 'Network capture (10s)',
subtitle: 'Capture network requests for 10 seconds and export JSON to Downloads',
icon: '\uD83D\uDCE1', // 📡
keywords: ['dev', 'developer', 'network', 'capture', 'requests', 'export', 'download', 'json'],
},
{
id: 'dev.performanceTrace5s',
title: 'Performance trace (5s)',
subtitle: 'Record a 5-second performance trace and save JSON to Downloads',
icon: '\u23F1\uFE0F', // ⏱️
keywords: [
'dev',
'developer',
'performance',
'trace',
'profiling',
'export',
'download',
'json',
],
},
{
id: 'dev.debugBundle',
title: 'Debug bundle',
subtitle: 'Collect screenshot/console/network/performance into a Downloads folder',
icon: '\uD83D\uDC1B', // 🐛
keywords: ['dev', 'developer', 'debug', 'bundle', 'bug', 'report', 'diagnostics'],
},
{
id: 'dev.debugBundleCancel',
title: 'Cancel debug bundle',
subtitle: 'Cancel active debug bundle collection for this tab',
icon: '\u270B', // ✋
keywords: ['dev', 'developer', 'debug', 'bundle', 'cancel', 'stop'],
},
{
id: 'page.readerMode',
title: 'Reader mode',
subtitle: 'Open a distraction-free reader overlay (Esc to close)',
icon: '\uD83D\uDCD6', // 📖
keywords: ['reader', 'readability', 'article', 'reading', 'mode'],
},
{
id: 'page.zenMode',
title: 'Zen mode',
subtitle: 'Hide common distractions (best-effort)',
icon: '\uD83E\uDDD8', // 🧘
keywords: ['zen', 'focus', 'minimal', 'distraction', 'hide'],
},
{
id: 'page.forceDark',
title: 'Force dark',
subtitle: 'Apply a simple dark filter (best-effort)',
icon: '\uD83C\uDF19', // 🌙
keywords: ['dark', 'night', 'theme', 'invert', 'contrast'],
},
{
id: 'page.allowCopy',
title: 'Allow copy',
subtitle: 'Enable selection and copy on this page (best-effort)',
icon: '\uD83D\uDCCB', // 📋
keywords: ['copy', 'select', 'selection', 'text', 'contextmenu'],
},
{
id: 'page.privacyCurtain',
title: 'Privacy curtain',
subtitle: 'Mask page content for screen sharing (Esc to hide)',
icon: '\uD83D\uDEE1\uFE0F', // 🛡️
keywords: ['privacy', 'curtain', 'mask', 'blur', 'screen', 'share'],
},
{
id: 'page.cleanUrl',
title: 'Clean URL',
subtitle: 'Remove tracking params and copy/open the cleaned URL',
icon: '\uD83E\uDDFC', // 🧼
keywords: ['clean', 'url', 'utm', 'tracking', 'share', 'sanitize'],
},
{
id: 'page.pictureInPicture',
title: 'Picture-in-Picture',
subtitle: 'Toggle Picture-in-Picture for the current page video',
icon: '\uD83D\uDCFA', // 📺
keywords: ['pip', 'picture', 'video', 'floating', 'player'],
},
{
id: 'page.skinVscode',
title: 'Skin: VS Code',
subtitle: 'Apply VS Code-inspired page skin (shows "Skin mode")',
icon: '\uD83C\uDFA8', // 🎨
keywords: ['skin', 'theme', 'style', 'vscode', 'code', 'editor'],
},
{
id: 'page.skinTerminal',
title: 'Skin: Terminal',
subtitle: 'Apply terminal-inspired page skin (shows "Skin mode")',
icon: '\uD83C\uDFA8', // 🎨
keywords: ['skin', 'theme', 'style', 'terminal', 'console', 'green'],
},
{
id: 'page.skinRetro',
title: 'Skin: Retro',
subtitle: 'Apply retro page skin (shows "Skin mode")',
icon: '\uD83C\uDFA8', // 🎨
keywords: ['skin', 'theme', 'style', 'retro', 'crt', 'nostalgia'],
},
{
id: 'page.skinPaper',
title: 'Skin: Paper',
subtitle: 'Apply paper page skin (shows "Skin mode")',
icon: '\uD83C\uDFA8', // 🎨
keywords: ['skin', 'theme', 'style', 'paper', 'serif', 'reading'],
},
{
id: 'page.skinOff',
title: 'Skin: Off',
subtitle: 'Remove page skin',
icon: '\uD83D\uDEAB', // 🚫
keywords: ['skin', 'theme', 'style', 'off', 'disable', 'clear', 'remove', 'reset'],
},
{
id: 'tab.close',
title: 'Close tab',
@@ -117,6 +331,27 @@ const COMMANDS: readonly CommandDef[] = [
icon: '\uD83D\uDD07', // 🔇
keywords: ['mute', 'unmute', 'toggle', 'sound', 'audio', 'tab'],
},
{
id: 'tab.closeOtherTabs',
title: 'Close other tabs',
subtitle: 'Close all other unpinned tabs in current window',
icon: '\uD83E\uDDF9', // 🧹
keywords: ['close', 'other', 'tabs', 'window', 'cleanup'],
},
{
id: 'tab.closeTabsToRight',
title: 'Close tabs to the right',
subtitle: 'Close unpinned tabs to the right in current window',
icon: '\u27A1\uFE0F\u2716', // ➡️✖
keywords: ['close', 'tabs', 'right', 'window', 'cleanup'],
},
{
id: 'tab.discardInactiveTabs',
title: 'Discard inactive tabs',
subtitle: 'Discard inactive unpinned tabs in current window',
icon: '\uD83D\uDCA4', // 💤
keywords: ['discard', 'sleep', 'unload', 'tabs', 'inactive', 'window', 'memory'],
},
{
id: 'copy.url',
title: 'Copy URL',
@@ -145,8 +380,412 @@ const COMMANDS: readonly CommandDef[] = [
icon: '\uD83E\uDE9F', // 🪟
keywords: ['new', 'window', 'open', 'create'],
},
{
id: 'window.newIncognitoWindow',
title: 'New incognito window',
subtitle: 'Open a new incognito window',
icon: '\uD83D\uDD76\uFE0F', // 🕶️
keywords: ['incognito', 'private', 'window', 'new'],
},
{
id: 'window.mergeAllWindows',
title: 'Merge all windows',
subtitle: 'Move tabs from other windows into current window',
icon: '\uD83E\uDDF2', // 🧲
keywords: ['merge', 'combine', 'windows', 'tabs', 'move'],
},
] as const;
// ============================================================
// Toolbox (Argument Commands)
// ============================================================
const TOOLBOX_SCORE = 1000;
const TOOLBOX_MAX_INPUT_CHARS = 100_000;
function formatPreview(value: string, maxLen: number): string {
const oneLine = String(value ?? '')
.replace(/\s+/g, ' ')
.trim();
if (oneLine.length <= maxLen) return oneLine;
return `${oneLine.slice(0, Math.max(0, maxLen - 1))}\u2026`;
}
function splitInvocation(rawQuery: string): { tool: string; args: string } | null {
const raw = String(rawQuery ?? '');
const trimmed = raw.trim();
if (!trimmed) return null;
const m = /^(\S+)\s*(.*)$/.exec(trimmed);
if (!m) return null;
const tool = String(m[1] ?? '').toLowerCase();
const args = String(m[2] ?? '');
return tool ? { tool, args } : null;
}
function isToolboxToolId(value: string): value is ToolboxToolId {
return (
value === 'json' ||
value === 'base64' ||
value === 'url' ||
value === 'ts' ||
value === 'uuid' ||
value === 'jwt'
);
}
function parseLeadingFlag(
args: string,
flagPatterns: readonly RegExp[],
): { hasFlag: boolean; rest: string } {
const raw = String(args ?? '');
const trimmed = raw.trimStart();
for (const pattern of flagPatterns) {
const m = pattern.exec(trimmed);
if (!m) continue;
const rest = trimmed.slice(m[0].length).trimStart();
return { hasFlag: true, rest };
}
return { hasFlag: false, rest: trimmed };
}
function createToolboxResult(options: {
id: string;
title: string;
subtitle: string;
icon: string;
tool: ToolboxToolId;
outputs: ToolboxOutputItem[];
score?: number;
}): SearchResult<CommandsSearchResultData> {
return {
id: options.id,
provider: 'commands',
title: options.title,
subtitle: options.subtitle,
icon: options.icon,
data: { kind: 'toolbox', tool: options.tool, outputs: options.outputs },
score: options.score ?? TOOLBOX_SCORE,
};
}
function createToolboxErrorResult(options: {
id: string;
title: string;
icon: string;
tool: ToolboxToolId;
error: string;
}): SearchResult<CommandsSearchResultData> {
const error = String(options.error ?? 'Unknown error');
return createToolboxResult({
id: options.id,
title: options.title,
subtitle: error,
icon: options.icon,
tool: options.tool,
outputs: [{ id: 'copyError', title: 'Copy error', value: error, hotkeyHint: 'Enter' }],
});
}
function createToolboxResults(rawQuery: string): SearchResult<CommandsSearchResultData>[] {
const invocation = splitInvocation(rawQuery);
if (!invocation) return [];
const rawArgs = invocation.args ?? '';
if (rawArgs.length > TOOLBOX_MAX_INPUT_CHARS) {
const tool = isToolboxToolId(invocation.tool) ? invocation.tool : 'json';
return [
createToolboxErrorResult({
id: `toolbox.${tool}.error.inputTooLarge`,
title: 'Toolbox',
icon: '\u26A0\uFE0F', // ⚠️
tool,
error: `Input too large (>${TOOLBOX_MAX_INPUT_CHARS} chars)`,
}),
];
}
switch (invocation.tool) {
case 'json': {
const input = rawArgs.trimStart();
if (!input) return [];
const res = formatJson(input);
if (!res.ok) {
return [
createToolboxErrorResult({
id: 'toolbox.json.error',
title: 'JSON',
icon: '\uD83D\uDCDD', // 📝
tool: 'json',
error: res.error,
}),
];
}
return [
createToolboxResult({
id: 'toolbox.json',
title: 'JSON',
subtitle: formatPreview(res.value.pretty, 120),
icon: '\uD83D\uDCDD', // 📝
tool: 'json',
outputs: [
{
id: 'copyPretty',
title: 'Copy pretty JSON',
value: res.value.pretty,
hotkeyHint: 'Enter',
},
{ id: 'copyMinified', title: 'Copy minified JSON', value: res.value.minified },
],
}),
];
}
case 'base64': {
const { hasFlag: decode, rest } = parseLeadingFlag(rawArgs, [
/^-d\b/i,
/^--decode\b/i,
/^decode\b/i,
]);
const input = rest;
if (!input) return [];
if (decode) {
const res = base64DecodeUtf8(input);
if (!res.ok) {
return [
createToolboxErrorResult({
id: 'toolbox.base64.decode.error',
title: 'Base64 decode',
icon: '\uD83D\uDD13', // 🔓
tool: 'base64',
error: res.error,
}),
];
}
return [
createToolboxResult({
id: 'toolbox.base64.decode',
title: 'Base64 decode',
subtitle: formatPreview(res.value, 120),
icon: '\uD83D\uDD13', // 🔓
tool: 'base64',
outputs: [
{
id: 'copyDecoded',
title: 'Copy decoded text',
value: res.value,
hotkeyHint: 'Enter',
},
],
}),
];
}
const res = base64EncodeUtf8(input);
if (!res.ok) {
return [
createToolboxErrorResult({
id: 'toolbox.base64.encode.error',
title: 'Base64 encode',
icon: '\uD83D\uDD12', // 🔒
tool: 'base64',
error: res.error,
}),
];
}
return [
createToolboxResult({
id: 'toolbox.base64.encode',
title: 'Base64 encode',
subtitle: formatPreview(res.value, 120),
icon: '\uD83D\uDD12', // 🔒
tool: 'base64',
outputs: [
{ id: 'copyEncoded', title: 'Copy Base64', value: res.value, hotkeyHint: 'Enter' },
],
}),
];
}
case 'url': {
const { hasFlag: decode, rest } = parseLeadingFlag(rawArgs, [
/^-d\b/i,
/^--decode\b/i,
/^decode\b/i,
]);
const input = rest;
if (!input) return [];
if (decode) {
const res = urlDecode(input);
if (!res.ok) {
return [
createToolboxErrorResult({
id: 'toolbox.url.decode.error',
title: 'URL decode',
icon: '\uD83E\uDDE9', // 🧩
tool: 'url',
error: res.error,
}),
];
}
return [
createToolboxResult({
id: 'toolbox.url.decode',
title: 'URL decode',
subtitle: formatPreview(res.value, 120),
icon: '\uD83E\uDDE9', // 🧩
tool: 'url',
outputs: [
{
id: 'copyDecoded',
title: 'Copy decoded text',
value: res.value,
hotkeyHint: 'Enter',
},
],
}),
];
}
const res = urlEncode(input);
if (!res.ok) {
return [
createToolboxErrorResult({
id: 'toolbox.url.encode.error',
title: 'URL encode',
icon: '\uD83E\uDDE9', // 🧩
tool: 'url',
error: res.error,
}),
];
}
return [
createToolboxResult({
id: 'toolbox.url.encode',
title: 'URL encode',
subtitle: formatPreview(res.value, 120),
icon: '\uD83E\uDDE9', // 🧩
tool: 'url',
outputs: [
{
id: 'copyEncoded',
title: 'Copy encoded text',
value: res.value,
hotkeyHint: 'Enter',
},
],
}),
];
}
case 'ts': {
const input = rawArgs.trim();
const res = convertUnixTimestamp(input || String(Date.now()));
if (!res.ok) {
return [
createToolboxErrorResult({
id: 'toolbox.ts.error',
title: 'Timestamp',
icon: '\u23F1\uFE0F', // ⏱️
tool: 'ts',
error: res.error,
}),
];
}
return [
createToolboxResult({
id: 'toolbox.ts',
title: 'Timestamp',
subtitle: res.value.iso,
icon: '\u23F1\uFE0F', // ⏱️
tool: 'ts',
outputs: [
{ id: 'copyIso', title: 'Copy ISO', value: res.value.iso, hotkeyHint: 'Enter' },
{ id: 'copySeconds', title: 'Copy seconds', value: String(res.value.seconds) },
{
id: 'copyMilliseconds',
title: 'Copy milliseconds',
value: String(res.value.milliseconds),
},
],
}),
];
}
case 'uuid': {
const uuid = generateUuidV4();
return [
createToolboxResult({
id: 'toolbox.uuid',
title: 'UUID v4',
subtitle: uuid,
icon: '\uD83C\uDD94', // 🆔
tool: 'uuid',
outputs: [{ id: 'copyUuid', title: 'Copy UUID', value: uuid, hotkeyHint: 'Enter' }],
}),
];
}
case 'jwt': {
const token = rawArgs.trim();
if (!token) return [];
const res = decodeJwt(token);
if (!res.ok) {
return [
createToolboxErrorResult({
id: 'toolbox.jwt.error',
title: 'JWT decode',
icon: '\uD83D\uDD11', // 🔑
tool: 'jwt',
error: res.error,
}),
];
}
const header = JSON.stringify(res.value.header, null, 2);
const payload = JSON.stringify(res.value.payload, null, 2);
return [
createToolboxResult({
id: 'toolbox.jwt.header',
title: 'JWT header',
subtitle: formatPreview(header, 120),
icon: '\uD83D\uDD11', // 🔑
tool: 'jwt',
outputs: [
{ id: 'copyHeader', title: 'Copy header JSON', value: header, hotkeyHint: 'Enter' },
],
}),
createToolboxResult({
id: 'toolbox.jwt.payload',
title: 'JWT payload',
subtitle: formatPreview(payload, 120),
icon: '\uD83D\uDD11', // 🔑
tool: 'jwt',
outputs: [
{ id: 'copyPayload', title: 'Copy payload JSON', value: payload, hotkeyHint: 'Enter' },
],
}),
];
}
default:
return [];
}
}
// ============================================================
// Command Execution
// ============================================================
@@ -164,6 +803,42 @@ function toPageCommand(commandId: QuickPanelCommandId): QuickPanelPageCommand |
return 'forward';
case 'page.stop':
return 'stop';
case 'page.screenshot':
return 'screenshot';
case 'dev.consoleSnapshot':
return 'dev_console_snapshot_export';
case 'dev.consoleErrors':
return 'dev_console_errors_export';
case 'dev.readPage':
return 'dev_read_page_export';
case 'dev.networkCapture10s':
return 'dev_network_capture_10s_export';
case 'dev.performanceTrace5s':
return 'dev_performance_trace_5s_export';
case 'dev.debugBundle':
return 'dev_debug_bundle_create';
case 'dev.debugBundleCancel':
return 'dev_debug_bundle_cancel';
case 'page.readerMode':
return 'reader_mode_toggle';
case 'page.zenMode':
return 'zen_mode_toggle';
case 'page.forceDark':
return 'force_dark_toggle';
case 'page.allowCopy':
return 'allow_copy_toggle';
case 'page.privacyCurtain':
return 'privacy_curtain_toggle';
case 'page.skinVscode':
return 'skin_vscode';
case 'page.skinTerminal':
return 'skin_terminal';
case 'page.skinRetro':
return 'skin_retro';
case 'page.skinPaper':
return 'skin_paper';
case 'page.skinOff':
return 'skin_off';
case 'tab.close':
return 'close_tab';
case 'tab.duplicate':
@@ -172,15 +847,65 @@ function toPageCommand(commandId: QuickPanelCommandId): QuickPanelPageCommand |
return 'toggle_pin';
case 'tab.toggleMute':
return 'toggle_mute';
case 'tab.closeOtherTabs':
return 'close_other_tabs';
case 'tab.closeTabsToRight':
return 'close_tabs_to_right';
case 'tab.discardInactiveTabs':
return 'discard_inactive_tabs';
case 'window.newTab':
return 'new_tab';
case 'window.newWindow':
return 'new_window';
case 'window.newIncognitoWindow':
return 'new_incognito_window';
case 'window.mergeAllWindows':
return 'merge_all_windows';
default:
return null;
}
}
function pickBestVideoElement(): HTMLVideoElement | null {
const videos = Array.from(document.querySelectorAll('video')) as HTMLVideoElement[];
if (videos.length === 0) return null;
let best: { el: HTMLVideoElement; area: number } | null = null;
for (const el of videos) {
try {
const rect = el.getBoundingClientRect();
const area = Math.max(0, rect.width) * Math.max(0, rect.height);
if (area <= 0) continue;
// Prefer videos with data loaded.
const ready = typeof el.readyState === 'number' ? el.readyState : 0;
const score = area + (ready >= 2 ? 10_000 : 0);
if (!best || score > best.area) best = { el, area: score };
} catch {
// Ignore and keep scanning.
}
}
return best?.el ?? videos[0] ?? null;
}
function togglePictureInPicture(): Promise<void> {
const anyDoc = document as unknown as {
pictureInPictureElement?: Element | null;
exitPictureInPicture?: () => Promise<void>;
};
if (anyDoc.pictureInPictureElement && typeof anyDoc.exitPictureInPicture === 'function') {
return anyDoc.exitPictureInPicture();
}
const video = pickBestVideoElement();
if (!video || typeof (video as any).requestPictureInPicture !== 'function') {
return Promise.reject(new Error('No Picture-in-Picture compatible video found'));
}
return (video as any).requestPictureInPicture().then(() => undefined);
}
/**
* Execute a page command via background service worker.
*/
@@ -247,32 +972,92 @@ export function createCommandsProvider(): SearchProvider<CommandsSearchResultDat
function getActions(
item: SearchResult<CommandsSearchResultData>,
): Action<CommandsSearchResultData>[] {
const commandId = item.data.commandId;
const data = item.data as unknown;
// Toolbox results: local-only copy actions (no background bridge).
if (typeof data === 'object' && data !== null && (data as any).kind === 'toolbox') {
const outputs = Array.isArray((data as ToolboxSearchResultData).outputs)
? (data as ToolboxSearchResultData).outputs
: [];
return outputs.map((o, idx) => ({
id: `toolbox.${String((data as ToolboxSearchResultData).tool)}.${o.id}`,
title: o.title,
hotkeyHint: idx === 0 ? 'Enter' : o.hotkeyHint,
execute: async () => {
const tool = String((data as ToolboxSearchResultData).tool);
await writeToClipboard(o.value, { source: `toolbox.${tool}.${o.id}`, label: o.title });
},
}));
}
const commandId = (data as { commandId?: unknown })?.commandId;
const normalized = typeof commandId === 'string' ? commandId.trim() : '';
const isDangerous =
normalized === 'tab.close' ||
normalized === 'tab.closeOtherTabs' ||
normalized === 'tab.closeTabsToRight';
return [
{
id: 'commands.run',
title: 'Run command',
tone: isDangerous ? 'danger' : 'default',
hotkeyHint: 'Enter',
execute: async () => {
// Handle clipboard commands directly in content script
if (commandId === 'copy.url') {
const url = window.location.href;
await writeToClipboard(url);
execute: async (ctx) => {
// Page tools executed in content script (need access to current URL / user gesture).
if (normalized === 'page.cleanUrl') {
const currentUrl = window.location.href;
const cleaned = cleanUrl(currentUrl).cleaned || currentUrl;
// Enter -> copy (safe default), Cmd/Ctrl+Enter -> open in new tab.
if (ctx.openMode === 'new_tab' || ctx.openMode === 'background_tab') {
await openUrl({ url: cleaned, disposition: ctx.openMode });
return;
}
await writeToClipboard(cleaned, {
source: 'commands.clean_url',
label: document.title || cleaned,
});
return;
}
if (commandId === 'copy.markdown') {
// Best-effort: PiP often requires a user gesture. Avoid extra awaits before calling it.
if (normalized === 'page.pictureInPicture') {
return togglePictureInPicture();
}
// Handle clipboard commands directly in content script
if (normalized === 'copy.url') {
const url = window.location.href;
await writeToClipboard(url, {
source: 'commands.copy.url',
label: document.title || url,
});
return;
}
if (normalized === 'copy.markdown') {
const url = window.location.href;
const title = document.title || url;
await writeToClipboard(formatMarkdownLink(title, url));
await writeToClipboard(formatMarkdownLink(title, url), {
source: 'commands.copy.markdown',
label: title,
});
return;
}
// Screenshot should run after the panel is closed to avoid capturing the overlay.
if (normalized === 'page.screenshot' || normalized === 'dev.debugBundle') {
await new Promise((resolve) => setTimeout(resolve, 0));
}
// Execute other commands via background
const pageCommand = toPageCommand(commandId);
const pageCommand = toPageCommand(normalized as QuickPanelCommandId);
if (!pageCommand) {
throw new Error(`Unsupported commandId: ${commandId}`);
throw new Error(`Unsupported commandId: ${normalized}`);
}
await executePageCommand(pageCommand);
},
@@ -289,6 +1074,8 @@ export function createCommandsProvider(): SearchProvider<CommandsSearchResultDat
if (ctx.signal.aborted) return [];
if (ctx.query.tokens.length === 0) return [];
const toolboxResults = createToolboxResults(ctx.query.raw);
// Score and filter commands
const scored = COMMANDS.map((def) => {
const score = computeCommandScore(def, ctx.query.tokens);
@@ -304,7 +1091,7 @@ export function createCommandsProvider(): SearchProvider<CommandsSearchResultDat
.slice(0, ctx.limit);
// Convert to SearchResult format
return scored.map(({ def, score }) => {
const commandResults = scored.map(({ def, score }) => {
const data: CommandsSearchResultData = {
commandId: def.id,
};
@@ -319,6 +1106,10 @@ export function createCommandsProvider(): SearchProvider<CommandsSearchResultDat
score,
};
});
const combined = [...toolboxResults, ...commandResults];
combined.sort((a, b) => b.score - a.score);
return combined.slice(0, ctx.limit);
}
return {
@@ -0,0 +1,218 @@
/**
* Content Search Provider (Quick Panel)
*
* Searches cached readable page text for open tabs via background service worker bridge.
*
* Notes:
* - Extraction and caching are performed in the background (see quick-panel/content-handler.ts)
* - This provider only handles querying and result actions (switch/open/copy/close)
*/
import {
BACKGROUND_MESSAGE_TYPES,
type QuickPanelActivateTabResponse,
type QuickPanelCloseTabResponse,
type QuickPanelContentMatchSummary,
type QuickPanelContentQueryResponse,
} from '@/common/message-types';
import type { Action, SearchProvider, SearchProviderContext, SearchResult } from '../core/types';
import { openUrl, writeToClipboard } from './provider-utils';
// ============================================================
// Types
// ============================================================
/**
* Data associated with a content search result.
*/
export interface ContentSearchResultData {
tabId: number;
windowId: number;
url: string;
title: string;
favIconUrl?: string;
snippet: string;
}
// ============================================================
// Client (Background Bridge)
// ============================================================
interface ContentClient {
query: (options: {
query: string;
maxResults: number;
signal: AbortSignal;
}) => Promise<QuickPanelContentMatchSummary[]>;
activateTab: (tabId: number, windowId?: number) => Promise<void>;
closeTab: (tabId: number) => Promise<void>;
}
function createRuntimeContentClient(): ContentClient {
async function query(options: {
query: string;
maxResults: number;
signal: AbortSignal;
}): Promise<QuickPanelContentMatchSummary[]> {
if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
throw new Error('chrome.runtime.sendMessage is not available');
}
if (options.signal.aborted) {
throw new Error('aborted');
}
const resp = (await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CONTENT_QUERY,
payload: { query: options.query, maxResults: options.maxResults },
})) as QuickPanelContentQueryResponse;
if (!resp || resp.success !== true) {
const err = (resp as { error?: unknown })?.error;
throw new Error(typeof err === 'string' ? err : 'Failed to query content');
}
return Array.isArray(resp.items) ? resp.items : [];
}
async function activateTab(tabId: number, windowId?: number): Promise<void> {
if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
throw new Error('chrome.runtime.sendMessage is not available');
}
const resp = (await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_ACTIVATE,
payload: { tabId, windowId },
})) as QuickPanelActivateTabResponse;
if (!resp || resp.success !== true) {
const err = (resp as { error?: unknown })?.error;
throw new Error(typeof err === 'string' ? err : 'Failed to activate tab');
}
}
async function closeTab(tabId: number): Promise<void> {
if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
throw new Error('chrome.runtime.sendMessage is not available');
}
const resp = (await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_CLOSE,
payload: { tabId },
})) as QuickPanelCloseTabResponse;
if (!resp || resp.success !== true) {
const err = (resp as { error?: unknown })?.error;
throw new Error(typeof err === 'string' ? err : 'Failed to close tab');
}
}
return { query, activateTab, closeTab };
}
// ============================================================
// Provider Factory
// ============================================================
export function createContentProvider(): SearchProvider<ContentSearchResultData> {
const id = 'content';
const name = 'Content';
const icon = '\uD83D\uDCC4'; // 📄
const client = createRuntimeContentClient();
function getActions(
item: SearchResult<ContentSearchResultData>,
): Action<ContentSearchResultData>[] {
const { tabId, windowId, url, title } = item.data;
return [
{
id: 'content.open',
title: 'Switch to tab',
hotkeyHint: 'Enter',
execute: async (ctx) => {
// Honor "open in new tab" keyboard hint by opening the URL instead of switching.
if (ctx.openMode === 'new_tab' || ctx.openMode === 'background_tab') {
await openUrl({ url, disposition: ctx.openMode });
return;
}
await client.activateTab(tabId, windowId);
},
},
{
id: 'content.openNewTab',
title: 'Open in new tab',
hotkeyHint: 'Cmd/Ctrl+Enter',
execute: async () => {
await openUrl({ url, disposition: 'new_tab' });
},
},
{
id: 'content.copyUrl',
title: 'Copy URL',
hotkeyHint: 'Cmd+C',
execute: async () => {
await writeToClipboard(url, { source: 'content.copy.url', label: title });
},
},
{
id: 'content.closeTab',
title: 'Close tab',
tone: 'danger',
hotkeyHint: 'Cmd+W',
execute: async () => {
await client.closeTab(tabId);
},
},
];
}
async function search(
ctx: SearchProviderContext,
): Promise<SearchResult<ContentSearchResultData>[]> {
if (ctx.signal.aborted) return [];
if (ctx.query.tokens.length === 0) return [];
const items = await client.query({
query: ctx.query.text,
maxResults: ctx.limit,
signal: ctx.signal,
});
if (ctx.signal.aborted) return [];
return items.slice(0, ctx.limit).map((m) => {
const data: ContentSearchResultData = {
tabId: m.tabId,
windowId: m.windowId,
url: m.url,
title: m.title,
favIconUrl: m.favIconUrl,
snippet: m.snippet,
};
return {
id: String(m.tabId),
provider: id,
title: m.title?.trim() || m.url || 'Untitled',
subtitle: m.snippet?.trim() || m.url,
icon,
data,
score: m.score,
};
});
}
return {
id,
name,
icon,
scopes: ['content'],
includeInAll: true,
priority: 10,
maxResults: 30,
supportsEmptyQuery: false,
search,
getActions,
};
}
File diff suppressed because it is too large Load Diff
@@ -8,12 +8,17 @@
import {
BACKGROUND_MESSAGE_TYPES,
type QuickPanelHistoryDeleteResponse,
type QuickPanelHistoryQueryResponse,
type QuickPanelHistorySummary,
type QuickPanelOpenUrlResponse,
} from '@/common/message-types';
import type { Action, SearchProvider, SearchProviderContext, SearchResult } from '../core/types';
import { computeWeightedTokenScore, formatMarkdownLink, writeToClipboard } from './provider-utils';
import {
computeWeightedTokenScore,
formatMarkdownLink,
openUrl,
writeToClipboard,
} from './provider-utils';
// ============================================================
// Types
@@ -41,10 +46,7 @@ interface HistoryClient {
maxResults: number;
signal: AbortSignal;
}) => Promise<QuickPanelHistorySummary[]>;
openUrl: (options: {
url: string;
disposition: 'current_tab' | 'new_tab' | 'background_tab';
}) => Promise<void>;
deleteUrl: (url: string) => Promise<void>;
}
function createRuntimeHistoryClient(): HistoryClient {
@@ -73,26 +75,28 @@ function createRuntimeHistoryClient(): HistoryClient {
return Array.isArray(resp.items) ? resp.items : [];
}
async function openUrl(options: {
url: string;
disposition: 'current_tab' | 'new_tab' | 'background_tab';
}): Promise<void> {
async function deleteUrl(url: string): Promise<void> {
if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
throw new Error('chrome.runtime.sendMessage is not available');
}
const normalized = String(url ?? '').trim();
if (!normalized) {
throw new Error('url is required');
}
const resp = (await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_OPEN_URL,
payload: { url: options.url, disposition: options.disposition },
})) as QuickPanelOpenUrlResponse;
type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_HISTORY_DELETE,
payload: { url: normalized },
})) as QuickPanelHistoryDeleteResponse;
if (!resp || resp.success !== true) {
const err = (resp as { error?: unknown })?.error;
throw new Error(typeof err === 'string' ? err : 'Failed to open url');
throw new Error(typeof err === 'string' ? err : 'Failed to delete history entry');
}
}
return { query, openUrl };
return { query, deleteUrl };
}
// ============================================================
@@ -165,16 +169,17 @@ export function createHistoryProvider(): SearchProvider<HistorySearchResultData>
id: 'history.open',
title: 'Open',
hotkeyHint: 'Enter',
execute: async () => {
await client.openUrl({ url, disposition: 'current_tab' });
execute: async (ctx) => {
await openUrl({ url, disposition: ctx.openMode ?? 'current_tab' });
},
},
// Open in new tab
{
id: 'history.openNewTab',
title: 'Open in new tab',
hotkeyHint: 'Cmd/Ctrl+Enter',
execute: async () => {
await client.openUrl({ url, disposition: 'new_tab' });
await openUrl({ url, disposition: 'new_tab' });
},
},
// Copy URL
@@ -183,7 +188,7 @@ export function createHistoryProvider(): SearchProvider<HistorySearchResultData>
title: 'Copy URL',
hotkeyHint: 'Cmd+C',
execute: async () => {
await writeToClipboard(url);
await writeToClipboard(url, { source: 'history.copy.url', label: title });
},
},
// Copy as Markdown link
@@ -192,7 +197,19 @@ export function createHistoryProvider(): SearchProvider<HistorySearchResultData>
title: 'Copy as Markdown',
hotkeyHint: 'Cmd+Shift+C',
execute: async () => {
await writeToClipboard(formatMarkdownLink(title, url));
await writeToClipboard(formatMarkdownLink(title, url), {
source: 'history.copy.markdown',
label: title,
});
},
},
// Delete from history (danger)
{
id: 'history.delete',
title: 'Delete from history',
tone: 'danger',
execute: async () => {
await client.deleteUrl(url);
},
},
];
@@ -14,8 +14,30 @@ export { createBookmarksProvider, type BookmarksSearchResultData } from './bookm
export { createHistoryProvider, type HistorySearchResultData } from './history-provider';
export { createContentProvider, type ContentSearchResultData } from './content-provider';
export {
createCommandsProvider,
type CommandsSearchResultData,
type QuickPanelCommandId,
} from './commands-provider';
export { createApiDetectiveProvider, type ApiDetectiveResultData } from './api-detective-provider';
export {
createWebSearchProvider,
type WebSearchResultData,
type WebSearchScope,
} from './web-search-provider';
export { createWorkspacesProvider, type WorkspacesResultData } from './workspaces-provider';
export { createClipboardProvider, type ClipboardResultData } from './clipboard-provider';
export { createNotesProvider, type NotesResultData } from './notes-provider';
export { createFocusProvider, type FocusResultData } from './focus-provider';
export { createMonitorProvider, type MonitorResultData } from './monitor-provider';
export { createAuditProvider, type AuditResultData } from './audit-provider';
@@ -0,0 +1,507 @@
/**
* Web Monitor / Price Track Provider (Quick Panel)
*
* Scope:
* - Prefix-only scope: `mon `
* - Not included in 'all' scope (privacy + noise)
*
* Notes:
* - This provider is UI-only. All scheduling and extraction happens in the background handler.
*/
import {
BACKGROUND_MESSAGE_TYPES,
type QuickPanelMonitorAlert,
type QuickPanelMonitorAlertDeleteResponse,
type QuickPanelMonitorAlertMarkReadResponse,
type QuickPanelMonitorCreateResponse,
type QuickPanelMonitorListResponse,
type QuickPanelMonitorSetEnabledResponse,
type QuickPanelMonitorSummary,
type QuickPanelMonitorCheckNowResponse,
} from '@/common/message-types';
import type { Action, SearchProvider, SearchProviderContext, SearchResult } from '../core/types';
import { openUrl, writeToClipboard } from './provider-utils';
// ============================================================
// Types
// ============================================================
export type MonitorResultData =
| { kind: 'summary'; unreadCount: number; monitorCount: number; alertCount: number }
| { kind: 'monitor'; monitor: QuickPanelMonitorSummary }
| { kind: 'alert'; alert: QuickPanelMonitorAlert }
| { kind: 'create'; url: string; selector: string }
| { kind: 'empty' }
| { kind: 'help' };
interface MonitorClient {
list: (options: {
query?: string;
maxMonitors: number;
maxAlerts: number;
signal: AbortSignal;
}) => Promise<{
monitors: QuickPanelMonitorSummary[];
alerts: QuickPanelMonitorAlert[];
unreadCount: number;
}>;
create: (options: {
url: string;
selector: string;
intervalMinutes: number;
}) => Promise<QuickPanelMonitorSummary>;
delete: (options: { id: string }) => Promise<void>;
setEnabled: (options: { id: string; enabled: boolean }) => Promise<QuickPanelMonitorSummary>;
checkNow: (options: {
id: string;
}) => Promise<{ monitor: QuickPanelMonitorSummary; alertCreated?: QuickPanelMonitorAlert }>;
alertMarkRead: (options: { id: string; read: boolean }) => Promise<number>;
alertDelete: (options: { id: string }) => Promise<number>;
}
// ============================================================
// Helpers
// ============================================================
function safeErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message || String(err);
return String(err);
}
function normalizeUrlInput(input: string): string | null {
const raw = String(input ?? '').trim();
if (!raw) return null;
try {
const u = new URL(raw);
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
u.hash = '';
return u.toString();
} catch {
if (!raw.includes('.') || raw.includes(' ')) return null;
try {
const u = new URL(`https://${raw}`);
u.hash = '';
return u.toString();
} catch {
return null;
}
}
}
function formatHost(url: string): string {
try {
return new URL(url).hostname || url;
} catch {
return url;
}
}
function formatTime(ts: number): string | null {
const n = typeof ts === 'number' && Number.isFinite(ts) ? ts : 0;
if (n <= 0) return null;
try {
return new Date(n).toLocaleString();
} catch {
return null;
}
}
function buildMonitorSubtitle(m: QuickPanelMonitorSummary): string {
const parts: string[] = [];
parts.push(`${m.intervalMinutes}m`);
parts.push(m.enabled ? 'Enabled' : 'Paused');
if (m.unreadAlerts > 0) parts.push(`${m.unreadAlerts} unread`);
const last = formatTime(m.lastCheckedAt);
if (last) parts.push(`Checked: ${last}`);
if (m.lastError) parts.push(`Error: ${m.lastError}`);
return parts.join(' \u00B7 ');
}
function buildAlertTitle(a: QuickPanelMonitorAlert): string {
const host = formatHost(a.url);
return a.read ? `Change (read): ${host}` : `Change: ${host}`;
}
function buildAlertSubtitle(a: QuickPanelMonitorAlert): string {
const parts: string[] = [];
parts.push(a.selector);
const when = formatTime(a.createdAt);
if (when) parts.push(when);
if (!a.read) parts.push('Unread');
return parts.join(' \u00B7 ');
}
function createRuntimeClient(): MonitorClient {
async function send<T>(type: string, payload: unknown): Promise<T> {
if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
throw new Error('chrome.runtime.sendMessage is not available');
}
return (await chrome.runtime.sendMessage({ type, payload })) as T;
}
async function list(options: {
query?: string;
maxMonitors: number;
maxAlerts: number;
signal: AbortSignal;
}): Promise<{
monitors: QuickPanelMonitorSummary[];
alerts: QuickPanelMonitorAlert[];
unreadCount: number;
}> {
if (options.signal.aborted) throw new Error('aborted');
const resp = await send<QuickPanelMonitorListResponse>(
BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_LIST,
{
query: options.query,
maxMonitors: options.maxMonitors,
maxAlerts: options.maxAlerts,
},
);
if (!resp || resp.success !== true) {
throw new Error((resp as any)?.error || 'Failed to list monitors');
}
return {
monitors: Array.isArray(resp.monitors) ? resp.monitors : [],
alerts: Array.isArray(resp.alerts) ? resp.alerts : [],
unreadCount: typeof resp.unreadCount === 'number' ? resp.unreadCount : 0,
};
}
async function create(options: {
url: string;
selector: string;
intervalMinutes: number;
}): Promise<QuickPanelMonitorSummary> {
const resp = await send<QuickPanelMonitorCreateResponse>(
BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_CREATE,
{
url: options.url,
selector: options.selector,
intervalMinutes: options.intervalMinutes,
fetchNow: true,
},
);
if (!resp || resp.success !== true) {
throw new Error((resp as any)?.error || 'Failed to create monitor');
}
return resp.monitor;
}
async function del(options: { id: string }): Promise<void> {
const id = String(options.id ?? '').trim();
if (!id) throw new Error('id is required');
const resp = await send<{ success: boolean; error?: string }>(
BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_DELETE,
{ id },
);
if (!resp || resp.success !== true) {
throw new Error((resp as any)?.error || 'Failed to delete monitor');
}
}
async function setEnabled(options: {
id: string;
enabled: boolean;
}): Promise<QuickPanelMonitorSummary> {
const id = String(options.id ?? '').trim();
if (!id) throw new Error('id is required');
const resp = await send<QuickPanelMonitorSetEnabledResponse>(
BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_SET_ENABLED,
{ id, enabled: options.enabled === true },
);
if (!resp || resp.success !== true) {
throw new Error((resp as any)?.error || 'Failed to update monitor');
}
return resp.monitor;
}
async function checkNow(options: {
id: string;
}): Promise<{ monitor: QuickPanelMonitorSummary; alertCreated?: QuickPanelMonitorAlert }> {
const id = String(options.id ?? '').trim();
if (!id) throw new Error('id is required');
const resp = await send<QuickPanelMonitorCheckNowResponse>(
BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_CHECK_NOW,
{ id },
);
if (!resp || resp.success !== true) {
throw new Error((resp as any)?.error || 'Failed to check monitor');
}
return { monitor: resp.monitor, alertCreated: resp.alertCreated };
}
async function alertMarkRead(options: { id: string; read: boolean }): Promise<number> {
const id = String(options.id ?? '').trim();
if (!id) throw new Error('id is required');
const resp = await send<QuickPanelMonitorAlertMarkReadResponse>(
BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_ALERT_MARK_READ,
{ id, read: options.read === true },
);
if (!resp || resp.success !== true) {
throw new Error((resp as any)?.error || 'Failed to update alert');
}
return typeof resp.unreadCount === 'number' ? resp.unreadCount : 0;
}
async function alertDelete(options: { id: string }): Promise<number> {
const id = String(options.id ?? '').trim();
if (!id) throw new Error('id is required');
const resp = await send<QuickPanelMonitorAlertDeleteResponse>(
BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_ALERT_DELETE,
{ id },
);
if (!resp || resp.success !== true) {
throw new Error((resp as any)?.error || 'Failed to delete alert');
}
return typeof resp.unreadCount === 'number' ? resp.unreadCount : 0;
}
return { list, create, delete: del, setEnabled, checkNow, alertMarkRead, alertDelete };
}
// ============================================================
// Provider Factory
// ============================================================
export function createMonitorProvider(): SearchProvider<MonitorResultData> {
const id = 'monitor';
const name = 'Monitor';
const icon = '\uD83D\uDC40'; // 👀
const client = createRuntimeClient();
function getActions(item: SearchResult<MonitorResultData>): Action<MonitorResultData>[] {
const data = item.data;
if (data.kind === 'create') {
const presets = [15, 60, 24 * 60];
return presets.map((minutes, idx) => ({
id: `monitor.create.${minutes}`,
title: idx === 0 ? `Create monitor (${minutes}m)` : `Create (${minutes}m)`,
hotkeyHint: idx === 0 ? 'Enter' : undefined,
execute: async () => {
await client.create({ url: data.url, selector: data.selector, intervalMinutes: minutes });
},
}));
}
if (data.kind === 'monitor') {
const monitor = data.monitor;
return [
{
id: 'monitor.checkNow',
title: 'Check now',
hotkeyHint: 'Enter',
execute: async () => {
await client.checkNow({ id: monitor.id });
},
},
{
id: 'monitor.open',
title: 'Open page',
execute: async (ctx) => {
await openUrl({ url: monitor.url, disposition: ctx.openMode ?? 'current_tab' });
},
},
{
id: 'monitor.toggleEnabled',
title: monitor.enabled ? 'Pause' : 'Resume',
execute: async () => {
await client.setEnabled({ id: monitor.id, enabled: !monitor.enabled });
},
},
{
id: 'monitor.copySelector',
title: 'Copy selector',
execute: async () => {
await writeToClipboard(monitor.selector, {
source: 'monitor.copy.selector',
label: 'Monitor selector',
});
},
},
{
id: 'monitor.delete',
title: 'Delete monitor',
tone: 'danger',
execute: async () => {
await client.delete({ id: monitor.id });
},
},
];
}
if (data.kind === 'alert') {
const alert = data.alert;
return [
{
id: 'monitor.alert.open',
title: 'Open page',
hotkeyHint: 'Enter',
execute: async (ctx) => {
await openUrl({ url: alert.url, disposition: ctx.openMode ?? 'current_tab' });
},
},
{
id: 'monitor.alert.markRead',
title: alert.read ? 'Mark as unread' : 'Mark as read',
execute: async () => {
await client.alertMarkRead({ id: alert.id, read: !alert.read });
},
},
{
id: 'monitor.alert.copyNew',
title: 'Copy new value',
execute: async () => {
await writeToClipboard(alert.newValue ?? '', {
source: 'monitor.alert.copy.new',
label: 'Monitor value',
});
},
},
{
id: 'monitor.alert.delete',
title: 'Delete alert',
tone: 'danger',
execute: async () => {
await client.alertDelete({ id: alert.id });
},
},
];
}
return [];
}
async function search(ctx: SearchProviderContext): Promise<SearchResult<MonitorResultData>[]> {
if (ctx.signal.aborted) return [];
if (ctx.requestedScope !== 'monitor') return [];
const raw = String(ctx.query.raw ?? '').trim();
const maxMonitors = Math.min(120, Math.max(20, ctx.limit * 6));
const maxAlerts = Math.min(120, Math.max(20, ctx.limit * 6));
const list = await client.list({
query: raw || undefined,
maxMonitors,
maxAlerts,
signal: ctx.signal,
});
if (ctx.signal.aborted) return [];
const results: SearchResult<MonitorResultData>[] = [];
results.push({
id: 'monitor.summary',
provider: id,
title: list.unreadCount > 0 ? `Monitor alerts: ${list.unreadCount} unread` : 'Monitor',
subtitle: `${list.monitors.length} monitors \u00B7 ${list.alerts.length} recent alerts`,
icon,
data: {
kind: 'summary',
unreadCount: list.unreadCount,
monitorCount: list.monitors.length,
alertCount: list.alerts.length,
},
score: 1000,
});
// Virtual create entry from `add <url> <selector>` or `<url> <selector>`.
const createRaw =
raw.toLowerCase().startsWith('add ') || raw.toLowerCase().startsWith('create ')
? raw.split(/\s+/).slice(1).join(' ').trim()
: raw;
if (createRaw) {
const first = createRaw.split(/\s+/)[0] || '';
const url = normalizeUrlInput(first);
const selector = createRaw.slice(first.length).trimStart();
if (url && selector) {
results.push({
id: `monitor.create.${url}`,
provider: id,
title: `Create monitor: ${formatHost(url)}`,
subtitle: selector,
icon: '\u2795', //
data: { kind: 'create', url, selector },
score: 990,
});
}
}
// Alerts first (unread boosted).
for (const alert of list.alerts) {
results.push({
id: `monitor.alert.${alert.id}`,
provider: id,
title: buildAlertTitle(alert),
subtitle: buildAlertSubtitle(alert),
icon: alert.read ? '\uD83D\uDCE9' : '\uD83D\uDCE8', // 📩/📨
data: { kind: 'alert', alert },
score: alert.read ? 700 : 900,
});
}
// Monitors.
for (const monitor of list.monitors) {
results.push({
id: `monitor.${monitor.id}`,
provider: id,
title: `${formatHost(monitor.url)} \u2192 ${monitor.selector}`,
subtitle: buildMonitorSubtitle(monitor),
icon: monitor.enabled ? '\uD83D\uDFE2' : '\u23F8\uFE0F', // 🟢 / ⏸️
data: { kind: 'monitor', monitor },
score: monitor.unreadAlerts > 0 ? 850 : 600,
});
}
if (results.length === 1 && !raw) {
results.push({
id: 'monitor.empty',
provider: id,
title: 'No monitors yet',
subtitle: 'Create one: `mon https://example.com .price`',
icon: '\u2139\uFE0F',
data: { kind: 'empty' },
score: 500,
});
}
if (raw) {
results.push({
id: 'monitor.help',
provider: id,
title: 'Create: `mon <url> <selector>` (example: `mon https://example.com .price`)',
subtitle: 'Actions: Tab to manage monitors and alerts',
icon: '\u2139\uFE0F',
data: { kind: 'help' },
score: 100,
});
}
return results.slice(0, Math.max(5, ctx.limit));
}
return {
id,
name,
icon,
scopes: ['monitor'],
includeInAll: false,
supportsEmptyQuery: true,
search,
getActions,
};
}

Some files were not shown because too many files have changed in this diff Show More