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