// ═══════════════════════════════════════════════════════════════════════════════ // Homepage WebKit Bridge // Communication layer between frontend and Go backend via WebKit message handlers // ═══════════════════════════════════════════════════════════════════════════════ import type { MessageType, HistoryEntry, Folder, Tag, Favorite, DomainStat, Analytics, HistoryCleanupRange, FolderCreateRequest, FolderUpdateRequest, TagCreateRequest, TagUpdateRequest, } from './types'; import { homepageState } from './state.svelte'; // ═══════════════════════════════════════════════════════════════════════════════ // REQUEST TRACKING // ═══════════════════════════════════════════════════════════════════════════════ interface PendingRequest { resolve: (value: T) => void; reject: (error: Error) => void; timeout: ReturnType; } const pendingRequests = new Map(); const REQUEST_TIMEOUT_MS = 10_000; let callbacksInitialized = false; // ═══════════════════════════════════════════════════════════════════════════════ // WEBKIT BRIDGE UTILITIES // ═══════════════════════════════════════════════════════════════════════════════ function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null { const bridge = (window as any).webkit?.messageHandlers?.dumber; if (bridge && typeof bridge.postMessage === 'function') { return bridge; } return null; } function getWebViewId(): number { return (window as any).__dumber_webview_id || 0; } function generateRequestId(type: string): string { return `${type}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; } // ═══════════════════════════════════════════════════════════════════════════════ // CALLBACK INITIALIZATION // ═══════════════════════════════════════════════════════════════════════════════ function initializeCallbacks(): void { if (callbacksInitialized) return; // Generic response handler - accepts a single Response object from Go backend (window as any).__dumber_homepage_response = ( response: { requestId: string; success: boolean; data?: unknown; error?: string } ) => { const request = pendingRequests.get(response.requestId); if (!request) return; clearTimeout(request.timeout); pendingRequests.delete(response.requestId); if (response.success) { request.resolve(response.data); } else { request.reject(new Error(response.error || 'Unknown error')); } }; // Legacy callbacks for backward compatibility // These can receive either raw data or a Response object (window as any).__dumber_history_timeline = (dataOrResponse: unknown) => { handleLegacyOrResponseCallback(dataOrResponse); }; (window as any).__dumber_folders = (dataOrResponse: unknown) => { handleLegacyOrResponseCallback(dataOrResponse); }; (window as any).__dumber_tags = (dataOrResponse: unknown) => { handleLegacyOrResponseCallback(dataOrResponse); }; (window as any).__dumber_favorites = (dataOrResponse: unknown) => { handleLegacyOrResponseCallback(dataOrResponse); }; (window as any).__dumber_analytics = (dataOrResponse: unknown) => { handleLegacyOrResponseCallback(dataOrResponse); }; (window as any).__dumber_domain_stats = (dataOrResponse: unknown) => { handleLegacyOrResponseCallback(dataOrResponse); }; (window as any).__dumber_history_search_results = (dataOrResponse: unknown) => { handleLegacyOrResponseCallback(dataOrResponse); }; (window as any).__dumber_history_deleted = (dataOrResponse: unknown) => { handleLegacyOrResponseCallback(dataOrResponse); }; (window as any).__dumber_history_cleared = (dataOrResponse: unknown) => { handleLegacyOrResponseCallback(dataOrResponse); }; (window as any).__dumber_error = (errorOrResponse: unknown) => { // Handle both formats: direct error string or Response object with error const response = errorOrResponse as { requestId?: string; error?: string } | string; if (typeof response === 'string') { // Legacy format: direct error string console.error('[messaging] Legacy error:', response); } else if (response && typeof response === 'object') { // Response object format const request = pendingRequests.get(response.requestId || 'default'); if (request) { clearTimeout(request.timeout); pendingRequests.delete(response.requestId || 'default'); request.reject(new Error(response.error || 'Unknown error')); } } }; // Listen for favorites changes from omnibox (e.g., when user toggles favorite) document.addEventListener('dumber:favorites-changed', () => { console.log('[homepage] Favorites changed event received, refreshing...'); fetchFavorites(); }); callbacksInitialized = true; } // Handle both legacy raw data and new Response object format function handleLegacyOrResponseCallback(dataOrResponse: unknown): void { // Check if it's a Response object (has requestId and success fields) const response = dataOrResponse as { requestId?: string; success?: boolean; data?: unknown; error?: string }; if (response && typeof response === 'object' && 'requestId' in response && 'success' in response) { // New Response object format const request = pendingRequests.get(response.requestId || 'default'); if (request) { clearTimeout(request.timeout); pendingRequests.delete(response.requestId || 'default'); if (response.success) { request.resolve(response.data); } else { request.reject(new Error(response.error || 'Unknown error')); } } } else { // Legacy format: raw data (for backward compatibility) // Try to find any pending request (not ideal but maintains compatibility) console.warn('[messaging] Received legacy callback format without requestId'); } } // ═══════════════════════════════════════════════════════════════════════════════ // CORE SEND FUNCTION // ═══════════════════════════════════════════════════════════════════════════════ async function sendMessage( type: MessageType, payload?: Record ): Promise { initializeCallbacks(); const bridge = getWebKitBridge(); if (!bridge) { throw new Error('WebKit message handler not available'); } const requestId = generateRequestId(type); return new Promise((resolve, reject) => { const timeout = setTimeout(() => { pendingRequests.delete(requestId); reject(new Error(`Request timeout: ${type}`)); }, REQUEST_TIMEOUT_MS); pendingRequests.set(requestId, { resolve: resolve as any, reject, timeout }); try { bridge.postMessage({ type, webviewId: getWebViewId(), payload: { requestId, ...payload }, }); } catch (error) { clearTimeout(timeout); pendingRequests.delete(requestId); reject(error); } }); } // ═══════════════════════════════════════════════════════════════════════════════ // HISTORY API // ═══════════════════════════════════════════════════════════════════════════════ export async function fetchHistoryTimeline( limit: number = 50, offset: number = 0 ): Promise { homepageState.setHistoryLoading(true); try { const data = await sendMessage('history_timeline', { limit, offset, }); const entries = Array.isArray(data) ? data : []; if (offset === 0) { homepageState.setHistory(entries); } else { homepageState.appendHistory(entries); } homepageState.setHistoryOffset(offset + entries.length); homepageState.setHasMoreHistory(entries.length === limit); return entries; } catch (error) { console.error('[messaging] fetchHistoryTimeline error:', error); if (offset === 0) { homepageState.setHistory([]); } homepageState.setHasMoreHistory(false); return []; } finally { homepageState.setHistoryLoading(false); } } export async function searchHistoryFTS(query: string): Promise { if (!query.trim()) { homepageState.setHistorySearchResults([]); return []; } homepageState.setHistorySearching(true); try { const data = await sendMessage('history_search_fts', { query, limit: 100, }); const results = Array.isArray(data) ? data : []; homepageState.setHistorySearchResults(results); return results; } catch (error) { console.error('[messaging] searchHistoryFTS error:', error); homepageState.setHistorySearchResults([]); return []; } finally { homepageState.setHistorySearching(false); } } export async function deleteHistoryEntry(id: number): Promise { try { await sendMessage('history_delete_entry', { id }); homepageState.deleteHistoryEntry(id); } catch (error) { console.error('[messaging] deleteHistoryEntry error:', error); throw error; } } export async function deleteHistoryByRange(range: HistoryCleanupRange): Promise { try { await sendMessage('history_delete_range', { range }); // Refresh history after deletion await fetchHistoryTimeline(); } catch (error) { console.error('[messaging] deleteHistoryByRange error:', error); throw error; } } export async function clearAllHistory(): Promise { try { await sendMessage('history_clear_all'); homepageState.clearHistory(); } catch (error) { console.error('[messaging] clearAllHistory error:', error); throw error; } } export async function deleteHistoryByDomain(domain: string): Promise { try { await sendMessage('history_delete_domain', { domain }); // Refresh history after deletion await fetchHistoryTimeline(); } catch (error) { console.error('[messaging] deleteHistoryByDomain error:', error); throw error; } } // ═══════════════════════════════════════════════════════════════════════════════ // FOLDERS API // ═══════════════════════════════════════════════════════════════════════════════ export async function fetchFolders(): Promise { try { const data = await sendMessage('folder_list'); const folders = Array.isArray(data) ? data : []; homepageState.setFolders(folders); return folders; } catch (error) { console.error('[messaging] fetchFolders error:', error); homepageState.setFolders([]); return []; } } export async function createFolder(req: FolderCreateRequest): Promise { const folder = await sendMessage('folder_create', { ...req }); homepageState.addFolder(folder); return folder; } export async function updateFolder(req: FolderUpdateRequest): Promise { await sendMessage('folder_update', { ...req }); homepageState.updateFolder({ ...homepageState.folders.find(f => f.id === req.id)!, name: req.name, icon: req.icon ?? null, }); } export async function deleteFolder(id: number): Promise { await sendMessage('folder_delete', { id }); homepageState.deleteFolder(id); } // ═══════════════════════════════════════════════════════════════════════════════ // TAGS API // ═══════════════════════════════════════════════════════════════════════════════ export async function fetchTags(): Promise { try { const data = await sendMessage('tag_list'); const tags = Array.isArray(data) ? data : []; homepageState.setTags(tags); return tags; } catch (error) { console.error('[messaging] fetchTags error:', error); homepageState.setTags([]); return []; } } export async function createTag(req: TagCreateRequest): Promise { const tag = await sendMessage('tag_create', { ...req }); homepageState.addTag(tag); return tag; } export async function updateTag(req: TagUpdateRequest): Promise { await sendMessage('tag_update', { ...req }); const existing = homepageState.tags.find(t => t.id === req.id); if (existing) { homepageState.updateTag({ ...existing, name: req.name ?? existing.name, color: req.color ?? existing.color, }); } } export async function deleteTag(id: number): Promise { await sendMessage('tag_delete', { id }); homepageState.deleteTag(id); } export async function assignTag(favoriteId: number, tagId: number): Promise { await sendMessage('tag_assign', { favorite_id: favoriteId, tag_id: tagId }); // Update local state const favorite = homepageState.favorites.find(f => f.id === favoriteId); const tag = homepageState.tags.find(t => t.id === tagId); if (favorite && tag) { homepageState.updateFavorite({ ...favorite, tags: [...(favorite.tags || []), tag], }); } } export async function removeTag(favoriteId: number, tagId: number): Promise { await sendMessage('tag_remove', { favorite_id: favoriteId, tag_id: tagId }); // Update local state const favorite = homepageState.favorites.find(f => f.id === favoriteId); if (favorite) { homepageState.updateFavorite({ ...favorite, tags: favorite.tags?.filter(t => t.id !== tagId), }); } } // ═══════════════════════════════════════════════════════════════════════════════ // FAVORITES API // ═══════════════════════════════════════════════════════════════════════════════ export async function fetchFavorites(): Promise { homepageState.setFavoritesLoading(true); try { const data = await sendMessage('favorite_list'); const favorites = Array.isArray(data) ? data : []; homepageState.setFavorites(favorites); return favorites; } catch (error) { console.error('[messaging] fetchFavorites error:', error); homepageState.setFavorites([]); return []; } finally { homepageState.setFavoritesLoading(false); } } export async function setFavoriteShortcut( favoriteId: number, shortcutKey: number | null ): Promise { await sendMessage('favorite_set_shortcut', { favorite_id: favoriteId, shortcut_key: shortcutKey, }); // Update local state const favorite = homepageState.favorites.find(f => f.id === favoriteId); if (favorite) { // Clear shortcut from any other favorite that had this key if (shortcutKey !== null) { for (const f of homepageState.favorites) { if (f.shortcut_key === shortcutKey && f.id !== favoriteId) { homepageState.updateFavorite({ ...f, shortcut_key: null }); } } } homepageState.updateFavorite({ ...favorite, shortcut_key: shortcutKey }); } } export async function getFavoriteByShortcut(shortcutKey: number): Promise { try { const data = await sendMessage('favorite_get_by_shortcut', { shortcut_key: shortcutKey, }); return data; } catch (error) { console.error('[messaging] getFavoriteByShortcut error:', error); return null; } } export async function setFavoriteFolder( favoriteId: number, folderId: number | null ): Promise { await sendMessage('favorite_set_folder', { favorite_id: favoriteId, folder_id: folderId, }); // Update local state const favorite = homepageState.favorites.find(f => f.id === favoriteId); if (favorite) { homepageState.updateFavorite({ ...favorite, folder_id: folderId }); } } // ═══════════════════════════════════════════════════════════════════════════════ // ANALYTICS API // ═══════════════════════════════════════════════════════════════════════════════ export async function fetchAnalytics(): Promise { homepageState.setAnalyticsLoading(true); try { const data = await sendMessage('history_analytics'); homepageState.setAnalytics(data); return data; } catch (error) { console.error('[messaging] fetchAnalytics error:', error); homepageState.setAnalytics(null); return null; } finally { homepageState.setAnalyticsLoading(false); } } export async function fetchDomainStats(limit: number = 20): Promise { try { const data = await sendMessage('history_domain_stats', { limit }); const stats = Array.isArray(data) ? data : []; homepageState.setDomainStats(stats); return stats; } catch (error) { console.error('[messaging] fetchDomainStats error:', error); homepageState.setDomainStats([]); return []; } } // ═══════════════════════════════════════════════════════════════════════════════ // INITIALIZATION // ═══════════════════════════════════════════════════════════════════════════════ export async function initializeHomepage(): Promise { // Initialize callbacks first initializeCallbacks(); // Fetch all initial data in parallel await Promise.all([ fetchHistoryTimeline(), fetchFolders(), fetchTags(), fetchFavorites(), fetchAnalytics(), ]); } // ═══════════════════════════════════════════════════════════════════════════════ // NAVIGATION // ═══════════════════════════════════════════════════════════════════════════════ export function navigateTo(url: string): void { window.location.href = url; }