···11-// features
22-// Note: groups is now an extension (./extensions/groups/)
11+/**
22+ * Features Collection
33+ *
44+ * This module provides feature schemas for the Settings UI.
55+ *
66+ * Architecture note (Jan 2025):
77+ * - cmd and scripts are CORE features (run in peek://app context)
88+ * - peeks, slides, groups are EXTENSIONS (run in isolated peek://ext contexts)
99+ *
1010+ * Extensions are now loaded by the main process ExtensionManager, not by
1111+ * importing them here. However, this module still exports extension schemas
1212+ * for the Settings UI to render settings forms.
1313+ *
1414+ * TODO: Settings UI should load extension schemas from manifest.json instead,
1515+ * then app/peeks/ and app/slides/ can be deleted.
1616+ */
1717+1818+// Core features
319import cmd from './cmd/index.js';
2020+import scripts from './scripts/index.js';
2121+2222+// Extension schemas (for Settings UI only - extensions run in isolated processes)
423import peeks from './peeks/index.js';
55-import scripts from './scripts/index.js';
624import slides from './slides/index.js';
725826const fc = {};
99-fc[cmd.id] = cmd,
1010-fc[peeks.id] = peeks,
1111-fc[scripts.id] = scripts,
1212-fc[slides.id] = slides
2727+2828+// Core features
2929+fc[cmd.id] = cmd;
3030+fc[scripts.id] = scripts;
3131+3232+// Extension schemas for Settings UI
3333+fc[peeks.id] = peeks;
3434+fc[slides.id] = slides;
13351436export default fc;
+45-108
app/index.js
···33import windowManager from "./windows.js";
44import api from './api.js';
55import fc from './features.js';
66-import extensionLoader from './extensions/loader.js';
66+import migrations from './migrations/index.js';
7788const { id, labels, schemas, storageKeys, defaults } = appConfig;
99···2727const settingsAddress = 'peek://app/settings/settings.html';
2828const topicCorePrefs = 'topic:core:prefs';
2929const topicFeatureToggle = 'core:feature:toggle';
3030+3131+// Built-in extensions (now loaded by main process ExtensionManager)
3232+const builtinExtensions = ['groups', 'peeks', 'slides'];
30333134let _settingsWin = null;
3235···4649 };
47504851 console.log('Opening settings window with params:', params);
4949-5252+5053 try {
5154 // Use the window creation API from windows.js
5255 const windowController = await windowManager.createWindow(settingsAddress, params);
5353-5656+5457 console.log('Settings window opened successfully with controller:', windowController);
5558 _settingsWin = windowController;
5656-5959+5760 // Focus the window to bring it to front
5861 await windowController.focus();
5962 } catch (error) {
···7376 return;
7477 }
75787676- // Skip extension-based features (they're loaded by the extension loader)
7777- if (f.extension) {
7878- debug && console.log('skipping extension-based feature:', f.name);
7979+ // Skip extension-based features (they're loaded by main process ExtensionManager)
8080+ const extId = f.name.toLowerCase();
8181+ if (builtinExtensions.includes(extId)) {
8282+ debug && console.log('skipping extension-based feature (loaded by main process):', f.name);
7983 return;
8084 }
8185···116120117121// Register extension management commands for cmd palette
118122const registerExtensionCommands = () => {
119119- // Reload extension command
123123+ // Reload extension command (uses main process IPC)
120124 api.commands.register({
121125 name: 'extension reload',
122126 description: 'Reload an extension by name',
···127131 return;
128132 }
129133130130- // Find extension by name or id (case-insensitive)
131131- const extensions = extensionLoader.getRunningExtensions();
132132- const ext = extensions.find(e =>
133133- e.id.toLowerCase() === extName.toLowerCase() ||
134134- (e.manifest?.name || '').toLowerCase() === extName.toLowerCase()
135135- );
136136-137137- if (!ext) {
138138- console.log(`extension reload: extension not found: ${extName}`);
139139- return;
140140- }
141141-142142- console.log(`Reloading extension: ${ext.id}`);
143143- const result = await extensionLoader.reloadExtension(ext.id);
134134+ console.log(`Reloading extension: ${extName}`);
135135+ const result = await api.extensions.reload(extName.toLowerCase());
144136 if (result.success) {
145145- console.log(`Extension reloaded: ${ext.id}`);
137137+ console.log(`Extension reloaded: ${extName}`);
146138 } else {
147139 console.error(`Failed to reload extension: ${result.error}`);
148140 }
149141 }
150142 });
151143152152- // List extensions command
144144+ // List extensions command (uses main process IPC)
153145 api.commands.register({
154146 name: 'extensions',
155147 description: 'List running extensions',
156148 execute: async (ctx) => {
157157- const extensions = extensionLoader.getRunningExtensions();
158158- console.log('Running extensions:');
159159- extensions.forEach(ext => {
160160- const manifest = ext.manifest || {};
161161- console.log(` - ${manifest.name || ext.id} (${ext.id}) v${manifest.version || '?'}`);
162162- });
149149+ const listResult = await api.extensions.list();
150150+ if (listResult.success && listResult.data) {
151151+ console.log('Running extensions:');
152152+ listResult.data.forEach(ext => {
153153+ const manifest = ext.manifest || {};
154154+ console.log(` - ${manifest.name || ext.id} (${ext.id}) v${manifest.version || '?'}`);
155155+ });
156156+ } else {
157157+ console.log('No extensions running');
158158+ }
163159164160 // Open settings to Extensions section
165161 const p = prefs();
···210206 }
211207 });
212208213213- // Open settings window on startup if configured
214214- if (p.startupFeature == settingsAddress) {
215215- try {
216216- await openSettingsWindow(p);
217217- } catch (error) {
218218- console.error('Error opening startup settings window:', error);
219219- }
209209+ // Always open settings window on startup
210210+ try {
211211+ await openSettingsWindow(p);
212212+ } catch (error) {
213213+ console.error('Error opening startup settings window:', error);
220214 }
221215222222- // feature enable/disable
216216+ // Feature enable/disable handler
217217+ // Extensions are now managed by main process ExtensionManager via IPC
223218 api.subscribe(topicFeatureToggle, async msg => {
224219 console.log('feature toggle', msg)
225220···233228234229 // Check if this feature is backed by an extension
235230 const extId = f.name.toLowerCase();
236236- const isExtension = extensionLoader.builtinExtensions.some(e => e.id === extId);
231231+ const isExtension = builtinExtensions.includes(extId);
237232238233 if (msg.enabled == false) {
239234 console.log('disabling', f.name);
240235 if (isExtension) {
241241- await extensionLoader.unloadExtension(extId);
236236+ // Use main process IPC to unload extension
237237+ await api.extensions.unload(extId);
242238 } else {
243239 uninitFeature(f);
244240 }
···246242 else if (msg.enabled == true) {
247243 console.log('enabling', f.name);
248244 if (isExtension) {
249249- const ext = extensionLoader.builtinExtensions.find(e => e.id === extId);
250250- if (ext) {
251251- await extensionLoader.loadExtension(ext);
252252- }
245245+ // Use main process IPC to load extension
246246+ await api.extensions.load(extId);
253247 } else {
254248 initFeature(f);
255249 }
···262256263257 initSettingsShortcut(p);
264258265265- features().forEach(initFeature);
259259+ // Run any pending migrations (e.g., localStorage -> datastore)
260260+ await migrations.runMigrations();
266261267267- // Load extensions
268268- // Helper to check if an extension (by name) is enabled in features
269269- const isExtensionEnabled = (extId) => {
270270- const featureList = features();
271271- // Match extension ID to feature name (case-insensitive)
272272- const feature = featureList.find(f =>
273273- f.name.toLowerCase() === extId.toLowerCase()
274274- );
275275- return feature ? feature.enabled : false;
276276- };
262262+ // Initialize core features (non-extension features only)
263263+ features().forEach(initFeature);
277264278278- await extensionLoader.loadBuiltinExtensions(isExtensionEnabled);
265265+ // Extensions are now loaded by main process ExtensionManager
266266+ // It receives the 'core:ready' signal and calls loadEnabledExtensions()
267267+ console.log('Core features initialized. Extensions loaded by main process.');
279268280269 // Register extension dev commands
281270 registerExtensionCommands();
282282-283283- //features.forEach(initIframeFeature);
284284-285285- /*
286286- // Example of using the new windows.js API:
287287- const addy = 'http://localhost';
288288- const params = {
289289- debug,
290290- key: addy,
291291- height: 300,
292292- width: 300
293293- };
294294-295295- windowManager.createWindow(addy, params)
296296- .then(windowController => {
297297- // Can use windowController to interact with the window
298298- windowController.hide();
299299- })
300300- .catch(error => {
301301- console.error('Error opening example window:', error);
302302- });
303303- */
304271};
305272306273window.addEventListener('load', () => {
···308275 console.error('Error during application initialization:', error);
309276 });
310277});
311311-312312-/*
313313-const odiff = (a, b) => Object.entries(b).reduce((c, [k, v]) => Object.assign(c, a[k] ? {} : { [k]: v }), {});
314314-315315-const onStorageChange = (e) => {
316316- const old = JSON.parse(e.oldValue);
317317- const now = JSON.parse(e.newValue);
318318-319319- const featureKey = `${id}+${storageKeys.ITEMS}`;
320320- //console.log('onStorageChane', e.key, featureKey)
321321- if (e.key == featureKey) {
322322- //console.log('STORAGE CHANGE', e.key, old[0].enabled, now[0].enabled);
323323- features().forEach((feat, i) => {
324324- console.log(feat.title, i, feat.enabled, old[i].enabled, now[i].enabled);
325325- // disabled, so unload
326326- if (old[i].enabled == true && now[i].enabled == false) {
327327- // TODO
328328- console.log('TODO: add unloading of features', feat)
329329- }
330330- // enabled, so load
331331- else if (old[i].enabled == false && now[i].enabled == true) {
332332- initFeature(feat);
333333- }
334334- });
335335- }
336336- //JSON.stringify(e.storageArea);
337337-};
338338-339339-window.addEventListener('storage', onStorageChange);
340340-*/
+65-3
app/settings/settings.js
···633633};
634634635635// Render feature settings (Peeks, Slides, etc.)
636636+// Now reads/writes from datastore extension_settings table for isolated extensions
636637const renderFeatureSettings = (feature) => {
637638 const { id, labels, schemas, storageKeys, defaults } = feature;
639639+640640+ // Use extension shortname for datastore key (e.g., 'peeks', 'slides', 'groups')
641641+ const extId = labels.name.toLowerCase();
642642+643643+ // For now, still use localStorage as fallback (will be migrated)
638644 const store = openStore(id, defaults, clear);
639645640646 let prefs = store.get(storageKeys.PREFS);
···645651 // Topic for notifying feature of settings changes (e.g., 'peeks:settings-changed')
646652 const settingsChangedTopic = `${labels.name.toLowerCase()}:settings-changed`;
647653648648- const save = () => {
654654+ const save = async () => {
655655+ // Save to localStorage (legacy)
649656 store.set(storageKeys.PREFS, prefs);
650657 store.set(storageKeys.ITEMS, items);
651651- // Notify feature to hot-reload with new settings
658658+659659+ // Also save to datastore for isolated extensions
660660+ const rowIdPrefs = `${extId}:prefs`;
661661+ const rowIdItems = `${extId}:items`;
662662+ const now = Date.now();
663663+664664+ await api.datastore.setRow('extension_settings', rowIdPrefs, {
665665+ extensionId: extId,
666666+ key: 'prefs',
667667+ value: JSON.stringify(prefs),
668668+ updatedAt: now
669669+ });
670670+671671+ if (items) {
672672+ await api.datastore.setRow('extension_settings', rowIdItems, {
673673+ extensionId: extId,
674674+ key: 'items',
675675+ value: JSON.stringify(items),
676676+ updatedAt: now
677677+ });
678678+ }
679679+680680+ // Notify feature to hot-reload with new settings (GLOBAL for cross-process)
652681 api.publish(settingsChangedTopic, {}, api.scopes.GLOBAL);
653682 };
654683···812841 }
813842};
814843844844+// Helper to check if a feature is enabled
845845+const isFeatureEnabled = (featureName) => {
846846+ const store = openStore(appConfig.id, appConfig.defaults, false);
847847+ const features = store.get(appConfig.storageKeys.ITEMS) || [];
848848+ const feature = features.find(f => f.name.toLowerCase() === featureName.toLowerCase());
849849+ return feature ? feature.enabled : false;
850850+};
851851+815852// Initialize
816853const init = () => {
817854 const sidebarNav = document.getElementById('sidebarNav');
···829866 coreSection.classList.add('active');
830867 contentArea.appendChild(coreSection);
831868832832- // Add feature sections
869869+ // Track feature nav items and sections for dynamic updates
870870+ const featureElements = new Map();
871871+872872+ // Add feature sections (only show enabled ones, but create all for hot-reload)
833873 for (const i in fc) {
834874 const feature = fc[i];
835875 const name = feature.labels.name;
836876 const sectionId = name.toLowerCase().replace(/\s+/g, '-');
877877+ const enabled = isFeatureEnabled(name);
837878838879 // Add nav item
839880 const navItem = document.createElement('a');
840881 navItem.className = 'nav-item';
841882 navItem.textContent = name;
842883 navItem.dataset.section = sectionId;
884884+ navItem.dataset.featureName = name;
843885 navItem.addEventListener('click', () => showSection(sectionId));
886886+ if (!enabled) navItem.style.display = 'none';
844887 sidebarNav.appendChild(navItem);
845888846889 // Add section
847890 const section = createSection(sectionId, name, () => renderFeatureSettings(feature));
891891+ if (!enabled) section.style.display = 'none';
848892 contentArea.appendChild(section);
893893+894894+ featureElements.set(name.toLowerCase(), { navItem, section });
849895 }
896896+897897+ // Listen for feature toggle events to update sidebar
898898+ api.subscribe('core:feature:toggle', (msg) => {
899899+ const featureName = msg.featureId?.toLowerCase();
900900+ const elements = featureElements.get(featureName);
901901+ if (elements) {
902902+ const display = msg.enabled ? '' : 'none';
903903+ elements.navItem.style.display = display;
904904+ elements.section.style.display = display;
905905+906906+ // If currently viewing a disabled section, switch to Core
907907+ if (!msg.enabled && elements.section.classList.contains('active')) {
908908+ showSection('core');
909909+ }
910910+ }
911911+ });
850912851913 // Add Extensions section
852914 const extNav = document.createElement('a');
+60-23
extensions/groups/background.js
···11-// Groups extension background script
22-// This runs in the core background context and registers the extension
11+/**
22+ * Groups Extension Background Script
33+ *
44+ * Tag-based grouping of addresses
55+ *
66+ * Runs in isolated extension process (peek://ext/groups/background.html)
77+ * Uses api.settings for datastore-backed settings storage
88+ */
39410import { id, labels, schemas, storageKeys, defaults } from './config.js';
55-// Use absolute peek:// URLs since relative paths stay within the ext host
66-import { openStore } from "peek://app/utils.js";
77-import windows from "peek://app/windows.js";
811912const api = window.app;
1013const debug = api.debug;
1111-const clear = false;
12141313-const store = openStore(id, defaults, clear /* clear storage */);
1515+console.log('[ext:groups] background', labels.name);
14161517// Extension content is served from peek://ext/groups/
1618const address = 'peek://ext/groups/home.html';
17192020+// In-memory settings cache (loaded from datastore on init)
2121+let currentSettings = {
2222+ prefs: defaults.prefs
2323+};
2424+2525+/**
2626+ * Load settings from datastore
2727+ * @returns {Promise<{prefs: object}>}
2828+ */
2929+const loadSettings = async () => {
3030+ const result = await api.settings.get();
3131+ if (result.success && result.data) {
3232+ return {
3333+ prefs: result.data.prefs || defaults.prefs
3434+ };
3535+ }
3636+ return { prefs: defaults.prefs };
3737+};
3838+3939+/**
4040+ * Save settings to datastore
4141+ * @param {object} settings - Settings object with prefs
4242+ */
4343+const saveSettings = async (settings) => {
4444+ const result = await api.settings.set(settings);
4545+ if (!result.success) {
4646+ console.error('[ext:groups] Failed to save settings:', result.error);
4747+ }
4848+};
4949+1850const openGroupsWindow = () => {
1951 const height = 600;
2052 const width = 800;
···2355 key: address,
2456 height,
2557 width,
2626- escapeMode: 'navigate', // Allow internal navigation before closing
5858+ escapeMode: 'navigate',
2759 trackingSource: 'cmd',
2860 trackingSourceId: 'groups'
2961 };
30623131- windows.createWindow(address, params)
6363+ api.window.open(address, params)
3264 .then(window => {
3333- debug && console.log('Groups window opened:', window);
6565+ debug && console.log('[ext:groups] Groups window opened:', window);
3466 })
3567 .catch(error => {
3636- console.error('Failed to open groups window:', error);
6868+ console.error('[ext:groups] Failed to open groups window:', error);
3769 });
3870};
39714040-// ===== Command helpers (moved from app/cmd/commands/groups.js) =====
7272+// ===== Command helpers =====
41734274/**
4375 * Helper to get or create an address for a URI
···4981 const existing = result.data.find(addr => addr.uri === uri);
5082 if (existing) return existing;
51835252- // Create new address
5384 const addResult = await api.datastore.addAddress(uri, {});
5485 if (!addResult.success) return null;
5586···71102const saveToGroup = async (groupName) => {
72103 console.log('[ext:groups] Saving to group:', groupName);
731047474- // Get or create the tag
75105 const tagResult = await api.datastore.getOrCreateTag(groupName);
76106 if (!tagResult.success) {
77107 console.error('[ext:groups] Failed to get/create tag:', tagResult.error);
···8011081111 const tagId = tagResult.data.id;
821128383- // Get all open windows (excluding internal peek:// URLs)
84113 const listResult = await api.window.list({ includeInternal: false });
85114 if (!listResult.success || listResult.windows.length === 0) {
86115 console.log('[ext:groups] No windows to save');
···109138const openGroup = async (groupName) => {
110139 console.log('[ext:groups] Opening group:', groupName);
111140112112- // Find the tag by name
113141 const tagsResult = await api.datastore.getTagsByFrecency();
114142 if (!tagsResult.success) {
115143 return { success: false, error: 'Failed to get tags' };
···121149 return { success: false, error: 'Group not found' };
122150 }
123151124124- // Get addresses with this tag
125152 const addressesResult = await api.datastore.getAddressesByTag(tag.id);
126153 if (!addressesResult.success || addressesResult.data.length === 0) {
127154 console.log('[ext:groups] No addresses in group:', groupName);
···129156 }
130157131158 for (const addr of addressesResult.data) {
132132- await windows.createWindow(addr.uri, {
159159+ await api.window.open(addr.uri, {
133160 trackingSource: 'cmd',
134161 trackingSourceId: `group:${groupName}`
135162 });
···173200 const groupName = ctx.search.trim();
174201 await openGroup(groupName);
175202 } else {
176176- // Show available groups
177203 const groups = await getAllGroups();
178204 if (groups.length === 0) {
179205 console.log('[ext:groups] No groups saved yet. Use "save group <name>" to create one.');
···191217let registeredShortcut = null;
192218let registeredCommands = [];
193219194194-const initShortcut = shortcut => {
220220+const initShortcut = (shortcut) => {
195221 api.shortcuts.register(shortcut, () => {
196222 openGroupsWindow();
197223 }, { global: true });
···214240 console.log('[ext:groups] Unregistered commands');
215241};
216242217217-const init = () => {
243243+const init = async () => {
218244 console.log('[ext:groups] init');
219245220220- const prefs = () => store.get(storageKeys.PREFS);
221221- initShortcut(prefs().shortcutKey);
246246+ // Load settings from datastore
247247+ currentSettings = await loadSettings();
248248+249249+ initShortcut(currentSettings.prefs.shortcutKey);
222250 initCommands();
251251+252252+ // Listen for settings changes to hot-reload (GLOBAL scope for cross-process)
253253+ api.subscribe('groups:settings-changed', async () => {
254254+ console.log('[ext:groups] settings changed, reinitializing');
255255+ uninit();
256256+ currentSettings = await loadSettings();
257257+ initShortcut(currentSettings.prefs.shortcutKey);
258258+ initCommands();
259259+ }, api.scopes.GLOBAL);
223260};
224261225262const uninit = () => {