experiments in a post-browser web
10
fork

Configure Feed

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

more delectronifying

+2967 -1843
+34
backend/config.ts
··· 1 + /** 2 + * Shared Backend Configuration 3 + * 4 + * Constants shared across all backend implementations (Electron, Tauri, etc.) 5 + */ 6 + 7 + // Default window dimensions 8 + export const APP_DEF_WIDTH = 1024; 9 + export const APP_DEF_HEIGHT = 768; 10 + 11 + // Core application addresses (peek:// protocol URLs) 12 + export const WEB_CORE_ADDRESS = 'peek://app/background.html'; 13 + export const SETTINGS_ADDRESS = 'peek://app/settings/settings.html'; 14 + 15 + // IPC message channels 16 + export const IPC_CHANNELS = { 17 + REGISTER_SHORTCUT: 'registershortcut', 18 + UNREGISTER_SHORTCUT: 'unregistershortcut', 19 + PUBLISH: 'publish', 20 + SUBSCRIBE: 'subscribe', 21 + CLOSE_WINDOW: 'closewindow', 22 + CONSOLE: 'console', 23 + RENDERER_LOG: 'renderer-log', 24 + APP_QUIT: 'app-quit', 25 + MODIFY_WINDOW: 'modifywindow', 26 + GET_REGISTERED_COMMANDS: 'get-registered-commands', 27 + } as const; 28 + 29 + // PubSub topics 30 + export const TOPICS = { 31 + PREFS: 'topic:core:prefs', 32 + CMD_REGISTER: 'cmd:register', 33 + CMD_UNREGISTER: 'cmd:unregister', 34 + } as const;
+62
backend/electron/config.ts
··· 1 + /** 2 + * Electron-Specific Runtime Configuration 3 + * 4 + * Runtime configuration and state for the Electron main process. 5 + * For shared constants, see backend/config.ts 6 + */ 7 + 8 + // Re-export shared constants for convenience 9 + export { 10 + APP_DEF_WIDTH, 11 + APP_DEF_HEIGHT, 12 + WEB_CORE_ADDRESS, 13 + SETTINGS_ADDRESS, 14 + IPC_CHANNELS, 15 + TOPICS, 16 + } from '../config.js'; 17 + 18 + // Runtime configuration (set during app initialization) 19 + let _preloadPath: string = ''; 20 + let _profile: string = ''; 21 + 22 + /** 23 + * Set runtime paths (called during app initialization) 24 + */ 25 + export function setPreloadPath(preloadPath: string): void { 26 + _preloadPath = preloadPath; 27 + } 28 + 29 + /** 30 + * Set profile (called during app initialization) 31 + */ 32 + export function setProfile(profile: string): void { 33 + _profile = profile; 34 + } 35 + 36 + /** 37 + * Get the preload script path 38 + */ 39 + export function getPreloadPath(): string { 40 + return _preloadPath; 41 + } 42 + 43 + /** 44 + * Get the current profile name 45 + */ 46 + export function getProfile(): string { 47 + return _profile; 48 + } 49 + 50 + /** 51 + * Check if running in test profile 52 + */ 53 + export function isTestProfile(): boolean { 54 + return _profile.startsWith('test'); 55 + } 56 + 57 + /** 58 + * Check if running in dev profile 59 + */ 60 + export function isDevProfile(): boolean { 61 + return _profile === 'dev'; 62 + }
+152
backend/electron/extensions.ts
··· 1 + /** 2 + * Extension discovery and manifest management 3 + * 4 + * Handles: 5 + * - Discovering extensions from filesystem 6 + * - Loading and parsing manifest files 7 + * - Checking extension enabled state 8 + */ 9 + 10 + import fs from 'node:fs'; 11 + import path from 'node:path'; 12 + import { getDb } from './datastore.js'; 13 + 14 + export interface ExtensionManifest { 15 + id?: string; 16 + shortname?: string; 17 + name?: string; 18 + description?: string; 19 + version?: string; 20 + background?: string; 21 + settingsSchema?: string; 22 + schemas?: { 23 + prefs?: unknown; 24 + item?: unknown; 25 + }; 26 + storageKeys?: Record<string, string>; 27 + defaults?: Record<string, unknown>; 28 + [key: string]: unknown; 29 + } 30 + 31 + export interface DiscoveredExtension { 32 + id: string; 33 + path: string; 34 + manifest: ExtensionManifest; 35 + } 36 + 37 + /** 38 + * Discover extensions in a directory 39 + * Scans for subdirectories containing manifest.json 40 + */ 41 + export function discoverExtensions(basePath: string): DiscoveredExtension[] { 42 + const extensions: DiscoveredExtension[] = []; 43 + 44 + if (!fs.existsSync(basePath)) return extensions; 45 + 46 + const entries = fs.readdirSync(basePath, { withFileTypes: true }); 47 + 48 + for (const entry of entries) { 49 + if (!entry.isDirectory()) continue; 50 + 51 + const extPath = path.join(basePath, entry.name); 52 + const manifestPath = path.join(extPath, 'manifest.json'); 53 + 54 + if (!fs.existsSync(manifestPath)) continue; 55 + 56 + try { 57 + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as ExtensionManifest; 58 + 59 + // Use manifest.id or folder name as fallback 60 + const id = manifest.id || manifest.shortname || entry.name; 61 + 62 + extensions.push({ id, path: extPath, manifest }); 63 + 64 + } catch (err) { 65 + const message = err instanceof Error ? err.message : String(err); 66 + console.error(`[ext:discovery] Failed to load ${entry.name}:`, message); 67 + } 68 + } 69 + 70 + return extensions; 71 + } 72 + 73 + /** 74 + * Load extension manifest with settings schema 75 + * Returns null if manifest doesn't exist or is invalid 76 + */ 77 + export function loadExtensionManifest(extPath: string): ExtensionManifest | null { 78 + try { 79 + const manifestPath = path.join(extPath, 'manifest.json'); 80 + if (!fs.existsSync(manifestPath)) return null; 81 + 82 + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as ExtensionManifest; 83 + 84 + // Load settings schema if specified 85 + if (manifest.settingsSchema) { 86 + const schemaPath = path.join(extPath, manifest.settingsSchema); 87 + if (fs.existsSync(schemaPath)) { 88 + const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8')); 89 + // Merge schema fields into manifest for Settings UI 90 + manifest.schemas = { prefs: schema.prefs, item: schema.item }; 91 + manifest.storageKeys = schema.storageKeys; 92 + manifest.defaults = schema.defaults; 93 + } 94 + } 95 + 96 + return manifest; 97 + } catch (err) { 98 + const message = err instanceof Error ? err.message : String(err); 99 + console.error(`[ext:manifest] Failed to load manifest from ${extPath}:`, message); 100 + return null; 101 + } 102 + } 103 + 104 + /** 105 + * Check if a built-in extension is enabled 106 + * Defaults to true for built-in extensions 107 + */ 108 + export function isBuiltinExtensionEnabled(extId: string): boolean { 109 + try { 110 + const db = getDb(); 111 + const setting = db.prepare( 112 + 'SELECT * FROM extension_settings WHERE extensionId = ? AND key = ?' 113 + ).get(extId, 'enabled') as { value?: string } | undefined; 114 + 115 + if (setting) { 116 + try { 117 + return JSON.parse(setting.value || 'true') !== false; 118 + } catch { 119 + return true; 120 + } 121 + } 122 + return true; // Default to enabled for builtins 123 + } catch { 124 + return true; // Database not ready, default to enabled 125 + } 126 + } 127 + 128 + /** 129 + * Get all external extensions from datastore 130 + */ 131 + export function getExternalExtensions(): Array<{ 132 + id: string; 133 + path: string | null; 134 + enabled: boolean; 135 + }> { 136 + try { 137 + const db = getDb(); 138 + const exts = db.prepare('SELECT * FROM extensions').all() as Array<{ 139 + id: string; 140 + path?: string; 141 + enabled?: number; 142 + }>; 143 + 144 + return exts.map(ext => ({ 145 + id: ext.id, 146 + path: ext.path || null, 147 + enabled: ext.enabled === 1 148 + })); 149 + } catch { 150 + return []; 151 + } 152 + }
+58
backend/electron/index.ts
··· 4 4 * Exports database functions and types for the Electron main process. 5 5 */ 6 6 7 + // Configuration 8 + export { 9 + APP_DEF_WIDTH, 10 + APP_DEF_HEIGHT, 11 + WEB_CORE_ADDRESS, 12 + SETTINGS_ADDRESS, 13 + IPC_CHANNELS, 14 + TOPICS, 15 + setPreloadPath, 16 + setProfile, 17 + getPreloadPath, 18 + getProfile, 19 + isTestProfile, 20 + isDevProfile, 21 + } from './config.js'; 22 + 7 23 // Database lifecycle and helpers 8 24 export { 9 25 initDatabase, ··· 140 156 registerWindow, 141 157 getWindowInfo, 142 158 findWindowByKey, 159 + removeWindow, 160 + getChildWindows, 161 + getAllWindows, 143 162 shutdown, 163 + // External URL handling 164 + handleExternalUrl, 165 + setAppReady, 166 + registerExternalUrlHandlers, 167 + registerSecondInstanceHandler, 168 + handleCliUrl, 169 + // Background window 170 + createBackgroundWindow, 171 + getBackgroundWindow, 172 + // App lifecycle 173 + registerWindowAllClosedHandler, 174 + registerActivateHandler, 175 + requestSingleInstance, 176 + quitApp, 144 177 } from './main.js'; 145 178 146 179 export type { AppConfig } from './main.js'; 180 + 181 + // IPC handlers 182 + export { 183 + registerDatastoreHandlers, 184 + registerExtensionHandlers, 185 + registerDarkModeHandlers, 186 + registerWindowHandlers, 187 + registerMiscHandlers, 188 + registerAllHandlers, 189 + getRegisteredCommands, 190 + } from './ipc.js'; 191 + 192 + // Window helpers 193 + export { 194 + setPrefsGetter, 195 + modWindow, 196 + addEscHandler, 197 + winDevtoolsConfig, 198 + closeWindow, 199 + getVisibleWindowCount, 200 + updateDockVisibility, 201 + maybeHideApp, 202 + closeOrHideWindow, 203 + closeChildWindows, 204 + } from './windows.js'; 147 205 148 206 // Re-export frontend API types (the contract that preload.js implements) 149 207 export type {
+1162
backend/electron/ipc.ts
··· 1 + /** 2 + * IPC Handler Registration 3 + * 4 + * Centralizes all IPC handlers for the main process. 5 + * Handlers are thin wrappers that delegate to backend functions. 6 + */ 7 + 8 + import { ipcMain, nativeTheme, dialog, BrowserWindow } from 'electron'; 9 + import fs from 'node:fs'; 10 + import path from 'node:path'; 11 + 12 + import { 13 + // Datastore operations 14 + addAddress, 15 + getAddress, 16 + updateAddress, 17 + queryAddresses, 18 + addVisit, 19 + queryVisits, 20 + addContent, 21 + queryContent, 22 + getOrCreateTag, 23 + tagAddress, 24 + untagAddress, 25 + getTagsByFrecency, 26 + getAddressTags, 27 + getAddressesByTag, 28 + getUntaggedAddresses, 29 + getTable, 30 + setRow, 31 + getStats, 32 + isValidTable, 33 + getDb, 34 + } from './datastore.js'; 35 + 36 + import { 37 + loadExtensionManifest, 38 + } from './extensions.js'; 39 + 40 + import { 41 + getExtensionPath, 42 + } from './protocol.js'; 43 + 44 + import { 45 + createExtensionWindow, 46 + destroyExtensionWindow, 47 + getExtensionWindow, 48 + getRunningExtensions, 49 + registerWindow, 50 + getWindowInfo, 51 + removeWindow, 52 + findWindowByKey, 53 + getAllWindows, 54 + } from './main.js'; 55 + 56 + import { 57 + APP_DEF_WIDTH, 58 + APP_DEF_HEIGHT, 59 + getPreloadPath, 60 + IPC_CHANNELS, 61 + TOPICS, 62 + } from './config.js'; 63 + 64 + import { 65 + addEscHandler, 66 + winDevtoolsConfig, 67 + closeOrHideWindow, 68 + updateDockVisibility, 69 + closeWindow, 70 + modWindow, 71 + } from './windows.js'; 72 + 73 + import { 74 + registerGlobalShortcut, 75 + unregisterGlobalShortcut, 76 + registerLocalShortcut, 77 + unregisterLocalShortcut, 78 + } from './shortcuts.js'; 79 + 80 + import { 81 + publish, 82 + subscribe, 83 + } from './pubsub.js'; 84 + 85 + // Command registry - stores commands registered via cmd:register topic 86 + const commandRegistry = new Map<string, { name: string; description: string; source: string }>(); 87 + 88 + /** 89 + * Get all registered commands 90 + */ 91 + export function getRegisteredCommands(): Array<{ name: string; description: string; source: string }> { 92 + return Array.from(commandRegistry.values()); 93 + } 94 + 95 + /** 96 + * Register a command in the registry 97 + */ 98 + export function registerCommand(name: string, description: string, source: string): void { 99 + commandRegistry.set(name, { name, description, source }); 100 + } 101 + 102 + /** 103 + * Unregister a command from the registry 104 + */ 105 + export function unregisterCommand(name: string): void { 106 + commandRegistry.delete(name); 107 + } 108 + 109 + /** 110 + * Register datastore IPC handlers 111 + */ 112 + export function registerDatastoreHandlers(): void { 113 + ipcMain.handle('datastore-add-address', async (ev, data) => { 114 + try { 115 + const result = addAddress(data.uri, data.options); 116 + return { success: true, data: result }; 117 + } catch (error) { 118 + const message = error instanceof Error ? error.message : String(error); 119 + return { success: false, error: message }; 120 + } 121 + }); 122 + 123 + ipcMain.handle('datastore-get-address', async (ev, data) => { 124 + try { 125 + const result = getAddress(data.id); 126 + return { success: true, data: result }; 127 + } catch (error) { 128 + const message = error instanceof Error ? error.message : String(error); 129 + return { success: false, error: message }; 130 + } 131 + }); 132 + 133 + ipcMain.handle('datastore-update-address', async (ev, data) => { 134 + try { 135 + const result = updateAddress(data.id, data.updates); 136 + return { success: true, data: result }; 137 + } catch (error) { 138 + const message = error instanceof Error ? error.message : String(error); 139 + return { success: false, error: message }; 140 + } 141 + }); 142 + 143 + ipcMain.handle('datastore-query-addresses', async (ev, data) => { 144 + try { 145 + const result = queryAddresses(data.filter); 146 + return { success: true, data: result }; 147 + } catch (error) { 148 + const message = error instanceof Error ? error.message : String(error); 149 + return { success: false, error: message }; 150 + } 151 + }); 152 + 153 + ipcMain.handle('datastore-add-visit', async (ev, data) => { 154 + try { 155 + const result = addVisit(data.addressId, data.options); 156 + return { success: true, data: result }; 157 + } catch (error) { 158 + const message = error instanceof Error ? error.message : String(error); 159 + return { success: false, error: message }; 160 + } 161 + }); 162 + 163 + ipcMain.handle('datastore-query-visits', async (ev, data) => { 164 + try { 165 + const result = queryVisits(data.filter); 166 + return { success: true, data: result }; 167 + } catch (error) { 168 + const message = error instanceof Error ? error.message : String(error); 169 + return { success: false, error: message }; 170 + } 171 + }); 172 + 173 + ipcMain.handle('datastore-add-content', async (ev, data) => { 174 + try { 175 + const result = addContent(data.options); 176 + return { success: true, data: result }; 177 + } catch (error) { 178 + const message = error instanceof Error ? error.message : String(error); 179 + return { success: false, error: message }; 180 + } 181 + }); 182 + 183 + ipcMain.handle('datastore-query-content', async (ev, data) => { 184 + try { 185 + const result = queryContent(data.filter); 186 + return { success: true, data: result }; 187 + } catch (error) { 188 + const message = error instanceof Error ? error.message : String(error); 189 + return { success: false, error: message }; 190 + } 191 + }); 192 + 193 + ipcMain.handle('datastore-get-table', async (ev, data) => { 194 + try { 195 + if (!isValidTable(data.table)) { 196 + return { success: false, error: `Invalid table: ${data.table}` }; 197 + } 198 + const result = getTable(data.table); 199 + return { success: true, data: result }; 200 + } catch (error) { 201 + const message = error instanceof Error ? error.message : String(error); 202 + return { success: false, error: message }; 203 + } 204 + }); 205 + 206 + ipcMain.handle('datastore-set-row', async (ev, data) => { 207 + try { 208 + if (!isValidTable(data.table)) { 209 + return { success: false, error: `Invalid table: ${data.table}` }; 210 + } 211 + const result = setRow(data.table, data.id, data.row); 212 + return { success: true, data: result }; 213 + } catch (error) { 214 + const message = error instanceof Error ? error.message : String(error); 215 + return { success: false, error: message }; 216 + } 217 + }); 218 + 219 + ipcMain.handle('datastore-get-stats', async () => { 220 + try { 221 + const result = getStats(); 222 + return { success: true, data: result }; 223 + } catch (error) { 224 + const message = error instanceof Error ? error.message : String(error); 225 + return { success: false, error: message }; 226 + } 227 + }); 228 + 229 + // Tag operations 230 + ipcMain.handle('datastore-get-or-create-tag', async (ev, data) => { 231 + try { 232 + const result = getOrCreateTag(data.name); 233 + return { success: true, data: result }; 234 + } catch (error) { 235 + const message = error instanceof Error ? error.message : String(error); 236 + return { success: false, error: message }; 237 + } 238 + }); 239 + 240 + ipcMain.handle('datastore-tag-address', async (ev, data) => { 241 + try { 242 + const result = tagAddress(data.addressId, data.tagId); 243 + return { success: true, data: result }; 244 + } catch (error) { 245 + const message = error instanceof Error ? error.message : String(error); 246 + return { success: false, error: message }; 247 + } 248 + }); 249 + 250 + ipcMain.handle('datastore-untag-address', async (ev, data) => { 251 + try { 252 + const result = untagAddress(data.addressId, data.tagId); 253 + return { success: true, data: result }; 254 + } catch (error) { 255 + const message = error instanceof Error ? error.message : String(error); 256 + return { success: false, error: message }; 257 + } 258 + }); 259 + 260 + ipcMain.handle('datastore-get-tags-by-frecency', async (ev, data = {}) => { 261 + try { 262 + const result = getTagsByFrecency(data.limit); 263 + return { success: true, data: result }; 264 + } catch (error) { 265 + const message = error instanceof Error ? error.message : String(error); 266 + return { success: false, error: message }; 267 + } 268 + }); 269 + 270 + ipcMain.handle('datastore-get-address-tags', async (ev, data) => { 271 + try { 272 + const result = getAddressTags(data.addressId); 273 + return { success: true, data: result }; 274 + } catch (error) { 275 + const message = error instanceof Error ? error.message : String(error); 276 + return { success: false, error: message }; 277 + } 278 + }); 279 + 280 + ipcMain.handle('datastore-get-addresses-by-tag', async (ev, data) => { 281 + try { 282 + const result = getAddressesByTag(data.tagId); 283 + return { success: true, data: result }; 284 + } catch (error) { 285 + const message = error instanceof Error ? error.message : String(error); 286 + return { success: false, error: message }; 287 + } 288 + }); 289 + 290 + ipcMain.handle('datastore-get-untagged-addresses', async (ev, data) => { 291 + try { 292 + const result = getUntaggedAddresses(); 293 + return { success: true, data: result }; 294 + } catch (error) { 295 + const message = error instanceof Error ? error.message : String(error); 296 + return { success: false, error: message }; 297 + } 298 + }); 299 + } 300 + 301 + /** 302 + * Register extension IPC handlers 303 + */ 304 + export function registerExtensionHandlers(): void { 305 + ipcMain.handle('extension-pick-folder', async (ev) => { 306 + try { 307 + const result = await dialog.showOpenDialog({ 308 + properties: ['openDirectory'] 309 + }); 310 + if (result.canceled || result.filePaths.length === 0) { 311 + return { success: false, error: 'No folder selected' }; 312 + } 313 + return { success: true, data: { path: result.filePaths[0] } }; 314 + } catch (error) { 315 + const message = error instanceof Error ? error.message : String(error); 316 + return { success: false, error: message }; 317 + } 318 + }); 319 + 320 + ipcMain.handle('extension-validate-folder', async (ev, data) => { 321 + try { 322 + const extPath = data.path; 323 + const manifestPath = path.join(extPath, 'manifest.json'); 324 + 325 + if (!fs.existsSync(manifestPath)) { 326 + return { success: false, error: 'No manifest.json found in folder' }; 327 + } 328 + 329 + const manifestContent = fs.readFileSync(manifestPath, 'utf-8'); 330 + const manifest = JSON.parse(manifestContent); 331 + 332 + if (!manifest.id && !manifest.shortname && !manifest.name) { 333 + return { success: false, error: 'Manifest must have id, shortname, or name' }; 334 + } 335 + 336 + // Check for background.html 337 + const backgroundPath = path.join(extPath, 'background.html'); 338 + if (!fs.existsSync(backgroundPath)) { 339 + return { success: false, error: 'No background.html found in folder' }; 340 + } 341 + 342 + return { 343 + success: true, 344 + data: { 345 + manifest, 346 + path: extPath 347 + } 348 + }; 349 + } catch (error) { 350 + const message = error instanceof Error ? error.message : String(error); 351 + return { success: false, error: message }; 352 + } 353 + }); 354 + 355 + ipcMain.handle('extension-add', async (ev, data) => { 356 + try { 357 + const db = getDb(); 358 + const extPath = data.path; 359 + const manifestPath = path.join(extPath, 'manifest.json'); 360 + 361 + if (!fs.existsSync(manifestPath)) { 362 + return { success: false, error: 'No manifest.json found' }; 363 + } 364 + 365 + const manifestContent = fs.readFileSync(manifestPath, 'utf-8'); 366 + const manifest = JSON.parse(manifestContent); 367 + const id = manifest.id || manifest.shortname || manifest.name || `ext_${Date.now()}`; 368 + 369 + // Check if already exists 370 + const existing = db.prepare('SELECT * FROM extensions WHERE id = ?').get(id); 371 + if (existing) { 372 + return { success: false, error: `Extension ${id} already installed` }; 373 + } 374 + 375 + // Insert into database 376 + db.prepare(` 377 + INSERT INTO extensions (id, name, description, version, path, enabled, builtin, status, installedAt, updatedAt, metadata) 378 + VALUES (?, ?, ?, ?, ?, 1, 0, 'installed', ?, ?, ?) 379 + `).run( 380 + id, 381 + manifest.name || id, 382 + manifest.description || '', 383 + manifest.version || '0.0.0', 384 + extPath, 385 + Date.now(), 386 + Date.now(), 387 + JSON.stringify(manifest) 388 + ); 389 + 390 + return { success: true, data: { id, manifest, path: extPath } }; 391 + } catch (error) { 392 + const message = error instanceof Error ? error.message : String(error); 393 + return { success: false, error: message }; 394 + } 395 + }); 396 + 397 + ipcMain.handle('extension-remove', async (ev, data) => { 398 + try { 399 + const db = getDb(); 400 + const extId = data.id; 401 + 402 + // Check if exists 403 + const existing = db.prepare('SELECT * FROM extensions WHERE id = ?').get(extId); 404 + if (!existing) { 405 + return { success: false, error: `Extension ${extId} not found` }; 406 + } 407 + 408 + // Unload if running 409 + destroyExtensionWindow(extId); 410 + 411 + // Remove from database 412 + db.prepare('DELETE FROM extensions WHERE id = ?').run(extId); 413 + db.prepare('DELETE FROM extension_settings WHERE extensionId = ?').run(extId); 414 + 415 + return { success: true, data: { id: extId } }; 416 + } catch (error) { 417 + const message = error instanceof Error ? error.message : String(error); 418 + return { success: false, error: message }; 419 + } 420 + }); 421 + 422 + ipcMain.handle('extension-update', async (ev, data) => { 423 + try { 424 + const db = getDb(); 425 + const extId = data.id; 426 + const updates = data.updates || {}; 427 + 428 + const existing = db.prepare('SELECT * FROM extensions WHERE id = ?').get(extId); 429 + if (!existing) { 430 + return { success: false, error: `Extension ${extId} not found` }; 431 + } 432 + 433 + const fields: string[] = []; 434 + const values: unknown[] = []; 435 + 436 + if (updates.enabled !== undefined) { 437 + fields.push('enabled = ?'); 438 + values.push(updates.enabled ? 1 : 0); 439 + } 440 + if (updates.status !== undefined) { 441 + fields.push('status = ?'); 442 + values.push(updates.status); 443 + } 444 + 445 + if (fields.length > 0) { 446 + fields.push('updatedAt = ?'); 447 + values.push(Date.now()); 448 + values.push(extId); 449 + db.prepare(`UPDATE extensions SET ${fields.join(', ')} WHERE id = ?`).run(...values); 450 + } 451 + 452 + const updated = db.prepare('SELECT * FROM extensions WHERE id = ?').get(extId); 453 + return { success: true, data: updated }; 454 + } catch (error) { 455 + const message = error instanceof Error ? error.message : String(error); 456 + return { success: false, error: message }; 457 + } 458 + }); 459 + 460 + ipcMain.handle('extension-get-all', async () => { 461 + try { 462 + const db = getDb(); 463 + const extensions = db.prepare('SELECT * FROM extensions').all(); 464 + return { success: true, data: extensions }; 465 + } catch (error) { 466 + const message = error instanceof Error ? error.message : String(error); 467 + return { success: false, error: message }; 468 + } 469 + }); 470 + 471 + ipcMain.handle('extension-get', async (ev, data) => { 472 + try { 473 + const db = getDb(); 474 + const ext = db.prepare('SELECT * FROM extensions WHERE id = ?').get(data.id); 475 + if (!ext) { 476 + return { success: false, error: `Extension ${data.id} not found` }; 477 + } 478 + return { success: true, data: ext }; 479 + } catch (error) { 480 + const message = error instanceof Error ? error.message : String(error); 481 + return { success: false, error: message }; 482 + } 483 + }); 484 + 485 + ipcMain.handle('extension-window-load', async (ev, data) => { 486 + try { 487 + const extId = data.id; 488 + const win = await createExtensionWindow(extId); 489 + if (!win) { 490 + return { success: false, error: `Failed to load extension ${extId}` }; 491 + } 492 + return { success: true, data: { id: extId, windowId: win.id } }; 493 + } catch (error) { 494 + const message = error instanceof Error ? error.message : String(error); 495 + return { success: false, error: message }; 496 + } 497 + }); 498 + 499 + ipcMain.handle('extension-window-unload', async (ev, data) => { 500 + try { 501 + const extId = data.id; 502 + const result = destroyExtensionWindow(extId); 503 + return { success: result, data: { id: extId } }; 504 + } catch (error) { 505 + const message = error instanceof Error ? error.message : String(error); 506 + return { success: false, error: message }; 507 + } 508 + }); 509 + 510 + ipcMain.handle('extension-window-reload', async (ev, data) => { 511 + try { 512 + const extId = data.id; 513 + destroyExtensionWindow(extId); 514 + // Small delay before reload 515 + await new Promise(resolve => setTimeout(resolve, 100)); 516 + const win = await createExtensionWindow(extId); 517 + if (!win) { 518 + return { success: false, error: `Failed to reload extension ${extId}` }; 519 + } 520 + return { success: true, data: { id: extId, windowId: win.id } }; 521 + } catch (error) { 522 + const message = error instanceof Error ? error.message : String(error); 523 + return { success: false, error: message }; 524 + } 525 + }); 526 + 527 + ipcMain.handle('extension-window-list', async () => { 528 + try { 529 + const running = getRunningExtensions(); 530 + return { success: true, data: running }; 531 + } catch (error) { 532 + const message = error instanceof Error ? error.message : String(error); 533 + return { success: false, error: message }; 534 + } 535 + }); 536 + 537 + // Extension settings handlers 538 + ipcMain.handle('extension-settings-get', async (ev, data) => { 539 + try { 540 + const db = getDb(); 541 + const settings = db.prepare( 542 + 'SELECT * FROM extension_settings WHERE extensionId = ?' 543 + ).all(data.id) as Array<{ key: string; value: string }>; 544 + 545 + const result: Record<string, unknown> = {}; 546 + for (const s of settings) { 547 + try { 548 + result[s.key] = JSON.parse(s.value); 549 + } catch { 550 + result[s.key] = s.value; 551 + } 552 + } 553 + return { success: true, data: result }; 554 + } catch (error) { 555 + const message = error instanceof Error ? error.message : String(error); 556 + return { success: false, error: message }; 557 + } 558 + }); 559 + 560 + ipcMain.handle('extension-settings-set', async (ev, data) => { 561 + try { 562 + const db = getDb(); 563 + const extId = data.id; 564 + const settings = data.settings || {}; 565 + 566 + for (const [key, value] of Object.entries(settings)) { 567 + const jsonValue = JSON.stringify(value); 568 + db.prepare(` 569 + INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 570 + VALUES (?, ?, ?, ?, ?) 571 + `).run(`${extId}_${key}`, extId, key, jsonValue, Date.now()); 572 + } 573 + 574 + return { success: true, data: settings }; 575 + } catch (error) { 576 + const message = error instanceof Error ? error.message : String(error); 577 + return { success: false, error: message }; 578 + } 579 + }); 580 + 581 + ipcMain.handle('extension-settings-get-key', async (ev, data) => { 582 + try { 583 + const db = getDb(); 584 + const setting = db.prepare( 585 + 'SELECT * FROM extension_settings WHERE extensionId = ? AND key = ?' 586 + ).get(data.id, data.key) as { value: string } | undefined; 587 + 588 + if (!setting) { 589 + return { success: true, data: null }; 590 + } 591 + 592 + try { 593 + return { success: true, data: JSON.parse(setting.value) }; 594 + } catch { 595 + return { success: true, data: setting.value }; 596 + } 597 + } catch (error) { 598 + const message = error instanceof Error ? error.message : String(error); 599 + return { success: false, error: message }; 600 + } 601 + }); 602 + 603 + ipcMain.handle('extension-settings-set-key', async (ev, data) => { 604 + try { 605 + const db = getDb(); 606 + const jsonValue = JSON.stringify(data.value); 607 + db.prepare(` 608 + INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 609 + VALUES (?, ?, ?, ?, ?) 610 + `).run(`${data.id}_${data.key}`, data.id, data.key, jsonValue, Date.now()); 611 + 612 + return { success: true, data: { key: data.key, value: data.value } }; 613 + } catch (error) { 614 + const message = error instanceof Error ? error.message : String(error); 615 + return { success: false, error: message }; 616 + } 617 + }); 618 + 619 + ipcMain.handle('extension-manifest-get', async (ev, data) => { 620 + try { 621 + const extPath = getExtensionPath(data.id); 622 + if (!extPath) { 623 + // Check database for external extensions 624 + const db = getDb(); 625 + const ext = db.prepare('SELECT * FROM extensions WHERE id = ?').get(data.id) as { path?: string } | undefined; 626 + if (!ext || !ext.path) { 627 + return { success: false, error: `Extension ${data.id} not found` }; 628 + } 629 + const manifest = loadExtensionManifest(ext.path); 630 + return { success: true, data: manifest }; 631 + } 632 + const manifest = loadExtensionManifest(extPath); 633 + return { success: true, data: manifest }; 634 + } catch (error) { 635 + const message = error instanceof Error ? error.message : String(error); 636 + return { success: false, error: message }; 637 + } 638 + }); 639 + 640 + ipcMain.handle('extension-settings-schema', async (ev, data) => { 641 + try { 642 + const extPath = getExtensionPath(data.id); 643 + if (!extPath) { 644 + return { success: false, error: `Extension ${data.id} not found` }; 645 + } 646 + 647 + const manifest = loadExtensionManifest(extPath); 648 + if (!manifest || !manifest.settingsSchema) { 649 + return { success: true, data: null }; 650 + } 651 + 652 + const schemaPath = path.join(extPath, manifest.settingsSchema); 653 + if (!fs.existsSync(schemaPath)) { 654 + return { success: true, data: null }; 655 + } 656 + 657 + const schemaContent = fs.readFileSync(schemaPath, 'utf-8'); 658 + const schema = JSON.parse(schemaContent); 659 + return { success: true, data: schema }; 660 + } catch (error) { 661 + const message = error instanceof Error ? error.message : String(error); 662 + return { success: false, error: message }; 663 + } 664 + }); 665 + } 666 + 667 + /** 668 + * Register dark mode IPC handlers 669 + */ 670 + export function registerDarkModeHandlers(): void { 671 + ipcMain.handle('dark-mode:toggle', () => { 672 + if (nativeTheme.shouldUseDarkColors) { 673 + nativeTheme.themeSource = 'light'; 674 + } else { 675 + nativeTheme.themeSource = 'dark'; 676 + } 677 + return nativeTheme.shouldUseDarkColors; 678 + }); 679 + 680 + ipcMain.handle('dark-mode:system', () => { 681 + nativeTheme.themeSource = 'system'; 682 + return nativeTheme.shouldUseDarkColors; 683 + }); 684 + } 685 + 686 + /** 687 + * Register window management IPC handlers 688 + */ 689 + export function registerWindowHandlers(): void { 690 + ipcMain.handle('window-open', async (ev, msg) => { 691 + console.log('window-open', msg); 692 + 693 + const { url, options } = msg; 694 + 695 + // Check if window with this key already exists 696 + if (options.key) { 697 + const existingWindow = findWindowByKey(msg.source, options.key); 698 + if (existingWindow) { 699 + console.log('Reusing existing window with key:', options.key); 700 + existingWindow.window.show(); 701 + return { success: true, id: existingWindow.id, reused: true }; 702 + } 703 + } 704 + 705 + // Prepare browser window options 706 + const winOptions: Electron.BrowserWindowConstructorOptions = { 707 + ...options, 708 + width: parseInt(options.width) || APP_DEF_WIDTH, 709 + height: parseInt(options.height) || APP_DEF_HEIGHT, 710 + show: options.show !== false, 711 + webPreferences: { 712 + ...options.webPreferences, 713 + preload: getPreloadPath() 714 + } 715 + }; 716 + 717 + // Make sure position parameters are correctly handled 718 + if (options.x !== undefined) { 719 + winOptions.x = parseInt(options.x); 720 + } 721 + if (options.y !== undefined) { 722 + winOptions.y = parseInt(options.y); 723 + } 724 + 725 + if (options.modal === true) { 726 + winOptions.frame = false; 727 + // Use panel type on macOS to improve focus restoration when closed 728 + if (process.platform === 'darwin') { 729 + winOptions.type = 'panel'; 730 + } 731 + } 732 + 733 + console.log('Creating window with options:', winOptions); 734 + 735 + // Create new window 736 + const win = new BrowserWindow(winOptions); 737 + 738 + // Forward console logs from window to main process stdout (for debugging) 739 + win.webContents.on('console-message', (_event, _level, message) => { 740 + // Only forward for peek:// URLs to avoid noise 741 + if (url.startsWith('peek://')) { 742 + console.log(`[${url.replace('peek://', '')}] ${message}`); 743 + } 744 + }); 745 + 746 + try { 747 + await win.loadURL(url); 748 + 749 + // Determine if this is a transient window (opened while no Peek window was focused) 750 + // Used for escapeMode: 'auto' to decide between navigate and close behavior 751 + const focusedWindow = BrowserWindow.getFocusedWindow(); 752 + const isTransient = !focusedWindow || focusedWindow.isDestroyed(); 753 + 754 + // Add to window manager with modal parameter 755 + const windowParams = { 756 + ...options, 757 + address: url, 758 + transient: isTransient 759 + }; 760 + console.log('Adding window to manager:', win.id, 'modal:', windowParams.modal, 'keepLive:', windowParams.keepLive); 761 + registerWindow(win.id, msg.source, windowParams); 762 + 763 + // Add escape key handler to all windows 764 + addEscHandler(win); 765 + 766 + // Set up DevTools if requested 767 + winDevtoolsConfig(win); 768 + 769 + // Set up modal behavior if requested 770 + // Delay blur handler attachment to avoid race condition where focus events 771 + // are still settling after window creation (can cause immediate close) 772 + if (options.modal === true) { 773 + setTimeout(() => { 774 + if (!win.isDestroyed()) { 775 + win.on('blur', () => { 776 + console.log('window-open: blur for modal window', url); 777 + closeOrHideWindow(win.id); 778 + }); 779 + } 780 + }, 100); 781 + } 782 + 783 + // Show dock when window opens 784 + updateDockVisibility(); 785 + 786 + return { success: true, id: win.id }; 787 + } catch (error) { 788 + console.error('Failed to open window:', error); 789 + const message = error instanceof Error ? error.message : String(error); 790 + return { success: false, error: message }; 791 + } 792 + }); 793 + 794 + ipcMain.handle('window-close', async (_ev, msg) => { 795 + console.log('window-close', msg); 796 + 797 + try { 798 + if (!msg.id) { 799 + return { success: false, error: 'Window ID is required' }; 800 + } 801 + 802 + const win = BrowserWindow.fromId(msg.id); 803 + if (!win) { 804 + return { success: false, error: 'Window not found' }; 805 + } 806 + 807 + win.close(); 808 + return { success: true }; 809 + } catch (error) { 810 + console.error('Failed to close window:', error); 811 + const message = error instanceof Error ? error.message : String(error); 812 + return { success: false, error: message }; 813 + } 814 + }); 815 + 816 + ipcMain.handle('window-hide', async (_ev, msg) => { 817 + console.log('window-hide', msg); 818 + 819 + try { 820 + if (!msg.id) { 821 + return { success: false, error: 'Window ID is required' }; 822 + } 823 + 824 + const winData = getWindowInfo(msg.id); 825 + if (!winData) { 826 + return { success: false, error: 'Window not found in window manager' }; 827 + } 828 + 829 + const win = BrowserWindow.fromId(msg.id); 830 + if (!win) { 831 + removeWindow(msg.id); 832 + return { success: false, error: 'Window not found' }; 833 + } 834 + 835 + win.hide(); 836 + return { success: true }; 837 + } catch (error) { 838 + console.error('Failed to hide window:', error); 839 + const message = error instanceof Error ? error.message : String(error); 840 + return { success: false, error: message }; 841 + } 842 + }); 843 + 844 + ipcMain.handle('window-show', async (_ev, msg) => { 845 + console.log('window-show', msg); 846 + 847 + try { 848 + if (!msg.id) { 849 + return { success: false, error: 'Window ID is required' }; 850 + } 851 + 852 + const winData = getWindowInfo(msg.id); 853 + if (!winData) { 854 + return { success: false, error: 'Window not found in window manager' }; 855 + } 856 + 857 + const win = BrowserWindow.fromId(msg.id); 858 + if (!win) { 859 + removeWindow(msg.id); 860 + return { success: false, error: 'Window not found' }; 861 + } 862 + 863 + win.show(); 864 + updateDockVisibility(); 865 + return { success: true }; 866 + } catch (error) { 867 + console.error('Failed to show window:', error); 868 + const message = error instanceof Error ? error.message : String(error); 869 + return { success: false, error: message }; 870 + } 871 + }); 872 + 873 + ipcMain.handle('window-move', async (_ev, msg) => { 874 + console.log('window-move', msg); 875 + 876 + try { 877 + if (!msg.id) { 878 + return { success: false, error: 'Window ID is required' }; 879 + } 880 + 881 + const winData = getWindowInfo(msg.id); 882 + if (!winData) { 883 + return { success: false, error: 'Window not found in window manager' }; 884 + } 885 + 886 + const win = BrowserWindow.fromId(msg.id); 887 + if (!win) { 888 + removeWindow(msg.id); 889 + return { success: false, error: 'Window not found' }; 890 + } 891 + 892 + if (typeof msg.x !== 'number' || typeof msg.y !== 'number') { 893 + return { success: false, error: 'Valid x and y coordinates are required' }; 894 + } 895 + 896 + win.setPosition(msg.x, msg.y); 897 + return { success: true }; 898 + } catch (error) { 899 + console.error('Failed to move window:', error); 900 + const message = error instanceof Error ? error.message : String(error); 901 + return { success: false, error: message }; 902 + } 903 + }); 904 + 905 + ipcMain.handle('window-focus', async (_ev, msg) => { 906 + console.log('window-focus', msg); 907 + 908 + try { 909 + if (!msg.id) { 910 + return { success: false, error: 'Window ID is required' }; 911 + } 912 + 913 + const winData = getWindowInfo(msg.id); 914 + if (!winData) { 915 + return { success: false, error: 'Window not found in window manager' }; 916 + } 917 + 918 + const win = BrowserWindow.fromId(msg.id); 919 + if (!win) { 920 + removeWindow(msg.id); 921 + return { success: false, error: 'Window not found' }; 922 + } 923 + 924 + win.focus(); 925 + return { success: true }; 926 + } catch (error) { 927 + console.error('Failed to focus window:', error); 928 + const message = error instanceof Error ? error.message : String(error); 929 + return { success: false, error: message }; 930 + } 931 + }); 932 + 933 + ipcMain.handle('window-blur', async (_ev, msg) => { 934 + console.log('window-blur', msg); 935 + 936 + try { 937 + if (!msg.id) { 938 + return { success: false, error: 'Window ID is required' }; 939 + } 940 + 941 + const winData = getWindowInfo(msg.id); 942 + if (!winData) { 943 + return { success: false, error: 'Window not found in window manager' }; 944 + } 945 + 946 + const win = BrowserWindow.fromId(msg.id); 947 + if (!win) { 948 + removeWindow(msg.id); 949 + return { success: false, error: 'Window not found' }; 950 + } 951 + 952 + win.blur(); 953 + return { success: true }; 954 + } catch (error) { 955 + console.error('Failed to blur window:', error); 956 + const message = error instanceof Error ? error.message : String(error); 957 + return { success: false, error: message }; 958 + } 959 + }); 960 + 961 + ipcMain.handle('window-exists', async (_ev, msg) => { 962 + console.log('window-exists', msg); 963 + 964 + try { 965 + if (!msg.id) { 966 + return { exists: false, error: 'Window ID is required' }; 967 + } 968 + 969 + const winData = getWindowInfo(msg.id); 970 + if (!winData) { 971 + return { exists: false }; 972 + } 973 + 974 + const win = BrowserWindow.fromId(msg.id); 975 + if (!win || win.isDestroyed()) { 976 + removeWindow(msg.id); 977 + return { exists: false }; 978 + } 979 + 980 + return { exists: true }; 981 + } catch (error) { 982 + console.error('Failed to check if window exists:', error); 983 + const message = error instanceof Error ? error.message : String(error); 984 + return { exists: false, error: message }; 985 + } 986 + }); 987 + 988 + ipcMain.handle('window-list', async (_ev, msg) => { 989 + console.log('window-list', msg); 990 + 991 + try { 992 + const windows: Array<{ 993 + id: number; 994 + url: string; 995 + title: string; 996 + source: string; 997 + params: Record<string, unknown>; 998 + }> = []; 999 + 1000 + for (const [id, winData] of getAllWindows()) { 1001 + const win = BrowserWindow.fromId(id); 1002 + if (win && !win.isDestroyed()) { 1003 + const url = win.webContents.getURL(); 1004 + 1005 + // Skip internal peek:// URLs unless requested 1006 + if (!msg?.includeInternal && url.startsWith('peek://')) { 1007 + continue; 1008 + } 1009 + 1010 + windows.push({ 1011 + id, 1012 + url, 1013 + title: win.getTitle(), 1014 + source: winData.source, 1015 + params: winData.params 1016 + }); 1017 + } 1018 + } 1019 + 1020 + return { success: true, windows }; 1021 + } catch (error) { 1022 + console.error('Failed to list windows:', error); 1023 + const message = error instanceof Error ? error.message : String(error); 1024 + return { success: false, error: message, windows: [] }; 1025 + } 1026 + }); 1027 + } 1028 + 1029 + /** 1030 + * Register miscellaneous IPC handlers (shortcuts, pubsub, commands, etc.) 1031 + */ 1032 + export function registerMiscHandlers(onQuit: () => void): void { 1033 + // Renderer log forwarding - prints renderer console.log to terminal 1034 + ipcMain.on(IPC_CHANNELS.RENDERER_LOG, (_ev, msg) => { 1035 + const shortSource = msg.source?.replace('peek://app/', '') || 'unknown'; 1036 + console.log(`[${shortSource}]`, ...(msg.args || [])); 1037 + }); 1038 + 1039 + // Register shortcut 1040 + ipcMain.on(IPC_CHANNELS.REGISTER_SHORTCUT, (ev, msg) => { 1041 + const isGlobal = msg.global === true; 1042 + console.log('ipc register shortcut', msg.shortcut, isGlobal ? '(global)' : '(local)'); 1043 + 1044 + const callback = () => { 1045 + console.log('on(registershortcut): shortcut executed', msg.shortcut, msg.replyTopic); 1046 + ev.reply(msg.replyTopic, { foo: 'bar' }); 1047 + }; 1048 + 1049 + if (isGlobal) { 1050 + registerGlobalShortcut(msg.shortcut, msg.source, callback); 1051 + } else { 1052 + registerLocalShortcut(msg.shortcut, msg.source, callback); 1053 + } 1054 + }); 1055 + 1056 + // Unregister shortcut 1057 + ipcMain.on(IPC_CHANNELS.UNREGISTER_SHORTCUT, (_ev, msg) => { 1058 + const isGlobal = msg.global === true; 1059 + console.log('ipc unregister shortcut', msg.shortcut, isGlobal ? '(global)' : '(local)'); 1060 + 1061 + if (isGlobal) { 1062 + const err = unregisterGlobalShortcut(msg.shortcut); 1063 + if (err) { 1064 + console.log('ipc unregister global shortcut error:', err.message); 1065 + } 1066 + } else { 1067 + unregisterLocalShortcut(msg.shortcut); 1068 + } 1069 + }); 1070 + 1071 + // Close window 1072 + ipcMain.on(IPC_CHANNELS.CLOSE_WINDOW, (ev, msg) => { 1073 + closeWindow(msg.params, (output) => { 1074 + console.log('main.closeWindow api callback, output:', output); 1075 + if (msg && msg.replyTopic) { 1076 + ev.reply(msg.replyTopic, output); 1077 + } 1078 + }); 1079 + }); 1080 + 1081 + // PubSub publish 1082 + ipcMain.on(IPC_CHANNELS.PUBLISH, (_ev, msg) => { 1083 + console.log('ipc:publish', msg); 1084 + 1085 + // Intercept command registration to store in registry 1086 + if (msg.topic === TOPICS.CMD_REGISTER && msg.data) { 1087 + commandRegistry.set(msg.data.name, { 1088 + name: msg.data.name, 1089 + description: msg.data.description || '', 1090 + source: msg.data.source 1091 + }); 1092 + console.log('[cmd-registry] Registered command:', msg.data.name); 1093 + } else if (msg.topic === TOPICS.CMD_UNREGISTER && msg.data) { 1094 + commandRegistry.delete(msg.data.name); 1095 + console.log('[cmd-registry] Unregistered command:', msg.data.name); 1096 + } 1097 + 1098 + publish(msg.source, msg.scope, msg.topic, msg.data); 1099 + }); 1100 + 1101 + // PubSub subscribe 1102 + ipcMain.on(IPC_CHANNELS.SUBSCRIBE, (ev, msg) => { 1103 + console.log('ipc:subscribe', msg); 1104 + 1105 + subscribe(msg.source, msg.scope, msg.topic, (data: unknown) => { 1106 + console.log('ipc:subscribe:notification', msg); 1107 + ev.reply(msg.replyTopic, data); 1108 + }); 1109 + }); 1110 + 1111 + // Get registered commands 1112 + ipcMain.handle(IPC_CHANNELS.GET_REGISTERED_COMMANDS, async () => { 1113 + const commands = Array.from(commandRegistry.values()); 1114 + console.log('[cmd-registry] Query returned', commands.length, 'commands'); 1115 + return { success: true, data: commands }; 1116 + }); 1117 + 1118 + // Console log from renderer 1119 + ipcMain.on(IPC_CHANNELS.CONSOLE, (_ev, msg) => { 1120 + console.log('r:', msg.source, msg.text); 1121 + }); 1122 + 1123 + // App quit request 1124 + ipcMain.on(IPC_CHANNELS.APP_QUIT, (_ev, msg) => { 1125 + console.log('app-quit requested from:', msg?.source); 1126 + onQuit(); 1127 + }); 1128 + 1129 + // Modify window 1130 + ipcMain.on(IPC_CHANNELS.MODIFY_WINDOW, (ev, msg) => { 1131 + console.log('modifywindow', msg); 1132 + 1133 + const key = Object.prototype.hasOwnProperty.call(msg, 'name') ? msg.name : null; 1134 + 1135 + if (key != null) { 1136 + const existingWindow = findWindowByKey(msg.source, key); 1137 + if (existingWindow) { 1138 + console.log('FOUND WINDOW FOR KEY', key); 1139 + const bw = existingWindow.window; 1140 + let r = false; 1141 + try { 1142 + modWindow(bw, msg.params); 1143 + r = true; 1144 + } catch (ex) { 1145 + console.error(ex); 1146 + } 1147 + ev.reply(msg.replyTopic, { output: r }); 1148 + } 1149 + } 1150 + }); 1151 + } 1152 + 1153 + /** 1154 + * Register all IPC handlers 1155 + */ 1156 + export function registerAllHandlers(onQuit: () => void): void { 1157 + registerDarkModeHandlers(); 1158 + registerDatastoreHandlers(); 1159 + registerExtensionHandlers(); 1160 + registerWindowHandlers(); 1161 + registerMiscHandlers(onQuit); 1162 + }
+659
backend/electron/main.ts
··· 1 + /** 2 + * Electron Main Process Entry Point 3 + * 4 + * This module orchestrates the main process startup and provides 5 + * a unified API for managing the Electron application. 6 + */ 7 + 8 + import { app, BrowserWindow, ipcMain, Menu, nativeImage, nativeTheme } from 'electron'; 9 + import path from 'node:path'; 10 + 11 + import { initDatabase, closeDatabase, getDb } from './datastore.js'; 12 + import { registerScheme, initProtocol, registerExtensionPath, getExtensionPath, getRegisteredExtensionIds } from './protocol.js'; 13 + import { discoverExtensions, loadExtensionManifest, isBuiltinExtensionEnabled, getExternalExtensions } from './extensions.js'; 14 + import { initTray } from './tray.js'; 15 + import { registerLocalShortcut, unregisterLocalShortcut, handleLocalShortcut, registerGlobalShortcut, unregisterGlobalShortcut, unregisterShortcutsForAddress } from './shortcuts.js'; 16 + import { scopes, publish, subscribe, setExtensionBroadcaster, getSystemAddress } from './pubsub.js'; 17 + import { APP_DEF_WIDTH, APP_DEF_HEIGHT, WEB_CORE_ADDRESS, getPreloadPath, isTestProfile } from './config.js'; 18 + import { addEscHandler, winDevtoolsConfig, closeOrHideWindow } from './windows.js'; 19 + 20 + // Configuration 21 + export interface AppConfig { 22 + rootDir: string; 23 + preloadPath: string; 24 + userDataPath: string; 25 + profile: string; 26 + isDev: boolean; 27 + isTest: boolean; 28 + } 29 + 30 + // App state 31 + let config: AppConfig; 32 + let mainWindow: BrowserWindow | null = null; 33 + 34 + // External URL handling state 35 + let _appReady = false; 36 + let _pendingUrls: Array<{ url: string; sourceId: string }> = []; 37 + 38 + // Extension windows: extId -> { win, manifest, status } 39 + const extensionWindows = new Map<string, { 40 + win: BrowserWindow; 41 + manifest: unknown; 42 + status: 'loading' | 'running' | 'crashed'; 43 + }>(); 44 + 45 + // Window manager: windowId -> { source, params } 46 + const windowRegistry = new Map<number, { 47 + source: string; 48 + params: Record<string, unknown>; 49 + }>(); 50 + 51 + /** 52 + * Initialize the application configuration 53 + * Must be called before app.ready 54 + */ 55 + export function configure(cfg: AppConfig): void { 56 + config = cfg; 57 + 58 + // Use system theme 59 + nativeTheme.themeSource = 'system'; 60 + 61 + // Register custom protocol scheme (must be before app.ready) 62 + registerScheme(); 63 + } 64 + 65 + /** 66 + * Initialize the application 67 + * Called after app.ready 68 + */ 69 + export async function initialize(): Promise<void> { 70 + if (!config) { 71 + throw new Error('App not configured. Call configure() first.'); 72 + } 73 + 74 + // Initialize protocol handler 75 + initProtocol(config.rootDir); 76 + 77 + // Initialize database 78 + const dbPath = path.join(config.userDataPath, config.profile, 'datastore.sqlite'); 79 + initDatabase(dbPath); 80 + 81 + // Set up extension broadcaster for pubsub 82 + setExtensionBroadcaster((topic, msg, source) => { 83 + for (const [extId, entry] of extensionWindows) { 84 + if (entry.win && !entry.win.isDestroyed() && entry.status === 'running') { 85 + const extOrigin = `peek://ext/${extId}/`; 86 + if (!source.startsWith(extOrigin)) { 87 + entry.win.webContents.send(`pubsub:${topic}`, { 88 + ...(msg as object), 89 + source 90 + }); 91 + } 92 + } 93 + } 94 + }); 95 + 96 + // Track window events globally 97 + app.on('browser-window-created', (_, window) => { 98 + // Handle window close 99 + window.on('closed', () => { 100 + const windowId = window.id; 101 + const windowData = windowRegistry.get(windowId); 102 + 103 + if (windowData) { 104 + publish(windowData.source, scopes.GLOBAL, 'window:closed', { 105 + id: windowId, 106 + source: windowData.source 107 + }); 108 + } 109 + 110 + windowRegistry.delete(windowId); 111 + }); 112 + 113 + // Handle local shortcuts 114 + window.webContents.on('before-input-event', (event, input) => { 115 + if (handleLocalShortcut(input)) { 116 + event.preventDefault(); 117 + } 118 + }); 119 + }); 120 + } 121 + 122 + /** 123 + * Discover and register built-in extensions 124 + */ 125 + export function discoverBuiltinExtensions(extensionsDir: string): void { 126 + const discovered = discoverExtensions(extensionsDir); 127 + for (const ext of discovered) { 128 + registerExtensionPath(ext.id, ext.path); 129 + } 130 + } 131 + 132 + /** 133 + * Create an extension window 134 + */ 135 + export async function createExtensionWindow(extId: string): Promise<BrowserWindow | null> { 136 + if (extensionWindows.has(extId)) { 137 + console.log(`[ext:win] Extension ${extId} already has a window`); 138 + return extensionWindows.get(extId)!.win; 139 + } 140 + 141 + const extPath = getExtensionPath(extId); 142 + if (!extPath) { 143 + console.error(`[ext:win] Extension path not found: ${extId}`); 144 + return null; 145 + } 146 + 147 + const manifest = loadExtensionManifest(extPath); 148 + 149 + console.log(`[ext:win] Creating window for extension: ${extId}`); 150 + 151 + const win = new BrowserWindow({ 152 + show: false, 153 + webPreferences: { 154 + preload: config.preloadPath 155 + } 156 + }); 157 + 158 + // Forward console logs 159 + win.webContents.on('console-message', (event, level, message) => { 160 + console.log(`[ext:${extId}] ${message}`); 161 + }); 162 + 163 + // Track crashes 164 + win.webContents.on('render-process-gone', (event, details) => { 165 + console.error(`[ext:win] Extension ${extId} crashed (reason: ${details.reason})`); 166 + const entry = extensionWindows.get(extId); 167 + if (entry) { 168 + entry.status = 'crashed'; 169 + } 170 + }); 171 + 172 + // Track close 173 + win.on('closed', () => { 174 + console.log(`[ext:win] Extension ${extId} window closed`); 175 + extensionWindows.delete(extId); 176 + }); 177 + 178 + extensionWindows.set(extId, { win, manifest, status: 'loading' }); 179 + 180 + try { 181 + await win.loadURL(`peek://ext/${extId}/background.html`); 182 + console.log(`[ext:win] Extension ${extId} loaded successfully`); 183 + const entry = extensionWindows.get(extId); 184 + if (entry) { 185 + entry.status = 'running'; 186 + } 187 + return win; 188 + } catch (error) { 189 + console.error(`[ext:win] Failed to load extension ${extId}:`, error); 190 + extensionWindows.delete(extId); 191 + win.destroy(); 192 + return null; 193 + } 194 + } 195 + 196 + /** 197 + * Load all enabled extensions 198 + */ 199 + export async function loadEnabledExtensions(): Promise<number> { 200 + // Load enabled built-in extensions 201 + const builtinExtIds = getRegisteredExtensionIds(); 202 + for (const extId of builtinExtIds) { 203 + if (isBuiltinExtensionEnabled(extId)) { 204 + console.log(`[ext:win] Loading enabled extension: ${extId}`); 205 + await createExtensionWindow(extId); 206 + } else { 207 + console.log(`[ext:win] Skipping disabled extension: ${extId}`); 208 + } 209 + } 210 + 211 + // Load enabled external extensions 212 + const externalExts = getExternalExtensions(); 213 + for (const ext of externalExts) { 214 + if (extensionWindows.has(ext.id)) continue; 215 + if (!ext.enabled) { 216 + console.log(`[ext:win] Skipping disabled external extension: ${ext.id}`); 217 + continue; 218 + } 219 + if (!ext.path) { 220 + console.log(`[ext:win] Skipping external extension without path: ${ext.id}`); 221 + continue; 222 + } 223 + console.log(`[ext:win] Loading enabled external extension: ${ext.id}`); 224 + await createExtensionWindow(ext.id); 225 + } 226 + 227 + console.log(`[ext:win] Loaded ${extensionWindows.size} extensions`); 228 + 229 + // Signal that all extensions are loaded 230 + publish('system', scopes.GLOBAL, 'ext:all-loaded', { 231 + count: extensionWindows.size 232 + }); 233 + 234 + return extensionWindows.size; 235 + } 236 + 237 + /** 238 + * Get running extensions info 239 + */ 240 + export function getRunningExtensions(): Array<{ id: string; manifest: unknown; status: string }> { 241 + const running = []; 242 + for (const [extId, entry] of extensionWindows) { 243 + if (entry.status === 'running') { 244 + running.push({ 245 + id: extId, 246 + manifest: entry.manifest, 247 + status: entry.status 248 + }); 249 + } 250 + } 251 + return running; 252 + } 253 + 254 + /** 255 + * Destroy an extension window 256 + */ 257 + export function destroyExtensionWindow(extId: string): boolean { 258 + const entry = extensionWindows.get(extId); 259 + if (!entry) { 260 + console.log(`[ext:win] No window to destroy for: ${extId}`); 261 + return false; 262 + } 263 + 264 + console.log(`[ext:win] Destroying window for: ${extId}`); 265 + 266 + if (entry.win && !entry.win.isDestroyed()) { 267 + entry.win.webContents.send('pubsub:app:shutdown', {}); 268 + setTimeout(() => { 269 + if (!entry.win.isDestroyed()) { 270 + entry.win.destroy(); 271 + } 272 + }, 100); 273 + } 274 + 275 + extensionWindows.delete(extId); 276 + return true; 277 + } 278 + 279 + /** 280 + * Get an extension window 281 + */ 282 + export function getExtensionWindow(extId: string): BrowserWindow | null { 283 + const entry = extensionWindows.get(extId); 284 + return entry ? entry.win : null; 285 + } 286 + 287 + /** 288 + * Register a window in the registry 289 + */ 290 + export function registerWindow(windowId: number, source: string, params: Record<string, unknown>): void { 291 + windowRegistry.set(windowId, { source, params }); 292 + } 293 + 294 + /** 295 + * Get window info from registry 296 + */ 297 + export function getWindowInfo(windowId: number): { source: string; params: Record<string, unknown> } | undefined { 298 + return windowRegistry.get(windowId); 299 + } 300 + 301 + /** 302 + * Find a window by source and key 303 + */ 304 + export function findWindowByKey(source: string, key: string): { id: number; window: BrowserWindow; data: unknown } | null { 305 + if (!key) return null; 306 + 307 + for (const [id, win] of windowRegistry) { 308 + if (win.source === source && win.params && win.params.key === key) { 309 + const browserWindow = BrowserWindow.fromId(id); 310 + if (browserWindow) { 311 + return { id, window: browserWindow, data: win }; 312 + } 313 + } 314 + } 315 + return null; 316 + } 317 + 318 + /** 319 + * Remove a window from the registry 320 + */ 321 + export function removeWindow(windowId: number): boolean { 322 + return windowRegistry.delete(windowId); 323 + } 324 + 325 + /** 326 + * Get all child windows for a source 327 + */ 328 + export function getChildWindows(source: string): Array<{ id: number; data: { source: string; params: Record<string, unknown> } }> { 329 + const children = []; 330 + for (const [id, win] of windowRegistry) { 331 + if (win.source === source) { 332 + children.push({ id, data: win }); 333 + } 334 + } 335 + return children; 336 + } 337 + 338 + /** 339 + * Get all registered windows 340 + */ 341 + export function getAllWindows(): Array<[number, { source: string; params: Record<string, unknown> }]> { 342 + return Array.from(windowRegistry.entries()); 343 + } 344 + 345 + /** 346 + * Shutdown the application 347 + */ 348 + export async function shutdown(): Promise<void> { 349 + // Publish shutdown event 350 + publish(getSystemAddress(), scopes.GLOBAL, 'app:shutdown', { 351 + timestamp: Date.now() 352 + }); 353 + 354 + // Close database 355 + closeDatabase(); 356 + } 357 + 358 + // ***** Background Window ***** 359 + 360 + let backgroundWindow: BrowserWindow | null = null; 361 + 362 + /** 363 + * Create the core background window 364 + */ 365 + export function createBackgroundWindow(): BrowserWindow { 366 + const preloadPath = getPreloadPath(); 367 + const systemAddress = getSystemAddress(); 368 + 369 + const winPrefs = { 370 + show: false, 371 + key: 'background-core', 372 + webPreferences: { 373 + preload: preloadPath, 374 + } 375 + }; 376 + 377 + // Create the background window 378 + const win = new BrowserWindow(winPrefs); 379 + win.loadURL(WEB_CORE_ADDRESS); 380 + 381 + // Setup devtools for the background window (debug mode, but not in tests) 382 + if (config.isDev && !isTestProfile()) { 383 + win.webContents.openDevTools({ mode: 'detach', activate: false }); 384 + } 385 + 386 + // Add to window manager 387 + registerWindow(win.id, systemAddress, { ...winPrefs, address: WEB_CORE_ADDRESS }); 388 + 389 + // NOTE: No ESC handler for background window - it should never be closed 390 + 391 + // Set up handlers for windows opened from the background window 392 + win.webContents.setWindowOpenHandler((details) => { 393 + console.log('Background window opening child window:', details.url); 394 + 395 + // Parse window features into options 396 + const featuresMap: Record<string, unknown> = {}; 397 + if (details.features) { 398 + details.features.split(',') 399 + .map(entry => entry.split('=')) 400 + .forEach(([key, value]) => { 401 + let parsedValue: unknown = value; 402 + // Convert string booleans to actual booleans 403 + if (value === 'true') parsedValue = true; 404 + else if (value === 'false') parsedValue = false; 405 + // Convert numeric values to numbers 406 + else if (!isNaN(Number(value)) && value.trim() !== '') { 407 + parsedValue = parseInt(value, 10); 408 + } 409 + featuresMap[key] = parsedValue; 410 + }); 411 + } 412 + 413 + console.log('Parsed features map:', featuresMap); 414 + 415 + // Check if window with this key already exists 416 + if (featuresMap.key) { 417 + const existingWindow = findWindowByKey(WEB_CORE_ADDRESS, featuresMap.key as string); 418 + if (existingWindow) { 419 + console.log('Reusing existing window with key:', featuresMap.key); 420 + existingWindow.window.show(); 421 + return { action: 'deny' as const }; 422 + } 423 + } 424 + 425 + // Prepare browser window options 426 + const winOptions: Electron.BrowserWindowConstructorOptions = { 427 + ...(featuresMap as Electron.BrowserWindowConstructorOptions), 428 + width: parseInt(String(featuresMap.width)) || APP_DEF_WIDTH, 429 + height: parseInt(String(featuresMap.height)) || APP_DEF_HEIGHT, 430 + show: featuresMap.show !== false, 431 + webPreferences: { 432 + preload: preloadPath 433 + } 434 + }; 435 + 436 + // Make sure position parameters are correctly handled 437 + if (featuresMap.x !== undefined) { 438 + winOptions.x = parseInt(String(featuresMap.x)); 439 + } 440 + if (featuresMap.y !== undefined) { 441 + winOptions.y = parseInt(String(featuresMap.y)); 442 + } 443 + 444 + console.log('Background window creating child with options:', winOptions); 445 + 446 + // Make sure we register browser window created handler to track the new window 447 + const onCreated = (_e: Electron.Event, newWin: BrowserWindow) => { 448 + // Check if this is the window we just created 449 + newWin.webContents.once('did-finish-load', () => { 450 + const loadedUrl = newWin.webContents.getURL(); 451 + if (loadedUrl === details.url) { 452 + // Remove the listener 453 + app.removeListener('browser-window-created', onCreated); 454 + 455 + // Add the window to our manager with necessary parameters 456 + registerWindow(newWin.id, WEB_CORE_ADDRESS, { 457 + ...featuresMap, 458 + address: details.url, 459 + modal: featuresMap.modal 460 + }); 461 + 462 + // Add escape key handler 463 + addEscHandler(newWin); 464 + 465 + // Set up DevTools if requested 466 + winDevtoolsConfig(newWin); 467 + 468 + // Set up modal behavior with delay to avoid focus race condition 469 + if (featuresMap.modal === true) { 470 + setTimeout(() => { 471 + if (!newWin.isDestroyed()) { 472 + newWin.on('blur', () => { 473 + console.log('Modal window lost focus:', details.url); 474 + closeOrHideWindow(newWin.id); 475 + }); 476 + } 477 + }, 100); 478 + } 479 + } 480 + }); 481 + }; 482 + 483 + // Start listening for the window creation 484 + app.on('browser-window-created', onCreated); 485 + 486 + // Return allow with overridden options 487 + return { 488 + action: 'allow' as const, 489 + overrideBrowserWindowOptions: winOptions 490 + }; 491 + }); 492 + 493 + backgroundWindow = win; 494 + return win; 495 + } 496 + 497 + /** 498 + * Get the background window 499 + */ 500 + export function getBackgroundWindow(): BrowserWindow | null { 501 + return backgroundWindow; 502 + } 503 + 504 + // ***** External URL Handling ***** 505 + 506 + /** 507 + * Handle URLs opened from external apps (e.g., when Peek is default browser) 508 + */ 509 + export function handleExternalUrl(url: string, sourceId = 'os'): void { 510 + console.log('External URL received:', url, 'from:', sourceId); 511 + 512 + if (!_appReady) { 513 + _pendingUrls.push({ url, sourceId }); 514 + return; 515 + } 516 + 517 + // Note: Using trackingSource/trackingSourceId because preload.js overwrites msg.source 518 + publish(getSystemAddress(), scopes.GLOBAL, 'external:open-url', { 519 + url, 520 + trackingSource: 'external', 521 + trackingSourceId: sourceId, 522 + timestamp: Date.now() 523 + }); 524 + } 525 + 526 + /** 527 + * Process any URLs that arrived before app was ready 528 + */ 529 + export function processPendingUrls(): void { 530 + _pendingUrls.forEach(({ url, sourceId }) => { 531 + handleExternalUrl(url, sourceId); 532 + }); 533 + _pendingUrls = []; 534 + } 535 + 536 + /** 537 + * Mark app as ready to handle external URLs 538 + */ 539 + export function setAppReady(): void { 540 + _appReady = true; 541 + processPendingUrls(); 542 + } 543 + 544 + /** 545 + * Register external URL event handlers 546 + * Must be called before app.ready for open-url, and in onReady for second-instance 547 + */ 548 + export function registerExternalUrlHandlers(): void { 549 + // macOS: handle open-url event 550 + app.on('open-url', (event, url) => { 551 + event.preventDefault(); 552 + handleExternalUrl(url, 'os'); 553 + }); 554 + } 555 + 556 + /** 557 + * Register second-instance handler (for Windows/Linux URL handling) 558 + * Call this inside onReady after acquiring single instance lock 559 + */ 560 + export function registerSecondInstanceHandler(): void { 561 + app.on('second-instance', (_event, argv) => { 562 + const url = argv.find(arg => 563 + arg.startsWith('http://') || arg.startsWith('https://') 564 + ); 565 + if (url) { 566 + console.log('second-instance URL:', url); 567 + handleExternalUrl(url, 'os'); 568 + } 569 + }); 570 + } 571 + 572 + /** 573 + * Check for URL in CLI arguments and handle it 574 + */ 575 + export function handleCliUrl(): void { 576 + const urlArg = process.argv.find(arg => 577 + arg.startsWith('http://') || arg.startsWith('https://') 578 + ); 579 + if (urlArg) { 580 + console.log('CLI URL argument:', urlArg); 581 + // Defer until background app is ready 582 + setTimeout(() => handleExternalUrl(urlArg, 'cli'), 1000); 583 + } 584 + } 585 + 586 + // ***** App Lifecycle ***** 587 + 588 + /** 589 + * Register the window-all-closed handler 590 + */ 591 + export function registerWindowAllClosedHandler(onQuit: () => void): void { 592 + app.on('window-all-closed', () => { 593 + console.log('window-all-closed', process.platform); 594 + if (process.platform !== 'darwin') { 595 + onQuit(); 596 + } 597 + }); 598 + } 599 + 600 + /** 601 + * Register the activate handler (macOS dock click) 602 + */ 603 + export function registerActivateHandler(): void { 604 + app.on('activate', () => { 605 + // On macOS it's common to re-create a window in the app when the 606 + // dock icon is clicked and there are no other windows open. 607 + if (BrowserWindow.getAllWindows().length === 0) { 608 + // Could recreate window here if needed 609 + } 610 + }); 611 + } 612 + 613 + /** 614 + * Request single instance lock 615 + * Returns true if lock acquired, false if another instance is running 616 + */ 617 + export function requestSingleInstance(): boolean { 618 + const gotTheLock = app.requestSingleInstanceLock(); 619 + if (!gotTheLock) { 620 + console.error('APP INSTANCE ALREADY RUNNING, QUITTING'); 621 + app.quit(); 622 + return false; 623 + } 624 + return true; 625 + } 626 + 627 + /** 628 + * Quit the application gracefully 629 + */ 630 + export function quitApp(): void { 631 + console.log('quitApp'); 632 + 633 + // Publish shutdown event and close database 634 + shutdown(); 635 + 636 + // Give windows a moment to clean up before forcing quit 637 + setTimeout(() => { 638 + app.quit(); 639 + }, 100); 640 + } 641 + 642 + // Re-export commonly used functions 643 + export { 644 + scopes, 645 + publish, 646 + subscribe, 647 + getSystemAddress, 648 + registerLocalShortcut, 649 + unregisterLocalShortcut, 650 + registerGlobalShortcut, 651 + unregisterGlobalShortcut, 652 + unregisterShortcutsForAddress, 653 + handleLocalShortcut, 654 + initTray, 655 + getDb, 656 + getExtensionPath, 657 + getRegisteredExtensionIds, 658 + loadExtensionManifest, 659 + };
+127
backend/electron/pubsub.ts
··· 1 + /** 2 + * PubSub messaging system for cross-process communication 3 + * 4 + * Handles: 5 + * - Topic-based publish/subscribe 6 + * - Scope-based message filtering (SYSTEM, SELF, GLOBAL) 7 + * - Extension window broadcasting (via callback) 8 + */ 9 + 10 + // Message scopes 11 + export const scopes = { 12 + SYSTEM: 1, 13 + SELF: 2, 14 + GLOBAL: 3 15 + } as const; 16 + 17 + export type Scope = typeof scopes[keyof typeof scopes]; 18 + 19 + // System address for privileged subscribers 20 + const SYSTEM_ADDRESS = 'peek://system/'; 21 + 22 + // Topic subscribers: topic -> Map<source, callback> 23 + const topics = new Map<string, Map<string, (msg: unknown) => void>>(); 24 + 25 + // Callback for broadcasting to extension windows 26 + let extensionBroadcaster: ((topic: string, msg: unknown, source: string) => void) | null = null; 27 + 28 + /** 29 + * Extract pseudo-host from a peek:// URL 30 + * e.g., 'peek://app/foo.html' -> 'app' 31 + */ 32 + function getPseudoHost(str: string): string { 33 + return str.split('/')[2] || ''; 34 + } 35 + 36 + /** 37 + * Check if a subscriber should receive a message based on scope 38 + */ 39 + function scopeCheck(pubSource: string, subSource: string, scope: Scope): boolean { 40 + // System address receives everything 41 + if (subSource === SYSTEM_ADDRESS) { 42 + return true; 43 + } 44 + // GLOBAL scope sends to everyone 45 + if (scope === scopes.GLOBAL) { 46 + return true; 47 + } 48 + // SELF scope only sends to same pseudo-host 49 + if (getPseudoHost(subSource) === getPseudoHost(pubSource)) { 50 + return true; 51 + } 52 + return false; 53 + } 54 + 55 + /** 56 + * Set the callback for broadcasting to extension windows 57 + * This is called from the main process to inject the window broadcasting logic 58 + */ 59 + export function setExtensionBroadcaster( 60 + broadcaster: (topic: string, msg: unknown, source: string) => void 61 + ): void { 62 + extensionBroadcaster = broadcaster; 63 + } 64 + 65 + /** 66 + * Publish a message to a topic 67 + */ 68 + export function publish(source: string, scope: Scope, topic: string, msg: unknown): void { 69 + // Route to traditional subscribers (via IPC callbacks) 70 + if (topics.has(topic)) { 71 + const t = topics.get(topic)!; 72 + for (const [subSource, cb] of t) { 73 + if (scopeCheck(source, subSource, scope)) { 74 + cb(msg); 75 + } 76 + } 77 + } 78 + 79 + // Route to extension windows (GLOBAL scope only) 80 + if (scope === scopes.GLOBAL && extensionBroadcaster) { 81 + extensionBroadcaster(topic, msg, source); 82 + } 83 + } 84 + 85 + /** 86 + * Subscribe to a topic 87 + */ 88 + export function subscribe( 89 + source: string, 90 + scope: Scope, 91 + topic: string, 92 + cb: (msg: unknown) => void 93 + ): void { 94 + if (!topics.has(topic)) { 95 + topics.set(topic, new Map([[source, cb]])); 96 + } else { 97 + const subscribers = topics.get(topic)!; 98 + subscribers.set(source, cb); 99 + } 100 + } 101 + 102 + /** 103 + * Unsubscribe from a topic 104 + */ 105 + export function unsubscribe(source: string, topic: string): boolean { 106 + if (!topics.has(topic)) { 107 + return false; 108 + } 109 + const subscribers = topics.get(topic)!; 110 + return subscribers.delete(source); 111 + } 112 + 113 + /** 114 + * Unsubscribe from all topics for a source 115 + */ 116 + export function unsubscribeAll(source: string): void { 117 + for (const [, subscribers] of topics) { 118 + subscribers.delete(source); 119 + } 120 + } 121 + 122 + /** 123 + * Get the system address constant 124 + */ 125 + export function getSystemAddress(): string { 126 + return SYSTEM_ADDRESS; 127 + }
+258
backend/electron/shortcuts.ts
··· 1 + /** 2 + * Shortcut management for Electron 3 + * 4 + * Handles: 5 + * - Global shortcuts (work even when app doesn't have focus) 6 + * - Local shortcuts (only work when app has focus) 7 + * - Shortcut parsing and matching 8 + */ 9 + 10 + import { globalShortcut } from 'electron'; 11 + 12 + // Maps for tracking shortcuts 13 + // Global shortcuts: shortcut string -> source address 14 + const globalShortcuts = new Map<string, string>(); 15 + 16 + // Local shortcuts: shortcut string -> { source, parsed, callback } 17 + interface ParsedShortcut { 18 + ctrl: boolean; 19 + alt: boolean; 20 + shift: boolean; 21 + meta: boolean; 22 + code: string; 23 + } 24 + 25 + interface LocalShortcutEntry { 26 + source: string; 27 + parsed: ParsedShortcut; 28 + callback: () => void; 29 + } 30 + 31 + const localShortcuts = new Map<string, LocalShortcutEntry>(); 32 + 33 + // Map key names to physical key codes (for before-input-event matching) 34 + // Electron's input.code follows the USB HID spec 35 + const keyToCode: Record<string, string> = { 36 + // Letters 37 + 'a': 'KeyA', 'b': 'KeyB', 'c': 'KeyC', 'd': 'KeyD', 'e': 'KeyE', 38 + 'f': 'KeyF', 'g': 'KeyG', 'h': 'KeyH', 'i': 'KeyI', 'j': 'KeyJ', 39 + 'k': 'KeyK', 'l': 'KeyL', 'm': 'KeyM', 'n': 'KeyN', 'o': 'KeyO', 40 + 'p': 'KeyP', 'q': 'KeyQ', 'r': 'KeyR', 's': 'KeyS', 't': 'KeyT', 41 + 'u': 'KeyU', 'v': 'KeyV', 'w': 'KeyW', 'x': 'KeyX', 'y': 'KeyY', 42 + 'z': 'KeyZ', 43 + // Numbers 44 + '0': 'Digit0', '1': 'Digit1', '2': 'Digit2', '3': 'Digit3', '4': 'Digit4', 45 + '5': 'Digit5', '6': 'Digit6', '7': 'Digit7', '8': 'Digit8', '9': 'Digit9', 46 + // Punctuation 47 + ',': 'Comma', '.': 'Period', '/': 'Slash', ';': 'Semicolon', "'": 'Quote', 48 + '[': 'BracketLeft', ']': 'BracketRight', '\\': 'Backslash', '`': 'Backquote', 49 + '-': 'Minus', '=': 'Equal', 50 + // Special keys 51 + 'enter': 'Enter', 'return': 'Enter', 52 + 'tab': 'Tab', 53 + 'space': 'Space', ' ': 'Space', 54 + 'backspace': 'Backspace', 55 + 'delete': 'Delete', 56 + 'escape': 'Escape', 'esc': 'Escape', 57 + 'up': 'ArrowUp', 'down': 'ArrowDown', 'left': 'ArrowLeft', 'right': 'ArrowRight', 58 + 'arrowup': 'ArrowUp', 'arrowdown': 'ArrowDown', 'arrowleft': 'ArrowLeft', 'arrowright': 'ArrowRight', 59 + 'home': 'Home', 'end': 'End', 60 + 'pageup': 'PageUp', 'pagedown': 'PageDown', 61 + // Function keys 62 + 'f1': 'F1', 'f2': 'F2', 'f3': 'F3', 'f4': 'F4', 'f5': 'F5', 'f6': 'F6', 63 + 'f7': 'F7', 'f8': 'F8', 'f9': 'F9', 'f10': 'F10', 'f11': 'F11', 'f12': 'F12', 64 + }; 65 + 66 + /** 67 + * Parse shortcut string to match Electron's input event format 68 + * e.g., 'Alt+Q' -> { alt: true, code: 'KeyQ' } 69 + * e.g., 'CommandOrControl+Shift+P' -> { meta: true, shift: true, code: 'KeyP' } (on Mac) 70 + */ 71 + export function parseShortcut(shortcut: string): ParsedShortcut { 72 + const parts = shortcut.toLowerCase().split('+'); 73 + const result: ParsedShortcut = { 74 + ctrl: false, 75 + alt: false, 76 + shift: false, 77 + meta: false, 78 + code: '' 79 + }; 80 + 81 + for (const part of parts) { 82 + const p = part.trim(); 83 + if (p === 'ctrl' || p === 'control') { 84 + result.ctrl = true; 85 + } else if (p === 'alt' || p === 'option') { 86 + result.alt = true; 87 + } else if (p === 'shift') { 88 + result.shift = true; 89 + } else if (p === 'meta' || p === 'cmd' || p === 'command' || p === 'super') { 90 + result.meta = true; 91 + } else if (p === 'commandorcontrol' || p === 'cmdorctrl') { 92 + // On Mac, use meta (Cmd), on others use ctrl 93 + if (process.platform === 'darwin') { 94 + result.meta = true; 95 + } else { 96 + result.ctrl = true; 97 + } 98 + } else { 99 + // This is the key itself - convert to code 100 + result.code = keyToCode[p] || p; 101 + } 102 + } 103 + 104 + return result; 105 + } 106 + 107 + /** 108 + * Check if an input event matches a parsed shortcut 109 + */ 110 + export interface InputEvent { 111 + type: string; 112 + alt: boolean; 113 + shift: boolean; 114 + meta: boolean; 115 + control: boolean; 116 + code: string; 117 + } 118 + 119 + export function inputMatchesShortcut(input: InputEvent, parsed: ParsedShortcut): boolean { 120 + // Check modifiers 121 + if (input.alt !== parsed.alt) return false; 122 + if (input.shift !== parsed.shift) return false; 123 + if (input.meta !== parsed.meta) return false; 124 + if (input.control !== parsed.ctrl) return false; 125 + 126 + // Check physical key code (case-insensitive comparison) 127 + return input.code.toLowerCase() === parsed.code.toLowerCase(); 128 + } 129 + 130 + /** 131 + * Register a global shortcut (works even when app doesn't have focus) 132 + */ 133 + export function registerGlobalShortcut( 134 + shortcut: string, 135 + source: string, 136 + callback: () => void 137 + ): Error | undefined { 138 + console.log('registerGlobalShortcut', shortcut); 139 + 140 + if (globalShortcut.isRegistered(shortcut)) { 141 + console.error('Shortcut already registered, unregistering first:', shortcut); 142 + globalShortcut.unregister(shortcut); 143 + } 144 + 145 + const ret = globalShortcut.register(shortcut, () => { 146 + console.log('shortcut executed', shortcut); 147 + callback(); 148 + }); 149 + 150 + if (ret !== true) { 151 + console.error('registerGlobalShortcut FAILED:', shortcut); 152 + return new Error(`Failed to register shortcut: ${shortcut}`); 153 + } 154 + 155 + globalShortcuts.set(shortcut, source); 156 + return undefined; 157 + } 158 + 159 + /** 160 + * Unregister a global shortcut 161 + */ 162 + export function unregisterGlobalShortcut(shortcut: string): Error | undefined { 163 + console.log('unregisterGlobalShortcut', shortcut); 164 + 165 + if (!globalShortcut.isRegistered(shortcut)) { 166 + console.error('Unable to unregister shortcut because not registered:', shortcut); 167 + return new Error(`Shortcut not registered: ${shortcut}`); 168 + } 169 + 170 + globalShortcut.unregister(shortcut); 171 + globalShortcuts.delete(shortcut); 172 + return undefined; 173 + } 174 + 175 + /** 176 + * Register a local shortcut (only works when app has focus) 177 + */ 178 + export function registerLocalShortcut( 179 + shortcut: string, 180 + source: string, 181 + callback: () => void 182 + ): void { 183 + console.log('registerLocalShortcut', shortcut); 184 + 185 + if (localShortcuts.has(shortcut)) { 186 + console.log('local shortcut already registered, replacing:', shortcut); 187 + } 188 + 189 + const parsed = parseShortcut(shortcut); 190 + localShortcuts.set(shortcut, { source, parsed, callback }); 191 + } 192 + 193 + /** 194 + * Unregister a local shortcut 195 + */ 196 + export function unregisterLocalShortcut(shortcut: string): void { 197 + console.log('unregisterLocalShortcut', shortcut); 198 + 199 + if (!localShortcuts.has(shortcut)) { 200 + console.error('local shortcut not registered:', shortcut); 201 + return; 202 + } 203 + 204 + localShortcuts.delete(shortcut); 205 + } 206 + 207 + /** 208 + * Handle local shortcuts from any focused window 209 + * Called from before-input-event handler 210 + * Returns true if shortcut was handled 211 + */ 212 + export function handleLocalShortcut(input: InputEvent): boolean { 213 + // Only handle keyDown events 214 + if (input.type !== 'keyDown') return false; 215 + 216 + for (const [, data] of localShortcuts) { 217 + if (inputMatchesShortcut(input, data.parsed)) { 218 + data.callback(); 219 + return true; 220 + } 221 + } 222 + return false; 223 + } 224 + 225 + /** 226 + * Unregister all shortcuts registered by a specific address 227 + */ 228 + export function unregisterShortcutsForAddress(address: string): void { 229 + // Unregister global shortcuts 230 + for (const [shortcut, source] of globalShortcuts) { 231 + if (source === address) { 232 + console.log('unregistering global shortcut', shortcut, 'for', address); 233 + unregisterGlobalShortcut(shortcut); 234 + } 235 + } 236 + 237 + // Unregister local shortcuts 238 + for (const [shortcut, data] of localShortcuts) { 239 + if (data.source === address) { 240 + console.log('unregistering local shortcut', shortcut, 'for', address); 241 + localShortcuts.delete(shortcut); 242 + } 243 + } 244 + } 245 + 246 + /** 247 + * Get the source address for a global shortcut 248 + */ 249 + export function getGlobalShortcutSource(shortcut: string): string | undefined { 250 + return globalShortcuts.get(shortcut); 251 + } 252 + 253 + /** 254 + * Check if a global shortcut is registered 255 + */ 256 + export function isGlobalShortcutRegistered(shortcut: string): boolean { 257 + return globalShortcut.isRegistered(shortcut); 258 + }
+66
backend/electron/tray.ts
··· 1 + /** 2 + * System tray management 3 + * 4 + * Handles: 5 + * - Creating and managing the system tray icon 6 + * - Tray click events 7 + */ 8 + 9 + import { Tray } from 'electron'; 10 + import path from 'node:path'; 11 + 12 + const ICON_RELATIVE_PATH = 'assets/tray/tray@2x.png'; 13 + 14 + let tray: Tray | null = null; 15 + 16 + export interface TrayOptions { 17 + tooltip: string; 18 + onClick?: () => void; 19 + } 20 + 21 + /** 22 + * Initialize the system tray 23 + * @param rootDir - Application root directory (for icon path) 24 + * @param options - Tray configuration options 25 + */ 26 + export function initTray(rootDir: string, options: TrayOptions): Tray | null { 27 + if (tray && !tray.isDestroyed()) { 28 + return tray; 29 + } 30 + 31 + const iconPath = path.join(rootDir, ICON_RELATIVE_PATH); 32 + console.log('initTray: loading icon from', iconPath); 33 + 34 + try { 35 + tray = new Tray(iconPath); 36 + tray.setToolTip(options.tooltip); 37 + 38 + if (options.onClick) { 39 + tray.on('click', options.onClick); 40 + } 41 + 42 + console.log('initTray: tray created successfully'); 43 + return tray; 44 + } catch (err) { 45 + const message = err instanceof Error ? err.message : String(err); 46 + console.error('initTray: failed to create tray:', message); 47 + return null; 48 + } 49 + } 50 + 51 + /** 52 + * Get the current tray instance 53 + */ 54 + export function getTray(): Tray | null { 55 + return tray && !tray.isDestroyed() ? tray : null; 56 + } 57 + 58 + /** 59 + * Destroy the tray 60 + */ 61 + export function destroyTray(): void { 62 + if (tray && !tray.isDestroyed()) { 63 + tray.destroy(); 64 + tray = null; 65 + } 66 + }
+336
backend/electron/windows.ts
··· 1 + /** 2 + * Window Management Helpers 3 + * 4 + * Helper functions for managing BrowserWindow instances. 5 + */ 6 + 7 + import { app, BrowserWindow, ipcMain } from 'electron'; 8 + import { 9 + WEB_CORE_ADDRESS, 10 + SETTINGS_ADDRESS, 11 + isTestProfile, 12 + } from './config.js'; 13 + import { 14 + getWindowInfo, 15 + getChildWindows, 16 + } from './main.js'; 17 + 18 + // Preferences getter - set by index.js during initialization 19 + let _getPrefs: () => Record<string, unknown> = () => ({}); 20 + 21 + /** 22 + * Set the preferences getter function 23 + */ 24 + export function setPrefsGetter(getter: () => Record<string, unknown>): void { 25 + _getPrefs = getter; 26 + } 27 + 28 + /** 29 + * Modify window state (close, hide, show) 30 + */ 31 + export function modWindow(bw: BrowserWindow, params: { action: string }): void { 32 + if (params.action === 'close') { 33 + bw.close(); 34 + } 35 + if (params.action === 'hide') { 36 + bw.hide(); 37 + } 38 + if (params.action === 'show') { 39 + bw.show(); 40 + } 41 + } 42 + 43 + /** 44 + * Ask renderer to handle escape key 45 + * Returns Promise<{ handled: boolean }> 46 + */ 47 + export function askRendererToHandleEscape(bw: BrowserWindow): Promise<{ handled: boolean }> { 48 + return new Promise((resolve) => { 49 + const responseChannel = `escape-response-${bw.id}-${Date.now()}`; 50 + 51 + // Timeout after 100ms - if renderer doesn't respond, assume not handled 52 + const timeout = setTimeout(() => { 53 + ipcMain.removeAllListeners(responseChannel); 54 + resolve({ handled: false }); 55 + }, 100); 56 + 57 + ipcMain.once(responseChannel, (_event, response) => { 58 + clearTimeout(timeout); 59 + resolve(response || { handled: false }); 60 + }); 61 + 62 + bw.webContents.send('escape-pressed', { responseChannel }); 63 + }); 64 + } 65 + 66 + /** 67 + * Add escape key handler to a window 68 + * Supports escapeMode: 'close' (default), 'navigate', 'auto' 69 + */ 70 + export function addEscHandler(bw: BrowserWindow): void { 71 + console.log('adding esc handler to window:', bw.id); 72 + bw.webContents.on('before-input-event', async (e, i) => { 73 + if (i.key === 'Escape' && i.type === 'keyUp') { 74 + // Get window info 75 + const entry = getWindowInfo(bw.id); 76 + const params = entry?.params || {}; 77 + const escapeMode = (params.escapeMode as string) || 'close'; 78 + 79 + console.log(`ESC pressed - window ${bw.id}, escapeMode: ${escapeMode}`); 80 + 81 + // For 'navigate' mode, ask renderer first 82 + if (escapeMode === 'navigate') { 83 + const response = await askRendererToHandleEscape(bw); 84 + console.log(`Renderer escape response:`, response); 85 + 86 + if (response.handled) { 87 + // Renderer handled the escape (internal navigation) 88 + console.log('Renderer handled escape, not closing'); 89 + return; 90 + } 91 + } 92 + 93 + // For 'auto' mode, check if transient (no focused window when opened) 94 + if (escapeMode === 'auto') { 95 + if (params.transient) { 96 + // Transient mode - close immediately 97 + console.log('Auto mode (transient) - closing'); 98 + } else { 99 + // Active mode - ask renderer first 100 + const response = await askRendererToHandleEscape(bw); 101 + console.log(`Renderer escape response (auto/active):`, response); 102 + 103 + if (response.handled) { 104 + console.log('Renderer handled escape, not closing'); 105 + return; 106 + } 107 + } 108 + } 109 + 110 + // Close or hide the window 111 + console.log('Closing/hiding window'); 112 + closeOrHideWindow(bw.id); 113 + } 114 + }); 115 + } 116 + 117 + /** 118 + * Configure devtools for a window based on its parameters 119 + */ 120 + export function winDevtoolsConfig(bw: BrowserWindow): void { 121 + const windowData = getWindowInfo(bw.id); 122 + const params = windowData ? windowData.params : {}; 123 + 124 + console.log('winDevtoolsConfig:', bw.id, 'openDevTools:', params.openDevTools, 'address:', params.address); 125 + 126 + // Check if devTools should be opened (never in test profiles) 127 + if (params.openDevTools === true && !isTestProfile()) { 128 + const isDetached = params.detachedDevTools === true; 129 + // Determine if detached mode should be used 130 + // activate: false prevents devtools from stealing focus (only works with detach/undocked) 131 + const devToolsOptions: Electron.OpenDevToolsOptions = { 132 + mode: isDetached ? 'detach' : 'right', 133 + activate: false 134 + }; 135 + 136 + console.log(`Opening DevTools for window ${bw.id} with options:`, devToolsOptions); 137 + 138 + // Open DevTools after a slight delay to let the main window settle 139 + setTimeout(() => { 140 + bw.webContents.openDevTools(devToolsOptions); 141 + 142 + // when devtools completely open, ensure content window has focus 143 + bw.webContents.once('devtools-opened', () => { 144 + // Re-focus the content window after devtools opens 145 + setTimeout(() => { 146 + if (bw.isVisible() && !bw.isDestroyed()) { 147 + bw.focus(); 148 + bw.webContents.focus(); 149 + } 150 + }, 100); 151 + }); 152 + }, 50); 153 + } 154 + } 155 + 156 + /** 157 + * Close a window and its children 158 + * This will actually close the window regardless of "keep alive" opener params 159 + */ 160 + export function closeWindow(params: { id?: number }, callback?: (success: boolean) => void): void { 161 + console.log('closeWindow', params, callback != null); 162 + 163 + let retval = false; 164 + 165 + if (params.id !== undefined && getWindowInfo(params.id)) { 166 + console.log('closeWindow(): closing', params.id); 167 + 168 + const entry = getWindowInfo(params.id); 169 + if (!entry) { 170 + // wtf 171 + if (callback) callback(false); 172 + return; 173 + } 174 + 175 + closeChildWindows(entry.params.address as string); 176 + 177 + const win = BrowserWindow.fromId(params.id); 178 + if (win) { 179 + win.close(); 180 + } 181 + 182 + retval = true; 183 + } 184 + 185 + if (callback) { 186 + callback(retval); 187 + } 188 + } 189 + 190 + /** 191 + * Get count of visible user windows (excluding background window) 192 + */ 193 + export function getVisibleWindowCount(excludeId: number | null = null): number { 194 + return BrowserWindow.getAllWindows().filter(win => { 195 + if (excludeId && win.id === excludeId) return false; 196 + if (win.isDestroyed()) return false; 197 + if (!win.isVisible()) return false; 198 + 199 + // Exclude the background window 200 + const entry = getWindowInfo(win.id); 201 + if (entry && entry.params.address === WEB_CORE_ADDRESS) return false; 202 + 203 + return true; 204 + }).length; 205 + } 206 + 207 + /** 208 + * Update dock visibility based on visible windows and pref 209 + * Show dock if: visible windows exist OR pref is enabled 210 + * Hide dock if: no visible windows AND pref is disabled 211 + */ 212 + export function updateDockVisibility(excludeId: number | null = null): void { 213 + if (process.platform !== 'darwin' || !app.dock) return; 214 + 215 + const visibleCount = getVisibleWindowCount(excludeId); 216 + const prefs = _getPrefs(); 217 + const prefShowDock = prefs?.showInDockAndSwitcher === true; 218 + 219 + console.log('updateDockVisibility:', { visibleCount, prefShowDock, excludeId }); 220 + 221 + if (visibleCount > 0 || prefShowDock) { 222 + console.log('Showing dock'); 223 + app.dock.show(); 224 + } else { 225 + console.log('Hiding dock'); 226 + app.dock.hide(); 227 + } 228 + } 229 + 230 + /** 231 + * Hide the app if there are no other visible windows 232 + */ 233 + export function maybeHideApp(excludeId: number): void { 234 + if (process.platform !== 'darwin') return; 235 + 236 + const visibleCount = getVisibleWindowCount(excludeId); 237 + console.log('maybeHideApp: visible windows (excluding', excludeId + '):', visibleCount); 238 + 239 + if (visibleCount === 0) { 240 + console.log('No other visible windows, hiding app'); 241 + app.hide(); 242 + } else { 243 + console.log('Other windows visible, not hiding app'); 244 + } 245 + 246 + // Also update dock visibility 247 + updateDockVisibility(excludeId); 248 + } 249 + 250 + /** 251 + * Close or hide a window based on its parameters 252 + */ 253 + export function closeOrHideWindow(id: number): void { 254 + console.log('closeOrHideWindow called for ID:', id); 255 + 256 + try { 257 + const win = BrowserWindow.fromId(id); 258 + if (!win || win.isDestroyed()) { 259 + console.log('Window already destroyed or invalid'); 260 + return; 261 + } 262 + 263 + const entry = getWindowInfo(id); 264 + console.log('Window entry from manager:', entry); 265 + 266 + if (!entry) { 267 + console.log('Window not found in window manager, closing directly'); 268 + win.close(); 269 + return; 270 + } 271 + 272 + const params = entry.params; 273 + console.log('Window parameters - modal:', params.modal, 'keepLive:', params.keepLive); 274 + 275 + // Never close the background window 276 + if (params.address === WEB_CORE_ADDRESS) { 277 + console.log('Refusing to close background window'); 278 + return; 279 + } 280 + 281 + // Special case for settings window - always close it on ESC 282 + if (params.address === SETTINGS_ADDRESS) { 283 + console.log(`CLOSING settings window ${id}`); 284 + closeChildWindows(params.address as string); 285 + win.close(); 286 + // Hide app to return focus to previous app (only if no other visible windows) 287 + maybeHideApp(id); 288 + } 289 + // Check if window should be hidden rather than closed 290 + // Either keepLive or modal parameter can trigger hiding behavior 291 + else if (params.keepLive === true || params.modal === true) { 292 + win.hide(); 293 + // Hide app to return focus to previous app (only if no other visible windows) 294 + maybeHideApp(id); 295 + } else { 296 + // close any open windows this window opened 297 + closeChildWindows(params.address as string); 298 + console.log(`CLOSING window ${id} (${params.address})`); 299 + win.close(); 300 + // Hide app to return focus to previous app (only if no other visible windows) 301 + maybeHideApp(id); 302 + } 303 + 304 + console.log('closeOrHideWindow completed'); 305 + } catch (error) { 306 + console.error('Error in closeOrHideWindow:', error); 307 + } 308 + } 309 + 310 + /** 311 + * Close all child windows of a given address 312 + */ 313 + export function closeChildWindows(aAddress: string): void { 314 + console.log('closeChildWindows()', aAddress); 315 + 316 + if (aAddress === WEB_CORE_ADDRESS) { 317 + return; 318 + } 319 + 320 + // Get all child windows from the window manager 321 + const childWindows = getChildWindows(aAddress); 322 + 323 + for (const child of childWindows) { 324 + const address = child.data.params.address as string; 325 + console.log('closing child window', address, 'for', aAddress); 326 + 327 + // recurseme 328 + closeChildWindows(address); 329 + 330 + // close window 331 + const win = BrowserWindow.fromId(child.id); 332 + if (win) { 333 + win.close(); 334 + } 335 + } 336 + }
+53 -1843
index.js
··· 1 1 // main.js 2 2 3 - import { 4 - app, 5 - BrowserWindow, 6 - dialog, 7 - ipcMain, 8 - Menu, 9 - nativeImage, 10 - nativeTheme, 11 - } from 'electron'; 3 + import { app } from 'electron'; 12 4 13 5 import fs from 'node:fs'; 14 6 import path from 'node:path'; ··· 19 11 configure, 20 12 initialize, 21 13 discoverBuiltinExtensions, 22 - createExtensionWindow, 23 14 loadEnabledExtensions, 24 - getRunningExtensions, 25 - destroyExtensionWindow, 26 - getExtensionWindow, 27 - registerWindow, 28 - getWindowInfo, 29 - findWindowByKey, 30 - shutdown, 31 - // Database 32 - getDb, 33 - isValidTable, 34 - // Datastore operations 35 - addAddress, 36 - getAddress, 37 - updateAddress, 38 - queryAddresses, 39 - addVisit, 40 - queryVisits, 41 - addContent, 42 - queryContent, 43 - getOrCreateTag, 44 - tagAddress, 45 - untagAddress, 46 - getTagsByFrecency, 47 - getAddressTags, 48 - getAddressesByTag, 49 - getUntaggedAddresses, 50 - getTable, 51 - setRow, 52 - getStats, 53 - // Protocol 54 - APP_SCHEME, 55 - APP_PROTOCOL, 56 - registerExtensionPath, 57 - getExtensionPath, 58 - loadExtensionManifest, 15 + // External URL handling 16 + setAppReady, 17 + registerExternalUrlHandlers, 18 + registerSecondInstanceHandler, 19 + handleCliUrl, 20 + // Background window 21 + createBackgroundWindow, 22 + // App lifecycle 23 + registerWindowAllClosedHandler, 24 + registerActivateHandler, 25 + requestSingleInstance, 26 + quitApp, 59 27 // Tray 60 28 initTray, 61 29 // Shortcuts 62 - registerGlobalShortcut, 63 - unregisterGlobalShortcut, 64 30 registerLocalShortcut, 65 31 unregisterLocalShortcut, 66 - unregisterShortcutsForAddress, 67 32 // PubSub 68 33 scopes, 69 34 publish as pubsubPublish, 70 35 subscribe as pubsubSubscribe, 71 36 getSystemAddress, 37 + // IPC 38 + registerAllHandlers, 39 + // Config 40 + WEB_CORE_ADDRESS, 41 + SETTINGS_ADDRESS, 42 + setPreloadPath, 43 + setProfile, 44 + isTestProfile, 45 + // Window helpers 46 + setPrefsGetter, 47 + updateDockVisibility, 72 48 } from './dist/backend/electron/index.js'; 73 49 import unhandled from 'electron-unhandled'; 74 50 ··· 95 71 // script loaded into every app window 96 72 const preloadPath = path.join(__dirname, 'preload.js'); 97 73 98 - const APP_CORE_PATH = 'app'; 99 - 100 - const APP_DEF_WIDTH = 1024; 101 - const APP_DEF_HEIGHT = 768; 74 + const systemAddress = getSystemAddress(); 102 75 103 - // app hidden window to load 104 - // core application logic is here 105 - const webCoreAddress = 'peek://app/background.html'; 106 - //const webCoreAddress = 'peek://test/index.html'; 107 - 108 - const systemAddress = getSystemAddress(); 109 - const settingsAddress = 'peek://app/settings/settings.html'; 76 + // Initialize backend config with runtime values 77 + setPreloadPath(preloadPath); 110 78 111 79 const strings = { 112 80 defaults: { 113 81 quitShortcut: 'Option+q' 114 82 }, 115 - msgs: { 116 - registerShortcut: 'registershortcut', 117 - unregisterShortcut: 'unregistershortcut', 118 - publish: 'publish', 119 - subscribe: 'subscribe', 120 - closeWindow: 'closewindow', 121 - console: 'console', 122 - }, 123 83 topics: { 124 84 prefs: 'topic:core:prefs' 125 - }, 126 - shortcuts: { 127 - errorAlreadyRegistered: 'Shortcut already registered', 128 - errorRegistrationFailed: 'Shortcut registration failed' 129 85 } 130 86 }; 131 87 ··· 141 97 142 98 console.log('PROFILE', PROFILE, app.isPackaged ? '(packaged)' : '(source)'); 143 99 144 - // Test profiles skip certain behaviors (devtools, dialogs, etc.) 145 - const isTestProfile = PROFILE.startsWith('test'); 100 + // Set profile in backend config 101 + setProfile(PROFILE); 146 102 147 103 // Profile dirs are subdir of userData dir 148 104 // ..................................... ↓ we set this per profile ··· 193 149 194 150 // ***** System / OS / Theme ***** 195 151 196 - // system dark mode handling 197 - ipcMain.handle('dark-mode:toggle', () => { 198 - if (nativeTheme.shouldUseDarkColors) { 199 - nativeTheme.themeSource = 'light'; 200 - } else { 201 - nativeTheme.themeSource = 'dark'; 202 - } 203 - return nativeTheme.shouldUseDarkColors 204 - }); 205 - 206 - ipcMain.handle('dark-mode:system', () => { 207 - nativeTheme.themeSource = 'system'; 208 - }); 209 - 210 - // TODO: when does this actually hit on each OS? 211 - app.on('activate', () => { 212 - // On macOS it's common to re-create a window in the app when the 213 - // dock icon is clicked and there are no other windows open. 214 - if (BrowserWindow.getAllWindows().length === 0) { 215 - //getMainWindow().show(); 216 - } 217 - }); 152 + // Register activate handler (macOS dock click) 153 + registerActivateHandler(); 218 154 219 155 // ***** Caches ***** 220 156 ··· 223 159 let _prefs = {}; 224 160 let _quitShortcut = null; 225 161 226 - // ***** Window Manager ***** 227 - 228 - class WindowManager { 229 - constructor() { 230 - this.windows = new Map(); 231 - 232 - // Track window close events to clean up 233 - app.on('browser-window-created', (_, window) => { 234 - window.on('closed', () => { 235 - const windowId = window.id; 236 - const windowData = this.getWindow(windowId); 237 - 238 - // Notify subscribers that window was closed 239 - if (windowData) { 240 - pubsub.publish(windowData.source, scopes.GLOBAL, 'window:closed', { 241 - id: windowId, 242 - source: windowData.source 243 - }); 244 - } 245 - 246 - // Remove from window manager 247 - this.removeWindow(windowId); 248 - }); 249 - 250 - // Handle local shortcuts on all windows via before-input-event 251 - window.webContents.on('before-input-event', (event, input) => { 252 - if (handleLocalShortcut(input)) { 253 - event.preventDefault(); 254 - } 255 - }); 256 - }); 257 - } 258 - 259 - addWindow(id, options) { 260 - this.windows.set(id, options); 261 - } 262 - 263 - getWindow(id) { 264 - return this.windows.get(id); 265 - } 266 - 267 - removeWindow(id) { 268 - this.windows.delete(id); 269 - } 270 - 271 - findWindowByKey(source, key) { 272 - if (!key) return null; 273 - 274 - for (const [id, win] of this.windows) { 275 - if (win.source === source && win.params && win.params.key === key) { 276 - return { id, window: BrowserWindow.fromId(id), data: win }; 277 - } 278 - } 279 - return null; 280 - } 281 - 282 - getChildWindows(source) { 283 - const children = []; 284 - for (const [id, win] of this.windows) { 285 - if (win.source === source) { 286 - children.push({ id, data: win }); 287 - } 288 - } 289 - return children; 290 - } 291 - } 292 - 293 - // Initialize window manager 294 - const windowManager = new WindowManager(); 162 + // Set up prefs getter for backend window helpers 163 + setPrefsGetter(() => _prefs); 295 164 296 165 // ***** pubsub ***** 297 166 // Wrapper object for backend pubsub functions ··· 299 168 publish: pubsubPublish, 300 169 subscribe: pubsubSubscribe 301 170 }; 302 - 303 - // ***** Command Registry ***** 304 - // Stores commands registered via cmd:register topic 305 - // This enables cmd app to query commands registered before it started 306 - const commandRegistry = new Map(); 307 171 308 172 // ***** init ***** 309 173 ··· 320 184 // Initialize backend (database, protocol handler, pubsub broadcaster) 321 185 await initialize(); 322 186 323 - //https://stackoverflow.com/questions/35916158/how-to-prevent-multiple-instances-in-electron 324 - const gotTheLock = app.requestSingleInstanceLock(); 325 - if (!gotTheLock) { 326 - console.error('APP INSTANCE ALREADY RUNNING, QUITTING'); 327 - app.quit(); 187 + // Register all IPC handlers from backend 188 + registerAllHandlers(onQuit); 189 + 190 + // Ensure single instance 191 + if (!requestSingleInstance()) { 328 192 return; 329 193 } 330 194 331 195 // Windows/Linux: handle URLs when another instance tries to open 332 - app.on('second-instance', (event, argv) => { 333 - const url = argv.find(arg => 334 - arg.startsWith('http://') || arg.startsWith('https://') 335 - ); 336 - if (url) { 337 - console.log('second-instance URL:', url); 338 - handleExternalUrl(url, 'os'); 339 - } 340 - }); 196 + registerSecondInstanceHandler(); 341 197 342 198 // Discover and register built-in extensions from extensions/ folder 343 199 discoverBuiltinExtensions(path.join(__dirname, 'extensions')); 344 200 345 201 // Register as default handler for http/https URLs (if not already and user hasn't declined) 346 202 // Skip for test profiles to avoid system dialogs during automated testing 347 - const isTestProfile = PROFILE.startsWith('test'); 348 - if (isTestProfile) { 203 + if (isTestProfile()) { 349 204 console.log('Skipping default browser check for test profile:', PROFILE); 350 205 } 351 206 352 207 const defaultBrowserPrefFile = path.join(profileDataPath, 'default-browser-pref.json'); 353 - let shouldPromptForDefault = !isTestProfile; 208 + let shouldPromptForDefault = !isTestProfile(); 354 209 355 210 // Check if user has previously declined 356 211 try { ··· 395 250 } 396 251 397 252 // Handle CLI arguments (e.g., yarn start -- "https://example.com") 398 - const urlArg = process.argv.find(arg => 399 - arg.startsWith('http://') || arg.startsWith('https://') 400 - ); 401 - if (urlArg) { 402 - console.log('CLI URL argument:', urlArg); 403 - // Defer until background app is ready 404 - setTimeout(() => handleExternalUrl(urlArg, 'cli'), 1000); 405 - } 253 + handleCliUrl(); 406 254 407 255 // Track if extensions have been loaded (only load once) 408 256 let extensionsLoaded = false; ··· 424 272 initTray(__dirname, { 425 273 tooltip: labels.tray.tooltip, 426 274 onClick: () => { 427 - pubsub.publish(webCoreAddress, scopes.GLOBAL, 'open', { 428 - address: settingsAddress 275 + pubsub.publish(WEB_CORE_ADDRESS, scopes.GLOBAL, 'open', { 276 + address: SETTINGS_ADDRESS 429 277 }); 430 278 } 431 279 }); ··· 451 299 } 452 300 }); 453 301 454 - // Initialize the background window using the new window-open method 455 - // Create a BrowserWindow directly for the core background process 456 - const winPrefs = { 457 - show: false, 458 - // TODO: maybe not necessary now? 459 - key: 'background-core', 460 - webPreferences: { 461 - preload: preloadPath, 462 - // Should not be needed false ever or something has gone very 463 - // wrong. 464 - //webSecurity: false 465 - } 466 - }; 467 - 468 - // Create the background window 469 - const win = new BrowserWindow(winPrefs); 470 - win.loadURL(webCoreAddress); 471 - 472 - // Setup devtools for the background window (debug mode, but not in tests) 473 - if (DEBUG && !isTestProfile) { 474 - win.webContents.openDevTools({ mode: 'detach', activate: false }); 475 - } 476 - 477 - // Add to window manager 478 - windowManager.addWindow(win.id, { 479 - id: win.id, 480 - source: systemAddress, 481 - params: { ...winPrefs, address: webCoreAddress } 482 - }); 483 - 484 - // NOTE: No ESC handler for background window - it should never be closed 485 - 486 - // Set up handlers for windows opened from the background window 487 - win.webContents.setWindowOpenHandler((details) => { 488 - console.log('Background window opening child window:', details.url); 489 - 490 - // Parse window features into options 491 - const featuresMap = {}; 492 - if (details.features) { 493 - details.features.split(',') 494 - .map(entry => entry.split('=')) 495 - .forEach(([key, value]) => { 496 - // Convert string booleans to actual booleans 497 - if (value === 'true') value = true; 498 - else if (value === 'false') value = false; 499 - // Convert numeric values to numbers 500 - else if (!isNaN(value) && value.trim() !== '') { 501 - value = parseInt(value, 10); 502 - } 503 - featuresMap[key] = value; 504 - }); 505 - } 506 - 507 - console.log('Parsed features map:', featuresMap); 508 - 509 - // Check if window with this key already exists 510 - if (featuresMap.key) { 511 - const existingWindow = windowManager.findWindowByKey(webCoreAddress, featuresMap.key); 512 - if (existingWindow) { 513 - console.log('Reusing existing window with key:', featuresMap.key); 514 - existingWindow.window.show(); 515 - return { action: 'deny' }; 516 - } 517 - } 518 - 519 - // Create a new window - we'll handle it directly 520 - 521 - // Prepare browser window options 522 - const winOptions = { 523 - ...featuresMap, 524 - width: parseInt(featuresMap.width) || APP_DEF_WIDTH, 525 - height: parseInt(featuresMap.height) || APP_DEF_HEIGHT, 526 - show: featuresMap.show !== false, 527 - webPreferences: { 528 - preload: preloadPath 529 - } 530 - }; 531 - 532 - // Make sure position parameters are correctly handled 533 - if (featuresMap.x !== undefined) { 534 - winOptions.x = parseInt(featuresMap.x); 535 - } 536 - if (featuresMap.y !== undefined) { 537 - winOptions.y = parseInt(featuresMap.y); 538 - } 539 - 540 - console.log('Background window creating child with options:', winOptions); 541 - 542 - // Make sure we register browser window created handler to track the new window 543 - const onCreated = (e, newWin) => { 544 - // Check if this is the window we just created 545 - newWin.webContents.once('did-finish-load', () => { 546 - const loadedUrl = newWin.webContents.getURL(); 547 - if (loadedUrl === details.url) { 548 - // Remove the listener 549 - app.removeListener('browser-window-created', onCreated); 550 - 551 - // Add the window to our manager with necessary parameters 552 - windowManager.addWindow(newWin.id, { 553 - id: newWin.id, 554 - source: webCoreAddress, 555 - params: { 556 - ...featuresMap, 557 - address: details.url, 558 - modal: featuresMap.modal 559 - } 560 - }); 561 - 562 - // Add escape key handler 563 - addEscHandler(newWin); 564 - 565 - // Set up DevTools if requested 566 - winDevtoolsConfig(newWin); 567 - 568 - // Set up modal behavior with delay to avoid focus race condition 569 - if (featuresMap.modal === true) { 570 - setTimeout(() => { 571 - if (!newWin.isDestroyed()) { 572 - newWin.on('blur', () => { 573 - console.log('Modal window lost focus:', details.url); 574 - closeOrHideWindow(newWin.id); 575 - }); 576 - } 577 - }, 100); 578 - } 579 - } 580 - }); 581 - }; 582 - 583 - // Start listening for the window creation 584 - app.on('browser-window-created', onCreated); 585 - 586 - // Return allow with overridden options 587 - return { 588 - action: 'allow', 589 - overrideBrowserWindowOptions: winOptions 590 - }; 591 - }); 302 + // Create the core background window 303 + createBackgroundWindow(); 592 304 593 305 // Register default quit shortcut (local - only works when app has focus) 594 306 // Will be updated when prefs arrive ··· 596 308 registerLocalShortcut(_quitShortcut, 'system', onQuit); 597 309 598 310 // Mark app as ready and process any URLs that arrived during startup 599 - _appReady = true; 600 - processPendingUrls(); 601 - }; 602 - 603 - // ***** External URL Handler ***** 604 - 605 - // Track if app is ready to handle URLs 606 - let _appReady = false; 607 - let _pendingUrls = []; 608 - 609 - // Handle URLs opened from external apps (e.g., when Peek is default browser) 610 - const handleExternalUrl = (url, sourceId = 'os') => { 611 - console.log('External URL received:', url, 'from:', sourceId); 612 - 613 - if (!_appReady) { 614 - _pendingUrls.push({ url, sourceId }); 615 - return; 616 - } 617 - 618 - // Note: Using trackingSource/trackingSourceId because preload.js overwrites msg.source 619 - pubsub.publish(systemAddress, scopes.GLOBAL, 'external:open-url', { 620 - url, 621 - trackingSource: 'external', 622 - trackingSourceId: sourceId, 623 - timestamp: Date.now() 624 - }); 625 - }; 626 - 627 - // Process any URLs that arrived before app was ready 628 - const processPendingUrls = () => { 629 - _pendingUrls.forEach(({ url, sourceId }) => { 630 - handleExternalUrl(url, sourceId); 631 - }); 632 - _pendingUrls = []; 311 + setAppReady(); 633 312 }; 634 313 635 314 // macOS: handle open-url event (must be registered before app.whenReady) 636 - app.on('open-url', (event, url) => { 637 - event.preventDefault(); 638 - handleExternalUrl(url, 'os'); 639 - }); 315 + registerExternalUrlHandlers(); 640 316 641 317 // Configure app before ready (registers protocol scheme, sets theme) 642 318 configure({ ··· 648 324 isTest: PROFILE.startsWith('test') 649 325 }); 650 326 651 - app.whenReady().then(onReady); 652 - 653 - // ***** API ***** 654 - 655 - // Renderer log forwarding - prints renderer console.log to terminal 656 - ipcMain.on('renderer-log', (ev, msg) => { 657 - const shortSource = msg.source.replace('peek://app/', ''); 658 - console.log(`[${shortSource}]`, ...msg.args); 659 - }); 660 - 661 - ipcMain.on(strings.msgs.registerShortcut, (ev, msg) => { 662 - const isGlobal = msg.global === true; 663 - console.log('ipc register shortcut', msg.shortcut, isGlobal ? '(global)' : '(local)'); 664 - 665 - const callback = () => { 666 - console.log('on(registershortcut): shortcut executed', msg.shortcut, msg.replyTopic); 667 - ev.reply(msg.replyTopic, { foo: 'bar' }); 668 - }; 669 - 670 - if (isGlobal) { 671 - registerGlobalShortcut(msg.shortcut, msg.source, callback); 672 - } else { 673 - registerLocalShortcut(msg.shortcut, msg.source, callback); 674 - } 675 - }); 676 - 677 - ipcMain.on(strings.msgs.unregisterShortcut, (ev, msg) => { 678 - const isGlobal = msg.global === true; 679 - console.log('ipc unregister shortcut', msg.shortcut, isGlobal ? '(global)' : '(local)'); 680 - 681 - if (isGlobal) { 682 - const err = unregisterGlobalShortcut(msg.shortcut); 683 - if (err) { 684 - console.log('ipc unregister global shortcut error:', err.message); 685 - } 686 - } else { 687 - unregisterLocalShortcut(msg.shortcut); 688 - } 689 - }); 690 - 691 - ipcMain.on(strings.msgs.closeWindow, (ev, msg) => { 692 - closeWindow(msg.params, output => { 693 - console.log('main.closeWindow api callback, output:', output); 694 - if (msg && msg.replyTopic) { 695 - ev.reply(msg.replyTopic, output); 696 - } 697 - }); 698 - }); 699 - 700 - // generic dispatch - messages only from trusted code (💀) 701 - ipcMain.on(strings.msgs.publish, (ev, msg) => { 702 - console.log('ipc:publish', msg); 703 - 704 - // Intercept command registration to store in registry 705 - if (msg.topic === 'cmd:register' && msg.data) { 706 - commandRegistry.set(msg.data.name, { 707 - name: msg.data.name, 708 - description: msg.data.description || '', 709 - source: msg.data.source 710 - }); 711 - console.log('[cmd-registry] Registered command:', msg.data.name); 712 - } else if (msg.topic === 'cmd:unregister' && msg.data) { 713 - commandRegistry.delete(msg.data.name); 714 - console.log('[cmd-registry] Unregistered command:', msg.data.name); 715 - } 716 - 717 - pubsub.publish(msg.source, msg.scope, msg.topic, msg.data); 718 - }); 719 - 720 - ipcMain.on(strings.msgs.subscribe, (ev, msg) => { 721 - console.log('ipc:subscribe', msg); 722 - 723 - pubsub.subscribe(msg.source, msg.scope, msg.topic, data => { 724 - console.log('ipc:subscribe:notification', msg); 725 - ev.reply(msg.replyTopic, data); 726 - }); 727 - }); 728 - 729 - // Query all registered commands from the registry 730 - ipcMain.handle('get-registered-commands', async () => { 731 - const commands = Array.from(commandRegistry.values()); 732 - console.log('[cmd-registry] Query returned', commands.length, 'commands'); 733 - return { success: true, data: commands }; 734 - }); 735 - 736 - ipcMain.on(strings.msgs.console, (ev, msg) => { 737 - console.log('r:', msg.source, msg.text); 738 - }); 739 - 740 - ipcMain.on('app-quit', (ev, msg) => { 741 - console.log('app-quit requested from:', msg?.source); 742 - onQuit(); 743 - }); 744 - 745 - ipcMain.on('modifywindow', (ev, msg) => { 746 - console.log('modifywindow', msg); 747 - 748 - const key = msg.hasOwnProperty('name') ? msg.name : null; 749 - 750 - if (key != null) { 751 - const existingWindow = windowManager.findWindowByKey(msg.source, key); 752 - if (existingWindow) { 753 - console.log('FOUND WINDOW FOR KEY', key); 754 - const bw = existingWindow.window; 755 - let r = false; 756 - try { 757 - modWindow(bw, msg.params); 758 - r = true; 759 - } 760 - catch(ex) { 761 - console.error(ex); 762 - } 763 - ev.reply(msg.replyTopic, { output: r }); 764 - } 765 - } 766 - }); 767 - 768 - // Window API handlers 769 - ipcMain.handle('window-open', async (ev, msg) => { 770 - console.log('window-open', msg); 771 - 772 - const { url, options } = msg; 773 - 774 - // Check if window with this key already exists 775 - if (options.key) { 776 - const existingWindow = windowManager.findWindowByKey(msg.source, options.key); 777 - if (existingWindow) { 778 - console.log('Reusing existing window with key:', options.key); 779 - existingWindow.window.show(); 780 - return { success: true, id: existingWindow.id, reused: true }; 781 - } 782 - } 783 - 784 - // Prepare browser window options 785 - const winOptions = { 786 - ...options, // Pass all options to support any BrowserWindow constructor param 787 - width: parseInt(options.width) || APP_DEF_WIDTH, 788 - height: parseInt(options.height) || APP_DEF_HEIGHT, 789 - show: options.show !== false, 790 - webPreferences: { 791 - ...options.webPreferences, 792 - preload: preloadPath 793 - } 794 - }; 795 - 796 - // Make sure position parameters are correctly handled 797 - if (options.x !== undefined) { 798 - winOptions.x = parseInt(options.x); 799 - } 800 - if (options.y !== undefined) { 801 - winOptions.y = parseInt(options.y); 802 - } 803 - 804 - if (options.modal === true) { 805 - winOptions.frame = false; 806 - // Use panel type on macOS to improve focus restoration when closed 807 - if (process.platform === 'darwin') { 808 - winOptions.type = 'panel'; 809 - } 810 - } 811 - 812 - console.log('Creating window with options:', winOptions); 813 - 814 - // Create new window 815 - const win = new BrowserWindow(winOptions); 816 - 817 - // Forward console logs from window to main process stdout (for debugging) 818 - win.webContents.on('console-message', (event, level, message, line, sourceId) => { 819 - // Only forward for peek:// URLs to avoid noise 820 - if (url.startsWith('peek://')) { 821 - console.log(`[${url.replace('peek://', '')}] ${message}`); 822 - } 823 - }); 824 - 825 - try { 826 - await win.loadURL(url); 827 - 828 - // Determine if this is a transient window (opened while no Peek window was focused) 829 - // Used for escapeMode: 'auto' to decide between navigate and close behavior 830 - const focusedWindow = BrowserWindow.getFocusedWindow(); 831 - const isTransient = !focusedWindow || focusedWindow.isDestroyed(); 832 - 833 - // Add to window manager with modal parameter 834 - const windowEntry = { 835 - id: win.id, 836 - source: msg.source, 837 - params: { 838 - ...options, 839 - address: url, 840 - transient: isTransient 841 - } 842 - }; 843 - console.log('Adding window to manager:', windowEntry.id, 'modal:', windowEntry.params.modal, 'keepLive:', windowEntry.params.keepLive); 844 - windowManager.addWindow(win.id, windowEntry); 845 - 846 - // Add escape key handler to all windows 847 - addEscHandler(win); 848 - 849 - // Set up DevTools if requested 850 - winDevtoolsConfig(win); 851 - 852 - // Set up modal behavior if requested 853 - // Delay blur handler attachment to avoid race condition where focus events 854 - // are still settling after window creation (can cause immediate close) 855 - if (options.modal === true) { 856 - setTimeout(() => { 857 - if (!win.isDestroyed()) { 858 - win.on('blur', () => { 859 - console.log('window-open: blur for modal window', url); 860 - closeOrHideWindow(win.id); 861 - }); 862 - } 863 - }, 100); 864 - } 865 - 866 - // Show dock when window opens 867 - updateDockVisibility(); 868 - 869 - return { success: true, id: win.id }; 870 - } catch (error) { 871 - console.error('Failed to open window:', error); 872 - return { success: false, error: error.message }; 873 - } 874 - }); 875 - 876 - ipcMain.handle('window-close', async (ev, msg) => { 877 - console.log('window-close', msg); 878 - 879 - try { 880 - if (!msg.id) { 881 - return { success: false, error: 'Window ID is required' }; 882 - } 883 - 884 - const win = BrowserWindow.fromId(msg.id); 885 - if (!win) { 886 - return { success: false, error: 'Window not found' }; 887 - } 888 - 889 - win.close(); 890 - // WindowManager will automatically clean up on window close event 891 - return { success: true }; 892 - } catch (error) { 893 - console.error('Failed to close window:', error); 894 - return { success: false, error: error.message }; 895 - } 896 - }); 897 - 898 - ipcMain.handle('window-hide', async (ev, msg) => { 899 - console.log('window-hide', msg); 900 - 901 - try { 902 - if (!msg.id) { 903 - return { success: false, error: 'Window ID is required' }; 904 - } 905 - 906 - // Get window data from manager to verify it exists 907 - const winData = windowManager.getWindow(msg.id); 908 - if (!winData) { 909 - return { success: false, error: 'Window not found in window manager' }; 910 - } 911 - 912 - const win = BrowserWindow.fromId(msg.id); 913 - if (!win) { 914 - // Clean up stale window reference 915 - windowManager.removeWindow(msg.id); 916 - return { success: false, error: 'Window not found' }; 917 - } 918 - 919 - win.hide(); 920 - return { success: true }; 921 - } catch (error) { 922 - console.error('Failed to hide window:', error); 923 - return { success: false, error: error.message }; 924 - } 925 - }); 926 - 927 - ipcMain.handle('window-show', async (ev, msg) => { 928 - console.log('window-show', msg); 929 - 930 - try { 931 - if (!msg.id) { 932 - return { success: false, error: 'Window ID is required' }; 933 - } 934 - 935 - // Get window data from manager to verify it exists 936 - const winData = windowManager.getWindow(msg.id); 937 - if (!winData) { 938 - return { success: false, error: 'Window not found in window manager' }; 939 - } 940 - 941 - const win = BrowserWindow.fromId(msg.id); 942 - if (!win) { 943 - // Clean up stale window reference 944 - windowManager.removeWindow(msg.id); 945 - return { success: false, error: 'Window not found' }; 946 - } 947 - 948 - win.show(); 949 - updateDockVisibility(); 950 - return { success: true }; 951 - } catch (error) { 952 - console.error('Failed to show window:', error); 953 - return { success: false, error: error.message }; 954 - } 955 - }); 956 - 957 - ipcMain.handle('window-move', async (ev, msg) => { 958 - console.log('window-move', msg); 959 - 960 - try { 961 - if (!msg.id) { 962 - return { success: false, error: 'Window ID is required' }; 963 - } 964 - 965 - // Get window data from manager to verify it exists 966 - const winData = windowManager.getWindow(msg.id); 967 - if (!winData) { 968 - return { success: false, error: 'Window not found in window manager' }; 969 - } 970 - 971 - const win = BrowserWindow.fromId(msg.id); 972 - if (!win) { 973 - // Clean up stale window reference 974 - windowManager.removeWindow(msg.id); 975 - return { success: false, error: 'Window not found' }; 976 - } 977 - 978 - if (typeof msg.x !== 'number' || typeof msg.y !== 'number') { 979 - return { success: false, error: 'Valid x and y coordinates are required' }; 980 - } 981 - 982 - win.setPosition(msg.x, msg.y); 983 - return { success: true }; 984 - } catch (error) { 985 - console.error('Failed to move window:', error); 986 - return { success: false, error: error.message }; 987 - } 988 - }); 989 - 990 - ipcMain.handle('window-focus', async (ev, msg) => { 991 - console.log('window-focus', msg); 992 - 993 - try { 994 - if (!msg.id) { 995 - return { success: false, error: 'Window ID is required' }; 996 - } 997 - 998 - // Get window data from manager to verify it exists 999 - const winData = windowManager.getWindow(msg.id); 1000 - if (!winData) { 1001 - return { success: false, error: 'Window not found in window manager' }; 1002 - } 1003 - 1004 - const win = BrowserWindow.fromId(msg.id); 1005 - if (!win) { 1006 - // Clean up stale window reference 1007 - windowManager.removeWindow(msg.id); 1008 - return { success: false, error: 'Window not found' }; 1009 - } 1010 - 1011 - win.focus(); 1012 - return { success: true }; 1013 - } catch (error) { 1014 - console.error('Failed to focus window:', error); 1015 - return { success: false, error: error.message }; 1016 - } 1017 - }); 1018 - 1019 - ipcMain.handle('window-blur', async (ev, msg) => { 1020 - console.log('window-blur', msg); 1021 - 1022 - try { 1023 - if (!msg.id) { 1024 - return { success: false, error: 'Window ID is required' }; 1025 - } 1026 - 1027 - // Get window data from manager to verify it exists 1028 - const winData = windowManager.getWindow(msg.id); 1029 - if (!winData) { 1030 - return { success: false, error: 'Window not found in window manager' }; 1031 - } 1032 - 1033 - const win = BrowserWindow.fromId(msg.id); 1034 - if (!win) { 1035 - // Clean up stale window reference 1036 - windowManager.removeWindow(msg.id); 1037 - return { success: false, error: 'Window not found' }; 1038 - } 1039 - 1040 - win.blur(); 1041 - return { success: true }; 1042 - } catch (error) { 1043 - console.error('Failed to blur window:', error); 1044 - return { success: false, error: error.message }; 1045 - } 1046 - }); 1047 - 1048 - // Add a window-exists handler to check if a window is still valid 1049 - ipcMain.handle('window-exists', async (ev, msg) => { 1050 - console.log('window-exists', msg); 1051 - 1052 - try { 1053 - if (!msg.id) { 1054 - return { exists: false, error: 'Window ID is required' }; 1055 - } 1056 - 1057 - // Check if the window exists in the window manager 1058 - const winData = windowManager.getWindow(msg.id); 1059 - if (!winData) { 1060 - return { exists: false }; 1061 - } 1062 - 1063 - // Double-check that the window object is still valid 1064 - const win = BrowserWindow.fromId(msg.id); 1065 - if (!win || win.isDestroyed()) { 1066 - // Clean up stale window reference 1067 - windowManager.removeWindow(msg.id); 1068 - return { exists: false }; 1069 - } 1070 - 1071 - return { exists: true }; 1072 - } catch (error) { 1073 - console.error('Failed to check if window exists:', error); 1074 - return { exists: false, error: error.message }; 1075 - } 1076 - }); 1077 - 1078 - ipcMain.handle('window-list', async (ev, msg) => { 1079 - console.log('window-list', msg); 1080 - 1081 - try { 1082 - const windows = []; 1083 - 1084 - for (const [id, winData] of windowManager.windows) { 1085 - const win = BrowserWindow.fromId(id); 1086 - if (win && !win.isDestroyed()) { 1087 - // Get the current URL of the window 1088 - const url = win.webContents.getURL(); 1089 - 1090 - // Skip internal peek:// URLs unless requested 1091 - if (!msg?.includeInternal && url.startsWith('peek://')) { 1092 - continue; 1093 - } 1094 - 1095 - windows.push({ 1096 - id, 1097 - url, 1098 - title: win.getTitle(), 1099 - source: winData.source, 1100 - params: winData.params 1101 - }); 1102 - } 1103 - } 1104 - 1105 - return { success: true, windows }; 1106 - } catch (error) { 1107 - console.error('Failed to list windows:', error); 1108 - return { success: false, error: error.message, windows: [] }; 1109 - } 1110 - }); 1111 - 1112 - // ***** Datastore IPC Handlers ***** 1113 - 1114 - ipcMain.handle('datastore-add-address', async (ev, data) => { 1115 - try { 1116 - const { uri, options = {} } = data; 1117 - const result = addAddress(uri, options); 1118 - return { success: true, id: result.id }; 1119 - } catch (error) { 1120 - console.error('datastore-add-address error:', error); 1121 - return { success: false, error: error.message }; 1122 - } 1123 - }); 1124 - 1125 - ipcMain.handle('datastore-get-address', async (ev, data) => { 1126 - try { 1127 - const { id } = data; 1128 - const row = getAddress(id); 1129 - return { success: true, data: row || {} }; 1130 - } catch (error) { 1131 - console.error('datastore-get-address error:', error); 1132 - return { success: false, error: error.message }; 1133 - } 1134 - }); 1135 - 1136 - ipcMain.handle('datastore-update-address', async (ev, data) => { 1137 - try { 1138 - const { id, updates } = data; 1139 - const updated = updateAddress(id, updates); 1140 - if (!updated) { 1141 - return { success: false, error: 'Address not found' }; 1142 - } 1143 - return { success: true, data: updated }; 1144 - } catch (error) { 1145 - console.error('datastore-update-address error:', error); 1146 - return { success: false, error: error.message }; 1147 - } 1148 - }); 1149 - 1150 - ipcMain.handle('datastore-query-addresses', async (ev, data) => { 1151 - try { 1152 - const { filter = {} } = data; 1153 - const results = queryAddresses(filter); 1154 - return { success: true, data: results }; 1155 - } catch (error) { 1156 - console.error('datastore-query-addresses error:', error); 1157 - return { success: false, error: error.message }; 1158 - } 1159 - }); 1160 - 1161 - ipcMain.handle('datastore-add-visit', async (ev, data) => { 1162 - try { 1163 - const { addressId, options = {} } = data; 1164 - const result = addVisit(addressId, options); 1165 - return { success: true, id: result.id }; 1166 - } catch (error) { 1167 - console.error('datastore-add-visit error:', error); 1168 - return { success: false, error: error.message }; 1169 - } 1170 - }); 1171 - 1172 - ipcMain.handle('datastore-query-visits', async (ev, data) => { 1173 - try { 1174 - const { filter = {} } = data; 1175 - const results = queryVisits(filter); 1176 - return { success: true, data: results }; 1177 - } catch (error) { 1178 - console.error('datastore-query-visits error:', error); 1179 - return { success: false, error: error.message }; 1180 - } 1181 - }); 1182 - 1183 - ipcMain.handle('datastore-add-content', async (ev, data) => { 1184 - try { 1185 - const { options = {} } = data; 1186 - const result = addContent(options); 1187 - return { success: true, id: result.id }; 1188 - } catch (error) { 1189 - console.error('datastore-add-content error:', error); 1190 - return { success: false, error: error.message }; 1191 - } 1192 - }); 1193 - 1194 - ipcMain.handle('datastore-query-content', async (ev, data) => { 1195 - try { 1196 - const { filter = {} } = data; 1197 - const results = queryContent(filter); 1198 - return { success: true, data: results }; 1199 - } catch (error) { 1200 - console.error('datastore-query-content error:', error); 1201 - return { success: false, error: error.message }; 1202 - } 1203 - }); 1204 - 1205 - ipcMain.handle('datastore-get-table', async (ev, data) => { 1206 - try { 1207 - const { tableName } = data; 1208 - if (!isValidTable(tableName)) { 1209 - return { success: false, error: `Invalid table name: ${tableName}` }; 1210 - } 1211 - const table = getTable(tableName); 1212 - return { success: true, data: table }; 1213 - } catch (error) { 1214 - console.error('datastore-get-table error:', error); 1215 - return { success: false, error: error.message }; 1216 - } 1217 - }); 1218 - 1219 - ipcMain.handle('datastore-set-row', async (ev, data) => { 1220 - try { 1221 - const { tableName, rowId, rowData } = data; 1222 - if (!isValidTable(tableName)) { 1223 - return { success: false, error: `Invalid table name: ${tableName}` }; 1224 - } 1225 - setRow(tableName, rowId, rowData); 1226 - return { success: true }; 1227 - } catch (error) { 1228 - console.error('datastore-set-row error:', error); 1229 - return { success: false, error: error.message }; 1230 - } 1231 - }); 1232 - 1233 - ipcMain.handle('datastore-get-stats', async () => { 1234 - try { 1235 - const stats = getStats(); 1236 - return { success: true, data: stats }; 1237 - } catch (error) { 1238 - console.error('datastore-get-stats error:', error); 1239 - return { success: false, error: error.message }; 1240 - } 1241 - }); 1242 - 1243 - // ***** Tag IPC Handlers ***** 1244 - 1245 - ipcMain.handle('datastore-get-or-create-tag', async (ev, data) => { 1246 - try { 1247 - const { name } = data; 1248 - const result = getOrCreateTag(name); 1249 - return { success: true, data: result.tag, created: result.created }; 1250 - } catch (error) { 1251 - console.error('datastore-get-or-create-tag error:', error); 1252 - return { success: false, error: error.message }; 1253 - } 1254 - }); 1255 - 1256 - ipcMain.handle('datastore-tag-address', async (ev, data) => { 1257 - try { 1258 - const { addressId, tagId } = data; 1259 - const result = tagAddress(addressId, tagId); 1260 - return { success: true, data: result.link, alreadyExists: result.alreadyExists }; 1261 - } catch (error) { 1262 - console.error('datastore-tag-address error:', error); 1263 - return { success: false, error: error.message }; 1264 - } 1265 - }); 1266 - 1267 - ipcMain.handle('datastore-untag-address', async (ev, data) => { 1268 - try { 1269 - const { addressId, tagId } = data; 1270 - const removed = untagAddress(addressId, tagId); 1271 - return { success: true, removed }; 1272 - } catch (error) { 1273 - console.error('datastore-untag-address error:', error); 1274 - return { success: false, error: error.message }; 1275 - } 1276 - }); 1277 - 1278 - ipcMain.handle('datastore-get-tags-by-frecency', async (ev, data = {}) => { 1279 - try { 1280 - const { domain } = data || {}; 1281 - const tags = getTagsByFrecency(domain); 1282 - return { success: true, data: tags }; 1283 - } catch (error) { 1284 - console.error('datastore-get-tags-by-frecency error:', error); 1285 - return { success: false, error: error.message }; 1286 - } 1287 - }); 1288 - 1289 - ipcMain.handle('datastore-get-address-tags', async (ev, data) => { 1290 - try { 1291 - const { addressId } = data; 1292 - const tags = getAddressTags(addressId); 1293 - return { success: true, data: tags }; 1294 - } catch (error) { 1295 - console.error('datastore-get-address-tags error:', error); 1296 - return { success: false, error: error.message }; 1297 - } 1298 - }); 1299 - 1300 - ipcMain.handle('datastore-get-addresses-by-tag', async (ev, data) => { 1301 - try { 1302 - const { tagId } = data; 1303 - const addresses = getAddressesByTag(tagId); 1304 - return { success: true, data: addresses }; 1305 - } catch (error) { 1306 - console.error('datastore-get-addresses-by-tag error:', error); 1307 - return { success: false, error: error.message }; 1308 - } 1309 - }); 1310 - 1311 - ipcMain.handle('datastore-get-untagged-addresses', async (ev, data) => { 1312 - try { 1313 - const addresses = getUntaggedAddresses(); 1314 - return { success: true, data: addresses }; 1315 - } catch (error) { 1316 - console.error('datastore-get-untagged-addresses error:', error); 1317 - return { success: false, error: error.message }; 1318 - } 1319 - }); 1320 - 1321 - // ==================== Extension Management ==================== 1322 - 1323 - // Open folder picker dialog for adding an extension 1324 - ipcMain.handle('extension-pick-folder', async (ev) => { 1325 - try { 1326 - const result = await dialog.showOpenDialog({ 1327 - properties: ['openDirectory'], 1328 - title: 'Select Extension Folder', 1329 - message: 'Select a folder containing a Peek extension (must have manifest.json)' 1330 - }); 1331 - 1332 - if (result.canceled || !result.filePaths.length) { 1333 - return { success: false, canceled: true }; 1334 - } 1335 - 1336 - const folderPath = result.filePaths[0]; 1337 - return { success: true, data: { path: folderPath } }; 1338 - } catch (error) { 1339 - console.error('extension-pick-folder error:', error); 1340 - return { success: false, error: error.message }; 1341 - } 1342 - }); 1343 - 1344 - // Validate an extension folder (check for manifest.json and parse it) 1345 - ipcMain.handle('extension-validate-folder', async (ev, data) => { 1346 - const { folderPath } = data; 1347 - 1348 - try { 1349 - const manifestPath = path.join(folderPath, 'manifest.json'); 1350 - 1351 - // Check if manifest exists 1352 - if (!fs.existsSync(manifestPath)) { 1353 - return { 1354 - success: false, 1355 - valid: false, 1356 - error: 'No manifest.json found in folder' 1357 - }; 1358 - } 1359 - 1360 - // Read and parse manifest 1361 - const manifestContent = fs.readFileSync(manifestPath, 'utf-8'); 1362 - let manifest; 1363 - try { 1364 - manifest = JSON.parse(manifestContent); 1365 - } catch (parseError) { 1366 - return { 1367 - success: true, 1368 - valid: false, 1369 - error: `Invalid JSON in manifest.json: ${parseError.message}`, 1370 - manifest: null 1371 - }; 1372 - } 1373 - 1374 - // Validate required fields 1375 - const errors = []; 1376 - if (!manifest.id) errors.push('Missing required field: id'); 1377 - if (!manifest.shortname) errors.push('Missing required field: shortname'); 1378 - if (!manifest.name) errors.push('Missing required field: name'); 1379 - 1380 - // Check shortname format 1381 - if (manifest.shortname && !/^[a-z0-9-]+$/.test(manifest.shortname)) { 1382 - errors.push('Invalid shortname format: must be lowercase alphanumeric with hyphens'); 1383 - } 1384 - 1385 - // Check for background script 1386 - const backgroundScript = manifest.background || 'background.js'; 1387 - const backgroundPath = path.join(folderPath, backgroundScript); 1388 - if (!fs.existsSync(backgroundPath)) { 1389 - errors.push(`Background script not found: ${backgroundScript}`); 1390 - } 1391 - 1392 - return { 1393 - success: true, 1394 - valid: errors.length === 0, 1395 - errors: errors.length > 0 ? errors : null, 1396 - manifest 1397 - }; 1398 - } catch (error) { 1399 - console.error('extension-validate-folder error:', error); 1400 - return { success: false, error: error.message }; 1401 - } 1402 - }); 1403 - 1404 - // Add extension to datastore 1405 - ipcMain.handle('extension-add', async (ev, data) => { 1406 - const { folderPath, manifest, enabled = false } = data; 1407 - 1408 - try { 1409 - const timestamp = now(); 1410 - const id = manifest?.id || `ext-${timestamp}`; 1411 - const db = getDb(); 1412 - 1413 - // Check if extension with this ID already exists 1414 - const existing = db.prepare('SELECT * FROM extensions WHERE id = ?').get(id); 1415 - if (existing) { 1416 - return { success: false, error: `Extension with ID '${id}' already exists` }; 1417 - } 1418 - 1419 - // Add to extensions table 1420 - db.prepare(` 1421 - INSERT INTO extensions (id, name, description, version, path, backgroundUrl, settingsUrl, iconPath, builtin, enabled, status, installedAt, updatedAt, lastErrorAt, lastError, metadata) 1422 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 1423 - `).run( 1424 - id, 1425 - manifest?.name || path.basename(folderPath), 1426 - manifest?.description || '', 1427 - manifest?.version || '0.0.0', 1428 - folderPath, 1429 - `peek://ext/${manifest?.shortname || id}/background.js`, 1430 - manifest?.settings_url || '', 1431 - manifest?.icon || '', 1432 - 0, 1433 - enabled ? 1 : 0, 1434 - enabled ? 'installed' : 'disabled', 1435 - timestamp, 1436 - timestamp, 1437 - 0, 1438 - '', 1439 - JSON.stringify({ shortname: manifest?.shortname || id }) 1440 - ); 1441 - 1442 - console.log(`Extension added: ${id} at ${folderPath}`); 1443 - return { success: true, data: { id } }; 1444 - } catch (error) { 1445 - console.error('extension-add error:', error); 1446 - return { success: false, error: error.message }; 1447 - } 1448 - }); 1449 - 1450 - // Remove extension from datastore 1451 - ipcMain.handle('extension-remove', async (ev, data) => { 1452 - const { id } = data; 1453 - 1454 - try { 1455 - const db = getDb(); 1456 - const existing = db.prepare('SELECT * FROM extensions WHERE id = ?').get(id); 1457 - if (!existing) { 1458 - return { success: false, error: `Extension '${id}' not found` }; 1459 - } 1460 - 1461 - // Don't allow removing builtin extensions 1462 - if (existing.builtin === 1) { 1463 - return { success: false, error: 'Cannot remove built-in extensions' }; 1464 - } 1465 - 1466 - db.prepare('DELETE FROM extensions WHERE id = ?').run(id); 1467 - console.log(`Extension removed: ${id}`); 1468 - return { success: true }; 1469 - } catch (error) { 1470 - console.error('extension-remove error:', error); 1471 - return { success: false, error: error.message }; 1472 - } 1473 - }); 1474 - 1475 - // Update extension (enable/disable, update error status, etc.) 1476 - ipcMain.handle('extension-update', async (ev, data) => { 1477 - const { id, updates } = data; 1478 - 1479 - try { 1480 - const db = getDb(); 1481 - const existing = db.prepare('SELECT * FROM extensions WHERE id = ?').get(id); 1482 - if (!existing) { 1483 - return { success: false, error: `Extension '${id}' not found` }; 1484 - } 1485 - 1486 - // Apply updates 1487 - const updatedRow = { ...existing, ...updates, updatedAt: now() }; 1488 - const columns = Object.keys(updatedRow).filter(k => k !== 'id'); 1489 - const setClause = columns.map(col => `${col} = ?`).join(', '); 1490 - const values = columns.map(col => updatedRow[col]); 1491 - 1492 - db.prepare(`UPDATE extensions SET ${setClause} WHERE id = ?`).run(...values, id); 1493 - 1494 - console.log(`Extension updated: ${id}`, updates); 1495 - return { success: true, data: { id, ...updatedRow } }; 1496 - } catch (error) { 1497 - console.error('extension-update error:', error); 1498 - return { success: false, error: error.message }; 1499 - } 1500 - }); 1501 - 1502 - // Get all extensions from datastore 1503 - ipcMain.handle('extension-get-all', async (ev) => { 1504 - try { 1505 - const db = getDb(); 1506 - const extensions = db.prepare('SELECT * FROM extensions').all(); 1507 - return { success: true, data: extensions }; 1508 - } catch (error) { 1509 - console.error('extension-get-all error:', error); 1510 - return { success: false, error: error.message }; 1511 - } 1512 - }); 1513 - 1514 - // Get single extension from datastore 1515 - ipcMain.handle('extension-get', async (ev, data) => { 1516 - const { id } = data; 1517 - 1518 - try { 1519 - const db = getDb(); 1520 - const row = db.prepare('SELECT * FROM extensions WHERE id = ?').get(id); 1521 - if (!row) { 1522 - return { success: false, error: `Extension '${id}' not found` }; 1523 - } 1524 - return { success: true, data: row }; 1525 - } catch (error) { 1526 - console.error('extension-get error:', error); 1527 - return { success: false, error: error.message }; 1528 - } 1529 - }); 1530 - 1531 - // ==================== Extension Window Management ==================== 1532 - 1533 - // Load extension (create window) - permission check in preload.js 1534 - ipcMain.handle('extension-window-load', async (ev, data) => { 1535 - const { extId } = data; 1536 - const url = ev.sender.getURL(); 1537 - 1538 - // Permission check: only core app can manage extension windows 1539 - if (!url.startsWith('peek://app/')) { 1540 - console.warn(`[ext:win] Permission denied for extension load from: ${url}`); 1541 - return { success: false, error: 'Permission denied' }; 1542 - } 1543 - 1544 - try { 1545 - const win = await createExtensionWindow(extId); 1546 - if (win) { 1547 - return { success: true, data: { extId } }; 1548 - } else { 1549 - return { success: false, error: 'Failed to create extension window' }; 1550 - } 1551 - } catch (error) { 1552 - console.error('extension-window-load error:', error); 1553 - return { success: false, error: error.message }; 1554 - } 1555 - }); 1556 - 1557 - // Unload extension (destroy window) - permission check in preload.js 1558 - ipcMain.handle('extension-window-unload', async (ev, data) => { 1559 - const { extId } = data; 1560 - const url = ev.sender.getURL(); 1561 - 1562 - // Permission check: only core app can manage extension windows 1563 - if (!url.startsWith('peek://app/')) { 1564 - console.warn(`[ext:win] Permission denied for extension unload from: ${url}`); 1565 - return { success: false, error: 'Permission denied' }; 1566 - } 1567 - 1568 - try { 1569 - const result = destroyExtensionWindow(extId); 1570 - return { success: true, data: { wasRunning: result } }; 1571 - } catch (error) { 1572 - console.error('extension-window-unload error:', error); 1573 - return { success: false, error: error.message }; 1574 - } 1575 - }); 1576 - 1577 - // Reload extension (destroy and recreate window) 1578 - ipcMain.handle('extension-window-reload', async (ev, data) => { 1579 - const { extId } = data; 1580 - const url = ev.sender.getURL(); 1581 - 1582 - // Permission check: only core app can manage extension windows 1583 - if (!url.startsWith('peek://app/')) { 1584 - console.warn(`[ext:win] Permission denied for extension reload from: ${url}`); 1585 - return { success: false, error: 'Permission denied' }; 1586 - } 1587 - 1588 - try { 1589 - destroyExtensionWindow(extId); 1590 - // Small delay to ensure cleanup 1591 - await new Promise(resolve => setTimeout(resolve, 200)); 1592 - const win = await createExtensionWindow(extId); 1593 - if (win) { 1594 - return { success: true, data: { extId } }; 1595 - } else { 1596 - return { success: false, error: 'Failed to reload extension window' }; 1597 - } 1598 - } catch (error) { 1599 - console.error('extension-window-reload error:', error); 1600 - return { success: false, error: error.message }; 1601 - } 1602 - }); 1603 - 1604 - // List running extension windows 1605 - ipcMain.handle('extension-window-list', async (ev) => { 1606 - try { 1607 - const running = getRunningExtensions(); 1608 - return { success: true, data: running }; 1609 - } catch (error) { 1610 - console.error('extension-window-list error:', error); 1611 - return { success: false, error: error.message }; 1612 - } 1613 - }); 1614 - 1615 - // ==================== Extension Settings (Cross-Origin Storage) ==================== 1616 - 1617 - // Get extension settings from datastore 1618 - ipcMain.handle('extension-settings-get', async (ev, data) => { 1619 - const { extId } = data; 1620 - 1621 - try { 1622 - const db = getDb(); 1623 - const rows = db.prepare('SELECT * FROM extension_settings WHERE extensionId = ?').all(extId); 1624 - const settings = {}; 1625 - 1626 - for (const row of rows) { 1627 - try { 1628 - settings[row.key] = JSON.parse(row.value); 1629 - } catch (e) { 1630 - settings[row.key] = row.value; 1631 - } 1632 - } 1633 - 1634 - return { success: true, data: settings }; 1635 - } catch (error) { 1636 - console.error('extension-settings-get error:', error); 1637 - return { success: false, error: error.message }; 1638 - } 1639 - }); 1640 - 1641 - // Set extension settings in datastore 1642 - ipcMain.handle('extension-settings-set', async (ev, data) => { 1643 - const { extId, settings } = data; 1644 - 1645 - try { 1646 - const timestamp = now(); 1647 - const db = getDb(); 1648 - 1649 - for (const [key, value] of Object.entries(settings)) { 1650 - const rowId = `${extId}:${key}`; 1651 - db.prepare(` 1652 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 1653 - VALUES (?, ?, ?, ?, ?) 1654 - `).run(rowId, extId, key, JSON.stringify(value), timestamp); 1655 - } 1656 - 1657 - return { success: true }; 1658 - } catch (error) { 1659 - console.error('extension-settings-set error:', error); 1660 - return { success: false, error: error.message }; 1661 - } 1662 - }); 1663 - 1664 - // Get a single setting key for an extension 1665 - ipcMain.handle('extension-settings-get-key', async (ev, data) => { 1666 - const { extId, key } = data; 1667 - 1668 - try { 1669 - const db = getDb(); 1670 - const row = db.prepare('SELECT * FROM extension_settings WHERE extensionId = ? AND key = ?').get(extId, key); 1671 - 1672 - if (!row) { 1673 - return { success: true, data: null }; 1674 - } 1675 - 1676 - try { 1677 - return { success: true, data: JSON.parse(row.value) }; 1678 - } catch (e) { 1679 - return { success: true, data: row.value }; 1680 - } 1681 - } catch (error) { 1682 - console.error('extension-settings-get-key error:', error); 1683 - return { success: false, error: error.message }; 1684 - } 1685 - }); 1686 - 1687 - // Set a single setting key for an extension 1688 - ipcMain.handle('extension-settings-set-key', async (ev, data) => { 1689 - const { extId, key, value } = data; 1690 - 1691 - try { 1692 - const db = getDb(); 1693 - const rowId = `${extId}:${key}`; 1694 - db.prepare(` 1695 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 1696 - VALUES (?, ?, ?, ?, ?) 1697 - `).run(rowId, extId, key, JSON.stringify(value), now()); 1698 - 1699 - return { success: true }; 1700 - } catch (error) { 1701 - console.error('extension-settings-set-key error:', error); 1702 - return { success: false, error: error.message }; 1703 - } 1704 - }); 1705 - 1706 - // Get extension manifest from filesystem 1707 - ipcMain.handle('extension-manifest-get', async (ev, data) => { 1708 - const { extId } = data; 1709 - 1710 - try { 1711 - const extPath = getExtensionPath(extId); 1712 - if (!extPath) { 1713 - return { success: false, error: 'Extension not found' }; 1714 - } 1715 - 1716 - const manifestPath = path.join(extPath, 'manifest.json'); 1717 - if (!fs.existsSync(manifestPath)) { 1718 - return { success: false, error: 'manifest.json not found' }; 1719 - } 1720 - 1721 - const manifestContent = fs.readFileSync(manifestPath, 'utf-8'); 1722 - const manifest = JSON.parse(manifestContent); 1723 - 1724 - return { success: true, data: manifest }; 1725 - } catch (error) { 1726 - console.error('extension-manifest-get error:', error); 1727 - return { success: false, error: error.message }; 1728 - } 1729 - }); 1730 - 1731 - // Get extension settings schema from filesystem 1732 - // Reads the schema file path from manifest.settingsSchema 1733 - ipcMain.handle('extension-settings-schema', async (ev, data) => { 1734 - const { extId } = data; 1735 - 1736 - try { 1737 - const extPath = getExtensionPath(extId); 1738 - if (!extPath) { 1739 - return { success: false, error: 'Extension not found' }; 1740 - } 1741 - 1742 - const manifestPath = path.join(extPath, 'manifest.json'); 1743 - if (!fs.existsSync(manifestPath)) { 1744 - return { success: false, error: 'manifest.json not found' }; 1745 - } 1746 - 1747 - const manifestContent = fs.readFileSync(manifestPath, 'utf-8'); 1748 - const manifest = JSON.parse(manifestContent); 1749 - 1750 - // Check if extension has a settings schema 1751 - if (!manifest.settingsSchema) { 1752 - return { success: true, data: null }; // No settings schema defined 1753 - } 1754 - 1755 - // Resolve schema path relative to extension directory 1756 - const schemaPath = path.join(extPath, manifest.settingsSchema); 1757 - if (!fs.existsSync(schemaPath)) { 1758 - return { success: false, error: `Settings schema not found: ${manifest.settingsSchema}` }; 1759 - } 1760 - 1761 - const schemaContent = fs.readFileSync(schemaPath, 'utf-8'); 1762 - const schema = JSON.parse(schemaContent); 1763 - 1764 - return { 1765 - success: true, 1766 - data: { 1767 - extId: manifest.id || extId, 1768 - name: manifest.name, 1769 - schema 1770 - } 1771 - }; 1772 - } catch (error) { 1773 - console.error('extension-settings-schema error:', error); 1774 - return { success: false, error: error.message }; 1775 - } 1776 - }); 1777 - 1778 - // ==================== End Extension Management ==================== 1779 - 1780 - const modWindow = (bw, params) => { 1781 - if (params.action == 'close') { 1782 - bw.close(); 1783 - } 1784 - if (params.action == 'hide') { 1785 - bw.hide(); 1786 - } 1787 - if (params.action == 'show') { 1788 - bw.show(); 1789 - } 1790 - }; 1791 - 1792 - // ***** Helpers ***** 1793 - 1794 - // Ask renderer to handle escape, returns Promise<{ handled: boolean }> 1795 - const askRendererToHandleEscape = (bw) => { 1796 - return new Promise((resolve) => { 1797 - const responseChannel = `escape-response-${bw.id}-${Date.now()}`; 1798 - 1799 - // Timeout after 100ms - if renderer doesn't respond, assume not handled 1800 - const timeout = setTimeout(() => { 1801 - ipcMain.removeAllListeners(responseChannel); 1802 - resolve({ handled: false }); 1803 - }, 100); 1804 - 1805 - ipcMain.once(responseChannel, (event, response) => { 1806 - clearTimeout(timeout); 1807 - resolve(response || { handled: false }); 1808 - }); 1809 - 1810 - bw.webContents.send('escape-pressed', { responseChannel }); 1811 - }); 1812 - }; 1813 - 1814 - // esc handler 1815 - // Supports escapeMode: 'close' (default), 'navigate', 'auto' 1816 - const addEscHandler = bw => { 1817 - console.log('adding esc handler to window:', bw.id); 1818 - bw.webContents.on('before-input-event', async (e, i) => { 1819 - if (i.key == 'Escape' && i.type == 'keyUp') { 1820 - // Get window info 1821 - const entry = windowManager.getWindow(bw.id); 1822 - const params = entry?.params || {}; 1823 - const escapeMode = params.escapeMode || 'close'; 1824 - 1825 - console.log(`ESC pressed - window ${bw.id}, escapeMode: ${escapeMode}`); 1826 - 1827 - // For 'navigate' mode, ask renderer first 1828 - if (escapeMode === 'navigate') { 1829 - const response = await askRendererToHandleEscape(bw); 1830 - console.log(`Renderer escape response:`, response); 1831 - 1832 - if (response.handled) { 1833 - // Renderer handled the escape (internal navigation) 1834 - console.log('Renderer handled escape, not closing'); 1835 - return; 1836 - } 1837 - } 1838 - 1839 - // For 'auto' mode, check if transient (no focused window when opened) 1840 - if (escapeMode === 'auto') { 1841 - if (params.transient) { 1842 - // Transient mode - close immediately 1843 - console.log('Auto mode (transient) - closing'); 1844 - } else { 1845 - // Active mode - ask renderer first 1846 - const response = await askRendererToHandleEscape(bw); 1847 - console.log(`Renderer escape response (auto/active):`, response); 1848 - 1849 - if (response.handled) { 1850 - console.log('Renderer handled escape, not closing'); 1851 - return; 1852 - } 1853 - } 1854 - } 1855 - 1856 - // Close or hide the window 1857 - console.log('Closing/hiding window'); 1858 - closeOrHideWindow(bw.id); 1859 - } 1860 - }); 1861 - }; 1862 - 1863 - // show/configure devtools when/after a window is opened 1864 - const winDevtoolsConfig = bw => { 1865 - const windowData = windowManager.getWindow(bw.id); 1866 - const params = windowData ? windowData.params : {}; 1867 - 1868 - console.log('winDevtoolsConfig:', bw.id, 'openDevTools:', params.openDevTools, 'address:', params.address); 1869 - 1870 - // Check if devTools should be opened (never in test profiles) 1871 - if (params.openDevTools === true && !isTestProfile) { 1872 - const isDetached = params.detachedDevTools === true; 1873 - // Determine if detached mode should be used 1874 - // activate: false prevents devtools from stealing focus (only works with detach/undocked) 1875 - const devToolsOptions = { 1876 - mode: isDetached ? 'detach' : 'right', 1877 - activate: false 1878 - }; 1879 - 1880 - console.log(`Opening DevTools for window ${bw.id} with options:`, devToolsOptions); 1881 - 1882 - // Open DevTools after a slight delay to let the main window settle 1883 - setTimeout(() => { 1884 - bw.webContents.openDevTools(devToolsOptions); 1885 - 1886 - // when devtools completely open, ensure content window has focus 1887 - bw.webContents.once('devtools-opened', () => { 1888 - // Re-focus the content window after devtools opens 1889 - setTimeout(() => { 1890 - if (bw.isVisible() && !bw.isDestroyed()) { 1891 - bw.focus(); 1892 - bw.webContents.focus(); 1893 - } 1894 - }, 100); 1895 - }); 1896 - }, 50); 1897 - } 1898 - }; 1899 - 1900 - // window closer 1901 - // this will actually close the the window 1902 - // regardless of "keep alive" opener params 1903 - const closeWindow = (params, callback) => { 1904 - console.log('closeWindow', params, callback != null); 1905 - 1906 - let retval = false; 1907 - 1908 - if (params.hasOwnProperty('id') && windowManager.getWindow(params.id)) { 1909 - console.log('closeWindow(): closing', params.id); 1910 - 1911 - const entry = windowManager.getWindow(params.id); 1912 - if (!entry) { 1913 - // wtf 1914 - return; 1915 - } 1916 - 1917 - closeChildWindows(entry.params.address); 1918 - 1919 - BrowserWindow.fromId(params.id).close(); 1920 - 1921 - retval = true; 1922 - } 1923 - 1924 - if (callback != null) { 1925 - callback(retval); 1926 - } 1927 - }; 1928 - 1929 - /** 1930 - * Get count of visible user windows (excluding background window) 1931 - */ 1932 - const getVisibleWindowCount = (excludeId = null) => { 1933 - return BrowserWindow.getAllWindows().filter(win => { 1934 - if (excludeId && win.id === excludeId) return false; 1935 - if (win.isDestroyed()) return false; 1936 - if (!win.isVisible()) return false; 327 + // Register window-all-closed handler 328 + registerWindowAllClosedHandler(quitApp); 1937 329 1938 - // Exclude the background window 1939 - const entry = windowManager.getWindow(win.id); 1940 - if (entry && entry.params.address === webCoreAddress) return false; 330 + app.whenReady().then(onReady); 1941 331 1942 - return true; 1943 - }).length; 1944 - }; 1945 - 1946 - /** 1947 - * Update dock visibility based on visible windows and pref 1948 - * Show dock if: visible windows exist OR pref is enabled 1949 - * Hide dock if: no visible windows AND pref is disabled 1950 - */ 1951 - const updateDockVisibility = (excludeId = null) => { 1952 - if (process.platform !== 'darwin' || !app.dock) return; 1953 - 1954 - const visibleCount = getVisibleWindowCount(excludeId); 1955 - const prefShowDock = _prefs?.showInDockAndSwitcher === true; 1956 - 1957 - console.log('updateDockVisibility:', { visibleCount, prefShowDock, excludeId }); 1958 - 1959 - if (visibleCount > 0 || prefShowDock) { 1960 - console.log('Showing dock'); 1961 - app.dock.show(); 1962 - } else { 1963 - console.log('Hiding dock'); 1964 - app.dock.hide(); 1965 - } 1966 - }; 1967 - 1968 - // Only hide the app if there are no other visible windows (besides the one being closed/hidden) 1969 - const maybeHideApp = (excludeId) => { 1970 - if (process.platform !== 'darwin') return; 1971 - 1972 - const visibleCount = getVisibleWindowCount(excludeId); 1973 - console.log('maybeHideApp: visible windows (excluding', excludeId + '):', visibleCount); 1974 - 1975 - if (visibleCount === 0) { 1976 - console.log('No other visible windows, hiding app'); 1977 - app.hide(); 1978 - } else { 1979 - console.log('Other windows visible, not hiding app'); 1980 - } 1981 - 1982 - // Also update dock visibility 1983 - updateDockVisibility(excludeId); 1984 - }; 1985 - 1986 - const closeOrHideWindow = id => { 1987 - console.log('closeOrHideWindow called for ID:', id); 1988 - 1989 - try { 1990 - const win = BrowserWindow.fromId(id); 1991 - if (!win || win.isDestroyed()) { 1992 - console.log('Window already destroyed or invalid'); 1993 - return; 1994 - } 1995 - 1996 - const entry = windowManager.getWindow(id); 1997 - console.log('Window entry from manager:', entry); 1998 - 1999 - if (!entry) { 2000 - console.log('Window not found in window manager, closing directly'); 2001 - win.close(); 2002 - return; 2003 - } 2004 - 2005 - const params = entry.params; 2006 - console.log('Window parameters - modal:', params.modal, 'keepLive:', params.keepLive); 2007 - 2008 - // Never close the background window 2009 - if (params.address === webCoreAddress) { 2010 - console.log('Refusing to close background window'); 2011 - return; 2012 - } 2013 - 2014 - // Special case for settings window - always close it on ESC 2015 - if (params.address === settingsAddress) { 2016 - console.log(`CLOSING settings window ${id}`); 2017 - closeChildWindows(params.address); 2018 - win.close(); 2019 - // Hide app to return focus to previous app (only if no other visible windows) 2020 - maybeHideApp(id); 2021 - } 2022 - // Check if window should be hidden rather than closed 2023 - // Either keepLive or modal parameter can trigger hiding behavior 2024 - else if (params.keepLive === true || params.modal === true) { 2025 - //console.log(`HIDING window ${id} (${params.address}) - modal: ${params.modal}, keepLive: ${params.keepLive}`); 2026 - win.hide(); 2027 - // Hide app to return focus to previous app (only if no other visible windows) 2028 - maybeHideApp(id); 2029 - } else { 2030 - // close any open windows this window opened 2031 - closeChildWindows(params.address); 2032 - console.log(`CLOSING window ${id} (${params.address})`); 2033 - win.close(); 2034 - // Hide app to return focus to previous app (only if no other visible windows) 2035 - maybeHideApp(id); 2036 - } 2037 - 2038 - console.log('closeOrHideWindow completed'); 2039 - } catch (error) { 2040 - console.error('Error in closeOrHideWindow:', error); 2041 - } 2042 - }; 2043 - 2044 - const closeChildWindows = (aAddress) => { 2045 - console.log('closeChildWindows()', aAddress); 2046 - 2047 - if (aAddress == webCoreAddress) { 2048 - return; 2049 - } 2050 - 2051 - // Get all child windows from the window manager 2052 - const childWindows = windowManager.getChildWindows(aAddress); 2053 - 2054 - for (const child of childWindows) { 2055 - const address = child.data.params.address; 2056 - console.log('closing child window', address, 'for', aAddress); 2057 - 2058 - // recurseme 2059 - closeChildWindows(address); 2060 - 2061 - // close window 2062 - const win = BrowserWindow.fromId(child.id); 2063 - if (win) { 2064 - win.close(); 2065 - } 2066 - } 2067 - }; 2068 - 2069 - /* 2070 - // send message to all windows 2071 - const broadcastToWindows = (topic, msg) => { 2072 - for (const [id, _] of windowManager.windows) { 2073 - const win = BrowserWindow.fromId(id); 2074 - if (win) { 2075 - win.webContents.send(topic, msg); 2076 - } 2077 - } 2078 - }; 2079 - */ 2080 - 2081 - // Quit when all windows are closed, except on macOS. There, it's common 2082 - // for applications and their menu bar to stay active until the user quits 2083 - // explicitly with Cmd + Q. 2084 - app.on('window-all-closed', () => { 2085 - console.log('window-all-closed', process.platform); 2086 - if (process.platform !== 'darwin') { 2087 - onQuit(); 2088 - } 2089 - }); 2090 - 2091 - const onQuit = async () => { 2092 - console.log('onQuit'); 2093 - 2094 - // Notify all processes that the app is shutting down 2095 - pubsub.publish(systemAddress, scopes.GLOBAL, 'app:shutdown', { 2096 - timestamp: Date.now() 2097 - }); 2098 - 2099 - // Close SQLite database 2100 - try { 2101 - closeDatabase(); 2102 - console.log('SQLite database closed'); 2103 - } catch (error) { 2104 - console.error('Error closing SQLite database:', error); 2105 - } 2106 - 2107 - // Give windows a moment to clean up before forcing quit 2108 - setTimeout(() => { 2109 - app.quit(); 2110 - }, 100); 2111 - }; 2112 - 2113 - const smash = (source, target, k, d, noset = false) => { 2114 - if (source.hasOwnProperty(k)) { 2115 - target[k] = source[k]; 2116 - } 2117 - else if (noset) { 2118 - /* no op */ 2119 - } 2120 - else { 2121 - target[k] = d; 2122 - } 2123 - }; 332 + // Define onQuit as alias to quitApp for use in IPC handlers and shortcuts 333 + const onQuit = quitApp; 2124 334 2125 335 })();