experiments in a post-browser web
10
fork

Configure Feed

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

feat(me-core): add AT Protocol identity extension with OAuth, side nav, and lexicon browser

+3349 -1
+33
app/index.js
··· 608 608 } 609 609 }); 610 610 611 + // Handle reopen-last-closed-window requests from main process 612 + api.subscribe('window:reopen-request', async (msg) => { 613 + log('core', 'window:reopen-request', msg); 614 + const { url, options } = msg; 615 + 616 + try { 617 + await windowManager.createWindow(url, { 618 + ...options, 619 + trackingSource: options?.trackingSource || 'reopen', 620 + trackingSourceId: options?.trackingSourceId || 'last-closed', 621 + }); 622 + } catch (error) { 623 + log.error('core', 'Error reopening window:', error); 624 + } 625 + }); 626 + 627 + // Register "reopen closed window" command for the command palette 628 + api.subscribe('cmd:execute:reopen closed window', async (msg) => { 629 + const result = await api.invoke('window-reopen-last-closed'); 630 + if (msg.expectResult && msg.resultTopic) { 631 + api.publish(msg.resultTopic, result || { success: true }, api.scopes.GLOBAL); 632 + } 633 + }, api.scopes.GLOBAL); 634 + api.publish('cmd:register', { 635 + name: 'reopen closed window', 636 + description: 'Reopen the last closed window (Cmd+Shift+T)', 637 + source: 'system', 638 + scope: 'global', 639 + accepts: [], 640 + produces: [], 641 + params: [] 642 + }, api.scopes.GLOBAL); 643 + 611 644 // Signal to main process that frontend subscribers are ready. 612 645 // This unblocks pending external URLs that arrived during startup. 613 646 api.invoke('frontend-ready').catch(err => {
+6
backend/electron/entry.ts
··· 52 52 // IPC 53 53 registerAllHandlers, 54 54 restoreSavedTheme, 55 + reopenLastClosedWindow, 55 56 // Database 56 57 getDb, 57 58 // Config ··· 641 642 // Will be updated when prefs arrive 642 643 _quitShortcut = strings.defaults.quitShortcut; 643 644 registerLocalShortcut(_quitShortcut, 'system', onQuit); 645 + 646 + // Register reopen-last-closed-window shortcut (Cmd+Shift+T / Ctrl+Shift+T) 647 + registerLocalShortcut('CommandOrControl+Shift+T', 'system', () => { 648 + reopenLastClosedWindow(); 649 + }); 644 650 645 651 // Mark app as ready and process any URLs that arrived during startup 646 652 setAppReady();
+1
backend/electron/index.ts
··· 198 198 restoreSavedTheme, 199 199 getDarkModeSetting, 200 200 applyDarkModeSetting, 201 + reopenLastClosedWindow, 201 202 } from './ipc.js'; 202 203 203 204 // Window helpers
+82
backend/electron/ipc.ts
··· 8 8 import { ipcMain, nativeTheme, dialog, BrowserWindow, app, screen, shell, webContents, net } from 'electron'; 9 9 import fs from 'node:fs'; 10 10 import path from 'node:path'; 11 + import { createLoopbackServer } from './oauth-loopback.js'; 11 12 12 13 import { 13 14 // Datastore operations ··· 104 105 findWindowByKey, 105 106 getAllWindows, 106 107 validateThemeCSS, 108 + popClosedWindow, 109 + getClosedWindowCount, 107 110 } from './main.js'; 108 111 109 112 import { ··· 2082 2085 } 2083 2086 2084 2087 /** 2088 + * Reopen the most recently closed window. 2089 + * Called from both the IPC handler and the keyboard shortcut. 2090 + * Uses pubsub to request the background window open the URL 2091 + * (so we get full window-open logic including canvas, mode detection, etc.) 2092 + */ 2093 + export function reopenLastClosedWindow(): { success: boolean; url?: string; error?: string } { 2094 + const entry = popClosedWindow(); 2095 + if (!entry) { 2096 + DEBUG && console.log('[reopen] No closed windows to reopen'); 2097 + return { success: false, error: 'No closed windows to reopen' }; 2098 + } 2099 + 2100 + DEBUG && console.log('[reopen] Reopening:', entry.url, 'bounds:', entry.bounds); 2101 + 2102 + // Build options to restore window state 2103 + const options: Record<string, unknown> = { 2104 + trackingSource: 'reopen', 2105 + trackingSourceId: 'last-closed', 2106 + }; 2107 + 2108 + if (entry.bounds) { 2109 + options.x = entry.bounds.x; 2110 + options.y = entry.bounds.y; 2111 + options.width = entry.bounds.width; 2112 + options.height = entry.bounds.height; 2113 + } 2114 + 2115 + if (entry.groupMode) { 2116 + options.groupMode = entry.groupMode; 2117 + } 2118 + 2119 + // Publish event for the background window to pick up and open 2120 + publish(getSystemAddress(), PubSubScopes.GLOBAL, 'window:reopen-request', { 2121 + url: entry.url, 2122 + options, 2123 + }); 2124 + 2125 + return { success: true, url: entry.url }; 2126 + } 2127 + 2128 + /** 2085 2129 * Register window management IPC handlers 2086 2130 */ 2087 2131 const pendingWindowKeys = new Set<string>(); ··· 3020 3064 const message = error instanceof Error ? error.message : String(error); 3021 3065 return { success: false, error: message }; 3022 3066 } 3067 + }); 3068 + 3069 + // Reopen last closed window 3070 + ipcMain.handle('window-reopen-last-closed', async () => { 3071 + return reopenLastClosedWindow(); 3023 3072 }); 3024 3073 3025 3074 ipcMain.handle('window-hide', async (_ev, msg) => { ··· 4094 4143 return { success: false, error: fetchMessage }; 4095 4144 } 4096 4145 } catch (error) { 4146 + const message = error instanceof Error ? error.message : String(error); 4147 + return { success: false, error: message }; 4148 + } 4149 + }); 4150 + 4151 + // ============================================================================ 4152 + // OAuth Loopback Server 4153 + // ============================================================================ 4154 + 4155 + const pendingOAuthFlows = new Map<number, { waitForCallback: () => Promise<{ params: Record<string, string> }>; cancel: () => void }>(); 4156 + 4157 + ipcMain.handle('oauth-start-loopback', async (_ev, options?: { callbackPath?: string; timeoutMs?: number }) => { 4158 + try { 4159 + const server = await createLoopbackServer(options); 4160 + pendingOAuthFlows.set(server.port, { waitForCallback: server.waitForCallback, cancel: server.cancel }); 4161 + return { success: true, port: server.port }; 4162 + } catch (error) { 4163 + const message = error instanceof Error ? error.message : String(error); 4164 + return { success: false, error: message }; 4165 + } 4166 + }); 4167 + 4168 + ipcMain.handle('oauth-await-callback', async (_ev, data: { port: number }) => { 4169 + try { 4170 + const flow = pendingOAuthFlows.get(data.port); 4171 + if (!flow) { 4172 + return { success: false, error: 'No pending OAuth flow for this port' }; 4173 + } 4174 + const result = await flow.waitForCallback(); 4175 + pendingOAuthFlows.delete(data.port); 4176 + return { success: true, params: result.params }; 4177 + } catch (error) { 4178 + pendingOAuthFlows.delete(data.port); 4097 4179 const message = error instanceof Error ? error.message : String(error); 4098 4180 return { success: false, error: message }; 4099 4181 }
+87 -1
backend/electron/main.ts
··· 10 10 import fs from 'node:fs'; 11 11 import { pathToFileURL } from 'node:url'; 12 12 13 - import { initDatabase, closeDatabase, getDb, trackWindowLoad, updateItemTitle, updateModeForNavigation } from './datastore.js'; 13 + import { initDatabase, closeDatabase, getDb, trackWindowLoad, updateItemTitle, updateModeForNavigation, getContextEntry } from './datastore.js'; 14 14 import { registerScheme, initProtocol, registerExtensionPath, getExtensionPath, getRegisteredExtensionIds, registerThemePath, getRegisteredThemeIds } from './protocol.js'; 15 15 import { discoverExtensions, loadExtensionManifest, isBuiltinExtensionEnabled, getExternalExtensions } from './extensions.js'; 16 16 import { initTray } from './tray.js'; ··· 72 72 source: string; 73 73 params: Record<string, unknown>; 74 74 }>(); 75 + 76 + // Recently closed windows stack (in-memory, most recent last) 77 + const MAX_CLOSED_WINDOWS = 20; 78 + 79 + interface ClosedWindowEntry { 80 + url: string; // Original URL (not the peek:// rewritten one) 81 + source: string; 82 + bounds: { x: number; y: number; width: number; height: number } | null; 83 + groupMode: { groupId: string; groupName: string; color?: string } | null; 84 + timestamp: number; 85 + } 86 + 87 + const closedWindowStack: ClosedWindowEntry[] = []; 75 88 76 89 /** 77 90 * Initialize the application configuration ··· 182 195 app.on('browser-window-created', (_, window) => { 183 196 const windowId = window.id; 184 197 198 + // Capture window bounds before the window is destroyed (for reopen-last-closed) 199 + let lastBounds: { x: number; y: number; width: number; height: number } | null = null; 200 + window.on('close', () => { 201 + try { 202 + if (!window.isDestroyed()) { 203 + lastBounds = window.getBounds(); 204 + } 205 + } catch { 206 + // Ignore errors during shutdown 207 + } 208 + }); 209 + 185 210 // Handle window close 186 211 // Wrapped in try-catch to prevent errors during shutdown from stalling the quit sequence. 187 212 // During app.quit(), windows close concurrently and some may already be destroyed. ··· 226 251 id: windowId, 227 252 source: windowData.source 228 253 }); 254 + 255 + // Save to closed window stack for reopen-last-closed 256 + // Only save user-facing content windows with web URLs (not background, modals, palettes, etc.) 257 + const address = windowData.params.address as string | undefined; 258 + const role = windowData.params.role as string | undefined; 259 + const isKeepLive = windowData.params.keepLive === true; 260 + const isModal = windowData.params.modal === true; 261 + const isWebUrl = address && (address.startsWith('http://') || address.startsWith('https://')); 262 + const isContentRole = !role || role === 'content' || role === 'child-content' || role === 'workspace'; 263 + 264 + if (isWebUrl && isContentRole && !isKeepLive && !isModal) { 265 + // Check for group mode context 266 + let groupMode: ClosedWindowEntry['groupMode'] = null; 267 + try { 268 + const modeEntry = getContextEntry('mode', windowId); 269 + if (modeEntry && modeEntry.value === 'group' && modeEntry.metadata) { 270 + groupMode = { 271 + groupId: modeEntry.metadata.groupId as string, 272 + groupName: modeEntry.metadata.groupName as string, 273 + color: modeEntry.metadata.color as string | undefined, 274 + }; 275 + } 276 + } catch { 277 + // Context may be cleaned up already during shutdown 278 + } 279 + 280 + pushClosedWindow({ 281 + url: address, 282 + source: windowData.source, 283 + bounds: lastBounds, 284 + groupMode, 285 + timestamp: Date.now(), 286 + }); 287 + } 229 288 } 230 289 231 290 windowRegistry.delete(windowId); ··· 937 996 */ 938 997 export function getAllWindows(): Array<[number, { source: string; params: Record<string, unknown> }]> { 939 998 return Array.from(windowRegistry.entries()); 999 + } 1000 + 1001 + /** 1002 + * Push a closed window entry onto the stack 1003 + */ 1004 + export function pushClosedWindow(entry: ClosedWindowEntry): void { 1005 + closedWindowStack.push(entry); 1006 + if (closedWindowStack.length > MAX_CLOSED_WINDOWS) { 1007 + closedWindowStack.shift(); 1008 + } 1009 + DEBUG && console.log('[reopen] Pushed closed window:', entry.url, 'stack size:', closedWindowStack.length); 1010 + } 1011 + 1012 + /** 1013 + * Pop the most recently closed window from the stack 1014 + */ 1015 + export function popClosedWindow(): ClosedWindowEntry | undefined { 1016 + const entry = closedWindowStack.pop(); 1017 + DEBUG && console.log('[reopen] Popped closed window:', entry?.url, 'stack size:', closedWindowStack.length); 1018 + return entry; 1019 + } 1020 + 1021 + /** 1022 + * Get the number of closed windows in the stack 1023 + */ 1024 + export function getClosedWindowCount(): number { 1025 + return closedWindowStack.length; 940 1026 } 941 1027 942 1028 /**
+99
backend/electron/oauth-loopback.ts
··· 1 + /** 2 + * Generic OAuth loopback HTTP server for handling OAuth callbacks. 3 + * 4 + * Starts a temporary HTTP server on 127.0.0.1 with an OS-assigned port. 5 + * Returns the port so the caller can build a redirect_uri, then waits 6 + * for the OAuth callback GET request and returns the query parameters. 7 + * 8 + * Reusable by any extension or future OAuth flow. 9 + */ 10 + 11 + import * as http from 'node:http'; 12 + 13 + const SUCCESS_HTML = `<!DOCTYPE html> 14 + <html><head><meta charset="utf-8"><title>Authorization Complete</title> 15 + <style>body{font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#1a1a1a;color:#e0e0e0} 16 + .card{text-align:center;padding:2rem}.check{font-size:3rem;margin-bottom:1rem}</style></head> 17 + <body><div class="card"><div class="check">&#10003;</div><p>Authorization complete. You can close this window.</p></div></body></html>`; 18 + 19 + interface LoopbackServer { 20 + port: number; 21 + waitForCallback: () => Promise<{ params: Record<string, string> }>; 22 + cancel: () => void; 23 + } 24 + 25 + export function createLoopbackServer(opts?: { 26 + callbackPath?: string; 27 + timeoutMs?: number; 28 + }): Promise<LoopbackServer> { 29 + const callbackPath = opts?.callbackPath ?? '/callback'; 30 + const timeoutMs = opts?.timeoutMs ?? 120_000; 31 + 32 + return new Promise((resolveOuter, rejectOuter) => { 33 + let settled = false; 34 + let callbackResolve: (value: { params: Record<string, string> }) => void; 35 + let callbackReject: (reason: Error) => void; 36 + 37 + const callbackPromise = new Promise<{ params: Record<string, string> }>((res, rej) => { 38 + callbackResolve = res; 39 + callbackReject = rej; 40 + }); 41 + 42 + const server = http.createServer((req, res) => { 43 + const url = new URL(req.url || '/', `http://127.0.0.1`); 44 + 45 + if (req.method === 'GET' && url.pathname === callbackPath) { 46 + const params: Record<string, string> = {}; 47 + url.searchParams.forEach((v, k) => { params[k] = v; }); 48 + 49 + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); 50 + res.end(SUCCESS_HTML); 51 + 52 + callbackResolve!({ params }); 53 + cleanup(); 54 + } else { 55 + res.writeHead(404, { 'Content-Type': 'text/plain' }); 56 + res.end('Not Found'); 57 + } 58 + }); 59 + 60 + const timeout = setTimeout(() => { 61 + if (!settled) { 62 + callbackReject!(new Error('OAuth callback timed out')); 63 + cleanup(); 64 + } 65 + }, timeoutMs); 66 + 67 + function cleanup() { 68 + settled = true; 69 + clearTimeout(timeout); 70 + try { server.close(); } catch {} 71 + } 72 + 73 + server.listen(0, '127.0.0.1', () => { 74 + const addr = server.address(); 75 + if (!addr || typeof addr === 'string') { 76 + rejectOuter(new Error('Failed to bind loopback server')); 77 + return; 78 + } 79 + 80 + resolveOuter({ 81 + port: addr.port, 82 + waitForCallback: () => callbackPromise, 83 + cancel: () => { 84 + if (!settled) { 85 + callbackReject!(new Error('OAuth flow cancelled')); 86 + cleanup(); 87 + } 88 + }, 89 + }); 90 + }); 91 + 92 + server.on('error', (err) => { 93 + if (!settled) { 94 + rejectOuter(err); 95 + cleanup(); 96 + } 97 + }); 98 + }); 99 + }
+589
docs/entity-correlation-design.md
··· 1 + # Entity Correlation System Design 2 + 3 + **Date:** 2026-02-19 4 + **Status:** Design proposal — not yet implemented 5 + 6 + --- 7 + 8 + ## 1. Current State Analysis 9 + 10 + ### How Entities Work Today 11 + 12 + Entities are stored as items in the unified `items` table with `type = 'entity'`. Each entity item has: 13 + 14 + - **`content`**: The canonical name (e.g., "Elon Musk") 15 + - **`metadata`** (JSON): `entityType`, `aliases[]`, `attributes{}`, `confidence`, `extractor`, `mergedFrom[]`, `feedback{}` 16 + - **`visitCount`** / **`lastVisitAt`** / **`frecencyScore`**: Tracking how often/recently the entity appears 17 + 18 + Observations (entity X was seen on page Y) are stored as `item_events` rows: 19 + 20 + - **`itemId`**: Points to the entity item 21 + - **`content`**: The source page URL 22 + - **`value`**: Extraction confidence for that observation 23 + - **`occurredAt`**: Timestamp 24 + - **`metadata`** (JSON): `extractor`, `pageTitle`, `extractedText`, `context` 25 + 26 + ### Entity Types 27 + 28 + The system recognizes these entity types: `person`, `organization`, `place`, `event`, `email`, `phone`, `date`, `product`, `creative_work`, `tracking_number`, `price`. 29 + 30 + ### Extractors 31 + 32 + Three extractors run in parallel on each page: 33 + 34 + 1. **JSON-LD / structured data** (highest confidence, 0.85-0.9): Parses `<script type="application/ld+json">`, maps schema.org types to entity types. Also extracts OG meta tags and standard meta tags (author, publisher). 35 + 2. **Microformats** (high confidence, 0.9-0.95): Parses h-card, h-event, h-adr markup. 36 + 3. **Regex** (variable confidence, 0.6-0.9): Extracts emails, phone numbers, addresses, tracking numbers from raw text. 37 + 38 + ### What's Missing 39 + 40 + Entities are entirely flat. Each entity knows which pages it appeared on (via `item_events`), but there is no concept of: 41 + 42 + - Which entities appear together on the same pages 43 + - How strongly two entities are related 44 + - Clusters of related entities 45 + - Entity identity across name variations (limited alias support exists but is only used at extraction time) 46 + 47 + The raw data for co-occurrence already exists in `item_events` — we just need to derive relationships from it. 48 + 49 + --- 50 + 51 + ## 2. Co-occurrence Model 52 + 53 + ### Core Insight 54 + 55 + Two entities are related if they appear on the same pages. The more pages they share, and the fewer total pages each appears on independently, the stronger the signal. This is essentially the same statistical reasoning behind TF-IDF, applied to entity pairs instead of document terms. 56 + 57 + ### Data Structure: Edge Table 58 + 59 + The fundamental unit is a **weighted edge** between two entities: 60 + 61 + ``` 62 + entity_correlations: 63 + entityA TEXT NOT NULL -- entity item ID (lexicographically smaller) 64 + entityB TEXT NOT NULL -- entity item ID (lexicographically larger) 65 + cooccurrenceCount INTEGER -- number of pages where both appear 66 + pmi REAL -- pointwise mutual information score 67 + lastCooccurrence INTEGER -- timestamp of most recent shared page 68 + firstCooccurrence INTEGER -- timestamp of first shared page 69 + updatedAt INTEGER -- when this edge was last recalculated 70 + PRIMARY KEY (entityA, entityB) 71 + ``` 72 + 73 + The canonical ordering (entityA < entityB lexicographically) ensures each pair is stored exactly once. 74 + 75 + ### Why PMI Over Raw Count 76 + 77 + Raw co-occurrence count is misleading. If "Google" appears on 200 of your pages and "Chrome" appears on 150, they'll co-occur frequently just by volume. PMI (Pointwise Mutual Information) corrects for this: 78 + 79 + ``` 80 + PMI(a, b) = log2( P(a,b) / (P(a) * P(b)) ) 81 + ``` 82 + 83 + Where: 84 + - `P(a,b)` = pages where both a and b appear / total pages with any entity 85 + - `P(a)` = pages where a appears / total pages with any entity 86 + - `P(b)` = pages where b appears / total pages with any entity 87 + 88 + PMI is positive when entities co-occur more than chance, zero when independent, and negative when they avoid each other. For our purposes, we only care about positive PMI. 89 + 90 + **Normalized PMI (NPMI)** bounds the score to [-1, 1]: 91 + 92 + ``` 93 + NPMI(a, b) = PMI(a, b) / -log2(P(a,b)) 94 + ``` 95 + 96 + NPMI = 1 means perfect co-occurrence (they always appear together), NPMI = 0 means independence. This is more interpretable and comparable across entity pairs with different frequencies. 97 + 98 + ### Temporal Decay 99 + 100 + Recent co-occurrences should matter more than old ones. Rather than decaying the PMI score itself (which would require continuous recomputation), we apply decay at query time: 101 + 102 + ``` 103 + effective_score = NPMI * decay_factor 104 + decay_factor = 1 / (1 + days_since_last_cooccurrence / 30) 105 + ``` 106 + 107 + This uses the same decay shape as the existing frecency calculation in the codebase (see `entity-store.js` line 103), with a 30-day half-life instead of 7 days — relationships are stickier than individual entity relevance. 108 + 109 + The `lastCooccurrence` timestamp on each edge enables this without recomputing PMI. 110 + 111 + --- 112 + 113 + ## 3. Entity Resolution 114 + 115 + ### The Problem 116 + 117 + The same real-world entity appears under different names: 118 + - "Elon Musk" / "Musk" / "@elonmusk" / "Elon R. Musk" 119 + - "United States" / "US" / "USA" / "the United States of America" 120 + - "SpaceX" / "Space Exploration Technologies Corp." 121 + 122 + ### Current State 123 + 124 + The system already has an `aliases` array in entity metadata and `normalizeName()` (lowercase, trim, remove diacritics) for matching. But aliases are only populated when extraction finds an exact match — there's no fuzzy matching or alias discovery. 125 + 126 + ### Proposed Approach: Layered Resolution 127 + 128 + Rather than one complex system, use three simple layers that each catch different cases: 129 + 130 + #### Layer 1: Normalized Exact Match (exists today) 131 + 132 + `normalizeName()` already handles case, whitespace, and diacritics. This catches "Elon Musk" vs "elon musk" vs "Elon Musk". 133 + 134 + #### Layer 2: Token-Based Similarity 135 + 136 + For person names, check if one name is a subset of another: 137 + 138 + ```javascript 139 + function isNameVariant(nameA, nameB) { 140 + const tokensA = normalizeName(nameA).split(' '); 141 + const tokensB = normalizeName(nameB).split(' '); 142 + 143 + // "Musk" matches "Elon Musk" (surname substring) 144 + if (tokensA.length === 1 && tokensB.includes(tokensA[0])) return true; 145 + if (tokensB.length === 1 && tokensA.includes(tokensB[0])) return true; 146 + 147 + // "E. Musk" matches "Elon Musk" (initial + surname) 148 + if (tokensA.length >= 2 && tokensB.length >= 2) { 149 + const lastA = tokensA[tokensA.length - 1]; 150 + const lastB = tokensB[tokensB.length - 1]; 151 + if (lastA === lastB) { 152 + // Same surname — check if first names are compatible 153 + const firstA = tokensA[0]; 154 + const firstB = tokensB[0]; 155 + if (firstA[0] === firstB[0]) return true; // Same initial 156 + } 157 + } 158 + 159 + return false; 160 + } 161 + ``` 162 + 163 + This is conservative — it won't merge "John Smith" and "Jane Smith" because we require initial match. Apply only within the same `entityType`. 164 + 165 + #### Layer 3: Co-occurrence-Based Alias Detection 166 + 167 + If two entities of the same type have very high NPMI (> 0.8) and one has significantly fewer observations than the other, the rarer one is likely an alias. This catches "@elonmusk" being an alias of "Elon Musk" — they'll always appear on the same pages (Twitter/X profiles). 168 + 169 + Criteria for automatic merge suggestion: 170 + - Same `entityType` 171 + - NPMI > 0.8 172 + - One entity has < 3 observations 173 + - The rarer entity's observations are a subset of the more common entity's observation pages 174 + 175 + This should be surfaced as a **suggestion** in the UI rather than automatically merged, at least initially. Users can confirm or reject merges. 176 + 177 + #### What We Don't Do 178 + 179 + We deliberately avoid: 180 + - **Levenshtein distance**: Too many false positives at short edit distances ("Mars" vs "Musk") 181 + - **External knowledge bases**: Wikidata validation is being researched separately (see `docs/entity-validation-research.md`) and can feed into resolution later 182 + - **ML embeddings**: Overkill for hundreds of entities; adds significant complexity 183 + 184 + --- 185 + 186 + ## 4. Signal vs. Noise 187 + 188 + ### Problem 189 + 190 + Not all co-occurrences are meaningful. Boilerplate entities (the publisher name, the site author) co-occur with everything on that site. Generic entities ("United States", "Google") appear everywhere. 191 + 192 + ### Statistical Filters 193 + 194 + #### Minimum Co-occurrence Threshold 195 + 196 + Require at least **3 co-occurrences** before storing an edge. A single shared page is noise; two could be coincidence; three starts to be a pattern. At this scale (hundreds of pages), this is a practical cutoff. 197 + 198 + ```sql 199 + -- Only create/maintain edges where cooccurrenceCount >= 3 200 + ``` 201 + 202 + #### PMI Minimum 203 + 204 + Only retain edges with **NPMI > 0.1**. This filters out pairs that co-occur roughly at chance level. NPMI of 0.1 means they co-occur about 10% more than you'd expect by random chance. 205 + 206 + #### Maximum Frequency Cap 207 + 208 + Entities that appear on more than 30% of all entity-bearing pages are "stopword entities" — too common to be informative. Examples: the user's own name, their employer, "Google", "United States". Exclude them from correlation computation (but don't delete them as entities). 209 + 210 + ```javascript 211 + const FREQUENCY_CAP = 0.3; // entities on >30% of pages are excluded from correlation 212 + ``` 213 + 214 + This is analogous to TF-IDF's inverse document frequency — the most common terms carry the least information. 215 + 216 + #### Per-Site Deduplication 217 + 218 + If a user reads 20 articles on nytimes.com, the NYT publisher entity will co-occur with every entity from those articles. To prevent site-level boilerplate from dominating: 219 + 220 + Count co-occurrences by **unique domains**, not raw page count. Two entities co-occurring across 3 different domains is a much stronger signal than co-occurring on 3 pages from the same domain. 221 + 222 + ``` 223 + entity_correlations: 224 + ... 225 + domainCount INTEGER -- number of unique domains where both appear 226 + ``` 227 + 228 + Use `domainCount` as the primary co-occurrence signal rather than raw page count for PMI calculation. 229 + 230 + --- 231 + 232 + ## 5. Clustering 233 + 234 + ### Algorithm Choice: Connected Components with Threshold 235 + 236 + For the scale of hundreds to low thousands of entities, sophisticated graph community detection (Louvain, Girvan-Newman) is unnecessary. A simple threshold-based approach works well: 237 + 238 + 1. Build an adjacency graph from edges with NPMI > 0.3 239 + 2. Find connected components using BFS/DFS 240 + 3. Within each component, identify the "hub" entity (highest degree, i.e., most connections) 241 + 242 + This naturally produces clusters like: 243 + - {Elon Musk, Tesla, SpaceX, SEC, Fremont} — a tech/business cluster 244 + - {Biden, Congress, White House, legislation} — a politics cluster 245 + - {React, JavaScript, Node.js, npm} — a tech stack cluster 246 + 247 + ### Hierarchical Clustering (Future) 248 + 249 + For finer-grained clustering within large components, use a simple single-linkage approach: 250 + 251 + 1. Start with NPMI > 0.6 (tight clusters) 252 + 2. If a component has > 20 entities, try a higher threshold (0.5) to break it into subclusters 253 + 3. Present as nested groups: "Tech" > "SpaceX ecosystem", "Tesla ecosystem" 254 + 255 + This is a future enhancement — connected components at a single threshold is the MVP. 256 + 257 + ### Cluster Metadata 258 + 259 + Each cluster gets derived metadata: 260 + 261 + ```javascript 262 + { 263 + id: 'cluster_abc123', 264 + entities: ['entity_id_1', 'entity_id_2', ...], 265 + hub: 'entity_id_1', // most connected entity 266 + dominantType: 'person', // most common entity type in cluster 267 + label: 'Elon Musk', // hub entity name as default label 268 + totalObservations: 45, // sum of entity observations 269 + lastActivity: 1708300000000, // most recent observation timestamp 270 + density: 0.72 // ratio of actual edges to possible edges 271 + } 272 + ``` 273 + 274 + Clusters are ephemeral — computed on demand from the edge table, not stored permanently. They change as new browsing data arrives. 275 + 276 + --- 277 + 278 + ## 6. Schema Design 279 + 280 + ### New Table: `entity_correlations` 281 + 282 + ```sql 283 + CREATE TABLE IF NOT EXISTS entity_correlations ( 284 + entityA TEXT NOT NULL, 285 + entityB TEXT NOT NULL, 286 + cooccurrenceCount INTEGER NOT NULL DEFAULT 0, 287 + domainCount INTEGER NOT NULL DEFAULT 0, 288 + npmi REAL NOT NULL DEFAULT 0, 289 + lastCooccurrence INTEGER NOT NULL DEFAULT 0, 290 + firstCooccurrence INTEGER NOT NULL DEFAULT 0, 291 + updatedAt INTEGER NOT NULL DEFAULT 0, 292 + PRIMARY KEY (entityA, entityB), 293 + FOREIGN KEY(entityA) REFERENCES items(id), 294 + FOREIGN KEY(entityB) REFERENCES items(id) 295 + ); 296 + 297 + CREATE INDEX IF NOT EXISTS idx_entity_corr_a ON entity_correlations(entityA); 298 + CREATE INDEX IF NOT EXISTS idx_entity_corr_b ON entity_correlations(entityB); 299 + CREATE INDEX IF NOT EXISTS idx_entity_corr_npmi ON entity_correlations(npmi DESC); 300 + CREATE INDEX IF NOT EXISTS idx_entity_corr_updated ON entity_correlations(updatedAt); 301 + ``` 302 + 303 + ### New Table: `entity_page_map` (Denormalized Lookup) 304 + 305 + The existing `item_events` table already stores entity-page relationships, but querying "which entities appeared on page X" requires scanning all events. A denormalized lookup table makes co-occurrence computation fast: 306 + 307 + ```sql 308 + CREATE TABLE IF NOT EXISTS entity_page_map ( 309 + entityId TEXT NOT NULL, 310 + pageUrl TEXT NOT NULL, 311 + pageDomain TEXT NOT NULL, 312 + observedAt INTEGER NOT NULL, 313 + PRIMARY KEY (entityId, pageUrl), 314 + FOREIGN KEY(entityId) REFERENCES items(id) 315 + ); 316 + 317 + CREATE INDEX IF NOT EXISTS idx_entity_page_url ON entity_page_map(pageUrl); 318 + CREATE INDEX IF NOT EXISTS idx_entity_page_domain ON entity_page_map(pageDomain); 319 + ``` 320 + 321 + This table is populated from `item_events` during initial setup and kept in sync as new observations arrive. It's a materialized view — the source of truth remains `item_events`. 322 + 323 + ### New Table: `entity_merge_suggestions` 324 + 325 + ```sql 326 + CREATE TABLE IF NOT EXISTS entity_merge_suggestions ( 327 + id TEXT PRIMARY KEY, 328 + entityA TEXT NOT NULL, 329 + entityB TEXT NOT NULL, 330 + reason TEXT NOT NULL, -- 'name_variant', 'high_cooccurrence', 'manual' 331 + confidence REAL NOT NULL, 332 + status TEXT DEFAULT 'pending', -- 'pending', 'accepted', 'rejected' 333 + createdAt INTEGER NOT NULL, 334 + resolvedAt INTEGER DEFAULT 0, 335 + FOREIGN KEY(entityA) REFERENCES items(id), 336 + FOREIGN KEY(entityB) REFERENCES items(id) 337 + ); 338 + ``` 339 + 340 + ### Query Patterns 341 + 342 + The schema is designed around these primary queries: 343 + 344 + **"Show me everything related to entity X":** 345 + ```sql 346 + SELECT entityB AS relatedId, npmi, cooccurrenceCount, domainCount 347 + FROM entity_correlations 348 + WHERE entityA = ? AND npmi > 0.1 349 + UNION ALL 350 + SELECT entityA AS relatedId, npmi, cooccurrenceCount, domainCount 351 + FROM entity_correlations 352 + WHERE entityB = ? AND npmi > 0.1 353 + ORDER BY npmi DESC 354 + LIMIT 20; 355 + ``` 356 + 357 + **"What clusters exist" (all edges above threshold):** 358 + ```sql 359 + SELECT entityA, entityB, npmi 360 + FROM entity_correlations 361 + WHERE npmi > 0.3 362 + ORDER BY npmi DESC; 363 + ``` 364 + 365 + Then compute connected components in JavaScript — the result set will be small (hundreds of edges at most). 366 + 367 + **"What's trending" (recently active correlations):** 368 + ```sql 369 + SELECT entityA, entityB, npmi, lastCooccurrence 370 + FROM entity_correlations 371 + WHERE lastCooccurrence > ? AND npmi > 0.2 372 + ORDER BY lastCooccurrence DESC 373 + LIMIT 50; 374 + ``` 375 + 376 + **"Which entities co-occurred on this page":** 377 + ```sql 378 + SELECT entityId FROM entity_page_map WHERE pageUrl = ?; 379 + ``` 380 + 381 + --- 382 + 383 + ## 7. Incremental Update Strategy 384 + 385 + ### Trigger: New Observation 386 + 387 + When `addObservation()` records that entity X was seen on page Y: 388 + 389 + 1. **Update `entity_page_map`**: Insert or ignore `(X, Y, domain(Y), now)` 390 + 2. **Find co-occurring entities**: Query `entity_page_map` for all other entities on page Y 391 + 3. **Update edges**: For each co-occurring entity Z, upsert `entity_correlations(X, Z)`: 392 + - Increment `cooccurrenceCount` 393 + - Recalculate `domainCount` from `entity_page_map` 394 + - Update `lastCooccurrence` 395 + - Recalculate NPMI (requires total page count — cache this) 396 + 397 + ### Batch Recomputation 398 + 399 + NPMI depends on global statistics (total pages, per-entity page counts) that change with every page visit. Rather than recomputing every edge on every observation, use a two-tier strategy: 400 + 401 + **Tier 1 — Immediate (per observation):** 402 + - Update `cooccurrenceCount` and `lastCooccurrence` for affected edges 403 + - Use cached global stats for approximate NPMI 404 + 405 + **Tier 2 — Periodic (background):** 406 + - Every 50 new observations (or on app startup), recompute NPMI for all edges 407 + - Update global stats cache 408 + - Prune edges below minimum thresholds 409 + - Generate merge suggestions from high-NPMI same-type pairs 410 + 411 + The periodic recomputation is fast — at the scale of hundreds of entities, the correlation table will have at most a few thousand edges. A full recompute takes milliseconds in SQLite. 412 + 413 + ### Initial Bootstrap 414 + 415 + On first run (or when the feature is enabled), populate `entity_page_map` from existing `item_events`: 416 + 417 + ```sql 418 + INSERT OR IGNORE INTO entity_page_map (entityId, pageUrl, pageDomain, observedAt) 419 + SELECT ie.itemId, ie.content, ?, ie.occurredAt 420 + FROM item_events ie 421 + JOIN items i ON ie.itemId = i.id 422 + WHERE i.type = 'entity' AND i.deletedAt = 0; 423 + ``` 424 + 425 + Then compute all pairwise co-occurrences and NPMI scores. For N entities, the worst case is N*(N-1)/2 pairs, but the minimum co-occurrence threshold of 3 will prune most of these. 426 + 427 + ### Where This Code Lives 428 + 429 + The correlation engine should be a **backend module** (`backend/electron/entity-correlations.ts`), not an extension. Reasons: 430 + 431 + - It needs direct SQLite access for efficient batch queries 432 + - It runs on the main process, not in extension renderer 433 + - The entity extension calls it via IPC 434 + 435 + The extension side (`extensions/entities/`) handles UI and user interaction. The backend handles data and computation. 436 + 437 + --- 438 + 439 + ## 8. UI Concepts 440 + 441 + ### 8.1 Related Entities Panel (MVP) 442 + 443 + The simplest useful UI: when viewing an entity's detail page (which already exists in `home.js`), show a "Related Entities" section below "Source Pages": 444 + 445 + ``` 446 + ─── Related Entities ─────────────────── 447 + Elon Musk ████████░░ 0.82 (person) 448 + SEC ██████░░░░ 0.61 (organization) 449 + Fremont, CA █████░░░░░ 0.54 (place) 450 + NHTSA ████░░░░░░ 0.41 (organization) 451 + ``` 452 + 453 + Each row is clickable, navigating to that entity's detail view. The bar represents NPMI score. This requires minimal UI changes — just a new section in the existing detail view. 454 + 455 + ### 8.2 Cluster View 456 + 457 + A new tab/mode in the entities browser that shows clusters instead of individual entities: 458 + 459 + ``` 460 + Clusters 461 + ───────────────────────────────── 462 + ┌──────────────────────┐ ┌──────────────────────┐ 463 + │ Tesla Ecosystem │ │ Web Standards │ 464 + │ 8 entities │ │ 5 entities │ 465 + │ ─────────────────── │ │ ─────────────────── │ 466 + │ Elon Musk (hub) │ │ W3C (hub) │ 467 + │ Tesla, SpaceX, SEC │ │ WHATWG, IndieWeb │ 468 + │ + 4 more │ │ + 2 more │ 469 + │ │ │ │ 470 + │ Last active: 2h ago │ │ Last active: 1d ago │ 471 + └──────────────────────┘ └──────────────────────┘ 472 + ``` 473 + 474 + Clicking a cluster expands it to show all member entities and their interconnections. 475 + 476 + ### 8.3 Graph Visualization (Future) 477 + 478 + A force-directed graph where: 479 + - Nodes are entities (sized by observation count, colored by type) 480 + - Edges are correlations (thickness proportional to NPMI) 481 + - Clusters naturally separate in the layout 482 + 483 + This is the most visually compelling but also the most complex to implement. Use a lightweight library like `d3-force` (already reasonable in an Electron renderer). 484 + 485 + This should be a later phase — the related entities panel and cluster view provide most of the value with far less implementation effort. 486 + 487 + ### 8.4 Contextual Correlation Display 488 + 489 + When browsing a web page, show a subtle indicator of how the entities on the current page relate to the user's existing knowledge graph. This could be: 490 + - A sidebar showing "You've seen these entities before, and they're related to X, Y, Z" 491 + - A badge on the entities extension icon showing how many correlated entities were found 492 + 493 + This is speculative and depends on the page-level entity display being implemented first. 494 + 495 + --- 496 + 497 + ## 9. Implementation Phases 498 + 499 + ### Phase 1: Foundation (MVP) 500 + 501 + **Goal:** Build the correlation data pipeline and show basic "related entities" in the UI. 502 + 503 + 1. Add `entity_correlations` and `entity_page_map` tables via datastore migration 504 + 2. Implement `entity-correlations.ts` backend module with: 505 + - `updateCorrelationsForPage(pageUrl, entityIds[])` — called after extraction 506 + - `getRelatedEntities(entityId, limit)` — query for UI 507 + - `recomputeAllCorrelations()` — batch recompute 508 + 3. Bootstrap from existing `item_events` data 509 + 4. Add "Related Entities" section to entity detail view in `home.js` 510 + 5. Wire extraction pipeline to call `updateCorrelationsForPage` after `processEntities` 511 + 512 + **Estimated scope:** ~500 lines of backend code, ~100 lines of UI additions. 513 + 514 + ### Phase 2: Signal Quality 515 + 516 + **Goal:** Improve correlation quality with noise filtering and domain-aware scoring. 517 + 518 + 1. Implement domain-based co-occurrence counting 519 + 2. Add frequency cap filtering (>30% entities excluded) 520 + 3. Periodic NPMI recomputation on app startup 521 + 4. Prune stale/weak edges (NPMI < threshold and no co-occurrence in 90 days) 522 + 523 + ### Phase 3: Entity Resolution 524 + 525 + **Goal:** Detect and suggest entity merges. 526 + 527 + 1. Implement token-based name variant detection (Layer 2) 528 + 2. Implement co-occurrence-based alias detection (Layer 3) 529 + 3. Add `entity_merge_suggestions` table and UI for accepting/rejecting 530 + 4. Merge operation: combine observations, update aliases, redirect correlations 531 + 532 + ### Phase 4: Clustering & Visualization 533 + 534 + **Goal:** Surface higher-level structure. 535 + 536 + 1. Implement connected-component clustering 537 + 2. Add cluster view to entities browser 538 + 3. Cluster metadata computation (hub, label, density) 539 + 4. Optional: force-directed graph visualization with d3-force 540 + 541 + ### Phase 5: Contextual Integration 542 + 543 + **Goal:** Surface correlations during browsing. 544 + 545 + 1. After entity extraction, check how page entities relate to existing clusters 546 + 2. Show contextual "related to your interests" indicators 547 + 3. Integration with groups/workspaces — suggest grouping related pages by entity cluster 548 + 549 + --- 550 + 551 + ## 10. Performance Considerations 552 + 553 + ### Scale Estimates 554 + 555 + For a personal browser: 556 + - **Entities:** 100-2,000 (after months of use) 557 + - **Pages with entities:** 200-5,000 558 + - **Entity-page pairs:** 500-20,000 559 + - **Correlation edges:** 200-5,000 (after threshold filtering) 560 + - **Clusters:** 10-100 561 + 562 + ### SQLite Performance 563 + 564 + All operations are well within SQLite's comfort zone: 565 + 566 + - **Pairwise co-occurrence computation**: For 1,000 entities across 2,000 pages, the entity_page_map has ~10,000 rows. Finding all pairs for a single page (say 5 entities = 10 pairs) is a simple index lookup. The full batch recompute touches at most a few thousand edges. 567 + 568 + - **NPMI calculation**: Pure arithmetic on cached counts. Sub-millisecond per edge. 569 + 570 + - **Cluster computation**: Loading all edges above threshold (a few hundred rows) into memory and running BFS takes microseconds. 571 + 572 + ### Memory 573 + 574 + The correlation engine maintains in memory: 575 + - Global page count (one integer) 576 + - Per-entity page count cache (Map of ~1,000 entries) 577 + - Nothing else — everything else is queried from SQLite on demand 578 + 579 + --- 580 + 581 + ## 11. Open Questions 582 + 583 + 1. **Should correlations cross entity types?** A person and an organization co-occurring is interesting ("Elon Musk" + "Tesla"). But should a person and a phone number be correlated? Probably yes — if an email address always appears with a person name, that's a useful signal. Start with cross-type correlations and filter in the UI if needed. 584 + 585 + 2. **How to handle entity deletion?** When a user thumbs-down an entity (existing feature), should its correlations be removed? Probably yes — cascade the deletion to `entity_correlations` and `entity_page_map`. This keeps the graph clean. 586 + 587 + 3. **Extension vs. backend boundary:** The correlation engine does heavy SQL work and should live in the backend. But the entity extraction pipeline lives in the extension. The cleanest boundary: the extension publishes `entities:extracted` events (already happens), and the backend subscribes to compute correlations. The extension queries correlations via IPC for display. 588 + 589 + 4. **Privacy implications:** The correlation graph is a dense summary of browsing interests. It should follow the same data storage and sync policies as other Peek data. No new privacy concerns beyond what entity extraction already introduces.
+689
extensions/me-core/atproto.js
··· 1 + /** 2 + * AT Protocol API helpers for the Me extension. 3 + * 4 + * Handles authentication via OAuth (PKCE + DPoP) using the generic 5 + * loopback server, and authenticated XRPC requests against the user's PDS. 6 + */ 7 + 8 + // Feature detection 9 + const hasPeekAPI = typeof window.app !== 'undefined'; 10 + const api = hasPeekAPI ? window.app : null; 11 + 12 + // ============================================================================ 13 + // Public API (unauthenticated) 14 + // ============================================================================ 15 + 16 + const PUBLIC_API = 'https://public.api.bsky.app'; 17 + 18 + /** 19 + * Search actors via the public Bluesky API (no auth needed). 20 + * @param {string} query - Search query 21 + * @returns {Promise<Array<{handle: string, did: string, displayName: string, avatar: string}>>} 22 + */ 23 + export async function searchActors(query) { 24 + if (!query || query.length < 2) return []; 25 + const url = `${PUBLIC_API}/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=8`; 26 + try { 27 + const res = await fetch(url); 28 + if (!res.ok) return []; 29 + const data = await res.json(); 30 + return (data.actors || []).map(a => ({ 31 + handle: a.handle, 32 + did: a.did || null, 33 + displayName: a.displayName || null, 34 + avatar: a.avatar || null, 35 + })); 36 + } catch { 37 + return []; 38 + } 39 + } 40 + 41 + /** 42 + * Resolve a handle to a DID via public API. 43 + * @param {string} handle 44 + * @returns {Promise<string|null>} DID or null 45 + */ 46 + export async function resolveHandle(handle) { 47 + try { 48 + const res = await fetch( 49 + `${PUBLIC_API}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}` 50 + ); 51 + if (!res.ok) return null; 52 + const data = await res.json(); 53 + return data.did || null; 54 + } catch { 55 + return null; 56 + } 57 + } 58 + 59 + /** 60 + * Get a user's profile from the public API. 61 + * @param {string} actor - DID or handle 62 + * @returns {Promise<Object|null>} 63 + */ 64 + export async function getProfile(actor) { 65 + try { 66 + const res = await fetch( 67 + `${PUBLIC_API}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(actor)}` 68 + ); 69 + if (!res.ok) return null; 70 + return await res.json(); 71 + } catch { 72 + return null; 73 + } 74 + } 75 + 76 + // ============================================================================ 77 + // PDS Discovery 78 + // ============================================================================ 79 + 80 + /** 81 + * Discover the user's PDS URL from their DID document. 82 + * @param {string} did 83 + * @returns {Promise<string|null>} PDS service URL 84 + */ 85 + export async function discoverPds(did) { 86 + try { 87 + let didDoc; 88 + if (did.startsWith('did:web:')) { 89 + const host = did.replace('did:web:', ''); 90 + const res = await fetch(`https://${host}/.well-known/did.json`); 91 + if (res.ok) didDoc = await res.json(); 92 + } else if (did.startsWith('did:plc:')) { 93 + const res = await fetch(`https://plc.directory/${did}`); 94 + if (res.ok) didDoc = await res.json(); 95 + } 96 + 97 + if (!didDoc || !didDoc.service) return null; 98 + 99 + const pdsService = didDoc.service.find( 100 + s => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer' 101 + ); 102 + return pdsService ? pdsService.serviceEndpoint : null; 103 + } catch { 104 + return null; 105 + } 106 + } 107 + 108 + // ============================================================================ 109 + // OAuth Authorization Server Discovery 110 + // ============================================================================ 111 + 112 + /** 113 + * Discover the OAuth authorization server metadata for a PDS. 114 + * @param {string} pdsUrl 115 + * @returns {Promise<Object>} Authorization server metadata 116 + */ 117 + async function discoverAuthServer(pdsUrl) { 118 + // Step 1: Get the authorization server URL from the PDS 119 + const resourceRes = await fetch(`${pdsUrl}/.well-known/oauth-protected-resource`); 120 + if (!resourceRes.ok) { 121 + throw new Error(`PDS does not support OAuth (${resourceRes.status})`); 122 + } 123 + const resourceMeta = await resourceRes.json(); 124 + const authServerUrl = resourceMeta.authorization_servers?.[0]; 125 + if (!authServerUrl) { 126 + throw new Error('No authorization server found in PDS metadata'); 127 + } 128 + 129 + // Step 2: Get the authorization server metadata 130 + const authRes = await fetch(`${authServerUrl}/.well-known/oauth-authorization-server`); 131 + if (!authRes.ok) { 132 + throw new Error(`Failed to fetch auth server metadata (${authRes.status})`); 133 + } 134 + const authMeta = await authRes.json(); 135 + return authMeta; 136 + } 137 + 138 + // ============================================================================ 139 + // PKCE Helpers (Web Crypto) 140 + // ============================================================================ 141 + 142 + /** 143 + * Generate a PKCE code verifier (43-128 chars, unreserved URI chars). 144 + * @returns {string} 145 + */ 146 + function generateCodeVerifier() { 147 + const array = new Uint8Array(32); 148 + crypto.getRandomValues(array); 149 + return base64urlEncode(array); 150 + } 151 + 152 + /** 153 + * Generate a PKCE code challenge from a verifier (S256). 154 + * @param {string} verifier 155 + * @returns {Promise<string>} 156 + */ 157 + async function generateCodeChallenge(verifier) { 158 + const encoded = new TextEncoder().encode(verifier); 159 + const hash = await crypto.subtle.digest('SHA-256', encoded); 160 + return base64urlEncode(new Uint8Array(hash)); 161 + } 162 + 163 + // ============================================================================ 164 + // DPoP Helpers (Web Crypto) 165 + // ============================================================================ 166 + 167 + /** 168 + * Generate a DPoP EC P-256 keypair. 169 + * @returns {Promise<CryptoKeyPair>} 170 + */ 171 + async function generateDpopKeyPair() { 172 + return crypto.subtle.generateKey( 173 + { name: 'ECDSA', namedCurve: 'P-256' }, 174 + true, // extractable for storage 175 + ['sign', 'verify'] 176 + ); 177 + } 178 + 179 + /** 180 + * Export a CryptoKeyPair to JWK format for storage. 181 + * @param {CryptoKeyPair} keyPair 182 + * @returns {Promise<{publicKey: Object, privateKey: Object}>} 183 + */ 184 + export async function exportKeyPair(keyPair) { 185 + const publicKey = await crypto.subtle.exportKey('jwk', keyPair.publicKey); 186 + const privateKey = await crypto.subtle.exportKey('jwk', keyPair.privateKey); 187 + return { publicKey, privateKey }; 188 + } 189 + 190 + /** 191 + * Import a JWK keypair back to CryptoKeyPair. 192 + * @param {{publicKey: Object, privateKey: Object}} jwks 193 + * @returns {Promise<CryptoKeyPair>} 194 + */ 195 + export async function importKeyPair(jwks) { 196 + const publicKey = await crypto.subtle.importKey( 197 + 'jwk', jwks.publicKey, 198 + { name: 'ECDSA', namedCurve: 'P-256' }, 199 + true, ['verify'] 200 + ); 201 + const privateKey = await crypto.subtle.importKey( 202 + 'jwk', jwks.privateKey, 203 + { name: 'ECDSA', namedCurve: 'P-256' }, 204 + true, ['sign'] 205 + ); 206 + return { publicKey, privateKey }; 207 + } 208 + 209 + /** 210 + * Create a DPoP proof JWT. 211 + * @param {CryptoKeyPair} keyPair 212 + * @param {string} method - HTTP method (GET, POST) 213 + * @param {string} url - Target URL 214 + * @param {string} [nonce] - Server-provided DPoP nonce 215 + * @param {string} [ath] - Access token hash (for resource requests) 216 + * @returns {Promise<string>} Signed JWT 217 + */ 218 + async function createDpopProof(keyPair, method, url, nonce, ath) { 219 + const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey); 220 + // Remove private fields from the public JWK 221 + const { d: _d, ...cleanPublicJwk } = publicJwk; 222 + 223 + const header = { 224 + typ: 'dpop+jwt', 225 + alg: 'ES256', 226 + jwk: cleanPublicJwk, 227 + }; 228 + 229 + const payload = { 230 + jti: generateJti(), 231 + htm: method, 232 + htu: url, 233 + iat: Math.floor(Date.now() / 1000), 234 + }; 235 + if (nonce) payload.nonce = nonce; 236 + if (ath) payload.ath = ath; 237 + 238 + const headerB64 = base64urlEncode(new TextEncoder().encode(JSON.stringify(header))); 239 + const payloadB64 = base64urlEncode(new TextEncoder().encode(JSON.stringify(payload))); 240 + const signingInput = `${headerB64}.${payloadB64}`; 241 + 242 + const signature = await crypto.subtle.sign( 243 + { name: 'ECDSA', hash: 'SHA-256' }, 244 + keyPair.privateKey, 245 + new TextEncoder().encode(signingInput) 246 + ); 247 + 248 + // ECDSA signature from WebCrypto is in IEEE P1363 format (r||s, 64 bytes for P-256). 249 + // JWT expects this format, so no conversion needed. 250 + const sigB64 = base64urlEncode(new Uint8Array(signature)); 251 + return `${signingInput}.${sigB64}`; 252 + } 253 + 254 + /** 255 + * Compute access token hash for DPoP proof. 256 + * @param {string} accessToken 257 + * @returns {Promise<string>} base64url-encoded SHA-256 hash 258 + */ 259 + async function computeAth(accessToken) { 260 + const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(accessToken)); 261 + return base64urlEncode(new Uint8Array(hash)); 262 + } 263 + 264 + // ============================================================================ 265 + // Base64url Encoding 266 + // ============================================================================ 267 + 268 + /** 269 + * Base64url encode a Uint8Array (no padding). 270 + * @param {Uint8Array} data 271 + * @returns {string} 272 + */ 273 + function base64urlEncode(data) { 274 + const binStr = Array.from(data, b => String.fromCharCode(b)).join(''); 275 + return btoa(binStr).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); 276 + } 277 + 278 + function generateJti() { 279 + const array = new Uint8Array(16); 280 + crypto.getRandomValues(array); 281 + return Array.from(array, b => b.toString(16).padStart(2, '0')).join(''); 282 + } 283 + 284 + // ============================================================================ 285 + // OAuth Login Flow 286 + // ============================================================================ 287 + 288 + /** 289 + * @typedef {Object} OAuthSession 290 + * @property {string} did 291 + * @property {string} handle 292 + * @property {string} accessToken 293 + * @property {string} refreshToken 294 + * @property {string} tokenEndpoint 295 + * @property {string} pdsUrl 296 + * @property {{publicKey: Object, privateKey: Object}} dpopKeyPairJwk - JWK format for storage 297 + */ 298 + 299 + /** 300 + * Login via OAuth PKCE + DPoP using the loopback server. 301 + * 302 + * @param {string} handle 303 + * @returns {Promise<OAuthSession>} 304 + */ 305 + export async function loginWithOAuth(handle) { 306 + // 1. Resolve handle → DID → PDS 307 + const did = await resolveHandle(handle); 308 + if (!did) throw new Error(`Could not resolve handle: ${handle}`); 309 + 310 + const pdsUrl = await discoverPds(did); 311 + if (!pdsUrl) throw new Error(`Could not discover PDS for ${handle}`); 312 + 313 + // 2. Discover auth server 314 + const authMeta = await discoverAuthServer(pdsUrl); 315 + const authEndpoint = authMeta.authorization_endpoint; 316 + const tokenEndpoint = authMeta.token_endpoint; 317 + const pushedAuthEndpoint = authMeta.pushed_authorization_request_endpoint; 318 + 319 + if (!authEndpoint || !tokenEndpoint) { 320 + throw new Error('Authorization server missing required endpoints'); 321 + } 322 + 323 + // 3. Start loopback server 324 + const loopback = await api.oauth.startLoopback(); 325 + if (!loopback.success) throw new Error(loopback.error || 'Failed to start loopback server'); 326 + const port = loopback.port; 327 + const redirectUri = `http://127.0.0.1:${port}/callback`; 328 + 329 + try { 330 + // 4. Generate PKCE 331 + const codeVerifier = generateCodeVerifier(); 332 + const codeChallenge = await generateCodeChallenge(codeVerifier); 333 + 334 + // 5. Generate DPoP keypair 335 + const dpopKeyPair = await generateDpopKeyPair(); 336 + 337 + // 6. Build client_id (loopback client metadata URL) 338 + const scope = 'atproto transition:generic'; 339 + const clientId = `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}`; 340 + 341 + // 7. Generate state 342 + const stateArray = new Uint8Array(16); 343 + crypto.getRandomValues(stateArray); 344 + const state = base64urlEncode(stateArray); 345 + 346 + // 8. Build authorization URL or use PAR 347 + let authUrl; 348 + if (pushedAuthEndpoint) { 349 + // Use Pushed Authorization Requests (PAR) — required by many AT Protocol auth servers 350 + const parBody = new URLSearchParams({ 351 + client_id: clientId, 352 + redirect_uri: redirectUri, 353 + response_type: 'code', 354 + scope, 355 + state, 356 + code_challenge: codeChallenge, 357 + code_challenge_method: 'S256', 358 + login_hint: handle, 359 + }); 360 + 361 + const dpopProof = await createDpopProof(dpopKeyPair, 'POST', pushedAuthEndpoint); 362 + const parRes = await fetch(pushedAuthEndpoint, { 363 + method: 'POST', 364 + headers: { 365 + 'Content-Type': 'application/x-www-form-urlencoded', 366 + 'DPoP': dpopProof, 367 + }, 368 + body: parBody.toString(), 369 + }); 370 + 371 + if (!parRes.ok) { 372 + // Check for DPoP nonce requirement 373 + const dpopNonce = parRes.headers.get('DPoP-Nonce'); 374 + if (dpopNonce && parRes.status === 400) { 375 + // Retry with nonce 376 + const retryProof = await createDpopProof(dpopKeyPair, 'POST', pushedAuthEndpoint, dpopNonce); 377 + const retryRes = await fetch(pushedAuthEndpoint, { 378 + method: 'POST', 379 + headers: { 380 + 'Content-Type': 'application/x-www-form-urlencoded', 381 + 'DPoP': retryProof, 382 + }, 383 + body: parBody.toString(), 384 + }); 385 + if (!retryRes.ok) { 386 + const err = await retryRes.json().catch(() => ({})); 387 + throw new Error(err.error_description || err.error || `PAR failed (${retryRes.status})`); 388 + } 389 + const parData = await retryRes.json(); 390 + authUrl = `${authEndpoint}?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(parData.request_uri)}`; 391 + } else { 392 + const err = await parRes.json().catch(() => ({})); 393 + throw new Error(err.error_description || err.error || `PAR failed (${parRes.status})`); 394 + } 395 + } else { 396 + const parData = await parRes.json(); 397 + authUrl = `${authEndpoint}?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(parData.request_uri)}`; 398 + } 399 + } else { 400 + // Direct authorization URL 401 + const params = new URLSearchParams({ 402 + client_id: clientId, 403 + redirect_uri: redirectUri, 404 + response_type: 'code', 405 + scope, 406 + state, 407 + code_challenge: codeChallenge, 408 + code_challenge_method: 'S256', 409 + login_hint: handle, 410 + }); 411 + authUrl = `${authEndpoint}?${params.toString()}`; 412 + } 413 + 414 + // 9. Open auth window 415 + api.window.open(authUrl, { 416 + width: 600, 417 + height: 700, 418 + role: 'modal', 419 + title: 'AT Protocol Authorization', 420 + }); 421 + 422 + // 10. Wait for callback 423 + const callbackResult = await api.oauth.awaitCallback(port); 424 + if (!callbackResult.success) throw new Error(callbackResult.error || 'OAuth callback failed'); 425 + const params = callbackResult.params; 426 + 427 + // 11. Verify state 428 + if (params.state !== state) { 429 + throw new Error('OAuth state mismatch — possible CSRF attack'); 430 + } 431 + if (params.error) { 432 + throw new Error(params.error_description || params.error); 433 + } 434 + const code = params.code; 435 + if (!code) throw new Error('No authorization code received'); 436 + 437 + // Also capture iss if provided 438 + const iss = params.iss; 439 + 440 + // 12. Exchange code for tokens (with DPoP proof) 441 + const tokenBody = new URLSearchParams({ 442 + grant_type: 'authorization_code', 443 + code, 444 + redirect_uri: redirectUri, 445 + client_id: clientId, 446 + code_verifier: codeVerifier, 447 + }); 448 + 449 + // Token exchange with DPoP nonce retry 450 + let dpopNonce; 451 + let tokenData = await tokenExchange(tokenEndpoint, tokenBody, dpopKeyPair, dpopNonce); 452 + 453 + // 13. Export DPoP keypair for storage 454 + const dpopKeyPairJwk = await exportKeyPair(dpopKeyPair); 455 + 456 + return { 457 + did, 458 + handle, 459 + accessToken: tokenData.access_token, 460 + refreshToken: tokenData.refresh_token, 461 + tokenEndpoint, 462 + pdsUrl, 463 + dpopKeyPairJwk, 464 + sub: tokenData.sub || did, 465 + }; 466 + } catch (err) { 467 + // Cancel the loopback server if still pending 468 + try { await api.oauth.awaitCallback(port); } catch {} 469 + throw err; 470 + } 471 + } 472 + 473 + /** 474 + * Exchange authorization code or refresh token at token endpoint with DPoP. 475 + * Handles DPoP nonce retry automatically. 476 + */ 477 + async function tokenExchange(tokenEndpoint, body, dpopKeyPair, nonce) { 478 + let dpopProof = await createDpopProof(dpopKeyPair, 'POST', tokenEndpoint, nonce); 479 + 480 + let res = await fetch(tokenEndpoint, { 481 + method: 'POST', 482 + headers: { 483 + 'Content-Type': 'application/x-www-form-urlencoded', 484 + 'DPoP': dpopProof, 485 + }, 486 + body: body.toString(), 487 + }); 488 + 489 + // Handle DPoP nonce requirement (use_dpop_nonce error) 490 + if (!res.ok) { 491 + const dpopNonce = res.headers.get('DPoP-Nonce'); 492 + if (dpopNonce) { 493 + dpopProof = await createDpopProof(dpopKeyPair, 'POST', tokenEndpoint, dpopNonce); 494 + res = await fetch(tokenEndpoint, { 495 + method: 'POST', 496 + headers: { 497 + 'Content-Type': 'application/x-www-form-urlencoded', 498 + 'DPoP': dpopProof, 499 + }, 500 + body: body.toString(), 501 + }); 502 + } 503 + } 504 + 505 + if (!res.ok) { 506 + const err = await res.json().catch(() => ({})); 507 + throw new Error(err.error_description || err.error || `Token exchange failed (${res.status})`); 508 + } 509 + 510 + return res.json(); 511 + } 512 + 513 + // ============================================================================ 514 + // Token Refresh 515 + // ============================================================================ 516 + 517 + /** 518 + * Refresh an OAuth access token using the refresh token + DPoP. 519 + * @param {OAuthSession} session 520 + * @returns {Promise<OAuthSession>} Updated session with new tokens 521 + */ 522 + export async function refreshOAuthSession(session) { 523 + const dpopKeyPair = await importKeyPair(session.dpopKeyPairJwk); 524 + 525 + const body = new URLSearchParams({ 526 + grant_type: 'refresh_token', 527 + refresh_token: session.refreshToken, 528 + client_id: `http://localhost?redirect_uri=${encodeURIComponent('http://127.0.0.1/callback')}&scope=${encodeURIComponent('atproto transition:generic')}`, 529 + }); 530 + 531 + const tokenData = await tokenExchange(session.tokenEndpoint, body, dpopKeyPair); 532 + 533 + return { 534 + ...session, 535 + accessToken: tokenData.access_token, 536 + refreshToken: tokenData.refresh_token || session.refreshToken, 537 + }; 538 + } 539 + 540 + // ============================================================================ 541 + // Authenticated XRPC requests (DPoP) 542 + // ============================================================================ 543 + 544 + /** 545 + * Make an authenticated GET request to the user's PDS using DPoP tokens. 546 + * Automatically retries once with a refreshed token on 401. 547 + * 548 + * @param {OAuthSession} session 549 + * @param {string} nsid - XRPC method 550 + * @param {Object} [params] - Query parameters 551 + * @param {function} [onSessionRefresh] - Called with updated session on token refresh 552 + * @returns {Promise<Object>} Response data 553 + */ 554 + export async function xrpcGet(session, nsid, params = {}, onSessionRefresh = null) { 555 + const qs = new URLSearchParams(); 556 + for (const [k, v] of Object.entries(params)) { 557 + if (v !== undefined && v !== null) qs.set(k, String(v)); 558 + } 559 + const qsStr = qs.toString(); 560 + const url = `${session.pdsUrl}/xrpc/${nsid}${qsStr ? '?' + qsStr : ''}`; 561 + 562 + const dpopKeyPair = await importKeyPair(session.dpopKeyPairJwk); 563 + 564 + // Compute access token hash for DPoP proof 565 + const ath = await computeAth(session.accessToken); 566 + let dpopProof = await createDpopProof(dpopKeyPair, 'GET', url, undefined, ath); 567 + 568 + let res = await fetch(url, { 569 + headers: { 570 + 'Authorization': `DPoP ${session.accessToken}`, 571 + 'DPoP': dpopProof, 572 + }, 573 + }); 574 + 575 + // Handle DPoP nonce requirement 576 + if (res.status === 401) { 577 + const dpopNonce = res.headers.get('DPoP-Nonce'); 578 + if (dpopNonce) { 579 + dpopProof = await createDpopProof(dpopKeyPair, 'GET', url, dpopNonce, ath); 580 + res = await fetch(url, { 581 + headers: { 582 + 'Authorization': `DPoP ${session.accessToken}`, 583 + 'DPoP': dpopProof, 584 + }, 585 + }); 586 + } 587 + } 588 + 589 + // Retry once on 401 (expired token) 590 + if (res.status === 401 && session.refreshToken) { 591 + try { 592 + const refreshed = await refreshOAuthSession(session); 593 + Object.assign(session, refreshed); 594 + if (onSessionRefresh) onSessionRefresh(refreshed); 595 + 596 + const newAth = await computeAth(refreshed.accessToken); 597 + dpopProof = await createDpopProof(dpopKeyPair, 'GET', url, undefined, newAth); 598 + 599 + res = await fetch(url, { 600 + headers: { 601 + 'Authorization': `DPoP ${refreshed.accessToken}`, 602 + 'DPoP': dpopProof, 603 + }, 604 + }); 605 + 606 + // Handle nonce on retry 607 + if (res.status === 401) { 608 + const retryNonce = res.headers.get('DPoP-Nonce'); 609 + if (retryNonce) { 610 + dpopProof = await createDpopProof(dpopKeyPair, 'GET', url, retryNonce, newAth); 611 + res = await fetch(url, { 612 + headers: { 613 + 'Authorization': `DPoP ${refreshed.accessToken}`, 614 + 'DPoP': dpopProof, 615 + }, 616 + }); 617 + } 618 + } 619 + } catch { 620 + // Refresh failed, throw the original 401 621 + } 622 + } 623 + 624 + if (!res.ok) { 625 + const err = await res.json().catch(() => ({})); 626 + throw new Error(err.message || `XRPC ${nsid} failed (${res.status})`); 627 + } 628 + 629 + return res.json(); 630 + } 631 + 632 + // ============================================================================ 633 + // Repo / Lexicon helpers 634 + // ============================================================================ 635 + 636 + /** 637 + * Describe a repo to get the list of collection NSIDs. 638 + * @param {OAuthSession} session 639 + * @param {function} [onSessionRefresh] 640 + * @returns {Promise<string[]>} Array of collection NSIDs 641 + */ 642 + export async function getCollections(session, onSessionRefresh) { 643 + const data = await xrpcGet(session, 'com.atproto.repo.describeRepo', { 644 + repo: session.did, 645 + }, onSessionRefresh); 646 + return data.collections || []; 647 + } 648 + 649 + /** 650 + * Count records in a collection. 651 + * @param {OAuthSession} session 652 + * @param {string} collection - Collection NSID 653 + * @param {function} [onSessionRefresh] 654 + * @returns {Promise<{count: number, hasMore: boolean}>} 655 + */ 656 + export async function getCollectionCount(session, collection, onSessionRefresh) { 657 + const data = await xrpcGet(session, 'com.atproto.repo.listRecords', { 658 + repo: session.did, 659 + collection, 660 + limit: 100, 661 + }, onSessionRefresh); 662 + 663 + const records = data.records || []; 664 + return { 665 + count: records.length, 666 + hasMore: !!data.cursor, 667 + }; 668 + } 669 + 670 + /** 671 + * Get all collection counts in parallel. 672 + * @param {OAuthSession} session 673 + * @param {string[]} collections 674 + * @param {function} [onSessionRefresh] 675 + * @returns {Promise<Array<{nsid: string, count: number, hasMore: boolean}>>} 676 + */ 677 + export async function getAllCollectionCounts(session, collections, onSessionRefresh) { 678 + const results = await Promise.allSettled( 679 + collections.map(async (nsid) => { 680 + const { count, hasMore } = await getCollectionCount(session, nsid, onSessionRefresh); 681 + return { nsid, count, hasMore }; 682 + }) 683 + ); 684 + 685 + return results 686 + .filter(r => r.status === 'fulfilled') 687 + .map(r => r.value) 688 + .sort((a, b) => b.count - a.count); 689 + }
+50
extensions/me-core/background.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'; connect-src https: http:;"> 6 + <title>Me Extension</title> 7 + </head> 8 + <body> 9 + <script type="module"> 10 + import extension from './background.js'; 11 + 12 + const api = window.app; 13 + const extId = extension.id; 14 + 15 + console.log(`[ext:${extId}] background.html loaded`); 16 + 17 + // Signal ready to main process 18 + api.publish('ext:ready', { 19 + id: extId, 20 + manifest: { 21 + id: extension.id, 22 + labels: extension.labels, 23 + version: '1.0.0' 24 + } 25 + }, api.scopes.SYSTEM); 26 + 27 + // Initialize extension 28 + if (extension.init) { 29 + console.log(`[ext:${extId}] calling init()`); 30 + extension.init(); 31 + } 32 + 33 + // Handle shutdown request from main process 34 + api.subscribe('app:shutdown', () => { 35 + console.log(`[ext:${extId}] received shutdown`); 36 + if (extension.uninit) { 37 + extension.uninit(); 38 + } 39 + }, api.scopes.SYSTEM); 40 + 41 + // Handle extension-specific shutdown 42 + api.subscribe(`ext:${extId}:shutdown`, () => { 43 + console.log(`[ext:${extId}] received extension shutdown`); 44 + if (extension.uninit) { 45 + extension.uninit(); 46 + } 47 + }, api.scopes.SYSTEM); 48 + </script> 49 + </body> 50 + </html>
+422
extensions/me-core/background.js
··· 1 + /** 2 + * Me Extension — AT Protocol identity and data browser 3 + * 4 + * Provides: 5 + * - Login via AT Protocol OAuth (PKCE + DPoP) 6 + * - View repo collections (lexicons) with record counts 7 + * - Account search with typeahead 8 + * - Commands: me login, me logout, me status, me lexicons 9 + */ 10 + 11 + import { 12 + searchActors, 13 + loginWithOAuth, 14 + getCollections, 15 + getAllCollectionCounts, 16 + getProfile, 17 + } from './atproto.js'; 18 + 19 + // Feature detection 20 + const hasPeekAPI = typeof window.app !== 'undefined'; 21 + const api = hasPeekAPI ? window.app : null; 22 + 23 + // ============================================================================ 24 + // Session state 25 + // ============================================================================ 26 + 27 + let currentSession = null; // { did, handle, accessToken, refreshToken, tokenEndpoint, pdsUrl, dpopKeyPairJwk } 28 + let currentProfile = null; // { displayName, avatar, handle, ... } 29 + 30 + const STORAGE_KEY = 'session'; 31 + const PROFILE_KEY = 'profile'; 32 + 33 + /** 34 + * Persist session to extension storage 35 + */ 36 + async function saveSession() { 37 + if (!api) return; 38 + try { 39 + await api.settings.setKey(STORAGE_KEY, currentSession); 40 + if (currentProfile) { 41 + await api.settings.setKey(PROFILE_KEY, currentProfile); 42 + } 43 + } catch (err) { 44 + console.warn('[me] Failed to save session:', err); 45 + } 46 + } 47 + 48 + /** 49 + * Load session from extension storage 50 + */ 51 + async function loadSession() { 52 + if (!api) return; 53 + try { 54 + const result = await api.settings.getKey(STORAGE_KEY); 55 + if (result.success && result.data) { 56 + currentSession = result.data; 57 + console.log('[me] Restored session for', currentSession.handle); 58 + } 59 + const profileResult = await api.settings.getKey(PROFILE_KEY); 60 + if (profileResult.success && profileResult.data) { 61 + currentProfile = profileResult.data; 62 + } 63 + } catch (err) { 64 + console.warn('[me] Failed to load session:', err); 65 + } 66 + } 67 + 68 + /** 69 + * Clear session 70 + */ 71 + async function clearSession() { 72 + currentSession = null; 73 + currentProfile = null; 74 + if (api) { 75 + try { 76 + await api.settings.setKey(STORAGE_KEY, null); 77 + await api.settings.setKey(PROFILE_KEY, null); 78 + } catch (err) { 79 + console.warn('[me] Failed to clear session:', err); 80 + } 81 + } 82 + } 83 + 84 + /** 85 + * Callback for when the session tokens are refreshed. 86 + * Updates our stored session with new tokens. 87 + */ 88 + function onSessionRefresh(refreshedSession) { 89 + currentSession = refreshedSession; 90 + saveSession(); 91 + } 92 + 93 + // ============================================================================ 94 + // Lexicon directory (lexidex) cache 95 + // ============================================================================ 96 + 97 + let lexiconCache = null; // Array of { nsid, domain } 98 + let lexiconCacheLoading = false; 99 + 100 + /** 101 + * Fetch and parse the lexidex directory. 102 + * Strategy: fetch homepage for domain list, then fetch each domain page for lexicon NSIDs. 103 + */ 104 + async function fetchLexiconDirectory() { 105 + if (lexiconCache) return lexiconCache; 106 + if (lexiconCacheLoading) return null; 107 + 108 + lexiconCacheLoading = true; 109 + 110 + try { 111 + // Step 1: Fetch homepage to get domain list 112 + const homeResult = await api.net.fetch('https://lexidex.bsky.dev/', { 113 + method: 'GET', 114 + timeout: 10000, 115 + }); 116 + 117 + if (!homeResult.success) { 118 + throw new Error(`Failed to fetch lexidex: ${homeResult.error || homeResult.status}`); 119 + } 120 + 121 + // Parse domains from homepage: <a href="/domain/xxx"> 122 + const domainRegex = /href="\/domain\/([^"]+)"/g; 123 + const domains = []; 124 + let match; 125 + while ((match = domainRegex.exec(homeResult.data)) !== null) { 126 + domains.push(match[1]); 127 + } 128 + 129 + if (domains.length === 0) { 130 + throw new Error('No domains found on lexidex'); 131 + } 132 + 133 + // Step 2: Fetch each domain page in parallel 134 + const allLexicons = []; 135 + const domainFetches = domains.map(async (domain) => { 136 + try { 137 + const result = await api.net.fetch(`https://lexidex.bsky.dev/domain/${domain}`, { 138 + method: 'GET', 139 + timeout: 10000, 140 + }); 141 + 142 + if (!result.success) return; 143 + 144 + // Parse lexicon NSIDs: <a href="/lexicon/NSID">NSID</a> 145 + const lexRegex = /href="\/lexicon\/([^"]+)"/g; 146 + let m; 147 + while ((m = lexRegex.exec(result.data)) !== null) { 148 + allLexicons.push({ nsid: m[1], domain }); 149 + } 150 + } catch (err) { 151 + console.warn(`[me] Failed to fetch lexidex domain ${domain}:`, err); 152 + } 153 + }); 154 + 155 + await Promise.all(domainFetches); 156 + 157 + // Sort by NSID 158 + allLexicons.sort((a, b) => a.nsid.localeCompare(b.nsid)); 159 + 160 + lexiconCache = allLexicons; 161 + console.log(`[me] Loaded ${allLexicons.length} lexicons from ${domains.length} domains`); 162 + return lexiconCache; 163 + } catch (err) { 164 + console.error('[me] Lexicon directory fetch failed:', err); 165 + throw err; 166 + } finally { 167 + lexiconCacheLoading = false; 168 + } 169 + } 170 + 171 + // ============================================================================ 172 + // Commands 173 + // ============================================================================ 174 + 175 + /** 176 + * Open the Me home window 177 + */ 178 + function openMe() { 179 + if (hasPeekAPI) { 180 + api.window.open('peek://ext/me-core/home.html', { 181 + role: 'workspace', 182 + key: 'me-home', 183 + width: 860, 184 + height: 620, 185 + title: 'Me', 186 + }); 187 + } 188 + } 189 + 190 + /** 191 + * Login command — opens the Me home window for login 192 + */ 193 + async function executeLogin(ctx) { 194 + openMe(); 195 + return { success: true, message: 'Opening login...' }; 196 + } 197 + 198 + /** 199 + * Logout command 200 + */ 201 + async function executeLogout(ctx) { 202 + if (!currentSession) { 203 + return { success: true, message: 'Not logged in' }; 204 + } 205 + const handle = currentSession.handle; 206 + await clearSession(); 207 + api.publish('me:session-changed', { authenticated: false }, api.scopes.GLOBAL); 208 + return { success: true, message: `Logged out from @${handle}` }; 209 + } 210 + 211 + /** 212 + * Status command 213 + */ 214 + async function executeStatus(ctx) { 215 + if (!currentSession) { 216 + return { 217 + success: true, 218 + output: 'Not logged in. Use "me login" to connect your AT Protocol account.', 219 + mimeType: 'text/plain', 220 + }; 221 + } 222 + 223 + const name = currentProfile?.displayName || currentSession.handle; 224 + return { 225 + success: true, 226 + output: { 227 + data: { 228 + handle: currentSession.handle, 229 + did: currentSession.did, 230 + pds: currentSession.pdsUrl, 231 + displayName: currentProfile?.displayName || null, 232 + }, 233 + mimeType: 'application/json', 234 + title: `Logged in as @${name}`, 235 + }, 236 + }; 237 + } 238 + 239 + /** 240 + * Lexicons command — show collections in the repo 241 + */ 242 + async function executeLexicons(ctx) { 243 + if (!currentSession) { 244 + return { success: false, message: 'Not logged in. Use "me login" first.' }; 245 + } 246 + 247 + try { 248 + const collections = await getCollections(currentSession, onSessionRefresh); 249 + const counts = await getAllCollectionCounts(currentSession, collections, onSessionRefresh); 250 + 251 + // Save updated session if tokens were refreshed 252 + await saveSession(); 253 + 254 + return { 255 + success: true, 256 + output: { 257 + data: counts.map(c => ({ 258 + lexicon: c.nsid, 259 + records: c.hasMore ? `${c.count}+` : c.count, 260 + })), 261 + mimeType: 'application/json', 262 + title: `${collections.length} collections in repo`, 263 + }, 264 + }; 265 + } catch (err) { 266 + return { success: false, message: err.message }; 267 + } 268 + } 269 + 270 + // ============================================================================ 271 + // PubSub API for the home UI 272 + // ============================================================================ 273 + 274 + function setupPubSub() { 275 + if (!api) return; 276 + 277 + // Home UI requests session state 278 + api.subscribe('me:get-session', async () => { 279 + if (currentSession) { 280 + api.publish('me:session-state', { 281 + authenticated: true, 282 + handle: currentSession.handle, 283 + did: currentSession.did, 284 + pdsUrl: currentSession.pdsUrl, 285 + profile: currentProfile, 286 + }, api.scopes.GLOBAL); 287 + } else { 288 + api.publish('me:session-state', { 289 + authenticated: false, 290 + }, api.scopes.GLOBAL); 291 + } 292 + }, api.scopes.GLOBAL); 293 + 294 + // Home UI requests actor search 295 + api.subscribe('me:search-actors', async (msg) => { 296 + const results = await searchActors(msg.query); 297 + api.publish('me:search-actors:response', { results }, api.scopes.GLOBAL); 298 + }, api.scopes.GLOBAL); 299 + 300 + // Home UI requests login via OAuth 301 + api.subscribe('me:do-login', async (msg) => { 302 + try { 303 + currentSession = await loginWithOAuth(msg.handle); 304 + 305 + // Fetch profile 306 + currentProfile = await getProfile(currentSession.did); 307 + 308 + await saveSession(); 309 + 310 + api.publish('me:session-changed', { 311 + authenticated: true, 312 + handle: currentSession.handle, 313 + did: currentSession.did, 314 + pdsUrl: currentSession.pdsUrl, 315 + profile: currentProfile, 316 + }, api.scopes.GLOBAL); 317 + 318 + console.log('[me] Logged in as', currentSession.handle); 319 + } catch (err) { 320 + api.publish('me:login-error', { message: err.message }, api.scopes.GLOBAL); 321 + console.error('[me] Login failed:', err); 322 + } 323 + }, api.scopes.GLOBAL); 324 + 325 + // Home UI requests logout 326 + api.subscribe('me:do-logout', async () => { 327 + await clearSession(); 328 + api.publish('me:session-changed', { authenticated: false }, api.scopes.GLOBAL); 329 + }, api.scopes.GLOBAL); 330 + 331 + // Home UI requests explore lexicons (lexidex directory) 332 + api.subscribe('me:search-lexicons', async () => { 333 + try { 334 + const lexicons = await fetchLexiconDirectory(); 335 + api.publish('me:search-lexicons:response', { lexicons: lexicons || [] }, api.scopes.GLOBAL); 336 + } catch (err) { 337 + api.publish('me:search-lexicons:response', { error: err.message }, api.scopes.GLOBAL); 338 + } 339 + }, api.scopes.GLOBAL); 340 + 341 + // Home UI requests collections/lexicons 342 + api.subscribe('me:get-collections', async () => { 343 + if (!currentSession) { 344 + api.publish('me:collections:response', { error: 'Not logged in' }, api.scopes.GLOBAL); 345 + return; 346 + } 347 + try { 348 + const collections = await getCollections(currentSession, onSessionRefresh); 349 + const counts = await getAllCollectionCounts(currentSession, collections, onSessionRefresh); 350 + await saveSession(); 351 + api.publish('me:collections:response', { collections: counts }, api.scopes.GLOBAL); 352 + } catch (err) { 353 + api.publish('me:collections:response', { error: err.message }, api.scopes.GLOBAL); 354 + } 355 + }, api.scopes.GLOBAL); 356 + } 357 + 358 + // ============================================================================ 359 + // Extension lifecycle 360 + // ============================================================================ 361 + 362 + const extension = { 363 + id: 'me-core', 364 + labels: { 365 + name: 'Me', 366 + }, 367 + 368 + registerCommands() { 369 + api.commands.register({ 370 + name: 'me:core', 371 + description: 'Open AT Protocol identity panel', 372 + execute: () => { 373 + openMe(); 374 + return { success: true }; 375 + }, 376 + }); 377 + 378 + console.log('[me] Commands registered'); 379 + }, 380 + 381 + async init() { 382 + console.log('[me] Initializing...'); 383 + 384 + if (!hasPeekAPI) { 385 + console.log('[me] Running without Peek API — limited functionality'); 386 + return; 387 + } 388 + 389 + // Load saved session 390 + await loadSession(); 391 + 392 + // If we have a saved session, refresh profile info 393 + if (currentSession) { 394 + try { 395 + const profile = await getProfile(currentSession.did); 396 + if (profile) { 397 + currentProfile = profile; 398 + await saveSession(); 399 + } 400 + } catch { 401 + // Profile refresh is best-effort 402 + } 403 + } 404 + 405 + // Register commands 406 + this.registerCommands(); 407 + 408 + // Set up pubsub for UI communication 409 + setupPubSub(); 410 + 411 + console.log('[me] Extension loaded', currentSession ? `(session: @${currentSession.handle})` : '(no session)'); 412 + }, 413 + 414 + uninit() { 415 + console.log('[me] Cleaning up...'); 416 + if (hasPeekAPI) { 417 + api.commands.unregister('me:core'); 418 + } 419 + }, 420 + }; 421 + 422 + export default extension;
+609
extensions/me-core/home.css
··· 1 + /* Import theme variables */ 2 + @import url('peek://theme/variables.css'); 3 + 4 + * { 5 + box-sizing: border-box; 6 + margin: 0; 7 + padding: 0; 8 + } 9 + 10 + html { 11 + font-family: var(--theme-font-sans); 12 + -webkit-font-smoothing: antialiased; 13 + font-size: 14px; 14 + line-height: 1.5; 15 + } 16 + 17 + body { 18 + background: var(--base00); 19 + color: var(--base05); 20 + min-height: 100vh; 21 + } 22 + 23 + #app { 24 + height: 100vh; 25 + overflow: hidden; 26 + } 27 + 28 + /* Login view keeps centered layout */ 29 + #login-view { 30 + max-width: 640px; 31 + margin: 0 auto; 32 + padding: 24px; 33 + } 34 + 35 + /* ==================== Shared ==================== */ 36 + 37 + .input { 38 + width: 100%; 39 + padding: 10px 14px; 40 + font-size: 14px; 41 + font-family: var(--theme-font-sans); 42 + background: var(--base01); 43 + border: 1px solid var(--base02); 44 + border-radius: 8px; 45 + color: var(--base05); 46 + outline: none; 47 + transition: border-color 0.15s; 48 + } 49 + 50 + .input:focus { 51 + border-color: var(--base0D); 52 + } 53 + 54 + .input::placeholder { 55 + color: var(--base03); 56 + } 57 + 58 + .btn { 59 + padding: 10px 20px; 60 + font-size: 14px; 61 + font-weight: 500; 62 + font-family: var(--theme-font-sans); 63 + border: none; 64 + border-radius: 8px; 65 + cursor: pointer; 66 + transition: all 0.15s; 67 + } 68 + 69 + .btn:disabled { 70 + opacity: 0.5; 71 + cursor: not-allowed; 72 + } 73 + 74 + .btn-primary { 75 + background: var(--base0D); 76 + color: var(--base00); 77 + } 78 + 79 + .btn-primary:hover:not(:disabled) { 80 + filter: brightness(1.1); 81 + } 82 + 83 + .btn-danger { 84 + background: var(--base08); 85 + color: var(--base00); 86 + padding: 8px 16px; 87 + font-size: 13px; 88 + } 89 + 90 + .btn-danger:hover:not(:disabled) { 91 + filter: brightness(1.1); 92 + } 93 + 94 + .error { 95 + margin-top: 12px; 96 + padding: 10px 14px; 97 + background: color-mix(in srgb, var(--base08) 15%, transparent); 98 + border: 1px solid var(--base08); 99 + border-radius: 8px; 100 + color: var(--base08); 101 + font-size: 13px; 102 + } 103 + 104 + .loading { 105 + color: var(--base04); 106 + font-size: 13px; 107 + padding: 12px 0; 108 + } 109 + 110 + .link { 111 + color: var(--base0D); 112 + text-decoration: none; 113 + } 114 + 115 + .link:hover { 116 + text-decoration: underline; 117 + } 118 + 119 + /* ==================== Login View ==================== */ 120 + 121 + .login-card { 122 + padding-top: 48px; 123 + } 124 + 125 + .login-title { 126 + font-size: 22px; 127 + font-weight: 600; 128 + color: var(--base05); 129 + margin-bottom: 8px; 130 + } 131 + 132 + .login-subtitle { 133 + font-size: 14px; 134 + color: var(--base04); 135 + margin-bottom: 24px; 136 + line-height: 1.6; 137 + } 138 + 139 + /* Account search / typeahead */ 140 + .search-wrap { 141 + position: relative; 142 + margin-bottom: 16px; 143 + } 144 + 145 + .search-results { 146 + display: none; 147 + position: absolute; 148 + top: 100%; 149 + left: 0; 150 + right: 0; 151 + z-index: 10; 152 + background: var(--base01); 153 + border: 1px solid var(--base02); 154 + border-top: none; 155 + border-radius: 0 0 8px 8px; 156 + max-height: 280px; 157 + overflow-y: auto; 158 + } 159 + 160 + .search-results.visible { 161 + display: block; 162 + } 163 + 164 + .search-result-item { 165 + display: flex; 166 + align-items: center; 167 + gap: 10px; 168 + padding: 10px 14px; 169 + cursor: pointer; 170 + transition: background 0.1s; 171 + } 172 + 173 + .search-result-item:hover, 174 + .search-result-item.active { 175 + background: var(--base02); 176 + } 177 + 178 + .search-result-avatar { 179 + width: 28px; 180 + height: 28px; 181 + border-radius: 50%; 182 + flex-shrink: 0; 183 + object-fit: cover; 184 + background: var(--base02); 185 + } 186 + 187 + .search-result-placeholder { 188 + display: flex; 189 + align-items: center; 190 + justify-content: center; 191 + width: 28px; 192 + height: 28px; 193 + border-radius: 50%; 194 + background: var(--base03); 195 + color: var(--base00); 196 + font-size: 12px; 197 + font-weight: 600; 198 + flex-shrink: 0; 199 + } 200 + 201 + .search-result-info { 202 + min-width: 0; 203 + } 204 + 205 + .search-result-name { 206 + font-size: 14px; 207 + font-weight: 500; 208 + color: var(--base05); 209 + white-space: nowrap; 210 + overflow: hidden; 211 + text-overflow: ellipsis; 212 + } 213 + 214 + .search-result-handle { 215 + font-size: 12px; 216 + color: var(--base04); 217 + } 218 + 219 + /* Selected account display */ 220 + .selected-account { 221 + display: flex; 222 + align-items: center; 223 + gap: 12px; 224 + padding: 12px 14px; 225 + background: var(--base01); 226 + border: 1px solid var(--base02); 227 + border-radius: 8px; 228 + margin-bottom: 16px; 229 + } 230 + 231 + .selected-account-avatar { 232 + width: 36px; 233 + height: 36px; 234 + border-radius: 50%; 235 + flex-shrink: 0; 236 + object-fit: cover; 237 + background: var(--base02); 238 + } 239 + 240 + .selected-account-placeholder { 241 + display: flex; 242 + align-items: center; 243 + justify-content: center; 244 + width: 36px; 245 + height: 36px; 246 + border-radius: 50%; 247 + background: var(--base03); 248 + color: var(--base00); 249 + font-size: 14px; 250 + font-weight: 600; 251 + flex-shrink: 0; 252 + } 253 + 254 + .selected-account-info { 255 + flex: 1; 256 + min-width: 0; 257 + } 258 + 259 + .selected-account-name { 260 + font-size: 14px; 261 + font-weight: 600; 262 + color: var(--base05); 263 + } 264 + 265 + .selected-account-handle { 266 + font-size: 12px; 267 + color: var(--base04); 268 + } 269 + 270 + .selected-account-clear { 271 + background: none; 272 + border: none; 273 + color: var(--base04); 274 + cursor: pointer; 275 + font-size: 18px; 276 + padding: 4px; 277 + line-height: 1; 278 + } 279 + 280 + .selected-account-clear:hover { 281 + color: var(--base05); 282 + } 283 + 284 + #password-section { 285 + display: flex; 286 + flex-direction: column; 287 + gap: 12px; 288 + } 289 + 290 + /* ==================== Account Layout ==================== */ 291 + 292 + .account-layout { 293 + display: flex; 294 + height: 100vh; 295 + overflow: hidden; 296 + } 297 + 298 + /* Sidebar */ 299 + .sidebar { 300 + width: 220px; 301 + min-width: 220px; 302 + display: flex; 303 + flex-direction: column; 304 + background: var(--base01); 305 + border-right: 1px solid var(--base02); 306 + overflow-y: auto; 307 + } 308 + 309 + .sidebar-account { 310 + padding: 16px; 311 + border-bottom: 1px solid var(--base02); 312 + } 313 + 314 + .sidebar-nav { 315 + flex: 1; 316 + padding: 8px 0; 317 + } 318 + 319 + .nav-item { 320 + display: flex; 321 + align-items: center; 322 + gap: 8px; 323 + padding: 10px 16px; 324 + font-size: 13px; 325 + color: var(--base04); 326 + cursor: pointer; 327 + transition: all 0.1s; 328 + user-select: none; 329 + } 330 + 331 + .nav-item:hover { 332 + color: var(--base05); 333 + background: var(--base02); 334 + } 335 + 336 + .nav-item.active { 337 + color: var(--base05); 338 + background: color-mix(in srgb, var(--base0D) 15%, transparent); 339 + font-weight: 500; 340 + } 341 + 342 + .nav-icon { 343 + font-size: 14px; 344 + width: 18px; 345 + text-align: center; 346 + flex-shrink: 0; 347 + } 348 + 349 + .sidebar-footer { 350 + padding: 12px 16px; 351 + border-top: 1px solid var(--base02); 352 + } 353 + 354 + .btn-sm { 355 + width: 100%; 356 + padding: 6px 12px; 357 + font-size: 12px; 358 + } 359 + 360 + /* Content area */ 361 + .content-area { 362 + flex: 1; 363 + overflow-y: auto; 364 + padding: 24px; 365 + min-width: 0; 366 + } 367 + 368 + /* Panels */ 369 + .panel { 370 + display: none; 371 + } 372 + 373 + .panel.active { 374 + display: block; 375 + } 376 + 377 + .panel-header { 378 + margin-bottom: 16px; 379 + } 380 + 381 + .panel-title { 382 + font-size: 16px; 383 + font-weight: 600; 384 + color: var(--base05); 385 + margin-bottom: 4px; 386 + } 387 + 388 + .panel-subtitle { 389 + font-size: 13px; 390 + color: var(--base04); 391 + } 392 + 393 + /* ==================== Account View ==================== */ 394 + 395 + .account-header { 396 + display: flex; 397 + align-items: center; 398 + justify-content: space-between; 399 + padding-bottom: 20px; 400 + border-bottom: 1px solid var(--base02); 401 + margin-bottom: 24px; 402 + } 403 + 404 + .account-info { 405 + display: flex; 406 + align-items: center; 407 + gap: 10px; 408 + } 409 + 410 + .account-avatar { 411 + width: 32px; 412 + height: 32px; 413 + border-radius: 50%; 414 + object-fit: cover; 415 + background: var(--base02); 416 + flex-shrink: 0; 417 + } 418 + 419 + .account-placeholder { 420 + display: flex; 421 + align-items: center; 422 + justify-content: center; 423 + width: 32px; 424 + height: 32px; 425 + border-radius: 50%; 426 + background: var(--base03); 427 + color: var(--base00); 428 + font-size: 13px; 429 + font-weight: 600; 430 + flex-shrink: 0; 431 + } 432 + 433 + .account-details { 434 + min-width: 0; 435 + } 436 + 437 + .account-name { 438 + font-size: 13px; 439 + font-weight: 600; 440 + color: var(--base05); 441 + white-space: nowrap; 442 + overflow: hidden; 443 + text-overflow: ellipsis; 444 + } 445 + 446 + .account-handle { 447 + font-size: 11px; 448 + color: var(--base04); 449 + white-space: nowrap; 450 + overflow: hidden; 451 + text-overflow: ellipsis; 452 + } 453 + 454 + .account-did { 455 + display: none; 456 + } 457 + 458 + /* Section (legacy, kept for compat) */ 459 + .section { 460 + margin-bottom: 24px; 461 + } 462 + 463 + .section-title { 464 + font-size: 16px; 465 + font-weight: 600; 466 + color: var(--base05); 467 + margin-bottom: 4px; 468 + } 469 + 470 + .section-subtitle { 471 + font-size: 13px; 472 + color: var(--base04); 473 + margin-bottom: 16px; 474 + } 475 + 476 + /* Collections list */ 477 + .collections-list { 478 + display: flex; 479 + flex-direction: column; 480 + gap: 2px; 481 + } 482 + 483 + .collection-item { 484 + display: flex; 485 + align-items: center; 486 + justify-content: space-between; 487 + padding: 10px 14px; 488 + background: var(--base01); 489 + border-radius: 6px; 490 + transition: background 0.1s; 491 + } 492 + 493 + .collection-item:hover { 494 + background: var(--base02); 495 + } 496 + 497 + .collection-nsid { 498 + font-size: 13px; 499 + font-family: var(--theme-font-mono, monospace); 500 + color: var(--base05); 501 + min-width: 0; 502 + overflow: hidden; 503 + text-overflow: ellipsis; 504 + white-space: nowrap; 505 + } 506 + 507 + .collection-count { 508 + font-size: 12px; 509 + color: var(--base04); 510 + flex-shrink: 0; 511 + margin-left: 12px; 512 + padding: 2px 8px; 513 + background: var(--base02); 514 + border-radius: 10px; 515 + } 516 + 517 + .collections-empty { 518 + color: var(--base04); 519 + font-size: 13px; 520 + padding: 16px 0; 521 + text-align: center; 522 + } 523 + 524 + /* ==================== Explore Lexicons Panel ==================== */ 525 + 526 + .explore-search { 527 + margin-bottom: 16px; 528 + } 529 + 530 + .explore-search peek-input { 531 + width: 100%; 532 + } 533 + 534 + .explore-list { 535 + display: flex; 536 + flex-direction: column; 537 + gap: 2px; 538 + } 539 + 540 + .explore-domain-group { 541 + margin-bottom: 12px; 542 + } 543 + 544 + .explore-domain-label { 545 + font-size: 11px; 546 + font-weight: 600; 547 + color: var(--base04); 548 + text-transform: uppercase; 549 + letter-spacing: 0.05em; 550 + padding: 6px 14px 4px; 551 + } 552 + 553 + .explore-item { 554 + display: flex; 555 + align-items: center; 556 + justify-content: space-between; 557 + padding: 8px 14px; 558 + background: var(--base01); 559 + border-radius: 6px; 560 + transition: background 0.1s; 561 + cursor: default; 562 + } 563 + 564 + .explore-item:hover { 565 + background: var(--base02); 566 + } 567 + 568 + .explore-nsid { 569 + font-size: 13px; 570 + font-family: var(--theme-font-mono, monospace); 571 + color: var(--base05); 572 + min-width: 0; 573 + overflow: hidden; 574 + text-overflow: ellipsis; 575 + white-space: nowrap; 576 + } 577 + 578 + .explore-domain-badge { 579 + font-size: 11px; 580 + color: var(--base04); 581 + flex-shrink: 0; 582 + margin-left: 12px; 583 + padding: 2px 8px; 584 + background: var(--base02); 585 + border-radius: 10px; 586 + } 587 + 588 + .explore-empty { 589 + color: var(--base04); 590 + font-size: 13px; 591 + padding: 16px 0; 592 + text-align: center; 593 + } 594 + 595 + .explore-stats { 596 + font-size: 12px; 597 + color: var(--base04); 598 + padding: 8px 0; 599 + } 600 + 601 + .explore-error { 602 + margin-top: 8px; 603 + padding: 10px 14px; 604 + background: color-mix(in srgb, var(--base08) 15%, transparent); 605 + border: 1px solid var(--base08); 606 + border-radius: 8px; 607 + color: var(--base08); 608 + font-size: 13px; 609 + }
+102
extensions/me-core/home.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'; connect-src https: http:; img-src https: data:;"> 6 + <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1"> 7 + <title>Me</title> 8 + <link rel="stylesheet" type="text/css" href="home.css"> 9 + <script type="module"> 10 + import 'peek://app/components/peek-input.js'; 11 + </script> 12 + </head> 13 + <body> 14 + <div id="app"> 15 + <!-- Login view --> 16 + <div id="login-view" class="view"> 17 + <div class="login-card"> 18 + <h1 class="login-title">Connect your identity</h1> 19 + <p class="login-subtitle"> 20 + Sign in with your AT Protocol account (Bluesky). 21 + </p> 22 + 23 + <div class="search-wrap"> 24 + <input 25 + type="text" 26 + id="handle-input" 27 + class="input" 28 + placeholder="Search your handle..." 29 + autocomplete="off" 30 + spellcheck="false" 31 + /> 32 + <div id="search-results" class="search-results"></div> 33 + </div> 34 + 35 + <div id="selected-account" class="selected-account" style="display:none"></div> 36 + 37 + <div id="login-error" class="error" style="display:none"></div> 38 + <div id="login-loading" class="loading" style="display:none">Connecting...</div> 39 + </div> 40 + </div> 41 + 42 + <!-- Logged-in view --> 43 + <div id="account-view" class="view" style="display:none"> 44 + <div class="account-layout"> 45 + <!-- Sidebar --> 46 + <div class="sidebar"> 47 + <div class="sidebar-account"> 48 + <div id="account-info" class="account-info"></div> 49 + </div> 50 + <nav class="sidebar-nav"> 51 + <div class="nav-item active" data-panel="my-lexicons"> 52 + <span class="nav-icon">&#9776;</span> 53 + My Lexicons 54 + </div> 55 + <div class="nav-item" data-panel="explore"> 56 + <span class="nav-icon">&#9906;</span> 57 + Explore Lexicons 58 + </div> 59 + </nav> 60 + <div class="sidebar-footer"> 61 + <button id="logout-btn" class="btn btn-danger btn-sm">Disconnect</button> 62 + </div> 63 + </div> 64 + 65 + <!-- Content area --> 66 + <div class="content-area"> 67 + <!-- My Lexicons panel --> 68 + <div id="panel-my-lexicons" class="panel active"> 69 + <div class="panel-header"> 70 + <h2 class="panel-title">My Lexicons</h2> 71 + <p class="panel-subtitle">Collections of records in your AT Protocol repo</p> 72 + </div> 73 + <div id="collections-list" class="collections-list"> 74 + <div class="loading">Loading collections...</div> 75 + </div> 76 + </div> 77 + 78 + <!-- Explore Lexicons panel --> 79 + <div id="panel-explore" class="panel" style="display:none"> 80 + <div class="panel-header"> 81 + <h2 class="panel-title">Explore Lexicons</h2> 82 + <p class="panel-subtitle">Browse known AT Protocol lexicons from the network</p> 83 + </div> 84 + <div class="explore-search"> 85 + <peek-input 86 + id="lexicon-search" 87 + type="search" 88 + placeholder="Filter lexicons..." 89 + ></peek-input> 90 + </div> 91 + <div id="explore-list" class="explore-list"> 92 + <div class="loading">Loading lexicon directory...</div> 93 + </div> 94 + </div> 95 + </div> 96 + </div> 97 + </div> 98 + </div> 99 + 100 + <script type="module" src="home.js"></script> 101 + </body> 102 + </html>
+566
extensions/me-core/home.js
··· 1 + /** 2 + * Me Home — AT Protocol identity and data browser UI 3 + * 4 + * Communicates with background.js via PubSub messages: 5 + * - me:get-session / me:session-state — get current session 6 + * - me:search-actors / me:search-actors:response — typeahead search 7 + * - me:do-login / me:session-changed / me:login-error — login flow 8 + * - me:do-logout / me:session-changed — logout 9 + * - me:get-collections / me:collections:response — fetch lexicons 10 + * - me:search-lexicons / me:search-lexicons:response — explore lexicons 11 + */ 12 + 13 + const api = window.app; 14 + 15 + // ============================================================================ 16 + // State 17 + // ============================================================================ 18 + 19 + let state = { 20 + authenticated: false, 21 + handle: null, 22 + did: null, 23 + pdsUrl: null, 24 + profile: null, 25 + collections: null, // null = not loaded, [] = empty 26 + selectedActor: null, // { handle, did, displayName, avatar } 27 + searchResults: [], 28 + searchVisible: false, 29 + activeIndex: -1, 30 + loading: false, 31 + error: null, 32 + // Nav / panels 33 + activePanel: 'my-lexicons', // 'my-lexicons' | 'explore' 34 + // Explore lexicons 35 + exploreLexicons: null, // null = not loaded, [] = loaded 36 + exploreFilter: '', 37 + exploreLoading: false, 38 + exploreError: null, 39 + }; 40 + 41 + // DOM refs 42 + let handleInput, searchResultsEl, selectedAccountEl; 43 + let loginError, loginLoading; 44 + let loginView, accountView, accountInfo, collectionsListEl, logoutBtn; 45 + let exploreListEl, lexiconSearchEl; 46 + 47 + // Debounce timer for search 48 + let searchTimer = null; 49 + let exploreFilterTimer = null; 50 + 51 + // ============================================================================ 52 + // Init 53 + // ============================================================================ 54 + 55 + async function init() { 56 + // Cache DOM refs 57 + handleInput = document.getElementById('handle-input'); 58 + searchResultsEl = document.getElementById('search-results'); 59 + selectedAccountEl = document.getElementById('selected-account'); 60 + loginError = document.getElementById('login-error'); 61 + loginLoading = document.getElementById('login-loading'); 62 + loginView = document.getElementById('login-view'); 63 + accountView = document.getElementById('account-view'); 64 + accountInfo = document.getElementById('account-info'); 65 + collectionsListEl = document.getElementById('collections-list'); 66 + logoutBtn = document.getElementById('logout-btn'); 67 + exploreListEl = document.getElementById('explore-list'); 68 + lexiconSearchEl = document.getElementById('lexicon-search'); 69 + 70 + // Set up event listeners 71 + setupEventListeners(); 72 + 73 + // Set up nav listeners 74 + setupNav(); 75 + 76 + // Set up PubSub listeners 77 + setupPubSub(); 78 + 79 + // Request current session state from background 80 + api.publish('me:get-session', {}, api.scopes.GLOBAL); 81 + 82 + // Register escape handler 83 + if (api.escape) { 84 + api.escape.onEscape(handleEscape); 85 + } 86 + } 87 + 88 + // ============================================================================ 89 + // Escape handling 90 + // ============================================================================ 91 + 92 + function handleEscape() { 93 + // If search results are visible, close them 94 + if (state.searchVisible) { 95 + state.searchVisible = false; 96 + state.searchResults = []; 97 + state.activeIndex = -1; 98 + renderSearchResults(); 99 + return { handled: true }; 100 + } 101 + 102 + // If an actor is selected but not yet logged in, clear selection 103 + if (state.selectedActor && !state.authenticated) { 104 + clearActorSelection(); 105 + return { handled: true }; 106 + } 107 + 108 + // Let window close 109 + return { handled: false }; 110 + } 111 + 112 + // ============================================================================ 113 + // Event listeners 114 + // ============================================================================ 115 + 116 + function setupEventListeners() { 117 + // Handle input for typeahead search 118 + handleInput.addEventListener('input', (e) => { 119 + const query = e.target.value.trim(); 120 + if (searchTimer) clearTimeout(searchTimer); 121 + 122 + if (!query || query.length < 2) { 123 + state.searchResults = []; 124 + state.searchVisible = false; 125 + state.activeIndex = -1; 126 + renderSearchResults(); 127 + return; 128 + } 129 + 130 + searchTimer = setTimeout(() => { 131 + api.publish('me:search-actors', { query }, api.scopes.GLOBAL); 132 + }, 250); 133 + }); 134 + 135 + // Keyboard navigation in search results 136 + handleInput.addEventListener('keydown', (e) => { 137 + if (!state.searchVisible) return; 138 + 139 + if (e.key === 'ArrowDown') { 140 + e.preventDefault(); 141 + state.activeIndex = Math.min(state.activeIndex + 1, state.searchResults.length - 1); 142 + renderSearchResults(); 143 + } else if (e.key === 'ArrowUp') { 144 + e.preventDefault(); 145 + state.activeIndex = Math.max(state.activeIndex - 1, 0); 146 + renderSearchResults(); 147 + } else if (e.key === 'Enter') { 148 + e.preventDefault(); 149 + if (state.activeIndex >= 0 && state.activeIndex < state.searchResults.length) { 150 + selectActor(state.searchResults[state.activeIndex]); 151 + } 152 + } else if (e.key === 'Escape') { 153 + state.searchVisible = false; 154 + renderSearchResults(); 155 + } 156 + }); 157 + 158 + // Close search results on blur (delayed to allow click) 159 + handleInput.addEventListener('blur', () => { 160 + setTimeout(() => { 161 + state.searchVisible = false; 162 + renderSearchResults(); 163 + }, 200); 164 + }); 165 + 166 + // Logout button 167 + logoutBtn.addEventListener('click', () => { 168 + api.publish('me:do-logout', {}, api.scopes.GLOBAL); 169 + }); 170 + } 171 + 172 + // ============================================================================ 173 + // Navigation 174 + // ============================================================================ 175 + 176 + function setupNav() { 177 + // Nav item clicks 178 + document.querySelectorAll('.nav-item').forEach(item => { 179 + item.addEventListener('click', () => { 180 + const panel = item.dataset.panel; 181 + switchPanel(panel); 182 + }); 183 + }); 184 + 185 + // Explore search input — peek-input fires native input event through shadow DOM 186 + if (lexiconSearchEl) { 187 + lexiconSearchEl.addEventListener('input', () => { 188 + const query = (lexiconSearchEl.value || '').trim().toLowerCase(); 189 + state.exploreFilter = query; 190 + if (exploreFilterTimer) clearTimeout(exploreFilterTimer); 191 + exploreFilterTimer = setTimeout(() => { 192 + renderExploreLexicons(); 193 + }, 100); 194 + }); 195 + } 196 + } 197 + 198 + function switchPanel(panel) { 199 + state.activePanel = panel; 200 + 201 + // Update nav items 202 + document.querySelectorAll('.nav-item').forEach(item => { 203 + item.classList.toggle('active', item.dataset.panel === panel); 204 + }); 205 + 206 + // Update panels 207 + document.querySelectorAll('.panel').forEach(p => { 208 + p.classList.remove('active'); 209 + p.style.display = 'none'; 210 + }); 211 + 212 + const panelEl = document.getElementById(`panel-${panel}`); 213 + if (panelEl) { 214 + panelEl.classList.add('active'); 215 + panelEl.style.display = ''; 216 + } 217 + 218 + // Lazy-load explore data on first visit 219 + if (panel === 'explore' && state.exploreLexicons === null && !state.exploreLoading) { 220 + loadExploreLexicons(); 221 + } 222 + } 223 + 224 + function loadExploreLexicons() { 225 + state.exploreLoading = true; 226 + state.exploreError = null; 227 + exploreListEl.innerHTML = '<div class="loading">Loading lexicon directory...</div>'; 228 + api.publish('me:search-lexicons', {}, api.scopes.GLOBAL); 229 + } 230 + 231 + function renderExploreLexicons() { 232 + if (!exploreListEl) return; 233 + 234 + if (state.exploreLoading) { 235 + exploreListEl.innerHTML = '<div class="loading">Loading lexicon directory...</div>'; 236 + return; 237 + } 238 + 239 + if (state.exploreError) { 240 + exploreListEl.innerHTML = `<div class="explore-error">${escapeHtml(state.exploreError)}</div>`; 241 + return; 242 + } 243 + 244 + if (!state.exploreLexicons || state.exploreLexicons.length === 0) { 245 + exploreListEl.innerHTML = '<div class="explore-empty">No lexicons found</div>'; 246 + return; 247 + } 248 + 249 + // Filter 250 + const filter = state.exploreFilter; 251 + const filtered = filter 252 + ? state.exploreLexicons.filter(lex => lex.nsid.toLowerCase().includes(filter)) 253 + : state.exploreLexicons; 254 + 255 + if (filtered.length === 0) { 256 + exploreListEl.innerHTML = `<div class="explore-empty">No lexicons matching "${escapeHtml(filter)}"</div>`; 257 + return; 258 + } 259 + 260 + // Group by domain 261 + const groups = {}; 262 + for (const lex of filtered) { 263 + const domain = lex.domain || 'unknown'; 264 + if (!groups[domain]) groups[domain] = []; 265 + groups[domain].push(lex); 266 + } 267 + 268 + const statsHtml = `<div class="explore-stats">${filtered.length} lexicon${filtered.length !== 1 ? 's' : ''}${filter ? ' matched' : ' loaded'}</div>`; 269 + 270 + const groupsHtml = Object.entries(groups).map(([domain, lexicons]) => { 271 + const items = lexicons.map(lex => ` 272 + <div class="explore-item" title="${escapeHtml(lex.nsid)}"> 273 + <span class="explore-nsid">${escapeHtml(lex.nsid)}</span> 274 + </div> 275 + `).join(''); 276 + 277 + return ` 278 + <div class="explore-domain-group"> 279 + <div class="explore-domain-label">${escapeHtml(domain)}</div> 280 + ${items} 281 + </div> 282 + `; 283 + }).join(''); 284 + 285 + exploreListEl.innerHTML = statsHtml + groupsHtml; 286 + } 287 + 288 + // ============================================================================ 289 + // PubSub 290 + // ============================================================================ 291 + 292 + function setupPubSub() { 293 + // Session state response (initial load) 294 + api.subscribe('me:session-state', (msg) => { 295 + state.authenticated = msg.authenticated; 296 + if (msg.authenticated) { 297 + state.handle = msg.handle; 298 + state.did = msg.did; 299 + state.pdsUrl = msg.pdsUrl; 300 + state.profile = msg.profile; 301 + showAccountView(); 302 + } else { 303 + showLoginView(); 304 + } 305 + }, api.scopes.GLOBAL); 306 + 307 + // Session changed (login/logout) 308 + api.subscribe('me:session-changed', (msg) => { 309 + state.loading = false; 310 + state.error = null; 311 + 312 + if (msg.authenticated) { 313 + state.authenticated = true; 314 + state.handle = msg.handle; 315 + state.did = msg.did; 316 + state.pdsUrl = msg.pdsUrl; 317 + state.profile = msg.profile; 318 + showAccountView(); 319 + } else { 320 + state.authenticated = false; 321 + state.handle = null; 322 + state.did = null; 323 + state.pdsUrl = null; 324 + state.profile = null; 325 + state.collections = null; 326 + showLoginView(); 327 + } 328 + }, api.scopes.GLOBAL); 329 + 330 + // Login error 331 + api.subscribe('me:login-error', (msg) => { 332 + state.loading = false; 333 + state.error = msg.message; 334 + renderLoginState(); 335 + }, api.scopes.GLOBAL); 336 + 337 + // Search results 338 + api.subscribe('me:search-actors:response', (msg) => { 339 + state.searchResults = msg.results || []; 340 + state.searchVisible = state.searchResults.length > 0; 341 + state.activeIndex = -1; 342 + renderSearchResults(); 343 + }, api.scopes.GLOBAL); 344 + 345 + // Collections response 346 + api.subscribe('me:collections:response', (msg) => { 347 + if (msg.error) { 348 + collectionsListEl.innerHTML = `<div class="error">${escapeHtml(msg.error)}</div>`; 349 + return; 350 + } 351 + state.collections = msg.collections || []; 352 + renderCollections(); 353 + }, api.scopes.GLOBAL); 354 + 355 + // Explore lexicons response 356 + api.subscribe('me:search-lexicons:response', (msg) => { 357 + state.exploreLoading = false; 358 + if (msg.error) { 359 + state.exploreError = msg.error; 360 + } else { 361 + state.exploreLexicons = msg.lexicons || []; 362 + } 363 + renderExploreLexicons(); 364 + }, api.scopes.GLOBAL); 365 + } 366 + 367 + // ============================================================================ 368 + // Actions 369 + // ============================================================================ 370 + 371 + function selectActor(actor) { 372 + state.selectedActor = actor; 373 + state.searchResults = []; 374 + state.searchVisible = false; 375 + state.activeIndex = -1; 376 + 377 + renderSearchResults(); 378 + renderSelectedAccount(); 379 + 380 + // Immediately start OAuth login 381 + doLogin(); 382 + } 383 + 384 + function clearActorSelection() { 385 + state.selectedActor = null; 386 + state.error = null; 387 + 388 + handleInput.value = ''; 389 + handleInput.style.display = ''; 390 + selectedAccountEl.style.display = 'none'; 391 + loginError.style.display = 'none'; 392 + 393 + handleInput.focus(); 394 + } 395 + 396 + function doLogin() { 397 + if (!state.selectedActor) return; 398 + 399 + state.loading = true; 400 + state.error = null; 401 + renderLoginState(); 402 + 403 + api.publish('me:do-login', { 404 + handle: state.selectedActor.handle, 405 + }, api.scopes.GLOBAL); 406 + } 407 + 408 + // ============================================================================ 409 + // Render 410 + // ============================================================================ 411 + 412 + function showLoginView() { 413 + loginView.style.display = ''; 414 + accountView.style.display = 'none'; 415 + 416 + // Reset login form 417 + handleInput.value = ''; 418 + handleInput.style.display = ''; 419 + selectedAccountEl.style.display = 'none'; 420 + loginError.style.display = 'none'; 421 + loginLoading.style.display = 'none'; 422 + state.selectedActor = null; 423 + state.loading = false; 424 + state.error = null; 425 + } 426 + 427 + function showAccountView() { 428 + loginView.style.display = 'none'; 429 + accountView.style.display = ''; 430 + 431 + renderAccountInfo(); 432 + 433 + // Fetch collections 434 + state.collections = null; 435 + collectionsListEl.innerHTML = '<div class="loading">Loading collections...</div>'; 436 + api.publish('me:get-collections', {}, api.scopes.GLOBAL); 437 + } 438 + 439 + function renderSearchResults() { 440 + if (!state.searchVisible || state.searchResults.length === 0) { 441 + searchResultsEl.classList.remove('visible'); 442 + searchResultsEl.innerHTML = ''; 443 + return; 444 + } 445 + 446 + searchResultsEl.classList.add('visible'); 447 + searchResultsEl.innerHTML = state.searchResults.map((actor, i) => { 448 + const initial = ((actor.handle || '?')[0]).toUpperCase(); 449 + const avatarHtml = actor.avatar 450 + ? `<img class="search-result-avatar" src="${escapeHtml(actor.avatar)}" alt="">` 451 + : `<span class="search-result-placeholder">${initial}</span>`; 452 + 453 + return ` 454 + <div class="search-result-item ${i === state.activeIndex ? 'active' : ''}" data-index="${i}"> 455 + ${avatarHtml} 456 + <div class="search-result-info"> 457 + <div class="search-result-name">${escapeHtml(actor.displayName || actor.handle)}</div> 458 + <div class="search-result-handle">@${escapeHtml(actor.handle)}</div> 459 + </div> 460 + </div> 461 + `; 462 + }).join(''); 463 + 464 + // Click handlers for results 465 + searchResultsEl.querySelectorAll('.search-result-item').forEach(el => { 466 + el.addEventListener('mousedown', (e) => { 467 + e.preventDefault(); // Prevent blur 468 + const idx = parseInt(el.dataset.index, 10); 469 + selectActor(state.searchResults[idx]); 470 + }); 471 + }); 472 + } 473 + 474 + function renderSelectedAccount() { 475 + if (!state.selectedActor) return; 476 + 477 + const actor = state.selectedActor; 478 + const initial = ((actor.handle || '?')[0]).toUpperCase(); 479 + const avatarHtml = actor.avatar 480 + ? `<img class="selected-account-avatar" src="${escapeHtml(actor.avatar)}" alt="">` 481 + : `<span class="selected-account-placeholder">${initial}</span>`; 482 + 483 + selectedAccountEl.innerHTML = ` 484 + ${avatarHtml} 485 + <div class="selected-account-info"> 486 + <div class="selected-account-name">${escapeHtml(actor.displayName || actor.handle)}</div> 487 + <div class="selected-account-handle">@${escapeHtml(actor.handle)}</div> 488 + </div> 489 + <button class="selected-account-clear" title="Clear selection">&times;</button> 490 + `; 491 + 492 + selectedAccountEl.querySelector('.selected-account-clear').addEventListener('click', clearActorSelection); 493 + 494 + // Show selected account, hide search input 495 + handleInput.style.display = 'none'; 496 + selectedAccountEl.style.display = 'flex'; 497 + } 498 + 499 + function renderLoginState() { 500 + if (state.loading) { 501 + loginLoading.style.display = ''; 502 + loginError.style.display = 'none'; 503 + } else { 504 + loginLoading.style.display = 'none'; 505 + } 506 + 507 + if (state.error) { 508 + loginError.textContent = state.error; 509 + loginError.style.display = ''; 510 + } else { 511 + loginError.style.display = 'none'; 512 + } 513 + } 514 + 515 + function renderAccountInfo() { 516 + const profile = state.profile || {}; 517 + const displayName = profile.displayName || state.handle; 518 + const initial = ((state.handle || '?')[0]).toUpperCase(); 519 + 520 + const avatarHtml = profile.avatar 521 + ? `<img class="account-avatar" src="${escapeHtml(profile.avatar)}" alt="">` 522 + : `<span class="account-placeholder">${initial}</span>`; 523 + 524 + accountInfo.innerHTML = ` 525 + ${avatarHtml} 526 + <div class="account-details"> 527 + <div class="account-name">${escapeHtml(displayName)}</div> 528 + <div class="account-handle">@${escapeHtml(state.handle)}</div> 529 + <div class="account-did">${escapeHtml(state.did)}</div> 530 + </div> 531 + `; 532 + } 533 + 534 + function renderCollections() { 535 + if (!state.collections || state.collections.length === 0) { 536 + collectionsListEl.innerHTML = '<div class="collections-empty">No collections found</div>'; 537 + return; 538 + } 539 + 540 + collectionsListEl.innerHTML = state.collections.map(col => { 541 + const countLabel = col.hasMore ? `${col.count}+` : String(col.count); 542 + return ` 543 + <div class="collection-item"> 544 + <span class="collection-nsid">${escapeHtml(col.nsid)}</span> 545 + <span class="collection-count">${countLabel}</span> 546 + </div> 547 + `; 548 + }).join(''); 549 + } 550 + 551 + // ============================================================================ 552 + // Helpers 553 + // ============================================================================ 554 + 555 + function escapeHtml(str) { 556 + if (!str) return ''; 557 + const div = document.createElement('div'); 558 + div.textContent = str; 559 + return div.innerHTML; 560 + } 561 + 562 + // ============================================================================ 563 + // Bootstrap 564 + // ============================================================================ 565 + 566 + document.addEventListener('DOMContentLoaded', init);
+8
extensions/me-core/manifest.json
··· 1 + { 2 + "id": "me-core", 3 + "shortname": "me", 4 + "name": "Me", 5 + "description": "Your AT Protocol identity and data", 6 + "version": "1.0.0", 7 + "background": "background.html" 8 + }
+6
preload.js
··· 1814 1814 } 1815 1815 }; 1816 1816 1817 + // OAuth loopback server — available to all pages including extensions 1818 + api.oauth = { 1819 + startLoopback: (options) => ipcRenderer.invoke('oauth-start-loopback', options), 1820 + awaitCallback: (port) => ipcRenderer.invoke('oauth-await-callback', { port }), 1821 + }; 1822 + 1817 1823 // Escape handler - responds to backend's escape query 1818 1824 // The backend intercepts ESC on keyDown via before-input-event and calls e.preventDefault(), 1819 1825 // so the DOM keydown event never reaches the page. This means peek-dialog's own keydown