diff --git a/app/chrome-extension/common/message-types.ts b/app/chrome-extension/common/message-types.ts index 625347c..98fa2da 100644 --- a/app/chrome-extension/common/message-types.ts +++ b/app/chrome-extension/common/message-types.ts @@ -61,6 +61,8 @@ export const BACKGROUND_MESSAGE_TYPES = { // Element picker (human-in-the-loop element selection) ELEMENT_PICKER_UI_EVENT: 'element_picker_ui_event', ELEMENT_PICKER_FRAME_EVENT: 'element_picker_frame_event', + // Tool approval (human-in-the-loop confirmation for risky tool calls) + TOOL_APPROVAL_UI_EVENT: 'tool_approval_ui_event', // Web editor (in-page visual editing) WEB_EDITOR_TOGGLE: 'web_editor_toggle', WEB_EDITOR_APPLY: 'web_editor_apply', @@ -94,11 +96,60 @@ export const BACKGROUND_MESSAGE_TYPES = { QUICK_PANEL_TAB_SET_MUTED: 'quick_panel_tab_set_muted', // Quick Panel Search - Bookmarks bridge QUICK_PANEL_BOOKMARKS_QUERY: 'quick_panel_bookmarks_query', + QUICK_PANEL_BOOKMARK_REMOVE: 'quick_panel_bookmark_remove', // Quick Panel Search - History bridge QUICK_PANEL_HISTORY_QUERY: 'quick_panel_history_query', + QUICK_PANEL_HISTORY_DELETE: 'quick_panel_history_delete', + // Quick Panel Search - Content bridge + QUICK_PANEL_CONTENT_QUERY: 'quick_panel_content_query', // Quick Panel Navigation & Commands QUICK_PANEL_OPEN_URL: 'quick_panel_open_url', QUICK_PANEL_PAGE_COMMAND: 'quick_panel_page_command', + // Quick Panel Diagnostics - API Detective + QUICK_PANEL_API_DETECTIVE_START: 'quick_panel_api_detective_start', + QUICK_PANEL_API_DETECTIVE_STOP: 'quick_panel_api_detective_stop', + QUICK_PANEL_API_DETECTIVE_STATUS: 'quick_panel_api_detective_status', + QUICK_PANEL_API_DETECTIVE_LIST: 'quick_panel_api_detective_list', + QUICK_PANEL_API_DETECTIVE_GET_REQUEST: 'quick_panel_api_detective_get_request', + QUICK_PANEL_API_DETECTIVE_REPLAY_REQUEST: 'quick_panel_api_detective_replay_request', + // Quick Panel Clipboard History + QUICK_PANEL_CLIPBOARD_RECORD: 'quick_panel_clipboard_record', + QUICK_PANEL_CLIPBOARD_LIST: 'quick_panel_clipboard_list', + QUICK_PANEL_CLIPBOARD_GET: 'quick_panel_clipboard_get', + QUICK_PANEL_CLIPBOARD_SET_PINNED: 'quick_panel_clipboard_set_pinned', + QUICK_PANEL_CLIPBOARD_DELETE: 'quick_panel_clipboard_delete', + // Quick Panel Audit Log (Agent Mode) + QUICK_PANEL_AUDIT_LOG_LIST: 'quick_panel_audit_log_list', + QUICK_PANEL_AUDIT_LOG_CLEAR: 'quick_panel_audit_log_clear', + // Quick Panel Notes + QUICK_PANEL_NOTES_LIST: 'quick_panel_notes_list', + QUICK_PANEL_NOTES_GET: 'quick_panel_notes_get', + QUICK_PANEL_NOTES_CREATE: 'quick_panel_notes_create', + QUICK_PANEL_NOTES_DELETE: 'quick_panel_notes_delete', + // Quick Panel Focus Mode (Pomodoro / Focus) + QUICK_PANEL_FOCUS_STATUS: 'quick_panel_focus_status', + QUICK_PANEL_FOCUS_START: 'quick_panel_focus_start', + QUICK_PANEL_FOCUS_STOP: 'quick_panel_focus_stop', + QUICK_PANEL_FOCUS_PAUSE: 'quick_panel_focus_pause', + QUICK_PANEL_FOCUS_RESUME: 'quick_panel_focus_resume', + QUICK_PANEL_FOCUS_EXTEND: 'quick_panel_focus_extend', + QUICK_PANEL_FOCUS_SET_BLOCKLIST: 'quick_panel_focus_set_blocklist', + QUICK_PANEL_FOCUS_SET_BLOCKING_ENABLED: 'quick_panel_focus_set_blocking_enabled', + QUICK_PANEL_FOCUS_SNOOZE_BLOCKING: 'quick_panel_focus_snooze_blocking', + QUICK_PANEL_FOCUS_RESUME_BLOCKING: 'quick_panel_focus_resume_blocking', + // Quick Panel Web Monitor / Price Track (optional) + QUICK_PANEL_MONITOR_LIST: 'quick_panel_monitor_list', + QUICK_PANEL_MONITOR_CREATE: 'quick_panel_monitor_create', + QUICK_PANEL_MONITOR_DELETE: 'quick_panel_monitor_delete', + QUICK_PANEL_MONITOR_SET_ENABLED: 'quick_panel_monitor_set_enabled', + QUICK_PANEL_MONITOR_CHECK_NOW: 'quick_panel_monitor_check_now', + QUICK_PANEL_MONITOR_ALERT_MARK_READ: 'quick_panel_monitor_alert_mark_read', + QUICK_PANEL_MONITOR_ALERT_DELETE: 'quick_panel_monitor_alert_delete', + // Quick Panel Workspaces (session snapshots) + QUICK_PANEL_WORKSPACES_LIST: 'quick_panel_workspaces_list', + QUICK_PANEL_WORKSPACES_SAVE: 'quick_panel_workspaces_save', + QUICK_PANEL_WORKSPACES_OPEN: 'quick_panel_workspaces_open', + QUICK_PANEL_WORKSPACES_DELETE: 'quick_panel_workspaces_delete', // Quick Panel Usage History (Frecency) - IndexedDB bridge QUICK_PANEL_USAGE_RECORD: 'quick_panel_usage_record', QUICK_PANEL_USAGE_GET_ENTRIES: 'quick_panel_usage_get_entries', @@ -111,6 +162,8 @@ export const OFFSCREEN_MESSAGE_TYPES = { SIMILARITY_ENGINE_COMPUTE: 'similarityEngineCompute', SIMILARITY_ENGINE_BATCH_COMPUTE: 'similarityEngineBatchCompute', SIMILARITY_ENGINE_STATUS: 'similarityEngineStatus', + // Web Monitor / Price Track (fetch + extract via DOMParser in offscreen document) + WEB_MONITOR_FETCH_EXTRACT: 'webMonitorFetchExtract', // GIF encoding GIF_ADD_FRAME: 'gifAddFrame', GIF_FINISH: 'gifFinish', @@ -191,6 +244,9 @@ export const TOOL_MESSAGE_TYPES = { ELEMENT_PICKER_UI_SHOW: 'elementPickerUiShow', ELEMENT_PICKER_UI_UPDATE: 'elementPickerUiUpdate', ELEMENT_PICKER_UI_HIDE: 'elementPickerUiHide', + // Tool approval prompt (background -> content scripts) + TOOL_APPROVAL_SHOW: 'toolApprovalShow', + TOOL_APPROVAL_HIDE: 'toolApprovalHide', } as const; // Type unions for type safety @@ -512,6 +568,28 @@ export interface QuickPanelBookmarksQueryMessage { payload: QuickPanelBookmarksQueryPayload; } +/** + * Payload for removing a bookmark. + */ +export interface QuickPanelBookmarkRemovePayload { + bookmarkId: string; +} + +/** + * Response from QUICK_PANEL_BOOKMARK_REMOVE message handler. + */ +export type QuickPanelBookmarkRemoveResponse = + | { success: true } + | { success: false; error: string }; + +/** + * Message structure for removing a bookmark. + */ +export interface QuickPanelBookmarkRemoveMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_BOOKMARK_REMOVE; + payload: QuickPanelBookmarkRemovePayload; +} + // ============================================================ // Quick Panel Search - History Bridge Contracts // ============================================================ @@ -551,6 +629,66 @@ export interface QuickPanelHistoryQueryMessage { payload: QuickPanelHistoryQueryPayload; } +/** + * Payload for deleting a history entry (by URL). + */ +export interface QuickPanelHistoryDeletePayload { + url: string; +} + +/** + * Response from QUICK_PANEL_HISTORY_DELETE message handler. + */ +export type QuickPanelHistoryDeleteResponse = { success: true } | { success: false; error: string }; + +/** + * Message structure for deleting a history entry. + */ +export interface QuickPanelHistoryDeleteMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_HISTORY_DELETE; + payload: QuickPanelHistoryDeletePayload; +} + +// ============================================================ +// Quick Panel Search - Content Bridge Contracts +// ============================================================ + +/** + * Payload for querying cached tab content. + */ +export interface QuickPanelContentQueryPayload { + query: string; + maxResults?: number; +} + +/** + * Summary of a single content match returned from the background. + */ +export interface QuickPanelContentMatchSummary { + tabId: number; + windowId: number; + url: string; + title: string; + favIconUrl?: string; + snippet: string; + score: number; +} + +/** + * Response from QUICK_PANEL_CONTENT_QUERY message handler. + */ +export type QuickPanelContentQueryResponse = + | { success: true; items: QuickPanelContentMatchSummary[] } + | { success: false; error: string }; + +/** + * Message structure for querying cached tab content. + */ +export interface QuickPanelContentQueryMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CONTENT_QUERY; + payload: QuickPanelContentQueryPayload; +} + // ============================================================ // Quick Panel Navigation Contracts // ============================================================ @@ -593,12 +731,35 @@ export type QuickPanelPageCommand = | 'back' | 'forward' | 'stop' + | 'screenshot' + | 'dev_console_snapshot_export' + | 'dev_console_errors_export' + | 'dev_read_page_export' + | 'dev_network_capture_10s_export' + | 'dev_performance_trace_5s_export' + | 'dev_debug_bundle_create' + | 'dev_debug_bundle_cancel' | 'close_tab' | 'duplicate_tab' | 'toggle_pin' | 'toggle_mute' + | 'close_other_tabs' + | 'close_tabs_to_right' + | 'discard_inactive_tabs' + | 'merge_all_windows' + | 'skin_vscode' + | 'skin_terminal' + | 'skin_retro' + | 'skin_paper' + | 'skin_off' + | 'zen_mode_toggle' + | 'force_dark_toggle' + | 'allow_copy_toggle' + | 'privacy_curtain_toggle' + | 'reader_mode_toggle' | 'new_tab' - | 'new_window'; + | 'new_window' + | 'new_incognito_window'; /** * Payload for executing a page command. @@ -607,10 +768,21 @@ export interface QuickPanelPageCommandPayload { command: QuickPanelPageCommand; } +export interface QuickPanelPageCommandInfo { + message?: string; + download?: { + downloadId?: number; + filename?: string; + fullPath?: string; + }; +} + /** * Response from QUICK_PANEL_PAGE_COMMAND message handler. */ -export type QuickPanelPageCommandResponse = { success: true } | { success: false; error: string }; +export type QuickPanelPageCommandResponse = + | { success: true; info?: QuickPanelPageCommandInfo } + | { success: false; error: string }; /** * Message structure for executing a page command. @@ -620,6 +792,778 @@ export interface QuickPanelPageCommandMessage { payload: QuickPanelPageCommandPayload; } +// ============================================================ +// Quick Panel API Detective (Diagnostics) Contracts +// ============================================================ + +export type QuickPanelApiDetectiveBackend = 'webRequest' | 'debugger'; +// eslint-disable-next-line +export interface QuickPanelApiDetectiveStatusPayload {} + +export type QuickPanelApiDetectiveStatusResponse = + | { + success: true; + active: boolean; + backend: QuickPanelApiDetectiveBackend | null; + startedAt: number | null; + lastCaptureAt: number | null; + lastRequestCount: number; + } + | { success: false; error: string }; + +export interface QuickPanelApiDetectiveStatusMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_STATUS; + payload: QuickPanelApiDetectiveStatusPayload; +} + +export interface QuickPanelApiDetectiveStartPayload { + /** + * When true, uses the debugger backend and captures response bodies (high risk). + * Default: false (webRequest backend, no response body). + */ + needResponseBody?: boolean; + /** Include static resources such as images/scripts/styles. Default: false. */ + includeStatic?: boolean; + /** Max capture time in milliseconds. Default: 180000 (3 minutes). */ + maxCaptureTimeMs?: number; +} + +export type QuickPanelApiDetectiveStartResponse = + | { + success: true; + active: true; + backend: QuickPanelApiDetectiveBackend; + startedAt: number; + } + | { success: false; error: string }; + +export interface QuickPanelApiDetectiveStartMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_START; + payload: QuickPanelApiDetectiveStartPayload; +} + +// eslint-disable-next-line +export interface QuickPanelApiDetectiveStopPayload {} + +export type QuickPanelApiDetectiveStopResponse = + | { + success: true; + active: false; + backend: QuickPanelApiDetectiveBackend; + capturedAt: number; + requestCount: number; + } + | { success: false; error: string }; + +export interface QuickPanelApiDetectiveStopMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_STOP; + payload: QuickPanelApiDetectiveStopPayload; +} + +export interface QuickPanelApiDetectiveRequestSummary { + requestId: string; + method: string; + url: string; + type?: string; + status?: number; + mimeType?: string; + requestBodyPreview?: string; +} + +export interface QuickPanelApiDetectiveListPayload { + /** Optional query for server-side filtering (best-effort). */ + query?: string; + /** Max results requested (best-effort). */ + maxResults?: number; +} + +export type QuickPanelApiDetectiveListResponse = + | { + success: true; + active: boolean; + backend: QuickPanelApiDetectiveBackend | null; + capturedAt: number | null; + tabUrl: string | null; + items: QuickPanelApiDetectiveRequestSummary[]; + } + | { success: false; error: string }; + +export interface QuickPanelApiDetectiveListMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_LIST; + payload: QuickPanelApiDetectiveListPayload; +} + +export interface QuickPanelApiDetectiveRequestDetail { + requestId: string; + method: string; + url: string; + type?: string; + status?: number; + mimeType?: string; + requestHeaders: Record; + requestBody?: string; +} + +export interface QuickPanelApiDetectiveGetRequestPayload { + requestId: string; +} + +export type QuickPanelApiDetectiveGetRequestResponse = + | { success: true; request: QuickPanelApiDetectiveRequestDetail } + | { success: false; error: string }; + +export interface QuickPanelApiDetectiveGetRequestMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_GET_REQUEST; + payload: QuickPanelApiDetectiveGetRequestPayload; +} + +export interface QuickPanelApiDetectiveReplayRequestPayload { + requestId: string; + timeoutMs?: number; +} + +export type QuickPanelApiDetectiveReplayRequestResponse = + | { success: true; result: unknown } + | { success: false; error: string }; + +export interface QuickPanelApiDetectiveReplayRequestMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_REPLAY_REQUEST; + payload: QuickPanelApiDetectiveReplayRequestPayload; +} + +// ============================================================ +// Quick Panel Clipboard History Contracts +// ============================================================ + +export interface QuickPanelClipboardItemSummary { + id: string; + preview: string; + pinned: boolean; + createdAt: number; + updatedAt: number; + incognito: boolean; + /** Best-effort source tag (e.g. "commands.copy.url", "toolbox.jwt.payload"). */ + source?: string; + /** Best-effort label (e.g. page title or output title). */ + label?: string; + /** Best-effort origin URL (tab where the copy happened). */ + originUrl?: string; + /** Best-effort origin title (tab where the copy happened). */ + originTitle?: string; + /** Original UTF-8 byte length (may exceed stored length). */ + byteLength: number; + /** Whether the full value is stored and retrievable. */ + stored: boolean; + /** Copy count for dedupe + ranking. */ + copyCount: number; +} + +export interface QuickPanelClipboardItemDetail extends QuickPanelClipboardItemSummary { + /** Full stored value. Null when not stored (e.g. too large). */ + value: string | null; +} + +export interface QuickPanelClipboardRecordPayload { + text: string; + source?: string; + label?: string; + originUrl?: string; + originTitle?: string; +} + +export type QuickPanelClipboardRecordResponse = + | { success: true } + | { success: false; error: string }; + +export interface QuickPanelClipboardRecordMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CLIPBOARD_RECORD; + payload: QuickPanelClipboardRecordPayload; +} + +export interface QuickPanelClipboardListPayload { + query?: string; + maxResults?: number; +} + +export type QuickPanelClipboardListResponse = + | { success: true; items: QuickPanelClipboardItemSummary[] } + | { success: false; error: string }; + +export interface QuickPanelClipboardListMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CLIPBOARD_LIST; + payload: QuickPanelClipboardListPayload; +} + +export interface QuickPanelClipboardGetPayload { + id: string; +} + +export type QuickPanelClipboardGetResponse = + | { success: true; item: QuickPanelClipboardItemDetail } + | { success: false; error: string }; + +export interface QuickPanelClipboardGetMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CLIPBOARD_GET; + payload: QuickPanelClipboardGetPayload; +} + +export interface QuickPanelClipboardSetPinnedPayload { + id: string; + pinned: boolean; +} + +export type QuickPanelClipboardSetPinnedResponse = + | { success: true } + | { success: false; error: string }; + +export interface QuickPanelClipboardSetPinnedMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CLIPBOARD_SET_PINNED; + payload: QuickPanelClipboardSetPinnedPayload; +} + +export interface QuickPanelClipboardDeletePayload { + id: string; +} + +export type QuickPanelClipboardDeleteResponse = + | { success: true } + | { success: false; error: string }; + +export interface QuickPanelClipboardDeleteMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CLIPBOARD_DELETE; + payload: QuickPanelClipboardDeletePayload; +} + +// ============================================================ +// Quick Panel Notes Contracts +// ============================================================ + +export interface QuickPanelNoteSummary { + id: string; + title: string; + preview: string; + createdAt: number; + updatedAt: number; + incognito: boolean; +} + +export interface QuickPanelNoteDetail extends QuickPanelNoteSummary { + content: string; +} + +export interface QuickPanelNotesListPayload { + query?: string; + maxResults?: number; +} + +export type QuickPanelNotesListResponse = + | { success: true; items: QuickPanelNoteSummary[] } + | { success: false; error: string }; + +export interface QuickPanelNotesListMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_NOTES_LIST; + payload: QuickPanelNotesListPayload; +} + +export interface QuickPanelNotesGetPayload { + id: string; +} + +export type QuickPanelNotesGetResponse = + | { success: true; note: QuickPanelNoteDetail } + | { success: false; error: string }; + +export interface QuickPanelNotesGetMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_NOTES_GET; + payload: QuickPanelNotesGetPayload; +} + +export interface QuickPanelNotesCreatePayload { + title?: string; + content: string; +} + +export type QuickPanelNotesCreateResponse = + | { success: true; note: QuickPanelNoteSummary } + | { success: false; error: string }; + +export interface QuickPanelNotesCreateMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_NOTES_CREATE; + payload: QuickPanelNotesCreatePayload; +} + +export interface QuickPanelNotesDeletePayload { + id: string; +} + +export type QuickPanelNotesDeleteResponse = { success: true } | { success: false; error: string }; + +export interface QuickPanelNotesDeleteMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_NOTES_DELETE; + payload: QuickPanelNotesDeletePayload; +} + +// ============================================================ +// Quick Panel Focus Mode Contracts +// ============================================================ + +export type QuickPanelFocusPhase = 'idle' | 'running' | 'paused'; + +export interface QuickPanelFocusSession { + phase: QuickPanelFocusPhase; + /** When the focus session was started. */ + startedAt: number; + /** When the focus session will end (only meaningful when running). */ + endsAt: number; + /** + * Remaining milliseconds. + * - When running: best-effort computed at response time. + * - When paused: persisted remaining time. + */ + remainingMs: number; + /** Total planned duration (initial duration + extensions). */ + durationMs: number; + /** Updated timestamp for UI ordering/debug. */ + updatedAt: number; +} + +export interface QuickPanelFocusStatus { + incognito: boolean; + now: number; + session: QuickPanelFocusSession; + blockingEnabled: boolean; + blockingActive: boolean; + /** + * Temporary blocking override (timestamp in ms). + * When `now < blockingSnoozedUntil`, blocking rules are suspended even if enabled. + */ + blockingSnoozedUntil: number; + blocklist: string[]; +} + +export type QuickPanelFocusStatusResponse = + | { success: true; status: QuickPanelFocusStatus } + | { success: false; error: string }; + +export interface QuickPanelFocusStatusMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_STATUS; + payload?: Record; +} + +export interface QuickPanelFocusStartPayload { + /** Focus duration in minutes. */ + durationMinutes: number; +} + +export type QuickPanelFocusStartResponse = + | { success: true; status: QuickPanelFocusStatus } + | { success: false; error: string }; + +export interface QuickPanelFocusStartMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_START; + payload: QuickPanelFocusStartPayload; +} + +export type QuickPanelFocusStopResponse = + | { success: true; status: QuickPanelFocusStatus } + | { success: false; error: string }; + +export interface QuickPanelFocusStopMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_STOP; + payload?: Record; +} + +export type QuickPanelFocusPauseResponse = + | { success: true; status: QuickPanelFocusStatus } + | { success: false; error: string }; + +export interface QuickPanelFocusPauseMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_PAUSE; + payload?: Record; +} + +export type QuickPanelFocusResumeResponse = + | { success: true; status: QuickPanelFocusStatus } + | { success: false; error: string }; + +export interface QuickPanelFocusResumeMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_RESUME; + payload?: Record; +} + +export interface QuickPanelFocusExtendPayload { + /** Extend duration in minutes. */ + minutes: number; +} + +export type QuickPanelFocusExtendResponse = + | { success: true; status: QuickPanelFocusStatus } + | { success: false; error: string }; + +export interface QuickPanelFocusExtendMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_EXTEND; + payload: QuickPanelFocusExtendPayload; +} + +export interface QuickPanelFocusSetBlocklistPayload { + /** Hostname list (e.g. "youtube.com"). */ + domains: string[]; +} + +export type QuickPanelFocusSetBlocklistResponse = + | { success: true; status: QuickPanelFocusStatus } + | { success: false; error: string }; + +export interface QuickPanelFocusSetBlocklistMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_SET_BLOCKLIST; + payload: QuickPanelFocusSetBlocklistPayload; +} + +export interface QuickPanelFocusSetBlockingEnabledPayload { + enabled: boolean; +} + +export type QuickPanelFocusSetBlockingEnabledResponse = + | { success: true; status: QuickPanelFocusStatus } + | { success: false; error: string }; + +export interface QuickPanelFocusSetBlockingEnabledMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_SET_BLOCKING_ENABLED; + payload: QuickPanelFocusSetBlockingEnabledPayload; +} + +export interface QuickPanelFocusSnoozeBlockingPayload { + /** Snooze duration in minutes. Extends if already snoozed. */ + minutes: number; +} + +export type QuickPanelFocusSnoozeBlockingResponse = + | { success: true; status: QuickPanelFocusStatus } + | { success: false; error: string }; + +export interface QuickPanelFocusSnoozeBlockingMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_SNOOZE_BLOCKING; + payload: QuickPanelFocusSnoozeBlockingPayload; +} + +export type QuickPanelFocusResumeBlockingResponse = + | { success: true; status: QuickPanelFocusStatus } + | { success: false; error: string }; + +export interface QuickPanelFocusResumeBlockingMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_RESUME_BLOCKING; + payload?: Record; +} + +// ============================================================ +// Quick Panel Web Monitor / Price Track Contracts +// ============================================================ + +export type QuickPanelMonitorExtractorKind = 'selector_text' | 'selector_attr'; + +export interface QuickPanelMonitorSummary { + id: string; + url: string; + extractor: QuickPanelMonitorExtractorKind; + selector: string; + attribute?: string; + intervalMinutes: number; + enabled: boolean; + incognito: boolean; + createdAt: number; + updatedAt: number; + lastCheckedAt: number; + lastChangedAt: number; + lastValuePreview?: string; + lastError?: string; + unreadAlerts: number; +} + +// ============================================================ +// Quick Panel Audit Log Contracts (Phase 14 - Agent Mode) +// ============================================================ + +export type QuickPanelAuditLogRiskLevel = 'low' | 'medium' | 'high'; + +export type QuickPanelAuditLogStatus = 'success' | 'error' | 'denied'; + +export interface QuickPanelAuditLogEntry { + id: string; + toolName: string; + toolDescription?: string; + riskLevel: QuickPanelAuditLogRiskLevel; + riskCategories: string[]; + source: 'native_host' | 'extension_ui' | 'internal'; + incognito: boolean; + status: QuickPanelAuditLogStatus; + startedAt: number; + finishedAt: number; + durationMs: number; + argsSummary: string; + resultSummary: string; +} + +export interface QuickPanelAuditLogListPayload { + query?: string; + maxResults?: number; +} + +export type QuickPanelAuditLogListResponse = + | { success: true; entries: QuickPanelAuditLogEntry[] } + | { success: false; error: string }; + +export interface QuickPanelAuditLogListMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_AUDIT_LOG_LIST; + payload: QuickPanelAuditLogListPayload; +} + +export type QuickPanelAuditLogClearResponse = { success: true } | { success: false; error: string }; + +export interface QuickPanelAuditLogClearMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_AUDIT_LOG_CLEAR; + payload?: Record; +} + +export interface QuickPanelMonitorAlert { + id: string; + monitorId: string; + incognito: boolean; + createdAt: number; + url: string; + selector: string; + oldValue: string | null; + newValue: string | null; + read: boolean; +} + +export interface QuickPanelMonitorListPayload { + /** Optional search query (best-effort). */ + query?: string; + /** Max monitors requested (best-effort). */ + maxMonitors?: number; + /** Max alerts requested (best-effort). */ + maxAlerts?: number; +} + +export type QuickPanelMonitorListResponse = + | { + success: true; + monitors: QuickPanelMonitorSummary[]; + alerts: QuickPanelMonitorAlert[]; + unreadCount: number; + } + | { success: false; error: string }; + +export interface QuickPanelMonitorListMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_LIST; + payload: QuickPanelMonitorListPayload; +} + +export interface QuickPanelMonitorCreatePayload { + url: string; + selector: string; + extractor?: QuickPanelMonitorExtractorKind; + attribute?: string; + intervalMinutes?: number; + /** If true, run an immediate baseline fetch. Default: true. */ + fetchNow?: boolean; +} + +export type QuickPanelMonitorCreateResponse = + | { success: true; monitor: QuickPanelMonitorSummary } + | { success: false; error: string }; + +export interface QuickPanelMonitorCreateMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_CREATE; + payload: QuickPanelMonitorCreatePayload; +} + +export interface QuickPanelMonitorDeletePayload { + id: string; +} + +export type QuickPanelMonitorDeleteResponse = { success: true } | { success: false; error: string }; + +export interface QuickPanelMonitorDeleteMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_DELETE; + payload: QuickPanelMonitorDeletePayload; +} + +export interface QuickPanelMonitorSetEnabledPayload { + id: string; + enabled: boolean; +} + +export type QuickPanelMonitorSetEnabledResponse = + | { success: true; monitor: QuickPanelMonitorSummary } + | { success: false; error: string }; + +export interface QuickPanelMonitorSetEnabledMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_SET_ENABLED; + payload: QuickPanelMonitorSetEnabledPayload; +} + +export interface QuickPanelMonitorCheckNowPayload { + id: string; +} + +export type QuickPanelMonitorCheckNowResponse = + | { + success: true; + monitor: QuickPanelMonitorSummary; + alertCreated?: QuickPanelMonitorAlert; + } + | { success: false; error: string }; + +export interface QuickPanelMonitorCheckNowMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_CHECK_NOW; + payload: QuickPanelMonitorCheckNowPayload; +} + +export interface QuickPanelMonitorAlertMarkReadPayload { + id: string; + read: boolean; +} + +export type QuickPanelMonitorAlertMarkReadResponse = + | { success: true; unreadCount: number } + | { success: false; error: string }; + +export interface QuickPanelMonitorAlertMarkReadMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_ALERT_MARK_READ; + payload: QuickPanelMonitorAlertMarkReadPayload; +} + +export interface QuickPanelMonitorAlertDeletePayload { + id: string; +} + +export type QuickPanelMonitorAlertDeleteResponse = + | { success: true; unreadCount: number } + | { success: false; error: string }; + +export interface QuickPanelMonitorAlertDeleteMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_ALERT_DELETE; + payload: QuickPanelMonitorAlertDeletePayload; +} + +// ============================================================ +// Quick Panel Workspaces (Session Snapshots) Contracts +// ============================================================ + +/** + * Minimal workspace snapshot summary for list/search UI. + */ +export interface QuickPanelWorkspaceSummary { + id: string; + name: string; + tabCount: number; + createdAt: number; + updatedAt: number; + incognito: boolean; +} + +/** + * Payload for listing workspace snapshots. + */ +export interface QuickPanelWorkspacesListPayload { + /** + * Optional filter query. + * Background may ignore and return recent list; UI can apply additional scoring. + */ + query?: string; + /** Max results requested (best-effort). */ + maxResults?: number; +} + +/** + * Response from QUICK_PANEL_WORKSPACES_LIST message handler. + */ +export type QuickPanelWorkspacesListResponse = + | { success: true; items: QuickPanelWorkspaceSummary[] } + | { success: false; error: string }; + +/** + * Message structure for listing workspaces. + */ +export interface QuickPanelWorkspacesListMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_WORKSPACES_LIST; + payload: QuickPanelWorkspacesListPayload; +} + +/** + * Payload for saving a snapshot of the current window session. + */ +export interface QuickPanelWorkspacesSavePayload { + /** Optional user-provided name. Background will generate a default when empty. */ + name?: string; +} + +/** + * Response from QUICK_PANEL_WORKSPACES_SAVE message handler. + */ +export type QuickPanelWorkspacesSaveResponse = + | { success: true; workspace: QuickPanelWorkspaceSummary } + | { success: false; error: string }; + +/** + * Message structure for saving a workspace snapshot. + */ +export interface QuickPanelWorkspacesSaveMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_WORKSPACES_SAVE; + payload: QuickPanelWorkspacesSavePayload; +} + +/** + * Where to open a workspace snapshot. + */ +export type QuickPanelWorkspacesOpenTarget = 'current_window' | 'new_window'; + +/** + * Payload for opening a workspace snapshot. + */ +export interface QuickPanelWorkspacesOpenPayload { + workspaceId: string; + target: QuickPanelWorkspacesOpenTarget; +} + +/** + * Response from QUICK_PANEL_WORKSPACES_OPEN message handler. + */ +export type QuickPanelWorkspacesOpenResponse = + | { success: true; openedCount: number; totalCount: number; windowId?: number } + | { success: false; error: string }; + +/** + * Message structure for opening a workspace snapshot. + */ +export interface QuickPanelWorkspacesOpenMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_WORKSPACES_OPEN; + payload: QuickPanelWorkspacesOpenPayload; +} + +/** + * Payload for deleting a workspace snapshot. + */ +export interface QuickPanelWorkspacesDeletePayload { + workspaceId: string; +} + +/** + * Response from QUICK_PANEL_WORKSPACES_DELETE message handler. + */ +export type QuickPanelWorkspacesDeleteResponse = + | { success: true } + | { success: false; error: string }; + +/** + * Message structure for deleting a workspace snapshot. + */ +export interface QuickPanelWorkspacesDeleteMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_WORKSPACES_DELETE; + payload: QuickPanelWorkspacesDeletePayload; +} + // ============================================================ // Quick Panel Usage History (Frecency) Contracts // ============================================================ diff --git a/app/chrome-extension/entrypoints/background/index.ts b/app/chrome-extension/entrypoints/background/index.ts index 530cf5f..20df86a 100644 --- a/app/chrome-extension/entrypoints/background/index.ts +++ b/app/chrome-extension/entrypoints/background/index.ts @@ -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(); diff --git a/app/chrome-extension/entrypoints/background/native-host.ts b/app/chrome-extension/entrypoints/background/native-host.ts index 18fcf1c..1c7d94c 100644 --- a/app/chrome-extension/entrypoints/background/native-host.ts +++ b/app/chrome-extension/entrypoints/background/native-host.ts @@ -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) }), diff --git a/app/chrome-extension/entrypoints/background/quick-panel/api-detective-handler.ts b/app/chrome-extension/entrypoints/background/quick-panel/api-detective-handler.ts new file mode 100644 index 0000000..e704d58 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/quick-panel/api-detective-handler.ts @@ -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; +} + +// ============================================================ +// State (best-effort, in-memory) +// ============================================================ + +const sessionsByTabId = new Map(); +const lastCaptureByTabId = new Map(); + +// ============================================================ +// 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 { + if (!value || typeof value !== 'object') return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + 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; + }>; + } + | { 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 { + 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 { + 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 { + 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(); + + 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 { + 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 { + 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 { + 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`); +} diff --git a/app/chrome-extension/entrypoints/background/quick-panel/audit-handler.ts b/app/chrome-extension/entrypoints/background/quick-panel/audit-handler.ts new file mode 100644 index 0000000..b1943f8 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/quick-panel/audit-handler.ts @@ -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 { + 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 { + 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; + }); +} diff --git a/app/chrome-extension/entrypoints/background/quick-panel/bookmarks-handler.ts b/app/chrome-extension/entrypoints/background/quick-panel/bookmarks-handler.ts index 4a1469a..5aaf598 100644 --- a/app/chrome-extension/entrypoints/background/quick-panel/bookmarks-handler.ts +++ b/app/chrome-extension/entrypoints/background/quick-panel/bookmarks-handler.ts @@ -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 { + 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; }); diff --git a/app/chrome-extension/entrypoints/background/quick-panel/clipboard-handler.ts b/app/chrome-extension/entrypoints/background/quick-panel/clipboard-handler.ts new file mode 100644 index 0000000..7477a3f --- /dev/null +++ b/app/chrome-extension/entrypoints/background/quick-panel/clipboard-handler.ts @@ -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 { + 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 { + 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(); + 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 { + try { + const res = await chrome.storage.local.get([STORAGE_KEY]); + const raw = (res as Record | undefined)?.[STORAGE_KEY]; + return parseStore(raw); + } catch { + return { version: SCHEMA_VERSION, updatedAt: 0, items: [] }; + } +} + +async function writeStore(store: ClipboardStoreV1): Promise { + await chrome.storage.local.set({ [STORAGE_KEY]: store }); +} + +let storeMutex: Promise = Promise.resolve(); + +async function withStoreLock(fn: () => Promise): Promise { + const prev = storeMutex; + let release: (() => void) | null = null; + storeMutex = new Promise((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 { + 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 { + 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 { + 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 { + 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 { + 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`); +} diff --git a/app/chrome-extension/entrypoints/background/quick-panel/content-handler.ts b/app/chrome-extension/entrypoints/background/quick-panel/content-handler.ts new file mode 100644 index 0000000..d920154 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/quick-panel/content-handler.ts @@ -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 { + 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(); + +let sessionLoaded = false; +let sessionLoadPromise: Promise | null = null; +let sessionPersistDisabled = false; +let persistTimer: ReturnType | null = null; +let persistInFlight: Promise | null = null; +let persistDirty = false; + +async function ensureSessionLoaded(): Promise { + 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 { + if (sessionPersistDisabled) return; + if (!chrome.storage?.session) return; + + if (persistInFlight) { + await persistInFlight.catch(() => {}); + } + + if (!persistDirty) return; + persistDirty = false; + + const snapshot: Record = {}; + 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>(); +const scheduledTimersByTabId = new Map>(); +const pendingReindexByTabId = new Set(); + +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): Promise { + 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 { + // 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 { + 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 { + 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 { + 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`); +} diff --git a/app/chrome-extension/entrypoints/background/quick-panel/debug-bundle.ts b/app/chrome-extension/entrypoints/background/quick-panel/debug-bundle.ts new file mode 100644 index 0000000..8e79df1 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/quick-panel/debug-bundle.ts @@ -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_/` + * - 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(); + +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 { + 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 + } + } +} diff --git a/app/chrome-extension/entrypoints/background/quick-panel/devtools-export.ts b/app/chrome-extension/entrypoints/background/quick-panel/devtools-export.ts new file mode 100644 index 0000000..01c2c95 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/quick-panel/devtools-export.ts @@ -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 { + 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 { + 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 { + 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 }; + } +} diff --git a/app/chrome-extension/entrypoints/background/quick-panel/focus-handler.ts b/app/chrome-extension/entrypoints/background/quick-panel/focus-handler.ts new file mode 100644 index 0000000..7916edc --- /dev/null +++ b/app/chrome-extension/entrypoints/background/quick-panel/focus-handler.ts @@ -0,0 +1,1263 @@ +/** + * Quick Panel Focus Mode Handler + * + * Background service worker bridge for Pomodoro / Focus Mode (Phase 15.3). + * + * Features: + * - Focus session state (running/paused/idle) persisted in chrome.storage.local + * - Timer scheduling via chrome.alarms (MV3-friendly) + * - Optional site blocking via chrome.declarativeNetRequest session rules + * + * Privacy / Isolation: + * - Enforces incognito boundary: focus state is separated for regular vs incognito senders. + * - Site blocking uses session-scoped rules. (tabIds/excludedTabIds are only supported for session rules.) + */ + +import { + BACKGROUND_MESSAGE_TYPES, + type QuickPanelFocusExtendMessage, + type QuickPanelFocusExtendResponse, + type QuickPanelFocusPauseMessage, + type QuickPanelFocusPauseResponse, + type QuickPanelFocusResumeMessage, + type QuickPanelFocusResumeResponse, + type QuickPanelFocusResumeBlockingMessage, + type QuickPanelFocusResumeBlockingResponse, + type QuickPanelFocusSnoozeBlockingMessage, + type QuickPanelFocusSnoozeBlockingResponse, + type QuickPanelFocusSetBlocklistMessage, + type QuickPanelFocusSetBlocklistResponse, + type QuickPanelFocusSetBlockingEnabledMessage, + type QuickPanelFocusSetBlockingEnabledResponse, + type QuickPanelFocusSession, + type QuickPanelFocusStartMessage, + type QuickPanelFocusStartResponse, + type QuickPanelFocusStatus, + type QuickPanelFocusStatusMessage, + type QuickPanelFocusStatusResponse, + type QuickPanelFocusStopMessage, + type QuickPanelFocusStopResponse, +} from '@/common/message-types'; + +const LOG_PREFIX = '[QuickPanelFocus]'; + +// ============================================================ +// Storage Schema +// ============================================================ + +const STORAGE_KEY = 'quick_panel_focus_v1'; +const SCHEMA_VERSION = 1 as const; + +const MIN_DURATION_MINUTES = 1; +const MAX_DURATION_MINUTES = 12 * 60; // 12h guardrail + +const MAX_BLOCKLIST_DOMAINS = 200; +const MAX_DOMAIN_LEN = 200; + +type FocusPhase = QuickPanelFocusSession['phase']; + +interface FocusSessionV1 { + phase: FocusPhase; + startedAt: number; + endsAt: number; + remainingMs: number; + durationMs: number; + updatedAt: number; +} + +interface FocusContextV1 { + session: FocusSessionV1; + blockingEnabled: boolean; + blockingSnoozedUntil: number; + blocklist: string[]; +} + +interface FocusStoreV1 { + version: typeof SCHEMA_VERSION; + updatedAt: number; + normal: FocusContextV1; + incognito: FocusContextV1; +} + +function createEmptySession(now: number): FocusSessionV1 { + return { + phase: 'idle', + startedAt: 0, + endsAt: 0, + remainingMs: 0, + durationMs: 0, + updatedAt: now, + }; +} + +function createEmptyContext(now: number): FocusContextV1 { + return { + session: createEmptySession(now), + blockingEnabled: false, + blockingSnoozedUntil: 0, + blocklist: [], + }; +} + +function createEmptyStore(now: number): FocusStoreV1 { + return { + version: SCHEMA_VERSION, + updatedAt: now, + normal: createEmptyContext(now), + incognito: createEmptyContext(now), + }; +} + +// ============================================================ +// DNR (site blocking) - session rules +// ============================================================ + +type FocusContextKey = 'normal' | 'incognito'; + +const SESSION_RULE_ID_BASE: Record = { + normal: 915_000, + incognito: 916_000, +}; +const SESSION_RULE_ID_CAP = 800; // reserve < 1000 ids per context + +function isRecord(value: unknown): value is Record { + 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 asContextKey(incognito: boolean): FocusContextKey { + return incognito ? 'incognito' : 'normal'; +} + +function durationMinutesToMs(minutes: number): number { + const clamped = clampInt(minutes, 25, MIN_DURATION_MINUTES, MAX_DURATION_MINUTES); + return clamped * 60_000; +} + +function normalizeDomainInput(input: string): string | null { + const raw = String(input ?? '') + .trim() + .toLowerCase(); + if (!raw) return null; + + // Accept either a hostname ("example.com") or a full URL ("https://example.com/path"). + let host = raw; + try { + if (raw.includes('://')) { + host = new URL(raw).hostname.toLowerCase(); + } + } catch { + // Fall back to raw string + } + + host = host.replace(/^\.+/, '').trim(); + if (!host) return null; + if (host.length > MAX_DOMAIN_LEN) return null; + + // Basic hostname safety: allow ascii letters/digits/dot/hyphen only. + if (!/^[a-z0-9.-]+$/.test(host)) return null; + if (!host.includes('.')) return null; + if (host.startsWith('-') || host.endsWith('-')) return null; + if (host.includes('..')) return null; + + return host; +} + +function normalizeBlocklist(domains: unknown): string[] { + const list = Array.isArray(domains) ? domains : []; + const out: string[] = []; + const seen = new Set(); + + for (const v of list) { + const d = normalizeDomainInput(normalizeString(v)); + if (!d) continue; + if (seen.has(d)) continue; + seen.add(d); + out.push(d); + if (out.length >= MAX_BLOCKLIST_DOMAINS) break; + } + + return out; +} + +async function listIncognitoTabIds(): Promise { + try { + const tabs = await chrome.tabs.query({}); + const ids = tabs + .filter((t) => t?.incognito === true && typeof t.id === 'number' && Number.isFinite(t.id)) + .map((t) => t.id as number); + // Deduplicate and keep stable order. + return [...new Set(ids)]; + } catch { + return []; + } +} + +function ruleIdRangeForContext(ctx: FocusContextKey): { min: number; max: number } { + const base = SESSION_RULE_ID_BASE[ctx]; + return { min: base, max: base + SESSION_RULE_ID_CAP - 1 }; +} + +function isFocusRuleId(ctx: FocusContextKey, id: number): boolean { + const { min, max } = ruleIdRangeForContext(ctx); + return Number.isFinite(id) && id >= min && id <= max; +} + +async function clearBlockingRules(ctx: FocusContextKey): Promise { + const dnr = chrome.declarativeNetRequest; + if (!dnr?.getSessionRules || !dnr?.updateSessionRules) return; + + try { + const rules = await Promise.resolve(dnr.getSessionRules()); + const ids = (Array.isArray(rules) ? rules : []) + .map((r) => r?.id) + .filter((id): id is number => typeof id === 'number' && isFocusRuleId(ctx, id)); + if (ids.length === 0) return; + await Promise.resolve(dnr.updateSessionRules({ removeRuleIds: ids })); + } catch (err) { + console.debug(`${LOG_PREFIX} Failed to clear blocking rules:`, err); + } +} + +async function applyBlockingRules(options: { + ctx: FocusContextKey; + domains: string[]; + incognitoTabIds: number[]; +}): Promise { + const dnr = chrome.declarativeNetRequest; + if (!dnr?.updateSessionRules) return; + + const { ctx, domains, incognitoTabIds } = options; + + // For privacy boundary: + // - normal session: exclude incognito tabs + // - incognito session: include only incognito tabs + // 说明:tabIds/excludedTabIds 目前仅 session rules 支持,因此这里使用 updateSessionRules。 + const tabConstraint = + ctx === 'incognito' + ? incognitoTabIds.length > 0 + ? { tabIds: incognitoTabIds } + : null + : { excludedTabIds: incognitoTabIds }; + + // If we cannot target any tab (incognito session with 0 incognito tabs), keep rules cleared. + if (ctx === 'incognito' && !tabConstraint) { + await clearBlockingRules(ctx); + return; + } + + // Always clear our previous rules first to avoid stale tab constraints / domains. + await clearBlockingRules(ctx); + + const { min, max } = ruleIdRangeForContext(ctx); + const capped = domains.slice(0, SESSION_RULE_ID_CAP); + const addRules: chrome.declarativeNetRequest.Rule[] = []; + + for (let i = 0; i < capped.length; i++) { + const id = min + i; + if (id > max) break; + + const domain = capped[i]; + addRules.push({ + id, + priority: 1, + action: { type: 'block' }, + condition: { + // Domain anchor matching for both domain and subdomains. + urlFilter: `||${domain}^`, + resourceTypes: ['main_frame', 'sub_frame'], + ...(tabConstraint ?? {}), + }, + }); + } + + if (addRules.length === 0) { + await clearBlockingRules(ctx); + return; + } + + try { + await Promise.resolve(dnr.updateSessionRules({ addRules })); + } catch (err) { + console.warn(`${LOG_PREFIX} Failed to apply blocking rules:`, err); + } +} + +// ============================================================ +// Storage Access +// ============================================================ + +function parseSession(raw: unknown, now: number): FocusSessionV1 { + if (!isRecord(raw)) return createEmptySession(now); + + const phaseRaw = normalizeString(raw.phase); + const phase: FocusPhase = + phaseRaw === 'running' || phaseRaw === 'paused' || phaseRaw === 'idle' + ? (phaseRaw as FocusPhase) + : 'idle'; + + const startedAt = Math.max(0, toFiniteNumber(raw.startedAt, 0)); + const endsAt = Math.max(0, toFiniteNumber(raw.endsAt, 0)); + const remainingMs = Math.max(0, toFiniteNumber(raw.remainingMs, 0)); + const durationMs = Math.max(0, toFiniteNumber(raw.durationMs, 0)); + const updatedAt = Math.max(0, toFiniteNumber(raw.updatedAt, now)); + + if (phase === 'idle') { + return { ...createEmptySession(now), updatedAt }; + } + + return { + phase, + startedAt, + endsAt, + remainingMs, + durationMs, + updatedAt, + }; +} + +function parseContext(raw: unknown, now: number): FocusContextV1 { + if (!isRecord(raw)) return createEmptyContext(now); + const session = parseSession(raw.session, now); + const blockingEnabled = normalizeBoolean(raw.blockingEnabled); + const blockingSnoozedUntil = Math.max(0, toFiniteNumber(raw.blockingSnoozedUntil, 0)); + const blocklist = normalizeBlocklist(raw.blocklist); + return { session, blockingEnabled, blockingSnoozedUntil, blocklist }; +} + +function parseStore(raw: unknown, now: number): FocusStoreV1 { + if (!isRecord(raw) || raw.version !== SCHEMA_VERSION) { + return createEmptyStore(now); + } + const updatedAt = Math.max(0, toFiniteNumber(raw.updatedAt, now)); + return { + version: SCHEMA_VERSION, + updatedAt, + normal: parseContext(raw.normal, now), + incognito: parseContext(raw.incognito, now), + }; +} + +async function readStore(now: number): Promise { + try { + const res = await chrome.storage.local.get([STORAGE_KEY]); + const raw = (res as Record | undefined)?.[STORAGE_KEY]; + return parseStore(raw, now); + } catch { + return createEmptyStore(now); + } +} + +async function writeStore(store: FocusStoreV1): Promise { + await chrome.storage.local.set({ [STORAGE_KEY]: store }); +} + +let storeMutex: Promise = Promise.resolve(); + +async function withStoreLock(fn: () => Promise): Promise { + const prev = storeMutex; + let release: (() => void) | null = null; + storeMutex = new Promise((r) => { + release = r; + }); + + await prev; + try { + return await fn(); + } finally { + try { + release?.(); + } catch { + // Best-effort + } + } +} + +// ============================================================ +// Incognito boundary +// ============================================================ + +function isValidTabId(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +async function resolveSenderIncognito(sender: chrome.runtime.MessageSender): Promise { + 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 END_ALARM_PREFIX = 'qp_focus_end_'; +const BLOCKING_SNOOZE_ALARM_PREFIX = 'qp_focus_block_snooze_end_'; + +function endAlarmName(ctx: FocusContextKey): string { + return `${END_ALARM_PREFIX}${ctx}`; +} + +function blockingSnoozeAlarmName(ctx: FocusContextKey): string { + return `${BLOCKING_SNOOZE_ALARM_PREFIX}${ctx}`; +} + +function parseContextFromEndAlarmName(name: string): FocusContextKey | null { + if (!name?.startsWith(END_ALARM_PREFIX)) return null; + const suffix = name.slice(END_ALARM_PREFIX.length); + if (suffix === 'normal' || suffix === 'incognito') return suffix; + return null; +} + +function parseContextFromBlockingSnoozeAlarmName(name: string): FocusContextKey | null { + if (!name?.startsWith(BLOCKING_SNOOZE_ALARM_PREFIX)) return null; + const suffix = name.slice(BLOCKING_SNOOZE_ALARM_PREFIX.length); + if (suffix === 'normal' || suffix === 'incognito') return suffix; + return null; +} + +async function clearAlarm(name: string): Promise { + if (!chrome.alarms?.clear) return; + try { + await Promise.resolve(chrome.alarms.clear(name)); + } catch (err) { + console.debug(`${LOG_PREFIX} alarms.clear failed:`, err); + } +} + +async function scheduleAlarm(name: string, whenMs: number): Promise { + if (!chrome.alarms?.create) { + console.warn(`${LOG_PREFIX} chrome.alarms.create is unavailable`); + return; + } + try { + await Promise.resolve(chrome.alarms.create(name, { when: whenMs })); + } catch (err) { + console.warn(`${LOG_PREFIX} alarms.create failed:`, err); + } +} + +async function clearEndAlarm(ctx: FocusContextKey): Promise { + await clearAlarm(endAlarmName(ctx)); +} + +async function scheduleEndAlarm(ctx: FocusContextKey, whenMs: number): Promise { + await scheduleAlarm(endAlarmName(ctx), whenMs); +} + +async function clearBlockingSnoozeAlarm(ctx: FocusContextKey): Promise { + await clearAlarm(blockingSnoozeAlarmName(ctx)); +} + +async function scheduleBlockingSnoozeAlarm(ctx: FocusContextKey, whenMs: number): Promise { + await scheduleAlarm(blockingSnoozeAlarmName(ctx), whenMs); +} + +// ============================================================ +// Status helpers +// ============================================================ + +function toQuickPanelSession(session: FocusSessionV1, now: number): QuickPanelFocusSession { + const phase = session.phase; + if (phase === 'running') { + const remainingMs = Math.max(0, (session.endsAt || 0) - now); + return { ...session, remainingMs }; + } + if (phase === 'paused') { + return { ...session, remainingMs: Math.max(0, session.remainingMs || 0) }; + } + return createEmptySession(now); +} + +function toStatus(params: { + incognito: boolean; + now: number; + context: FocusContextV1; +}): QuickPanelFocusStatus { + const { incognito, now, context } = params; + const session = toQuickPanelSession(context.session, now); + const supportsBlocking = context.blockingEnabled === true && context.blocklist.length > 0; + const blockingSnoozedUntilRaw = Math.max(0, context.blockingSnoozedUntil || 0); + const blockingSnoozedUntil = + supportsBlocking && session.phase !== 'idle' && blockingSnoozedUntilRaw > now + ? blockingSnoozedUntilRaw + : 0; + + const blockingActive = + session.phase === 'running' && supportsBlocking && blockingSnoozedUntil === 0; + + return { + incognito, + now, + session, + blockingEnabled: context.blockingEnabled === true, + blockingActive, + blockingSnoozedUntil, + blocklist: [...context.blocklist], + }; +} + +function getContext(store: FocusStoreV1, ctx: FocusContextKey): FocusContextV1 { + return ctx === 'incognito' ? store.incognito : store.normal; +} + +function setContext(store: FocusStoreV1, ctx: FocusContextKey, next: FocusContextV1): void { + if (ctx === 'incognito') store.incognito = next; + else store.normal = next; +} + +// ============================================================ +// Core mutations +// ============================================================ + +async function syncSideEffects( + ctx: FocusContextKey, + context: FocusContextV1, + now: number, +): Promise { + // Alarms: keep only when running and endsAt is in the future. + if (context.session.phase === 'running' && context.session.endsAt > now) { + await scheduleEndAlarm(ctx, context.session.endsAt); + } else { + await clearEndAlarm(ctx); + } + + const supportsBlocking = context.blockingEnabled === true && context.blocklist.length > 0; + const snoozedUntil = supportsBlocking ? Math.max(0, context.blockingSnoozedUntil || 0) : 0; + const isSnoozed = context.session.phase !== 'idle' && snoozedUntil > now; + + // Blocking snooze alarm (re-applies rules when snooze ends). + if (isSnoozed) { + await scheduleBlockingSnoozeAlarm(ctx, snoozedUntil); + } else { + await clearBlockingSnoozeAlarm(ctx); + } + + // Site blocking: active only while running and not snoozed. + if (context.session.phase === 'running' && supportsBlocking && !isSnoozed) { + const incognitoTabIds = await listIncognitoTabIds(); + await applyBlockingRules({ ctx, domains: context.blocklist, incognitoTabIds }); + } else { + await clearBlockingRules(ctx); + } +} + +async function reconcileExpiredSessions(): Promise { + const now = Date.now(); + const { store, changedContexts } = await withStoreLock(async () => { + const store = await readStore(now); + const changedContexts = new Set(); + + for (const ctx of ['normal', 'incognito'] as const) { + const context = getContext(store, ctx); + + if (context.session.phase === 'running') { + const remaining = Math.max(0, (context.session.endsAt || 0) - now); + if (remaining <= 0) { + context.session = createEmptySession(now); + context.blockingSnoozedUntil = 0; + changedContexts.add(ctx); + } else { + const snoozedUntil = Math.max(0, context.blockingSnoozedUntil || 0); + if (snoozedUntil > 0 && snoozedUntil <= now) { + context.blockingSnoozedUntil = 0; + changedContexts.add(ctx); + } + } + } else if (context.session.phase === 'idle') { + if (context.blockingSnoozedUntil !== 0) { + context.blockingSnoozedUntil = 0; + changedContexts.add(ctx); + } + } else { + const snoozedUntil = Math.max(0, context.blockingSnoozedUntil || 0); + if (snoozedUntil > 0 && snoozedUntil <= now) { + context.blockingSnoozedUntil = 0; + changedContexts.add(ctx); + } + } + + setContext(store, ctx, context); + } + + if (changedContexts.size > 0) { + store.updatedAt = now; + await writeStore(store); + } + + return { store, changedContexts: [...changedContexts] }; + }); + + // Clear alarms / blocking for any session that was auto-ended. + for (const ctx of changedContexts) { + await syncSideEffects(ctx, getContext(store, ctx), now); + } +} + +// ============================================================ +// Message handlers +// ============================================================ + +async function handleStatus( + _message: QuickPanelFocusStatusMessage, + sender: chrome.runtime.MessageSender, +): Promise { + try { + const incognito = await resolveSenderIncognito(sender); + const now = Date.now(); + + await reconcileExpiredSessions(); + const store = await readStore(now); + + const ctx = asContextKey(incognito); + const status = toStatus({ incognito, now, context: getContext(store, ctx) }); + return { success: true, status }; + } catch (err) { + return { success: false, error: safeErrorMessage(err) || 'Failed to get focus status' }; + } +} + +async function handleStart( + message: QuickPanelFocusStartMessage, + sender: chrome.runtime.MessageSender, +): Promise { + try { + const incognito = await resolveSenderIncognito(sender); + const ctx = asContextKey(incognito); + const now = Date.now(); + + const durationMinutes = clampInt( + message.payload?.durationMinutes, + 25, + MIN_DURATION_MINUTES, + MAX_DURATION_MINUTES, + ); + const durationMs = durationMinutesToMs(durationMinutes); + + const status = await withStoreLock(async () => { + const store = await readStore(now); + const context = getContext(store, ctx); + + context.session = { + phase: 'running', + startedAt: now, + endsAt: now + durationMs, + remainingMs: durationMs, + durationMs, + updatedAt: now, + }; + context.blockingSnoozedUntil = 0; + + store.updatedAt = now; + setContext(store, ctx, context); + await writeStore(store); + + return toStatus({ incognito, now, context }); + }); + + await syncSideEffects( + ctx, + { + session: status.session, + blockingEnabled: status.blockingEnabled, + blockingSnoozedUntil: status.blockingSnoozedUntil, + blocklist: status.blocklist, + }, + now, + ); + + return { success: true, status }; + } catch (err) { + return { success: false, error: safeErrorMessage(err) || 'Failed to start focus session' }; + } +} + +async function handleStop( + _message: QuickPanelFocusStopMessage, + sender: chrome.runtime.MessageSender, +): Promise { + try { + const incognito = await resolveSenderIncognito(sender); + const ctx = asContextKey(incognito); + const now = Date.now(); + + const status = await withStoreLock(async () => { + const store = await readStore(now); + const context = getContext(store, ctx); + context.session = createEmptySession(now); + context.blockingSnoozedUntil = 0; + store.updatedAt = now; + setContext(store, ctx, context); + await writeStore(store); + return toStatus({ incognito, now, context }); + }); + + await syncSideEffects( + ctx, + { + session: status.session, + blockingEnabled: status.blockingEnabled, + blockingSnoozedUntil: status.blockingSnoozedUntil, + blocklist: status.blocklist, + }, + now, + ); + + return { success: true, status }; + } catch (err) { + return { success: false, error: safeErrorMessage(err) || 'Failed to stop focus session' }; + } +} + +async function handlePause( + _message: QuickPanelFocusPauseMessage, + sender: chrome.runtime.MessageSender, +): Promise { + try { + const incognito = await resolveSenderIncognito(sender); + const ctx = asContextKey(incognito); + const now = Date.now(); + + const status = await withStoreLock(async () => { + const store = await readStore(now); + const context = getContext(store, ctx); + + if (context.session.phase === 'idle') { + throw new Error('No active focus session'); + } + if (context.session.phase === 'paused') { + return toStatus({ incognito, now, context }); + } + + const remainingMs = Math.max(0, (context.session.endsAt || 0) - now); + if (remainingMs <= 0) { + context.session = createEmptySession(now); + } else { + context.session = { + ...context.session, + phase: 'paused', + endsAt: 0, + remainingMs, + updatedAt: now, + }; + } + + store.updatedAt = now; + setContext(store, ctx, context); + await writeStore(store); + return toStatus({ incognito, now, context }); + }); + + await syncSideEffects( + ctx, + { + session: status.session, + blockingEnabled: status.blockingEnabled, + blockingSnoozedUntil: status.blockingSnoozedUntil, + blocklist: status.blocklist, + }, + now, + ); + + return { success: true, status }; + } catch (err) { + return { success: false, error: safeErrorMessage(err) || 'Failed to pause focus session' }; + } +} + +async function handleResume( + _message: QuickPanelFocusResumeMessage, + sender: chrome.runtime.MessageSender, +): Promise { + try { + const incognito = await resolveSenderIncognito(sender); + const ctx = asContextKey(incognito); + const now = Date.now(); + + const status = await withStoreLock(async () => { + const store = await readStore(now); + const context = getContext(store, ctx); + + if (context.session.phase === 'idle') { + throw new Error('No active focus session'); + } + if (context.session.phase === 'running') { + return toStatus({ incognito, now, context }); + } + + const remainingMs = Math.max(0, context.session.remainingMs || 0); + if (remainingMs <= 0) { + context.session = createEmptySession(now); + } else { + context.session = { + ...context.session, + phase: 'running', + endsAt: now + remainingMs, + updatedAt: now, + }; + } + + store.updatedAt = now; + setContext(store, ctx, context); + await writeStore(store); + return toStatus({ incognito, now, context }); + }); + + await syncSideEffects( + ctx, + { + session: status.session, + blockingEnabled: status.blockingEnabled, + blockingSnoozedUntil: status.blockingSnoozedUntil, + blocklist: status.blocklist, + }, + now, + ); + + return { success: true, status }; + } catch (err) { + return { success: false, error: safeErrorMessage(err) || 'Failed to resume focus session' }; + } +} + +async function handleExtend( + message: QuickPanelFocusExtendMessage, + sender: chrome.runtime.MessageSender, +): Promise { + try { + const incognito = await resolveSenderIncognito(sender); + const ctx = asContextKey(incognito); + const now = Date.now(); + + const minutes = clampInt(message.payload?.minutes, 5, 1, 180); + const extendMs = minutes * 60_000; + + const status = await withStoreLock(async () => { + const store = await readStore(now); + const context = getContext(store, ctx); + + if (context.session.phase === 'idle') { + throw new Error('No active focus session'); + } + + if (context.session.phase === 'running') { + context.session = { + ...context.session, + endsAt: (context.session.endsAt || now) + extendMs, + durationMs: Math.max(0, context.session.durationMs || 0) + extendMs, + updatedAt: now, + }; + } else if (context.session.phase === 'paused') { + context.session = { + ...context.session, + remainingMs: Math.max(0, context.session.remainingMs || 0) + extendMs, + durationMs: Math.max(0, context.session.durationMs || 0) + extendMs, + updatedAt: now, + }; + } + + store.updatedAt = now; + setContext(store, ctx, context); + await writeStore(store); + return toStatus({ incognito, now, context }); + }); + + await syncSideEffects( + ctx, + { + session: status.session, + blockingEnabled: status.blockingEnabled, + blockingSnoozedUntil: status.blockingSnoozedUntil, + blocklist: status.blocklist, + }, + now, + ); + + return { success: true, status }; + } catch (err) { + return { success: false, error: safeErrorMessage(err) || 'Failed to extend focus session' }; + } +} + +async function handleSetBlocklist( + message: QuickPanelFocusSetBlocklistMessage, + sender: chrome.runtime.MessageSender, +): Promise { + try { + const incognito = await resolveSenderIncognito(sender); + const ctx = asContextKey(incognito); + const now = Date.now(); + + const nextList = normalizeBlocklist(message.payload?.domains); + + const status = await withStoreLock(async () => { + const store = await readStore(now); + const context = getContext(store, ctx); + context.blocklist = nextList; + if (context.blocklist.length === 0) context.blockingSnoozedUntil = 0; + context.session.updatedAt = now; + store.updatedAt = now; + setContext(store, ctx, context); + await writeStore(store); + return toStatus({ incognito, now, context }); + }); + + await syncSideEffects( + ctx, + { + session: status.session, + blockingEnabled: status.blockingEnabled, + blockingSnoozedUntil: status.blockingSnoozedUntil, + blocklist: status.blocklist, + }, + now, + ); + + return { success: true, status }; + } catch (err) { + return { success: false, error: safeErrorMessage(err) || 'Failed to update blocklist' }; + } +} + +async function handleSetBlockingEnabled( + message: QuickPanelFocusSetBlockingEnabledMessage, + sender: chrome.runtime.MessageSender, +): Promise { + try { + const incognito = await resolveSenderIncognito(sender); + const ctx = asContextKey(incognito); + const now = Date.now(); + + const enabled = normalizeBoolean(message.payload?.enabled); + + const status = await withStoreLock(async () => { + const store = await readStore(now); + const context = getContext(store, ctx); + context.blockingEnabled = enabled; + context.blockingSnoozedUntil = 0; + context.session.updatedAt = now; + store.updatedAt = now; + setContext(store, ctx, context); + await writeStore(store); + return toStatus({ incognito, now, context }); + }); + + await syncSideEffects( + ctx, + { + session: status.session, + blockingEnabled: status.blockingEnabled, + blockingSnoozedUntil: status.blockingSnoozedUntil, + blocklist: status.blocklist, + }, + now, + ); + + return { success: true, status }; + } catch (err) { + return { success: false, error: safeErrorMessage(err) || 'Failed to update blocking setting' }; + } +} + +async function handleSnoozeBlocking( + message: QuickPanelFocusSnoozeBlockingMessage, + sender: chrome.runtime.MessageSender, +): Promise { + try { + const incognito = await resolveSenderIncognito(sender); + const ctx = asContextKey(incognito); + const now = Date.now(); + + const minutes = clampInt(message.payload?.minutes, 5, 1, 180); + const extendMs = minutes * 60_000; + + const status = await withStoreLock(async () => { + const store = await readStore(now); + const context = getContext(store, ctx); + + if (context.session.phase === 'idle') { + throw new Error('No active focus session'); + } + if (context.blockingEnabled !== true) { + throw new Error('Blocking is disabled'); + } + if (context.blocklist.length === 0) { + throw new Error('No blocked domains configured'); + } + + const base = Math.max(now, Math.max(0, context.blockingSnoozedUntil || 0)); + context.blockingSnoozedUntil = base + extendMs; + context.session.updatedAt = now; + store.updatedAt = now; + setContext(store, ctx, context); + await writeStore(store); + return toStatus({ incognito, now, context }); + }); + + await syncSideEffects( + ctx, + { + session: status.session, + blockingEnabled: status.blockingEnabled, + blockingSnoozedUntil: status.blockingSnoozedUntil, + blocklist: status.blocklist, + }, + now, + ); + + return { success: true, status }; + } catch (err) { + return { success: false, error: safeErrorMessage(err) || 'Failed to snooze blocking' }; + } +} + +async function handleResumeBlocking( + _message: QuickPanelFocusResumeBlockingMessage, + sender: chrome.runtime.MessageSender, +): Promise { + try { + const incognito = await resolveSenderIncognito(sender); + const ctx = asContextKey(incognito); + const now = Date.now(); + + const status = await withStoreLock(async () => { + const store = await readStore(now); + const context = getContext(store, ctx); + context.blockingSnoozedUntil = 0; + context.session.updatedAt = now; + store.updatedAt = now; + setContext(store, ctx, context); + await writeStore(store); + return toStatus({ incognito, now, context }); + }); + + await syncSideEffects( + ctx, + { + session: status.session, + blockingEnabled: status.blockingEnabled, + blockingSnoozedUntil: status.blockingSnoozedUntil, + blocklist: status.blocklist, + }, + now, + ); + + return { success: true, status }; + } catch (err) { + return { success: false, error: safeErrorMessage(err) || 'Failed to resume blocking' }; + } +} + +// ============================================================ +// Startup + listeners +// ============================================================ + +async function restoreOnStartup(): Promise { + const now = Date.now(); + await reconcileExpiredSessions(); + + const store = await readStore(now); + for (const ctx of ['normal', 'incognito'] as const) { + const context = getContext(store, ctx); + await syncSideEffects(ctx, context, now); + } +} + +function registerAlarmListener(): void { + if (!chrome.alarms?.onAlarm?.addListener) return; + + chrome.alarms.onAlarm.addListener((alarm) => { + const name = alarm?.name ?? ''; + + const endCtx = parseContextFromEndAlarmName(name); + const snoozeCtx = endCtx ? null : parseContextFromBlockingSnoozeAlarmName(name); + + const ctx = endCtx ?? snoozeCtx; + if (!ctx) return; + + void (async () => { + const now = Date.now(); + if (endCtx) { + await withStoreLock(async () => { + const store = await readStore(now); + const context = getContext(store, ctx); + if (context.session.phase === 'running') { + const remaining = Math.max(0, (context.session.endsAt || 0) - now); + if (remaining <= 0) { + context.session = createEmptySession(now); + context.blockingSnoozedUntil = 0; + store.updatedAt = now; + setContext(store, ctx, context); + await writeStore(store); + } + } + }); + } else if (snoozeCtx) { + await withStoreLock(async () => { + const store = await readStore(now); + const context = getContext(store, ctx); + const snoozedUntil = Math.max(0, context.blockingSnoozedUntil || 0); + if (snoozedUntil > 0 && snoozedUntil <= now) { + context.blockingSnoozedUntil = 0; + store.updatedAt = now; + setContext(store, ctx, context); + await writeStore(store); + } + }); + } + + const storeAfter = await readStore(now); + await syncSideEffects(ctx, getContext(storeAfter, ctx), now); + })(); + }); +} + +let tabRefreshTimer: ReturnType | null = null; + +function registerTabListeners(): void { + if (!chrome.tabs?.onCreated?.addListener || !chrome.tabs?.onRemoved?.addListener) return; + + const scheduleRefresh = () => { + if (tabRefreshTimer) clearTimeout(tabRefreshTimer); + tabRefreshTimer = setTimeout(() => { + tabRefreshTimer = null; + void (async () => { + const now = Date.now(); + const store = await readStore(now); + + // Only refresh if any context is actively blocking (running + enabled + non-empty blocklist). + const needs = + (store.normal.session.phase === 'running' && + store.normal.blockingEnabled && + store.normal.blocklist.length > 0) || + (store.incognito.session.phase === 'running' && + store.incognito.blockingEnabled && + store.incognito.blocklist.length > 0); + + if (!needs) return; + + const incognitoTabIds = await listIncognitoTabIds(); + + if ( + store.normal.session.phase === 'running' && + store.normal.blockingEnabled && + store.normal.blocklist.length > 0 + ) { + await applyBlockingRules({ + ctx: 'normal', + domains: store.normal.blocklist, + incognitoTabIds, + }); + } + if ( + store.incognito.session.phase === 'running' && + store.incognito.blockingEnabled && + store.incognito.blocklist.length > 0 + ) { + await applyBlockingRules({ + ctx: 'incognito', + domains: store.incognito.blocklist, + incognitoTabIds, + }); + } + })(); + }, 150); + }; + + chrome.tabs.onCreated.addListener(() => scheduleRefresh()); + chrome.tabs.onRemoved.addListener(() => scheduleRefresh()); +} + +// ============================================================ +// Initialization +// ============================================================ + +let initialized = false; + +export function initQuickPanelFocusHandler(): void { + if (initialized) return; + initialized = true; + + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_STATUS) { + handleStatus(message as QuickPanelFocusStatusMessage, sender).then(sendResponse); + return true; + } + if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_START) { + handleStart(message as QuickPanelFocusStartMessage, sender).then(sendResponse); + return true; + } + if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_STOP) { + handleStop(message as QuickPanelFocusStopMessage, sender).then(sendResponse); + return true; + } + if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_PAUSE) { + handlePause(message as QuickPanelFocusPauseMessage, sender).then(sendResponse); + return true; + } + if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_RESUME) { + handleResume(message as QuickPanelFocusResumeMessage, sender).then(sendResponse); + return true; + } + if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_EXTEND) { + handleExtend(message as QuickPanelFocusExtendMessage, sender).then(sendResponse); + return true; + } + if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_SET_BLOCKLIST) { + handleSetBlocklist(message as QuickPanelFocusSetBlocklistMessage, sender).then(sendResponse); + return true; + } + if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_SET_BLOCKING_ENABLED) { + handleSetBlockingEnabled(message as QuickPanelFocusSetBlockingEnabledMessage, sender).then( + sendResponse, + ); + return true; + } + if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_SNOOZE_BLOCKING) { + handleSnoozeBlocking(message as QuickPanelFocusSnoozeBlockingMessage, sender).then( + sendResponse, + ); + return true; + } + if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_RESUME_BLOCKING) { + handleResumeBlocking(message as QuickPanelFocusResumeBlockingMessage, sender).then( + sendResponse, + ); + return true; + } + return false; + }); + + registerAlarmListener(); + registerTabListeners(); + void restoreOnStartup(); + + console.debug(`${LOG_PREFIX} Initialized`); +} diff --git a/app/chrome-extension/entrypoints/background/quick-panel/history-handler.ts b/app/chrome-extension/entrypoints/background/quick-panel/history-handler.ts index 4f15fea..bb682cc 100644 --- a/app/chrome-extension/entrypoints/background/quick-panel/history-handler.ts +++ b/app/chrome-extension/entrypoints/background/quick-panel/history-handler.ts @@ -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 { + 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; }); diff --git a/app/chrome-extension/entrypoints/background/quick-panel/monitor-handler.ts b/app/chrome-extension/entrypoints/background/quick-panel/monitor-handler.ts new file mode 100644 index 0000000..1137779 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/quick-panel/monitor-handler.ts @@ -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 { + 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(); + 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(); + 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 { + try { + const res = await chrome.storage.local.get([STORAGE_KEY]); + const raw = (res as Record | undefined)?.[STORAGE_KEY]; + return parseStore(raw, now); + } catch { + return createEmptyStore(now); + } +} + +async function writeStore(store: MonitorStoreV1): Promise { + await chrome.storage.local.set({ [STORAGE_KEY]: store }); +} + +let storeMutex: Promise = Promise.resolve(); + +async function withStoreLock(fn: () => Promise): Promise { + const prev = storeMutex; + let release: (() => void) | null = null; + storeMutex = new Promise((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 { + 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 { + 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 { + if (!chrome.alarms?.clear) return; + try { + await Promise.resolve(chrome.alarms.clear(alarmName(monitorId))); + } catch { + // Best-effort + } +} + +async function clearStaleMonitorAlarms(validIds: Set): Promise { + 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 { + 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 { + 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>(); + +async function runMonitorCheck(monitorId: string): Promise { + const existing = inFlightChecks.get(monitorId); + if (existing) return existing; + + const promise = (async (): Promise => { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const now = Date.now(); + const store = await readStore(now); + + const enabledIds = new Set(); + 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`); +} diff --git a/app/chrome-extension/entrypoints/background/quick-panel/notes-handler.ts b/app/chrome-extension/entrypoints/background/quick-panel/notes-handler.ts new file mode 100644 index 0000000..042fe1a --- /dev/null +++ b/app/chrome-extension/entrypoints/background/quick-panel/notes-handler.ts @@ -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 { + 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 { + 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(); + + 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 { + try { + const res = await chrome.storage.local.get([STORAGE_KEY]); + const raw = (res as Record | undefined)?.[STORAGE_KEY]; + return parseStore(raw); + } catch { + return { version: SCHEMA_VERSION, updatedAt: 0, items: [] }; + } +} + +async function writeStore(store: NotesStoreV1): Promise { + await chrome.storage.local.set({ [STORAGE_KEY]: store }); +} + +let storeMutex: Promise = Promise.resolve(); + +async function withStoreLock(fn: () => Promise): Promise { + const prev = storeMutex; + let release: (() => void) | null = null; + storeMutex = new Promise((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 { + 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 { + 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 { + 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 { + 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`); +} diff --git a/app/chrome-extension/entrypoints/background/quick-panel/page-commands-handler.ts b/app/chrome-extension/entrypoints/background/quick-panel/page-commands-handler.ts index 8184e64..38f0ef3 100644 --- a/app/chrome-extension/entrypoints/background/quick-panel/page-commands-handler.ts +++ b/app/chrome-extension/entrypoints/background/quick-panel/page-commands-handler.ts @@ -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 { + 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>(); + 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); diff --git a/app/chrome-extension/entrypoints/background/quick-panel/page-skins.ts b/app/chrome-extension/entrypoints/background/quick-panel/page-skins.ts new file mode 100644 index 0000000..00f7ab1 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/quick-panel/page-skins.ts @@ -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(); + +let sessionLoaded = false; +let sessionLoadPromise: Promise | null = null; +let persistTimer: ReturnType | null = null; + +function isRecord(value: unknown): value is Record { + 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 { + 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 { + if (!chrome.storage?.session) return; + + const snapshot: Record = {}; + 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 { + await chrome.scripting.insertCSS({ + target: { tabId }, + css: PAGE_SKINS_CSS, + }); +} + +async function setBodySkinAttr(tabId: number, skinId: QuickPanelPageSkinId): Promise { + 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 { + 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 { + 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 = ` + +
+ Skin mode${title} +
+ `; + } catch { + // Best-effort + } + }, + args: [WATERMARK_HOST_ID, label], + }); +} + +async function removeWatermark(tabId: number): Promise { + 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 { + 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 { + 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`); +} diff --git a/app/chrome-extension/entrypoints/background/quick-panel/page-tools.ts b/app/chrome-extension/entrypoints/background/quick-panel/page-tools.ts new file mode 100644 index 0000000..1f8533a --- /dev/null +++ b/app/chrome-extension/entrypoints/background/quick-panel/page-tools.ts @@ -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 { + return typeof value === 'object' && value !== null; +} + +async function toggleBodyAttribute(tabId: number, attr: string, css: string): Promise { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 Esc 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 { + 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' }; + } +} diff --git a/app/chrome-extension/entrypoints/background/quick-panel/workspaces-handler.ts b/app/chrome-extension/entrypoints/background/quick-panel/workspaces-handler.ts new file mode 100644 index 0000000..a07a90e --- /dev/null +++ b/app/chrome-extension/entrypoints/background/quick-panel/workspaces-handler.ts @@ -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 { + 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(); + 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 { + try { + const res = await chrome.storage.local.get([STORAGE_KEY]); + const raw = (res as Record | undefined)?.[STORAGE_KEY]; + return parseStore(raw); + } catch { + return { version: SCHEMA_VERSION, updatedAt: 0, items: [] }; + } +} + +async function writeStore(store: WorkspaceStoreV1): Promise { + await chrome.storage.local.set({ + [STORAGE_KEY]: { + version: store.version, + updatedAt: store.updatedAt, + items: store.items, + }, + }); +} + +let writeLock: Promise = Promise.resolve(); + +async function withWriteLock(fn: () => Promise): Promise { + const prev = writeLock; + let release: (() => void) | null = null; + writeLock = new Promise((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 { + 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 { + 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 { + 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 { + 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`); +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/runner.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/runner.ts index 71e17af..113a8ea 100644 --- a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/runner.ts +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/runner.ts @@ -140,6 +140,73 @@ function deepClone(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } +function isRecord(value: unknown): value is Record { + 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)[k] as JsonValue)) + return false; + } + return true; + } + + return false; +} + +function diffVarsPatch( + from: Record, + to: Record, +): 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. + } + } } } } diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/register-v2-replay-nodes.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/register-v2-replay-nodes.ts index 48a59eb..9b74e52 100644 --- a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/register-v2-replay-nodes.ts +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/register-v2-replay-nodes.ts @@ -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 { diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/v2-action-adapter.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/v2-action-adapter.ts index a574d62..694f07f 100644 --- a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/v2-action-adapter.ts +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/v2-action-adapter.ts @@ -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'; diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/adapter.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/adapter.ts index e35a718..cdf92a8 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/actions/adapter.ts +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/adapter.ts @@ -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 = { 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', }; // ================================ diff --git a/app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-executor.ts b/app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-executor.ts index 49e727e..3ef22e6 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-executor.ts +++ b/app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-executor.ts @@ -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, diff --git a/app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-runner.ts b/app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-runner.ts index 6981091..5ecc304 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-runner.ts +++ b/app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-runner.ts @@ -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'; diff --git a/app/chrome-extension/entrypoints/background/record-replay/engine/runners/subflow-runner.ts b/app/chrome-extension/entrypoints/background/record-replay/engine/runners/subflow-runner.ts index f91826d..f8f91f8 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/engine/runners/subflow-runner.ts +++ b/app/chrome-extension/entrypoints/background/record-replay/engine/runners/subflow-runner.ts @@ -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; diff --git a/app/chrome-extension/entrypoints/background/record-replay/engine/scheduler.ts b/app/chrome-extension/entrypoints/background/record-replay/engine/scheduler.ts index 9bf5e89..9236d77 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/engine/scheduler.ts +++ b/app/chrome-extension/entrypoints/background/record-replay/engine/scheduler.ts @@ -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'; diff --git a/app/chrome-extension/entrypoints/background/record-replay/legacy-types.ts b/app/chrome-extension/entrypoints/background/record-replay/legacy-types.ts index 2586abb..ade8b7d 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/legacy-types.ts +++ b/app/chrome-extension/entrypoints/background/record-replay/legacy-types.ts @@ -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 */ diff --git a/app/chrome-extension/entrypoints/background/record-replay/nodes/loops.ts b/app/chrome-extension/entrypoints/background/record-replay/nodes/loops.ts index 2cf3740..c9ac3ca 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/nodes/loops.ts +++ b/app/chrome-extension/entrypoints/background/record-replay/nodes/loops.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 = { validate: (step) => { diff --git a/app/chrome-extension/entrypoints/background/record-replay/rr-utils.ts b/app/chrome-extension/entrypoints/background/record-replay/rr-utils.ts index 6a747f2..078584e 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/rr-utils.ts +++ b/app/chrome-extension/entrypoints/background/record-replay/rr-utils.ts @@ -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 { - 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((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); diff --git a/app/chrome-extension/entrypoints/background/record-replay/types.ts b/app/chrome-extension/entrypoints/background/record-replay/types.ts index c8f039d..9cc7617 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/types.ts +++ b/app/chrome-extension/entrypoints/background/record-replay/types.ts @@ -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) */ diff --git a/app/chrome-extension/entrypoints/background/record-replay/engine/constants.ts b/app/chrome-extension/entrypoints/background/replay-actions/engine/constants.ts similarity index 100% rename from app/chrome-extension/entrypoints/background/record-replay/engine/constants.ts rename to app/chrome-extension/entrypoints/background/replay-actions/engine/constants.ts diff --git a/app/chrome-extension/entrypoints/background/record-replay/engine/policies/wait.ts b/app/chrome-extension/entrypoints/background/replay-actions/engine/policies/wait.ts similarity index 91% rename from app/chrome-extension/entrypoints/background/record-replay/engine/policies/wait.ts rename to app/chrome-extension/entrypoints/background/replay-actions/engine/policies/wait.ts index 67f26fe..7f9d429 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/engine/policies/wait.ts +++ b/app/chrome-extension/entrypoints/background/replay-actions/engine/policies/wait.ts @@ -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, ); diff --git a/app/chrome-extension/entrypoints/background/replay-actions/engine/utils/wait.ts b/app/chrome-extension/entrypoints/background/replay-actions/engine/utils/wait.ts new file mode 100644 index 0000000..1d0a157 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/replay-actions/engine/utils/wait.ts @@ -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 { + 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((resolve, reject) => { + let done = false; + const timer: ReturnType | 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); + }); +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/assert.ts b/app/chrome-extension/entrypoints/background/replay-actions/handlers/assert.ts similarity index 100% rename from app/chrome-extension/entrypoints/background/record-replay/actions/handlers/assert.ts rename to app/chrome-extension/entrypoints/background/replay-actions/handlers/assert.ts diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/click.ts b/app/chrome-extension/entrypoints/background/replay-actions/handlers/click.ts similarity index 98% rename from app/chrome-extension/entrypoints/background/record-replay/actions/handlers/click.ts rename to app/chrome-extension/entrypoints/background/replay-actions/handlers/click.ts index 11f98ad..008df62 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/click.ts +++ b/app/chrome-extension/entrypoints/background/replay-actions/handlers/click.ts @@ -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, diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/common.ts b/app/chrome-extension/entrypoints/background/replay-actions/handlers/common.ts similarity index 100% rename from app/chrome-extension/entrypoints/background/record-replay/actions/handlers/common.ts rename to app/chrome-extension/entrypoints/background/replay-actions/handlers/common.ts diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/control-flow.ts b/app/chrome-extension/entrypoints/background/replay-actions/handlers/control-flow.ts similarity index 100% rename from app/chrome-extension/entrypoints/background/record-replay/actions/handlers/control-flow.ts rename to app/chrome-extension/entrypoints/background/replay-actions/handlers/control-flow.ts diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/delay.ts b/app/chrome-extension/entrypoints/background/replay-actions/handlers/delay.ts similarity index 100% rename from app/chrome-extension/entrypoints/background/record-replay/actions/handlers/delay.ts rename to app/chrome-extension/entrypoints/background/replay-actions/handlers/delay.ts diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/dom.ts b/app/chrome-extension/entrypoints/background/replay-actions/handlers/dom.ts similarity index 100% rename from app/chrome-extension/entrypoints/background/record-replay/actions/handlers/dom.ts rename to app/chrome-extension/entrypoints/background/replay-actions/handlers/dom.ts diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/drag.ts b/app/chrome-extension/entrypoints/background/replay-actions/handlers/drag.ts similarity index 100% rename from app/chrome-extension/entrypoints/background/record-replay/actions/handlers/drag.ts rename to app/chrome-extension/entrypoints/background/replay-actions/handlers/drag.ts diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/execute-flow.ts b/app/chrome-extension/entrypoints/background/replay-actions/handlers/execute-flow.ts similarity index 100% rename from app/chrome-extension/entrypoints/background/record-replay/actions/handlers/execute-flow.ts rename to app/chrome-extension/entrypoints/background/replay-actions/handlers/execute-flow.ts diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/extract.ts b/app/chrome-extension/entrypoints/background/replay-actions/handlers/extract.ts similarity index 100% rename from app/chrome-extension/entrypoints/background/record-replay/actions/handlers/extract.ts rename to app/chrome-extension/entrypoints/background/replay-actions/handlers/extract.ts diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/fill.ts b/app/chrome-extension/entrypoints/background/replay-actions/handlers/fill.ts similarity index 100% rename from app/chrome-extension/entrypoints/background/record-replay/actions/handlers/fill.ts rename to app/chrome-extension/entrypoints/background/replay-actions/handlers/fill.ts diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/http.ts b/app/chrome-extension/entrypoints/background/replay-actions/handlers/http.ts similarity index 100% rename from app/chrome-extension/entrypoints/background/record-replay/actions/handlers/http.ts rename to app/chrome-extension/entrypoints/background/replay-actions/handlers/http.ts diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/index.ts b/app/chrome-extension/entrypoints/background/replay-actions/handlers/index.ts similarity index 100% rename from app/chrome-extension/entrypoints/background/record-replay/actions/handlers/index.ts rename to app/chrome-extension/entrypoints/background/replay-actions/handlers/index.ts diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/key.ts b/app/chrome-extension/entrypoints/background/replay-actions/handlers/key.ts similarity index 100% rename from app/chrome-extension/entrypoints/background/record-replay/actions/handlers/key.ts rename to app/chrome-extension/entrypoints/background/replay-actions/handlers/key.ts diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/loop-elements.ts b/app/chrome-extension/entrypoints/background/replay-actions/handlers/loop-elements.ts similarity index 100% rename from app/chrome-extension/entrypoints/background/record-replay/actions/handlers/loop-elements.ts rename to app/chrome-extension/entrypoints/background/replay-actions/handlers/loop-elements.ts diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/navigate.ts b/app/chrome-extension/entrypoints/background/replay-actions/handlers/navigate.ts similarity index 95% rename from app/chrome-extension/entrypoints/background/record-replay/actions/handlers/navigate.ts rename to app/chrome-extension/entrypoints/background/replay-actions/handlers/navigate.ts index 0a2c974..db768e8 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/navigate.ts +++ b/app/chrome-extension/entrypoints/background/replay-actions/handlers/navigate.ts @@ -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'; diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/screenshot.ts b/app/chrome-extension/entrypoints/background/replay-actions/handlers/screenshot.ts similarity index 100% rename from app/chrome-extension/entrypoints/background/record-replay/actions/handlers/screenshot.ts rename to app/chrome-extension/entrypoints/background/replay-actions/handlers/screenshot.ts diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/script.ts b/app/chrome-extension/entrypoints/background/replay-actions/handlers/script.ts similarity index 100% rename from app/chrome-extension/entrypoints/background/record-replay/actions/handlers/script.ts rename to app/chrome-extension/entrypoints/background/replay-actions/handlers/script.ts diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/scroll.ts b/app/chrome-extension/entrypoints/background/replay-actions/handlers/scroll.ts similarity index 100% rename from app/chrome-extension/entrypoints/background/record-replay/actions/handlers/scroll.ts rename to app/chrome-extension/entrypoints/background/replay-actions/handlers/scroll.ts diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/tabs.ts b/app/chrome-extension/entrypoints/background/replay-actions/handlers/tabs.ts similarity index 100% rename from app/chrome-extension/entrypoints/background/record-replay/actions/handlers/tabs.ts rename to app/chrome-extension/entrypoints/background/replay-actions/handlers/tabs.ts diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/wait.ts b/app/chrome-extension/entrypoints/background/replay-actions/handlers/wait.ts similarity index 97% rename from app/chrome-extension/entrypoints/background/record-replay/actions/handlers/wait.ts rename to app/chrome-extension/entrypoints/background/replay-actions/handlers/wait.ts index e2ec8d9..35f7b28 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/wait.ts +++ b/app/chrome-extension/entrypoints/background/replay-actions/handlers/wait.ts @@ -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'; diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/index.ts b/app/chrome-extension/entrypoints/background/replay-actions/index.ts similarity index 73% rename from app/chrome-extension/entrypoints/background/record-replay/actions/index.ts rename to app/chrome-extension/entrypoints/background/replay-actions/index.ts index e8df2b6..975668a 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/actions/index.ts +++ b/app/chrome-extension/entrypoints/background/replay-actions/index.ts @@ -23,17 +23,6 @@ export { type ActionRegistryHooks, } from './registry'; -// 适配器导出 -export { - execCtxToActionCtx, - stepToAction, - actionResultToExecResult, - createStepExecutor, - isActionSupported, - getActionType, - type StepExecutionAttempt, -} from './adapter'; - // Handler 工厂导出 export { createReplayActionRegistry, diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/registry.ts b/app/chrome-extension/entrypoints/background/replay-actions/registry.ts similarity index 100% rename from app/chrome-extension/entrypoints/background/record-replay/actions/registry.ts rename to app/chrome-extension/entrypoints/background/replay-actions/registry.ts diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/types.ts b/app/chrome-extension/entrypoints/background/replay-actions/types.ts similarity index 100% rename from app/chrome-extension/entrypoints/background/record-replay/actions/types.ts rename to app/chrome-extension/entrypoints/background/replay-actions/types.ts diff --git a/app/chrome-extension/entrypoints/background/tools/browser/network-capture-debugger.ts b/app/chrome-extension/entrypoints/background/tools/browser/network-capture-debugger.ts index 49cb973..79f7659 100644 --- a/app/chrome-extension/entrypoints/background/tools/browser/network-capture-debugger.ts +++ b/app/chrome-extension/entrypoints/background/tools/browser/network-capture-debugger.ts @@ -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 = new Map(); // tabId -> capture data + public captureData: Map = new Map(); // tabId -> capture data private captureTimers: Map = new Map(); // tabId -> max capture timer private inactivityTimers: Map = new Map(); // tabId -> inactivity timer private lastActivityTime: Map = new Map(); // tabId -> timestamp of last network activity @@ -767,6 +769,8 @@ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor { async execute(args: NetworkDebuggerStartToolParams): Promise { 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 { + async execute(args?: { tabId?: number; windowId?: number }): Promise { 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)) { diff --git a/app/chrome-extension/entrypoints/background/tools/browser/network-capture-web-request.ts b/app/chrome-extension/entrypoints/background/tools/browser/network-capture-web-request.ts index 462808b..c1f50a4 100644 --- a/app/chrome-extension/entrypoints/background/tools/browser/network-capture-web-request.ts +++ b/app/chrome-extension/entrypoints/background/tools/browser/network-capture-web-request.ts @@ -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 { 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 { + async execute(args?: { tabId?: number; windowId?: number }): Promise { 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 diff --git a/app/chrome-extension/entrypoints/background/tools/browser/network-capture.ts b/app/chrome-extension/entrypoints/background/tools/browser/network-capture.ts index aebfd9f..c84e1fe 100644 --- a/app/chrome-extension/entrypoints/background/tools/browser/network-capture.ts +++ b/app/chrome-extension/entrypoints/background/tools/browser/network-capture.ts @@ -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): * Check if debugger-based capture is active */ function isDebuggerCaptureActive(): boolean { - const captureData = ( - networkDebuggerStartTool as unknown as { captureData?: Map } - ).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, diff --git a/app/chrome-extension/entrypoints/background/tools/browser/network-request.ts b/app/chrome-extension/entrypoints/background/tools/browser/network-request.ts index 93b58cd..33f135a 100644 --- a/app/chrome-extension/entrypoints/background/tools/browser/network-request.ts +++ b/app/chrome-extension/entrypoints/background/tools/browser/network-request.ts @@ -11,12 +11,22 @@ interface NetworkRequestToolParams { headers?: Record; // 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, 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, diff --git a/app/chrome-extension/entrypoints/background/tools/browser/performance.ts b/app/chrome-extension/entrypoints/background/tools/browser/performance.ts index 8fcb257..cf9c333 100644 --- a/app/chrome-extension/entrypoints/background/tools/browser/performance.ts +++ b/app/chrome-extension/entrypoints/background/tools/browser/performance.ts @@ -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 { + 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 { - 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 { - 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 { - 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: [ diff --git a/app/chrome-extension/entrypoints/background/tools/index.ts b/app/chrome-extension/entrypoints/background/tools/index.ts index 2625338..c11c77b 100644 --- a/app/chrome-extension/entrypoints/background/tools/index.ts +++ b/app/chrome-extension/entrypoints/background/tools/index.ts @@ -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 { + 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); } }; diff --git a/app/chrome-extension/entrypoints/background/tools/tool-action-log.ts b/app/chrome-extension/entrypoints/background/tools/tool-action-log.ts new file mode 100644 index 0000000..aba08b8 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/tool-action-log.ts @@ -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 | null = null; +let writeChain: Promise = 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 { + 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 = {}; + 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 { + 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 | 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 { + try { + await chrome.storage.local.set({ [STORAGE_KEY]: state }); + } catch { + // Ignore storage errors (quota/incognito restrictions) + } +} + +export async function appendToolActionLogEntry( + entry: Omit & { id?: string }, +): Promise { + 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 { + 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 { + 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; +} diff --git a/app/chrome-extension/entrypoints/background/tools/tool-approval.ts b/app/chrome-extension/entrypoints/background/tools/tool-approval.ts new file mode 100644 index 0000000..f675feb --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/tool-approval.ts @@ -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 { + throw new Error('Not implemented'); + } + + tryGetTabPublic(tabId?: number): Promise { + return this.tryGetTab(tabId); + } + + getActiveTabOrThrowInWindowPublic(windowId?: number): Promise { + return this.getActiveTabOrThrowInWindow(windowId); + } + + ensureFocusPublic( + tab: chrome.tabs.Tab, + options: { activate?: boolean; focusWindow?: boolean }, + ): Promise { + return this.ensureFocus(tab, options); + } + + injectContentScriptPublic( + tabId: number, + files: string[], + injectImmediately = false, + world: 'MAIN' | 'ISOLATED' = 'ISOLATED', + ): Promise { + return this.injectContentScript(tabId, files, injectImmediately, world); + } + + sendMessageToTabPublic(tabId: number, message: any, frameId?: number): Promise { + return this.sendMessageToTab(tabId, message, frameId); + } +} + +const helper = new ToolApprovalHelper(); + +// Serialize approvals to avoid multiple overlapping prompts. +let approvalQueue: Promise = Promise.resolve(); + +async function resolveTab(tabId?: number, windowId?: number): Promise { + 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, +): Promise { + 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 { + 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 { + 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((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 | 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 { + const run = async () => requestToolApprovalImpl(req); + const chained = approvalQueue.then(run, run); + approvalQueue = chained.then( + () => undefined, + () => undefined, + ); + return chained; +} diff --git a/app/chrome-extension/entrypoints/background/tools/tool-risk.ts b/app/chrome-extension/entrypoints/background/tools/tool-risk.ts new file mode 100644 index 0000000..60035dd --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/tool-risk.ts @@ -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(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)[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, + }; +} diff --git a/app/chrome-extension/entrypoints/builder/App.vue b/app/chrome-extension/entrypoints/builder/App.vue index 0bfef5c..c3ecdfc 100644 --- a/app/chrome-extension/entrypoints/builder/App.vue +++ b/app/chrome-extension/entrypoints/builder/App.vue @@ -310,6 +310,8 @@ function toggleTheme() { } const store = useBuilderStore(); +const preferredRunTabId = ref(null); + // V3 RPC client const rpc = useRRV3Rpc({ autoConnect: true, @@ -348,6 +350,12 @@ function getQuery(): Record { 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'); } diff --git a/app/chrome-extension/entrypoints/offscreen/main.ts b/app/chrome-extension/entrypoints/offscreen/main.ts index 31aa588..5e43df4 100644 --- a/app/chrome-extension/entrypoints/offscreen/main.ts +++ b/app/chrome-extension/entrypoints/offscreen/main.ts @@ -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: diff --git a/app/chrome-extension/entrypoints/offscreen/web-monitor.ts b/app/chrome-extension/entrypoints/offscreen/web-monitor.ts new file mode 100644 index 0000000..8638c06 --- /dev/null +++ b/app/chrome-extension/entrypoints/offscreen/web-monitor.ts @@ -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 { + 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 { + 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; +} diff --git a/app/chrome-extension/entrypoints/popup/App.vue b/app/chrome-extension/entrypoints/popup/App.vue index 7f53591..56f2c5d 100644 --- a/app/chrome-extension/entrypoints/popup/App.vue +++ b/app/chrome-extension/entrypoints/popup/App.vue @@ -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 }); } diff --git a/app/chrome-extension/entrypoints/sidepanel/App.vue b/app/chrome-extension/entrypoints/sidepanel/App.vue index eb4182a..e75d7b2 100644 --- a/app/chrome-extension/entrypoints/sidepanel/App.vue +++ b/app/chrome-extension/entrypoints/sidepanel/App.vue @@ -27,6 +27,19 @@ @edit-trigger="editTrigger" @remove-trigger="removeTrigger" /> + + @@ -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(null); +const triggerEditorOpen = ref(false); +const triggerEditorMode = ref<'create' | 'edit'>('create'); +const triggerEditorTrigger = ref(null); +const triggerEditorSaving = ref(false); +const triggerEditorError = ref(null); + // Element markers state const currentPageUrl = ref(''); const markers = ref([]); @@ -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 & { 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 }); } diff --git a/app/chrome-extension/entrypoints/sidepanel/components/workflows/TriggerEditorModal.vue b/app/chrome-extension/entrypoints/sidepanel/components/workflows/TriggerEditorModal.vue new file mode 100644 index 0000000..68bcef4 --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/components/workflows/TriggerEditorModal.vue @@ -0,0 +1,881 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/sidepanel/components/workflows/index.ts b/app/chrome-extension/entrypoints/sidepanel/components/workflows/index.ts index b37c75a..7b3e286 100644 --- a/app/chrome-extension/entrypoints/sidepanel/components/workflows/index.ts +++ b/app/chrome-extension/entrypoints/sidepanel/components/workflows/index.ts @@ -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'; diff --git a/app/chrome-extension/entrypoints/sidepanel/composables/useWorkflowsV3.ts b/app/chrome-extension/entrypoints/sidepanel/composables/useWorkflowsV3.ts index 68e174b..1794506 100644 --- a/app/chrome-extension/entrypoints/sidepanel/composables/useWorkflowsV3.ts +++ b/app/chrome-extension/entrypoints/sidepanel/composables/useWorkflowsV3.ts @@ -142,14 +142,19 @@ export interface UseWorkflowsV3Return { refreshFlows: () => Promise; refreshRuns: () => Promise; refreshTriggers: () => Promise; - runFlow: (flowId: string) => Promise<{ runId: string } | null>; + runFlow: (flowId: string, options?: { tabId?: number }) => Promise<{ runId: string } | null>; deleteFlow: (flowId: string) => Promise; exportFlow: (flowId: string) => Promise; deleteTrigger: (triggerId: string) => Promise; + createTrigger: ( + trigger: Omit & { id?: string }, + ) => Promise; + updateTrigger: (trigger: TriggerSpec) => Promise; // V3-specific getFlowById: (flowId: string) => Promise; getRunEvents: (runId: string) => Promise; + getTriggerById: (triggerId: string) => Promise; } /** @@ -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 & { id?: string }, + ): Promise { + 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 { + 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 { try { return (await rpc.request('rr_v3.getFlow', { @@ -293,6 +332,17 @@ export function useWorkflowsV3(options: UseWorkflowsV3Options = {}): UseWorkflow } } + async function getTriggerById(triggerId: string): Promise { + 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, }; } diff --git a/app/chrome-extension/inject-scripts/tool-approval.js b/app/chrome-extension/inject-scripts/tool-approval.js new file mode 100644 index 0000000..0e3783d --- /dev/null +++ b/app/chrome-extension/inject-scripts/tool-approval.js @@ -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 {} + } + }); +})(); diff --git a/app/chrome-extension/shared/quick-panel/core/api-detective-snippets.ts b/app/chrome-extension/shared/quick-panel/core/api-detective-snippets.ts new file mode 100644 index 0000000..413be99 --- /dev/null +++ b/app/chrome-extension/shared/quick-panel/core/api-detective-snippets.ts @@ -0,0 +1,99 @@ +export interface ApiDetectiveRequestForSnippet { + method: string; + url: string; + headers?: Record; + body?: string; +} + +function normalizeString(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function normalizeHeaders(value: unknown): Record { + if (!value || typeof value !== 'object') return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + 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'); +} diff --git a/app/chrome-extension/shared/quick-panel/core/clean-url.ts b/app/chrome-extension/shared/quick-panel/core/clean-url.ts new file mode 100644 index 0000000..a947b88 --- /dev/null +++ b/app/chrome-extension/shared/quick-panel/core/clean-url.ts @@ -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 }; +} diff --git a/app/chrome-extension/shared/quick-panel/core/content-search.ts b/app/chrome-extension/shared/quick-panel/core/content-search.ts new file mode 100644 index 0000000..9553f1f --- /dev/null +++ b/app/chrome-extension/shared/quick-panel/core/content-search.ts @@ -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; +} diff --git a/app/chrome-extension/shared/quick-panel/core/keyboard-controller.ts b/app/chrome-extension/shared/quick-panel/core/keyboard-controller.ts index c33310b..b8100cd 100644 --- a/app/chrome-extension/shared/quick-panel/core/keyboard-controller.ts +++ b/app/chrome-extension/shared/quick-panel/core/keyboard-controller.ts @@ -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; diff --git a/app/chrome-extension/shared/quick-panel/core/text-score.ts b/app/chrome-extension/shared/quick-panel/core/text-score.ts new file mode 100644 index 0000000..490ace9 --- /dev/null +++ b/app/chrome-extension/shared/quick-panel/core/text-score.ts @@ -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; +} diff --git a/app/chrome-extension/shared/quick-panel/core/toolbox/base64.ts b/app/chrome-extension/shared/quick-panel/core/toolbox/base64.ts new file mode 100644 index 0000000..7952d60 --- /dev/null +++ b/app/chrome-extension/shared/quick-panel/core/toolbox/base64.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return base64DecodeUtf8(input); +} diff --git a/app/chrome-extension/shared/quick-panel/core/toolbox/index.ts b/app/chrome-extension/shared/quick-panel/core/toolbox/index.ts new file mode 100644 index 0000000..8f00a87 --- /dev/null +++ b/app/chrome-extension/shared/quick-panel/core/toolbox/index.ts @@ -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'; diff --git a/app/chrome-extension/shared/quick-panel/core/toolbox/json.ts b/app/chrome-extension/shared/quick-panel/core/toolbox/json.ts new file mode 100644 index 0000000..3864268 --- /dev/null +++ b/app/chrome-extension/shared/quick-panel/core/toolbox/json.ts @@ -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 { + 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)); + } +} diff --git a/app/chrome-extension/shared/quick-panel/core/toolbox/jwt.ts b/app/chrome-extension/shared/quick-panel/core/toolbox/jwt.ts new file mode 100644 index 0000000..1fd0666 --- /dev/null +++ b/app/chrome-extension/shared/quick-panel/core/toolbox/jwt.ts @@ -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 { + try { + return ok(JSON.parse(text) as unknown); + } catch (e) { + return err(safeErrorMessage(e)); + } +} + +export function decodeJwt(input: string): ToolboxResult { + 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] ?? '', + }); +} diff --git a/app/chrome-extension/shared/quick-panel/core/toolbox/result.ts b/app/chrome-extension/shared/quick-panel/core/toolbox/result.ts new file mode 100644 index 0000000..19b9fb2 --- /dev/null +++ b/app/chrome-extension/shared/quick-panel/core/toolbox/result.ts @@ -0,0 +1,20 @@ +/** + * Quick Panel Toolbox Result + * + * A small, serializable result type for pure utility functions. + */ + +export type ToolboxResult = { ok: true; value: T } | { ok: false; error: string }; + +export function ok(value: T): ToolboxResult { + return { ok: true, value }; +} + +export function err(error: string): ToolboxResult { + return { ok: false, error }; +} + +export function safeErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message || String(error); + return String(error); +} diff --git a/app/chrome-extension/shared/quick-panel/core/toolbox/timestamp.ts b/app/chrome-extension/shared/quick-panel/core/toolbox/timestamp.ts new file mode 100644 index 0000000..5782495 --- /dev/null +++ b/app/chrome-extension/shared/quick-panel/core/toolbox/timestamp.ts @@ -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 { + 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() }); +} diff --git a/app/chrome-extension/shared/quick-panel/core/toolbox/url.ts b/app/chrome-extension/shared/quick-panel/core/toolbox/url.ts new file mode 100644 index 0000000..61404a5 --- /dev/null +++ b/app/chrome-extension/shared/quick-panel/core/toolbox/url.ts @@ -0,0 +1,25 @@ +/** + * Quick Panel Toolbox - URL Encode/Decode + */ + +import { err, ok, safeErrorMessage, type ToolboxResult } from './result'; + +export function urlEncode(input: string): ToolboxResult { + 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 { + const raw = String(input ?? ''); + if (!raw.trim()) return err('Input is required'); + try { + return ok(decodeURIComponent(raw)); + } catch (e) { + return err(safeErrorMessage(e)); + } +} diff --git a/app/chrome-extension/shared/quick-panel/core/toolbox/uuid.ts b/app/chrome-extension/shared/quick-panel/core/toolbox/uuid.ts new file mode 100644 index 0000000..874fde5 --- /dev/null +++ b/app/chrome-extension/shared/quick-panel/core/toolbox/uuid.ts @@ -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)}`; +} diff --git a/app/chrome-extension/shared/quick-panel/core/types.ts b/app/chrome-extension/shared/quick-panel/core/types.ts index 07f4ced..9881f0d 100644 --- a/app/chrome-extension/shared/quick-panel/core/types.ts +++ b/app/chrome-extension/shared/quick-panel/core/types.ts @@ -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', 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 = { - 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 diff --git a/app/chrome-extension/shared/quick-panel/core/url-template.ts b/app/chrome-extension/shared/quick-panel/core/url-template.ts new file mode 100644 index 0000000..8030b57 --- /dev/null +++ b/app/chrome-extension/shared/quick-panel/core/url-template.ts @@ -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; +} diff --git a/app/chrome-extension/shared/quick-panel/index.ts b/app/chrome-extension/shared/quick-panel/index.ts index baf3bf9..4ee1543 100644 --- a/app/chrome-extension/shared/quick-panel/index.ts +++ b/app/chrome-extension/shared/quick-panel/index.ts @@ -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 | 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(); diff --git a/app/chrome-extension/shared/quick-panel/providers/api-detective-provider.ts b/app/chrome-extension/shared/quick-panel/providers/api-detective-provider.ts new file mode 100644 index 0000000..d8d0dc4 --- /dev/null +++ b/app/chrome-extension/shared/quick-panel/providers/api-detective-provider.ts @@ -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; + start: (options: { needResponseBody: boolean }) => Promise; + stop: () => Promise; + list: (options: { + query?: string; + maxResults: number; + signal: AbortSignal; + }) => Promise; + getRequest: (options: { requestId: string }) => Promise; + replay: (options: { requestId: string }) => Promise; +} + +// ============================================================ +// 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(type: string, payload: unknown): Promise { + 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( + BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_STATUS, + {}, + ), + start: (options) => + send( + BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_START, + { + needResponseBody: options.needResponseBody, + includeStatic: false, + maxCaptureTimeMs: 180_000, + }, + ), + stop: () => + send( + BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_STOP, + {}, + ), + list: (options) => + options.signal.aborted + ? Promise.reject(new Error('aborted')) + : send( + BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_LIST, + { + query: options.query || undefined, + maxResults: options.maxResults, + }, + ), + getRequest: (options) => + send( + BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_GET_REQUEST, + { requestId: options.requestId }, + ), + replay: (options) => + send( + BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_REPLAY_REQUEST, + { requestId: options.requestId, timeoutMs: 30_000 }, + ), + }; +} + +// ============================================================ +// Provider Factory +// ============================================================ + +export function createApiDetectiveProvider(): SearchProvider { + const id = 'api_detective'; + const name = 'API Detective'; + const icon = '\uD83D\uDD75\uFE0F'; // 🕵️ + + const client = createRuntimeClient(); + + function getActions( + item: SearchResult, + ): Action[] { + 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[]> { + 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[] = []; + + 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, + }; +} diff --git a/app/chrome-extension/shared/quick-panel/providers/audit-provider.ts b/app/chrome-extension/shared/quick-panel/providers/audit-provider.ts new file mode 100644 index 0000000..6946322 --- /dev/null +++ b/app/chrome-extension/shared/quick-panel/providers/audit-provider.ts @@ -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; + clear: () => Promise; +} + +// ============================================================ +// 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(type: string, payload: unknown): Promise { + 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 { + if (options.signal.aborted) throw new Error('aborted'); + + const resp = await send( + 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 { + const resp = await send( + 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 { + const id = 'audit'; + const name = 'Audit'; + const icon = '\uD83E\uDDFE'; // 🧾 + + const client = createRuntimeClient(); + + function getActions(item: SearchResult): Action[] { + 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 = { + 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 = { + 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 = { + 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[]> { + if (ctx.signal.aborted) return []; + if (ctx.requestedScope !== 'audit') return []; + + const q = ctx.query.text; + const tokens = ctx.query.tokens; + + const results: SearchResult[] = []; + + // 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, + }; +} diff --git a/app/chrome-extension/shared/quick-panel/providers/bookmarks-provider.ts b/app/chrome-extension/shared/quick-panel/providers/bookmarks-provider.ts index c488b2a..d2abfe0 100644 --- a/app/chrome-extension/shared/quick-panel/providers/bookmarks-provider.ts +++ b/app/chrome-extension/shared/quick-panel/providers/bookmarks-provider.ts @@ -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; - openUrl: (options: { - url: string; - disposition: 'current_tab' | 'new_tab' | 'background_tab'; - }) => Promise; + removeBookmark: (bookmarkId: string) => Promise; } 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 { + async function removeBookmark(bookmarkId: string): Promise { 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, ): Action[] { - 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 { - 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 { - await writeToClipboard(url); + await writeToClipboard(url, { source: 'bookmarks.copy.url', label: title }); }, }, // Copy as Markdown link @@ -191,7 +196,19 @@ export function createBookmarksProvider(): SearchProvider { - 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); }, }, ]; diff --git a/app/chrome-extension/shared/quick-panel/providers/clipboard-provider.ts b/app/chrome-extension/shared/quick-panel/providers/clipboard-provider.ts new file mode 100644 index 0000000..4d436e9 --- /dev/null +++ b/app/chrome-extension/shared/quick-panel/providers/clipboard-provider.ts @@ -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; + get: (options: { id: string }) => Promise<{ value: string | null; stored: boolean }>; + setPinned: (options: { id: string; pinned: boolean }) => Promise; + delete: (options: { id: string }) => Promise; +} + +// ============================================================ +// 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(type: string, payload: unknown): Promise { + 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 { + if (options.signal.aborted) throw new Error('aborted'); + + const resp = await send( + 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( + 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 { + const id = String(options.id ?? '').trim(); + if (!id) throw new Error('id is required'); + + const resp = await send( + 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 { + const id = String(options.id ?? '').trim(); + if (!id) throw new Error('id is required'); + + const resp = await send( + 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 { + const id = 'clipboard'; + const name = 'Clipboard'; + const icon = '\uD83D\uDCCB'; // 📋 + + const client = createRuntimeClient(); + + function getActions(item: SearchResult): Action[] { + const data = item.data; + if (data.kind !== 'clipboard') return []; + + const primaryCopy: Action = { + 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 = { + id: 'clipboard.togglePin', + title: data.pinned ? 'Unpin' : 'Pin', + execute: async () => { + await client.setPinned({ id: data.id, pinned: !data.pinned }); + }, + }; + + const deleteAction: Action = { + 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[]> { + 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; 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 + }, + }; +} diff --git a/app/chrome-extension/shared/quick-panel/providers/commands-provider.ts b/app/chrome-extension/shared/quick-panel/providers/commands-provider.ts index 0eebd38..b5dc86a 100644 --- a/app/chrome-extension/shared/quick-panel/providers/commands-provider.ts +++ b/app/chrome-extension/shared/quick-panel/providers/commands-provider.ts @@ -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 { + 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 { + 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[] { + 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 { + const anyDoc = document as unknown as { + pictureInPictureElement?: Element | null; + exitPictureInPicture?: () => Promise; + }; + + 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, ): Action[] { - 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 { const score = computeCommandScore(def, ctx.query.tokens); @@ -304,7 +1091,7 @@ export function createCommandsProvider(): SearchProvider { + const commandResults = scored.map(({ def, score }) => { const data: CommandsSearchResultData = { commandId: def.id, }; @@ -319,6 +1106,10 @@ export function createCommandsProvider(): SearchProvider b.score - a.score); + return combined.slice(0, ctx.limit); } return { diff --git a/app/chrome-extension/shared/quick-panel/providers/content-provider.ts b/app/chrome-extension/shared/quick-panel/providers/content-provider.ts new file mode 100644 index 0000000..50924c5 --- /dev/null +++ b/app/chrome-extension/shared/quick-panel/providers/content-provider.ts @@ -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; + activateTab: (tabId: number, windowId?: number) => Promise; + closeTab: (tabId: number) => Promise; +} + +function createRuntimeContentClient(): ContentClient { + async function query(options: { + query: string; + maxResults: number; + signal: AbortSignal; + }): Promise { + 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 { + 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 { + 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 { + const id = 'content'; + const name = 'Content'; + const icon = '\uD83D\uDCC4'; // 📄 + + const client = createRuntimeContentClient(); + + function getActions( + item: SearchResult, + ): Action[] { + 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[]> { + 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, + }; +} diff --git a/app/chrome-extension/shared/quick-panel/providers/focus-provider.ts b/app/chrome-extension/shared/quick-panel/providers/focus-provider.ts new file mode 100644 index 0000000..8940a28 --- /dev/null +++ b/app/chrome-extension/shared/quick-panel/providers/focus-provider.ts @@ -0,0 +1,1068 @@ +/** + * Focus Provider (Quick Panel) + * + * Scope: + * - Prefix-only scope: `focus ` + * - Not included in 'all' scope (noise + privacy) + * + * Supports: + * - Start / stop / pause / resume / extend focus sessions (via background bridge) + * - Optional site blocking configuration (blocklist + toggle) + */ + +import { + BACKGROUND_MESSAGE_TYPES, + type QuickPanelFocusExtendResponse, + type QuickPanelFocusPauseResponse, + type QuickPanelFocusResumeBlockingResponse, + type QuickPanelFocusSession, + type QuickPanelFocusSnoozeBlockingResponse, + type QuickPanelFocusSetBlocklistResponse, + type QuickPanelFocusSetBlockingEnabledResponse, + type QuickPanelFocusResumeResponse, + type QuickPanelFocusStartResponse, + type QuickPanelFocusStatus, + type QuickPanelFocusStatusResponse, + type QuickPanelFocusStopResponse, +} from '@/common/message-types'; +import type { Action, SearchProvider, SearchProviderContext, SearchResult } from '../core/types'; +import { computeWeightedTokenScore, writeToClipboard } from './provider-utils'; + +// ============================================================ +// Types +// ============================================================ + +export type FocusResultData = + | { kind: 'status'; status: QuickPanelFocusStatus } + | { kind: 'start'; minutes: number } + | { kind: 'stop' } + | { kind: 'pause' } + | { kind: 'resume' } + | { kind: 'extend'; minutes: number } + | { kind: 'snooze_blocking'; minutes: number } + | { kind: 'resume_blocking' } + | { kind: 'set_blocklist'; domains: string[] } + | { kind: 'set_blocking_enabled'; enabled: boolean } + | { kind: 'blocklist_domain'; domain: string; blocklist: string[] } + | { kind: 'help' }; + +interface FocusClient { + status: (options: { signal: AbortSignal }) => Promise; + start: (options: { minutes: number }) => Promise; + stop: () => Promise; + pause: () => Promise; + resume: () => Promise; + extend: (options: { minutes: number }) => Promise; + snoozeBlocking: (options: { minutes: number }) => Promise; + resumeBlocking: () => Promise; + setBlocklist: (options: { domains: string[] }) => Promise; + setBlockingEnabled: (options: { enabled: boolean }) => Promise; +} + +// ============================================================ +// Helpers +// ============================================================ + +function parseMinutesToken(token: string | undefined): number | null { + const raw = String(token ?? '') + .trim() + .toLowerCase(); + if (!raw) return null; + const m = raw.match(/^(\d+)(m)?$/); + if (!m) return null; + const n = Number(m[1]); + if (!Number.isFinite(n) || n <= 0) return null; + return Math.floor(n); +} + +function normalizeDomainToken(token: string): string | null { + const raw = String(token ?? '') + .trim() + .toLowerCase(); + if (!raw) return null; + + // Split "a.com,b.com" into tokens (provider-side convenience; background is source of truth). + const first = raw + .split(',') + .map((s) => s.trim()) + .find(Boolean); + if (!first) return null; + + let host = first; + try { + if (first.includes('://')) host = new URL(first).hostname.toLowerCase(); + } catch { + // ignore + } + + host = host.replace(/^\.+/, '').trim(); + if (!host) return null; + if (!/^[a-z0-9.-]+$/.test(host)) return null; + if (!host.includes('.')) return null; + if (host.includes('..')) return null; + return host; +} + +function formatDuration(ms: number): string { + const n = Math.max(0, Math.floor(ms)); + const totalSeconds = Math.floor(n / 1000); + const s = totalSeconds % 60; + const totalMinutes = Math.floor(totalSeconds / 60); + const m = totalMinutes % 60; + const h = Math.floor(totalMinutes / 60); + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; +} + +function formatSessionTitle(session: QuickPanelFocusSession): string { + if (session.phase === 'running') return `Focus: ${formatDuration(session.remainingMs)} remaining`; + if (session.phase === 'paused') + return `Focus paused: ${formatDuration(session.remainingMs)} remaining`; + return 'Focus: idle'; +} + +function buildStatusSubtitle(status: QuickPanelFocusStatus): string { + const parts: string[] = []; + const snoozedMs = Math.max(0, (status.blockingSnoozedUntil || 0) - (status.now || 0)); + const isSnoozed = snoozedMs > 0; + + if (status.blockingEnabled && status.blocklist.length > 0) { + if (isSnoozed) { + parts.push(`Blocking: snoozed ${formatDuration(snoozedMs)}`); + } else { + parts.push(status.blockingActive ? 'Blocking: on' : 'Blocking: off'); + } + parts.push(`${status.blocklist.length} domains`); + } else if (status.blockingEnabled) { + parts.push('Blocking: on (no domains)'); + } else { + parts.push('Blocking: off'); + } + if (status.incognito) parts.push('Incognito'); + return parts.join(' \u00B7 '); +} + +function formatStatusForClipboard(status: QuickPanelFocusStatus): string { + const s = status.session; + if (s.phase === 'running') { + return `Focus running (${formatDuration(s.remainingMs)} remaining)`; + } + if (s.phase === 'paused') { + return `Focus paused (${formatDuration(s.remainingMs)} remaining)`; + } + return 'Focus idle'; +} + +function createRuntimeClient(): FocusClient { + async function send(type: string, payload: unknown): Promise { + 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 status(options: { signal: AbortSignal }): Promise { + if (options.signal.aborted) throw new Error('aborted'); + + const resp = await send( + BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_STATUS, + {}, + ); + + if (!resp || resp.success !== true) { + const err = (resp as { error?: unknown })?.error; + throw new Error(typeof err === 'string' ? err : 'Failed to get focus status'); + } + + return resp.status; + } + + async function start(options: { minutes: number }): Promise { + const minutes = Math.floor(Number(options.minutes)); + if (!Number.isFinite(minutes) || minutes <= 0) throw new Error('minutes must be positive'); + + const resp = await send( + BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_START, + { durationMinutes: minutes }, + ); + + if (!resp || resp.success !== true) { + const err = (resp as { error?: unknown })?.error; + throw new Error(typeof err === 'string' ? err : 'Failed to start focus'); + } + + return resp.status; + } + + async function stop(): Promise { + const resp = await send( + BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_STOP, + {}, + ); + + if (!resp || resp.success !== true) { + const err = (resp as { error?: unknown })?.error; + throw new Error(typeof err === 'string' ? err : 'Failed to stop focus'); + } + + return resp.status; + } + + async function pause(): Promise { + const resp = await send( + BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_PAUSE, + {}, + ); + + if (!resp || resp.success !== true) { + const err = (resp as { error?: unknown })?.error; + throw new Error(typeof err === 'string' ? err : 'Failed to pause focus'); + } + + return resp.status; + } + + async function resume(): Promise { + const resp = await send( + BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_RESUME, + {}, + ); + + if (!resp || resp.success !== true) { + const err = (resp as { error?: unknown })?.error; + throw new Error(typeof err === 'string' ? err : 'Failed to resume focus'); + } + + return resp.status; + } + + async function extend(options: { minutes: number }): Promise { + const minutes = Math.floor(Number(options.minutes)); + if (!Number.isFinite(minutes) || minutes <= 0) throw new Error('minutes must be positive'); + + const resp = await send( + BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_EXTEND, + { minutes }, + ); + + if (!resp || resp.success !== true) { + const err = (resp as { error?: unknown })?.error; + throw new Error(typeof err === 'string' ? err : 'Failed to extend focus'); + } + + return resp.status; + } + + async function snoozeBlocking(options: { minutes: number }): Promise { + const minutes = Math.floor(Number(options.minutes)); + if (!Number.isFinite(minutes) || minutes <= 0) throw new Error('minutes must be positive'); + + const resp = await send( + BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_SNOOZE_BLOCKING, + { minutes }, + ); + + if (!resp || resp.success !== true) { + const err = (resp as { error?: unknown })?.error; + throw new Error(typeof err === 'string' ? err : 'Failed to snooze blocking'); + } + + return resp.status; + } + + async function resumeBlocking(): Promise { + const resp = await send( + BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_RESUME_BLOCKING, + {}, + ); + + if (!resp || resp.success !== true) { + const err = (resp as { error?: unknown })?.error; + throw new Error(typeof err === 'string' ? err : 'Failed to resume blocking'); + } + + return resp.status; + } + + async function setBlocklist(options: { domains: string[] }): Promise { + const domains = Array.isArray(options.domains) ? options.domains : []; + + const resp = await send( + BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_SET_BLOCKLIST, + { domains }, + ); + + if (!resp || resp.success !== true) { + const err = (resp as { error?: unknown })?.error; + throw new Error(typeof err === 'string' ? err : 'Failed to update blocklist'); + } + + return resp.status; + } + + async function setBlockingEnabled(options: { enabled: boolean }): Promise { + const resp = await send( + BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_SET_BLOCKING_ENABLED, + { enabled: options.enabled === true }, + ); + + if (!resp || resp.success !== true) { + const err = (resp as { error?: unknown })?.error; + throw new Error(typeof err === 'string' ? err : 'Failed to update blocking setting'); + } + + return resp.status; + } + + return { + status, + start, + stop, + pause, + resume, + extend, + snoozeBlocking, + resumeBlocking, + setBlocklist, + setBlockingEnabled, + }; +} + +// ============================================================ +// Provider Factory +// ============================================================ + +export function createFocusProvider(): SearchProvider { + const id = 'focus'; + const name = 'Focus'; + const icon = '\uD83C\uDF45'; // 🍅 + + const client = createRuntimeClient(); + + function getActions(item: SearchResult): Action[] { + const data = item.data; + + if (data.kind === 'status') { + const phase = data.status.session.phase; + const actions: Action[] = [ + { + id: 'focus.copyStatus', + title: 'Copy status', + hotkeyHint: 'Enter', + execute: async () => { + const text = formatStatusForClipboard(data.status); + await writeToClipboard(text, { source: 'focus.status.copy', label: 'Focus' }); + }, + }, + ]; + + if (phase === 'running') { + actions.push( + { + id: 'focus.pause', + title: 'Pause', + execute: async () => { + await client.pause(); + }, + }, + { + id: 'focus.extend5', + title: 'Extend +5m', + execute: async () => { + await client.extend({ minutes: 5 }); + }, + }, + { + id: 'focus.stop', + title: 'Stop', + tone: 'danger', + execute: async () => { + await client.stop(); + }, + }, + ); + } else if (phase === 'paused') { + actions.push( + { + id: 'focus.resume', + title: 'Resume', + execute: async () => { + await client.resume(); + }, + }, + { + id: 'focus.extend5', + title: 'Extend +5m', + execute: async () => { + await client.extend({ minutes: 5 }); + }, + }, + { + id: 'focus.stop', + title: 'Stop', + tone: 'danger', + execute: async () => { + await client.stop(); + }, + }, + ); + } else { + actions.push({ + id: 'focus.start25', + title: 'Start 25m', + execute: async () => { + await client.start({ minutes: 25 }); + }, + }); + } + + actions.push({ + id: 'focus.toggleBlocking', + title: data.status.blockingEnabled ? 'Disable blocking' : 'Enable blocking', + execute: async () => { + await client.setBlockingEnabled({ enabled: !data.status.blockingEnabled }); + }, + }); + + const snoozedMs = Math.max( + 0, + (data.status.blockingSnoozedUntil || 0) - (data.status.now || 0), + ); + const isSnoozed = snoozedMs > 0; + const supportsSnooze = data.status.blockingEnabled && data.status.blocklist.length > 0; + + if (supportsSnooze && isSnoozed) { + actions.push( + { + id: 'focus.blocking.resume', + title: 'Resume blocking now', + execute: async () => { + await client.resumeBlocking(); + }, + }, + { + id: 'focus.blocking.snooze5', + title: 'Extend blocking snooze +5m', + execute: async () => { + await client.snoozeBlocking({ minutes: 5 }); + }, + }, + ); + } else if (supportsSnooze && phase === 'running' && data.status.blockingActive) { + actions.push( + { + id: 'focus.blocking.snooze5', + title: 'Snooze blocking 5m', + execute: async () => { + await client.snoozeBlocking({ minutes: 5 }); + }, + }, + { + id: 'focus.blocking.snooze15', + title: 'Snooze blocking 15m', + execute: async () => { + await client.snoozeBlocking({ minutes: 15 }); + }, + }, + ); + } + + return actions; + } + + if (data.kind === 'start') { + return [ + { + id: 'focus.start', + title: `Start ${data.minutes}m`, + hotkeyHint: 'Enter', + execute: async () => { + await client.start({ minutes: data.minutes }); + }, + }, + ]; + } + + if (data.kind === 'pause') { + return [ + { + id: 'focus.pause', + title: 'Pause focus', + hotkeyHint: 'Enter', + execute: async () => { + await client.pause(); + }, + }, + ]; + } + + if (data.kind === 'resume') { + return [ + { + id: 'focus.resume', + title: 'Resume focus', + hotkeyHint: 'Enter', + execute: async () => { + await client.resume(); + }, + }, + ]; + } + + if (data.kind === 'extend') { + return [ + { + id: 'focus.extend', + title: `Extend +${data.minutes}m`, + hotkeyHint: 'Enter', + execute: async () => { + await client.extend({ minutes: data.minutes }); + }, + }, + ]; + } + + if (data.kind === 'snooze_blocking') { + return [ + { + id: 'focus.blocking.snooze', + title: `Snooze blocking ${data.minutes}m`, + hotkeyHint: 'Enter', + execute: async () => { + await client.snoozeBlocking({ minutes: data.minutes }); + }, + }, + ]; + } + + if (data.kind === 'resume_blocking') { + return [ + { + id: 'focus.blocking.resume', + title: 'Resume blocking now', + hotkeyHint: 'Enter', + execute: async () => { + await client.resumeBlocking(); + }, + }, + ]; + } + + if (data.kind === 'stop') { + return [ + { + id: 'focus.stop', + title: 'Stop focus', + tone: 'danger', + hotkeyHint: 'Enter', + execute: async () => { + await client.stop(); + }, + }, + ]; + } + + if (data.kind === 'set_blocklist') { + return [ + { + id: 'focus.setBlocklist', + title: 'Set blocklist', + hotkeyHint: 'Enter', + execute: async () => { + await client.setBlocklist({ domains: data.domains }); + }, + }, + ]; + } + + if (data.kind === 'set_blocking_enabled') { + return [ + { + id: 'focus.setBlockingEnabled', + title: data.enabled ? 'Enable blocking' : 'Disable blocking', + hotkeyHint: 'Enter', + execute: async () => { + await client.setBlockingEnabled({ enabled: data.enabled }); + }, + }, + ]; + } + + if (data.kind === 'blocklist_domain') { + return [ + { + id: 'focus.blocklist.remove', + title: 'Remove domain', + tone: 'danger', + execute: async () => { + const next = data.blocklist.filter((d) => d !== data.domain); + await client.setBlocklist({ domains: next }); + }, + }, + { + id: 'focus.blocklist.copy', + title: 'Copy domain', + execute: async () => { + await writeToClipboard(data.domain, { + source: 'focus.blocklist.copy', + label: 'Focus domain', + }); + }, + }, + ]; + } + + return []; + } + + function buildDefaultResults(status: QuickPanelFocusStatus): SearchResult[] { + const results: SearchResult[] = []; + const snoozedMs = Math.max(0, (status.blockingSnoozedUntil || 0) - (status.now || 0)); + const isSnoozed = snoozedMs > 0; + const supportsSnooze = status.blockingEnabled && status.blocklist.length > 0; + + results.push({ + id: 'focus.status', + provider: id, + title: formatSessionTitle(status.session), + subtitle: buildStatusSubtitle(status), + icon, + data: { kind: 'status', status }, + score: 1000, + }); + + const phase = status.session.phase; + if (phase === 'idle') { + const presets = [25, 45, 60]; + for (let i = 0; i < presets.length; i++) { + const m = presets[i]; + results.push({ + id: `focus.start.${m}`, + provider: id, + title: `Start focus: ${m}m`, + subtitle: 'Starts a timer via background alarms', + icon: '\u25B6\uFE0F', // ▶️ + data: { kind: 'start', minutes: m }, + score: 900 - i, + }); + } + } else if (phase === 'running') { + results.push( + { + id: 'focus.pause', + provider: id, + title: 'Pause focus', + subtitle: 'Pauses timer and disables blocking until resumed', + icon: '\u23F8\uFE0F', // ⏸️ + data: { kind: 'pause' }, + score: 900, + }, + { + id: 'focus.extend.5', + provider: id, + title: 'Extend focus: +5m', + subtitle: 'Adds 5 minutes to the current session', + icon: '\u2795', // ➕ + data: { kind: 'extend', minutes: 5 }, + score: 850, + }, + { + id: 'focus.stop', + provider: id, + title: 'Stop focus', + subtitle: 'Stops timer and clears blocking rules', + icon: '\u23F9\uFE0F', // ⏹️ + data: { kind: 'stop' }, + score: 800, + }, + ); + + if (supportsSnooze && isSnoozed) { + results.push( + { + id: 'focus.blocking.resume', + provider: id, + title: 'Resume site blocking now', + subtitle: `Snoozed ${formatDuration(snoozedMs)} remaining`, + icon: '\uD83D\uDEE1\uFE0F', // 🛡️ + data: { kind: 'resume_blocking' }, + score: 790, + }, + { + id: 'focus.blocking.snooze.5', + provider: id, + title: 'Extend blocking snooze: +5m', + subtitle: 'Adds 5 minutes to the temporary unblock', + icon: '\u2795', // ➕ + data: { kind: 'snooze_blocking', minutes: 5 }, + score: 780, + }, + ); + } else if (supportsSnooze && status.blockingActive) { + results.push( + { + id: 'focus.blocking.snooze.5', + provider: id, + title: 'Snooze site blocking: 5m', + subtitle: 'Temporarily allow blocked domains', + icon: '\u23F8\uFE0F', // ⏸️ + data: { kind: 'snooze_blocking', minutes: 5 }, + score: 790, + }, + { + id: 'focus.blocking.snooze.15', + provider: id, + title: 'Snooze site blocking: 15m', + subtitle: 'Temporarily allow blocked domains', + icon: '\u23F8\uFE0F', // ⏸️ + data: { kind: 'snooze_blocking', minutes: 15 }, + score: 780, + }, + ); + } + } else if (phase === 'paused') { + results.push( + { + id: 'focus.resume', + provider: id, + title: 'Resume focus', + subtitle: 'Resumes the paused timer', + icon: '\u25B6\uFE0F', // ▶️ + data: { kind: 'resume' }, + score: 900, + }, + { + id: 'focus.extend.5', + provider: id, + title: 'Extend focus: +5m', + subtitle: 'Adds 5 minutes (while paused)', + icon: '\u2795', // ➕ + data: { kind: 'extend', minutes: 5 }, + score: 850, + }, + { + id: 'focus.stop', + provider: id, + title: 'Stop focus', + subtitle: 'Stops the paused session', + icon: '\u23F9\uFE0F', // ⏹️ + data: { kind: 'stop' }, + score: 800, + }, + ); + } + + // Blocking toggle shortcut. + results.push({ + id: 'focus.blocking.toggle', + provider: id, + title: status.blockingEnabled ? 'Disable site blocking' : 'Enable site blocking', + subtitle: + status.blocklist.length > 0 + ? `${status.blocklist.length} domains configured` + : 'No domains configured', + icon: '\uD83D\uDEAB', // 🚫 + data: { kind: 'set_blocking_enabled', enabled: !status.blockingEnabled }, + score: 700, + }); + + // Blocklist items (best-effort). + for (const domain of status.blocklist.slice(0, 10)) { + results.push({ + id: `focus.block.${domain}`, + provider: id, + title: domain, + subtitle: 'Blocked domain', + icon: '\uD83C\uDF10', // 🌐 + data: { kind: 'blocklist_domain', domain, blocklist: status.blocklist }, + score: 600, + }); + } + + // Lightweight help hint. + results.push({ + id: 'focus.help', + provider: id, + title: + 'Commands: start 25 | pause | resume | stop | extend 5 | snooze 5 | resume-blocking | block youtube.com twitter.com | blocking on/off', + subtitle: 'Type after `focus `', + icon: '\u2139\uFE0F', // ℹ️ + data: { kind: 'help' }, + score: 100, + }); + + return results; + } + + async function search(ctx: SearchProviderContext): Promise[]> { + if (ctx.signal.aborted) return []; + if (ctx.requestedScope !== 'focus') return []; + + const status = await client.status({ signal: ctx.signal }); + if (ctx.signal.aborted) return []; + + const tokens = ctx.query.tokens; + if (tokens.length === 0) { + return buildDefaultResults(status).slice(0, ctx.limit); + } + + const [t0, ...rest] = tokens; + const results: SearchResult[] = []; + + const directMinutes = parseMinutesToken(t0); + if (directMinutes) { + results.push({ + id: `focus.start.${directMinutes}`, + provider: id, + title: `Start focus: ${directMinutes}m`, + subtitle: 'Starts a timer via background alarms', + icon: '\u25B6\uFE0F', + data: { kind: 'start', minutes: directMinutes }, + score: 1200, + }); + results.push({ + id: 'focus.status', + provider: id, + title: formatSessionTitle(status.session), + subtitle: buildStatusSubtitle(status), + icon, + data: { kind: 'status', status }, + score: 1000, + }); + return results.slice(0, ctx.limit); + } + + const cmd = String(t0 ?? '').toLowerCase(); + + if (cmd === 'start') { + const minutes = parseMinutesToken(rest[0]) ?? 25; + results.push({ + id: `focus.start.${minutes}`, + provider: id, + title: `Start focus: ${minutes}m`, + subtitle: 'Starts a timer via background alarms', + icon: '\u25B6\uFE0F', + data: { kind: 'start', minutes }, + score: 1200, + }); + return results.slice(0, ctx.limit); + } + + if (cmd === 'stop') { + results.push({ + id: 'focus.stop', + provider: id, + title: 'Stop focus', + subtitle: 'Stops timer and clears blocking rules', + icon: '\u23F9\uFE0F', + data: { kind: 'stop' }, + score: 1200, + }); + return results.slice(0, ctx.limit); + } + + if (cmd === 'pause') { + results.push({ + id: 'focus.pause', + provider: id, + title: 'Pause focus', + subtitle: 'Pauses timer and disables blocking until resumed', + icon: '\u23F8\uFE0F', + data: { kind: 'pause' }, + score: 1200, + }); + return results.slice(0, ctx.limit); + } + + if (cmd === 'resume' || cmd === 'continue') { + results.push({ + id: 'focus.resume', + provider: id, + title: 'Resume focus', + subtitle: 'Resumes the paused timer', + icon: '\u25B6\uFE0F', + data: { kind: 'resume' }, + score: 1200, + }); + return results.slice(0, ctx.limit); + } + + if (cmd === 'extend') { + const minutes = parseMinutesToken(rest[0]) ?? 5; + results.push({ + id: `focus.extend.${minutes}`, + provider: id, + title: `Extend focus: +${minutes}m`, + subtitle: 'Adds time to the current session', + icon: '\u2795', + data: { kind: 'extend', minutes }, + score: 1200, + }); + return results.slice(0, ctx.limit); + } + + if (cmd === 'snooze') { + const minutes = parseMinutesToken(rest[0]) ?? 5; + results.push({ + id: `focus.blocking.snooze.${minutes}`, + provider: id, + title: `Snooze site blocking: ${minutes}m`, + subtitle: 'Temporarily allow blocked domains', + icon: '\u23F8\uFE0F', // ⏸️ + data: { kind: 'snooze_blocking', minutes }, + score: 1200, + }); + return results.slice(0, ctx.limit); + } + + if (cmd === 'resume-blocking' || cmd === 'unsnooze') { + results.push({ + id: 'focus.blocking.resume', + provider: id, + title: 'Resume site blocking now', + subtitle: 'Re-enables blocking immediately', + icon: '\uD83D\uDEE1\uFE0F', // 🛡️ + data: { kind: 'resume_blocking' }, + score: 1200, + }); + return results.slice(0, ctx.limit); + } + + if (cmd === 'blocking') { + const next = String(rest[0] ?? '').toLowerCase(); + if (next === 'snooze' || next === 'pause') { + const minutes = parseMinutesToken(rest[1]) ?? 5; + results.push({ + id: `focus.blocking.snooze.${minutes}`, + provider: id, + title: `Snooze site blocking: ${minutes}m`, + subtitle: 'Temporarily allow blocked domains', + icon: '\u23F8\uFE0F', // ⏸️ + data: { kind: 'snooze_blocking', minutes }, + score: 1200, + }); + return results.slice(0, ctx.limit); + } + if (next === 'resume' || next === 'now') { + results.push({ + id: 'focus.blocking.resume', + provider: id, + title: 'Resume site blocking now', + subtitle: 'Re-enables blocking immediately', + icon: '\uD83D\uDEE1\uFE0F', // 🛡️ + data: { kind: 'resume_blocking' }, + score: 1200, + }); + return results.slice(0, ctx.limit); + } + if (next === 'on' || next === 'true' || next === '1') { + results.push({ + id: 'focus.blocking.on', + provider: id, + title: 'Enable site blocking', + subtitle: 'Applies blocklist during running sessions', + icon: '\uD83D\uDEAB', + data: { kind: 'set_blocking_enabled', enabled: true }, + score: 1200, + }); + return results.slice(0, ctx.limit); + } + if (next === 'off' || next === 'false' || next === '0') { + results.push({ + id: 'focus.blocking.off', + provider: id, + title: 'Disable site blocking', + subtitle: 'Does not apply blocklist rules', + icon: '\uD83D\uDEAB', + data: { kind: 'set_blocking_enabled', enabled: false }, + score: 1200, + }); + return results.slice(0, ctx.limit); + } + + results.push({ + id: 'focus.blocking.toggle', + provider: id, + title: status.blockingEnabled ? 'Disable site blocking' : 'Enable site blocking', + subtitle: 'Use: blocking on/off | blocking snooze 5 | blocking resume', + icon: '\uD83D\uDEAB', + data: { kind: 'set_blocking_enabled', enabled: !status.blockingEnabled }, + score: 1100, + }); + return results.slice(0, ctx.limit); + } + + if (cmd === 'block' || cmd === 'blocklist') { + const next = String(rest[0] ?? '').toLowerCase(); + if (next === 'clear' || next === 'off' || next === 'none') { + results.push({ + id: 'focus.blocklist.clear', + provider: id, + title: 'Clear blocklist', + subtitle: 'Removes all blocked domains', + icon: '\u274C', // ❌ + data: { kind: 'set_blocklist', domains: [] }, + score: 1200, + }); + return results.slice(0, ctx.limit); + } + + const domains = rest + .flatMap((t) => String(t ?? '').split(',')) + .map((t) => normalizeDomainToken(t)) + .filter((d): d is string => typeof d === 'string' && !!d); + + if (domains.length > 0) { + results.push({ + id: 'focus.blocklist.set', + provider: id, + title: `Set blocklist: ${domains.join(', ')}`, + subtitle: 'Updates stored blocklist (best-effort validation)', + icon: '\uD83D\uDEAB', + data: { kind: 'set_blocklist', domains }, + score: 1200, + }); + return results.slice(0, ctx.limit); + } + + // No args -> show status + current blocklist + return buildDefaultResults(status).slice(0, ctx.limit); + } + + // Fallback: show status + help (token scoring keeps it relevant) + const helpScore = computeWeightedTokenScore([{ value: cmd, weight: 1, mode: 'text' }], tokens); + results.push({ + id: 'focus.help', + provider: id, + title: 'Unknown focus command', + subtitle: + 'Try: start 25 | pause | resume | stop | extend 5 | snooze 5 | resume-blocking | block youtube.com twitter.com | blocking on/off', + icon: '\u2139\uFE0F', + data: { kind: 'help' }, + score: 500 + helpScore, + }); + results.push({ + id: 'focus.status', + provider: id, + title: formatSessionTitle(status.session), + subtitle: buildStatusSubtitle(status), + icon, + data: { kind: 'status', status }, + score: 400, + }); + + return results.slice(0, ctx.limit); + } + + return { + id, + name, + icon, + scopes: ['focus'], + includeInAll: false, + supportsEmptyQuery: true, + search, + getActions, + }; +} diff --git a/app/chrome-extension/shared/quick-panel/providers/history-provider.ts b/app/chrome-extension/shared/quick-panel/providers/history-provider.ts index 0c80c99..9f5ca99 100644 --- a/app/chrome-extension/shared/quick-panel/providers/history-provider.ts +++ b/app/chrome-extension/shared/quick-panel/providers/history-provider.ts @@ -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; - openUrl: (options: { - url: string; - disposition: 'current_tab' | 'new_tab' | 'background_tab'; - }) => Promise; + deleteUrl: (url: string) => Promise; } 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 { + async function deleteUrl(url: string): Promise { 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 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 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 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); }, }, ]; diff --git a/app/chrome-extension/shared/quick-panel/providers/index.ts b/app/chrome-extension/shared/quick-panel/providers/index.ts index 0f2bb5b..80092d1 100644 --- a/app/chrome-extension/shared/quick-panel/providers/index.ts +++ b/app/chrome-extension/shared/quick-panel/providers/index.ts @@ -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'; diff --git a/app/chrome-extension/shared/quick-panel/providers/monitor-provider.ts b/app/chrome-extension/shared/quick-panel/providers/monitor-provider.ts new file mode 100644 index 0000000..31b75ac --- /dev/null +++ b/app/chrome-extension/shared/quick-panel/providers/monitor-provider.ts @@ -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; + delete: (options: { id: string }) => Promise; + setEnabled: (options: { id: string; enabled: boolean }) => Promise; + checkNow: (options: { + id: string; + }) => Promise<{ monitor: QuickPanelMonitorSummary; alertCreated?: QuickPanelMonitorAlert }>; + alertMarkRead: (options: { id: string; read: boolean }) => Promise; + alertDelete: (options: { id: string }) => Promise; +} + +// ============================================================ +// 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(type: string, payload: unknown): Promise { + 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( + 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 { + const resp = await send( + 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 { + 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 { + const id = String(options.id ?? '').trim(); + if (!id) throw new Error('id is required'); + + const resp = await send( + 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( + 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 { + const id = String(options.id ?? '').trim(); + if (!id) throw new Error('id is required'); + + const resp = await send( + 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 { + const id = String(options.id ?? '').trim(); + if (!id) throw new Error('id is required'); + + const resp = await send( + 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 { + const id = 'monitor'; + const name = 'Monitor'; + const icon = '\uD83D\uDC40'; // 👀 + + const client = createRuntimeClient(); + + function getActions(item: SearchResult): Action[] { + 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[]> { + 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[] = []; + + 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 ` or ` `. + 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 ` (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, + }; +} diff --git a/app/chrome-extension/shared/quick-panel/providers/notes-provider.ts b/app/chrome-extension/shared/quick-panel/providers/notes-provider.ts new file mode 100644 index 0000000..d5b991a --- /dev/null +++ b/app/chrome-extension/shared/quick-panel/providers/notes-provider.ts @@ -0,0 +1,318 @@ +/** + * Notes Provider (Quick Panel) + * + * Provides local-first quick notes via background storage bridge. + * + * Scope: + * - Prefix-only scope: `note ` + * - Not included in 'all' scope (privacy + noise) + */ + +import { + BACKGROUND_MESSAGE_TYPES, + type QuickPanelNoteSummary, + type QuickPanelNotesCreateResponse, + type QuickPanelNotesDeleteResponse, + type QuickPanelNotesGetResponse, + type QuickPanelNotesListResponse, +} from '@/common/message-types'; +import type { Action, SearchProvider, SearchProviderContext, SearchResult } from '../core/types'; +import { computeWeightedTokenScore, writeToClipboard } from './provider-utils'; + +// ============================================================ +// Types +// ============================================================ + +export type NotesResultData = + | { + kind: 'note'; + id: string; + title: string; + preview: string; + createdAt: number; + updatedAt: number; + incognito: boolean; + } + | { + kind: 'create'; + content: string; + } + | { kind: 'empty' }; + +interface NotesClient { + list: (options: { + query?: string; + maxResults: number; + signal: AbortSignal; + }) => Promise; + get: (options: { id: string }) => Promise; + create: (options: { content: string }) => Promise; + delete: (options: { id: string }) => Promise; +} + +// ============================================================ +// Helpers +// ============================================================ + +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 buildSubtitle(note: QuickPanelNoteSummary): string { + const parts: string[] = []; + if (note.preview && note.preview !== note.title) parts.push(note.preview); + const time = formatTime(note.updatedAt); + if (time) parts.push(time); + return parts.filter((p) => p).join(' \u00B7 '); +} + +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 normalizeString(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function createRuntimeClient(): NotesClient { + async function send(type: string, payload: unknown): Promise { + 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 { + if (options.signal.aborted) throw new Error('aborted'); + + const resp = await send( + BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_NOTES_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 notes'); + } + + return Array.isArray(resp.items) ? resp.items : []; + } + + async function get(options: { id: string }): Promise { + const id = normalizeString(options.id).trim(); + if (!id) throw new Error('id is required'); + + const resp = await send( + BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_NOTES_GET, + { id }, + ); + + if (!resp || resp.success !== true) { + const err = (resp as { error?: unknown })?.error; + throw new Error(typeof err === 'string' ? err : 'Failed to get note'); + } + + return normalizeString(resp.note.content); + } + + async function create(options: { content: string }): Promise { + const content = normalizeString(options.content); + if (!content.trim()) throw new Error('content is required'); + + const resp = await send( + BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_NOTES_CREATE, + { content }, + ); + + if (!resp || resp.success !== true) { + const err = (resp as { error?: unknown })?.error; + throw new Error(typeof err === 'string' ? err : 'Failed to create note'); + } + + return resp.note; + } + + async function del(options: { id: string }): Promise { + const id = normalizeString(options.id).trim(); + if (!id) throw new Error('id is required'); + + const resp = await send( + BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_NOTES_DELETE, + { id }, + ); + + if (!resp || resp.success !== true) { + const err = (resp as { error?: unknown })?.error; + throw new Error(typeof err === 'string' ? err : 'Failed to delete note'); + } + } + + return { list, get, create, delete: del }; +} + +// ============================================================ +// Provider Factory +// ============================================================ + +export function createNotesProvider(): SearchProvider { + const id = 'notes'; + const name = 'Notes'; + const icon = '\uD83D\uDCDD'; // 📝 + + const client = createRuntimeClient(); + + function getActions(item: SearchResult): Action[] { + const data = item.data; + + if (data.kind === 'create') { + return [ + { + id: 'notes.create', + title: 'Create note', + hotkeyHint: 'Enter', + execute: async () => { + await client.create({ content: data.content }); + }, + }, + ]; + } + + if (data.kind !== 'note') return []; + + return [ + { + id: 'notes.copy', + title: 'Copy note', + hotkeyHint: 'Enter', + execute: async () => { + const content = await client.get({ id: data.id }); + await writeToClipboard(content, { source: 'notes.copy', label: data.title }); + }, + }, + { + id: 'notes.delete', + title: 'Delete note', + tone: 'danger', + execute: async () => { + await client.delete({ id: data.id }); + }, + }, + ]; + } + + async function search(ctx: SearchProviderContext): Promise[]> { + if (ctx.signal.aborted) return []; + if (ctx.requestedScope !== 'notes') 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; score: number }> = []; + + // Virtual create entry when user typed a note content. + if (trimmed) { + const title = trimmed.length > 80 ? `${trimmed.slice(0, 79)}\u2026` : trimmed; + results.push({ + score: 1000, + result: { + id: 'notes.create', + provider: id, + title: `Create note: ${title}`, + subtitle: 'Saves to local storage', + icon: '\u2795', // ➕ + data: { kind: 'create', content: trimmed }, + score: 1000, + }, + }); + } + + for (const it of items) { + const base = + tokens.length === 0 + ? 200 + computeRecencyScore(it.updatedAt, now) + : computeWeightedTokenScore( + [ + { value: it.title, weight: 0.7, mode: 'text' }, + { value: it.preview, weight: 0.3, mode: 'text' }, + ], + tokens, + ); + if (base <= 0) continue; + + results.push({ + score: base, + result: { + id: `note.${it.id}`, + provider: id, + title: it.title, + subtitle: buildSubtitle(it), + icon, + data: { + kind: 'note', + id: it.id, + title: it.title, + preview: it.preview, + createdAt: it.createdAt, + updatedAt: it.updatedAt, + incognito: it.incognito, + }, + score: base, + }, + }); + } + + results.sort((a, b) => b.score - a.score); + + if (results.length === 0) { + results.push({ + score: 1, + result: { + id: 'notes.empty', + provider: id, + title: 'No notes yet', + subtitle: 'Type `note ...` to create a new note', + icon, + data: { kind: 'empty' }, + score: 1, + }, + }); + } + + return results.map((r) => r.result).slice(0, ctx.limit); + } + + return { + id, + name, + icon, + scopes: ['notes'], + includeInAll: false, + priority: 5, + maxResults: 50, + supportsEmptyQuery: true, + search, + getActions, + }; +} diff --git a/app/chrome-extension/shared/quick-panel/providers/provider-utils.ts b/app/chrome-extension/shared/quick-panel/providers/provider-utils.ts index 7b4c9cb..08341ca 100644 --- a/app/chrome-extension/shared/quick-panel/providers/provider-utils.ts +++ b/app/chrome-extension/shared/quick-panel/providers/provider-utils.ts @@ -4,14 +4,73 @@ * Shared utilities for all Quick Panel search providers: * - Clipboard operations * - Markdown formatting - * - Text normalization - * - Token-based scoring + * - Text normalization (pure) + * - Token-based scoring (pure) + * - Navigation helpers (background bridge) */ +import { + BACKGROUND_MESSAGE_TYPES, + type QuickPanelOpenUrlDisposition, + type QuickPanelOpenUrlResponse, +} from '@/common/message-types'; + +export { + computeWeightedTokenScore, + normalizeText, + normalizeUrl, + scoreToken, + type WeightedField, + type WeightedFieldMode, +} from '../core/text-score'; + // ============================================================ // Clipboard Utilities // ============================================================ +export interface ClipboardRecordOptions { + /** Best-effort source tag (e.g. "commands.copy.url", "toolbox.jwt.payload"). */ + source?: string; + /** Best-effort label (e.g. page title or output title). */ + label?: string; + /** Best-effort origin URL (tab where the copy happened). */ + originUrl?: string; + /** Best-effort origin title (tab where the copy happened). */ + originTitle?: string; +} + +async function recordQuickPanelClipboardWrite( + text: string, + options: ClipboardRecordOptions | undefined, +): Promise { + try { + if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) return; + + const raw = String(text ?? ''); + if (!raw) return; + + // Avoid sending very large payloads over runtime messaging. + // 对于超大文本(如长 base64),这里选择不记录以避免消息/存储开销过高。 + if (raw.length > 500_000) return; + + const originUrl = options?.originUrl ?? window.location.href; + const originTitle = options?.originTitle ?? document.title; + + await chrome.runtime.sendMessage({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CLIPBOARD_RECORD, + payload: { + text: raw, + source: options?.source, + label: options?.label, + originUrl, + originTitle, + }, + }); + } catch { + // Best-effort + } +} + /** * Write text to the clipboard using execCommand fallback. * This is the legacy approach that works in more contexts. @@ -38,11 +97,15 @@ function writeToClipboardFallback(text: string): void { * Write text to the clipboard. * Uses the modern Clipboard API with fallback for older browsers or restricted contexts. */ -export async function writeToClipboard(text: string): Promise { +export async function writeToClipboard( + text: string, + record?: ClipboardRecordOptions, +): Promise { // Try modern API first, with fallback on any failure if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { try { await navigator.clipboard.writeText(text); + void recordQuickPanelClipboardWrite(text, record); return; } catch { // Modern API failed (permission denied, no user gesture, etc.) @@ -52,6 +115,7 @@ export async function writeToClipboard(text: string): Promise { // Fallback for older browsers or when modern API fails writeToClipboardFallback(text); + void recordQuickPanelClipboardWrite(text, record); } /** @@ -67,188 +131,35 @@ export function formatMarkdownLink(title: string, url: string): string { } // ============================================================ -// Text Normalization +// Navigation Utilities (Background Bridge) // ============================================================ /** - * Normalize text for comparison: - * - Trim whitespace - * - Convert to lowercase - * - Collapse multiple spaces + * Open a URL via the background service worker. + * Uses `QUICK_PANEL_OPEN_URL` to ensure consistent tab disposition behavior. */ -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 +export async function openUrl(options: { + url: string; + disposition?: QuickPanelOpenUrlDisposition; +}): Promise { + if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) { + throw new Error('chrome.runtime.sendMessage is not available'); } - 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); + const url = String(options.url ?? '').trim(); + if (!url) { + throw new Error('url is required'); } - // Subsequence match (fuzzy) - only for tokens >= MIN_SUBSEQUENCE_TOKEN_LENGTH - if (token.length >= MIN_SUBSEQUENCE_TOKEN_LENGTH && isSubsequence(token, haystack)) { - return 0.4; + const disposition = options.disposition ?? 'current_tab'; + + const resp = (await chrome.runtime.sendMessage({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_OPEN_URL, + payload: { url, disposition }, + })) as QuickPanelOpenUrlResponse; + + if (!resp || resp.success !== true) { + const err = (resp as { error?: unknown })?.error; + throw new Error(typeof err === 'string' ? err : 'Failed to open url'); } - - 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; } diff --git a/app/chrome-extension/shared/quick-panel/providers/tabs-provider.ts b/app/chrome-extension/shared/quick-panel/providers/tabs-provider.ts index a4d94f4..eabcdc5 100644 --- a/app/chrome-extension/shared/quick-panel/providers/tabs-provider.ts +++ b/app/chrome-extension/shared/quick-panel/providers/tabs-provider.ts @@ -281,7 +281,7 @@ export function createTabsProvider( title: 'Copy URL', hotkeyHint: 'Cmd+C', execute: async () => { - await writeToClipboard(url); + await writeToClipboard(url, { source: 'tabs.copy.url', label: title }); }, }, // Copy as Markdown link @@ -290,7 +290,10 @@ export function createTabsProvider( title: 'Copy as Markdown', hotkeyHint: 'Cmd+Shift+C', execute: async () => { - await writeToClipboard(formatMarkdownLink(title, url)); + await writeToClipboard(formatMarkdownLink(title, url), { + source: 'tabs.copy.markdown', + label: title, + }); }, }, // Pin/Unpin toggle diff --git a/app/chrome-extension/shared/quick-panel/providers/web-search-provider.ts b/app/chrome-extension/shared/quick-panel/providers/web-search-provider.ts new file mode 100644 index 0000000..80433d8 --- /dev/null +++ b/app/chrome-extension/shared/quick-panel/providers/web-search-provider.ts @@ -0,0 +1,162 @@ +/** + * Web Search Provider (Quick Panel) + * + * Scope-driven web search entries using prefix-only scopes: + * - `g ` -> Google + * - `gh ` -> GitHub + * - `npm ` -> NPM + * - `so ` -> Stack Overflow + * - `mdn ` -> MDN + * + * This provider is intentionally excluded from the 'all' scope to avoid + * polluting local-first results (tabs/bookmarks/history/content). + */ + +import { buildSearchUrl } from '../core/url-template'; +import type { Action, SearchProvider, SearchProviderContext, SearchResult } from '../core/types'; +import { openUrl } from './provider-utils'; + +// ============================================================ +// Types +// ============================================================ + +export type WebSearchScope = + | 'web_google' + | 'web_github' + | 'web_npm' + | 'web_stackoverflow' + | 'web_mdn'; + +export interface WebSearchResultData { + scope: WebSearchScope; + url: string; + query: string; +} + +interface WebSearchEngineDef { + scope: WebSearchScope; + label: string; + icon: string; + template: string; +} + +// ============================================================ +// Engine Templates +// ============================================================ + +const WEB_SEARCH_ENGINES: Readonly> = { + web_google: { + scope: 'web_google', + label: 'Google', + icon: '\uD83D\uDD0D', // 🔍 + template: 'https://www.google.com/search?q={rawQuery}', + }, + web_github: { + scope: 'web_github', + label: 'GitHub', + icon: '\uD83D\uDC19', // 🐙 + template: 'https://github.com/search?q={rawQuery}', + }, + web_npm: { + scope: 'web_npm', + label: 'NPM', + icon: '\uD83D\uDCE6', // 📦 + template: 'https://www.npmjs.com/search?q={rawQuery}', + }, + web_stackoverflow: { + scope: 'web_stackoverflow', + label: 'Stack Overflow', + icon: '\uD83D\uDCA1', // 💡 + template: 'https://stackoverflow.com/search?q={rawQuery}', + }, + web_mdn: { + scope: 'web_mdn', + label: 'MDN', + icon: '\uD83D\uDCDA', // 📚 + template: 'https://developer.mozilla.org/en-US/search?q={rawQuery}', + }, +} as const; + +function isWebSearchScope(scope: unknown): scope is WebSearchScope { + return ( + scope === 'web_google' || + scope === 'web_github' || + scope === 'web_npm' || + scope === 'web_stackoverflow' || + scope === 'web_mdn' + ); +} + +// ============================================================ +// Provider Factory +// ============================================================ + +export function createWebSearchProvider(): SearchProvider { + const id = 'web-search'; + const name = 'Web Search'; + const icon = '\uD83C\uDF10'; // 🌐 + + function getActions(item: SearchResult): Action[] { + const { url } = item.data; + + return [ + { + id: 'web.open', + title: 'Open', + hotkeyHint: 'Enter', + execute: async (ctx) => { + await openUrl({ url, disposition: ctx.openMode ?? 'current_tab' }); + }, + }, + { + id: 'web.openNewTab', + title: 'Open in new tab', + hotkeyHint: 'Cmd/Ctrl+Enter', + execute: async () => { + await openUrl({ url, disposition: 'new_tab' }); + }, + }, + ]; + } + + async function search(ctx: SearchProviderContext): Promise[]> { + if (ctx.signal.aborted) return []; + + const scope = ctx.requestedScope; + if (!isWebSearchScope(scope)) return []; + + const rawQuery = String(ctx.query.raw ?? '').trim(); + if (!rawQuery) return []; + + const engine = WEB_SEARCH_ENGINES[scope]; + const url = buildSearchUrl({ template: engine.template }, rawQuery); + + const data: WebSearchResultData = { + scope, + url, + query: rawQuery, + }; + + return [ + { + id: `${scope}:${encodeURIComponent(rawQuery)}`, + provider: id, + title: `Search ${engine.label}`, + subtitle: rawQuery, + icon: engine.icon, + data, + score: 100, + }, + ]; + } + + return { + id, + name, + icon, + scopes: ['web_google', 'web_github', 'web_npm', 'web_stackoverflow', 'web_mdn'], + includeInAll: false, + search, + getActions, + }; +} diff --git a/app/chrome-extension/shared/quick-panel/providers/workspaces-provider.ts b/app/chrome-extension/shared/quick-panel/providers/workspaces-provider.ts new file mode 100644 index 0000000..03d0f3e --- /dev/null +++ b/app/chrome-extension/shared/quick-panel/providers/workspaces-provider.ts @@ -0,0 +1,339 @@ +/** + * Workspaces Provider (Quick Panel) + * + * Provides session snapshots (tab workspaces) via background bridge. + * + * Scope: + * - Prefix-only scope: `ws ` + * - Not included in 'all' scope (to keep local search noise-free) + */ + +import { + BACKGROUND_MESSAGE_TYPES, + type QuickPanelWorkspaceSummary, + type QuickPanelWorkspacesDeleteResponse, + type QuickPanelWorkspacesListResponse, + type QuickPanelWorkspacesOpenResponse, + type QuickPanelWorkspacesSaveResponse, +} from '@/common/message-types'; +import type { Action, SearchProvider, SearchProviderContext, SearchResult } from '../core/types'; +import { computeWeightedTokenScore } from './provider-utils'; + +// ============================================================ +// Types +// ============================================================ + +export type WorkspacesResultData = + | { + kind: 'workspace'; + workspaceId: string; + name: string; + tabCount: number; + createdAt: number; + updatedAt: number; + incognito: boolean; + } + | { + kind: 'save'; + name: string | null; + }; + +// ============================================================ +// Background Client +// ============================================================ + +interface WorkspacesClient { + list: (options: { + query?: string; + maxResults: number; + signal: AbortSignal; + }) => Promise; + save: (options: { name?: string }) => Promise; + open: (options: { + workspaceId: string; + target: 'current_window' | 'new_window'; + }) => Promise; + delete: (options: { workspaceId: string }) => Promise; +} + +function createRuntimeWorkspacesClient(): WorkspacesClient { + async function list(options: { + query?: string; + maxResults: number; + signal: AbortSignal; + }): Promise { + 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_WORKSPACES_LIST, + payload: { query: options.query, maxResults: options.maxResults }, + })) as QuickPanelWorkspacesListResponse; + + if (!resp || resp.success !== true) { + const err = (resp as { error?: unknown })?.error; + throw new Error(typeof err === 'string' ? err : 'Failed to list workspaces'); + } + + return Array.isArray(resp.items) ? resp.items : []; + } + + async function save(options: { name?: string }): Promise { + 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_WORKSPACES_SAVE, + payload: { name: options.name }, + })) as QuickPanelWorkspacesSaveResponse; + + if (!resp || resp.success !== true) { + const err = (resp as { error?: unknown })?.error; + throw new Error(typeof err === 'string' ? err : 'Failed to save workspace'); + } + + return resp.workspace; + } + + async function open(options: { + workspaceId: string; + target: 'current_window' | 'new_window'; + }): Promise { + if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) { + throw new Error('chrome.runtime.sendMessage is not available'); + } + + const id = String(options.workspaceId ?? '').trim(); + if (!id) throw new Error('workspaceId is required'); + + const resp = (await chrome.runtime.sendMessage({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_WORKSPACES_OPEN, + payload: { workspaceId: id, target: options.target }, + })) as QuickPanelWorkspacesOpenResponse; + + if (!resp || resp.success !== true) { + const err = (resp as { error?: unknown })?.error; + throw new Error(typeof err === 'string' ? err : 'Failed to open workspace'); + } + } + + async function del(options: { workspaceId: string }): Promise { + if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) { + throw new Error('chrome.runtime.sendMessage is not available'); + } + + const id = String(options.workspaceId ?? '').trim(); + if (!id) throw new Error('workspaceId is required'); + + const resp = (await chrome.runtime.sendMessage({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_WORKSPACES_DELETE, + payload: { workspaceId: id }, + })) as QuickPanelWorkspacesDeleteResponse; + + if (!resp || resp.success !== true) { + const err = (resp as { error?: unknown })?.error; + throw new Error(typeof err === 'string' ? err : 'Failed to delete workspace'); + } + } + + return { list, save, open, delete: del }; +} + +// ============================================================ +// Scoring +// ============================================================ + +function computeWorkspaceScore( + item: QuickPanelWorkspaceSummary, + tokens: readonly string[], + now: number, +): number { + if (tokens.length === 0) { + // Empty query: sort by recency (updatedAt) with a baseline to keep it visible. + const ageMs = Math.max(0, now - (item.updatedAt || item.createdAt || 0)); + const ageHours = ageMs / (1000 * 60 * 60); + const recencyBoost = Math.max(0, Math.min(20, 20 - ageHours / 24)); + return 50 + recencyBoost; + } + + const base = computeWeightedTokenScore([{ value: item.name, weight: 1, mode: 'text' }], tokens); + if (base <= 0) return 0; + + const ageMs = Math.max(0, now - (item.updatedAt || item.createdAt || 0)); + const ageHours = ageMs / (1000 * 60 * 60); + const recencyBoost = Math.max(0, Math.min(10, 10 - ageHours / 24)); + return base + recencyBoost; +} + +// ============================================================ +// Provider Factory +// ============================================================ + +export function createWorkspacesProvider(): SearchProvider { + const id = 'workspaces'; + const name = 'Workspaces'; + const icon = '\uD83D\uDDC3\uFE0F'; // 🗃️ + + const client = createRuntimeWorkspacesClient(); + + function getActions(item: SearchResult): Action[] { + const data = item.data; + + if (data.kind === 'save') { + const saveName = data.name?.trim() || undefined; + return [ + { + id: 'workspaces.save', + title: saveName ? `Save session as "${saveName}"` : 'Save current session', + hotkeyHint: 'Enter', + execute: async () => { + await client.save({ name: saveName }); + }, + }, + ]; + } + + const workspaceId = data.workspaceId; + + return [ + { + id: 'workspaces.openNewWindow', + title: 'Open in new window', + hotkeyHint: 'Enter', + execute: async (ctx) => { + // Treat Cmd/Ctrl+Enter as a deliberate "restore into current window". + const target = ctx.openMode === 'new_tab' ? 'current_window' : 'new_window'; + await client.open({ workspaceId, target }); + }, + }, + { + id: 'workspaces.openCurrentWindow', + title: 'Open in current window', + tone: 'danger', + hotkeyHint: 'Cmd/Ctrl+Enter', + execute: async () => { + await client.open({ workspaceId, target: 'current_window' }); + }, + }, + { + id: 'workspaces.delete', + title: 'Delete workspace', + tone: 'danger', + execute: async () => { + await client.delete({ workspaceId }); + }, + }, + ]; + } + + async function search(ctx: SearchProviderContext): Promise[]> { + if (ctx.signal.aborted) return []; + if (ctx.requestedScope !== 'workspaces') return []; + + const raw = String(ctx.query.raw ?? ''); + const trimmed = raw.trim(); + const tokens = ctx.query.tokens; + + const maxResults = Math.min(100, Math.max(10, ctx.limit * 5)); + const items = await client.list({ + query: trimmed || undefined, + maxResults, + signal: ctx.signal, + }); + if (ctx.signal.aborted) return []; + + const results: Array<{ result: SearchResult; score: number }> = []; + + const lowerQuery = trimmed.toLowerCase(); + const hasQuery = tokens.length > 0; + + // Virtual save entries + if (!hasQuery) { + results.push({ + score: 1000, + result: { + id: 'workspaces.saveCurrent', + provider: id, + title: 'Save current session', + subtitle: 'Create a snapshot of tabs in this window', + icon: '\uD83D\uDCBE', // 💾 + data: { kind: 'save', name: null }, + score: 1000, + }, + }); + } else { + const hasExact = items.some((w) => w.name.trim().toLowerCase() === lowerQuery); + if (!hasExact) { + results.push({ + score: 1000, + result: { + id: `workspaces.saveAs:${encodeURIComponent(trimmed)}`, + provider: id, + title: `Save session as "${trimmed}"`, + subtitle: 'Create a snapshot of tabs in this window', + icon: '\uD83D\uDCBE', // 💾 + data: { kind: 'save', name: trimmed }, + score: 1000, + }, + }); + } + } + + // Workspace snapshots + for (const w of items) { + const score = computeWorkspaceScore(w, tokens, ctx.now); + if (hasQuery && score <= 0) continue; + + const data: WorkspacesResultData = { + kind: 'workspace', + workspaceId: w.id, + name: w.name, + tabCount: w.tabCount, + createdAt: w.createdAt, + updatedAt: w.updatedAt, + incognito: w.incognito, + }; + + results.push({ + score, + result: { + id: w.id, + provider: id, + title: w.name, + subtitle: `${w.tabCount} tabs`, + icon, + data, + score, + }, + }); + } + + // Sort by score desc, then updatedAt desc, then name. + results.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + const aUpdated = a.result.data.kind === 'workspace' ? a.result.data.updatedAt : 0; + const bUpdated = b.result.data.kind === 'workspace' ? b.result.data.updatedAt : 0; + if (bUpdated !== aUpdated) return bUpdated - aUpdated; + return a.result.title.localeCompare(b.result.title); + }); + + return results.map((r) => r.result).slice(0, ctx.limit); + } + + return { + id, + name, + icon, + scopes: ['workspaces'], + includeInAll: false, + supportsEmptyQuery: true, + maxResults: 50, + search, + getActions, + }; +} diff --git a/app/chrome-extension/shared/quick-panel/ui/search-input.ts b/app/chrome-extension/shared/quick-panel/ui/search-input.ts index 82bf2a4..ec6c85c 100644 --- a/app/chrome-extension/shared/quick-panel/ui/search-input.ts +++ b/app/chrome-extension/shared/quick-panel/ui/search-input.ts @@ -62,7 +62,7 @@ export interface SearchInputManager { /** Get current state */ getState: () => SearchInputState; /** Set scope programmatically */ - setScope: (scope: QuickPanelScope, options?: { emit?: boolean }) => void; + setScope: (scope: QuickPanelScope, options?: { emit?: boolean; allowNonCycle?: boolean }) => void; /** Set query programmatically */ setQuery: (query: string, options?: { emit?: boolean }) => void; /** Clear the input */ @@ -247,14 +247,17 @@ export function createSearchInput(options: SearchInputOptions): SearchInputManag // State Mutators // -------------------------------------------------------- - function setScope(next: QuickPanelScope, opts: { emit?: boolean } = {}): void { + function setScope( + next: QuickPanelScope, + opts: { emit?: boolean; allowNonCycle?: boolean } = {}, + ): void { if (disposed) return; const normalized = normalizeQuickPanelScope(next, DEFAULT_SCOPE); if (state.scope === normalized) return; // Only allow scopes in the cycle list - if (!scopes.includes(normalized)) return; + if (opts.allowNonCycle !== true && !scopes.includes(normalized)) return; state = { ...state, scope: normalized }; render(); @@ -303,9 +306,10 @@ export function createSearchInput(options: SearchInputOptions): SearchInputManag const parsed = parseScopePrefixedQuery(input.value, state.scope); if (parsed.consumedPrefix) { - // Apply scope change if available - if (scopes.includes(parsed.scope) && parsed.scope !== state.scope) { - setScope(parsed.scope, { emit: false }); + // Apply scope change even if it's not part of the cycle list. + // Some scopes are designed to be prefix-only (e.g. web search engines). + if (parsed.scope !== state.scope) { + setScope(parsed.scope, { emit: false, allowNonCycle: true }); } // Consume the prefix from the visible input diff --git a/app/chrome-extension/shared/quick-panel/ui/search-view.ts b/app/chrome-extension/shared/quick-panel/ui/search-view.ts index d443c3a..5051787 100644 --- a/app/chrome-extension/shared/quick-panel/ui/search-view.ts +++ b/app/chrome-extension/shared/quick-panel/ui/search-view.ts @@ -103,7 +103,7 @@ export interface QuickPanelSearchViewManager { // Constants // ============================================================ -const DEFAULT_PLACEHOLDER = 'Search tabs, bookmarks, commands...'; +const DEFAULT_PLACEHOLDER = 'Search tabs, bookmarks, history, content, commands...'; const DEFAULT_SCOPES: QuickPanelScope[] = ['all', 'tabs', 'commands']; const DEFAULT_RECENT_LIMIT = 20; @@ -263,6 +263,7 @@ export function mountQuickPanelSearchView( const hints = [ { key: '\u2191\u2193', label: 'Navigate' }, { key: '\u21b5', label: 'Select' }, + { key: 'Cmd/Ctrl+\u21b5', label: 'New tab' }, { key: 'Tab', label: 'Actions' }, { key: 'Esc', label: 'Close' }, ]; diff --git a/app/chrome-extension/tests/quick-panel/api-detective-provider.test.ts b/app/chrome-extension/tests/quick-panel/api-detective-provider.test.ts new file mode 100644 index 0000000..de7b1b5 --- /dev/null +++ b/app/chrome-extension/tests/quick-panel/api-detective-provider.test.ts @@ -0,0 +1,210 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createApiDetectiveProvider } from '@/shared/quick-panel/providers/api-detective-provider'; +import type { SearchProviderContext } from '@/shared/quick-panel/core/types'; +import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types'; + +describe('Quick Panel API Detective provider', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + function stubClipboard() { + vi.stubGlobal('navigator', { + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + } + + it('search returns command entries and request list for `api` query', async () => { + const sendMessage = vi.fn().mockImplementation((msg: any) => { + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_STATUS) { + return Promise.resolve({ + success: true, + active: false, + backend: null, + startedAt: null, + lastCaptureAt: 111, + lastRequestCount: 1, + }); + } + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_LIST) { + return Promise.resolve({ + success: true, + active: false, + backend: null, + capturedAt: 111, + tabUrl: 'https://example.com', + items: [ + { + requestId: 'r1', + method: 'GET', + url: 'https://api.example.com/user', + type: 'XHR', + status: 200, + mimeType: 'application/json', + }, + ], + }); + } + return Promise.resolve({ success: true }); + }); + + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createApiDetectiveProvider(); + const ctx: SearchProviderContext = { + requestedScope: 'commands', + query: { raw: 'api', text: 'api', tokens: ['api'] }, + limit: 10, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + expect(results.some((r) => r.id === 'api_detective.status')).toBe(true); + expect(results.some((r) => r.id === 'api_detective.start')).toBe(true); + expect(results.some((r) => r.id === 'api_detective.stop')).toBe(true); + expect(results.some((r) => r.id === 'api_detective.req.r1')).toBe(true); + }); + + it('executes start capture via QUICK_PANEL_API_DETECTIVE_START', async () => { + const sendMessage = vi.fn().mockImplementation((msg: any) => { + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_START) { + return Promise.resolve({ + success: true, + active: true, + backend: 'webRequest', + startedAt: 1, + }); + } + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_STATUS) { + return Promise.resolve({ + success: true, + active: false, + backend: null, + startedAt: null, + lastCaptureAt: null, + lastRequestCount: 0, + }); + } + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_LIST) { + return Promise.resolve({ + success: true, + active: false, + backend: null, + capturedAt: null, + tabUrl: null, + items: [], + }); + } + return Promise.resolve({ success: true }); + }); + + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createApiDetectiveProvider(); + const action = provider.getActions({ + id: 'api_detective.start', + provider: 'api_detective', + title: 'Start capture', + score: 1, + data: { kind: 'command', command: 'start' }, + } as any)[0]; + + await action.execute({ result: {} as any } as any); + + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_START, + payload: { needResponseBody: false, includeStatic: false, maxCaptureTimeMs: 180000 }, + }); + }); + + it('copies curl snippet via GET_REQUEST + clipboard', async () => { + stubClipboard(); + + const sendMessage = vi.fn().mockImplementation((msg: any) => { + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_GET_REQUEST) { + return Promise.resolve({ + success: true, + request: { + requestId: 'r1', + method: 'POST', + url: 'https://example.com/api', + requestHeaders: { 'Content-Type': 'application/json' }, + requestBody: '{"a":1}', + }, + }); + } + return Promise.resolve({ success: true }); + }); + + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createApiDetectiveProvider(); + const result = { + id: 'api_detective.req.r1', + provider: 'api_detective', + title: 'POST example.com/api', + score: 1, + data: { + kind: 'request', + requestId: 'r1', + method: 'POST', + url: 'https://example.com/api', + backend: 'webRequest', + capturedAt: 1, + tabUrl: 'https://example.com', + }, + }; + + const action = provider.getActions(result as any)[0]; + await action.execute({ result } as any); + + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_GET_REQUEST, + payload: { requestId: 'r1' }, + }); + + const writeText = (navigator as any).clipboard.writeText as any; + expect(writeText).toHaveBeenCalled(); + const copied = String(writeText.mock.calls[0]?.[0] ?? ''); + expect(copied.startsWith('curl')).toBe(true); + }); + + it('replays request via QUICK_PANEL_API_DETECTIVE_REPLAY_REQUEST', async () => { + const sendMessage = vi.fn().mockImplementation((msg: any) => { + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_REPLAY_REQUEST) { + return Promise.resolve({ success: true, result: { ok: true } }); + } + return Promise.resolve({ success: true }); + }); + + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createApiDetectiveProvider(); + const result = { + id: 'api_detective.req.r1', + provider: 'api_detective', + title: 'GET example.com/api', + score: 1, + data: { + kind: 'request', + requestId: 'r1', + method: 'GET', + url: 'https://example.com/api', + backend: 'webRequest', + capturedAt: 1, + tabUrl: 'https://example.com', + }, + }; + + const action = provider.getActions(result as any).find((a) => a.id === 'api_detective.replay')!; + await action.execute({ result } as any); + + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_API_DETECTIVE_REPLAY_REQUEST, + payload: { requestId: 'r1', timeoutMs: 30000 }, + }); + }); +}); diff --git a/app/chrome-extension/tests/quick-panel/api-detective-snippets.test.ts b/app/chrome-extension/tests/quick-panel/api-detective-snippets.test.ts new file mode 100644 index 0000000..ca4dd9f --- /dev/null +++ b/app/chrome-extension/tests/quick-panel/api-detective-snippets.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { toCurlCommand, toFetchSnippet } from '@/shared/quick-panel/core/api-detective-snippets'; + +describe('API Detective snippets', () => { + it('builds a basic curl command', () => { + const curl = toCurlCommand({ + method: 'POST', + url: 'https://example.com/api/v1/items', + headers: { 'Content-Type': 'application/json' }, + body: '{"a":1}', + }); + + expect(curl).toContain('curl'); + expect(curl).toContain("-X 'POST'"); + expect(curl).toContain("-H 'Content-Type: application/json'"); + expect(curl).toContain('--data-raw'); + expect(curl).toContain('{"a":1}'); + expect(curl).toContain("'https://example.com/api/v1/items'"); + }); + + it('escapes single quotes for POSIX shells', () => { + const curl = toCurlCommand({ + method: 'POST', + url: "https://example.com/api?note=o'clock", + headers: { Authorization: "Bearer o'clock" }, + body: "o'clock", + }); + + // curl uses the classic '"'"' escape sequence + expect(curl).toContain("'\"'\"'"); + }); + + it('builds a fetch snippet', () => { + const snippet = toFetchSnippet({ + method: 'PUT', + url: 'https://example.com/api/v1/items/1', + headers: { 'Content-Type': 'application/json', 'X-Test': '1' }, + body: '{"ok":true}', + }); + + expect(snippet).toContain('await fetch("https://example.com/api/v1/items/1"'); + expect(snippet).toContain('method: "PUT"'); + expect(snippet).toContain('"Content-Type": "application/json"'); + expect(snippet).toContain('body: "{\\"ok\\":true}"'); + }); +}); diff --git a/app/chrome-extension/tests/quick-panel/audit-provider.test.ts b/app/chrome-extension/tests/quick-panel/audit-provider.test.ts new file mode 100644 index 0000000..2c53a8b --- /dev/null +++ b/app/chrome-extension/tests/quick-panel/audit-provider.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createAuditProvider } from '@/shared/quick-panel/providers/audit-provider'; +import type { SearchProviderContext } from '@/shared/quick-panel/core/types'; +import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types'; + +describe('Quick Panel audit provider', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('lists recent audit entries on empty query', async () => { + const entry = { + id: 'log_1', + toolName: 'chrome_read_page', + toolDescription: 'read page', + riskLevel: 'low' as const, + riskCategories: ['read'], + source: 'native_host' as const, + incognito: false, + status: 'success' as const, + startedAt: Date.now() - 1000, + finishedAt: Date.now(), + durationMs: 1000, + argsSummary: '{ "depth": 2 }', + resultSummary: '{ "success": true }', + }; + + const sendMessage = vi.fn().mockImplementation((msg: any) => { + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_AUDIT_LOG_LIST) { + return Promise.resolve({ success: true, entries: [entry] }); + } + return Promise.resolve({ success: true }); + }); + + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createAuditProvider(); + const ctx: SearchProviderContext = { + requestedScope: 'audit', + query: { raw: '', text: '', tokens: [] }, + limit: 20, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + expect(results.some((r) => r.id === 'audit.log_1')).toBe(true); + }); + + it('offers clear command and triggers clear', async () => { + const sendMessage = vi.fn().mockImplementation((msg: any) => { + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_AUDIT_LOG_LIST) { + return Promise.resolve({ success: true, entries: [] }); + } + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_AUDIT_LOG_CLEAR) { + return Promise.resolve({ success: true }); + } + return Promise.resolve({ success: false, error: 'unexpected' }); + }); + + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createAuditProvider(); + const ctx: SearchProviderContext = { + requestedScope: 'audit', + query: { raw: 'clear', text: 'clear', tokens: ['clear'] }, + limit: 20, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + expect(results[0]?.id).toBe('audit.clear'); + + const action = provider.getActions(results[0] as any)[0]; + await action.execute({ result: results[0] as any } as any); + + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_AUDIT_LOG_CLEAR, + payload: {}, + }); + }); +}); diff --git a/app/chrome-extension/tests/quick-panel/clean-url.test.ts b/app/chrome-extension/tests/quick-panel/clean-url.test.ts new file mode 100644 index 0000000..79c643b --- /dev/null +++ b/app/chrome-extension/tests/quick-panel/clean-url.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { cleanUrl } from '@/shared/quick-panel/core/clean-url'; + +describe('Quick Panel clean-url', () => { + it('removes utm_* and common tracking params while preserving hash', () => { + const res = cleanUrl('https://example.com/path?a=1&utm_source=x&utm_medium=y&gclid=abc#sec'); + expect(res.cleaned).toBe('https://example.com/path?a=1#sec'); + expect(res.changed).toBe(true); + expect(res.removedParams.map((p) => p.toLowerCase())).toEqual([ + 'utm_source', + 'utm_medium', + 'gclid', + ]); + }); + + it('does nothing for non-http(s) URLs', () => { + const res = cleanUrl('chrome://extensions/?utm_source=x'); + expect(res.cleaned).toBe('chrome://extensions/?utm_source=x'); + expect(res.changed).toBe(false); + }); + + it('returns input as-is for invalid URLs', () => { + const res = cleanUrl('not a url'); + expect(res.cleaned).toBe('not a url'); + expect(res.changed).toBe(false); + }); +}); diff --git a/app/chrome-extension/tests/quick-panel/clipboard-provider.test.ts b/app/chrome-extension/tests/quick-panel/clipboard-provider.test.ts new file mode 100644 index 0000000..780dc1c --- /dev/null +++ b/app/chrome-extension/tests/quick-panel/clipboard-provider.test.ts @@ -0,0 +1,165 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createClipboardProvider } from '@/shared/quick-panel/providers/clipboard-provider'; +import type { SearchProviderContext } from '@/shared/quick-panel/core/types'; +import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types'; + +describe('Quick Panel clipboard provider', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + function stubClipboard() { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + return writeText; + } + + it('returns an empty state entry when history is empty', async () => { + const sendMessage = vi.fn().mockImplementation((msg: any) => { + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CLIPBOARD_LIST) { + return Promise.resolve({ success: true, items: [] }); + } + return Promise.resolve({ success: true }); + }); + + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createClipboardProvider(); + const ctx: SearchProviderContext = { + requestedScope: 'clipboard', + query: { raw: '', text: '', tokens: [] }, + limit: 20, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + expect(results.some((r) => r.id === 'clip.empty')).toBe(true); + }); + + it('lists clipboard items and exposes copy action', async () => { + const writeText = stubClipboard(); + + const sendMessage = vi.fn().mockImplementation((msg: any) => { + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CLIPBOARD_LIST) { + return Promise.resolve({ + success: true, + items: [ + { + id: 'c1', + preview: 'hello world', + pinned: false, + createdAt: 1, + updatedAt: 2, + incognito: false, + source: 'commands.copy.url', + label: 'Example', + originUrl: 'https://example.com', + originTitle: 'Example', + byteLength: 11, + stored: true, + copyCount: 1, + }, + ], + }); + } + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CLIPBOARD_GET) { + return Promise.resolve({ + success: true, + item: { + id: 'c1', + preview: 'hello world', + pinned: false, + createdAt: 1, + updatedAt: 2, + incognito: false, + source: 'commands.copy.url', + label: 'Example', + originUrl: 'https://example.com', + originTitle: 'Example', + byteLength: 11, + stored: true, + copyCount: 1, + value: 'hello world', + }, + }); + } + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CLIPBOARD_RECORD) { + return Promise.resolve({ success: true }); + } + return Promise.resolve({ success: true }); + }); + + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createClipboardProvider(); + const ctx: SearchProviderContext = { + requestedScope: 'clipboard', + query: { raw: 'hello', text: 'hello', tokens: ['hello'] }, + limit: 20, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + const item = results.find((r) => r.id === 'clip.c1'); + expect(item).toBeTruthy(); + + const actions = provider.getActions(item as any); + expect(actions[0]?.id).toBe('clipboard.copy'); + + await actions[0].execute({ result: item as any } as any); + expect(writeText).toHaveBeenCalledWith('hello world'); + }); + + it('pins and deletes clipboard items via background bridge', async () => { + const sendMessage = vi.fn().mockImplementation((msg: any) => { + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CLIPBOARD_SET_PINNED) { + return Promise.resolve({ success: true }); + } + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CLIPBOARD_DELETE) { + return Promise.resolve({ success: true }); + } + return Promise.resolve({ success: true, items: [] }); + }); + + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createClipboardProvider(); + const result = { + id: 'clip.c1', + provider: 'clipboard', + title: 'hello', + score: 1, + data: { + kind: 'clipboard' as const, + id: 'c1', + preview: 'hello', + pinned: false, + createdAt: 1, + updatedAt: 2, + incognito: false, + byteLength: 5, + stored: true, + copyCount: 1, + }, + }; + + const actions = provider.getActions(result as any); + const pin = actions.find((a) => a.id === 'clipboard.togglePin')!; + await pin.execute({ result } as any); + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CLIPBOARD_SET_PINNED, + payload: { id: 'c1', pinned: true }, + }); + + const del = actions.find((a) => a.id === 'clipboard.delete')!; + await del.execute({ result } as any); + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CLIPBOARD_DELETE, + payload: { id: 'c1' }, + }); + }); +}); diff --git a/app/chrome-extension/tests/quick-panel/commands-provider.test.ts b/app/chrome-extension/tests/quick-panel/commands-provider.test.ts new file mode 100644 index 0000000..950f0eb --- /dev/null +++ b/app/chrome-extension/tests/quick-panel/commands-provider.test.ts @@ -0,0 +1,496 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createCommandsProvider } from '@/shared/quick-panel/providers/commands-provider'; +import type { SearchProviderContext } from '@/shared/quick-panel/core/types'; +import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types'; + +describe('Quick Panel commands provider', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + try { + history.replaceState({}, '', 'https://example.com/'); + } catch { + // Best-effort + } + }); + + it('search includes advanced tab/window management commands', async () => { + vi.stubGlobal('chrome', { runtime: { sendMessage: vi.fn() } }); + + const provider = createCommandsProvider(); + + const ctx: SearchProviderContext = { + requestedScope: 'commands', + query: { raw: 'merge windows', text: 'merge windows', tokens: ['merge', 'windows'] }, + limit: 20, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + expect(results.some((r) => r.id === 'window.mergeAllWindows')).toBe(true); + }); + + it('search includes page skin commands', async () => { + vi.stubGlobal('chrome', { runtime: { sendMessage: vi.fn() } }); + + const provider = createCommandsProvider(); + + const ctx: SearchProviderContext = { + requestedScope: 'commands', + query: { raw: 'skin vscode', text: 'skin vscode', tokens: ['skin', 'vscode'] }, + limit: 20, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + expect(results.some((r) => r.id === 'page.skinVscode')).toBe(true); + }); + + it('search includes page tools (reader, clean url)', async () => { + vi.stubGlobal('chrome', { runtime: { sendMessage: vi.fn() } }); + + const provider = createCommandsProvider(); + + const readerCtx: SearchProviderContext = { + requestedScope: 'commands', + query: { raw: 'reader', text: 'reader', tokens: ['reader'] }, + limit: 50, + signal: new AbortController().signal, + now: Date.now(), + }; + + const readerResults = await provider.search(readerCtx); + expect(readerResults.some((r) => r.id === 'page.readerMode')).toBe(true); + + const cleanCtx: SearchProviderContext = { + requestedScope: 'commands', + query: { raw: 'clean url', text: 'clean url', tokens: ['clean', 'url'] }, + limit: 50, + signal: new AbortController().signal, + now: Date.now(), + }; + + const cleanResults = await provider.search(cleanCtx); + expect(cleanResults.some((r) => r.id === 'page.cleanUrl')).toBe(true); + }); + + it('search includes devtools export commands', async () => { + vi.stubGlobal('chrome', { runtime: { sendMessage: vi.fn() } }); + + const provider = createCommandsProvider(); + + const consoleCtx: SearchProviderContext = { + requestedScope: 'commands', + query: { raw: 'export console', text: 'export console', tokens: ['export', 'console'] }, + limit: 50, + signal: new AbortController().signal, + now: Date.now(), + }; + + const consoleResults = await provider.search(consoleCtx); + expect(consoleResults.some((r) => r.id === 'dev.consoleSnapshot')).toBe(true); + expect(consoleResults.some((r) => r.id === 'dev.consoleErrors')).toBe(true); + + const netCtx: SearchProviderContext = { + requestedScope: 'commands', + query: { raw: 'network capture', text: 'network capture', tokens: ['network', 'capture'] }, + limit: 50, + signal: new AbortController().signal, + now: Date.now(), + }; + + const netResults = await provider.search(netCtx); + expect(netResults.some((r) => r.id === 'dev.networkCapture10s')).toBe(true); + + const perfCtx: SearchProviderContext = { + requestedScope: 'commands', + query: { + raw: 'performance trace', + text: 'performance trace', + tokens: ['performance', 'trace'], + }, + limit: 50, + signal: new AbortController().signal, + now: Date.now(), + }; + + const perfResults = await provider.search(perfCtx); + expect(perfResults.some((r) => r.id === 'dev.performanceTrace5s')).toBe(true); + + const bundleCtx: SearchProviderContext = { + requestedScope: 'commands', + query: { raw: 'debug bundle', text: 'debug bundle', tokens: ['debug', 'bundle'] }, + limit: 50, + signal: new AbortController().signal, + now: Date.now(), + }; + + const bundleResults = await provider.search(bundleCtx); + expect(bundleResults.some((r) => r.id === 'dev.debugBundle')).toBe(true); + }); + + it('executes close other tabs via QUICK_PANEL_PAGE_COMMAND and marks as danger', async () => { + const sendMessage = vi.fn().mockResolvedValue({ success: true }); + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createCommandsProvider(); + + const result = { + id: 'tab.closeOtherTabs', + provider: 'commands', + title: 'Close other tabs', + subtitle: 'Close all other unpinned tabs in current window', + icon: '🧹', + data: { commandId: 'tab.closeOtherTabs' as const }, + score: 1, + }; + + const action = provider.getActions(result as any)[0]; + expect(action?.tone).toBe('danger'); + + await action.execute({ result } as any); + + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_PAGE_COMMAND, + payload: { command: 'close_other_tabs' }, + }); + }); + + it('executes skin command via QUICK_PANEL_PAGE_COMMAND', async () => { + const sendMessage = vi.fn().mockResolvedValue({ success: true }); + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createCommandsProvider(); + + const result = { + id: 'page.skinTerminal', + provider: 'commands', + title: 'Skin: Terminal', + subtitle: 'Apply terminal-inspired page skin (shows "Skin mode")', + icon: '🎨', + data: { commandId: 'page.skinTerminal' as const }, + score: 1, + }; + + const action = provider.getActions(result as any)[0]; + await action.execute({ result } as any); + + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_PAGE_COMMAND, + payload: { command: 'skin_terminal' }, + }); + }); + + it('executes reader mode via QUICK_PANEL_PAGE_COMMAND', async () => { + const sendMessage = vi.fn().mockResolvedValue({ success: true }); + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createCommandsProvider(); + + const result = { + id: 'page.readerMode', + provider: 'commands', + title: 'Reader mode', + subtitle: 'Open a distraction-free reader overlay (Esc to close)', + icon: '📖', + data: { commandId: 'page.readerMode' as const }, + score: 1, + }; + + const action = provider.getActions(result as any)[0]; + await action.execute({ result } as any); + + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_PAGE_COMMAND, + payload: { command: 'reader_mode_toggle' }, + }); + }); + + it('executes devtools network capture export via QUICK_PANEL_PAGE_COMMAND', async () => { + const sendMessage = vi.fn().mockResolvedValue({ success: true }); + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createCommandsProvider(); + + const result = { + id: 'dev.networkCapture10s', + provider: 'commands', + title: 'Network capture (10s)', + subtitle: 'Capture network requests for 10 seconds and export JSON to Downloads', + icon: '📡', + data: { commandId: 'dev.networkCapture10s' as const }, + score: 1, + }; + + const action = provider.getActions(result as any)[0]; + await action.execute({ result } as any); + + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_PAGE_COMMAND, + payload: { command: 'dev_network_capture_10s_export' }, + }); + }); + + it('executes debug bundle via QUICK_PANEL_PAGE_COMMAND', async () => { + const sendMessage = vi.fn().mockResolvedValue({ success: true }); + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createCommandsProvider(); + + const result = { + id: 'dev.debugBundle', + provider: 'commands', + title: 'Debug bundle', + subtitle: 'Collect screenshot/console/network/performance into a Downloads folder', + icon: '🐛', + data: { commandId: 'dev.debugBundle' as const }, + score: 1, + }; + + const action = provider.getActions(result as any)[0]; + await action.execute({ result } as any); + + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_PAGE_COMMAND, + payload: { command: 'dev_debug_bundle_create' }, + }); + }); + + it('clean url opens cleaned URL in new tab and preserves hash', async () => { + const sendMessage = vi.fn().mockResolvedValue({ success: true }); + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + // Update JSDOM location for this test + history.pushState({}, '', 'https://example.com/path?utm_source=x&gclid=abc#sec'); + + const provider = createCommandsProvider(); + + const result = { + id: 'page.cleanUrl', + provider: 'commands', + title: 'Clean URL', + subtitle: 'Remove tracking params and copy/open the cleaned URL', + icon: '🧼', + data: { commandId: 'page.cleanUrl' as const }, + score: 1, + }; + + const action = provider.getActions(result as any)[0]; + await action.execute({ result, openMode: 'new_tab' } as any); + + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_OPEN_URL, + payload: { url: 'https://example.com/path#sec', disposition: 'new_tab' }, + }); + }); + + it('clean url copies cleaned URL by default (safe)', async () => { + const sendMessage = vi.fn().mockResolvedValue({ success: true }); + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); + + history.pushState({}, '', 'https://example.com/path?utm_source=x'); + + const provider = createCommandsProvider(); + + const result = { + id: 'page.cleanUrl', + provider: 'commands', + title: 'Clean URL', + subtitle: 'Remove tracking params and copy/open the cleaned URL', + icon: '🧼', + data: { commandId: 'page.cleanUrl' as const }, + score: 1, + }; + + const action = provider.getActions(result as any)[0]; + await action.execute({ result, openMode: 'current_tab' } as any); + + expect(writeText).toHaveBeenCalledWith('https://example.com/path'); + }); + + it('toolbox json formats and copies pretty output', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); + + const provider = createCommandsProvider(); + const ctx: SearchProviderContext = { + requestedScope: 'commands', + query: { raw: 'json {"a":1}', text: 'json {"a":1}', tokens: ['json', '{"a":1}'] }, + limit: 20, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + const jsonResult = results.find((r) => r.id === 'toolbox.json'); + expect(jsonResult).toBeTruthy(); + + const action = provider.getActions(jsonResult as any)[0]; + await action.execute({ result: jsonResult } as any); + + expect(writeText).toHaveBeenCalledWith('{\n "a": 1\n}'); + }); + + it('toolbox base64 decode copies decoded text', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); + + const provider = createCommandsProvider(); + const ctx: SearchProviderContext = { + requestedScope: 'commands', + query: { + raw: 'base64 -d SGVsbG8=', + text: 'base64 -d sgvsbg8=', + tokens: ['base64', '-d', 'sgvsbg8='], + }, + limit: 20, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + const res = results.find((r) => r.id === 'toolbox.base64.decode'); + expect(res).toBeTruthy(); + + const action = provider.getActions(res as any)[0]; + await action.execute({ result: res } as any); + + expect(writeText).toHaveBeenCalledWith('Hello'); + }); + + it('toolbox url decode copies decoded text', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); + + const provider = createCommandsProvider(); + const ctx: SearchProviderContext = { + requestedScope: 'commands', + query: { + raw: 'url -d %E4%BD%A0%E5%A5%BD', + text: 'url -d %e4%bd%a0%e5%a5%bd', + tokens: ['url', '-d', '%e4%bd%a0%e5%a5%bd'], + }, + limit: 20, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + const res = results.find((r) => r.id === 'toolbox.url.decode'); + expect(res).toBeTruthy(); + + const action = provider.getActions(res as any)[0]; + await action.execute({ result: res } as any); + + expect(writeText).toHaveBeenCalledWith('你好'); + }); + + it('toolbox ts converts timestamp and copies ISO', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); + + const provider = createCommandsProvider(); + const ctx: SearchProviderContext = { + requestedScope: 'commands', + query: { raw: 'ts 1699123456', text: 'ts 1699123456', tokens: ['ts', '1699123456'] }, + limit: 20, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + const res = results.find((r) => r.id === 'toolbox.ts'); + expect(res).toBeTruthy(); + + const action = provider.getActions(res as any)[0]; + await action.execute({ result: res } as any); + + expect(writeText).toHaveBeenCalledWith(new Date(1699123456000).toISOString()); + }); + + it('toolbox uuid generates and copies uuid', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); + + const provider = createCommandsProvider(); + const ctx: SearchProviderContext = { + requestedScope: 'commands', + query: { raw: 'uuid', text: 'uuid', tokens: ['uuid'] }, + limit: 20, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + const res = results.find((r) => r.id === 'toolbox.uuid'); + expect(res).toBeTruthy(); + + const action = provider.getActions(res as any)[0]; + await action.execute({ result: res } as any); + + const [firstCallArg] = writeText.mock.calls[0] ?? []; + expect(firstCallArg).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + }); + + it('toolbox jwt returns header and payload results', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); + + const provider = createCommandsProvider(); + const token = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; + + const ctx: SearchProviderContext = { + requestedScope: 'commands', + query: { raw: `jwt ${token}`, text: `jwt ${token}`, tokens: ['jwt', token] }, + limit: 20, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + const header = results.find((r) => r.id === 'toolbox.jwt.header'); + const payload = results.find((r) => r.id === 'toolbox.jwt.payload'); + expect(header).toBeTruthy(); + expect(payload).toBeTruthy(); + + const headerAction = provider.getActions(header as any)[0]; + await headerAction.execute({ result: header } as any); + expect(writeText).toHaveBeenCalledWith(JSON.stringify({ alg: 'HS256', typ: 'JWT' }, null, 2)); + + const payloadAction = provider.getActions(payload as any)[0]; + await payloadAction.execute({ result: payload } as any); + expect(writeText).toHaveBeenCalledWith( + JSON.stringify({ sub: '1234567890', name: 'John Doe', iat: 1516239022 }, null, 2), + ); + }); +}); diff --git a/app/chrome-extension/tests/quick-panel/content-provider.test.ts b/app/chrome-extension/tests/quick-panel/content-provider.test.ts new file mode 100644 index 0000000..026fee6 --- /dev/null +++ b/app/chrome-extension/tests/quick-panel/content-provider.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createContentProvider } from '@/shared/quick-panel/providers/content-provider'; +import type { SearchProviderContext } from '@/shared/quick-panel/core/types'; +import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types'; + +describe('Quick Panel content provider', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('maps background matches to SearchResult items', async () => { + const sendMessage = vi.fn().mockResolvedValue({ + success: true, + items: [ + { + tabId: 10, + windowId: 2, + url: 'https://example.com/docs', + title: 'Docs', + favIconUrl: 'https://example.com/favicon.ico', + snippet: '…example snippet…', + score: 123, + }, + ], + }); + + vi.stubGlobal('chrome', { + runtime: { sendMessage }, + }); + + const provider = createContentProvider(); + + const ctx: SearchProviderContext = { + requestedScope: 'content', + query: { raw: 'docs', text: 'docs', tokens: ['docs'] }, + limit: 10, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + expect(results).toHaveLength(1); + expect(results[0]?.provider).toBe('content'); + expect(results[0]?.title).toBe('Docs'); + expect(results[0]?.subtitle).toBe('…example snippet…'); + + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CONTENT_QUERY, + payload: { query: 'docs', maxResults: 10 }, + }); + }); + + it('honors openMode=new_tab by opening the URL instead of switching tabs', async () => { + const sendMessage = vi.fn().mockResolvedValue({ success: true }); + + vi.stubGlobal('chrome', { + runtime: { sendMessage }, + }); + + const provider = createContentProvider(); + + const result = { + id: '10', + provider: 'content', + title: 'Docs', + subtitle: '…example snippet…', + icon: '📄', + data: { + tabId: 10, + windowId: 2, + url: 'https://example.com/docs', + title: 'Docs', + favIconUrl: undefined, + snippet: '…example snippet…', + }, + score: 1, + }; + + const actions = provider.getActions(result as any); + const open = actions[0]; + expect(open?.id).toBe('content.open'); + + await open.execute({ result, openMode: 'new_tab' } as any); + + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_OPEN_URL, + payload: { url: 'https://example.com/docs', disposition: 'new_tab' }, + }); + }); +}); diff --git a/app/chrome-extension/tests/quick-panel/content-search.test.ts b/app/chrome-extension/tests/quick-panel/content-search.test.ts new file mode 100644 index 0000000..d25b42e --- /dev/null +++ b/app/chrome-extension/tests/quick-panel/content-search.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { + createContentSnippet, + scoreTokensAgainstNormalizedText, +} from '@/shared/quick-panel/core/content-search'; + +describe('Quick Panel content search utilities', () => { + it('scores tokens with AND semantics', () => { + expect(scoreTokensAgainstNormalizedText('hello world', [])).toBe(0); + + const single = scoreTokensAgainstNormalizedText('hello world', ['hello']); + expect(single).toBeGreaterThan(80); + + const both = scoreTokensAgainstNormalizedText('hello world', ['hello', 'world']); + expect(both).toBeGreaterThan(50); + + const missing = scoreTokensAgainstNormalizedText('hello world', ['hello', 'missing']); + expect(missing).toBe(0); + }); + + it('creates a snippet around the first matched token', () => { + const content = `${'x '.repeat(80)}token ${'y '.repeat(80)}`.trim(); + const snippet = createContentSnippet(content, ['token']); + + expect(snippet).toContain('token'); + expect(snippet.startsWith('…')).toBe(true); + expect(snippet.endsWith('…')).toBe(true); + expect(snippet.length).toBeLessThanOrEqual(220); + }); + + it('creates a snippet from the start when no token matches', () => { + const content = `${'alpha '.repeat(100)}omega`.trim(); + const snippet = createContentSnippet(content, ['missing']); + + expect(snippet.startsWith('…')).toBe(false); + expect(snippet.endsWith('…')).toBe(true); + expect(snippet.length).toBeLessThanOrEqual(220); + }); +}); diff --git a/app/chrome-extension/tests/quick-panel/focus-provider.test.ts b/app/chrome-extension/tests/quick-panel/focus-provider.test.ts new file mode 100644 index 0000000..1cb9c0c --- /dev/null +++ b/app/chrome-extension/tests/quick-panel/focus-provider.test.ts @@ -0,0 +1,237 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createFocusProvider } from '@/shared/quick-panel/providers/focus-provider'; +import type { SearchProviderContext } from '@/shared/quick-panel/core/types'; +import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types'; + +describe('Quick Panel focus provider', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + function makeIdleStatus() { + return { + incognito: false, + now: Date.now(), + session: { + phase: 'idle' as const, + startedAt: 0, + endsAt: 0, + remainingMs: 0, + durationMs: 0, + updatedAt: Date.now(), + }, + blockingEnabled: false, + blockingActive: false, + blockingSnoozedUntil: 0, + blocklist: [] as string[], + }; + } + + it('shows status and start presets when idle', async () => { + const sendMessage = vi.fn().mockImplementation((msg: any) => { + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_STATUS) { + return Promise.resolve({ success: true, status: makeIdleStatus() }); + } + return Promise.resolve({ success: true, status: makeIdleStatus() }); + }); + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createFocusProvider(); + const ctx: SearchProviderContext = { + requestedScope: 'focus', + query: { raw: '', text: '', tokens: [] }, + limit: 20, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + expect(results.some((r) => r.id === 'focus.status')).toBe(true); + expect(results.some((r) => r.id === 'focus.start.25')).toBe(true); + }); + + it('starts a focus session with numeric query', async () => { + const sendMessage = vi.fn().mockImplementation((msg: any) => { + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_STATUS) { + return Promise.resolve({ success: true, status: makeIdleStatus() }); + } + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_START) { + return Promise.resolve({ success: true, status: makeIdleStatus() }); + } + return Promise.resolve({ success: true, status: makeIdleStatus() }); + }); + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createFocusProvider(); + const ctx: SearchProviderContext = { + requestedScope: 'focus', + query: { raw: '25', text: '25', tokens: ['25'] }, + limit: 20, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + expect(results[0]?.id).toBe('focus.start.25'); + + const action = provider.getActions(results[0] as any)[0]; + await action.execute({ result: results[0] as any } as any); + + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_START, + payload: { durationMinutes: 25 }, + }); + }); + + it('toggles blocking via status actions', async () => { + const sendMessage = vi.fn().mockImplementation((msg: any) => { + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_STATUS) { + return Promise.resolve({ success: true, status: makeIdleStatus() }); + } + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_SET_BLOCKING_ENABLED) { + return Promise.resolve({ success: true, status: makeIdleStatus() }); + } + return Promise.resolve({ success: true, status: makeIdleStatus() }); + }); + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createFocusProvider(); + const ctx: SearchProviderContext = { + requestedScope: 'focus', + query: { raw: '', text: '', tokens: [] }, + limit: 20, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + const status = results.find((r) => r.id === 'focus.status')!; + const toggle = provider.getActions(status as any).find((a) => a.id === 'focus.toggleBlocking')!; + + await toggle.execute({ result: status as any } as any); + + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_SET_BLOCKING_ENABLED, + payload: { enabled: true }, + }); + }); + + it('sets blocklist via `block` command', async () => { + const sendMessage = vi.fn().mockImplementation((msg: any) => { + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_STATUS) { + return Promise.resolve({ success: true, status: makeIdleStatus() }); + } + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_SET_BLOCKLIST) { + return Promise.resolve({ success: true, status: makeIdleStatus() }); + } + return Promise.resolve({ success: true, status: makeIdleStatus() }); + }); + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createFocusProvider(); + const ctx: SearchProviderContext = { + requestedScope: 'focus', + query: { + raw: 'block youtube.com twitter.com', + text: 'block youtube.com twitter.com', + tokens: ['block', 'youtube.com', 'twitter.com'], + }, + limit: 20, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + const entry = results.find((r) => r.id === 'focus.blocklist.set')!; + const action = provider.getActions(entry as any)[0]; + await action.execute({ result: entry as any } as any); + + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_SET_BLOCKLIST, + payload: { domains: ['youtube.com', 'twitter.com'] }, + }); + }); + + it('snoozes blocking via `snooze` command', async () => { + const running = { + ...makeIdleStatus(), + session: { + phase: 'running' as const, + startedAt: Date.now(), + endsAt: Date.now() + 25 * 60_000, + remainingMs: 25 * 60_000, + durationMs: 25 * 60_000, + updatedAt: Date.now(), + }, + blockingEnabled: true, + blockingActive: true, + blockingSnoozedUntil: 0, + blocklist: ['youtube.com'], + }; + + const sendMessage = vi.fn().mockImplementation((msg: any) => { + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_STATUS) { + return Promise.resolve({ success: true, status: running }); + } + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_SNOOZE_BLOCKING) { + return Promise.resolve({ success: true, status: running }); + } + return Promise.resolve({ success: true, status: running }); + }); + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createFocusProvider(); + const ctx: SearchProviderContext = { + requestedScope: 'focus', + query: { raw: 'snooze 5', text: 'snooze 5', tokens: ['snooze', '5'] }, + limit: 20, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + const entry = results.find((r) => r.id === 'focus.blocking.snooze.5')!; + const action = provider.getActions(entry as any)[0]; + await action.execute({ result: entry as any } as any); + + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_SNOOZE_BLOCKING, + payload: { minutes: 5 }, + }); + }); + + it('resumes blocking via `resume-blocking` command', async () => { + const status = makeIdleStatus(); + const sendMessage = vi.fn().mockImplementation((msg: any) => { + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_STATUS) { + return Promise.resolve({ success: true, status }); + } + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_RESUME_BLOCKING) { + return Promise.resolve({ success: true, status }); + } + return Promise.resolve({ success: true, status }); + }); + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createFocusProvider(); + const ctx: SearchProviderContext = { + requestedScope: 'focus', + query: { raw: 'resume-blocking', text: 'resume-blocking', tokens: ['resume-blocking'] }, + limit: 20, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + expect(results[0]?.id).toBe('focus.blocking.resume'); + const action = provider.getActions(results[0] as any)[0]; + await action.execute({ result: results[0] as any } as any); + + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_FOCUS_RESUME_BLOCKING, + payload: {}, + }); + }); +}); diff --git a/app/chrome-extension/tests/quick-panel/keyboard-controller.test.ts b/app/chrome-extension/tests/quick-panel/keyboard-controller.test.ts new file mode 100644 index 0000000..2c841f6 --- /dev/null +++ b/app/chrome-extension/tests/quick-panel/keyboard-controller.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createKeyboardController } from '@/shared/quick-panel/core/keyboard-controller'; + +function createShadowDomInput(): { + shadowRoot: ShadowRoot; + input: HTMLInputElement; + dispose: () => void; +} { + const host = document.createElement('div'); + document.body.appendChild(host); + + const shadowRoot = host.attachShadow({ mode: 'open' }); + const input = document.createElement('input'); + input.type = 'text'; + shadowRoot.appendChild(input); + + return { shadowRoot, input, dispose: () => host.remove() }; +} + +describe('Quick Panel keyboard controller', () => { + it('does not intercept ArrowLeft inside input when query is not empty', () => { + const { shadowRoot, input, dispose } = createShadowDomInput(); + + const onBack = vi.fn(); + + const keyboard = createKeyboardController({ + shadowRoot, + getCurrentView: () => 'search', + isActionPanelOpen: () => false, + isInputEmpty: () => input.value.trim().length === 0, + onBack, + }); + keyboard.enable(); + + input.value = 'abc'; + input.setSelectionRange(1, 1); + + const ev = new KeyboardEvent('keydown', { + key: 'ArrowLeft', + bubbles: true, + cancelable: true, + composed: true, + }); + input.dispatchEvent(ev); + + expect(onBack).not.toHaveBeenCalled(); + expect(ev.defaultPrevented).toBe(false); + + keyboard.dispose(); + dispose(); + }); + + it('intercepts ArrowLeft inside input when query is empty and caret is at start', () => { + const { shadowRoot, input, dispose } = createShadowDomInput(); + + const onBack = vi.fn(); + + const keyboard = createKeyboardController({ + shadowRoot, + getCurrentView: () => 'search', + isActionPanelOpen: () => false, + isInputEmpty: () => input.value.trim().length === 0, + onBack, + }); + keyboard.enable(); + + input.value = ''; + input.setSelectionRange(0, 0); + + const ev = new KeyboardEvent('keydown', { + key: 'ArrowLeft', + bubbles: true, + cancelable: true, + composed: true, + }); + input.dispatchEvent(ev); + + expect(onBack).toHaveBeenCalledTimes(1); + expect(ev.defaultPrevented).toBe(true); + + keyboard.dispose(); + dispose(); + }); + + it('intercepts ArrowRight inside input when caret is at end (opens action panel)', () => { + const { shadowRoot, input, dispose } = createShadowDomInput(); + + const onOpenActionPanel = vi.fn(); + + const keyboard = createKeyboardController({ + shadowRoot, + getCurrentView: () => 'search', + isActionPanelOpen: () => false, + isInputEmpty: () => input.value.trim().length === 0, + onOpenActionPanel, + }); + keyboard.enable(); + + input.value = 'abc'; + input.setSelectionRange(3, 3); + + const ev = new KeyboardEvent('keydown', { + key: 'ArrowRight', + bubbles: true, + cancelable: true, + composed: true, + }); + input.dispatchEvent(ev); + + expect(onOpenActionPanel).toHaveBeenCalledTimes(1); + expect(ev.defaultPrevented).toBe(true); + + keyboard.dispose(); + dispose(); + }); + + it('does not intercept ArrowRight inside input when caret is not at end', () => { + const { shadowRoot, input, dispose } = createShadowDomInput(); + + const onOpenActionPanel = vi.fn(); + + const keyboard = createKeyboardController({ + shadowRoot, + getCurrentView: () => 'search', + isActionPanelOpen: () => false, + isInputEmpty: () => input.value.trim().length === 0, + onOpenActionPanel, + }); + keyboard.enable(); + + input.value = 'abc'; + input.setSelectionRange(1, 1); + + const ev = new KeyboardEvent('keydown', { + key: 'ArrowRight', + bubbles: true, + cancelable: true, + composed: true, + }); + input.dispatchEvent(ev); + + expect(onOpenActionPanel).not.toHaveBeenCalled(); + expect(ev.defaultPrevented).toBe(false); + + keyboard.dispose(); + dispose(); + }); +}); diff --git a/app/chrome-extension/tests/quick-panel/monitor-provider.test.ts b/app/chrome-extension/tests/quick-panel/monitor-provider.test.ts new file mode 100644 index 0000000..ba3c736 --- /dev/null +++ b/app/chrome-extension/tests/quick-panel/monitor-provider.test.ts @@ -0,0 +1,219 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createMonitorProvider } from '@/shared/quick-panel/providers/monitor-provider'; +import type { SearchProviderContext } from '@/shared/quick-panel/core/types'; +import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types'; + +describe('Quick Panel monitor provider', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('shows empty state when no monitors exist', async () => { + const sendMessage = vi.fn().mockImplementation((msg: any) => { + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_LIST) { + return Promise.resolve({ success: true, monitors: [], alerts: [], unreadCount: 0 }); + } + return Promise.resolve({ success: true }); + }); + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createMonitorProvider(); + const ctx: SearchProviderContext = { + requestedScope: 'monitor', + query: { raw: '', text: '', tokens: [] }, + limit: 20, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + expect(results.some((r) => r.id === 'monitor.summary')).toBe(true); + expect(results.some((r) => r.id === 'monitor.empty')).toBe(true); + }); + + it('creates a virtual create entry from ` ` and sends create message', async () => { + const sendMessage = vi.fn().mockImplementation((msg: any) => { + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_LIST) { + return Promise.resolve({ success: true, monitors: [], alerts: [], unreadCount: 0 }); + } + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_CREATE) { + return Promise.resolve({ + success: true, + monitor: { + id: 'm1', + url: msg.payload.url, + extractor: 'selector_text', + selector: msg.payload.selector, + intervalMinutes: msg.payload.intervalMinutes, + enabled: true, + incognito: false, + createdAt: 1, + updatedAt: 1, + lastCheckedAt: 0, + lastChangedAt: 0, + unreadAlerts: 0, + }, + }); + } + return Promise.resolve({ success: true }); + }); + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createMonitorProvider(); + const ctx: SearchProviderContext = { + requestedScope: 'monitor', + query: { + raw: 'https://example.com .price', + text: 'https://example.com .price', + tokens: ['https://example.com', '.price'], + }, + limit: 20, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + const create = results.find((r) => r.id.startsWith('monitor.create.'))!; + expect(create).toBeTruthy(); + + const action = provider.getActions(create as any)[0]!; + await action.execute({ result: create as any } as any); + + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_CREATE, + payload: { + url: 'https://example.com/', + selector: '.price', + intervalMinutes: 15, + fetchNow: true, + }, + }); + }); + + it('checks a monitor via QUICK_PANEL_MONITOR_CHECK_NOW', async () => { + const sendMessage = vi.fn().mockImplementation((msg: any) => { + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_LIST) { + return Promise.resolve({ + success: true, + unreadCount: 0, + monitors: [ + { + id: 'm1', + url: 'https://example.com', + extractor: 'selector_text', + selector: '.price', + intervalMinutes: 15, + enabled: true, + incognito: false, + createdAt: 1, + updatedAt: 2, + lastCheckedAt: 3, + lastChangedAt: 0, + unreadAlerts: 0, + }, + ], + alerts: [], + }); + } + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_CHECK_NOW) { + return Promise.resolve({ + success: true, + monitor: { + id: 'm1', + url: 'https://example.com', + extractor: 'selector_text', + selector: '.price', + intervalMinutes: 15, + enabled: true, + incognito: false, + createdAt: 1, + updatedAt: 2, + lastCheckedAt: 3, + lastChangedAt: 0, + unreadAlerts: 0, + }, + }); + } + return Promise.resolve({ success: true }); + }); + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createMonitorProvider(); + const ctx: SearchProviderContext = { + requestedScope: 'monitor', + query: { raw: '', text: '', tokens: [] }, + limit: 20, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + const monitorResult = results.find((r) => r.id === 'monitor.m1')!; + expect(monitorResult).toBeTruthy(); + + const check = provider + .getActions(monitorResult as any) + .find((a) => a.id === 'monitor.checkNow')!; + await check.execute({ result: monitorResult as any } as any); + + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_CHECK_NOW, + payload: { id: 'm1' }, + }); + }); + + it('marks an alert read via QUICK_PANEL_MONITOR_ALERT_MARK_READ', async () => { + const sendMessage = vi.fn().mockImplementation((msg: any) => { + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_LIST) { + return Promise.resolve({ + success: true, + unreadCount: 1, + monitors: [], + alerts: [ + { + id: 'a1', + monitorId: 'm1', + incognito: false, + createdAt: 1, + url: 'https://example.com', + selector: '.price', + oldValue: '$1', + newValue: '$2', + read: false, + }, + ], + }); + } + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_ALERT_MARK_READ) { + return Promise.resolve({ success: true, unreadCount: 0 }); + } + return Promise.resolve({ success: true }); + }); + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createMonitorProvider(); + const ctx: SearchProviderContext = { + requestedScope: 'monitor', + query: { raw: '', text: '', tokens: [] }, + limit: 20, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + const alertResult = results.find((r) => r.id === 'monitor.alert.a1')!; + expect(alertResult).toBeTruthy(); + + const mark = provider + .getActions(alertResult as any) + .find((a) => a.id === 'monitor.alert.markRead')!; + await mark.execute({ result: alertResult as any } as any); + + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_MONITOR_ALERT_MARK_READ, + payload: { id: 'a1', read: true }, + }); + }); +}); diff --git a/app/chrome-extension/tests/quick-panel/notes-provider.test.ts b/app/chrome-extension/tests/quick-panel/notes-provider.test.ts new file mode 100644 index 0000000..9c905ba --- /dev/null +++ b/app/chrome-extension/tests/quick-panel/notes-provider.test.ts @@ -0,0 +1,169 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createNotesProvider } from '@/shared/quick-panel/providers/notes-provider'; +import type { SearchProviderContext } from '@/shared/quick-panel/core/types'; +import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types'; + +describe('Quick Panel notes provider', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + function stubClipboard() { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + return writeText; + } + + it('shows a create entry when query is non-empty', async () => { + const sendMessage = vi.fn().mockImplementation((msg: any) => { + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_NOTES_LIST) { + return Promise.resolve({ success: true, items: [] }); + } + return Promise.resolve({ success: true }); + }); + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createNotesProvider(); + const ctx: SearchProviderContext = { + requestedScope: 'notes', + query: { raw: 'hello', text: 'hello', tokens: ['hello'] }, + limit: 20, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + expect(results.some((r) => r.id === 'notes.create')).toBe(true); + }); + + it('creates a note via QUICK_PANEL_NOTES_CREATE', async () => { + const sendMessage = vi.fn().mockImplementation((msg: any) => { + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_NOTES_CREATE) { + return Promise.resolve({ + success: true, + note: { + id: 'n1', + title: 'hello', + preview: 'hello', + createdAt: 1, + updatedAt: 1, + incognito: false, + }, + }); + } + return Promise.resolve({ success: true, items: [] }); + }); + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createNotesProvider(); + const action = provider.getActions({ + id: 'notes.create', + provider: 'notes', + title: 'Create note', + score: 1, + data: { kind: 'create', content: 'hello' }, + } as any)[0]; + + await action.execute({ result: {} as any } as any); + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_NOTES_CREATE, + payload: { content: 'hello' }, + }); + }); + + it('copies a note via GET + clipboard', async () => { + const writeText = stubClipboard(); + + const sendMessage = vi.fn().mockImplementation((msg: any) => { + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_NOTES_LIST) { + return Promise.resolve({ + success: true, + items: [ + { + id: 'n1', + title: 'Todo', + preview: 'Buy milk', + createdAt: 1, + updatedAt: 2, + incognito: false, + }, + ], + }); + } + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_NOTES_GET) { + return Promise.resolve({ + success: true, + note: { + id: 'n1', + title: 'Todo', + preview: 'Buy milk', + content: 'Buy milk', + createdAt: 1, + updatedAt: 2, + incognito: false, + }, + }); + } + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CLIPBOARD_RECORD) { + return Promise.resolve({ success: true }); + } + return Promise.resolve({ success: true }); + }); + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createNotesProvider(); + const ctx: SearchProviderContext = { + requestedScope: 'notes', + query: { raw: '', text: '', tokens: [] }, + limit: 20, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + const note = results.find((r) => r.id === 'note.n1')!; + expect(note).toBeTruthy(); + + const copy = provider.getActions(note as any).find((a) => a.id === 'notes.copy')!; + await copy.execute({ result: note as any } as any); + + expect(writeText).toHaveBeenCalledWith('Buy milk'); + }); + + it('deletes a note via QUICK_PANEL_NOTES_DELETE', async () => { + const sendMessage = vi.fn().mockImplementation((msg: any) => { + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_NOTES_DELETE) { + return Promise.resolve({ success: true }); + } + return Promise.resolve({ success: true }); + }); + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createNotesProvider(); + const result = { + id: 'note.n1', + provider: 'notes', + title: 'Todo', + score: 1, + data: { + kind: 'note', + id: 'n1', + title: 'Todo', + preview: 'Buy milk', + createdAt: 1, + updatedAt: 2, + incognito: false, + }, + }; + + const del = provider.getActions(result as any).find((a) => a.id === 'notes.delete')!; + await del.execute({ result } as any); + + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_NOTES_DELETE, + payload: { id: 'n1' }, + }); + }); +}); diff --git a/app/chrome-extension/tests/quick-panel/search-input-prefix.test.ts b/app/chrome-extension/tests/quick-panel/search-input-prefix.test.ts new file mode 100644 index 0000000..023268e --- /dev/null +++ b/app/chrome-extension/tests/quick-panel/search-input-prefix.test.ts @@ -0,0 +1,162 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { createSearchInput } from '@/shared/quick-panel/ui/search-input'; + +describe('Quick Panel search input prefix scopes', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('allows prefix-only scopes that are not in the cycle list', () => { + const container = document.createElement('div'); + document.body.append(container); + + const input = createSearchInput({ + container, + initialScope: 'all', + availableScopes: ['all', 'tabs', 'commands'], // web scopes are intentionally excluded + }); + + input.input.value = 'g React Hooks'; + input.input.dispatchEvent(new Event('input', { bubbles: true })); + + expect(input.getState().scope).toBe('web_google'); + expect(input.getState().query).toBe('React Hooks'); + expect(input.input.value).toBe('React Hooks'); + + input.dispose(); + }); + + it('prefers longer prefixes over shorter ones', () => { + const container = document.createElement('div'); + document.body.append(container); + + const input = createSearchInput({ + container, + initialScope: 'all', + availableScopes: ['all', 'tabs', 'commands'], + }); + + input.input.value = 'gh openai'; + input.input.dispatchEvent(new Event('input', { bubbles: true })); + + expect(input.getState().scope).toBe('web_github'); + expect(input.getState().query).toBe('openai'); + + input.dispose(); + }); + + it('supports ws prefix for workspaces scope', () => { + const container = document.createElement('div'); + document.body.append(container); + + const input = createSearchInput({ + container, + initialScope: 'all', + availableScopes: ['all', 'tabs', 'commands'], + }); + + input.input.value = 'ws Project X'; + input.input.dispatchEvent(new Event('input', { bubbles: true })); + + expect(input.getState().scope).toBe('workspaces'); + expect(input.getState().query).toBe('Project X'); + + input.dispose(); + }); + + it('supports clip prefix for clipboard scope', () => { + const container = document.createElement('div'); + document.body.append(container); + + const input = createSearchInput({ + container, + initialScope: 'all', + availableScopes: ['all', 'tabs', 'commands'], + }); + + input.input.value = 'clip hello world'; + input.input.dispatchEvent(new Event('input', { bubbles: true })); + + expect(input.getState().scope).toBe('clipboard'); + expect(input.getState().query).toBe('hello world'); + + input.dispose(); + }); + + it('supports note prefix for notes scope', () => { + const container = document.createElement('div'); + document.body.append(container); + + const input = createSearchInput({ + container, + initialScope: 'all', + availableScopes: ['all', 'tabs', 'commands'], + }); + + input.input.value = 'note buy milk'; + input.input.dispatchEvent(new Event('input', { bubbles: true })); + + expect(input.getState().scope).toBe('notes'); + expect(input.getState().query).toBe('buy milk'); + + input.dispose(); + }); + + it('supports focus prefix for focus scope', () => { + const container = document.createElement('div'); + document.body.append(container); + + const input = createSearchInput({ + container, + initialScope: 'all', + availableScopes: ['all', 'tabs', 'commands'], + }); + + input.input.value = 'focus start 25'; + input.input.dispatchEvent(new Event('input', { bubbles: true })); + + expect(input.getState().scope).toBe('focus'); + expect(input.getState().query).toBe('start 25'); + + input.dispose(); + }); + + it('supports mon prefix for monitor scope', () => { + const container = document.createElement('div'); + document.body.append(container); + + const input = createSearchInput({ + container, + initialScope: 'all', + availableScopes: ['all', 'tabs', 'commands'], + }); + + input.input.value = 'mon https://example.com .price'; + input.input.dispatchEvent(new Event('input', { bubbles: true })); + + expect(input.getState().scope).toBe('monitor'); + expect(input.getState().query).toBe('https://example.com .price'); + + input.dispose(); + }); + + it('supports audit prefix for audit scope', () => { + const container = document.createElement('div'); + document.body.append(container); + + const input = createSearchInput({ + container, + initialScope: 'all', + availableScopes: ['all', 'tabs', 'commands'], + }); + + input.input.value = 'audit chrome_navigate'; + input.input.dispatchEvent(new Event('input', { bubbles: true })); + + expect(input.getState().scope).toBe('audit'); + expect(input.getState().query).toBe('chrome_navigate'); + + input.dispose(); + }); +}); diff --git a/app/chrome-extension/tests/quick-panel/toolbox.test.ts b/app/chrome-extension/tests/quick-panel/toolbox.test.ts new file mode 100644 index 0000000..f41c7cf --- /dev/null +++ b/app/chrome-extension/tests/quick-panel/toolbox.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import { + base64DecodeUtf8, + base64EncodeUtf8, + convertUnixTimestamp, + decodeJwt, + formatJson, + generateUuidV4, + urlDecode, + urlEncode, +} from '@/shared/quick-panel/core/toolbox'; + +describe('Quick Panel toolbox', () => { + it('formats json (pretty + minified)', () => { + const res = formatJson('{"a":1,"b":[2,3]}'); + expect(res.ok).toBe(true); + if (!res.ok) return; + expect(res.value.minified).toBe('{"a":1,"b":[2,3]}'); + expect(res.value.pretty).toContain('\n'); + expect(res.value.pretty).toContain('"b": ['); + }); + + it('base64 encodes and decodes utf-8', () => { + const encoded = base64EncodeUtf8('你好, world'); + expect(encoded.ok).toBe(true); + if (!encoded.ok) return; + + const decoded = base64DecodeUtf8(encoded.value); + expect(decoded.ok).toBe(true); + if (!decoded.ok) return; + expect(decoded.value).toBe('你好, world'); + }); + + it('url encodes and decodes', () => { + const encoded = urlEncode('你好 world?x=1'); + expect(encoded.ok).toBe(true); + if (!encoded.ok) return; + + const decoded = urlDecode(encoded.value); + expect(decoded.ok).toBe(true); + if (!decoded.ok) return; + expect(decoded.value).toBe('你好 world?x=1'); + }); + + it('converts unix timestamp in seconds and milliseconds', () => { + const s = convertUnixTimestamp('1699123456'); + expect(s.ok).toBe(true); + if (!s.ok) return; + expect(s.value.seconds).toBe(1699123456); + expect(s.value.milliseconds).toBe(1699123456000); + expect(s.value.iso).toBe(new Date(1699123456000).toISOString()); + + const ms = convertUnixTimestamp('1699123456000'); + expect(ms.ok).toBe(true); + if (!ms.ok) return; + expect(ms.value.seconds).toBe(1699123456); + expect(ms.value.milliseconds).toBe(1699123456000); + }); + + it('generates uuid v4', () => { + const id = generateUuidV4(); + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + }); + + it('decodes jwt header and payload', () => { + const token = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; + const res = decodeJwt(token); + expect(res.ok).toBe(true); + if (!res.ok) return; + + expect(res.value.header).toMatchObject({ alg: 'HS256', typ: 'JWT' }); + expect(res.value.payload).toMatchObject({ + sub: '1234567890', + name: 'John Doe', + iat: 1516239022, + }); + }); +}); diff --git a/app/chrome-extension/tests/quick-panel/url-template.test.ts b/app/chrome-extension/tests/quick-panel/url-template.test.ts new file mode 100644 index 0000000..843c25f --- /dev/null +++ b/app/chrome-extension/tests/quick-panel/url-template.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { buildSearchUrl } from '@/shared/quick-panel/core/url-template'; + +describe('Quick Panel url-template', () => { + it('fills {query} using normalized encoding', () => { + const url = buildSearchUrl( + { template: 'https://www.google.com/search?q={query}' }, + 'React Hooks', + ); + expect(url).toBe('https://www.google.com/search?q=React%20Hooks'); + }); + + it('fills {rawQuery} using trim-only encoding', () => { + const url = buildSearchUrl( + { template: 'https://www.google.com/search?q={rawQuery}' }, + ' a b ', + ); + expect(url).toBe('https://www.google.com/search?q=a%20%20b'); + }); + + it('supports templates with both placeholders', () => { + const url = buildSearchUrl( + { template: 'https://example.com/?q={query}&raw={rawQuery}' }, + ' Hello World ', + ); + expect(url).toBe('https://example.com/?q=Hello%20World&raw=Hello%20%20%20World'); + }); + + it('rejects templates without placeholders', () => { + expect(() => buildSearchUrl({ template: 'https://example.com/' }, 'x')).toThrow( + /must include/i, + ); + }); + + it('rejects empty templates', () => { + expect(() => buildSearchUrl({ template: '' }, 'x')).toThrow(/required/i); + }); +}); diff --git a/app/chrome-extension/tests/quick-panel/web-search-provider.test.ts b/app/chrome-extension/tests/quick-panel/web-search-provider.test.ts new file mode 100644 index 0000000..29458e5 --- /dev/null +++ b/app/chrome-extension/tests/quick-panel/web-search-provider.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createWebSearchProvider } from '@/shared/quick-panel/providers/web-search-provider'; +import type { SearchProviderContext } from '@/shared/quick-panel/core/types'; +import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types'; + +describe('Quick Panel web search provider', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('builds a search URL and honors openMode via QUICK_PANEL_OPEN_URL', async () => { + const sendMessage = vi.fn().mockResolvedValue({ success: true }); + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createWebSearchProvider(); + + const ctx: SearchProviderContext = { + requestedScope: 'web_google', + query: { raw: 'React Hooks', text: 'react hooks', tokens: ['react', 'hooks'] }, + limit: 10, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + expect(results).toHaveLength(1); + expect(results[0]?.title).toBe('Search Google'); + expect(results[0]?.subtitle).toBe('React Hooks'); + expect(results[0]?.data.url).toBe('https://www.google.com/search?q=React%20Hooks'); + + const open = provider.getActions(results[0] as any)[0]; + await open.execute({ result: results[0], openMode: 'new_tab' } as any); + + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_OPEN_URL, + payload: { url: 'https://www.google.com/search?q=React%20Hooks', disposition: 'new_tab' }, + }); + }); +}); diff --git a/app/chrome-extension/tests/quick-panel/workspaces-provider.test.ts b/app/chrome-extension/tests/quick-panel/workspaces-provider.test.ts new file mode 100644 index 0000000..58405d6 --- /dev/null +++ b/app/chrome-extension/tests/quick-panel/workspaces-provider.test.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createWorkspacesProvider } from '@/shared/quick-panel/providers/workspaces-provider'; +import type { SearchProviderContext } from '@/shared/quick-panel/core/types'; +import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types'; + +describe('Quick Panel workspaces provider', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('shows save current session entry on empty query', async () => { + const sendMessage = vi.fn(async (msg: any) => { + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_WORKSPACES_LIST) { + return { + success: true, + items: [ + { + id: 'ws_1', + name: 'My session', + tabCount: 12, + createdAt: 1, + updatedAt: 2, + incognito: false, + }, + ], + }; + } + throw new Error('Unexpected message'); + }); + + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createWorkspacesProvider(); + const ctx: SearchProviderContext = { + requestedScope: 'workspaces', + query: { raw: '', text: '', tokens: [] }, + limit: 10, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + expect(results[0]?.id).toBe('workspaces.saveCurrent'); + expect(results.some((r) => r.id === 'ws_1')).toBe(true); + }); + + it('shows save-as entry when query has no exact match', async () => { + const sendMessage = vi.fn(async (msg: any) => { + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_WORKSPACES_LIST) { + return { + success: true, + items: [ + { + id: 'ws_1', + name: 'Other', + tabCount: 2, + createdAt: 1, + updatedAt: 2, + incognito: false, + }, + ], + }; + } + throw new Error('Unexpected message'); + }); + + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createWorkspacesProvider(); + const ctx: SearchProviderContext = { + requestedScope: 'workspaces', + query: { raw: 'Project X', text: 'project x', tokens: ['project', 'x'] }, + limit: 10, + signal: new AbortController().signal, + now: Date.now(), + }; + + const results = await provider.search(ctx); + expect(results[0]?.id).toBe(`workspaces.saveAs:${encodeURIComponent('Project X')}`); + }); + + it('open action maps openMode to new_window vs current_window', async () => { + const sendMessage = vi.fn(async (msg: any) => { + if (msg?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_WORKSPACES_OPEN) { + return { success: true, openedCount: 1, totalCount: 1 }; + } + throw new Error('Unexpected message'); + }); + + vi.stubGlobal('chrome', { runtime: { sendMessage } }); + + const provider = createWorkspacesProvider(); + const result = { + id: 'ws_1', + provider: 'workspaces', + title: 'My session', + subtitle: '1 tabs', + icon: '🗃️', + data: { + kind: 'workspace', + workspaceId: 'ws_1', + name: 'My session', + tabCount: 1, + createdAt: 1, + updatedAt: 2, + incognito: false, + }, + score: 100, + }; + + const open = provider.getActions(result as any)[0]; + await open.execute({ result, openMode: 'current_tab' } as any); + + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_WORKSPACES_OPEN, + payload: { workspaceId: 'ws_1', target: 'new_window' }, + }); + + await open.execute({ result, openMode: 'new_tab' } as any); + + expect(sendMessage).toHaveBeenCalledWith({ + type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_WORKSPACES_OPEN, + payload: { workspaceId: 'ws_1', target: 'current_window' }, + }); + }); +}); diff --git a/app/chrome-extension/tests/record-replay-v3/control-flow.test.ts b/app/chrome-extension/tests/record-replay-v3/control-flow.test.ts index d719b57..8d7ae46 100644 --- a/app/chrome-extension/tests/record-replay-v3/control-flow.test.ts +++ b/app/chrome-extension/tests/record-replay-v3/control-flow.test.ts @@ -839,6 +839,23 @@ describe('Control Flow Infrastructure', () => { expect(result.status).toBe('succeeded'); // With inline=false, target flow's var change should NOT affect caller expect(runner.getVar('result')).toBe('initial'); + + // Event stream should also reconstruct the same final vars (debug/replay consistency). + const reconstructed: Record = { result: 'initial' }; + for (const event of eventsBus.events) { + if (event.type !== 'vars.patch') continue; + const patch = ( + event as unknown as { patch: Array<{ op: string; name: string; value?: unknown }> } + ).patch; + for (const op of patch) { + if (op.op === 'set') { + reconstructed[op.name] = op.value ?? null; + } else if (op.op === 'delete') { + delete reconstructed[op.name]; + } + } + } + expect(reconstructed['result']).toBe('initial'); }); it('should pass args to target flow', async () => { diff --git a/app/chrome-extension/tests/record-replay-v3/rpc-api.test.ts b/app/chrome-extension/tests/record-replay-v3/rpc-api.test.ts index 3e26390..24e8673 100644 --- a/app/chrome-extension/tests/record-replay-v3/rpc-api.test.ts +++ b/app/chrome-extension/tests/record-replay-v3/rpc-api.test.ts @@ -321,6 +321,26 @@ describe('V3 RPC Queue Management APIs', () => { ); }); + it('persists tabId into the queue item when provided', async () => { + const flow = createTestFlow('flow-1'); + getInternal(storage).flowsMap.set(flow.id, flow); + + await (server as unknown as { handleRequest: Function }).handleRequest( + { + method: 'rr_v3.enqueueRun', + params: { flowId: 'flow-1', tabId: 123 }, + requestId: 'req-1', + }, + { subscriptions: new Set() }, + ); + + expect(storage.queue.enqueue).toHaveBeenCalledWith( + expect.objectContaining({ + tabId: 123, + }), + ); + }); + it('rejects NaN priority', async () => { const flow = createTestFlow('flow-1'); getInternal(storage).flowsMap.set(flow.id, flow); diff --git a/app/chrome-extension/tests/record-replay-v3/v2-action-adapter.test.ts b/app/chrome-extension/tests/record-replay-v3/v2-action-adapter.test.ts index a7352f4..35b1e44 100644 --- a/app/chrome-extension/tests/record-replay-v3/v2-action-adapter.test.ts +++ b/app/chrome-extension/tests/record-replay-v3/v2-action-adapter.test.ts @@ -17,7 +17,7 @@ import type { ActionExecutionContext, ActionExecutionResult, ActionHandler, -} from '@/entrypoints/background/record-replay/actions/types'; +} from '@/entrypoints/background/replay-actions/types'; import type { NodeExecutionContext, NodeExecutionResult, diff --git a/app/chrome-extension/tests/record-replay-v3/v2-adapter-integration.test.ts b/app/chrome-extension/tests/record-replay-v3/v2-adapter-integration.test.ts index 54ca8be..257be4e 100644 --- a/app/chrome-extension/tests/record-replay-v3/v2-adapter-integration.test.ts +++ b/app/chrome-extension/tests/record-replay-v3/v2-adapter-integration.test.ts @@ -21,8 +21,8 @@ import { } from '@/entrypoints/background/record-replay-v3'; import { PluginRegistry } from '@/entrypoints/background/record-replay-v3/engine/plugins/registry'; -import { ifHandler } from '@/entrypoints/background/record-replay/actions/handlers/control-flow'; -import { delayHandler } from '@/entrypoints/background/record-replay/actions/handlers/delay'; +import { ifHandler } from '@/entrypoints/background/replay-actions/handlers/control-flow'; +import { delayHandler } from '@/entrypoints/background/replay-actions/handlers/delay'; import { adaptV2ActionHandlerToV3NodeDefinition } from '@/entrypoints/background/record-replay-v3/engine/plugins/v2-action-adapter'; import { createV3E2EHarness, type V3E2EHarness, type RpcClient } from './v3-e2e-harness'; diff --git a/app/chrome-extension/tests/record-replay/_test-helpers.ts b/app/chrome-extension/tests/record-replay/_test-helpers.ts index 6f509c4..2b9cf69 100644 --- a/app/chrome-extension/tests/record-replay/_test-helpers.ts +++ b/app/chrome-extension/tests/record-replay/_test-helpers.ts @@ -7,7 +7,7 @@ import { vi } from 'vitest'; import type { ExecCtx } from '@/entrypoints/background/record-replay/nodes/types'; -import type { ActionExecutionContext } from '@/entrypoints/background/record-replay/actions/types'; +import type { ActionExecutionContext } from '@/entrypoints/background/replay-actions/types'; /** * Create a minimal ExecCtx for testing diff --git a/app/chrome-extension/tests/record-replay/high-risk-actions.integration.test.ts b/app/chrome-extension/tests/record-replay/high-risk-actions.integration.test.ts index cb20fa0..c5632ec 100644 --- a/app/chrome-extension/tests/record-replay/high-risk-actions.integration.test.ts +++ b/app/chrome-extension/tests/record-replay/high-risk-actions.integration.test.ts @@ -60,7 +60,7 @@ vi.mock('@/shared/selector', async (importOriginal) => { }); // Mock navigation wait wrappers to avoid real webNavigation waiting -vi.mock('@/entrypoints/background/record-replay/engine/policies/wait', () => ({ +vi.mock('@/entrypoints/background/replay-actions/engine/policies/wait', () => ({ waitForNavigationDone: mocks.waitForNavigationDone, ensureReadPageIfWeb: mocks.ensureReadPageIfWeb, maybeQuickWaitForNav: mocks.maybeQuickWaitForNav, @@ -74,7 +74,7 @@ vi.mock('@/entrypoints/background/record-replay/engine/policies/wait', () => ({ import { createMockExecCtx } from './_test-helpers'; import { createHybridConfig } from '@/entrypoints/background/record-replay/engine/execution-mode'; import { HybridStepExecutor } from '@/entrypoints/background/record-replay/engine/runners/step-executor'; -import { createReplayActionRegistry } from '@/entrypoints/background/record-replay/actions'; +import { createReplayActionRegistry } from '@/entrypoints/background/replay-actions'; // ============================================================================= // Test Constants diff --git a/app/chrome-extension/tests/record-replay/hybrid-actions.integration.test.ts b/app/chrome-extension/tests/record-replay/hybrid-actions.integration.test.ts index 5d7087c..ce3c5b1 100644 --- a/app/chrome-extension/tests/record-replay/hybrid-actions.integration.test.ts +++ b/app/chrome-extension/tests/record-replay/hybrid-actions.integration.test.ts @@ -57,7 +57,7 @@ vi.mock('@/shared/selector', async (importOriginal) => { import { createMockExecCtx } from './_test-helpers'; import { createHybridConfig } from '@/entrypoints/background/record-replay/engine/execution-mode'; import { HybridStepExecutor } from '@/entrypoints/background/record-replay/engine/runners/step-executor'; -import { createReplayActionRegistry } from '@/entrypoints/background/record-replay/actions'; +import { createReplayActionRegistry } from '@/entrypoints/background/replay-actions'; // ============================================================================= // Test Constants diff --git a/app/chrome-extension/tests/record-replay/script-control-flow.integration.test.ts b/app/chrome-extension/tests/record-replay/script-control-flow.integration.test.ts index 17debbf..bcb0203 100644 --- a/app/chrome-extension/tests/record-replay/script-control-flow.integration.test.ts +++ b/app/chrome-extension/tests/record-replay/script-control-flow.integration.test.ts @@ -62,7 +62,7 @@ vi.mock('@/shared/selector', async (importOriginal) => { import { createMockExecCtx } from './_test-helpers'; import { createHybridConfig } from '@/entrypoints/background/record-replay/engine/execution-mode'; import { HybridStepExecutor } from '@/entrypoints/background/record-replay/engine/runners/step-executor'; -import { createReplayActionRegistry } from '@/entrypoints/background/record-replay/actions'; +import { createReplayActionRegistry } from '@/entrypoints/background/replay-actions'; // ============================================================================= // Test Constants diff --git a/app/chrome-extension/tests/record-replay/tab-cursor.integration.test.ts b/app/chrome-extension/tests/record-replay/tab-cursor.integration.test.ts index 659d9cb..9d0cbe4 100644 --- a/app/chrome-extension/tests/record-replay/tab-cursor.integration.test.ts +++ b/app/chrome-extension/tests/record-replay/tab-cursor.integration.test.ts @@ -54,7 +54,7 @@ vi.mock('@/shared/selector', async (importOriginal) => { import { createMockExecCtx } from './_test-helpers'; import { createHybridConfig } from '@/entrypoints/background/record-replay/engine/execution-mode'; import { HybridStepExecutor } from '@/entrypoints/background/record-replay/engine/runners/step-executor'; -import { createReplayActionRegistry } from '@/entrypoints/background/record-replay/actions'; +import { createReplayActionRegistry } from '@/entrypoints/background/replay-actions'; // ============================================================================= // Test Constants diff --git a/app/chrome-extension/utils/offscreen-manager.ts b/app/chrome-extension/utils/offscreen-manager.ts index 627cf48..f6f653e 100644 --- a/app/chrome-extension/utils/offscreen-manager.ts +++ b/app/chrome-extension/utils/offscreen-manager.ts @@ -59,8 +59,8 @@ export class OffscreenManager { await chrome.offscreen.createDocument({ url: 'offscreen.html', - reasons: ['WORKERS'], - justification: 'Need to run semantic similarity engine with workers', + reasons: ['WORKERS', 'DOM_PARSER'], + justification: 'Run extension workers and perform DOMParser-based HTML extraction', }); this.isCreated = true; @@ -104,5 +104,4 @@ export class OffscreenManager { } } - export const offscreenManager = OffscreenManager.getInstance(); diff --git a/docs/temp/quick-panel-plan.md b/docs/temp/quick-panel-plan.md index f051ab8..282e277 100644 --- a/docs/temp/quick-panel-plan.md +++ b/docs/temp/quick-panel-plan.md @@ -11,24 +11,34 @@ Quick Panel 是一个类 Raycast 的浏览器命令面板,提供: - **本地优先**:数据本地处理,隐私安全 - **差异化能力**:利用 CDP/Native Host/MCP 实现独特功能 -### 1.2 当前实现状态(更新于 2025-12-30) +### 1.2 当前实现状态(P0 + 部分 P1,更新于 2026-01-03) -| 模块 | 状态 | 说明 | -| ------------------ | ------- | --------------------------------------------------------- | -| Shadow DOM 宿主 | ✅ 完成 | `ui/shadow-host.ts` 样式隔离、事件阻断、主题同步 | -| AI Chat 面板 | ✅ 完成 | `ui/ai-chat-panel.ts` 流式对话、SSE、取消 | -| AI Chat View | ✅ 完成 | `ui/ai-chat-view.ts` 可嵌入的 View 组件(Phase 1) | -| 双视图 Shell | ✅ 完成 | `ui/panel-shell.ts` 已集成到 Controller | -| SearchEngine | ✅ 完成 | `core/search-engine.ts` 已接入 UI | -| Tabs Provider | ✅ 完成 | `providers/tabs-provider.ts` 已接入 SearchView | -| Search View | ✅ 完成 | `ui/search-view.ts` 搜索视图容器(Phase 2) | -| 键盘导航 | ✅ 完成 | `core/keyboard-controller.ts` IME 支持(Phase 3) | -| 结果列表 UI | ✅ 完成 | 集成在 search-view.ts,支持 favicon 优先显示 | -| 动作面板 UI | ✅ 完成 | `ui/action-panel.ts` 支持 Tab 动作(Phase 4) | -| Bookmarks Provider | ✅ 完成 | `providers/bookmarks-provider.ts` 书签搜索(Phase 5) | -| History Provider | ✅ 完成 | `providers/history-provider.ts` 历史搜索(Phase 5) | -| Commands Provider | ✅ 完成 | `providers/commands-provider.ts` 页面/标签命令(Phase 5) | -| 使用历史追踪 | ✅ 完成 | 最近使用记录与排序(Phase 6) | +| 模块 | 状态 | 说明 | +| ------------------- | ------- | ----------------------------------------------------------------------------------------------- | +| Shadow DOM 宿主 | ✅ 完成 | `ui/shadow-host.ts` 样式隔离、事件阻断、主题同步 | +| AI Chat 面板 | ✅ 完成 | `ui/ai-chat-panel.ts` 流式对话、SSE、取消 | +| AI Chat View | ✅ 完成 | `ui/ai-chat-view.ts` 可嵌入的 View 组件(Phase 1) | +| 双视图 Shell | ✅ 完成 | `ui/panel-shell.ts` 已集成到 Controller | +| SearchEngine | ✅ 完成 | `core/search-engine.ts` 已接入 UI | +| Tabs Provider | ✅ 完成 | `providers/tabs-provider.ts` 已接入 SearchView | +| Search View | ✅ 完成 | `ui/search-view.ts` 搜索视图容器(Phase 2) | +| 键盘导航 | ✅ 完成 | `core/keyboard-controller.ts` IME 支持(Phase 3) | +| 结果列表 UI | ✅ 完成 | 集成在 search-view.ts,支持 favicon 优先显示 | +| 动作面板 UI | ✅ 完成 | `ui/action-panel.ts` 支持 Tab 动作(Phase 4) | +| Bookmarks Provider | ✅ 完成 | `providers/bookmarks-provider.ts` 书签搜索(Phase 5) | +| History Provider | ✅ 完成 | `providers/history-provider.ts` 历史搜索(Phase 5) | +| Content Provider | ✅ 完成 | `providers/content-provider.ts` 内容搜索(Phase 7) | +| Commands Provider | ✅ 完成 | `providers/commands-provider.ts` 页面/标签命令(Phase 5) | +| 使用历史追踪 | ✅ 完成 | 最近使用记录与排序(Phase 6) | +| Workspaces Provider | ✅ 完成 | `providers/workspaces-provider.ts` 会话快照(Phase 11) | +| Diagnostics Suite | ✅ 完成 | Debug Bundle + API Detective(Phase 13) | +| Clipboard History | ✅ 完成 | `providers/clipboard-provider.ts` + `background/quick-panel/clipboard-handler.ts`(Phase 15.1) | +| Quick Notes | ✅ 完成 | `providers/notes-provider.ts` + `background/quick-panel/notes-handler.ts`(Phase 15.2) | +| Focus Mode | ✅ 完成 | `providers/focus-provider.ts` + `background/quick-panel/focus-handler.ts`(Phase 15.3) | +| Web Monitor | ✅ 完成 | `providers/monitor-provider.ts` + `background/quick-panel/monitor-handler.ts`(Phase 15.4) | +| Tool Audit Log | ✅ 完成 | `providers/audit-provider.ts` + `background/quick-panel/audit-handler.ts`(Phase 14) | + +> 注:PRD 中的 “AI 编排与可控工具层(Agent Mode)/ 个人效率与提醒” 已完成第一版(Phase 14/15);后续仍可扩展(见下文)。 ### 1.3 关键架构冲突(✅ 已解决) @@ -262,6 +272,219 @@ app/chrome-extension/common/ --- +### Phase 7: 内容搜索 Provider + +**目标**:实现 PRD 的“内容搜索”能力(scope: `c `),可在打开的标签页中按正文内容检索。 + +#### 7.1 后台内容缓存 + +- **新建** `background/quick-panel/content-handler.ts` +- 复用 `inject-scripts/web-fetcher-helper.js`(Readability)抽取可读正文 +- 内容截断到 50KB(best-effort),并限制缓存最多 200 个标签页 +- 触发时机(best-effort): + - `tabs.onUpdated` (status === 'complete') + - `webNavigation.onHistoryStateUpdated` (SPA 路由变化) + - `tabs.onRemoved` (清理缓存) + +#### 7.2 Content Provider + +- **新建** `providers/content-provider.ts` +- 默认动作:切换到匹配标签页;`Cmd/Ctrl+Enter` 以新标签打开 URL +- 二级动作:新标签打开、复制 URL、关闭标签页 + +--- + +### Phase 8: 标签页高级管理(Commands) + +**目标**:实现 PRD P1 的高频标签页管理能力,并复用现有 `>` Commands 体系(不新增 UI 复杂度)。 + +#### 8.1 新增命令项(Commands Provider) + +- **修改** `providers/commands-provider.ts` + - Close other tabs(关闭当前窗口其它未固定标签) + - Close tabs to the right(关闭右侧未固定标签) + - Discard inactive tabs(丢弃当前窗口未固定的非激活标签,释放内存) + - Merge all windows(将其它窗口的标签移动到当前窗口) + +#### 8.2 扩展后台命令执行能力 + +- **修改** `background/quick-panel/page-commands-handler.ts` + - 新增 page command:`close_other_tabs` / `close_tabs_to_right` / `discard_inactive_tabs` / `merge_all_windows` +- **修改** `common/message-types.ts` + - 扩展 `QuickPanelPageCommand` union + +--- + +### Phase 9: 多引擎搜索 & 快捷链接(来自 `.docs/tansuo.md`) + +**目标**:在 Quick Panel 内用前缀快速打开常用搜索引擎与 URL 模板(例如 `g react hooks`、`gh openai`、`npm vitest`),减少上下文切换。 + +#### 9.1 Scope / Prefix 扩展(输入协议) + +- **修改** `core/types.ts` + - 扩展 `QuickPanelScope` / `QUICK_PANEL_SCOPES`(新增若干 “Web Search” scope) + - 扩展 `parseScopePrefixedQuery`:识别 `g ` / `gh ` / `npm ` / `so ` / `mdn ` 等前缀(按 PRD 精简) +- 约束:新增 scope **仅前缀触发**,不强制增加新的键盘切换快捷键,避免 UI 复杂度膨胀 + +#### 9.2 Web Search Provider + +- **新建** `providers/web-search-provider.ts` + - scope 驱动:不同 scope → 不同 engine URL 模板(Google/GitHub/NPM/StackOverflow/MDN…) + - 动作:Enter 在当前标签打开;`Cmd/Ctrl+Enter` 新标签打开(复用 openMode → `QUICK_PANEL_OPEN_URL`) +- **新建** `core/url-template.ts`(纯函数) + - 统一处理 URL 模板填充与编码规则(`{query}`、可选的 `{rawQuery}` 等) + - 提供可测试的最小接口:`buildSearchUrl(engine, query)` + +#### 9.3 配置(可选) + +- **可选**:支持用户自定义搜索引擎模板(`chrome.storage.sync`),并提供默认模板集作为 fallback + - 说明:建议优先做 “内置 5-8 个引擎 + 可编辑” 的闭环,再考虑更泛化的模板 DSL + +--- + +### Phase 10: 页面工具(Zen / Dark / Reader / Outline / Clean URL / PIP / Allow Copy) + +**目标**:补齐 PRD P1 “页面工具”能力,优先以 `>` Commands 落地(低 UI 成本),并复用既有 background bridge。 + +#### 10.1 无参数 Commands(toggle / one-shot 优先) + +- **修改** `providers/commands-provider.ts` + - Page Skins:VS Code / Terminal / Retro / Paper / Off(始终显示 “Skin mode” 水印,避免产生“伪装”歧义) + - Clean URL:去除常见追踪参数(utm/fbclid/gclid 等),支持复制与新标签打开 + - Zen Mode:注入 CSS 隐藏干扰元素(best-effort),支持关闭还原 + - Force Dark:CSS filter 强制暗色(best-effort),支持关闭还原 + - Allow Copy:解除复制/选中限制(best-effort,不承诺对所有站点有效) + - Picture-in-Picture:对页面内可用 `