experiments in a post-browser web
10
fork

Configure Feed

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

refactor(tile): rename internal extension surface to tile/feature vocabulary

Split backend/electron/extensions.ts into the focused
backend/electron/tile-feature-state.ts (discoverFeatures /
loadFeatureManifest / isBuiltinFeatureEnabled) and delete the original.
The dead v1-manifest plumbing — ExtensionManifest, DiscoveredExtension,
ManifestCommand/Shortcut/Action types, getExternalExtensions,
discoverAnnotatedExtensions, AnnotatedExtension.v1Manifest, and the
onV1Feature fallback callback through tile-loader / feature-startup /
main — went with it. Pre-v3 (legacy) manifests are still detected via
detectManifestVersion and now surface a clean error instead of routing
through dead code.

Renamed the user-facing function/variable surface in main.ts +
re-exports + every caller (entry.ts, ipc.ts, tile-ipc.ts,
tile-launcher.ts, index.ts, app/index.js comments) from extension
vocabulary to tile/feature:

loadExtensions -> loadFeatures
discoverBuiltinExtensions -> discoverBuiltinFeatures
getRunningExtensions -> getRunningTiles
getAllRegisteredExtensions -> getAllRegisteredTiles
isDevExtension -> isDevTile
registerDevExtension -> registerDevTile
loadDevExtension -> loadDevTile
loadDevExtensions -> loadDevTiles
cleanupDevExtensions -> cleanupDevTiles
getDevExtensionIds -> getDevTileIds
reloadExtension -> reloadTile
devExtensions map -> devTiles
devExtensionPaths -> devTilePaths
extensionsLoaded flag -> featuresLoaded

The CLI flag --load-extension keeps its name for backward compatibility.
IPC channel names (tile:extensions:*) and chrome-extensions.ts (real
Chrome MV3 extensions) are intentionally untouched — those are separate
concepts.

yarn build clean. yarn test:unit 1689 + 588 / 0 fail.

