mirror of
https://github.com/hangwin/mcp-chrome.git
synced 2026-08-28 17:45:23 +08:00
feat: [WIP]quick panel
This commit is contained in:
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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',
|
||||
};
|
||||
|
||||
// ================================
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+2
-2
@@ -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';
|
||||
|
||||
+1
-1
@@ -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)
|
||||
*/
|
||||
|
||||
|
||||
+4
-4
@@ -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);
|
||||
});
|
||||
}
|
||||
+2
-2
@@ -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,
|
||||
+2
-2
@@ -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';
|
||||
+2
-2
@@ -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';
|
||||
-11
@@ -23,17 +23,6 @@ export {
|
||||
type ActionRegistryHooks,
|
||||
} from './registry';
|
||||
|
||||
// 适配器导出
|
||||
export {
|
||||
execCtxToActionCtx,
|
||||
stepToAction,
|
||||
actionResultToExecResult,
|
||||
createStepExecutor,
|
||||
isActionSupported,
|
||||
getActionType,
|
||||
type StepExecutionAttempt,
|
||||
} from './adapter';
|
||||
|
||||
// Handler 工厂导出
|
||||
export {
|
||||
createReplayActionRegistry,
|
||||
+42
-8
@@ -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)) {
|
||||
|
||||
+70
-4
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
+881
@@ -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
Reference in New Issue
Block a user