experiments in a post-browser web
10
fork

Configure Feed

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

moreportmoreport

+309 -686
+80
backend/electron/index.ts
··· 65 65 66 66 export { tableNames } from '../types/index.js'; 67 67 68 + // Protocol handling 69 + export { 70 + APP_SCHEME, 71 + APP_PROTOCOL, 72 + registerScheme, 73 + registerExtensionPath, 74 + getExtensionPath, 75 + getRegisteredExtensionIds, 76 + initProtocol, 77 + } from './protocol.js'; 78 + 79 + // Extension management 80 + export { 81 + discoverExtensions, 82 + loadExtensionManifest, 83 + isBuiltinExtensionEnabled, 84 + getExternalExtensions, 85 + } from './extensions.js'; 86 + 87 + export type { 88 + ExtensionManifest, 89 + DiscoveredExtension, 90 + } from './extensions.js'; 91 + 92 + // System tray 93 + export { 94 + initTray, 95 + getTray, 96 + destroyTray, 97 + } from './tray.js'; 98 + 99 + export type { TrayOptions } from './tray.js'; 100 + 101 + // Shortcuts 102 + export { 103 + parseShortcut, 104 + inputMatchesShortcut, 105 + registerGlobalShortcut, 106 + unregisterGlobalShortcut, 107 + registerLocalShortcut, 108 + unregisterLocalShortcut, 109 + handleLocalShortcut, 110 + unregisterShortcutsForAddress, 111 + getGlobalShortcutSource, 112 + isGlobalShortcutRegistered, 113 + } from './shortcuts.js'; 114 + 115 + export type { InputEvent } from './shortcuts.js'; 116 + 117 + // PubSub messaging 118 + export { 119 + scopes, 120 + publish, 121 + subscribe, 122 + unsubscribe, 123 + unsubscribeAll, 124 + setExtensionBroadcaster, 125 + getSystemAddress, 126 + } from './pubsub.js'; 127 + 128 + export type { Scope } from './pubsub.js'; 129 + 130 + // Main process orchestration 131 + export { 132 + configure, 133 + initialize, 134 + discoverBuiltinExtensions, 135 + createExtensionWindow, 136 + loadEnabledExtensions, 137 + getRunningExtensions, 138 + destroyExtensionWindow, 139 + getExtensionWindow, 140 + registerWindow, 141 + getWindowInfo, 142 + findWindowByKey, 143 + shutdown, 144 + } from './main.js'; 145 + 146 + export type { AppConfig } from './main.js'; 147 + 68 148 // Re-export frontend API types (the contract that preload.js implements) 69 149 export type { 70 150 IPeekApi,
+161
backend/electron/protocol.ts
··· 1 + /** 2 + * Electron protocol handling for peek:// scheme 3 + * 4 + * Handles: 5 + * - peek://app/{path} - Core app files 6 + * - peek://ext/{ext-id}/{path} - Extension content 7 + * - peek://extensions/{path} - Shared extension infrastructure 8 + */ 9 + 10 + import { protocol, net } from 'electron'; 11 + import path from 'node:path'; 12 + import { pathToFileURL } from 'node:url'; 13 + import { getDb } from './datastore.js'; 14 + 15 + export const APP_SCHEME = 'peek'; 16 + export const APP_PROTOCOL = `${APP_SCHEME}:`; 17 + 18 + // Extension path cache: extensionId -> filesystem path 19 + const extensionPaths = new Map<string, string>(); 20 + 21 + // Root directory (set during init) 22 + let rootDir: string; 23 + 24 + /** 25 + * Register the peek:// scheme as privileged 26 + * MUST be called before app.ready 27 + */ 28 + export function registerScheme(): void { 29 + protocol.registerSchemesAsPrivileged([{ 30 + scheme: APP_SCHEME, 31 + privileges: { 32 + standard: true, 33 + secure: true, 34 + supportFetchAPI: true, 35 + bypassCSP: true, 36 + corsEnabled: true, 37 + allowServiceWorkers: false 38 + } 39 + }]); 40 + } 41 + 42 + /** 43 + * Register a built-in extension path 44 + */ 45 + export function registerExtensionPath(id: string, fsPath: string): void { 46 + extensionPaths.set(id, fsPath); 47 + console.log('Registered extension path:', id, fsPath); 48 + } 49 + 50 + /** 51 + * Get all registered built-in extension IDs 52 + */ 53 + export function getRegisteredExtensionIds(): string[] { 54 + return Array.from(extensionPaths.keys()); 55 + } 56 + 57 + /** 58 + * Get extension filesystem path by ID 59 + * First checks built-in extensions, then datastore for external extensions 60 + */ 61 + export function getExtensionPath(id: string): string | null { 62 + // Check built-in extensions first 63 + const builtinPath = extensionPaths.get(id); 64 + if (builtinPath) return builtinPath; 65 + 66 + // Check datastore for external extensions 67 + try { 68 + const db = getDb(); 69 + const ext = db.prepare('SELECT * FROM extensions WHERE id = ?').get(id) as { path?: string } | undefined; 70 + if (ext && ext.path) { 71 + return ext.path; 72 + } 73 + 74 + // Also check by shortname (stored in metadata) 75 + const allExts = db.prepare('SELECT * FROM extensions').all() as Array<{ path?: string; metadata?: string }>; 76 + for (const extData of allExts) { 77 + try { 78 + const metadata = JSON.parse(extData.metadata || '{}'); 79 + if (metadata.shortname === id && extData.path) { 80 + return extData.path; 81 + } 82 + } catch { 83 + // Ignore JSON parse errors 84 + } 85 + } 86 + } catch { 87 + // Database not initialized yet 88 + } 89 + 90 + return null; 91 + } 92 + 93 + /** 94 + * Initialize the protocol handler 95 + * Must be called after app.ready 96 + */ 97 + export function initProtocol(appRootDir: string): void { 98 + rootDir = appRootDir; 99 + 100 + protocol.handle(APP_SCHEME, (req) => { 101 + let { host, pathname } = new URL(req.url); 102 + 103 + // trim leading slash 104 + pathname = pathname.replace(/^\//, ''); 105 + 106 + // Handle extension content: peek://ext/{ext-id}/{path} 107 + if (host === 'ext') { 108 + const parts = pathname.split('/'); 109 + const extId = parts[0]; 110 + const extPath = parts.slice(1).join('/') || 'index.html'; 111 + 112 + const extBasePath = getExtensionPath(extId); 113 + if (!extBasePath) { 114 + console.log('Extension not found:', extId); 115 + return new Response('Extension not found', { status: 404 }); 116 + } 117 + 118 + const absolutePath = path.resolve(extBasePath, extPath); 119 + 120 + // Security: ensure path stays within extension folder 121 + const normalizedBase = path.normalize(extBasePath); 122 + if (!absolutePath.startsWith(normalizedBase)) { 123 + console.error('Path traversal attempt blocked:', absolutePath); 124 + return new Response('Forbidden', { status: 403 }); 125 + } 126 + 127 + const fileURL = pathToFileURL(absolutePath).toString(); 128 + return net.fetch(fileURL); 129 + } 130 + 131 + // Handle extensions infrastructure: peek://extensions/{path} 132 + // This serves the extension loader and other shared extension code 133 + if (host === 'extensions') { 134 + const absolutePath = path.resolve(rootDir, 'extensions', pathname); 135 + 136 + // Security: ensure path stays within extensions folder 137 + const extensionsBase = path.resolve(rootDir, 'extensions'); 138 + if (!absolutePath.startsWith(extensionsBase)) { 139 + console.error('Path traversal attempt blocked:', absolutePath); 140 + return new Response('Forbidden', { status: 403 }); 141 + } 142 + 143 + const fileURL = pathToFileURL(absolutePath).toString(); 144 + return net.fetch(fileURL); 145 + } 146 + 147 + let relativePath = pathname; 148 + 149 + // Handle node_modules paths 150 + const isNode = pathname.indexOf('node_modules') > -1; 151 + 152 + if (!isNode) { 153 + relativePath = path.join(host, pathname); 154 + } 155 + 156 + const absolutePath = path.resolve(rootDir, relativePath); 157 + const fileURL = pathToFileURL(absolutePath).toString(); 158 + 159 + return net.fetch(fileURL); 160 + }); 161 + }
+68 -686
index.js
··· 4 4 app, 5 5 BrowserWindow, 6 6 dialog, 7 - globalShortcut, 8 7 ipcMain, 9 8 Menu, 10 9 nativeImage, 11 10 nativeTheme, 12 - net, 13 - protocol, 14 - Tray 15 11 } from 'electron'; 16 12 17 13 import fs from 'node:fs'; 18 14 import path from 'node:path'; 19 - import { pathToFileURL } from 'url'; 15 + 20 16 // Import from compiled TypeScript backend 21 17 import { 22 - initDatabase, 23 - closeDatabase, 18 + // Main process orchestration 19 + configure, 20 + initialize, 21 + discoverBuiltinExtensions, 22 + createExtensionWindow, 23 + loadEnabledExtensions, 24 + getRunningExtensions, 25 + destroyExtensionWindow, 26 + getExtensionWindow, 27 + registerWindow, 28 + getWindowInfo, 29 + findWindowByKey, 30 + shutdown, 31 + // Database 24 32 getDb, 25 33 isValidTable, 26 34 // Datastore operations ··· 42 50 getTable, 43 51 setRow, 44 52 getStats, 53 + // Protocol 54 + APP_SCHEME, 55 + APP_PROTOCOL, 56 + registerExtensionPath, 57 + getExtensionPath, 58 + loadExtensionManifest, 59 + // Tray 60 + initTray, 61 + // Shortcuts 62 + registerGlobalShortcut, 63 + unregisterGlobalShortcut, 64 + registerLocalShortcut, 65 + unregisterLocalShortcut, 66 + unregisterShortcutsForAddress, 67 + // PubSub 68 + scopes, 69 + publish as pubsubPublish, 70 + subscribe as pubsubSubscribe, 71 + getSystemAddress, 45 72 } from './dist/backend/electron/index.js'; 46 73 import unhandled from 'electron-unhandled'; 47 74 ··· 68 95 // script loaded into every app window 69 96 const preloadPath = path.join(__dirname, 'preload.js'); 70 97 71 - const APP_SCHEME = 'peek'; 72 - const APP_PROTOCOL = `${APP_SCHEME}:`; 73 98 const APP_CORE_PATH = 'app'; 74 99 75 100 const APP_DEF_WIDTH = 1024; ··· 80 105 const webCoreAddress = 'peek://app/background.html'; 81 106 //const webCoreAddress = 'peek://test/index.html'; 82 107 83 - const systemAddress = 'peek://system/'; 108 + const systemAddress = getSystemAddress(); 84 109 const settingsAddress = 'peek://app/settings/settings.html'; 85 110 86 111 const strings = { ··· 154 179 // Note: getDb, generateId, now, parseUrl, normalizeUrl, calculateFrecency, isValidTable 155 180 // are imported directly from backend/electron 156 181 157 - const initDatastore = async (userDataPath) => { 158 - const dbPath = path.join(userDataPath, 'datastore.sqlite'); 159 - return initDatabase(dbPath); 160 - }; 161 - 162 182 // ***** Features / Strings ***** 163 183 164 184 const labels = { ··· 172 192 }; 173 193 174 194 // ***** System / OS / Theme ***** 175 - 176 - // Use system theme by default 177 - nativeTheme.themeSource = 'system'; 178 195 179 196 // system dark mode handling 180 197 ipcMain.handle('dark-mode:toggle', () => { ··· 200 217 }); 201 218 202 219 // ***** Caches ***** 203 - 204 - // keyed on shortcut string, value is source address (for global shortcuts) 205 - const shortcuts = new Map(); 206 - 207 - // keyed on shortcut string, value is { source, callback, replyTopic } 208 - // Local shortcuts only work when app has focus 209 - const localShortcuts = new Map(); 210 220 211 221 // app global prefs configurable by user 212 222 // populated during app init ··· 284 294 const windowManager = new WindowManager(); 285 295 286 296 // ***** pubsub ***** 287 - 288 - const getPseudoHost = str => str.split('/')[2]; 289 - 290 - const scopes = { 291 - SYSTEM: 1, 292 - SELF: 2, 293 - GLOBAL: 3 297 + // Wrapper object for backend pubsub functions 298 + const pubsub = { 299 + publish: pubsubPublish, 300 + subscribe: pubsubSubscribe 294 301 }; 295 302 296 - const pubsub = (() => { 297 - 298 - const topics = new Map(); 299 - 300 - const scopeCheck = (pubSource, subSource, scope) => { 301 - //console.log('scopeCheck', subSource, pubSource, scope); 302 - if (subSource == systemAddress) { 303 - return true 304 - } 305 - if (scope == scopes.GLOBAL) { 306 - return true; 307 - } 308 - if (getPseudoHost(subSource) == getPseudoHost(pubSource)) { 309 - return true; 310 - } 311 - return false; 312 - }; 313 - 314 - return { 315 - publish: (source, scope, topic, msg) => { 316 - //console.log('ps.pub', topic); 317 - 318 - // Route to traditional subscribers (via IPC callbacks) 319 - if (topics.has(topic)) { 320 - 321 - const t = topics.get(topic); 322 - 323 - for (const [subSource, cb] of t) { 324 - if (scopeCheck(source, subSource, scope)) { 325 - //console.log('FOUND ONE!', subSource); 326 - cb(msg); 327 - } 328 - }; 329 - } 330 - 331 - // Route to extension windows (GLOBAL scope only) 332 - // This enables cross-origin communication to isolated extension processes 333 - if (scope === scopes.GLOBAL && extensionWindows) { 334 - for (const [extId, entry] of extensionWindows) { 335 - if (entry.win && !entry.win.isDestroyed() && entry.status === 'running') { 336 - // Don't send back to the source extension 337 - const extOrigin = `peek://ext/${extId}/`; 338 - if (!source.startsWith(extOrigin)) { 339 - entry.win.webContents.send(`pubsub:${topic}`, { 340 - ...msg, 341 - source 342 - }); 343 - } 344 - } 345 - } 346 - } 347 - }, 348 - subscribe: (source, scope, topic, cb) => { 349 - //console.log('ps.sub', source, scope, topic); 350 - 351 - if (!topics.has(topic)) { 352 - topics.set(topic, new Map([ [source, cb] ])); 353 - } 354 - else { 355 - const subscribers = topics.get(topic); 356 - subscribers.set(source, cb); 357 - topics.set(topic, subscribers); 358 - } 359 - }, 360 - }; 361 - 362 - })(); 363 - 364 303 // ***** Command Registry ***** 365 304 // Stores commands registered via cmd:register topic 366 305 // This enables cmd app to query commands registered before it started 367 306 const commandRegistry = new Map(); 368 307 369 - // ***** Tray ***** 370 - 371 - const ICON_RELATIVE_PATH = 'assets/tray/tray@2x.png'; 372 - 373 - let _tray = null; 374 - 375 - const initTray = () => { 376 - if (!_tray || _tray.isDestroyed()) { 377 - const iconPath = path.join(__dirname, ICON_RELATIVE_PATH); 378 - console.log('initTray: loading icon from', iconPath); 379 - 380 - try { 381 - _tray = new Tray(iconPath); 382 - _tray.setToolTip(labels.tray.tooltip); 383 - _tray.on('click', () => { 384 - pubsub.publish(webCoreAddress, scopes.GLOBAL, 'open', { 385 - address: settingsAddress 386 - }); 387 - }); 388 - console.log('initTray: tray created successfully'); 389 - } catch (err) { 390 - console.error('initTray: failed to create tray:', err); 391 - return null; 392 - } 393 - } 394 - return _tray; 395 - }; 396 - 397 - // ***** protocol handling 398 - 399 - protocol.registerSchemesAsPrivileged([{ 400 - scheme: APP_SCHEME, 401 - privileges: { 402 - standard: true, 403 - secure: true, 404 - supportFetchAPI: true, 405 - bypassCSP: true, 406 - corsEnabled: true, 407 - allowServiceWorkers: false 408 - } 409 - }]); 410 - 411 - // Extension path cache: extensionId -> filesystem path 412 - const extensionPaths = new Map(); 413 - 414 - // Register a built-in extension path 415 - const registerExtensionPath = (id, fsPath) => { 416 - extensionPaths.set(id, fsPath); 417 - DEBUG && console.log('Registered extension path:', id, fsPath); 418 - }; 419 - 420 - // Get extension filesystem path by ID 421 - // First checks built-in extensions, then datastore for external extensions 422 - const getExtensionPath = (id) => { 423 - // Check built-in extensions first 424 - const builtinPath = extensionPaths.get(id); 425 - if (builtinPath) return builtinPath; 426 - 427 - // Check datastore for external extensions 428 - try { 429 - const db = getDb(); 430 - const ext = db.prepare('SELECT * FROM extensions WHERE id = ?').get(id); 431 - if (ext && ext.path) { 432 - return ext.path; 433 - } 434 - 435 - // Also check by shortname (stored in metadata) 436 - const allExts = db.prepare('SELECT * FROM extensions').all(); 437 - for (const extData of allExts) { 438 - try { 439 - const metadata = JSON.parse(extData.metadata || '{}'); 440 - if (metadata.shortname === id && extData.path) { 441 - return extData.path; 442 - } 443 - } catch (e) { 444 - // Ignore JSON parse errors 445 - } 446 - } 447 - } catch { 448 - // Database not initialized yet 449 - } 450 - 451 - return null; 452 - }; 453 - 454 - /** 455 - * Scan a directory for valid extensions (folders with manifest.json) 456 - * @param {string} basePath - Directory to scan 457 - * @returns {Array<{id: string, path: string, manifest: object}>} 458 - */ 459 - const discoverExtensions = (basePath) => { 460 - const extensions = []; 461 - 462 - if (!fs.existsSync(basePath)) return extensions; 463 - 464 - const entries = fs.readdirSync(basePath, { withFileTypes: true }); 465 - 466 - for (const entry of entries) { 467 - if (!entry.isDirectory()) continue; 468 - 469 - const extPath = path.join(basePath, entry.name); 470 - const manifestPath = path.join(extPath, 'manifest.json'); 471 - 472 - if (!fs.existsSync(manifestPath)) continue; 473 - 474 - try { 475 - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); 476 - 477 - // Use manifest.id or folder name as fallback 478 - const id = manifest.id || manifest.shortname || entry.name; 479 - 480 - extensions.push({ id, path: extPath, manifest }); 481 - 482 - } catch (err) { 483 - console.error(`[ext:discovery] Failed to load ${entry.name}:`, err.message); 484 - } 485 - } 486 - 487 - return extensions; 488 - }; 489 - 490 - // ***** Extension Window Management ***** 491 - // Each extension runs in its own isolated BrowserWindow at peek://ext/{id}/background.html 492 - 493 - const extensionWindows = new Map(); // extId -> { win, manifest, status } 494 - 495 - const createExtensionWindow = async (extId) => { 496 - if (extensionWindows.has(extId)) { 497 - console.log(`[ext:win] Extension ${extId} already has a window`); 498 - return extensionWindows.get(extId).win; 499 - } 500 - 501 - const extPath = getExtensionPath(extId); 502 - if (!extPath) { 503 - console.error(`[ext:win] Extension path not found: ${extId}`); 504 - return null; 505 - } 506 - 507 - // Load manifest and settings schema 508 - let manifest = null; 509 - try { 510 - const manifestPath = path.join(extPath, 'manifest.json'); 511 - if (fs.existsSync(manifestPath)) { 512 - manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); 513 - 514 - // Load settings schema if specified 515 - if (manifest.settingsSchema) { 516 - const schemaPath = path.join(extPath, manifest.settingsSchema); 517 - if (fs.existsSync(schemaPath)) { 518 - const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8')); 519 - // Merge schema fields into manifest for Settings UI 520 - manifest.schemas = { prefs: schema.prefs, item: schema.item }; 521 - manifest.storageKeys = schema.storageKeys; 522 - manifest.defaults = schema.defaults; 523 - } 524 - } 525 - } 526 - } catch (err) { 527 - console.error(`[ext:win] Failed to load manifest for ${extId}:`, err); 528 - } 529 - 530 - console.log(`[ext:win] Creating window for extension: ${extId}`); 531 - 532 - const win = new BrowserWindow({ 533 - show: false, 534 - webPreferences: { 535 - preload: preloadPath 536 - } 537 - }); 538 - 539 - // Forward console logs from extension to main process stdout 540 - win.webContents.on('console-message', (event, level, message, line, sourceId) => { 541 - const levelStr = ['debug', 'info', 'warn', 'error'][level] || 'log'; 542 - console.log(`[ext:${extId}] ${message}`); 543 - }); 544 - 545 - // Track crash events 546 - win.webContents.on('crashed', (event, killed) => { 547 - console.error(`[ext:win] Extension ${extId} crashed (killed: ${killed})`); 548 - const entry = extensionWindows.get(extId); 549 - if (entry) { 550 - entry.status = 'crashed'; 551 - } 552 - // Optionally auto-restart: createExtensionWindow(extId); 553 - }); 554 - 555 - // Track close events 556 - win.on('closed', () => { 557 - console.log(`[ext:win] Extension ${extId} window closed`); 558 - extensionWindows.delete(extId); 559 - }); 560 - 561 - // Store before loading to handle async issues 562 - extensionWindows.set(extId, { win, manifest, status: 'loading' }); 563 - 564 - try { 565 - await win.loadURL(`peek://ext/${extId}/background.html`); 566 - console.log(`[ext:win] Extension ${extId} loaded successfully`); 567 - const entry = extensionWindows.get(extId); 568 - if (entry) { 569 - entry.status = 'running'; 570 - } 571 - return win; 572 - } catch (error) { 573 - console.error(`[ext:win] Failed to load extension ${extId}:`, error); 574 - extensionWindows.delete(extId); 575 - win.destroy(); 576 - return null; 577 - } 578 - }; 579 - 580 - const destroyExtensionWindow = (extId) => { 581 - const entry = extensionWindows.get(extId); 582 - if (!entry) { 583 - console.log(`[ext:win] No window to destroy for: ${extId}`); 584 - return false; 585 - } 586 - 587 - console.log(`[ext:win] Destroying window for: ${extId}`); 588 - 589 - // Notify extension of shutdown before destroying 590 - if (entry.win && !entry.win.isDestroyed()) { 591 - entry.win.webContents.send('pubsub:app:shutdown', {}); 592 - // Give it a moment to clean up, then destroy 593 - setTimeout(() => { 594 - if (!entry.win.isDestroyed()) { 595 - entry.win.destroy(); 596 - } 597 - }, 100); 598 - } 599 - 600 - extensionWindows.delete(extId); 601 - return true; 602 - }; 603 - 604 - const getExtensionWindow = (extId) => { 605 - const entry = extensionWindows.get(extId); 606 - return entry ? entry.win : null; 607 - }; 608 - 609 - const getRunningExtensions = () => { 610 - const running = []; 611 - for (const [extId, entry] of extensionWindows) { 612 - if (entry.status === 'running') { 613 - running.push({ 614 - id: extId, 615 - manifest: entry.manifest, 616 - status: entry.status 617 - }); 618 - } 619 - } 620 - return running; 621 - }; 622 - 623 - // Load enabled extensions on startup 624 - const loadEnabledExtensions = async () => { 625 - // Get all discovered extensions (registered via discoverExtensions) 626 - const builtinExtIds = Array.from(extensionPaths.keys()); 627 - 628 - // Check which are enabled from datastore/localStorage 629 - const db = getDb(); 630 - for (const extId of builtinExtIds) { 631 - // Check if enabled in extension_settings or extensions table 632 - let enabled = true; // Default to enabled for builtins 633 - 634 - // Check extension_settings for enabled state 635 - const setting = db.prepare('SELECT * FROM extension_settings WHERE extensionId = ? AND key = ?').get(extId, 'enabled'); 636 - if (setting) { 637 - try { 638 - enabled = JSON.parse(setting.value) !== false; 639 - } catch (e) { 640 - enabled = true; 641 - } 642 - } 643 - 644 - if (enabled) { 645 - console.log(`[ext:win] Loading enabled extension: ${extId}`); 646 - await createExtensionWindow(extId); 647 - } else { 648 - console.log(`[ext:win] Skipping disabled extension: ${extId}`); 649 - } 650 - } 651 - 652 - // Load external extensions from datastore 653 - const externalExts = db.prepare('SELECT * FROM extensions').all(); 654 - for (const extData of externalExts) { 655 - const extId = extData.id; 656 - // Skip if already loaded (shouldn't happen but be safe) 657 - if (extensionWindows.has(extId)) continue; 658 - 659 - // Skip if not enabled 660 - if (extData.enabled !== 1) { 661 - console.log(`[ext:win] Skipping disabled external extension: ${extId}`); 662 - continue; 663 - } 664 - 665 - // Need a path to load from 666 - if (!extData.path) { 667 - console.log(`[ext:win] Skipping external extension without path: ${extId}`); 668 - continue; 669 - } 670 - 671 - console.log(`[ext:win] Loading enabled external extension: ${extId}`); 672 - await createExtensionWindow(extId); 673 - } 674 - 675 - console.log(`[ext:win] Loaded ${extensionWindows.size} extensions`); 676 - 677 - // Signal that all extensions are loaded (GLOBAL so Settings can receive it) 678 - pubsub.publish('system', scopes.GLOBAL, 'ext:all-loaded', { 679 - count: extensionWindows.size 680 - }); 681 - }; 682 - 683 - // TODO: unhack all this trash fire 684 - const initAppProtocol = () => { 685 - protocol.handle(APP_SCHEME, req => { 686 - //console.log('PROTOCOL', req.url); 687 - 688 - let { host, pathname } = new URL(req.url); 689 - //console.log('host, pathname', host, pathname); 690 - 691 - // trim trailing slash 692 - pathname = pathname.replace(/^\//, ''); 693 - 694 - // Handle extension content: peek://ext/{ext-id}/{path} 695 - if (host === 'ext') { 696 - const parts = pathname.split('/'); 697 - const extId = parts[0]; 698 - const extPath = parts.slice(1).join('/') || 'index.html'; 699 - 700 - const extBasePath = getExtensionPath(extId); 701 - if (!extBasePath) { 702 - DEBUG && console.log('Extension not found:', extId); 703 - return new Response('Extension not found', { status: 404 }); 704 - } 705 - 706 - const absolutePath = path.resolve(extBasePath, extPath); 707 - 708 - // Security: ensure path stays within extension folder 709 - const normalizedBase = path.normalize(extBasePath); 710 - if (!absolutePath.startsWith(normalizedBase)) { 711 - console.error('Path traversal attempt blocked:', absolutePath); 712 - return new Response('Forbidden', { status: 403 }); 713 - } 714 - 715 - const fileURL = pathToFileURL(absolutePath).toString(); 716 - return net.fetch(fileURL); 717 - } 718 - 719 - // Handle extensions infrastructure: peek://extensions/{path} 720 - // This serves the extension loader and other shared extension code 721 - if (host === 'extensions') { 722 - const absolutePath = path.resolve(__dirname, 'extensions', pathname); 723 - 724 - // Security: ensure path stays within extensions folder 725 - const extensionsBase = path.resolve(__dirname, 'extensions'); 726 - if (!absolutePath.startsWith(extensionsBase)) { 727 - console.error('Path traversal attempt blocked:', absolutePath); 728 - return new Response('Forbidden', { status: 403 }); 729 - } 730 - 731 - const fileURL = pathToFileURL(absolutePath).toString(); 732 - return net.fetch(fileURL); 733 - } 734 - 735 - let relativePath = pathname; 736 - 737 - // Ugh, handle node_modules paths 738 - // does this even work in packaged build? 739 - const isNode = pathname.indexOf('node_modules') > -1; 740 - 741 - if (!isNode) { 742 - relativePath = path.join(host, pathname); 743 - } 744 - 745 - const absolutePath = path.resolve(__dirname, relativePath); 746 - //console.log('ABSOLUTE PATH', absolutePath); 747 - 748 - const fileURL = pathToFileURL(absolutePath).toString(); 749 - //console.log('FILE URL', fileURL); 750 - 751 - return net.fetch(fileURL); 752 - }); 753 - }; 754 - 755 308 // ***** init ***** 756 309 757 310 // Electron app load ··· 764 317 app.dock.hide(); 765 318 } 766 319 767 - // Initialize datastore with SQLite persistence 768 - await initDatastore(profileDataPath); 320 + // Initialize backend (database, protocol handler, pubsub broadcaster) 321 + await initialize(); 769 322 770 323 //https://stackoverflow.com/questions/35916158/how-to-prevent-multiple-instances-in-electron 771 324 const gotTheLock = app.requestSingleInstanceLock(); ··· 786 339 } 787 340 }); 788 341 789 - // handle peek:// 790 - initAppProtocol(); 791 - 792 342 // Discover and register built-in extensions from extensions/ folder 793 - const discoveredExtensions = discoverExtensions(path.join(__dirname, 'extensions')); 794 - console.log(`[ext:discovery] Found ${discoveredExtensions.length} extensions:`, discoveredExtensions.map(e => e.id).join(', ')); 795 - 796 - for (const ext of discoveredExtensions) { 797 - registerExtensionPath(ext.id, ext.path); 798 - } 343 + discoverBuiltinExtensions(path.join(__dirname, 'extensions')); 799 344 800 345 // Register as default handler for http/https URLs (if not already and user hasn't declined) 801 346 // Skip for test profiles to avoid system dialogs during automated testing ··· 876 421 // initialize system tray 877 422 if (msg.prefs.showTrayIcon == true) { 878 423 console.log('showing tray'); 879 - initTray(); 424 + initTray(__dirname, { 425 + tooltip: labels.tray.tooltip, 426 + onClick: () => { 427 + pubsub.publish(webCoreAddress, scopes.GLOBAL, 'open', { 428 + address: settingsAddress 429 + }); 430 + } 431 + }); 880 432 } 881 433 882 434 // update quit shortcut if changed (local shortcut - only works when app has focus) ··· 887 439 unregisterLocalShortcut(_quitShortcut); 888 440 } 889 441 console.log('registering new quit shortcut:', newQuitShortcut); 890 - registerLocalShortcut(newQuitShortcut, onQuit); 442 + registerLocalShortcut(newQuitShortcut, 'system', onQuit); 891 443 _quitShortcut = newQuitShortcut; 892 444 } 893 445 ··· 1041 593 // Register default quit shortcut (local - only works when app has focus) 1042 594 // Will be updated when prefs arrive 1043 595 _quitShortcut = strings.defaults.quitShortcut; 1044 - registerLocalShortcut(_quitShortcut, onQuit); 596 + registerLocalShortcut(_quitShortcut, 'system', onQuit); 1045 597 1046 598 // Mark app as ready and process any URLs that arrived during startup 1047 599 _appReady = true; ··· 1086 638 handleExternalUrl(url, 'os'); 1087 639 }); 1088 640 641 + // Configure app before ready (registers protocol scheme, sets theme) 642 + configure({ 643 + rootDir: __dirname, 644 + preloadPath: preloadPath, 645 + userDataPath: defaultUserDataPath, 646 + profile: PROFILE, 647 + isDev: DEBUG, 648 + isTest: PROFILE.startsWith('test') 649 + }); 650 + 1089 651 app.whenReady().then(onReady); 1090 652 1091 653 // ***** API ***** ··· 1106 668 }; 1107 669 1108 670 if (isGlobal) { 1109 - // Global shortcut (works even when app doesn't have focus) 1110 - shortcuts.set(msg.shortcut, msg.source); 1111 - registerShortcut(msg.shortcut, callback); 671 + registerGlobalShortcut(msg.shortcut, msg.source, callback); 1112 672 } else { 1113 - // Local shortcut (only works when app has focus) 1114 - const parsed = parseShortcut(msg.shortcut); 1115 - localShortcuts.set(msg.shortcut, { source: msg.source, parsed, callback }); 673 + registerLocalShortcut(msg.shortcut, msg.source, callback); 1116 674 } 1117 675 }); 1118 676 ··· 1121 679 console.log('ipc unregister shortcut', msg.shortcut, isGlobal ? '(global)' : '(local)'); 1122 680 1123 681 if (isGlobal) { 1124 - unregisterShortcut(msg.shortcut, res => { 1125 - console.log('ipc unregister global shortcut callback result:', res); 1126 - }); 682 + const err = unregisterGlobalShortcut(msg.shortcut); 683 + if (err) { 684 + console.log('ipc unregister global shortcut error:', err.message); 685 + } 1127 686 } else { 1128 687 unregisterLocalShortcut(msg.shortcut); 1129 688 } ··· 2231 1790 }; 2232 1791 2233 1792 // ***** Helpers ***** 2234 - 2235 - const registerShortcut = (shortcut, callback) => { 2236 - console.log('registerShortcut', shortcut); 2237 - 2238 - if (globalShortcut.isRegistered(shortcut)) { 2239 - console.error(strings.shortcuts.errorAlreadyRegistered, shortcut); 2240 - globalShortcut.unregister(shortcut); 2241 - } 2242 - 2243 - const ret = globalShortcut.register(shortcut, () => { 2244 - console.log('shortcut executed', shortcut); 2245 - callback(); 2246 - }); 2247 - 2248 - if (ret !== true) { 2249 - console.error('registerShortcut FAILED:', shortcut); 2250 - return new Error(strings.shortcuts.errorRegistrationFailed); 2251 - } 2252 - }; 2253 - 2254 - const unregisterShortcut = (shortcut, callback) => { 2255 - console.log('unregisterShortcut', shortcut) 2256 - 2257 - if (!globalShortcut.isRegistered(shortcut)) { 2258 - console.error('Unable to unregister shortcut because not registered or it is not us', shortcut); 2259 - return new Error("Shortcut not registered: " + shortcut); 2260 - } 2261 - 2262 - globalShortcut.unregister(shortcut, () => { 2263 - console.log('shortcut unregistered', shortcut); 2264 - 2265 - // delete from cache 2266 - shortcuts.delete(shortcut); 2267 - callback(); 2268 - }); 2269 - }; 2270 - 2271 - // unregister any shortcuts this address registered 2272 - // and delete entry from cache 2273 - const unregisterShortcutsForAddress = (aAddress) => { 2274 - for (const [shortcut, address] of shortcuts) { 2275 - if (address == aAddress) { 2276 - console.log('unregistering global shortcut', shortcut, 'for', address); 2277 - unregisterShortcut(shortcut); 2278 - } 2279 - } 2280 - // Also unregister local shortcuts for this address 2281 - for (const [shortcut, data] of localShortcuts) { 2282 - if (data.source === aAddress) { 2283 - console.log('unregistering local shortcut', shortcut, 'for', aAddress); 2284 - localShortcuts.delete(shortcut); 2285 - } 2286 - } 2287 - }; 2288 - 2289 - // Map key names to physical key codes (for before-input-event matching) 2290 - // Electron's input.code follows the USB HID spec 2291 - const keyToCode = { 2292 - // Letters 2293 - 'a': 'KeyA', 'b': 'KeyB', 'c': 'KeyC', 'd': 'KeyD', 'e': 'KeyE', 2294 - 'f': 'KeyF', 'g': 'KeyG', 'h': 'KeyH', 'i': 'KeyI', 'j': 'KeyJ', 2295 - 'k': 'KeyK', 'l': 'KeyL', 'm': 'KeyM', 'n': 'KeyN', 'o': 'KeyO', 2296 - 'p': 'KeyP', 'q': 'KeyQ', 'r': 'KeyR', 's': 'KeyS', 't': 'KeyT', 2297 - 'u': 'KeyU', 'v': 'KeyV', 'w': 'KeyW', 'x': 'KeyX', 'y': 'KeyY', 2298 - 'z': 'KeyZ', 2299 - // Numbers 2300 - '0': 'Digit0', '1': 'Digit1', '2': 'Digit2', '3': 'Digit3', '4': 'Digit4', 2301 - '5': 'Digit5', '6': 'Digit6', '7': 'Digit7', '8': 'Digit8', '9': 'Digit9', 2302 - // Punctuation 2303 - ',': 'Comma', '.': 'Period', '/': 'Slash', ';': 'Semicolon', "'": 'Quote', 2304 - '[': 'BracketLeft', ']': 'BracketRight', '\\': 'Backslash', '`': 'Backquote', 2305 - '-': 'Minus', '=': 'Equal', 2306 - // Special keys 2307 - 'enter': 'Enter', 'return': 'Enter', 2308 - 'tab': 'Tab', 2309 - 'space': 'Space', ' ': 'Space', 2310 - 'backspace': 'Backspace', 2311 - 'delete': 'Delete', 2312 - 'escape': 'Escape', 'esc': 'Escape', 2313 - 'up': 'ArrowUp', 'down': 'ArrowDown', 'left': 'ArrowLeft', 'right': 'ArrowRight', 2314 - 'arrowup': 'ArrowUp', 'arrowdown': 'ArrowDown', 'arrowleft': 'ArrowLeft', 'arrowright': 'ArrowRight', 2315 - 'home': 'Home', 'end': 'End', 2316 - 'pageup': 'PageUp', 'pagedown': 'PageDown', 2317 - // Function keys 2318 - 'f1': 'F1', 'f2': 'F2', 'f3': 'F3', 'f4': 'F4', 'f5': 'F5', 'f6': 'F6', 2319 - 'f7': 'F7', 'f8': 'F8', 'f9': 'F9', 'f10': 'F10', 'f11': 'F11', 'f12': 'F12', 2320 - }; 2321 - 2322 - // Parse shortcut string to match Electron's input event format 2323 - // e.g., 'Alt+Q' -> { alt: true, code: 'KeyQ' } 2324 - // e.g., 'CommandOrControl+Shift+P' -> { meta: true, shift: true, code: 'KeyP' } (on Mac) 2325 - const parseShortcut = (shortcut) => { 2326 - const parts = shortcut.toLowerCase().split('+'); 2327 - const result = { 2328 - ctrl: false, 2329 - alt: false, 2330 - shift: false, 2331 - meta: false, 2332 - code: '' 2333 - }; 2334 - 2335 - for (const part of parts) { 2336 - const p = part.trim(); 2337 - if (p === 'ctrl' || p === 'control') { 2338 - result.ctrl = true; 2339 - } else if (p === 'alt' || p === 'option') { 2340 - result.alt = true; 2341 - } else if (p === 'shift') { 2342 - result.shift = true; 2343 - } else if (p === 'meta' || p === 'cmd' || p === 'command' || p === 'super') { 2344 - result.meta = true; 2345 - } else if (p === 'commandorcontrol' || p === 'cmdorctrl') { 2346 - // On Mac, use meta (Cmd), on others use ctrl 2347 - if (process.platform === 'darwin') { 2348 - result.meta = true; 2349 - } else { 2350 - result.ctrl = true; 2351 - } 2352 - } else { 2353 - // This is the key itself - convert to code 2354 - result.code = keyToCode[p] || p; 2355 - } 2356 - } 2357 - 2358 - return result; 2359 - }; 2360 - 2361 - // Check if an input event matches a parsed shortcut 2362 - const inputMatchesShortcut = (input, parsed) => { 2363 - // Check modifiers 2364 - if (input.alt !== parsed.alt) return false; 2365 - if (input.shift !== parsed.shift) return false; 2366 - if (input.meta !== parsed.meta) return false; 2367 - if (input.control !== parsed.ctrl) return false; 2368 - 2369 - // Check physical key code (case-insensitive comparison) 2370 - return input.code.toLowerCase() === parsed.code.toLowerCase(); 2371 - }; 2372 - 2373 - // Register a local (app-only) shortcut 2374 - const registerLocalShortcut = (shortcut, callback) => { 2375 - console.log('registerLocalShortcut', shortcut); 2376 - 2377 - if (localShortcuts.has(shortcut)) { 2378 - console.log('local shortcut already registered, replacing:', shortcut); 2379 - } 2380 - 2381 - const parsed = parseShortcut(shortcut); 2382 - localShortcuts.set(shortcut, { parsed, callback }); 2383 - }; 2384 - 2385 - // Unregister a local shortcut 2386 - const unregisterLocalShortcut = (shortcut) => { 2387 - console.log('unregisterLocalShortcut', shortcut); 2388 - 2389 - if (!localShortcuts.has(shortcut)) { 2390 - console.error('local shortcut not registered:', shortcut); 2391 - return; 2392 - } 2393 - 2394 - localShortcuts.delete(shortcut); 2395 - }; 2396 - 2397 - // Handle local shortcuts from any focused window 2398 - // Called from before-input-event handler 2399 - const handleLocalShortcut = (input) => { 2400 - // Only handle keyDown events 2401 - if (input.type !== 'keyDown') return false; 2402 - 2403 - for (const [shortcut, data] of localShortcuts) { 2404 - if (inputMatchesShortcut(input, data.parsed)) { 2405 - data.callback(); 2406 - return true; 2407 - } 2408 - } 2409 - return false; 2410 - }; 2411 1793 2412 1794 // Ask renderer to handle escape, returns Promise<{ handled: boolean }> 2413 1795 const askRendererToHandleEscape = (bw) => {