experiments in a post-browser web
10
fork

Configure Feed

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

fix: screen centering, download navigation, search card migration

- Fix window centering on display switch: use cursor display instead of primary
- Fix download navigation: page stays loaded when clicking download links
- Migrate search list view to standard peek-card components
- Add download handling tests

+294 -83
+14 -11
backend/electron/ipc.ts
··· 2313 2313 } 2314 2314 2315 2315 // Center window on screen if no position specified (fallback when no opener) 2316 + // Use the display where the cursor is (the active display), not the primary display, 2317 + // so windows open on the screen the user is currently working on. 2316 2318 if (winOptions.x === undefined && winOptions.y === undefined) { 2317 - const primaryDisplay = screen.getPrimaryDisplay(); 2318 - const { width: screenW, height: screenH } = primaryDisplay.workAreaSize; 2319 + const cursorDisplay = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()); 2320 + const wa = cursorDisplay.workArea; 2319 2321 const winW = winOptions.width as number || APP_DEF_WIDTH; 2320 2322 const winH = winOptions.height as number || APP_DEF_HEIGHT; 2321 - winOptions.x = Math.round((screenW - winW) / 2); 2322 - winOptions.y = Math.round((screenH - winH) / 2); 2323 + winOptions.x = wa.x + Math.round((wa.width - winW) / 2); 2324 + winOptions.y = wa.y + Math.round((wa.height - winH) / 2); 2323 2325 } 2324 2326 2325 2327 if (options.modal === true) { ··· 2350 2352 // When no explicit position is provided (e.g., external URLs, pubsub-triggered opens), 2351 2353 // compute a centered position first so canvas bounds adjustment has valid coordinates. 2352 2354 // Without this, undefined x/y produces NaN which breaks window positioning. 2355 + // Use the display where the cursor is (the active display), not the primary display. 2353 2356 if (winOptions.x === undefined || winOptions.y === undefined) { 2354 - const primaryDisplay = screen.getPrimaryDisplay(); 2355 - const { width: screenW, height: screenH } = primaryDisplay.workAreaSize; 2357 + const cursorDisplay = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()); 2358 + const wa = cursorDisplay.workArea; 2356 2359 const winW = winOptions.width as number || APP_DEF_WIDTH; 2357 2360 const winH = winOptions.height as number || APP_DEF_HEIGHT; 2358 - winOptions.x = Math.round((screenW - winW) / 2); 2359 - winOptions.y = Math.round((screenH - winH) / 2); 2361 + winOptions.x = wa.x + Math.round((wa.width - winW) / 2); 2362 + winOptions.y = wa.y + Math.round((wa.height - winH) / 2); 2360 2363 } 2361 2364 2362 2365 // winOptions.x/y/width/height are currently the webview screen coordinates ··· 3027 3030 // Use explicit bounds instead of win.maximize() because panel-type windows 3028 3031 // on macOS don't respond to maximize() properly 3029 3032 if (options.maximize === true && !isHeadless()) { 3030 - const display = screen.getPrimaryDisplay(); 3031 - const { width, height } = display.workAreaSize; 3032 - win.setBounds({ x: 0, y: 0, width, height }); 3033 + const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()); 3034 + const wa = display.workArea; 3035 + win.setBounds({ x: wa.x, y: wa.y, width: wa.width, height: wa.height }); 3033 3036 } 3034 3037 3035 3038 // Handle overlay mode: hide other windows after this one is ready
+72 -11
backend/electron/session-partition.ts
··· 226 226 227 227 /** 228 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). 229 + * Saves downloads to the user's Downloads folder. 230 + * 231 + * Three cases: 232 + * 1. Top-level BrowserWindow (not webview): close it — no page to show. 233 + * 2. Webview guest with a real page loaded: keep open — user is browsing, 234 + * Chromium aborts the download navigation and the webview stays put. 235 + * 3. Webview guest in a popup window opened for a download URL: close 236 + * the host window — the webview has no real content (blank page). 231 237 */ 232 238 function registerDownloadHandler(ses: Session): void { 233 239 ses.on('will-download', (_event, item, webContents) => { ··· 236 242 item.setSavePath(savePath); 237 243 console.log('[download] Saving:', filename, '→', savePath); 238 244 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(); 245 + // Detect whether the download originated from a <webview> guest. 246 + // Use webContents.getType() which reliably returns 'webview' for guest contents. 247 + // (hostWebContents was deprecated and may be undefined in modern Electron.) 248 + const isWebviewGuest = webContents.getType() === 'webview'; 249 + 250 + if (!isWebviewGuest) { 251 + // Top-level window opened just for this download — close it. 252 + const win = BrowserWindow.fromWebContents(webContents); 253 + if (win && !win.isDestroyed()) { 254 + DEBUG && console.log('[download] Closing download-trigger window:', win.id); 255 + win.close(); 256 + } 257 + } else { 258 + // Download from a webview guest. Two cases: 259 + // 260 + // 1. User clicked a download link on a page they're browsing. 261 + // Chromium aborts the download navigation (error -3) and the webview 262 + // stays on (or reverts to) its previous URL — a real http(s) page. 263 + // → Keep the host window open. 264 + // 265 + // 2. A popup window (target="_blank") was opened for a download URL. 266 + // The download URL was the first URL loaded in the webview. After 267 + // Chromium aborts the navigation, the webview has no URL or a blank URL. 268 + // → Close the host window (it has no content to show). 269 + // 270 + // We distinguish these by checking the webview's current URL after 271 + // the download navigation was aborted. If it's a real page, keep open. 272 + // Use a short delay to let Chromium finish aborting the navigation. 273 + setTimeout(() => { 274 + if (webContents.isDestroyed()) return; 275 + const currentUrl = webContents.getURL(); 276 + const hasRealPage = currentUrl && currentUrl.startsWith('http') && currentUrl !== item.getURL(); 277 + if (hasRealPage) { 278 + DEBUG && console.log('[download] Webview guest has loaded page, keeping window open:', currentUrl); 279 + } else { 280 + // No real page loaded — close the host BrowserWindow. 281 + // Find the host window: try hostWebContents first, fall back to 282 + // scanning all windows (hostWebContents is deprecated in newer Electron). 283 + let hostWin: BrowserWindow | null = null; 284 + const hostWC = (webContents as any).hostWebContents; 285 + if (hostWC) { 286 + hostWin = BrowserWindow.fromWebContents(hostWC); 287 + } 288 + if (!hostWin) { 289 + // Fallback: find which BrowserWindow owns this guest webContents 290 + for (const bw of BrowserWindow.getAllWindows()) { 291 + if (bw.isDestroyed()) continue; 292 + // Check if this window's webContents has the guest attached 293 + // by looking at the page URL pattern (peek://app/page/index.html?url=...) 294 + const bwUrl = bw.webContents.getURL(); 295 + if (bwUrl.includes('page/index.html') && bwUrl.includes(encodeURIComponent(item.getURL()))) { 296 + hostWin = bw; 297 + break; 298 + } 299 + } 300 + } 301 + if (hostWin && !hostWin.isDestroyed()) { 302 + console.log('[download] Closing popup window opened for download:', hostWin.id); 303 + hostWin.close(); 304 + } else { 305 + DEBUG && console.log('[download] Download from webview guest (no page, no host window found)'); 306 + } 307 + } 308 + }, 500); 248 309 } 249 310 250 311 item.on('done', (_e, state) => {
+21 -61
features/search/home.js
··· 13 13 setupToolbar as _setupToolbar, 14 14 createViewPrefs 15 15 } from 'peek://app/lib/grid-nav.js'; 16 - import { 17 - createHeaderSlot, createFooterSlot, createFaviconEl, createUrlSpan, 18 - extractTitle, formatUrl, getItemDisplayInfo 19 - } from 'peek://app/lib/card-helpers.js'; 20 - import { 21 - createAffordanceElements, createActionRulesCache 22 - } from 'peek://app/lib/tag-action-affordances.js'; 16 + import { getItemDisplayInfo } from 'peek://app/lib/card-helpers.js'; 17 + import { createActionRulesCache } from 'peek://app/lib/tag-action-affordances.js'; 23 18 import { createSearchResultCard } from 'peek://app/lib/search-result-card.js'; 24 19 25 20 const api = window.app; ··· 35 30 parsedTags: [], // tag name strings (without #) 36 31 parsedText: '', // free-text portion 37 32 results: [], 33 + itemTags: new Map(), // Map of itemId -> [tags] 38 34 selectedIndex: 0 39 35 }; 40 36 ··· 202 198 } 203 199 204 200 state.results = items; 201 + 202 + // Pre-load tags for all result items 203 + state.itemTags.clear(); 204 + for (const item of items) { 205 + const tagsResult = await api.datastore.getItemTags(item.id); 206 + if (tagsResult.success) { 207 + state.itemTags.set(item.id, tagsResult.data); 208 + } 209 + } 210 + 205 211 debug && console.log('[search] Results:', items.length); 206 212 }; 207 213 ··· 264 270 * Uses the shared search-result-card builder for consistent rendering. 265 271 */ 266 272 const createResultCard = (item) => { 273 + const tags = state.itemTags.get(item.id) || []; 267 274 const rules = actionRulesCache ? actionRulesCache.getRules() : []; 268 - const { itemUrl, itemType } = getItemDisplayInfo(item); 275 + const { itemUrl, itemType } = getItemDisplayInfo(item, { tags }); 269 276 270 277 const card = createSearchResultCard(item, { 271 278 className: 'result-card', 279 + tags, 272 280 visitCount: item.visitCount, 273 281 showOpenButton: itemUrl && isWebUrl(itemUrl), 274 282 onOpen: (url) => { ··· 284 292 await api.datastore.deleteItem(item.id); 285 293 api.publish('item:deleted', { id: item.id }, api.scopes.GLOBAL); 286 294 state.results = state.results.filter(r => r.id !== item.id); 295 + state.itemTags.delete(item.id); 287 296 render(); 288 297 }, 289 298 onTagRemove: async (item, tag) => { 290 299 try { 291 300 await api.datastore.untagItem(item.id, tag.id); 292 301 api.publish('tag:item-removed', { itemId: item.id, tagId: tag.id }, api.scopes.GLOBAL); 302 + // Update local tag cache and re-render 303 + const currentTags = state.itemTags.get(item.id) || []; 304 + state.itemTags.set(item.id, currentTags.filter(t => t.id !== tag.id)); 305 + render(); 293 306 } catch (err) { 294 307 console.error('[search] Failed to remove tag:', err); 295 308 } ··· 311 324 api.publish('editor:open', { itemId: item.id }, api.scopes.GLOBAL); 312 325 } 313 326 } 314 - }); 315 - 316 - // Async: load item tags and render tag chips + affordances in footer 317 - api.datastore.getItemTags(item.id).then(tagsResult => { 318 - if (!tagsResult.success || !tagsResult.data.length) return; 319 - const tags = tagsResult.data; 320 - const footer = card.querySelector('[slot="footer"]'); 321 - if (!footer) return; 322 - 323 - // Build right-side container for tags and affordances 324 - let footerRight = footer.querySelector('span:last-child'); 325 - if (!footerRight || footerRight.className === 'card-url') { 326 - footerRight = document.createElement('span'); 327 - footerRight.style.cssText = 'display:flex;align-items:center;gap:6px;flex-shrink:0;'; 328 - footer.appendChild(footerRight); 329 - } 330 - 331 - // Tag action affordances 332 - if (rules.length > 0) { 333 - const tagNames = tags.map(t => t.name); 334 - const el = createAffordanceElements(item.id, tagNames, rules, api, { 335 - onToggle: () => { render(); } 336 - }); 337 - if (el) footerRight.appendChild(el); 338 - } 339 - 340 - // Tag chips with remove buttons 341 - const tagsContainer = document.createElement('span'); 342 - tagsContainer.className = 'card-tags'; 343 - tagsContainer.style.cssText = 'display:flex;gap:3px;flex-wrap:wrap;flex-shrink:0;'; 344 - tags.forEach(tag => { 345 - const chip = document.createElement('span'); 346 - chip.className = 'card-tag'; 347 - chip.dataset.tagId = tag.id; 348 - chip.textContent = tag.name; 349 - 350 - const removeBtn = document.createElement('span'); 351 - removeBtn.className = 'card-tag-remove'; 352 - removeBtn.textContent = '\u00D7'; 353 - removeBtn.title = `Remove ${tag.name}`; 354 - removeBtn.addEventListener('click', async (e) => { 355 - e.stopPropagation(); 356 - try { 357 - await api.datastore.untagItem(item.id, tag.id); 358 - api.publish('tag:item-removed', { itemId: item.id, tagId: tag.id }, api.scopes.GLOBAL); 359 - } catch (err) { 360 - console.error('[search] Failed to remove tag:', err); 361 - } 362 - }); 363 - chip.appendChild(removeBtn); 364 - tagsContainer.appendChild(chip); 365 - }); 366 - footerRight.appendChild(tagsContainer); 367 327 }); 368 328 369 329 return card;
+187
tests/desktop/download.spec.ts
··· 1 + /** 2 + * Download Navigation Tests 3 + * 4 + * Verifies that downloads in webviews don't leave the page blank or 5 + * create orphan popup windows. 6 + * 7 + * Run with: 8 + * yarn test:electron -- --grep "download" 9 + */ 10 + 11 + import { test, expect, DesktopApp, getSharedApp, closeSharedApp } from '../fixtures/desktop-app'; 12 + import { Page } from '@playwright/test'; 13 + import { waitForExtensionsReady, sleep } from '../helpers/window-utils'; 14 + import http from 'http'; 15 + 16 + // Shared app instance 17 + let sharedApp: DesktopApp; 18 + let sharedBgWindow: Page; 19 + let downloadServer: http.Server; 20 + let serverPort: number; 21 + 22 + // Track requests to verify downloads happened 23 + let downloadCount = 0; 24 + 25 + test.beforeAll(async () => { 26 + sharedApp = await getSharedApp(); 27 + sharedBgWindow = await sharedApp.getBackgroundWindow(); 28 + await waitForExtensionsReady(sharedBgWindow); 29 + 30 + // Local HTTP server that serves a page with download links 31 + await new Promise<void>((resolve) => { 32 + downloadServer = http.createServer((req, res) => { 33 + if (req.url === '/page') { 34 + res.writeHead(200, { 'Content-Type': 'text/html' }); 35 + res.end(` 36 + <!DOCTYPE html> 37 + <html> 38 + <head><title>Download Test Page</title></head> 39 + <body> 40 + <h1>Download Test</h1> 41 + <a id="direct-download" href="/download">Direct Download</a> 42 + <a id="blank-download" href="/download" target="_blank">Blank Download</a> 43 + </body> 44 + </html> 45 + `); 46 + } else if (req.url === '/download') { 47 + downloadCount++; 48 + res.writeHead(200, { 49 + 'Content-Type': 'application/octet-stream', 50 + 'Content-Disposition': 'attachment; filename="test-file.bin"', 51 + 'Content-Length': '12', 52 + }); 53 + res.end('file content'); 54 + } else { 55 + res.writeHead(404); 56 + res.end('Not found'); 57 + } 58 + }); 59 + downloadServer.listen(0, '127.0.0.1', () => { 60 + const addr = downloadServer.address(); 61 + serverPort = typeof addr === 'object' && addr ? addr.port : 0; 62 + resolve(); 63 + }); 64 + }); 65 + }); 66 + 67 + test.afterAll(async () => { 68 + if (downloadServer) { 69 + downloadServer.close(); 70 + } 71 + await closeSharedApp(); 72 + }); 73 + 74 + // ============================================================================ 75 + // Helpers 76 + // ============================================================================ 77 + 78 + async function openCanvasPage( 79 + bgWindow: Page, 80 + url: string 81 + ): Promise<{ pageWindow: Page; windowId: number }> { 82 + const result = await bgWindow.evaluate(async (targetUrl: string) => { 83 + return await (window as any).app.window.open(targetUrl, { 84 + width: 800, 85 + height: 600, 86 + }); 87 + }, url); 88 + expect(result.success).toBe(true); 89 + const windowId = result.id; 90 + 91 + const pageWindow = await sharedApp.getWindow('page/index.html', 15000); 92 + expect(pageWindow).toBeTruthy(); 93 + 94 + return { pageWindow, windowId }; 95 + } 96 + 97 + async function waitForPageLoaded(pageWindow: Page, timeout = 30000): Promise<void> { 98 + await pageWindow.waitForFunction( 99 + () => { 100 + const webview = document.getElementById('content'); 101 + return webview && webview.classList.contains('loaded'); 102 + }, 103 + undefined, 104 + { timeout } 105 + ); 106 + } 107 + 108 + async function getWebviewUrl(pageWindow: Page): Promise<string | null> { 109 + return pageWindow.evaluate(() => { 110 + const webview = document.getElementById('content') as any; 111 + return webview ? webview.getURL() : null; 112 + }); 113 + } 114 + 115 + // ============================================================================ 116 + // Download Tests 117 + // ============================================================================ 118 + 119 + test.describe('Download Navigation @desktop', () => { 120 + test('direct download link keeps page on original URL', async () => { 121 + downloadCount = 0; 122 + 123 + const pageUrl = `http://127.0.0.1:${serverPort}/page`; 124 + const { pageWindow, windowId } = await openCanvasPage(sharedBgWindow, pageUrl); 125 + await waitForPageLoaded(pageWindow); 126 + 127 + const urlBefore = await getWebviewUrl(pageWindow); 128 + expect(urlBefore).toContain('/page'); 129 + 130 + // Click a same-page download link (no target="_blank") 131 + await pageWindow.evaluate(() => { 132 + const webview = document.getElementById('content') as any; 133 + return webview.executeJavaScript(` 134 + document.getElementById('direct-download').click(); 135 + `); 136 + }); 137 + 138 + // Wait for download to be processed 139 + await sleep(3000); 140 + 141 + // Webview should still show the original page 142 + const urlAfter = await getWebviewUrl(pageWindow); 143 + expect(urlAfter).toContain('/page'); 144 + expect(downloadCount).toBeGreaterThan(0); 145 + 146 + await sharedBgWindow.evaluate(async (id: number) => { 147 + return await (window as any).app.window.close(id); 148 + }, windowId); 149 + }); 150 + 151 + test('target=_blank download cleans up the popup window', async () => { 152 + downloadCount = 0; 153 + 154 + const pageUrl = `http://127.0.0.1:${serverPort}/page`; 155 + const { pageWindow, windowId } = await openCanvasPage(sharedBgWindow, pageUrl); 156 + await waitForPageLoaded(pageWindow); 157 + 158 + const windowCountBefore = sharedApp.windows().length; 159 + 160 + // Click a target="_blank" download link. 161 + // This opens a new popup window for the download URL. 162 + // The popup should be cleaned up after the download starts. 163 + await pageWindow.evaluate(() => { 164 + const webview = document.getElementById('content') as any; 165 + return webview.executeJavaScript(` 166 + document.getElementById('blank-download').click(); 167 + `); 168 + }); 169 + 170 + // Wait for download and window cleanup 171 + await sleep(5000); 172 + 173 + expect(downloadCount).toBeGreaterThan(0); 174 + 175 + // Original page should still be intact 176 + const urlAfter = await getWebviewUrl(pageWindow); 177 + expect(urlAfter).toContain('/page'); 178 + 179 + // The popup window should have been cleaned up — no leftover blank windows 180 + const windowCountAfter = sharedApp.windows().length; 181 + expect(windowCountAfter).toBeLessThanOrEqual(windowCountBefore); 182 + 183 + await sharedBgWindow.evaluate(async (id: number) => { 184 + return await (window as any).app.window.close(id); 185 + }, windowId); 186 + }); 187 + });