1// ═══════════════════════════════════════════════════════════════════════════════
2// Homepage WebKit Bridge
3// Communication layer between frontend and Go backend via WebKit message handlers
4// ═══════════════════════════════════════════════════════════════════════════════
5
6import type {
7 MessageType,
8 HistoryEntry,
9 Folder,
10 Tag,
11 Favorite,
12 DomainStat,
13 Analytics,
14 HistoryCleanupRange,
15 FolderCreateRequest,
16 FolderUpdateRequest,
17 TagCreateRequest,
18 TagUpdateRequest,
19} from './types';
20import { homepageState } from './state.svelte';
21
22// ═══════════════════════════════════════════════════════════════════════════════
23// REQUEST TRACKING
24// ═══════════════════════════════════════════════════════════════════════════════
25
26// Pending requests store resolvers for different response types.
27// The resolve function accepts `unknown` since requests with different
28// response types share the same map. Type safety is maintained at the
29// sendMessage<T> API level.
30interface PendingRequest {
31 resolve: (value: unknown) => void;
32 reject: (error: Error) => void;
33 timeout: ReturnType<typeof setTimeout>;
34}
35
36const pendingRequests = new Map<string, PendingRequest>();
37const REQUEST_TIMEOUT_MS = 10_000;
38
39let callbacksInitialized = false;
40
41// ═══════════════════════════════════════════════════════════════════════════════
42// WEBKIT BRIDGE UTILITIES
43// ═══════════════════════════════════════════════════════════════════════════════
44
45function getWebKitBridge(): DumberBridge | null {
46 const bridge = window.webkit?.messageHandlers?.dumber;
47 if (bridge && typeof bridge.postMessage === 'function') {
48 return bridge;
49 }
50 return null;
51}
52
53function getWebViewId(): number {
54 return window.__dumber_webview_id ?? 0;
55}
56
57function generateRequestId(type: string): string {
58 return `${type}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
59}
60
61// ═══════════════════════════════════════════════════════════════════════════════
62// CALLBACK INITIALIZATION
63// ═══════════════════════════════════════════════════════════════════════════════
64
65function initializeCallbacks(): void {
66 if (callbacksInitialized) return;
67
68 // Generic response handler - accepts a single Response object from Go backend
69 window.__dumber_homepage_response = (response) => {
70 const request = pendingRequests.get(response.requestId);
71 if (!request) return;
72
73 clearTimeout(request.timeout);
74 pendingRequests.delete(response.requestId);
75
76 if (response.success) {
77 request.resolve(response.data);
78 } else {
79 request.reject(new Error(response.error ?? 'Unknown error'));
80 }
81 };
82
83 // Legacy callbacks for backward compatibility
84 // These can receive either raw data or a Response object
85 window.__dumber_history_timeline = (dataOrResponse) => {
86 handleLegacyOrResponseCallback(dataOrResponse);
87 };
88
89 window.__dumber_folders = (dataOrResponse) => {
90 handleLegacyOrResponseCallback(dataOrResponse);
91 };
92
93 window.__dumber_tags = (dataOrResponse) => {
94 handleLegacyOrResponseCallback(dataOrResponse);
95 };
96
97 window.__dumber_favorites = (dataOrResponse) => {
98 handleLegacyOrResponseCallback(dataOrResponse);
99 };
100
101 window.__dumber_analytics = (dataOrResponse) => {
102 handleLegacyOrResponseCallback(dataOrResponse);
103 };
104
105 window.__dumber_domain_stats = (dataOrResponse) => {
106 handleLegacyOrResponseCallback(dataOrResponse);
107 };
108
109 window.__dumber_history_search_results = (dataOrResponse) => {
110 handleLegacyOrResponseCallback(dataOrResponse);
111 };
112
113 window.__dumber_history_deleted = (dataOrResponse) => {
114 handleLegacyOrResponseCallback(dataOrResponse);
115 };
116
117 window.__dumber_history_cleared = (dataOrResponse) => {
118 handleLegacyOrResponseCallback(dataOrResponse);
119 };
120
121 window.__dumber_error = (errorOrResponse) => {
122 // Handle both formats: direct error string or Response object with error
123 if (typeof errorOrResponse === 'string') {
124 // Legacy format: direct error string
125 console.error('[messaging] Legacy error:', errorOrResponse);
126 } else if (errorOrResponse && typeof errorOrResponse === 'object') {
127 // Response object format
128 const request = pendingRequests.get(errorOrResponse.requestId ?? 'default');
129 if (request) {
130 clearTimeout(request.timeout);
131 pendingRequests.delete(errorOrResponse.requestId ?? 'default');
132 request.reject(new Error(errorOrResponse.error ?? 'Unknown error'));
133 }
134 }
135 };
136
137 // Listen for favorites changes from omnibox (e.g., when user toggles favorite)
138 document.addEventListener('dumber:favorites-changed', () => {
139 console.log('[homepage] Favorites changed event received, refreshing...');
140 fetchFavorites();
141 });
142
143 callbacksInitialized = true;
144}
145
146// Handle both legacy raw data and new Response object format
147function handleLegacyOrResponseCallback(dataOrResponse: unknown): void {
148 // Check if it's a Response object (has requestId and success fields)
149 const response = dataOrResponse as { requestId?: string; success?: boolean; data?: unknown; error?: string };
150 if (response && typeof response === 'object' && 'requestId' in response && 'success' in response) {
151 // New Response object format
152 const request = pendingRequests.get(response.requestId || 'default');
153 if (request) {
154 clearTimeout(request.timeout);
155 pendingRequests.delete(response.requestId || 'default');
156 if (response.success) {
157 request.resolve(response.data);
158 } else {
159 request.reject(new Error(response.error || 'Unknown error'));
160 }
161 }
162 } else {
163 // Legacy format: raw data (for backward compatibility)
164 // Try to find any pending request (not ideal but maintains compatibility)
165 console.warn('[messaging] Received legacy callback format without requestId');
166 }
167}
168
169// ═══════════════════════════════════════════════════════════════════════════════
170// CORE SEND FUNCTION
171// ═══════════════════════════════════════════════════════════════════════════════
172
173async function sendMessage<T = unknown>(
174 type: MessageType,
175 payload?: Record<string, unknown>
176): Promise<T> {
177 initializeCallbacks();
178
179 const bridge = getWebKitBridge();
180 if (!bridge) {
181 throw new Error('WebKit message handler not available');
182 }
183
184 const requestId = generateRequestId(type);
185
186 return new Promise<T>((resolve, reject) => {
187 const timeout = setTimeout(() => {
188 pendingRequests.delete(requestId);
189 reject(new Error(`Request timeout: ${type}`));
190 }, REQUEST_TIMEOUT_MS);
191
192 // Cast resolve to accept unknown - type safety is maintained at API level
193 // since sendMessage<T> returns Promise<T> and callers expect typed responses
194 pendingRequests.set(requestId, {
195 resolve: resolve as (value: unknown) => void,
196 reject,
197 timeout,
198 });
199
200 try {
201 bridge.postMessage({
202 type,
203 webviewId: getWebViewId(),
204 payload: { requestId, ...payload },
205 });
206 } catch (error) {
207 clearTimeout(timeout);
208 pendingRequests.delete(requestId);
209 reject(error);
210 }
211 });
212}
213
214// ═══════════════════════════════════════════════════════════════════════════════
215// HISTORY API
216// ═══════════════════════════════════════════════════════════════════════════════
217
218export async function fetchHistoryTimeline(
219 limit: number = 50,
220 offset: number = 0
221): Promise<HistoryEntry[]> {
222 homepageState.setHistoryLoading(true);
223 try {
224 const data = await sendMessage<HistoryEntry[]>('history_timeline', {
225 limit,
226 offset,
227 });
228 const entries = Array.isArray(data) ? data : [];
229
230 if (offset === 0) {
231 homepageState.setHistory(entries);
232 } else {
233 homepageState.appendHistory(entries);
234 }
235
236 homepageState.setHistoryOffset(offset + entries.length);
237 homepageState.setHasMoreHistory(entries.length === limit);
238
239 return entries;
240 } catch (error) {
241 console.error('[messaging] fetchHistoryTimeline error:', error);
242 if (offset === 0) {
243 homepageState.setHistory([]);
244 }
245 homepageState.setHasMoreHistory(false);
246 return [];
247 } finally {
248 homepageState.setHistoryLoading(false);
249 }
250}
251
252export async function searchHistoryFTS(query: string): Promise<HistoryEntry[]> {
253 if (!query.trim()) {
254 homepageState.setHistorySearchResults([]);
255 return [];
256 }
257
258 homepageState.setHistorySearching(true);
259 try {
260 const data = await sendMessage<HistoryEntry[]>('history_search_fts', {
261 query,
262 limit: 100,
263 });
264 const results = Array.isArray(data) ? data : [];
265 homepageState.setHistorySearchResults(results);
266 return results;
267 } catch (error) {
268 console.error('[messaging] searchHistoryFTS error:', error);
269 homepageState.setHistorySearchResults([]);
270 return [];
271 } finally {
272 homepageState.setHistorySearching(false);
273 }
274}
275
276export async function deleteHistoryEntry(id: number): Promise<void> {
277 try {
278 await sendMessage('history_delete_entry', { id });
279 homepageState.deleteHistoryEntry(id);
280 } catch (error) {
281 console.error('[messaging] deleteHistoryEntry error:', error);
282 throw error;
283 }
284}
285
286export async function deleteHistoryByRange(range: HistoryCleanupRange): Promise<void> {
287 try {
288 await sendMessage('history_delete_range', { range });
289 // Refresh history after deletion
290 await fetchHistoryTimeline();
291 } catch (error) {
292 console.error('[messaging] deleteHistoryByRange error:', error);
293 throw error;
294 }
295}
296
297export async function clearAllHistory(): Promise<void> {
298 try {
299 await sendMessage('history_clear_all');
300 homepageState.clearHistory();
301 } catch (error) {
302 console.error('[messaging] clearAllHistory error:', error);
303 throw error;
304 }
305}
306
307export async function deleteHistoryByDomain(domain: string): Promise<void> {
308 try {
309 await sendMessage('history_delete_domain', { domain });
310 // Refresh history after deletion
311 await fetchHistoryTimeline();
312 } catch (error) {
313 console.error('[messaging] deleteHistoryByDomain error:', error);
314 throw error;
315 }
316}
317
318// ═══════════════════════════════════════════════════════════════════════════════
319// FOLDERS API
320// ═══════════════════════════════════════════════════════════════════════════════
321
322export async function fetchFolders(): Promise<Folder[]> {
323 try {
324 const data = await sendMessage<Folder[]>('folder_list');
325 const folders = Array.isArray(data) ? data : [];
326 homepageState.setFolders(folders);
327 return folders;
328 } catch (error) {
329 console.error('[messaging] fetchFolders error:', error);
330 homepageState.setFolders([]);
331 return [];
332 }
333}
334
335export async function createFolder(req: FolderCreateRequest): Promise<Folder> {
336 const folder = await sendMessage<Folder>('folder_create', { ...req });
337 homepageState.addFolder(folder);
338 return folder;
339}
340
341export async function updateFolder(req: FolderUpdateRequest): Promise<void> {
342 await sendMessage('folder_update', { ...req });
343 homepageState.updateFolder({
344 ...homepageState.folders.find(f => f.id === req.id)!,
345 name: req.name,
346 icon: req.icon ?? null,
347 });
348}
349
350export async function deleteFolder(id: number): Promise<void> {
351 await sendMessage('folder_delete', { id });
352 homepageState.deleteFolder(id);
353}
354
355// ═══════════════════════════════════════════════════════════════════════════════
356// TAGS API
357// ═══════════════════════════════════════════════════════════════════════════════
358
359export async function fetchTags(): Promise<Tag[]> {
360 try {
361 const data = await sendMessage<Tag[]>('tag_list');
362 const tags = Array.isArray(data) ? data : [];
363 homepageState.setTags(tags);
364 return tags;
365 } catch (error) {
366 console.error('[messaging] fetchTags error:', error);
367 homepageState.setTags([]);
368 return [];
369 }
370}
371
372export async function createTag(req: TagCreateRequest): Promise<Tag> {
373 const tag = await sendMessage<Tag>('tag_create', { ...req });
374 homepageState.addTag(tag);
375 return tag;
376}
377
378export async function updateTag(req: TagUpdateRequest): Promise<void> {
379 await sendMessage('tag_update', { ...req });
380 const existing = homepageState.tags.find(t => t.id === req.id);
381 if (existing) {
382 homepageState.updateTag({
383 ...existing,
384 name: req.name ?? existing.name,
385 color: req.color ?? existing.color,
386 });
387 }
388}
389
390export async function deleteTag(id: number): Promise<void> {
391 await sendMessage('tag_delete', { id });
392 homepageState.deleteTag(id);
393}
394
395export async function assignTag(favoriteId: number, tagId: number): Promise<void> {
396 await sendMessage('tag_assign', { favorite_id: favoriteId, tag_id: tagId });
397 // Update local state
398 const favorite = homepageState.favorites.find(f => f.id === favoriteId);
399 const tag = homepageState.tags.find(t => t.id === tagId);
400 if (favorite && tag) {
401 homepageState.updateFavorite({
402 ...favorite,
403 tags: [...(favorite.tags || []), tag],
404 });
405 }
406}
407
408export async function removeTag(favoriteId: number, tagId: number): Promise<void> {
409 await sendMessage('tag_remove', { favorite_id: favoriteId, tag_id: tagId });
410 // Update local state
411 const favorite = homepageState.favorites.find(f => f.id === favoriteId);
412 if (favorite) {
413 homepageState.updateFavorite({
414 ...favorite,
415 tags: favorite.tags?.filter(t => t.id !== tagId),
416 });
417 }
418}
419
420// ═══════════════════════════════════════════════════════════════════════════════
421// FAVORITES API
422// ═══════════════════════════════════════════════════════════════════════════════
423
424export async function fetchFavorites(): Promise<Favorite[]> {
425 homepageState.setFavoritesLoading(true);
426 try {
427 const data = await sendMessage<Favorite[]>('favorite_list');
428 const favorites = Array.isArray(data) ? data : [];
429 homepageState.setFavorites(favorites);
430 return favorites;
431 } catch (error) {
432 console.error('[messaging] fetchFavorites error:', error);
433 homepageState.setFavorites([]);
434 return [];
435 } finally {
436 homepageState.setFavoritesLoading(false);
437 }
438}
439
440export async function setFavoriteShortcut(
441 favoriteId: number,
442 shortcutKey: number | null
443): Promise<void> {
444 await sendMessage('favorite_set_shortcut', {
445 favorite_id: favoriteId,
446 shortcut_key: shortcutKey,
447 });
448 // Update local state
449 const favorite = homepageState.favorites.find(f => f.id === favoriteId);
450 if (favorite) {
451 // Clear shortcut from any other favorite that had this key
452 if (shortcutKey !== null) {
453 for (const f of homepageState.favorites) {
454 if (f.shortcut_key === shortcutKey && f.id !== favoriteId) {
455 homepageState.updateFavorite({ ...f, shortcut_key: null });
456 }
457 }
458 }
459 homepageState.updateFavorite({ ...favorite, shortcut_key: shortcutKey });
460 }
461}
462
463export async function getFavoriteByShortcut(shortcutKey: number): Promise<Favorite | null> {
464 try {
465 const data = await sendMessage<Favorite | null>('favorite_get_by_shortcut', {
466 shortcut_key: shortcutKey,
467 });
468 return data;
469 } catch (error) {
470 console.error('[messaging] getFavoriteByShortcut error:', error);
471 return null;
472 }
473}
474
475export async function setFavoriteFolder(
476 favoriteId: number,
477 folderId: number | null
478): Promise<void> {
479 await sendMessage('favorite_set_folder', {
480 favorite_id: favoriteId,
481 folder_id: folderId,
482 });
483 // Update local state
484 const favorite = homepageState.favorites.find(f => f.id === favoriteId);
485 if (favorite) {
486 homepageState.updateFavorite({ ...favorite, folder_id: folderId });
487 }
488}
489
490// ═══════════════════════════════════════════════════════════════════════════════
491// ANALYTICS API
492// ═══════════════════════════════════════════════════════════════════════════════
493
494export async function fetchAnalytics(): Promise<Analytics | null> {
495 homepageState.setAnalyticsLoading(true);
496 try {
497 const data = await sendMessage<Analytics>('history_analytics');
498 homepageState.setAnalytics(data);
499 return data;
500 } catch (error) {
501 console.error('[messaging] fetchAnalytics error:', error);
502 homepageState.setAnalytics(null);
503 return null;
504 } finally {
505 homepageState.setAnalyticsLoading(false);
506 }
507}
508
509export async function fetchDomainStats(limit: number = 20): Promise<DomainStat[]> {
510 try {
511 const data = await sendMessage<DomainStat[]>('history_domain_stats', { limit });
512 const stats = Array.isArray(data) ? data : [];
513 homepageState.setDomainStats(stats);
514 return stats;
515 } catch (error) {
516 console.error('[messaging] fetchDomainStats error:', error);
517 homepageState.setDomainStats([]);
518 return [];
519 }
520}
521
522// ═══════════════════════════════════════════════════════════════════════════════
523// INITIALIZATION
524// ═══════════════════════════════════════════════════════════════════════════════
525
526export async function initializeHomepage(): Promise<void> {
527 // Initialize callbacks first
528 initializeCallbacks();
529
530 // Fetch all initial data in parallel
531 await Promise.all([
532 fetchHistoryTimeline(),
533 fetchFolders(),
534 fetchTags(),
535 fetchFavorites(),
536 fetchAnalytics(),
537 ]);
538}
539
540// ═══════════════════════════════════════════════════════════════════════════════
541// NAVIGATION
542// ═══════════════════════════════════════════════════════════════════════════════
543
544export function navigateTo(url: string): void {
545 window.location.href = url;
546}