+357 -537
+6 -6
app/index.js
··· 607 607 // await initHud(); // disabled — see import comment above 608 608 609 609 // Register ext:all-loaded subscriber BEFORE publishing topicCorePrefs. 610 - // Main subscribes to topicCorePrefs and kicks off loadExtensions() which 611 - // publishes ext:all-loaded. Because loadExtensions runs in parallel with 610 + // Main subscribes to topicCorePrefs and kicks off loadFeatures() which 611 + // publishes ext:all-loaded. Because loadFeatures runs in parallel with 612 612 // the rest of this init(), subscribing later in init() would race and 613 613 // miss the publish — the renderer's subscriber would be registered 614 614 // after the IPC message had already been sent and dropped. ··· 833 833 // Initialize core features (non-extension features only) 834 834 features().forEach(initFeature); 835 835 836 - // Extensions are now loaded by main process ExtensionManager. 837 - // The main-process startup (entry.ts -> loadExtensions()) runs the v2 838 - // feature pipeline (tile-compat / tile-loader); core background just 836 + // Features (tiles) are loaded by the main process feature pipeline. 837 + // The main-process startup (entry.ts -> loadFeatures()) runs the v2 838 + // tile pipeline (tile-compat / tile-loader); core background just 839 839 // initializes in-process features and waits for `ext:all-loaded`. 840 - log('core', 'Core features initialized. Extensions loaded by main process.'); 840 + log('core', 'Core features initialized. Tiles loaded by main process.'); 841 841 842 842 }; 843 843
+44 -43
backend/electron/entry.ts
··· 16 16 // Main process orchestration 17 17 configure, 18 18 initialize, 19 - discoverBuiltinExtensions, 19 + discoverBuiltinFeatures, 20 20 discoverBuiltinThemes, 21 - loadExtensions, 22 - // Dev extension support 23 - registerDevExtension, 24 - loadDevExtensions, 25 - cleanupDevExtensions, 21 + loadFeatures, 22 + // Dev tile support (CLI --load-extension) 23 + registerDevTile, 24 + loadDevTiles, 25 + cleanupDevTiles, 26 26 // External URL handling 27 27 setAppReady, 28 28 notifyFrontendReady, ··· 66 66 closeOrHideWindow, 67 67 setPrefsGetter, 68 68 applyDockPreference, 69 - // Extension loading 70 - loadExtensionManifest, 69 + // Tile manifest loading 70 + loadFeatureManifest, 71 71 // Closed window stack persistence 72 72 loadClosedWindowStack, 73 73 } from './index.js'; ··· 131 131 (globalThis as any).__peek_test = { handleLocalShortcut, handleExternalUrl }; 132 132 (globalThis as any).__peek_electron = { globalShortcut }; 133 133 134 - // Parse --load-extension CLI arguments for dev workflow 135 - // Usage: yarn start -- --load-extension=/path/to/extension 136 - // Multiple extensions: yarn start -- --load-extension=/path1 --load-extension=/path2 137 - const devExtensionPaths: string[] = []; 134 + // Parse --load-extension CLI arguments for dev workflow. 135 + // (Flag name retained for backward compatibility; the loaded thing is a tile.) 136 + // Usage: yarn start -- --load-extension=/path/to/tile 137 + // Multiple tiles: yarn start -- --load-extension=/path1 --load-extension=/path2 138 + const devTilePaths: string[] = []; 138 139 for (const arg of process.argv) { 139 140 if (arg.startsWith('--load-extension=')) { 140 - const extPath = arg.slice('--load-extension='.length); 141 + const tilePath = arg.slice('--load-extension='.length); 141 142 // Expand ~ to home directory 142 - const expandedPath = extPath.startsWith('~') 143 - ? path.join(app.getPath('home'), extPath.slice(1)) 144 - : extPath; 143 + const expandedPath = tilePath.startsWith('~') 144 + ? path.join(app.getPath('home'), tilePath.slice(1)) 145 + : tilePath; 145 146 // Resolve to absolute path 146 147 const absolutePath = path.resolve(expandedPath); 147 - devExtensionPaths.push(absolutePath); 148 - DEBUG && console.log(`[cli] Dev extension path: ${absolutePath}`); 148 + devTilePaths.push(absolutePath); 149 + DEBUG && console.log(`[cli] Dev tile path: ${absolutePath}`); 149 150 } 150 151 } 151 152 ··· 401 402 402 403 // Define onQuit for use in IPC handlers and shortcuts 403 404 const onQuit = async () => { 404 - // Clean up dev extensions before quitting 405 - cleanupDevExtensions(); 405 + // Clean up dev tiles before quitting 406 + cleanupDevTiles(); 406 407 // Clean up web extensions 407 408 cleanupAdblocker(); 408 409 await cleanupChromeExtensions(); ··· 422 423 try { stopAutosaveTimer(); } catch (e) { DEBUG && console.error('[lifecycle] stopAutosaveTimer error:', e); } 423 424 try { cleanupDisplayWatcher(); } catch (e) { DEBUG && console.error('[lifecycle] cleanupDisplayWatcher error:', e); } 424 425 try { saveSessionSnapshot('before-quit'); } catch (e) { DEBUG && console.error('[lifecycle] saveSessionSnapshot error:', e); } 425 - try { cleanupDevExtensions(); } catch (e) { DEBUG && console.error('[lifecycle] cleanupDevExtensions error:', e); } 426 + try { cleanupDevTiles(); } catch (e) { DEBUG && console.error('[lifecycle] cleanupDevTiles error:', e); } 426 427 try { cleanupAdblocker(); } catch (e) { DEBUG && console.error('[lifecycle] cleanupAdblocker error:', e); } 427 428 try { stopHotReload(); } catch (e) { DEBUG && console.error('[lifecycle] stopHotReload error:', e); } 428 429 try { cleanupThumbnails(getActiveThumbnailHashes); } catch (e) { DEBUG && console.error('[lifecycle] cleanupThumbnails error:', e); } ··· 640 641 // are called early (before app.whenReady) for proper URL forwarding. 641 642 // See the block after setProfile(PROFILE) above. 642 643 643 - // Discover and register built-in extensions from features/ folder 644 - discoverBuiltinExtensions(path.join(ROOT_DIR, 'features')); 644 + // Discover and register built-in features (tiles) from features/ folder 645 + discoverBuiltinFeatures(path.join(ROOT_DIR, 'features')); 645 646 646 - // Register dev extensions from CLI arguments 647 + // Register dev tiles from CLI arguments (--load-extension flag) 647 648 // These are transient (not persisted) and load with devtools open 648 - for (const extPath of devExtensionPaths) { 649 + for (const tilePath of devTilePaths) { 649 650 try { 650 - const manifest = loadExtensionManifest(extPath); 651 + const manifest = loadFeatureManifest(tilePath); 651 652 if (manifest && manifest.id) { 652 - registerDevExtension(manifest.id, extPath); 653 + registerDevTile(manifest.id as string, tilePath); 653 654 } else { 654 - console.error(`[cli] Invalid extension at ${extPath}: missing id in manifest`); 655 + console.error(`[cli] Invalid tile at ${tilePath}: missing id in manifest`); 655 656 } 656 657 } catch (err) { 657 - console.error(`[cli] Failed to load extension manifest at ${extPath}:`, err); 658 + console.error(`[cli] Failed to load tile manifest at ${tilePath}:`, err); 658 659 } 659 660 } 660 661 ··· 723 724 // Handle CLI arguments (e.g., yarn start -- "https://example.com") 724 725 handleCliUrl(); 725 726 726 - // Track if extensions have been loaded (only load once) 727 - let extensionsLoaded = false; 727 + // Track if features have been loaded (only load once) 728 + let featuresLoaded = false; 728 729 729 730 // listen for app prefs to configure ourself 730 731 // TODO: kinda janky, needs rethink ··· 763 764 _quitShortcut = newQuitShortcut; 764 765 } 765 766 766 - // Load extensions after core app is ready (only once) 767 - if (!extensionsLoaded) { 768 - extensionsLoaded = true; 769 - const extStart = Date.now(); 770 - await loadExtensions(); 767 + // Load features after core app is ready (only once) 768 + if (!featuresLoaded) { 769 + featuresLoaded = true; 770 + const featStart = Date.now(); 771 + await loadFeatures(); 771 772 772 - // Load dev extensions after normal extensions (always with devtools) 773 - if (devExtensionPaths.length > 0) { 774 - const devCount = await loadDevExtensions(); 775 - DEBUG && console.log(`[ext:dev] Loaded ${devCount} dev extension(s)`); 773 + // Load dev tiles after normal features (always with devtools) 774 + if (devTilePaths.length > 0) { 775 + const devCount = await loadDevTiles(); 776 + DEBUG && console.log(`[tile:dev] Loaded ${devCount} dev tile(s)`); 776 777 } 777 778 778 779 // Load bundled web extensions (chrome extensions + adblocker) ··· 791 792 console.error('[startup] Chrome extensions init failed:', err); 792 793 }); 793 794 794 - const extTime = Date.now() - extStart; 795 + const featTime = Date.now() - featStart; 795 796 const totalTime = Date.now() - ((global as Record<string, unknown>).__startupStart as number); 796 - DEBUG && console.log(`[startup] main: ${extStart - ((global as Record<string, unknown>).__startupStart as number)}ms, extensions: ${extTime}ms, total: ${totalTime}ms`); 797 + DEBUG && console.log(`[startup] main: ${featStart - ((global as Record<string, unknown>).__startupStart as number)}ms, features: ${featTime}ms, total: ${totalTime}ms`); 797 798 798 - // Restore session after extensions are loaded 799 + // Restore session after features are loaded 799 800 if (_sessionRestorePending) { 800 801 try { 801 802 const restoreResult = await restoreSessionSnapshot(prefsMsg.prefs, _crashState);
-229
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 - import { 14 - validateSettingsSchema, 15 - logValidationIssues, 16 - } from './settings-schema-validation.js'; 17 - 18 - // Declarative command action types 19 - export interface CommandActionWindow { 20 - type: 'window'; 21 - url: string; 22 - options?: Record<string, unknown>; 23 - } 24 - 25 - export interface CommandActionPublish { 26 - type: 'publish'; 27 - topic: string; 28 - data?: Record<string, unknown>; 29 - } 30 - 31 - export interface CommandActionExecute { 32 - type: 'execute'; 33 - } 34 - 35 - export type CommandAction = CommandActionWindow | CommandActionPublish | CommandActionExecute; 36 - 37 - // Declarative command definition in manifest.json 38 - export interface ManifestCommand { 39 - name: string; 40 - description?: string; 41 - action: CommandAction; 42 - scope?: 'global' | 'window' | 'page'; 43 - modes?: string[]; 44 - accepts?: string[]; 45 - produces?: string[]; 46 - params?: Array<{ name: string; type: string; required?: boolean; description?: string; [key: string]: unknown }>; 47 - } 48 - 49 - // Declarative shortcut definition in manifest.json 50 - export interface ManifestShortcut { 51 - keys: string; 52 - command: string; 53 - global?: boolean; 54 - mode?: string; 55 - } 56 - 57 - export interface ExtensionManifest { 58 - id?: string; 59 - type?: 'theme' | 'extension'; 60 - shortname?: string; 61 - name?: string; 62 - description?: string; 63 - version?: string; 64 - builtin?: boolean; 65 - lazy?: boolean; // If true, extension iframe is not loaded at startup; loaded on first command use 66 - commands?: ManifestCommand[]; 67 - shortcuts?: ManifestShortcut[]; 68 - settingsSchema?: string; 69 - schemas?: { 70 - prefs?: unknown; 71 - item?: unknown; 72 - }; 73 - storageKeys?: Record<string, string>; 74 - defaults?: Record<string, unknown>; 75 - [key: string]: unknown; 76 - } 77 - 78 - export interface DiscoveredExtension { 79 - id: string; 80 - path: string; 81 - manifest: ExtensionManifest; 82 - } 83 - 84 - /** 85 - * Discover extensions in a directory 86 - * Scans for subdirectories containing manifest.json 87 - */ 88 - export function discoverExtensions(basePath: string): DiscoveredExtension[] { 89 - const extensions: DiscoveredExtension[] = []; 90 - 91 - if (!fs.existsSync(basePath)) return extensions; 92 - 93 - const entries = fs.readdirSync(basePath, { withFileTypes: true }); 94 - 95 - for (const entry of entries) { 96 - if (!entry.isDirectory()) continue; 97 - 98 - const extPath = path.join(basePath, entry.name); 99 - const manifestPath = path.join(extPath, 'manifest.json'); 100 - 101 - if (!fs.existsSync(manifestPath)) continue; 102 - 103 - try { 104 - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as ExtensionManifest; 105 - 106 - // Skip theme extensions — they are handled by discoverBuiltinThemes 107 - if (manifest.type === 'theme') continue; 108 - 109 - // Use manifest.id or folder name as fallback 110 - const id = manifest.id || manifest.shortname || entry.name; 111 - 112 - extensions.push({ id, path: extPath, manifest }); 113 - 114 - } catch (err) { 115 - const message = err instanceof Error ? err.message : String(err); 116 - console.error(`[ext:discovery] Failed to load ${entry.name}:`, message); 117 - } 118 - } 119 - 120 - return extensions; 121 - } 122 - 123 - /** 124 - * Load extension manifest with settings schema 125 - * Returns null if manifest doesn't exist or is invalid 126 - */ 127 - export function loadExtensionManifest(extPath: string): ExtensionManifest | null { 128 - try { 129 - const manifestPath = path.join(extPath, 'manifest.json'); 130 - if (!fs.existsSync(manifestPath)) return null; 131 - 132 - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as ExtensionManifest; 133 - 134 - // Load settings schema if specified 135 - if (manifest.settingsSchema) { 136 - const schemaPath = path.join(extPath, manifest.settingsSchema); 137 - if (fs.existsSync(schemaPath)) { 138 - try { 139 - const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8')); 140 - 141 - // Validate the schema shape. Any issues are logged but never block 142 - // the feature from loading — an invalid schema just means its 143 - // settings UI may not render, not that the feature is unusable. 144 - const extId = manifest.id || manifest.shortname || path.basename(extPath); 145 - try { 146 - const result = validateSettingsSchema(schema); 147 - logValidationIssues(extId, result); 148 - } catch (validationErr) { 149 - const vmsg = validationErr instanceof Error ? validationErr.message : String(validationErr); 150 - console.error(`[schema-validation] ${extId}: validator threw unexpectedly:`, vmsg); 151 - } 152 - 153 - // Merge schema fields into manifest for Settings UI 154 - manifest.schemas = { prefs: schema.prefs, item: schema.item }; 155 - manifest.storageKeys = schema.storageKeys; 156 - manifest.defaults = schema.defaults; 157 - if (schema.labels) { 158 - manifest.labels = schema.labels; 159 - } 160 - } catch (schemaErr) { 161 - const extId = manifest.id || manifest.shortname || path.basename(extPath); 162 - const msg = schemaErr instanceof Error ? schemaErr.message : String(schemaErr); 163 - console.error(`[schema-validation] ${extId}: failed to parse ${manifest.settingsSchema}:`, msg); 164 - } 165 - } 166 - } 167 - 168 - return manifest; 169 - } catch (err) { 170 - const message = err instanceof Error ? err.message : String(err); 171 - console.error(`[ext:manifest] Failed to load manifest from ${extPath}:`, message); 172 - return null; 173 - } 174 - } 175 - 176 - /** 177 - * Check if a built-in extension is enabled 178 - * Defaults to true for built-in extensions 179 - * Note: 'cmd' used to be special-cased here but was extracted out of features/ 180 - * into core app code (app/cmd/). It no longer appears in the extension registry. 181 - */ 182 - export function isBuiltinExtensionEnabled(extId: string): boolean { 183 - try { 184 - const db = getDb(); 185 - // Use 'ext_disabled' key to avoid collision with extension-internal settings. 186 - // Extensions may use 'enabled' for their own purposes (e.g., HUD overlay toggle), 187 - // so we use a distinct key that only the extension manager writes. 188 - const setting = db.prepare( 189 - 'SELECT * FROM feature_settings WHERE featureId = ? AND key = ?' 190 - ).get(extId, 'ext_disabled') as { value?: string } | undefined; 191 - 192 - if (setting) { 193 - try { 194 - return JSON.parse(setting.value || 'false') !== true; 195 - } catch { 196 - return true; 197 - } 198 - } 199 - return true; // Default to enabled for builtins 200 - } catch { 201 - return true; // Database not ready, default to enabled 202 - } 203 - } 204 - 205 - /** 206 - * Get all external extensions from datastore 207 - */ 208 - export function getExternalExtensions(): Array<{ 209 - id: string; 210 - path: string | null; 211 - enabled: boolean; 212 - }> { 213 - try { 214 - const db = getDb(); 215 - const exts = db.prepare('SELECT * FROM extensions').all() as Array<{ 216 - id: string; 217 - path?: string; 218 - enabled?: number; 219 - }>; 220 - 221 - return exts.map(ext => ({ 222 - id: ext.id, 223 - path: ext.path || null, 224 - enabled: ext.enabled === 1 225 - })); 226 - } catch { 227 - return []; 228 - } 229 - }
-3
backend/electron/feature-startup.ts
··· 31 31 tilePreloadPath: string; 32 32 /** Feature IDs that should load eagerly */ 33 33 eagerIds?: Set<string>; 34 - /** Callback for v1 features needing legacy loading */ 35 - onV1Feature?: (id: string, featurePath: string, manifest: unknown) => void; 36 34 /** Current Peek app version (for minPeekVersion checks on installs) */ 37 35 peekVersion?: string; 38 36 } ··· 213 211 const loaderConfig: TileLoaderConfig = { 214 212 tilePreloadPath: config.tilePreloadPath, 215 213 eagerIds: config.eagerIds, 216 - onV1Feature: config.onV1Feature as TileLoaderConfig['onV1Feature'], 217 214 }; 218 215 219 216 const loadResults = loadFeaturesFromRegistry(registry, loaderConfig);
+18 -20
backend/electron/index.ts
··· 85 85 initProtocol, 86 86 } from './protocol.js'; 87 87 88 - // Extension management 88 + // Feature/tile state queries 89 89 export { 90 - discoverExtensions, 91 - loadExtensionManifest, 92 - isBuiltinExtensionEnabled, 93 - getExternalExtensions, 94 - } from './extensions.js'; 90 + discoverFeatures, 91 + loadFeatureManifest, 92 + isBuiltinFeatureEnabled, 93 + } from './tile-feature-state.js'; 95 94 96 95 export type { 97 - ExtensionManifest, 98 - DiscoveredExtension, 99 - } from './extensions.js'; 96 + DiscoveredFeature, 97 + } from './tile-feature-state.js'; 100 98 101 99 // System tray 102 100 export { ··· 138 136 export { 139 137 configure, 140 138 initialize, 141 - discoverBuiltinExtensions, 139 + discoverBuiltinFeatures, 142 140 discoverBuiltinThemes, 143 - loadExtensions, 144 - getRunningExtensions, 145 - // Dev extension support (CLI --load-extension) 146 - registerDevExtension, 147 - loadDevExtension, 148 - loadDevExtensions, 149 - cleanupDevExtensions, 150 - isDevExtension, 151 - getDevExtensionIds, 152 - reloadExtension, 141 + loadFeatures, 142 + getRunningTiles, 143 + // Dev tile support (CLI --load-extension) 144 + registerDevTile, 145 + loadDevTile, 146 + loadDevTiles, 147 + cleanupDevTiles, 148 + isDevTile, 149 + getDevTileIds, 150 + reloadTile, 153 151 registerWindow, 154 152 getWindowInfo, 155 153 findWindowByKey,
+3 -8
backend/electron/ipc.ts
··· 32 32 import type { Item } from '../types/index.js'; 33 33 34 34 import { 35 - loadExtensionManifest, 36 - } from './extensions.js'; 37 - 38 - 39 - import { 40 35 getRegisteredThemeIds, 41 36 getThemePath, 42 37 getActiveThemeId, ··· 44 39 } from './protocol.js'; 45 40 46 41 import { 47 - getRunningExtensions, 48 - getAllRegisteredExtensions, 49 - reloadExtension, 42 + getRunningTiles, 43 + getAllRegisteredTiles, 44 + reloadTile, 50 45 registerWindow, 51 46 getWindowInfo, 52 47 removeWindow,
+84 -92
backend/electron/main.ts
··· 14 14 import { registerScheme, initProtocol, registerTilePath, getTilePath, getRegisteredTileIds, registerThemePath, getRegisteredThemeIds } from './protocol.js'; 15 15 import { initCore, getCoreBackgroundWindow } from './core-glue.js'; 16 16 import { initTestFixture } from './test-fixture-glue.js'; 17 - import { discoverExtensions, loadExtensionManifest, isBuiltinExtensionEnabled } from './extensions.js'; 17 + import { discoverFeatures, loadFeatureManifest, isBuiltinFeatureEnabled } from './tile-feature-state.js'; 18 18 import { initializeFeatures, type FeatureStartupResult } from './feature-startup.js'; 19 19 import { ensureTileIpcHandlers } from './tile-compat.js'; 20 20 import { ··· 77 77 // this set in addition to the manifest `resident: true` flag. 78 78 const EAGER_TILE_IDS = new Set(['entities']); 79 79 80 - // Dev extensions loaded via --load-extension CLI flag 80 + // Dev tiles loaded via --load-extension CLI flag 81 81 // These are transient (not persisted) and always have devtools open 82 - const devExtensions = new Map<string, { path: string }>(); 82 + const devTiles = new Map<string, { path: string }>(); 83 83 84 84 // Window manager: windowId -> { source, params } 85 85 const windowRegistry = new Map<number, { ··· 190 190 registerTileLifecycleIpc(); 191 191 192 192 // Resolve & cache the tile-preload path early (also used by `initCore` 193 - // from entry.ts before `loadExtensions` runs). Preload source is 193 + // from entry.ts before `loadFeatures` runs). Preload source is 194 194 // `tile-preload.cts` (CommonJS) so tsc emits `tile-preload.cjs` — 195 195 // Electron preload scripts must be CommonJS under `sandbox: true`. 196 196 const tilePreloadPath = path.join(config.rootDir, 'dist', 'backend', 'electron', 'tile-preload.cjs'); ··· 210 210 // `cmd:execute:{name}` or declared-lazyEvent publish passes through 211 211 // this hook, which awaits the owning tile's boot before letting the 212 212 // publish reach subscribers. Must run before any command can fire 213 - // (i.e. before loadExtensions / initializeFeatures). 213 + // (i.e. before loadFeatures / initializeFeatures). 214 214 installLoadOnDispatchHook(); 215 215 216 216 // Initialize database ··· 590 590 } 591 591 592 592 /** 593 - * Discover and register built-in extensions 593 + * Discover and register built-in features (each feature is a tile in v2). 594 594 */ 595 - export function discoverBuiltinExtensions(extensionsDir: string): void { 596 - const discovered = discoverExtensions(extensionsDir); 597 - for (const ext of discovered) { 598 - registerTilePath(ext.id, ext.path); 595 + export function discoverBuiltinFeatures(featuresDir: string): void { 596 + const discovered = discoverFeatures(featuresDir); 597 + for (const feat of discovered) { 598 + registerTilePath(feat.id, feat.path); 599 599 } 600 600 } 601 601 ··· 712 712 713 713 714 714 /** 715 - * Load all enabled extensions (hybrid mode) 716 - * - Built-in extensions (cmd, groups, peeks, slides) → consolidated (iframes) 717 - * - External extensions (including example) → separate windows 715 + * Load all enabled features. Each feature is a tile in v2. 716 + * - Builtin features (cmd/hud/page core renderers) launch via core-glue. 717 + * - Feature tiles in `features/` launch via the v2 tile launcher (eager 718 + * if `resident: true` or in `EAGER_TILE_IDS`, otherwise lazy stubs). 718 719 */ 719 - export async function loadExtensions(): Promise<number> { 720 - const extStart = Date.now(); 720 + export async function loadFeatures(): Promise<number> { 721 + const featStart = Date.now(); 721 722 722 723 const featuresDir = path.join(config.rootDir, 'features'); 723 724 // `tilePreloadPath` is now resolved + cached in `initialize()`. Read 724 725 // it via the config getter so the value stays consistent whether we 725 726 // were reached from `initialize()` or directly from a test harness. 726 727 const tilePreloadPath = getTilePreloadPath(); 727 - const v1FeatureIds = new Set<string>(); 728 - const v2FeatureIds = new Set<string>(); 728 + const loadedTileIds = new Set<string>(); 729 729 730 730 // cmd, hud, and page's command/shortcut wiring all run inside the 731 731 // core background renderer (app/index.js awaits initCmd(), initHud(), ··· 746 746 } 747 747 } 748 748 749 - // ── V2 Feature Startup ── 749 + // ── Feature Startup ── 750 750 // Run the feature-startup pipeline: discover, sync registry, and load v2 tile manifests. 751 - // V2 manifests are handled by the tile launcher; v1 manifests fall through to legacy loading below. 752 751 // This runs AFTER initCmd so tile-lazy's cmd:register-batch publishes reach cmd. 753 752 try { 754 753 const featureResult: FeatureStartupResult = initializeFeatures({ ··· 756 755 builtinFeaturesDir: featuresDir, 757 756 tilePreloadPath, 758 757 eagerIds: EAGER_TILE_IDS, 759 - onV1Feature: (id: string, _featurePath: string, _manifest: unknown) => { 760 - // Collect v1 feature IDs — they'll be loaded via the legacy path below 761 - v1FeatureIds.add(id); 762 - }, 763 758 }); 764 - // Track which features the v2 tile system handled — skip these in legacy loading 765 759 for (const r of featureResult.loadResults) { 766 - if (r.loaded) v2FeatureIds.add(r.id); 760 + if (r.loaded) loadedTileIds.add(r.id); 767 761 } 768 - DEBUG && console.log( 769 - `[ext] Feature startup: ${v2FeatureIds.size} v2 loaded, ` + 770 - `${v1FeatureIds.size} v1 deferred to legacy loader` 771 - ); 762 + DEBUG && console.log(`[feature] Feature startup: ${loadedTileIds.size} tiles loaded`); 772 763 } catch (err) { 773 - console.error('[ext] Feature startup failed, falling back to legacy-only loading:', err); 764 + console.error('[feature] Feature startup failed:', err); 774 765 } 775 766 776 767 // Phase 1: Early ··· 786 777 // Declarative `type: 'window'` and `type: 'publish'` commands are 787 778 // short-circuited inside the hook without loading the tile. 788 779 789 - DEBUG && console.log(`[ext:timing] hybrid total: ${Date.now() - extStart}ms`); 780 + DEBUG && console.log(`[feature:timing] startup total: ${Date.now() - featStart}ms`); 790 781 791 782 // Phase 3: UI 792 783 publish('system', 'ext:startup:phase', { phase: 'ui' }); ··· 799 790 } 800 791 801 792 // Core renderers that are always considered "running" once the app has started. 802 - // These are not v2 tiles (no tile manifest) and not v1 iframe extensions, but 803 - // the Features pane should always show them as running. 793 + // These are not v2 feature tiles (no tile manifest in features/) but the 794 + // Features pane should always show them as running. 804 795 const CORE_RENDERER_IDS = ['cmd', 'hud', 'page']; 805 796 806 797 /** 807 - * Get running extensions info 808 - * Includes core renderers and v2 tiles. 798 + * Get running tiles info. 799 + * Includes core renderers and feature tiles launched by the tile-launcher. 809 800 */ 810 - export function getRunningExtensions(): Array<{ id: string; manifest: unknown; status: string }> { 801 + export function getRunningTiles(): Array<{ id: string; manifest: unknown; status: string }> { 811 802 const running: Array<{ id: string; manifest: unknown; status: string }> = []; 812 803 813 804 // Add core renderers (cmd, hud, page) — always running once app is up 814 - for (const extId of CORE_RENDERER_IDS) { 815 - if (!isBuiltinExtensionEnabled(extId)) continue; 816 - const extPath = getTilePath(extId); 817 - const manifest = extPath ? loadExtensionManifest(extPath) : null; 805 + for (const tileId of CORE_RENDERER_IDS) { 806 + if (!isBuiltinFeatureEnabled(tileId)) continue; 807 + const tilePath = getTilePath(tileId); 808 + const manifest = tilePath ? loadFeatureManifest(tilePath) : null; 818 809 running.push({ 819 - id: extId, 820 - manifest: manifest || { id: extId }, 810 + id: tileId, 811 + manifest: manifest || { id: tileId }, 821 812 status: 'running' 822 813 }); 823 814 } ··· 825 816 // Add v2 tiles launched by the tile-launcher as separate BrowserWindows. 826 817 for (const tileId of getLoadedTileIds()) { 827 818 if (running.some(r => r.id === tileId)) continue; 828 - if (!isBuiltinExtensionEnabled(tileId)) continue; 819 + if (!isBuiltinFeatureEnabled(tileId)) continue; 829 820 const tileManifest = getTileManifest(tileId); 830 821 running.push({ 831 822 id: tileId, ··· 838 829 } 839 830 840 831 /** 841 - * Get all registered (discovered) builtin extensions, whether running or not. 842 - * Used by the Settings Features pane to show ALL extensions from the extensions/ directory. 832 + * Get all registered (discovered) builtin tiles, whether running or not. 833 + * Used by the Settings Features pane to show ALL tiles from the features/ directory. 843 834 */ 844 - export function getAllRegisteredExtensions(): Array<{ id: string; manifest: unknown; status: string }> { 835 + export function getAllRegisteredTiles(): Array<{ id: string; manifest: unknown; status: string }> { 845 836 const registeredIds = getRegisteredTileIds(); 846 837 const result: Array<{ id: string; manifest: unknown; status: string }> = []; 847 838 ··· 852 843 runningSet.add(tileId); 853 844 } 854 845 855 - for (const extId of registeredIds) { 856 - const extPath = getTilePath(extId); 857 - const manifest = extPath ? loadExtensionManifest(extPath) : null; 846 + for (const tileId of registeredIds) { 847 + const tilePath = getTilePath(tileId); 848 + const manifest = tilePath ? loadFeatureManifest(tilePath) : null; 858 849 result.push({ 859 - id: extId, 860 - manifest: manifest || { id: extId }, 861 - status: runningSet.has(extId) ? 'running' : 'registered' 850 + id: tileId, 851 + manifest: manifest || { id: tileId }, 852 + status: runningSet.has(tileId) ? 'running' : 'registered' 862 853 }); 863 854 } 864 855 ··· 867 858 868 859 869 860 /** 870 - * Check if an extension is a dev extension (loaded via CLI) 861 + * Check if a tile was loaded as a dev tile via the CLI --load-extension flag. 871 862 */ 872 - export function isDevExtension(extId: string): boolean { 873 - return devExtensions.has(extId); 863 + export function isDevTile(tileId: string): boolean { 864 + return devTiles.has(tileId); 874 865 } 875 866 876 867 /** 877 - * Register a dev extension path for CLI --load-extension 878 - * These are transient and not persisted to datastore 868 + * Register a dev tile path for the CLI --load-extension flag. 869 + * These are transient and not persisted to datastore. 879 870 */ 880 - export function registerDevExtension(extId: string, extPath: string): void { 881 - devExtensions.set(extId, { path: extPath }); 882 - // Also register the path so the protocol handler can resolve peek://{extId}/... 883 - registerTilePath(extId, extPath); 884 - DEBUG && console.log(`[ext:dev] Registered dev extension: ${extId} from ${extPath}`); 871 + export function registerDevTile(tileId: string, tilePath: string): void { 872 + devTiles.set(tileId, { path: tilePath }); 873 + // Also register the path so the protocol handler can resolve peek://{tileId}/... 874 + registerTilePath(tileId, tilePath); 875 + DEBUG && console.log(`[tile:dev] Registered dev tile: ${tileId} from ${tilePath}`); 885 876 } 886 877 887 878 /** 888 - * Load a dev extension with devtools always open 879 + * Load a dev tile with devtools always open. 889 880 */ 890 - export async function loadDevExtension(extId: string): Promise<object | null> { 891 - const devExt = devExtensions.get(extId); 892 - if (!devExt) { 893 - console.error(`[ext:dev] Dev extension not registered: ${extId}`); 881 + export async function loadDevTile(tileId: string): Promise<object | null> { 882 + const devTile = devTiles.get(tileId); 883 + if (!devTile) { 884 + console.error(`[tile:dev] Dev tile not registered: ${tileId}`); 894 885 return null; 895 886 } 896 887 897 888 // Relaunch as v2 tile: re-read manifest, revoke old token, close old window, launch fresh. 898 - const result = await relaunchTile(extId); 889 + const result = await relaunchTile(tileId); 899 890 if (!result) { 900 891 return null; 901 892 } 902 893 903 - // Always open devtools for dev extensions (regardless of debug mode) 894 + // Always open devtools for dev tiles (regardless of debug mode) 904 895 if (!isHeadless()) { 905 896 result.window.webContents.openDevTools({ mode: 'detach', activate: false }); 906 897 } 907 898 908 - DEBUG && console.log(`[ext:dev] Loaded dev extension: ${extId}`); 899 + DEBUG && console.log(`[tile:dev] Loaded dev tile: ${tileId}`); 909 900 return result; 910 901 } 911 902 912 903 /** 913 - * Load all registered dev extensions 904 + * Load all registered dev tiles. 914 905 */ 915 - export async function loadDevExtensions(): Promise<number> { 906 + export async function loadDevTiles(): Promise<number> { 916 907 let count = 0; 917 - for (const [extId] of devExtensions) { 918 - const win = await loadDevExtension(extId); 908 + for (const [tileId] of devTiles) { 909 + const win = await loadDevTile(tileId); 919 910 if (win) count++; 920 911 } 921 912 return count; 922 913 } 923 914 924 915 /** 925 - * Unload and clean up all dev extensions 916 + * Unload and clean up all dev tiles. 926 917 */ 927 - export function cleanupDevExtensions(): void { 928 - for (const [extId] of devExtensions) { 929 - DEBUG && console.log(`[ext:dev] Cleaned up dev extension: ${extId}`); 918 + export function cleanupDevTiles(): void { 919 + for (const [tileId] of devTiles) { 920 + DEBUG && console.log(`[tile:dev] Cleaned up dev tile: ${tileId}`); 930 921 } 931 - devExtensions.clear(); 922 + devTiles.clear(); 932 923 } 933 924 934 925 /** 935 - * Get list of dev extension IDs 926 + * Get list of dev tile IDs. 936 927 */ 937 - export function getDevExtensionIds(): string[] { 938 - return Array.from(devExtensions.keys()); 928 + export function getDevTileIds(): string[] { 929 + return Array.from(devTiles.keys()); 939 930 } 940 931 941 932 /** 942 - * Reload an extension by closing and relaunching its tile window. 933 + * Reload a tile by closing and relaunching its window. 943 934 * Returns a truthy result on success, null on failure. 944 935 */ 945 - export async function reloadExtension(extId: string): Promise<object | null> { 946 - DEBUG && console.log(`[ext:reload] Reloading extension: ${extId}`); 936 + export async function reloadTile(tileId: string): Promise<object | null> { 937 + DEBUG && console.log(`[tile:reload] Reloading tile: ${tileId}`); 947 938 948 939 // Check if it's a core renderer (cmd/hud/page) — not reloadable without a full app restart 949 - if (CORE_RENDERER_IDS.includes(extId)) { 950 - console.error(`[ext:reload] Cannot reload core renderer: ${extId} (reload the app instead)`); 940 + if (CORE_RENDERER_IDS.includes(tileId)) { 941 + console.error(`[tile:reload] Cannot reload core renderer: ${tileId} (reload the app instead)`); 951 942 return null; 952 943 } 953 944 954 - // For dev extensions, open devtools after relaunch 955 - if (isDevExtension(extId)) { 956 - return loadDevExtension(extId); 945 + // For dev tiles, open devtools after relaunch 946 + if (isDevTile(tileId)) { 947 + return loadDevTile(tileId); 957 948 } 958 949 959 950 // Relaunch as v2 tile: re-read manifest, revoke old token, close old window, launch fresh. 960 - return relaunchTile(extId); 951 + return relaunchTile(tileId); 961 952 } 962 953 963 954 /** ··· 1603 1594 getDb, 1604 1595 getTilePath, 1605 1596 getRegisteredTileIds, 1606 - loadExtensionManifest, 1597 + loadFeatureManifest, 1607 1598 }; 1599 +
+19 -87
backend/electron/tile-compat.ts
··· 1 1 /** 2 - * Tile Compatibility Layer 3 - * 4 - * Both v1 (legacy extension) and v2 (tile) manifests work side by side. 5 - * This module handles: 6 - * - Detecting manifest version during extension discovery 7 - * - Routing v1 manifests to the legacy extension loader 8 - * - Routing v2 manifests to the tile launcher 9 - * - Allowing incremental migration of extensions to tiles 2 + * Tile loader bridge — registers `tile:*` IPC handlers and routes a 3 + * parsed v2 tile manifest to either the eager `launchTile()` path or 4 + * the lazy registration path in `tile-lazy.ts`. 10 5 */ 11 6 12 - import path from 'node:path'; 13 - 14 - import { discoverExtensions, type DiscoveredExtension, type ExtensionManifest } from './extensions.js'; 15 - import { parseManifestFile, detectManifestVersion, type TileManifestV2, type ParsedManifest } from './tile-manifest.js'; 7 + import { type TileManifestV2 } from './tile-manifest.js'; 16 8 import { launchTile, type TileLaunchResult } from './tile-launcher.js'; 17 9 import { registerLazyTile } from './tile-lazy.js'; 18 10 import { registerTileIpcHandlers } from './tile-ipc.js'; ··· 22 14 // ─── Types ─────────────────────────────────────────────────────────── 23 15 24 16 /** 25 - * Discovered extension with manifest version annotation 17 + * A tile annotated with its parsed v2 manifest, ready for loading. 26 18 */ 27 - export interface AnnotatedExtension { 19 + export interface AnnotatedTile { 28 20 id: string; 29 21 path: string; 30 - manifestVersion: 'v1' | 'v2'; 31 - /** Original v1 manifest (always present) */ 32 - v1Manifest: ExtensionManifest; 33 - /** Parsed v2 manifest (only if manifestVersion is 'v2') */ 34 - v2Manifest?: TileManifestV2; 22 + v2Manifest: TileManifestV2; 35 23 } 36 24 37 25 // ─── State ─────────────────────────────────────────────────────────── ··· 55 43 ipcHandlersRegistered = true; 56 44 } 57 45 58 - // ─── Discovery ─────────────────────────────────────────────────────── 59 - 60 - /** 61 - * Discover and annotate all extensions with their manifest version 62 - * 63 - * This is the compatibility-aware replacement for discoverExtensions(). 64 - * Returns all extensions with their detected manifest version, allowing 65 - * the caller to route them to the appropriate loader. 66 - */ 67 - export function discoverAnnotatedExtensions(basePath: string): AnnotatedExtension[] { 68 - // Use existing discovery to find all extensions 69 - const extensions = discoverExtensions(basePath); 70 - const annotated: AnnotatedExtension[] = []; 71 - 72 - for (const ext of extensions) { 73 - const manifestPath = path.join(ext.path, 'manifest.json'); 74 - const parsed = parseManifestFile(manifestPath); 75 - 76 - if (!parsed) { 77 - // Failed to parse — treat as v1 (legacy) 78 - annotated.push({ 79 - id: ext.id, 80 - path: ext.path, 81 - manifestVersion: 'v1', 82 - v1Manifest: ext.manifest, 83 - }); 84 - continue; 85 - } 86 - 87 - annotated.push({ 88 - id: ext.id, 89 - path: ext.path, 90 - manifestVersion: parsed.version, 91 - v1Manifest: ext.manifest, 92 - v2Manifest: parsed.v2, 93 - }); 94 - } 95 - 96 - DEBUG && console.log(`[tile-compat] Discovered ${annotated.length} extensions: ${annotated.filter(e => e.manifestVersion === 'v2').length} v2, ${annotated.filter(e => e.manifestVersion === 'v1').length} v1`); 97 - 98 - return annotated; 99 - } 100 - 101 46 // ─── Loading ───────────────────────────────────────────────────────── 102 47 103 48 /** 104 - * Configuration for loading extensions through the compatibility layer 49 + * Configuration for loading tiles through the compatibility layer 105 50 */ 106 51 export interface TileCompatConfig { 107 52 /** Path to the tile preload script */ 108 53 tilePreloadPath: string; 109 - /** Set of extension IDs that should load eagerly (not lazy) */ 54 + /** Set of tile IDs that should load eagerly (not lazy) */ 110 55 eagerIds?: Set<string>; 111 56 } 112 57 113 58 /** 114 - * Load a v2 tile extension 59 + * Load a v2 tile. 115 60 * 116 61 * Registers the tile's path with the protocol handler (for peek://{tileId}/ URLs), 117 62 * then either launches it eagerly or registers it for lazy loading. 118 63 */ 119 64 export function loadV2Tile( 120 - ext: AnnotatedExtension, 65 + tile: AnnotatedTile, 121 66 config: TileCompatConfig 122 67 ): TileLaunchResult | null { 123 - if (!ext.v2Manifest) { 124 - console.error(`[tile-compat] Cannot load ${ext.id} as v2: no v2 manifest`); 125 - return null; 126 - } 127 - 128 68 // Ensure IPC handlers are registered (once) 129 69 if (!ipcHandlersRegistered) { 130 70 registerTileIpcHandlers(); ··· 132 72 } 133 73 134 74 // Register with protocol handler so peek://{tileId}/ URLs resolve 135 - registerTilePath(ext.id, ext.path); 75 + registerTilePath(tile.id, tile.path); 136 76 137 - const manifest = ext.v2Manifest; 138 - const isEager = config.eagerIds?.has(ext.id) ?? false; 77 + const manifest = tile.v2Manifest; 78 + const isEager = config.eagerIds?.has(tile.id) ?? false; 139 79 140 80 // A tile is resident if any entry declares `resident: true`. 141 81 const residentEntry = manifest.tiles.find(t => t.resident === true); 142 - const hasLazyEntries = manifest.tiles.some(t => t.lazy === true); 143 82 const hasLazyEvents = manifest.tiles.some(t => Array.isArray(t.lazyEvents) && t.lazyEvents.length > 0); 144 83 // Non-eager tiles with no resident entry are lazy-loaded on first use. 145 84 const shouldLazyLoad = !isEager && !residentEntry; ··· 148 87 if (shouldLazyLoad && (hasCommands || hasLazyEvents)) { 149 88 // Lazy load: register command stubs and/or event interceptors. 150 89 // The tile is loaded on first command invocation or first lazy event publish. 151 - registerLazyTile(manifest, ext.path, config.tilePreloadPath); 152 - DEBUG && console.log(`[tile-compat] Registered ${ext.id} for lazy loading`); 90 + registerLazyTile(manifest, tile.path, config.tilePreloadPath); 91 + DEBUG && console.log(`[tile-compat] Registered ${tile.id} for lazy loading`); 153 92 return null; 154 93 } 155 94 156 95 // Eager load: launch the resident entry immediately 157 96 if (residentEntry) { 158 97 const result = launchTile({ 159 - tilePath: ext.path, 98 + tilePath: tile.path, 160 99 manifest, 161 100 preloadPath: config.tilePreloadPath, 162 101 entryId: residentEntry.id, 163 102 }); 164 - DEBUG && console.log(`[tile-compat] Eagerly launched ${ext.id} (resident entry: ${residentEntry.id})`); 103 + DEBUG && console.log(`[tile-compat] Eagerly launched ${tile.id} (resident entry: ${residentEntry.id})`); 165 104 return result; 166 105 } 167 106 168 107 // No resident entry — just register the protocol path. 169 108 // Tiles will be launched on demand via commands. 170 - DEBUG && console.log(`[tile-compat] Registered ${ext.id} (no resident entry, commands only)`); 109 + DEBUG && console.log(`[tile-compat] Registered ${tile.id} (no resident entry, commands only)`); 171 110 return null; 172 111 } 173 - 174 - /** 175 - * Check if an extension should be loaded via the tile runtime 176 - */ 177 - export function shouldUseTileRuntime(ext: AnnotatedExtension): boolean { 178 - return ext.manifestVersion === 'v2' && ext.v2Manifest !== undefined; 179 - }
+156
backend/electron/tile-feature-state.ts
··· 1 + /** 2 + * Tile feature state — discovery and enabled-state helpers 3 + * 4 + * Thin filesystem + datastore queries that the rest of the backend uses 5 + * to enumerate features (each feature is a tile in v2) and check whether 6 + * a builtin is enabled. The actual tile launch / lifecycle lives in 7 + * `tile-launcher.ts` / `tile-lifecycle.ts`; manifest parsing lives in 8 + * `tile-manifest.ts` (returns the strict v3 `TileManifestV2` shape). 9 + * 10 + * Returned manifest objects here are deliberately untyped (`unknown`) — 11 + * callers that need typed access go through `tile-manifest.ts`. This 12 + * module is only for the few places that need a quick path-from-disk 13 + * read (e.g. the Features pane listing every registered tile). 14 + */ 15 + 16 + import fs from 'node:fs'; 17 + import path from 'node:path'; 18 + import { getDb } from './datastore.js'; 19 + import { 20 + validateSettingsSchema, 21 + logValidationIssues, 22 + } from './settings-schema-validation.js'; 23 + 24 + export interface DiscoveredFeature { 25 + id: string; 26 + path: string; 27 + manifest: Record<string, unknown>; 28 + } 29 + 30 + /** 31 + * Discover features in a directory. 32 + * Scans for subdirectories containing manifest.json. Theme manifests 33 + * (`type: "theme"`) are skipped here — themes are discovered separately 34 + * by `discoverBuiltinThemes` in `main.ts`. 35 + */ 36 + export function discoverFeatures(basePath: string): DiscoveredFeature[] { 37 + const features: DiscoveredFeature[] = []; 38 + 39 + if (!fs.existsSync(basePath)) return features; 40 + 41 + const entries = fs.readdirSync(basePath, { withFileTypes: true }); 42 + 43 + for (const entry of entries) { 44 + if (!entry.isDirectory()) continue; 45 + 46 + const featPath = path.join(basePath, entry.name); 47 + const manifestPath = path.join(featPath, 'manifest.json'); 48 + 49 + if (!fs.existsSync(manifestPath)) continue; 50 + 51 + try { 52 + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as Record<string, unknown>; 53 + 54 + // Skip theme features — handled by discoverBuiltinThemes 55 + if (manifest.type === 'theme') continue; 56 + 57 + const id = (manifest.id as string | undefined) 58 + || (manifest.shortname as string | undefined) 59 + || entry.name; 60 + 61 + features.push({ id, path: featPath, manifest }); 62 + 63 + } catch (err) { 64 + const message = err instanceof Error ? err.message : String(err); 65 + console.error(`[feature:discovery] Failed to load ${entry.name}:`, message); 66 + } 67 + } 68 + 69 + return features; 70 + } 71 + 72 + /** 73 + * Load a feature's manifest.json (with optional settings schema merge). 74 + * Returns null if the manifest doesn't exist or fails to parse. 75 + * 76 + * If the manifest declares `settingsSchema`, the schema is loaded, 77 + * validated, and merged into the returned manifest so the Settings UI 78 + * can render it. Validation issues are logged but never block loading. 79 + */ 80 + export function loadFeatureManifest(featPath: string): Record<string, unknown> | null { 81 + try { 82 + const manifestPath = path.join(featPath, 'manifest.json'); 83 + if (!fs.existsSync(manifestPath)) return null; 84 + 85 + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as Record<string, unknown>; 86 + 87 + const settingsSchemaRel = manifest.settingsSchema as string | undefined; 88 + if (settingsSchemaRel) { 89 + const schemaPath = path.join(featPath, settingsSchemaRel); 90 + if (fs.existsSync(schemaPath)) { 91 + try { 92 + const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8')); 93 + 94 + const featId = (manifest.id as string | undefined) 95 + || (manifest.shortname as string | undefined) 96 + || path.basename(featPath); 97 + try { 98 + const result = validateSettingsSchema(schema); 99 + logValidationIssues(featId, result); 100 + } catch (validationErr) { 101 + const vmsg = validationErr instanceof Error ? validationErr.message : String(validationErr); 102 + console.error(`[schema-validation] ${featId}: validator threw unexpectedly:`, vmsg); 103 + } 104 + 105 + // Merge schema fields into manifest for Settings UI 106 + manifest.schemas = { prefs: schema.prefs, item: schema.item }; 107 + manifest.storageKeys = schema.storageKeys; 108 + manifest.defaults = schema.defaults; 109 + if (schema.labels) { 110 + manifest.labels = schema.labels; 111 + } 112 + } catch (schemaErr) { 113 + const featId = (manifest.id as string | undefined) 114 + || (manifest.shortname as string | undefined) 115 + || path.basename(featPath); 116 + const msg = schemaErr instanceof Error ? schemaErr.message : String(schemaErr); 117 + console.error(`[schema-validation] ${featId}: failed to parse ${settingsSchemaRel}:`, msg); 118 + } 119 + } 120 + } 121 + 122 + return manifest; 123 + } catch (err) { 124 + const message = err instanceof Error ? err.message : String(err); 125 + console.error(`[feature:manifest] Failed to load manifest from ${featPath}:`, message); 126 + return null; 127 + } 128 + } 129 + 130 + /** 131 + * Check if a built-in feature is enabled. 132 + * Defaults to true for built-in features. 133 + * 134 + * Uses the `ext_disabled` settings key to avoid collision with feature- 135 + * internal settings (some features use `enabled` for their own purposes, 136 + * e.g. HUD overlay toggle). Only the feature manager writes this key. 137 + */ 138 + export function isBuiltinFeatureEnabled(featId: string): boolean { 139 + try { 140 + const db = getDb(); 141 + const setting = db.prepare( 142 + 'SELECT * FROM feature_settings WHERE featureId = ? AND key = ?' 143 + ).get(featId, 'ext_disabled') as { value?: string } | undefined; 144 + 145 + if (setting) { 146 + try { 147 + return JSON.parse(setting.value || 'false') !== true; 148 + } catch { 149 + return true; 150 + } 151 + } 152 + return true; // Default to enabled for builtins 153 + } catch { 154 + return true; // Database not ready, default to enabled 155 + } 156 + }
+11 -11
backend/electron/tile-ipc.ts
··· 88 88 import type { CapabilityGrant, TileCapabilities } from './tile-manifest.js'; 89 89 import { invokeWindowOpen, popupToOpener, reopenLastClosedWindow, getLastFocusedVisibleWindowId, getLastContentWindowId, clearLastContentWindowId, getDarkModeSetting, setDarkModeSetting, applyDarkModeSetting, persistAdBlockerPref } from './ipc.js'; 90 90 import { 91 - getRunningExtensions, 92 - getAllRegisteredExtensions, 93 - reloadExtension, 91 + getRunningTiles, 92 + getAllRegisteredTiles, 93 + reloadTile, 94 94 getWindowInfo, 95 95 validateThemeCSS, 96 96 } from './main.js'; ··· 5931 5931 return { success: false, error: 'trustedBuiltin required for tile:extensions:windowList' }; 5932 5932 } 5933 5933 try { 5934 - const running = getRunningExtensions(); 5934 + const running = getRunningTiles(); 5935 5935 return { success: true, data: running }; 5936 5936 } catch (err) { 5937 5937 return { success: false, error: err instanceof Error ? err.message : String(err) }; ··· 5948 5948 return { success: false, error: 'trustedBuiltin required for tile:extensions:listAllRegistered' }; 5949 5949 } 5950 5950 try { 5951 - const all = getAllRegisteredExtensions(); 5951 + const all = getAllRegisteredTiles(); 5952 5952 return { success: true, data: all }; 5953 5953 } catch (err) { 5954 5954 return { success: false, error: err instanceof Error ? err.message : String(err) }; ··· 5981 5981 return { success: false, error: 'trustedBuiltin required for tile:extensions:reload' }; 5982 5982 } 5983 5983 try { 5984 - const extId = args.id; 5985 - if (!extId) { 5986 - return { success: false, error: 'Missing extension id' }; 5984 + const tileId = args.id; 5985 + if (!tileId) { 5986 + return { success: false, error: 'Missing tile id' }; 5987 5987 } 5988 - const win = await reloadExtension(extId); 5988 + const win = await reloadTile(tileId); 5989 5989 if (!win) { 5990 - return { success: false, error: `Failed to reload extension: ${extId}` }; 5990 + return { success: false, error: `Failed to reload tile: ${tileId}` }; 5991 5991 } 5992 - return { success: true, data: { id: extId } }; 5992 + return { success: true, data: { id: tileId } }; 5993 5993 } catch (err) { 5994 5994 return { success: false, error: err instanceof Error ? err.message : String(err) }; 5995 5995 }
+1 -1
backend/electron/tile-launcher.ts
··· 789 789 * Relaunch a tile — re-read the manifest from disk, close the old window 790 790 * (if any), revoke its tokens, then call `launchTile()` with the fresh manifest. 791 791 * 792 - * Used by `reloadExtension()` and `loadDevExtension()` in main.ts. 792 + * Used by `reloadTile()` and `loadDevTile()` in main.ts. 793 793 * 794 794 * Edge cases: 795 795 * - Tile not currently running (no window): just launches fresh.
+15 -37
backend/electron/tile-loader.ts
··· 5 5 * 1. Load registry 6 6 * 2. Iterate features 7 7 * 3. Load manifests 8 - * 4. Route to tile launcher (v2) or legacy loader (v1) 8 + * 4. Route to tile launcher 9 9 * 10 10 * This module replaces the direct features/ scan pattern in main.ts. 11 11 * It reads from the registry (populated by feature-installer) and ··· 21 21 type TileManifestV2, 22 22 type ParsedManifest, 23 23 } from './tile-manifest.js'; 24 - import { type AnnotatedExtension, loadV2Tile, type TileCompatConfig } from './tile-compat.js'; 25 - import { type ExtensionManifest } from './extensions.js'; 24 + import { type AnnotatedTile, loadV2Tile, type TileCompatConfig } from './tile-compat.js'; 26 25 import * as manifestCache from './manifest-cache.js'; 27 26 import { DEBUG } from './config.js'; 28 27 ··· 34 33 export interface FeatureLoadResult { 35 34 id: string; 36 35 loaded: boolean; 37 - manifestVersion: 'v1' | 'v2'; 38 36 error?: string; 39 37 } 40 38 ··· 46 44 tilePreloadPath: string; 47 45 /** Set of feature IDs that should load eagerly */ 48 46 eagerIds?: Set<string>; 49 - /** Callback for v1 features that need legacy loading */ 50 - onV1Feature?: (id: string, featurePath: string, manifest: ExtensionManifest) => void; 51 47 } 52 48 53 49 // ─── Loader ───────────────────────────────────────────────────────── ··· 81 77 /** 82 78 * Resolve a feature's manifest, preferring manifest_cache over disk. 83 79 * 84 - * Phase C: the cache is the authoritative source. Disk fallback covers 85 - * two legitimate cases — v1 manifests (which the cache deliberately 86 - * doesn't store) and cache drift (e.g. ephemeral profile whose cache 87 - * was cleared mid-session between registry seed and feature load). 80 + * The cache is the authoritative source. Disk fallback covers cache 81 + * drift (e.g. ephemeral profile whose cache was cleared mid-session 82 + * between registry seed and feature load). 88 83 * 89 84 * Returning `null` means both paths failed; caller surfaces an error. 90 85 */ ··· 124 119 return { 125 120 id: entry.id, 126 121 loaded: false, 127 - manifestVersion: 'v2', 128 122 error: 'Feature is disabled', 129 123 }; 130 124 } ··· 132 126 const manifestPath = path.join(entry.path, 'manifest.json'); 133 127 134 128 // Cache-first read. feature-startup seeds manifest_cache from the 135 - // registry at boot (Phase B), so every v2 feature that went through 136 - // the installer is here. A miss means either a v1 feature (not 137 - // cached — we don't extract v1 decls) or drift (cache cleared mid- 138 - // session, disk-only manifest). Fall back to the disk parse so we 139 - // don't hard-fail in either case. 129 + // registry at boot, so every feature that went through the installer 130 + // is here. A miss means cache drift (cleared mid-session, disk-only 131 + // manifest). Fall back to the disk parse so we don't hard-fail. 140 132 const parsed = loadParsedManifest(entry, manifestPath); 141 133 if (!parsed) { 142 134 return { 143 135 id: entry.id, 144 136 loaded: false, 145 - manifestVersion: 'v2', 146 137 error: `Manifest not available for ${entry.id} (cache miss + disk parse failed)`, 147 138 }; 148 139 } ··· 151 142 return loadV2Feature(entry, parsed.v2, config); 152 143 } 153 144 154 - // V1 manifest — delegate to legacy loader 155 - if (config.onV1Feature) { 156 - try { 157 - config.onV1Feature(entry.id, entry.path, parsed.raw as unknown as ExtensionManifest); 158 - return { id: entry.id, loaded: true, manifestVersion: 'v1' }; 159 - } catch (err) { 160 - const message = err instanceof Error ? err.message : String(err); 161 - return { id: entry.id, loaded: false, manifestVersion: 'v1', error: message }; 162 - } 163 - } 164 - 145 + // Legacy (pre-v3 manifestVersion) — rejected by parseManifestFile in 146 + // practice, but we surface a clear error if one slips through. 165 147 return { 166 148 id: entry.id, 167 149 loaded: false, 168 - manifestVersion: 'v1', 169 - error: 'No v1 handler configured', 150 + error: `Feature ${entry.id} has a legacy manifest (manifestVersion < 3); reinstall or update the manifest.`, 170 151 }; 171 152 } 172 153 ··· 183 164 return { 184 165 id: entry.id, 185 166 loaded: false, 186 - manifestVersion: 'v2', 187 167 error: 'Feature not yet approved by user', 188 168 }; 189 169 } 190 170 191 - // Build the annotated extension object that tile-compat expects 192 - const annotated: AnnotatedExtension = { 171 + // Build the annotated tile object that tile-compat expects 172 + const annotated: AnnotatedTile = { 193 173 id: entry.id, 194 174 path: entry.path, 195 - manifestVersion: 'v2', 196 - v1Manifest: manifest as unknown as ExtensionManifest, 197 175 v2Manifest: manifest, 198 176 }; 199 177 ··· 204 182 205 183 try { 206 184 loadV2Tile(annotated, compatConfig); 207 - return { id: entry.id, loaded: true, manifestVersion: 'v2' }; 185 + return { id: entry.id, loaded: true }; 208 186 } catch (err) { 209 187 const message = err instanceof Error ? err.message : String(err); 210 - return { id: entry.id, loaded: false, manifestVersion: 'v2', error: message }; 188 + return { id: entry.id, loaded: false, error: message }; 211 189 } 212 190 }