experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(page): handle downloads, loading timeout, and restore Cmd+L shortcut

- Add will-download handler on profile session to save to ~/Downloads
and close the originating window (no page to display for downloads)
- Fix did-fail-load error -3 to clear loading state on download aborts
- Add 30s loading timeout safety net for permanently frozen windows
- Move did-attach-webview listener before loadURL so guest keyboard
shortcuts (Cmd+L navbar, Cmd+R reload, Cmd+[/] nav) are registered
before the webview attaches

+213 -139
+35 -1
app/page/page.js
··· 398 398 // Simple state machine: 'loading' or 'loaded'. 399 399 // Visual effect (CSS class) subscribes to state changes. 400 400 401 + const LOADING_TIMEOUT_MS = 30000; // 30 second safety net 402 + 401 403 const loadingLifecycle = { 402 404 _state: 'idle', 403 405 _listeners: [], 406 + _timeoutId: null, 404 407 405 408 get state() { return this._state; }, 406 409 ··· 409 412 this._state = 'loading'; 410 413 this._notify(); 411 414 DEBUG && console.log('[page] Loading started'); 415 + 416 + // Safety net: auto-clear loading state after timeout to prevent permanently frozen windows 417 + this._clearTimeout(); 418 + this._timeoutId = setTimeout(() => { 419 + if (this._state === 'loading') { 420 + console.warn('[page] Loading timeout after', LOADING_TIMEOUT_MS, 'ms — force clearing loading state'); 421 + this.stopLoading(); 422 + } 423 + }, LOADING_TIMEOUT_MS); 412 424 }, 413 425 414 426 stopLoading() { 415 427 if (this._state === 'loaded') return; 428 + this._clearTimeout(); 416 429 this._state = 'loaded'; 417 430 this._notify(); 418 431 DEBUG && console.log('[page] Loading finished'); ··· 420 433 421 434 onChange(fn) { 422 435 this._listeners.push(fn); 436 + }, 437 + 438 + _clearTimeout() { 439 + if (this._timeoutId) { 440 + clearTimeout(this._timeoutId); 441 + this._timeoutId = null; 442 + } 423 443 }, 424 444 425 445 _notify() { ··· 453 473 } 454 474 } 455 475 }); 476 + 477 + // Track whether the webview has ever successfully loaded a page. 478 + // Used to distinguish download-abort errors (-3) from legitimate re-navigation. 479 + let hasEverLoaded = false; 456 480 457 481 initWebview(); 458 482 loadingLifecycle.startLoading(); ··· 1305 1329 }); 1306 1330 1307 1331 webview.addEventListener('did-fail-load', (e) => { 1308 - if (e.errorCode === -3) return; // Aborted navigation (e.g. replaced by new nav) 1332 + if (e.errorCode === -3) { 1333 + // Error -3 (ABORTED) normally fires during legitimate re-navigation and is safe to ignore. 1334 + // However, if no page has ever loaded (e.g. URL triggered a download instead), 1335 + // the window is stuck in loading state forever — clear it. 1336 + if (!hasEverLoaded) { 1337 + console.log('[page] Load aborted before any page loaded (likely download), clearing loading state'); 1338 + loadingLifecycle.stopLoading(); 1339 + } 1340 + return; 1341 + } 1309 1342 console.error('[page] Load failed:', e.errorCode, e.errorDescription); 1310 1343 loadingLifecycle.stopLoading(); 1311 1344 }); ··· 1365 1398 // --- OpenSearch discovery, loading lifecycle & page:loaded event --- 1366 1399 1367 1400 webview.addEventListener('did-finish-load', async () => { 1401 + hasEverLoaded = true; 1368 1402 loadingLifecycle.stopLoading(); 1369 1403 const pageUrl = webview.getURL(); 1370 1404 if (!pageUrl) return;
+141 -137
backend/electron/ipc.ts
··· 2567 2567 const detectedMode = detectModeFromUrl(modeUrl); 2568 2568 DEBUG && console.log('Set window mode:', win.id, 'url:', modeUrl, 'mode:', detectedMode); 2569 2569 2570 - // Load the URL AFTER mode/context is set, so page.js can read inherited mode 2571 - console.log(`[page-host:${win.id}] Loading: ${loadUrl.substring(0, 120)}`); 2572 - await win.loadURL(loadUrl); 2573 - console.log(`[page-host:${win.id}] loadURL resolved`); 2574 - 2575 - // Background detection for non-canvas web pages (slides, modals, quick-views). 2576 - // Canvas pages get this via the webview dom-ready handler in page.js. 2577 - // Non-canvas pages load directly in the BrowserWindow, so we detect here. 2578 - // Without this, pages that don't set a background show dark text on a dark 2579 - // BrowserWindow backgroundColor — unreadable in dark mode. 2580 - if (isWebPage && !useCanvas) { 2581 - win.webContents.on('dom-ready', async () => { 2582 - try { 2583 - const needsBackground = await win.webContents.executeJavaScript(` 2584 - (function() { 2585 - function isTransparentColor(color) { 2586 - if (!color) return true; 2587 - if (color === 'transparent') return true; 2588 - if (color === 'rgba(0, 0, 0, 0)') return true; 2589 - var rgbaMatch = color.match(/rgba\\s*\\(\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*([\\d.]+)\\s*\\)/); 2590 - if (rgbaMatch && parseFloat(rgbaMatch[1]) === 0) return true; 2591 - return false; 2592 - } 2593 - function hasBackground(el) { 2594 - if (!el) return false; 2595 - var style = window.getComputedStyle(el); 2596 - var bgColor = style.backgroundColor; 2597 - var hasColor = !isTransparentColor(bgColor); 2598 - var bgImage = style.backgroundImage; 2599 - var hasImage = bgImage && bgImage !== 'none'; 2600 - return hasColor || hasImage; 2601 - } 2602 - var html = document.documentElement; 2603 - var body = document.body; 2604 - return !(hasBackground(html) || hasBackground(body)); 2605 - })(); 2606 - `); 2607 - if (needsBackground) { 2608 - await win.webContents.executeJavaScript(` 2609 - document.documentElement.style.backgroundColor = '#ffffff'; 2610 - `); 2611 - DEBUG && console.log('[non-canvas-bg] No background detected, set default white for window:', win.id); 2612 - } else { 2613 - DEBUG && console.log('[non-canvas-bg] Page has background, skipping default for window:', win.id); 2614 - } 2615 - } catch (err) { 2616 - console.error('[non-canvas-bg] Failed to check/set background:', err); 2617 - } 2618 - }); 2619 - } 2620 - 2621 - // Update item titles when the page finishes loading (non-canvas web pages). 2622 - // Canvas pages have a guest webContents handler below via did-attach-webview. 2623 - // Non-canvas pages (modals, slides, quick-views) load directly in the BrowserWindow. 2624 - if (isWebPage && !useCanvas && !url.startsWith('peek://')) { 2625 - win.webContents.on('page-title-updated', (_event: Electron.Event, title: string) => { 2626 - if (title && title !== 'Loading...') { 2627 - const pageUrl = win.webContents.getURL() || url; 2628 - try { 2629 - updateItemTitle(pageUrl, title); 2630 - } catch (e) { 2631 - DEBUG && console.log('Failed to update title from page-title-updated:', e); 2632 - } 2633 - // Notify extensions that page content is available for extraction 2634 - publish('system', PubSubScopes.GLOBAL, 'page:content-ready', { 2635 - url: pageUrl, 2636 - title, 2637 - windowId: win.id, 2638 - }); 2639 - } 2640 - }); 2641 - } 2642 - 2643 - // Track this load in history (skip internal peek:// URLs) 2644 - if (!url.startsWith('peek://')) { 2645 - try { 2646 - const trackResult = trackWindowLoad(url, { 2647 - source: options.trackingSource || options.feature || 'window', 2648 - sourceId: options.trackingSourceId || '', 2649 - windowType: options.modal ? 'modal' : 'main', 2650 - title: options.title || (win.getTitle() !== 'Loading...' ? win.getTitle() : '') || '', 2651 - }); 2652 - if (trackResult.created) { 2653 - publish('system', PubSubScopes.GLOBAL, 'item:created', { 2654 - itemId: trackResult.itemId, 2655 - itemType: 'url', 2656 - content: url 2657 - }); 2658 - } 2659 - // Auto-tag with group if this window is in group mode 2660 - const loadModeEntry = getContextEntry('mode', win.id); 2661 - console.log('[openWindow] trackWindowLoad auto-tag check:', { winId: win.id, mode: loadModeEntry?.value, groupId: loadModeEntry?.metadata?.groupId, itemId: trackResult.itemId }); 2662 - if (loadModeEntry && loadModeEntry.value === 'group' && loadModeEntry.metadata?.groupId) { 2663 - tagItemAndPublish(trackResult.itemId, loadModeEntry.metadata.groupId as string); 2664 - console.log('[openWindow] Auto-tagged item', trackResult.itemId, 'with group', loadModeEntry.metadata.groupId); 2665 - } 2666 - } catch (e) { 2667 - DEBUG && console.log('Failed to track window load:', e); 2668 - } 2669 - } 2670 - 2671 - // Track in-page navigation (link clicks within the window) 2672 - // Skip tracking for peek:// URLs - these are internal (container pages, etc.) 2673 - // Web pages inside peek://app/page container handle their own tracking via webview events 2674 - win.webContents.on('did-navigate', (_event: Electron.Event, navUrl: string) => { 2675 - // Skip if it's the same URL we just loaded 2676 - if (navUrl === url) return; 2677 - // Skip internal peek:// URLs 2678 - if (navUrl.startsWith('peek://')) return; 2679 - try { 2680 - const navTrack = trackWindowLoad(navUrl, { 2681 - source: 'navigation', 2682 - sourceId: '', 2683 - windowType: options.modal ? 'modal' : 'main', 2684 - title: (win.getTitle() !== 'Loading...' ? win.getTitle() : '') || '', 2685 - }); 2686 - if (navTrack.created) { 2687 - publish('system', PubSubScopes.GLOBAL, 'item:created', { 2688 - itemId: navTrack.itemId, 2689 - itemType: 'url', 2690 - content: navUrl 2691 - }); 2692 - } 2693 - // Auto-tag with group if this window is in group mode 2694 - const navModeEntry = getContextEntry('mode', win.id); 2695 - if (navModeEntry && navModeEntry.value === 'group' && navModeEntry.metadata?.groupId) { 2696 - tagItemAndPublish(navTrack.itemId, navModeEntry.metadata.groupId as string); 2697 - DEBUG && console.log('[did-navigate] Auto-tagged item', navTrack.itemId, 'with group', navModeEntry.metadata.groupId); 2698 - } 2699 - } catch (e) { 2700 - DEBUG && console.log('Failed to track did-navigate:', e); 2701 - } 2702 - }); 2703 - 2570 + // Register did-attach-webview BEFORE loadURL so we don't miss the event. 2571 + // The webview attaches during loadURL, so if we register after, the event is missed 2572 + // and Cmd+L / window.open handlers never get wired up on the guest webContents. 2573 + // 2704 2574 // Handle window.open() / target="_blank" from <webview> guest webContents. 2705 2575 // The host BrowserWindow loads peek://app/page/index.html which contains a <webview>. 2706 2576 // Keystrokes and window.open() calls from web content go to the guest's webContents, ··· 2925 2795 }); 2926 2796 } 2927 2797 2928 - // Add escape key handler to all windows 2929 - addEscHandler(win); 2930 - 2931 2798 // Add keyboard shortcuts on the HOST webContents as a fallback. 2932 2799 // The primary handlers are on the webview guest (added via did-attach-webview above). 2933 2800 // These host-level handlers fire when the page container's own DOM has focus (rare, ··· 2951 2818 } 2952 2819 }); 2953 2820 } 2821 + 2822 + // Load the URL AFTER mode/context is set, so page.js can read inherited mode 2823 + console.log(`[page-host:${win.id}] Loading: ${loadUrl.substring(0, 120)}`); 2824 + await win.loadURL(loadUrl); 2825 + console.log(`[page-host:${win.id}] loadURL resolved`); 2826 + 2827 + // Background detection for non-canvas web pages (slides, modals, quick-views). 2828 + // Canvas pages get this via the webview dom-ready handler in page.js. 2829 + // Non-canvas pages load directly in the BrowserWindow, so we detect here. 2830 + // Without this, pages that don't set a background show dark text on a dark 2831 + // BrowserWindow backgroundColor — unreadable in dark mode. 2832 + if (isWebPage && !useCanvas) { 2833 + win.webContents.on('dom-ready', async () => { 2834 + try { 2835 + const needsBackground = await win.webContents.executeJavaScript(` 2836 + (function() { 2837 + function isTransparentColor(color) { 2838 + if (!color) return true; 2839 + if (color === 'transparent') return true; 2840 + if (color === 'rgba(0, 0, 0, 0)') return true; 2841 + var rgbaMatch = color.match(/rgba\\s*\\(\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*([\\d.]+)\\s*\\)/); 2842 + if (rgbaMatch && parseFloat(rgbaMatch[1]) === 0) return true; 2843 + return false; 2844 + } 2845 + function hasBackground(el) { 2846 + if (!el) return false; 2847 + var style = window.getComputedStyle(el); 2848 + var bgColor = style.backgroundColor; 2849 + var hasColor = !isTransparentColor(bgColor); 2850 + var bgImage = style.backgroundImage; 2851 + var hasImage = bgImage && bgImage !== 'none'; 2852 + return hasColor || hasImage; 2853 + } 2854 + var html = document.documentElement; 2855 + var body = document.body; 2856 + return !(hasBackground(html) || hasBackground(body)); 2857 + })(); 2858 + `); 2859 + if (needsBackground) { 2860 + await win.webContents.executeJavaScript(` 2861 + document.documentElement.style.backgroundColor = '#ffffff'; 2862 + `); 2863 + DEBUG && console.log('[non-canvas-bg] No background detected, set default white for window:', win.id); 2864 + } else { 2865 + DEBUG && console.log('[non-canvas-bg] Page has background, skipping default for window:', win.id); 2866 + } 2867 + } catch (err) { 2868 + console.error('[non-canvas-bg] Failed to check/set background:', err); 2869 + } 2870 + }); 2871 + } 2872 + 2873 + // Update item titles when the page finishes loading (non-canvas web pages). 2874 + // Canvas pages have a guest webContents handler via did-attach-webview (registered above, before loadURL). 2875 + // Non-canvas pages (modals, slides, quick-views) load directly in the BrowserWindow. 2876 + if (isWebPage && !useCanvas && !url.startsWith('peek://')) { 2877 + win.webContents.on('page-title-updated', (_event: Electron.Event, title: string) => { 2878 + if (title && title !== 'Loading...') { 2879 + const pageUrl = win.webContents.getURL() || url; 2880 + try { 2881 + updateItemTitle(pageUrl, title); 2882 + } catch (e) { 2883 + DEBUG && console.log('Failed to update title from page-title-updated:', e); 2884 + } 2885 + // Notify extensions that page content is available for extraction 2886 + publish('system', PubSubScopes.GLOBAL, 'page:content-ready', { 2887 + url: pageUrl, 2888 + title, 2889 + windowId: win.id, 2890 + }); 2891 + } 2892 + }); 2893 + } 2894 + 2895 + // Track this load in history (skip internal peek:// URLs) 2896 + if (!url.startsWith('peek://')) { 2897 + try { 2898 + const trackResult = trackWindowLoad(url, { 2899 + source: options.trackingSource || options.feature || 'window', 2900 + sourceId: options.trackingSourceId || '', 2901 + windowType: options.modal ? 'modal' : 'main', 2902 + title: options.title || (win.getTitle() !== 'Loading...' ? win.getTitle() : '') || '', 2903 + }); 2904 + if (trackResult.created) { 2905 + publish('system', PubSubScopes.GLOBAL, 'item:created', { 2906 + itemId: trackResult.itemId, 2907 + itemType: 'url', 2908 + content: url 2909 + }); 2910 + } 2911 + // Auto-tag with group if this window is in group mode 2912 + const loadModeEntry = getContextEntry('mode', win.id); 2913 + console.log('[openWindow] trackWindowLoad auto-tag check:', { winId: win.id, mode: loadModeEntry?.value, groupId: loadModeEntry?.metadata?.groupId, itemId: trackResult.itemId }); 2914 + if (loadModeEntry && loadModeEntry.value === 'group' && loadModeEntry.metadata?.groupId) { 2915 + tagItemAndPublish(trackResult.itemId, loadModeEntry.metadata.groupId as string); 2916 + console.log('[openWindow] Auto-tagged item', trackResult.itemId, 'with group', loadModeEntry.metadata.groupId); 2917 + } 2918 + } catch (e) { 2919 + DEBUG && console.log('Failed to track window load:', e); 2920 + } 2921 + } 2922 + 2923 + // Track in-page navigation (link clicks within the window) 2924 + // Skip tracking for peek:// URLs - these are internal (container pages, etc.) 2925 + // Web pages inside peek://app/page container handle their own tracking via webview events 2926 + win.webContents.on('did-navigate', (_event: Electron.Event, navUrl: string) => { 2927 + // Skip if it's the same URL we just loaded 2928 + if (navUrl === url) return; 2929 + // Skip internal peek:// URLs 2930 + if (navUrl.startsWith('peek://')) return; 2931 + try { 2932 + const navTrack = trackWindowLoad(navUrl, { 2933 + source: 'navigation', 2934 + sourceId: '', 2935 + windowType: options.modal ? 'modal' : 'main', 2936 + title: (win.getTitle() !== 'Loading...' ? win.getTitle() : '') || '', 2937 + }); 2938 + if (navTrack.created) { 2939 + publish('system', PubSubScopes.GLOBAL, 'item:created', { 2940 + itemId: navTrack.itemId, 2941 + itemType: 'url', 2942 + content: navUrl 2943 + }); 2944 + } 2945 + // Auto-tag with group if this window is in group mode 2946 + const navModeEntry = getContextEntry('mode', win.id); 2947 + if (navModeEntry && navModeEntry.value === 'group' && navModeEntry.metadata?.groupId) { 2948 + tagItemAndPublish(navTrack.itemId, navModeEntry.metadata.groupId as string); 2949 + DEBUG && console.log('[did-navigate] Auto-tagged item', navTrack.itemId, 'with group', navModeEntry.metadata.groupId); 2950 + } 2951 + } catch (e) { 2952 + DEBUG && console.log('Failed to track did-navigate:', e); 2953 + } 2954 + }); 2955 + 2956 + // Add escape key handler to all windows 2957 + addEscHandler(win); 2954 2958 2955 2959 // Track content window focus for devtools command 2956 2960 win.on('focus', () => {
+37 -1
backend/electron/session-partition.ts
··· 16 16 * - Migration is safe: copy first, mark complete only on success 17 17 */ 18 18 19 - import { session, Session } from 'electron'; 19 + import { app, session, Session, BrowserWindow } from 'electron'; 20 20 import fs from 'node:fs'; 21 21 import path from 'node:path'; 22 22 import { registerProtocolOnSession } from './protocol.js'; ··· 53 53 54 54 // Register the peek:// protocol handler on the profile session 55 55 registerProtocolOnSession(profileSession); 56 + 57 + // Handle file downloads — without this, download URLs leave windows stuck in loading state 58 + registerDownloadHandler(profileSession); 56 59 } 57 60 58 61 return profileSession; ··· 219 222 } 220 223 } 221 224 } 225 + } 226 + 227 + /** 228 + * Register a will-download handler on a session. 229 + * Saves downloads to the user's Downloads folder and closes the 230 + * originating window (since there's no page content to display). 231 + */ 232 + function registerDownloadHandler(ses: Session): void { 233 + ses.on('will-download', (_event, item, webContents) => { 234 + const filename = item.getFilename(); 235 + const savePath = path.join(app.getPath('downloads'), filename); 236 + item.setSavePath(savePath); 237 + console.log('[download] Saving:', filename, '→', savePath); 238 + 239 + // Close the window that triggered the download — it has no page to show. 240 + // webContents may be a webview guest; try the host (embedder) webContents first. 241 + let win = BrowserWindow.fromWebContents(webContents); 242 + if (!win && (webContents as any).hostWebContents) { 243 + win = BrowserWindow.fromWebContents((webContents as any).hostWebContents); 244 + } 245 + if (win && !win.isDestroyed()) { 246 + DEBUG && console.log('[download] Closing download-trigger window:', win.id); 247 + win.close(); 248 + } 249 + 250 + item.on('done', (_e, state) => { 251 + if (state === 'completed') { 252 + console.log('[download] Completed:', filename); 253 + } else { 254 + console.log('[download] Failed/cancelled:', filename, state); 255 + } 256 + }); 257 + }); 222 258 } 223 259 224 260 /**