experiments in a post-browser web
10
fork

Configure Feed

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

more extension breakout and fixes

+422 -817
+12 -4
app/cmd/index.js
··· 22 22 * Initialize command registration listeners 23 23 * Extensions publish cmd:register to add commands, cmd:unregister to remove 24 24 */ 25 - const initCommandRegistry = () => { 26 - // Listen for command registrations from extensions 25 + const initCommandRegistry = async () => { 26 + // Query already-registered commands from main process 27 + // This catches commands registered before cmd started (race condition fix) 28 + const existingCommands = await api.commands.getAll(); 29 + console.log('[cmd] Loaded', existingCommands.length, 'existing commands from registry'); 30 + existingCommands.forEach(cmd => { 31 + dynamicCommands.set(cmd.name, cmd); 32 + }); 33 + 34 + // Listen for command registrations from extensions (for live updates) 27 35 api.subscribe('cmd:register', (msg) => { 28 36 console.log('[cmd] cmd:register received:', msg.name); 29 37 dynamicCommands.set(msg.name, { ··· 103 111 }, { global: true }); 104 112 }; 105 113 106 - const init = () => { 114 + const init = async () => { 107 115 console.log('init'); 108 116 109 117 const prefs = () => store.get(storageKeys.PREFS); 110 118 111 119 // Initialize command registry before shortcuts so extensions can register 112 - initCommandRegistry(); 120 + await initCommandRegistry(); 113 121 114 122 initShortcut(prefs()); 115 123 };
+5 -22
app/features.js
··· 1 1 /** 2 2 * Features Collection 3 3 * 4 - * This module provides feature schemas for the Settings UI. 4 + * This module provides core feature schemas for the Settings UI. 5 5 * 6 - * Architecture note (Jan 2025): 6 + * Architecture (Jan 2025): 7 7 * - cmd and scripts are CORE features (run in peek://app context) 8 - * - peeks, slides, groups are EXTENSIONS (run in isolated peek://ext contexts) 9 - * 10 - * Extensions are now loaded by the main process ExtensionManager, not by 11 - * importing them here. However, this module still exports extension schemas 12 - * for the Settings UI to render settings forms. 13 - * 14 - * TODO: Settings UI should load extension schemas from manifest.json instead, 15 - * then app/peeks/ and app/slides/ can be deleted. 8 + * - Extensions (peeks, slides, groups) run in isolated peek://ext contexts 9 + * and their schemas are loaded dynamically from manifest.json 16 10 */ 17 11 18 - // Core features 12 + // Core features only 19 13 import cmd from './cmd/index.js'; 20 14 import scripts from './scripts/index.js'; 21 - 22 - // Extension schemas (for Settings UI only - extensions run in isolated processes) 23 - import peeks from './peeks/index.js'; 24 - import slides from './slides/index.js'; 25 - import groups from './groups/index.js'; 26 15 27 16 const fc = {}; 28 17 29 - // Core features 30 18 fc[cmd.id] = cmd; 31 19 fc[scripts.id] = scripts; 32 - 33 - // Extension schemas for Settings UI 34 - fc[peeks.id] = peeks; 35 - fc[slides.id] = slides; 36 - fc[groups.id] = groups; 37 20 38 21 export default fc;
-131
app/peeks/config.js
··· 1 - const id = 'ef3bd271-d408-421f-9338-47b615571e43'; 2 - 3 - const labels = { 4 - name: 'Peeks', 5 - prefs: { 6 - keyPrefix: 'Peek shortcut prefix', 7 - } 8 - }; 9 - 10 - const prefsSchema = { 11 - "$schema": "https://json-schema.org/draft/2020-12/schema", 12 - "$id": "peek.peeks.prefs.schema.json", 13 - "title": "Peeks preferences", 14 - "description": "Peeks user preferences", 15 - "type": "object", 16 - "properties": { 17 - "shortcutKeyPrefix": { 18 - "description": "Global OS hotkey prefix to trigger peeks - will be followed by 0-9", 19 - "type": "string", 20 - "default": "Option+" 21 - }, 22 - }, 23 - "required": [ "shortcutKeyPrefix"] 24 - }; 25 - 26 - const itemSchema = { 27 - "$schema": "https://json-schema.org/draft/2020-12/schema", 28 - "$id": "peek.peeks.peek.schema.json", 29 - "title": "Peek - page peek", 30 - "description": "Peek page peek", 31 - "type": "object", 32 - "properties": { 33 - "keyNum": { 34 - "description": "Number on keyboard to open this peek, 0-9", 35 - "type": "integer", 36 - "minimum": 0, 37 - "maximum": 9, 38 - "default": 0 39 - }, 40 - "title": { 41 - "description": "Name of the peek - user defined label", 42 - "type": "string", 43 - "default": "New Peek" 44 - }, 45 - "address": { 46 - "description": "URL to load", 47 - "type": "string", 48 - "default": "https://example.com" 49 - }, 50 - "persistState": { 51 - "description": "Whether to persist local state or load page into empty container - defaults to false", 52 - "type": "boolean", 53 - "default": false 54 - }, 55 - "keepLive": { 56 - "description": "Whether to keep page alive in background or load fresh when triggered - defaults to false", 57 - "type": "boolean", 58 - "default": false 59 - }, 60 - "allowSound": { 61 - "description": "Whether to allow the page to emit sound or not (eg for background music player peeks - defaults to false", 62 - "type": "boolean", 63 - "default": false 64 - }, 65 - "height": { 66 - "description": "User-defined height of peek page", 67 - "type": "integer", 68 - "default": 600 69 - }, 70 - "width": { 71 - "description": "User-defined width of peek page", 72 - "type": "integer", 73 - "default": 800 74 - }, 75 - "enabled": { 76 - "description": "Whether this peek is enabled or not.", 77 - "type": "boolean", 78 - "default": false 79 - }, 80 - }, 81 - "required": [ "keyNum", "title", "address", "persistState", "keepLive", "allowSound", 82 - "height", "width", "enabled" ] 83 - }; 84 - 85 - const listSchema = { 86 - type: 'array', 87 - items: { "$ref": "#/$defs/peek" } 88 - }; 89 - 90 - // TODO: schemaize 0-9 constraints for peeks 91 - const schemas = { 92 - prefs: prefsSchema, 93 - item: itemSchema, 94 - items: listSchema 95 - }; 96 - 97 - const storageKeys = { 98 - PREFS: 'prefs', 99 - ITEMS: 'items', 100 - }; 101 - 102 - const defaults = { 103 - prefs: { 104 - shortcutKeyPrefix: 'Option+' 105 - }, 106 - items: Array.from(Array(10)), 107 - }; 108 - 109 - for (var i = 0; i != 10; i++) { 110 - const address = i == 0 ? 'https://example.com/' : ''; 111 - const enabled = i == 0 ? true : false; 112 - defaults.items[i] = { 113 - keyNum: i, 114 - title: `Peek key ${i}`, 115 - address: address, 116 - persistState: false, 117 - keepLive: false, 118 - allowSound: false, 119 - height: 600, 120 - width: 800, 121 - enabled: enabled, 122 - }; 123 - } 124 - 125 - export { 126 - id, 127 - labels, 128 - schemas, 129 - storageKeys, 130 - defaults 131 - };
-124
app/peeks/index.js
··· 1 - import { id, labels, schemas, storageKeys, defaults } from './config.js'; 2 - import { openStore } from "../utils.js"; 3 - import windows from "../windows.js"; 4 - import api from '../api.js'; 5 - 6 - console.log('background', labels.name); 7 - 8 - const debug = api.debug; 9 - const clear = false; 10 - 11 - const store = openStore(id, defaults, clear /* clear storage */); 12 - 13 - // Track registered shortcuts for cleanup 14 - let registeredShortcuts = []; 15 - 16 - const executeItem = (item) => { 17 - console.log('executeItem:peek', item); 18 - const height = item.height || 600; 19 - const width = item.width || 800; 20 - 21 - const params = { 22 - // browserwindow 23 - height, 24 - width, 25 - 26 - // peek 27 - feature: labels.name, 28 - keepLive: item.keepLive || false, 29 - persistState: item.persistState || false, 30 - 31 - // Create a unique key for this peek using its address 32 - key: `peek:${item.address}`, 33 - 34 - // tracking (handled automatically by windows API) 35 - trackingSource: 'peek', 36 - trackingSourceId: item.keyNum ? `peek_${item.keyNum}` : 'peek', 37 - title: item.title || '' 38 - }; 39 - 40 - windows.openModalWindow(item.address, params) 41 - .then(result => { 42 - console.log('Peek window opened:', result); 43 - }) 44 - .catch(error => { 45 - console.error('Failed to open peek window:', error); 46 - }); 47 - }; 48 - 49 - const initItems = (prefs, items) => { 50 - const cmdPrefix = prefs.shortcutKeyPrefix; 51 - console.log('initItems', items); 52 - 53 - items.forEach(item => { 54 - if (item.enabled == true && item.address.length > 0) { 55 - const shortcut = `${cmdPrefix}${item.keyNum}`; 56 - 57 - api.shortcuts.register(shortcut, () => { 58 - executeItem(item); 59 - }, { global: true }); 60 - 61 - registeredShortcuts.push(shortcut); 62 - } 63 - }); 64 - }; 65 - 66 - /** 67 - * Unregister all shortcuts and clean up 68 - */ 69 - const uninit = () => { 70 - console.log('peeks uninit - unregistering', registeredShortcuts.length, 'shortcuts'); 71 - 72 - registeredShortcuts.forEach(shortcut => { 73 - api.shortcuts.unregister(shortcut, { global: true }); 74 - }); 75 - 76 - registeredShortcuts = []; 77 - }; 78 - 79 - /** 80 - * Reinitialize peeks (called when settings change) 81 - * 82 - * TODO: This is inefficient - reinitializes all peeks when any single 83 - * property changes. A better approach would be to diff the old and new 84 - * settings and only update the shortcuts that actually changed. 85 - */ 86 - const reinit = () => { 87 - console.log('peeks reinit'); 88 - uninit(); 89 - 90 - const prefs = store.get(storageKeys.PREFS); 91 - const items = store.get(storageKeys.ITEMS); 92 - 93 - if (items && items.length > 0) { 94 - initItems(prefs, items); 95 - } 96 - }; 97 - 98 - const init = () => { 99 - console.log('peeks init'); 100 - 101 - const prefs = () => store.get(storageKeys.PREFS); 102 - const items = () => store.get(storageKeys.ITEMS); 103 - 104 - // Initialize peeks 105 - if (items().length > 0) { 106 - initItems(prefs(), items()); 107 - } 108 - 109 - // Listen for settings changes to hot-reload 110 - api.subscribe('peeks:settings-changed', () => { 111 - console.log('peeks settings changed, reinitializing'); 112 - reinit(); 113 - }); 114 - }; 115 - 116 - export default { 117 - defaults, 118 - id, 119 - init, 120 - uninit, 121 - labels, 122 - schemas, 123 - storageKeys 124 - }
+76 -4
app/settings/settings.js
··· 912 912 }; 913 913 914 914 // Initialize 915 - const init = () => { 915 + const init = async () => { 916 916 const sidebarNav = document.getElementById('sidebarNav'); 917 917 const contentArea = document.getElementById('settingsContent'); 918 918 ··· 931 931 // Track feature nav items and sections for dynamic updates 932 932 const featureElements = new Map(); 933 933 934 - // Add feature sections (only show enabled ones, but create all for hot-reload) 934 + // Add CORE feature sections (cmd, scripts - features that run in peek://app context) 935 935 for (const i in fc) { 936 936 const feature = fc[i]; 937 937 const name = feature.labels.name; ··· 956 956 featureElements.set(name.toLowerCase(), { navItem, section }); 957 957 } 958 958 959 - // Listen for feature toggle events to update sidebar 959 + // Listen for feature toggle events to update sidebar (core features only) 960 960 api.subscribe('core:feature:toggle', (msg) => { 961 961 const featureName = msg.featureId?.toLowerCase(); 962 962 const elements = featureElements.get(featureName); ··· 972 972 } 973 973 }); 974 974 975 - // Add Extensions section 975 + // Add Extensions management section (must be created before loading extension settings) 976 976 const extNav = document.createElement('a'); 977 977 extNav.className = 'nav-item'; 978 978 extNav.textContent = 'Extensions'; ··· 996 996 }); 997 997 998 998 contentArea.appendChild(extSection); 999 + 1000 + // Add EXTENSION settings sections (peeks, slides, groups - run in isolated peek://ext contexts) 1001 + // Load schemas dynamically from extension manifests 1002 + // Track which extensions we've already added to avoid duplicates 1003 + const addedExtensions = new Set(); 1004 + 1005 + const loadExtensionSettings = async () => { 1006 + try { 1007 + const result = await api.extensions.list(); 1008 + if (result.success && result.data) { 1009 + for (const ext of result.data) { 1010 + // Only show extensions that have schemas defined 1011 + if (!ext.manifest?.schemas) continue; 1012 + 1013 + // Skip if already added 1014 + const extName = ext.manifest.name.toLowerCase(); 1015 + if (addedExtensions.has(extName)) continue; 1016 + addedExtensions.add(extName); 1017 + 1018 + // Construct feature-like object from manifest 1019 + const feature = { 1020 + id: ext.manifest.id, 1021 + labels: { name: ext.manifest.name }, 1022 + schemas: ext.manifest.schemas, 1023 + storageKeys: ext.manifest.storageKeys || { PREFS: 'prefs', ITEMS: 'items' }, 1024 + defaults: ext.manifest.defaults || {} 1025 + }; 1026 + 1027 + const name = feature.labels.name; 1028 + const sectionId = name.toLowerCase().replace(/\s+/g, '-'); 1029 + 1030 + // Add nav item (insert before Extensions nav item) 1031 + const navItem = document.createElement('a'); 1032 + navItem.className = 'nav-item'; 1033 + navItem.textContent = name; 1034 + navItem.dataset.section = sectionId; 1035 + navItem.dataset.featureName = name; 1036 + navItem.dataset.isExtension = 'true'; 1037 + navItem.addEventListener('click', () => showSection(sectionId)); 1038 + 1039 + // Insert before the Extensions section in nav 1040 + const extNavItem = sidebarNav.querySelector('[data-section="extensions"]'); 1041 + if (extNavItem) { 1042 + sidebarNav.insertBefore(navItem, extNavItem); 1043 + } else { 1044 + sidebarNav.appendChild(navItem); 1045 + } 1046 + 1047 + // Add section (insert before extensions section) 1048 + const section = createSection(sectionId, name, () => renderFeatureSettings(feature)); 1049 + const extSectionEl = document.getElementById('section-extensions'); 1050 + if (extSectionEl) { 1051 + contentArea.insertBefore(section, extSectionEl); 1052 + } else { 1053 + contentArea.appendChild(section); 1054 + } 1055 + 1056 + featureElements.set(name.toLowerCase(), { navItem, section }); 1057 + } 1058 + } 1059 + } catch (err) { 1060 + console.error('[settings] Failed to load extension schemas:', err); 1061 + } 1062 + }; 1063 + 1064 + // Load extensions that are already running 1065 + await loadExtensionSettings(); 1066 + 1067 + // Listen for all extensions loaded event to catch any we missed 1068 + api.subscribe('ext:all-loaded', () => { 1069 + loadExtensionSettings(); 1070 + }, api.scopes.GLOBAL); 999 1071 1000 1072 // Add Datastore link 1001 1073 const datastoreNav = document.createElement('a');
-163
app/slides/config.js
··· 1 - const id = '434108f3-18a6-437a-b507-2f998f693bb2'; 2 - 3 - const labels = { 4 - name: 'Slides', 5 - prefs: { 6 - keyPrefix: 'Slide shortcut prefix', 7 - } 8 - }; 9 - 10 - const prefsSchema = { 11 - "$schema": "https://json-schema.org/draft/2020-12/schema", 12 - "$id": "peek.slides.prefs.schema.json", 13 - "title": "Slides prefs", 14 - "description": "Peek app Slides user preferences", 15 - "type": "object", 16 - "properties": { 17 - "shortcutKeyPrefix": { 18 - "description": "Global OS hotkey prefix to trigger slides - will be followed by up/down/left/right arrows", 19 - "type": "string", 20 - "default": "Option+" 21 - }, 22 - }, 23 - "required": [ "shortcutKeyPrefix"] 24 - }; 25 - 26 - const itemSchema = { 27 - "$schema": "https://json-schema.org/draft/2020-12/schema", 28 - "$id": "peek.slides.slide.schema.json", 29 - "title": "Peek - page slide", 30 - "description": "Peek page slide", 31 - "type": "object", 32 - "properties": { 33 - "screenEdge": { 34 - "description": "Edge of screen or arrow key to open this slide, up/down/left/right", 35 - "type": "string", 36 - "oneOf": [ 37 - { "format": "Up" }, 38 - { "format": "Down" }, 39 - { "format": "Left" }, 40 - { "format": "Right" } 41 - ], 42 - "default": "Right" 43 - }, 44 - "title": { 45 - "description": "Name of the slide - user defined label", 46 - "type": "string", 47 - "default": "New Slide" 48 - }, 49 - "address": { 50 - "description": "URL to load", 51 - "type": "string", 52 - "default": "https://example.com" 53 - }, 54 - "persistState": { 55 - "description": "Whether to persist local state or load page into empty container - defaults to false", 56 - "type": "boolean", 57 - "default": false 58 - }, 59 - "keepLive": { 60 - "description": "Whether to keep page alive in background or load fresh when triggered - defaults to false", 61 - "type": "boolean", 62 - "default": false 63 - }, 64 - "allowSound": { 65 - "description": "Whether to allow the page to emit sound or not (eg for background music player slides - defaults to false", 66 - "type": "boolean", 67 - "default": false 68 - }, 69 - "height": { 70 - "description": "User-defined height of slide page", 71 - "type": "integer", 72 - "default": 600 73 - }, 74 - "width": { 75 - "description": "User-defined width of slide page", 76 - "type": "integer", 77 - "default": 800 78 - }, 79 - "enabled": { 80 - "description": "Whether this slide is enabled or not.", 81 - "type": "boolean", 82 - "default": false 83 - }, 84 - }, 85 - "required": [ "screenEdge", "title", "address", "persistState", "keepLive", "allowSound", 86 - "height", "width", "enabled" ] 87 - }; 88 - 89 - const listSchema = { 90 - type: 'array', 91 - items: { "$ref": "#/$defs/slide" } 92 - }; 93 - 94 - const schemas = { 95 - prefs: prefsSchema, 96 - item: itemSchema, 97 - items: listSchema 98 - }; 99 - 100 - const storageKeys = { 101 - PREFS: 'prefs', 102 - ITEMS: 'items', 103 - }; 104 - 105 - const defaults = { 106 - prefs: { 107 - shortcutKeyPrefix: 'Option+' 108 - }, 109 - items: [ 110 - { 111 - screenEdge: 'Up', 112 - title: 'Slide from top', 113 - address: 'http://localhost/', 114 - persistState: false, 115 - keepLive: false, 116 - allowSound: false, 117 - height: 600, 118 - width: 800, 119 - enabled: true, 120 - }, 121 - { 122 - screenEdge: 'Down', 123 - title: 'Slide from bottom', 124 - address: '', 125 - persistState: false, 126 - keepLive: false, 127 - allowSound: false, 128 - height: 600, 129 - width: 800, 130 - enabled: false, 131 - }, 132 - { 133 - screenEdge: 'Left', 134 - title: 'Slide from left', 135 - address: '', 136 - persistState: false, 137 - keepLive: false, 138 - allowSound: false, 139 - height: 600, 140 - width: 800, 141 - enabled: false, 142 - }, 143 - { 144 - screenEdge: 'Right', 145 - title: 'Slide from right', 146 - address: '', 147 - persistState: false, 148 - keepLive: false, 149 - allowSound: false, 150 - height: 600, 151 - width: 800, 152 - enabled: false, 153 - }, 154 - ] 155 - }; 156 - 157 - export { 158 - id, 159 - labels, 160 - schemas, 161 - storageKeys, 162 - defaults 163 - };
-319
app/slides/index.js
··· 1 - import { id, labels, schemas, storageKeys, defaults } from './config.js'; 2 - import { openStore } from "../utils.js"; 3 - import windows from "../windows.js"; 4 - import api from '../api.js'; 5 - 6 - console.log('background', labels.name); 7 - 8 - const debug = api.debug; 9 - const clear = false; 10 - 11 - const store = openStore(id, defaults, clear /* clear storage */); 12 - 13 - // Map to track opened slides - key is slide key, value is window ID 14 - const slideWindows = new Map(); 15 - 16 - // Track registered shortcuts for cleanup 17 - let registeredShortcuts = []; 18 - 19 - const executeItem = (item) => { 20 - const height = item.height || 600; 21 - const width = item.width || 800; 22 - 23 - const screen = { 24 - height: window.screen.height, 25 - width: window.screen.width 26 - }; 27 - 28 - let x, y, center = null; 29 - 30 - switch(item.screenEdge) { 31 - case 'Up': 32 - // horizontally center 33 - x = (screen.width - width) / 2; 34 - 35 - // y starts at screen top and stays there 36 - y = 0; 37 - 38 - //width = item.width; 39 - //height = 1; 40 - break; 41 - case 'Down': 42 - // horizonally center 43 - x = (screen.width - item.width) / 2; 44 - 45 - // y ends up at window height from bottom 46 - // 47 - // eg: y = screen.height - item.height; 48 - // 49 - // but starts at screen bottom 50 - y = screen.height; 51 - 52 - //width = item.width; 53 - //height = 1; 54 - break; 55 - case 'Left': 56 - // x starts and ends at at left screen edge 57 - // at left edge 58 - x = 0; 59 - 60 - // vertically center 61 - y = (screen.height - item.height) / 2; 62 - 63 - //width = 1; 64 - //height = item.height; 65 - break; 66 - case 'Right': 67 - // x ends at at right screen edge - window size 68 - // 69 - // eg: x = screen.width - item.width; 70 - // 71 - // but starts at screen right edge, will animate in 72 - x = screen.width; 73 - 74 - // vertically center 75 - y = (screen.height - item.height) / 2; 76 - 77 - //width = 1; 78 - //height = item.height; 79 - break; 80 - default: 81 - center = true; 82 - console.log('waddafa'); 83 - } 84 - 85 - console.log('execute slide', item.screenEdge, x, y); 86 - 87 - const key = `${item.address}:${item.screenEdge}`; 88 - 89 - //animateSlide(win, item).then(); 90 - 91 - // Check if this slide is already open 92 - if (slideWindows.has(key)) { 93 - // Get the window ID for the existing slide 94 - const windowId = slideWindows.get(key); 95 - console.log('Slide already open, verifying window exists with ID:', windowId); 96 - 97 - // First check if window exists 98 - api.window.exists({ id: windowId }).then(existsResult => { 99 - if (existsResult.exists) { 100 - // Window exists, try to show it 101 - api.window.show({ id: windowId }).then(result => { 102 - if (result.success) { 103 - console.log('Successfully showed existing slide:', key); 104 - } else { 105 - console.error('Failed to show existing slide:', result.error); 106 - slideWindows.delete(key); 107 - openNewSlide(); 108 - } 109 - }).catch(err => { 110 - console.error('Error showing window:', err); 111 - slideWindows.delete(key); 112 - openNewSlide(); 113 - }); 114 - } else { 115 - console.log('Window no longer exists, creating new one'); 116 - slideWindows.delete(key); 117 - openNewSlide(); 118 - } 119 - }).catch(err => { 120 - console.error('Error checking if window exists:', err); 121 - slideWindows.delete(key); 122 - openNewSlide(); 123 - }); 124 - } else { 125 - openNewSlide(); 126 - } 127 - 128 - function openNewSlide() { 129 - const params = { 130 - address: item.address, 131 - height, 132 - width, 133 - key, 134 - 135 - feature: labels.name, 136 - keepLive: item.keepLive || false, 137 - persistState: item.persistState || false, 138 - 139 - x, 140 - y, 141 - 142 - // tracking (handled automatically by windows API) 143 - trackingSource: 'slide', 144 - trackingSourceId: item.screenEdge ? `slide_${item.screenEdge}` : 'slide', 145 - title: item.title || '' 146 - }; 147 - 148 - // Open the window 149 - windows.openModalWindow(item.address, params).then(result => { 150 - if (result.success) { 151 - console.log('Successfully opened slide with ID:', result.id); 152 - // Store the window ID for future reference 153 - slideWindows.set(key, result.id); 154 - } else { 155 - console.error('Failed to open slide:', result.error); 156 - } 157 - }); 158 - } 159 - 160 - }; 161 - 162 - const initItems = (prefs, items) => { 163 - const cmdPrefix = prefs.shortcutKeyPrefix; 164 - 165 - items.forEach(item => { 166 - if (item.enabled == true && item.address.length > 0) { 167 - const shortcut = `${cmdPrefix}${item.screenEdge}`; 168 - 169 - api.shortcuts.register(shortcut, () => { 170 - executeItem(item); 171 - }, { global: true }); 172 - 173 - registeredShortcuts.push(shortcut); 174 - } 175 - }); 176 - }; 177 - 178 - /** 179 - * Unregister all shortcuts and clean up windows 180 - */ 181 - const uninit = () => { 182 - console.log('slides uninit - unregistering', registeredShortcuts.length, 'shortcuts'); 183 - 184 - // Unregister all shortcuts 185 - registeredShortcuts.forEach(shortcut => { 186 - api.shortcuts.unregister(shortcut, { global: true }); 187 - }); 188 - registeredShortcuts = []; 189 - 190 - // Close or hide all slide windows 191 - for (const [key, windowId] of slideWindows.entries()) { 192 - console.log('Closing slide window:', key); 193 - api.window.hide({ id: windowId }).catch(err => { 194 - console.error('Error hiding slide window:', err); 195 - api.window.close({ id: windowId }).catch(err => { 196 - console.error('Error closing slide window:', err); 197 - }); 198 - }); 199 - } 200 - slideWindows.clear(); 201 - }; 202 - 203 - /** 204 - * Reinitialize slides (called when settings change) 205 - * 206 - * TODO: This is inefficient - reinitializes all slides when any single 207 - * property changes. A better approach would be to diff the old and new 208 - * settings and only update the shortcuts that actually changed. 209 - */ 210 - const reinit = () => { 211 - console.log('slides reinit'); 212 - uninit(); 213 - 214 - const prefs = store.get(storageKeys.PREFS); 215 - const items = store.get(storageKeys.ITEMS); 216 - 217 - if (items && items.length > 0) { 218 - initItems(prefs, items); 219 - } 220 - }; 221 - 222 - const init = () => { 223 - console.log('slides init'); 224 - 225 - const prefs = () => store.get(storageKeys.PREFS); 226 - const items = () => store.get(storageKeys.ITEMS); 227 - 228 - // Add global window closed handler 229 - api.subscribe('window:closed', (data) => { 230 - // Check all slide windows to see if any match the closed window ID 231 - for (const [key, windowId] of slideWindows.entries()) { 232 - if (data.id === windowId) { 233 - console.log('Slide window was closed externally:', key); 234 - slideWindows.delete(key); 235 - } 236 - } 237 - }); 238 - 239 - // Initialize slides 240 - if (items().length > 0) { 241 - initItems(prefs(), items()); 242 - } 243 - 244 - // Listen for settings changes to hot-reload 245 - api.subscribe('slides:settings-changed', () => { 246 - console.log('slides settings changed, reinitializing'); 247 - reinit(); 248 - }); 249 - 250 - // Set up listener for app shutdown to clean up windows 251 - api.subscribe('app:shutdown', uninit); 252 - }; 253 - 254 - export default { 255 - defaults, 256 - id, 257 - init, 258 - uninit, 259 - labels, 260 - schemas, 261 - storageKeys 262 - } 263 - 264 - /* 265 - const animateSlide = (win, slide) => { 266 - return new Promise((res, rej) => { 267 - const { size, bounds } = screen.getPrimaryDisplay(); 268 - 269 - // get x/y field 270 - const coord = slide.screenEdge == 'Left' || slide.screenEdge == 'Right' ? 'x' : 'y'; 271 - 272 - const dim = coord == 'x' ? 'width' : 'height'; 273 - 274 - const winBounds = win.getBounds(); 275 - 276 - // created window at x/y taking animation into account 277 - let pos = winBounds[coord]; 278 - 279 - const speedMs = 150; 280 - const timerInterval = 10; 281 - 282 - let tick = 0; 283 - const numTicks = parseInt(speedMs / timerInterval); 284 - 285 - const offset = slide[dim] / numTicks; 286 - 287 - //console.log('numTicks', numTicks, 'widthChunk', offset); 288 - 289 - const timer = setInterval(() => { 290 - tick++; 291 - 292 - if (tick >= numTicks) { 293 - clearInterval(timer); 294 - res(); 295 - } 296 - 297 - const winBounds = win.getBounds(); 298 - 299 - if (slide.screenEdge == 'Right' || slide.screenEdge == 'Down') { 300 - // new position is current position +/- offset 301 - pos = pos - offset; 302 - } 303 - 304 - const grownEnough = winBounds[dim] <= slide[dim]; 305 - const newDim = grownEnough ? 306 - winBounds[dim] + offset 307 - : winBounds[dim]; 308 - 309 - const newBounds = {}; 310 - newBounds[coord] = parseInt(pos, 10); 311 - newBounds[dim] = parseInt(newDim, 10); 312 - 313 - // set new bounds 314 - win.setBounds(newBounds); 315 - 316 - }, timerInterval); 317 - }); 318 - }; 319 - */
+35
extensions/groups/background.js
··· 257 257 initShortcut(currentSettings.prefs.shortcutKey); 258 258 initCommands(); 259 259 }, api.scopes.GLOBAL); 260 + 261 + // Listen for settings updates from Settings UI 262 + // Settings UI sends proposed changes, we validate and save 263 + api.subscribe('groups:settings-update', async (msg) => { 264 + console.log('[ext:groups] settings-update received:', msg); 265 + 266 + try { 267 + // Apply the update based on what was sent 268 + if (msg.data) { 269 + // Full data object sent 270 + currentSettings = { 271 + prefs: msg.data.prefs || currentSettings.prefs 272 + }; 273 + } else if (msg.key === 'prefs' && msg.path) { 274 + // Single pref field update 275 + const field = msg.path.split('.')[1]; 276 + if (field) { 277 + currentSettings.prefs = { ...currentSettings.prefs, [field]: msg.value }; 278 + } 279 + } 280 + 281 + // Save to datastore 282 + await saveSettings(currentSettings); 283 + 284 + // Reinitialize with new settings 285 + uninit(); 286 + initShortcut(currentSettings.prefs.shortcutKey); 287 + initCommands(); 288 + 289 + // Confirm change back to Settings UI 290 + api.publish('groups:settings-changed', currentSettings, api.scopes.GLOBAL); 291 + } catch (err) { 292 + console.error('[ext:groups] settings-update error:', err); 293 + } 294 + }, api.scopes.GLOBAL); 260 295 }; 261 296 262 297 const uninit = () => {
+16 -6
extensions/groups/manifest.json
··· 6 6 "version": "1.0.0", 7 7 "background": "background.html", 8 8 "builtin": true, 9 - "settings": { 9 + "schemas": { 10 10 "prefs": { 11 - "shortcutKey": { 12 - "type": "string", 13 - "label": "Shortcut", 14 - "description": "Global OS hotkey to open groups manager", 15 - "default": "Option+g" 11 + "type": "object", 12 + "properties": { 13 + "shortcutKey": { 14 + "type": "string", 15 + "description": "Global OS hotkey to open groups manager", 16 + "default": "Option+g" 17 + } 16 18 } 19 + } 20 + }, 21 + "storageKeys": { 22 + "PREFS": "prefs" 23 + }, 24 + "defaults": { 25 + "prefs": { 26 + "shortcutKey": "Option+g" 17 27 } 18 28 } 19 29 }
+41
extensions/peeks/background.js
··· 154 154 console.log('[ext:peeks] settings changed, reinitializing'); 155 155 reinit(); 156 156 }, api.scopes.GLOBAL); 157 + 158 + // Listen for settings updates from Settings UI 159 + // Settings UI sends proposed changes, we validate and save 160 + api.subscribe('peeks:settings-update', async (msg) => { 161 + console.log('[ext:peeks] settings-update received:', msg); 162 + 163 + try { 164 + // Apply the update based on what was sent 165 + if (msg.data) { 166 + // Full data object sent 167 + currentSettings = { 168 + prefs: msg.data.prefs || currentSettings.prefs, 169 + items: msg.data.items || currentSettings.items 170 + }; 171 + } else if (msg.key === 'prefs' && msg.path) { 172 + // Single pref field update 173 + const field = msg.path.split('.')[1]; 174 + if (field) { 175 + currentSettings.prefs = { ...currentSettings.prefs, [field]: msg.value }; 176 + } 177 + } else if (msg.key === 'items' && msg.index !== undefined) { 178 + // Item field update 179 + const items = [...currentSettings.items]; 180 + if (items[msg.index]) { 181 + items[msg.index] = { ...items[msg.index], [msg.field]: msg.value }; 182 + currentSettings.items = items; 183 + } 184 + } 185 + 186 + // Save to datastore 187 + await saveSettings(currentSettings); 188 + 189 + // Reinitialize with new settings 190 + await reinit(); 191 + 192 + // Confirm change back to Settings UI 193 + api.publish('peeks:settings-changed', currentSettings, api.scopes.GLOBAL); 194 + } catch (err) { 195 + console.error('[ext:peeks] settings-update error:', err); 196 + } 197 + }, api.scopes.GLOBAL); 157 198 }; 158 199 159 200 export default {
+47 -21
extensions/peeks/manifest.json
··· 6 6 "version": "1.0.0", 7 7 "background": "background.html", 8 8 "builtin": true, 9 - "settings": { 9 + "schemas": { 10 10 "prefs": { 11 - "shortcutKeyPrefix": { 12 - "type": "string", 13 - "label": "Shortcut prefix", 14 - "description": "Global OS hotkey prefix to trigger peeks - followed by 0-9", 15 - "default": "Option+" 11 + "type": "object", 12 + "properties": { 13 + "shortcutKeyPrefix": { 14 + "type": "string", 15 + "description": "Global OS hotkey prefix to trigger peeks - followed by 0-9", 16 + "default": "Option+" 17 + } 16 18 } 17 19 }, 18 - "items": { 19 - "type": "array", 20 - "maxItems": 10, 21 - "itemSchema": { 20 + "item": { 21 + "title": "Peeks", 22 + "type": "object", 23 + "properties": { 22 24 "keyNum": { 23 25 "type": "integer", 24 - "label": "Key number", 25 26 "description": "Number key (0-9) to trigger this peek", 26 27 "minimum": 0, 27 - "maximum": 9 28 + "maximum": 9, 29 + "default": 0 28 30 }, 29 31 "title": { 30 32 "type": "string", 31 - "label": "Title", 32 - "description": "Name of the peek" 33 + "description": "Name of the peek", 34 + "default": "New Peek" 33 35 }, 34 36 "address": { 35 37 "type": "string", 36 - "label": "URL", 37 - "description": "URL to load" 38 + "description": "URL to load", 39 + "default": "https://example.com" 38 40 }, 39 41 "height": { 40 42 "type": "integer", 41 - "label": "Height", 43 + "description": "Window height", 42 44 "default": 600 43 45 }, 44 46 "width": { 45 47 "type": "integer", 46 - "label": "Width", 48 + "description": "Window width", 47 49 "default": 800 48 50 }, 49 51 "persistState": { 50 52 "type": "boolean", 51 - "label": "Persist state", 52 53 "description": "Persist local state between sessions", 53 54 "default": false 54 55 }, 55 56 "keepLive": { 56 57 "type": "boolean", 57 - "label": "Keep live", 58 58 "description": "Keep page alive in background", 59 59 "default": false 60 60 }, 61 + "allowSound": { 62 + "type": "boolean", 63 + "description": "Allow the page to emit sound", 64 + "default": false 65 + }, 61 66 "enabled": { 62 67 "type": "boolean", 63 - "label": "Enabled", 68 + "description": "Whether this peek is enabled", 64 69 "default": false 65 70 } 66 71 } 67 72 } 73 + }, 74 + "storageKeys": { 75 + "PREFS": "prefs", 76 + "ITEMS": "items" 77 + }, 78 + "defaults": { 79 + "prefs": { 80 + "shortcutKeyPrefix": "Option+" 81 + }, 82 + "items": [ 83 + { "keyNum": 0, "title": "Peek key 0", "address": "https://example.com/", "persistState": false, "keepLive": false, "allowSound": false, "height": 600, "width": 800, "enabled": true }, 84 + { "keyNum": 1, "title": "Peek key 1", "address": "", "persistState": false, "keepLive": false, "allowSound": false, "height": 600, "width": 800, "enabled": false }, 85 + { "keyNum": 2, "title": "Peek key 2", "address": "", "persistState": false, "keepLive": false, "allowSound": false, "height": 600, "width": 800, "enabled": false }, 86 + { "keyNum": 3, "title": "Peek key 3", "address": "", "persistState": false, "keepLive": false, "allowSound": false, "height": 600, "width": 800, "enabled": false }, 87 + { "keyNum": 4, "title": "Peek key 4", "address": "", "persistState": false, "keepLive": false, "allowSound": false, "height": 600, "width": 800, "enabled": false }, 88 + { "keyNum": 5, "title": "Peek key 5", "address": "", "persistState": false, "keepLive": false, "allowSound": false, "height": 600, "width": 800, "enabled": false }, 89 + { "keyNum": 6, "title": "Peek key 6", "address": "", "persistState": false, "keepLive": false, "allowSound": false, "height": 600, "width": 800, "enabled": false }, 90 + { "keyNum": 7, "title": "Peek key 7", "address": "", "persistState": false, "keepLive": false, "allowSound": false, "height": 600, "width": 800, "enabled": false }, 91 + { "keyNum": 8, "title": "Peek key 8", "address": "", "persistState": false, "keepLive": false, "allowSound": false, "height": 600, "width": 800, "enabled": false }, 92 + { "keyNum": 9, "title": "Peek key 9", "address": "", "persistState": false, "keepLive": false, "allowSound": false, "height": 600, "width": 800, "enabled": false } 93 + ] 68 94 } 69 95 }
+41
extensions/slides/background.js
··· 238 238 console.log('[ext:slides] settings changed, reinitializing'); 239 239 reinit(); 240 240 }, api.scopes.GLOBAL); 241 + 242 + // Listen for settings updates from Settings UI 243 + // Settings UI sends proposed changes, we validate and save 244 + api.subscribe('slides:settings-update', async (msg) => { 245 + console.log('[ext:slides] settings-update received:', msg); 246 + 247 + try { 248 + // Apply the update based on what was sent 249 + if (msg.data) { 250 + // Full data object sent 251 + currentSettings = { 252 + prefs: msg.data.prefs || currentSettings.prefs, 253 + items: msg.data.items || currentSettings.items 254 + }; 255 + } else if (msg.key === 'prefs' && msg.path) { 256 + // Single pref field update 257 + const field = msg.path.split('.')[1]; 258 + if (field) { 259 + currentSettings.prefs = { ...currentSettings.prefs, [field]: msg.value }; 260 + } 261 + } else if (msg.key === 'items' && msg.index !== undefined) { 262 + // Item field update 263 + const items = [...currentSettings.items]; 264 + if (items[msg.index]) { 265 + items[msg.index] = { ...items[msg.index], [msg.field]: msg.value }; 266 + currentSettings.items = items; 267 + } 268 + } 269 + 270 + // Save to datastore 271 + await saveSettings(currentSettings); 272 + 273 + // Reinitialize with new settings 274 + await reinit(); 275 + 276 + // Confirm change back to Settings UI 277 + api.publish('slides:settings-changed', currentSettings, api.scopes.GLOBAL); 278 + } catch (err) { 279 + console.error('[ext:slides] settings-update error:', err); 280 + } 281 + }, api.scopes.GLOBAL); 241 282 }; 242 283 243 284 export default {
+41 -22
extensions/slides/manifest.json
··· 6 6 "version": "1.0.0", 7 7 "background": "background.html", 8 8 "builtin": true, 9 - "settings": { 9 + "schemas": { 10 10 "prefs": { 11 - "shortcutKeyPrefix": { 12 - "type": "string", 13 - "label": "Shortcut prefix", 14 - "description": "Global OS hotkey prefix to trigger slides - followed by arrow keys", 15 - "default": "Option+" 11 + "type": "object", 12 + "properties": { 13 + "shortcutKeyPrefix": { 14 + "type": "string", 15 + "description": "Global OS hotkey prefix to trigger slides - followed by arrow keys", 16 + "default": "Option+" 17 + } 16 18 } 17 19 }, 18 - "items": { 19 - "type": "array", 20 - "maxItems": 4, 21 - "itemSchema": { 20 + "item": { 21 + "title": "Slides", 22 + "type": "object", 23 + "properties": { 22 24 "screenEdge": { 23 25 "type": "string", 24 - "label": "Screen edge", 25 - "description": "Edge of screen to slide from", 26 - "enum": ["Up", "Down", "Left", "Right"] 26 + "description": "Edge of screen to slide from (Up/Down/Left/Right)", 27 + "default": "Right" 27 28 }, 28 29 "title": { 29 30 "type": "string", 30 - "label": "Title", 31 - "description": "Name of the slide" 31 + "description": "Name of the slide", 32 + "default": "New Slide" 32 33 }, 33 34 "address": { 34 35 "type": "string", 35 - "label": "URL", 36 - "description": "URL to load" 36 + "description": "URL to load", 37 + "default": "https://example.com" 37 38 }, 38 39 "height": { 39 40 "type": "integer", 40 - "label": "Height", 41 + "description": "Window height", 41 42 "default": 600 42 43 }, 43 44 "width": { 44 45 "type": "integer", 45 - "label": "Width", 46 + "description": "Window width", 46 47 "default": 800 47 48 }, 48 49 "persistState": { 49 50 "type": "boolean", 50 - "label": "Persist state", 51 51 "description": "Persist local state between sessions", 52 52 "default": false 53 53 }, 54 54 "keepLive": { 55 55 "type": "boolean", 56 - "label": "Keep live", 57 56 "description": "Keep page alive in background", 58 57 "default": false 59 58 }, 59 + "allowSound": { 60 + "type": "boolean", 61 + "description": "Allow the page to emit sound", 62 + "default": false 63 + }, 60 64 "enabled": { 61 65 "type": "boolean", 62 - "label": "Enabled", 66 + "description": "Whether this slide is enabled", 63 67 "default": false 64 68 } 65 69 } 66 70 } 71 + }, 72 + "storageKeys": { 73 + "PREFS": "prefs", 74 + "ITEMS": "items" 75 + }, 76 + "defaults": { 77 + "prefs": { 78 + "shortcutKeyPrefix": "Option+" 79 + }, 80 + "items": [ 81 + { "screenEdge": "Up", "title": "Slide from top", "address": "http://localhost/", "persistState": false, "keepLive": false, "allowSound": false, "height": 600, "width": 800, "enabled": true }, 82 + { "screenEdge": "Down", "title": "Slide from bottom", "address": "", "persistState": false, "keepLive": false, "allowSound": false, "height": 600, "width": 800, "enabled": false }, 83 + { "screenEdge": "Left", "title": "Slide from left", "address": "", "persistState": false, "keepLive": false, "allowSound": false, "height": 600, "width": 800, "enabled": false }, 84 + { "screenEdge": "Right", "title": "Slide from right", "address": "", "persistState": false, "keepLive": false, "allowSound": false, "height": 600, "width": 800, "enabled": false } 85 + ] 67 86 } 68 87 }
+84 -1
index.js
··· 447 447 448 448 })(); 449 449 450 + // ***** Command Registry ***** 451 + // Stores commands registered via cmd:register topic 452 + // This enables cmd app to query commands registered before it started 453 + const commandRegistry = new Map(); 454 + 450 455 // ***** Tray ***** 451 456 452 457 const ICON_RELATIVE_PATH = 'assets/tray/tray@2x.png'; ··· 546 551 return null; 547 552 } 548 553 554 + // Load manifest 555 + let manifest = null; 556 + try { 557 + const manifestPath = path.join(extPath, 'manifest.json'); 558 + if (fs.existsSync(manifestPath)) { 559 + manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); 560 + } 561 + } catch (err) { 562 + console.error(`[ext:win] Failed to load manifest for ${extId}:`, err); 563 + } 564 + 549 565 console.log(`[ext:win] Creating window for extension: ${extId}`); 550 566 551 567 const win = new BrowserWindow({ ··· 578 594 }); 579 595 580 596 // Store before loading to handle async issues 581 - extensionWindows.set(extId, { win, manifest: null, status: 'loading' }); 597 + extensionWindows.set(extId, { win, manifest, status: 'loading' }); 582 598 583 599 try { 584 600 await win.loadURL(`peek://ext/${extId}/background.html`); ··· 1134 1150 ipcMain.on(strings.msgs.publish, (ev, msg) => { 1135 1151 console.log('ipc:publish', msg); 1136 1152 1153 + // Intercept command registration to store in registry 1154 + if (msg.topic === 'cmd:register' && msg.data) { 1155 + commandRegistry.set(msg.data.name, { 1156 + name: msg.data.name, 1157 + description: msg.data.description || '', 1158 + source: msg.data.source 1159 + }); 1160 + console.log('[cmd-registry] Registered command:', msg.data.name); 1161 + } else if (msg.topic === 'cmd:unregister' && msg.data) { 1162 + commandRegistry.delete(msg.data.name); 1163 + console.log('[cmd-registry] Unregistered command:', msg.data.name); 1164 + } 1165 + 1137 1166 pubsub.publish(msg.source, msg.scope, msg.topic, msg.data); 1138 1167 }); 1139 1168 ··· 1144 1173 console.log('ipc:subscribe:notification', msg); 1145 1174 ev.reply(msg.replyTopic, data); 1146 1175 }); 1176 + }); 1177 + 1178 + // Query all registered commands from the registry 1179 + ipcMain.handle('get-registered-commands', async () => { 1180 + const commands = Array.from(commandRegistry.values()); 1181 + console.log('[cmd-registry] Query returned', commands.length, 'commands'); 1182 + return { success: true, data: commands }; 1147 1183 }); 1148 1184 1149 1185 ipcMain.on(strings.msgs.console, (ev, msg) => { ··· 2474 2510 return { success: true, data: manifest }; 2475 2511 } catch (error) { 2476 2512 console.error('extension-manifest-get error:', error); 2513 + return { success: false, error: error.message }; 2514 + } 2515 + }); 2516 + 2517 + // Get extension settings schema from filesystem 2518 + // Reads the schema file path from manifest.settingsSchema 2519 + ipcMain.handle('extension-settings-schema', async (ev, data) => { 2520 + const { extId } = data; 2521 + 2522 + try { 2523 + const extPath = getExtensionPath(extId); 2524 + if (!extPath) { 2525 + return { success: false, error: 'Extension not found' }; 2526 + } 2527 + 2528 + const manifestPath = path.join(extPath, 'manifest.json'); 2529 + if (!fs.existsSync(manifestPath)) { 2530 + return { success: false, error: 'manifest.json not found' }; 2531 + } 2532 + 2533 + const manifestContent = fs.readFileSync(manifestPath, 'utf-8'); 2534 + const manifest = JSON.parse(manifestContent); 2535 + 2536 + // Check if extension has a settings schema 2537 + if (!manifest.settingsSchema) { 2538 + return { success: true, data: null }; // No settings schema defined 2539 + } 2540 + 2541 + // Resolve schema path relative to extension directory 2542 + const schemaPath = path.join(extPath, manifest.settingsSchema); 2543 + if (!fs.existsSync(schemaPath)) { 2544 + return { success: false, error: `Settings schema not found: ${manifest.settingsSchema}` }; 2545 + } 2546 + 2547 + const schemaContent = fs.readFileSync(schemaPath, 'utf-8'); 2548 + const schema = JSON.parse(schemaContent); 2549 + 2550 + return { 2551 + success: true, 2552 + data: { 2553 + extId: manifest.id || extId, 2554 + name: manifest.name, 2555 + schema 2556 + } 2557 + }; 2558 + } catch (error) { 2559 + console.error('extension-settings-schema error:', error); 2477 2560 return { success: false, error: error.message }; 2478 2561 } 2479 2562 });
+24
preload.js
··· 388 388 }); 389 389 390 390 console.log('commands.unregister:', name); 391 + }, 392 + 393 + /** 394 + * Get all registered commands from the main process registry 395 + * This queries the authoritative registry, not pubsub subscriptions 396 + * @returns {Promise<Array>} Array of command objects 397 + */ 398 + getAll: async () => { 399 + const result = await ipcRenderer.invoke('get-registered-commands'); 400 + if (result.success) { 401 + return result.data; 402 + } 403 + console.error('commands.getAll failed:', result); 404 + return []; 391 405 } 392 406 }; 393 407 ··· 644 658 */ 645 659 get: (id) => { 646 660 return ipcRenderer.invoke('extension-get', { id }); 661 + }, 662 + 663 + /** 664 + * Get settings schema for an extension 665 + * Reads schema from file specified in manifest.settingsSchema 666 + * @param {string} extId - Extension ID 667 + * @returns {Promise<{success: boolean, data?: {extId, name, schema}, error?: string}>} 668 + */ 669 + getSettingsSchema: (extId) => { 670 + return ipcRenderer.invoke('extension-settings-schema', { extId }); 647 671 } 648 672 }; 649 673