···55import windows from '../../windows.js';
66import api from '../../api.js';
7788-const GROUPS_ADDRESS = 'peek://app/groups/home.html';
88+const GROUPS_ADDRESS = 'peek://ext/groups/home.html';
991010/**
1111 * Helper to get or create an address for a URI
+3-2
app/config.js
···130130 { id: '82de735f-a4b7-4fe6-a458-ec29939ae00d',
131131 name: 'Groups',
132132 description: 'View your web in groups',
133133- start_url: 'peek://app/groups/background.html',
133133+ start_url: 'peek://ext/groups/background.js', // Extension
134134 enabled: false,
135135- settings_url: 'peek://app/groups/settings.html',
135135+ settings_url: 'peek://ext/groups/settings.html',
136136+ extension: true, // Mark as extension-based feature
136137 },
137138 { id: 'ef3bd271-d408-421f-9338-47b615571e43',
138139 name: 'Peeks',
···11// features
22+// Note: groups is now an extension (./extensions/groups/)
23import cmd from './cmd/index.js';
33-import groups from './groups/index.js';
44import peeks from './peeks/index.js';
55import scripts from './scripts/index.js';
66import slides from './slides/index.js';
7788const fc = {};
99fc[cmd.id] = cmd,
1010-fc[groups.id] = groups,
1110fc[peeks.id] = peeks,
1211fc[scripts.id] = scripts,
1312fc[slides.id] = slides
···33import windowManager from "./windows.js";
44import api from './api.js';
55import fc from './features.js';
66+// Use absolute peek:// URL since relative paths stay within the app host
77+import extensionLoader from 'peek://extensions/loader.js';
6879const { id, labels, schemas, storageKeys, defaults } = appConfig;
810···7274 return;
7375 }
74767777+ // Skip extension-based features (they're loaded by the extension loader)
7878+ if (f.extension) {
7979+ debug && console.log('skipping extension-based feature:', f.name);
8080+ return;
8181+ }
8282+8383+ // Check if feature exists in the features collection
8484+ if (!fc[f.id]) {
8585+ console.log('feature not found in collection:', f.name, f.id);
8686+ return;
8787+ }
8888+7589 console.log('initializing feature ', f);
76907791 fc[f.id].init();
···151165 }
152166153167 // feature enable/disable
154154- api.subscribe(topicFeatureToggle, msg => {
168168+ api.subscribe(topicFeatureToggle, async msg => {
155169 console.log('feature toggle', msg)
156170157171 const f = features().find(f => f.id == msg.featureId);
158172 if (f) {
159173 console.log('feature toggle', f);
174174+175175+ // Check if this feature is backed by an extension
176176+ const extId = f.name.toLowerCase();
177177+ const isExtension = extensionLoader.builtinExtensions.some(e => e.id === extId);
178178+160179 if (msg.enabled == false) {
161180 console.log('disabling', f.name);
162162- uninitFeature(f);
181181+ if (isExtension) {
182182+ await extensionLoader.unloadExtension(extId);
183183+ } else {
184184+ uninitFeature(f);
185185+ }
163186 }
164187 else if (msg.enabled == true) {
165188 console.log('enabling', f.name);
166166- initFeature(f);
189189+ if (isExtension) {
190190+ const ext = extensionLoader.builtinExtensions.find(e => e.id === extId);
191191+ if (ext) {
192192+ await extensionLoader.loadExtension(ext);
193193+ }
194194+ } else {
195195+ initFeature(f);
196196+ }
167197 }
168198 }
169199 else {
170170- console.log('feature toggle - no feature found for', f.name);
200200+ console.log('feature toggle - no feature found for', msg.featureId);
171201 }
172202 });
173203174204 initSettingsShortcut(p);
175205176206 features().forEach(initFeature);
207207+208208+ // Load extensions
209209+ // Helper to check if an extension (by name) is enabled in features
210210+ const isExtensionEnabled = (extId) => {
211211+ const featureList = features();
212212+ // Match extension ID to feature name (case-insensitive)
213213+ const feature = featureList.find(f =>
214214+ f.name.toLowerCase() === extId.toLowerCase()
215215+ );
216216+ return feature ? feature.enabled : false;
217217+ };
218218+219219+ await extensionLoader.loadBuiltinExtensions(isExtensionEnabled);
177220178221 //features.forEach(initIframeFeature);
179222
+80-9
index.js
···435435 }
436436}]);
437437438438+// Extension path cache: extensionId -> filesystem path
439439+const extensionPaths = new Map();
440440+441441+// Register a built-in extension path
442442+const registerExtensionPath = (id, fsPath) => {
443443+ extensionPaths.set(id, fsPath);
444444+ DEBUG && console.log('Registered extension path:', id, fsPath);
445445+};
446446+447447+// Get extension filesystem path by ID
448448+const getExtensionPath = (id) => {
449449+ return extensionPaths.get(id);
450450+};
451451+438452// TODO: unhack all this trash fire
439453const initAppProtocol = () => {
440454 protocol.handle(APP_SCHEME, req => {
···446460 // trim trailing slash
447461 pathname = pathname.replace(/^\//, '');
448462463463+ // Handle extension content: peek://ext/{ext-id}/{path}
464464+ if (host === 'ext') {
465465+ const parts = pathname.split('/');
466466+ const extId = parts[0];
467467+ const extPath = parts.slice(1).join('/') || 'index.html';
468468+469469+ const extBasePath = getExtensionPath(extId);
470470+ if (!extBasePath) {
471471+ DEBUG && console.log('Extension not found:', extId);
472472+ return new Response('Extension not found', { status: 404 });
473473+ }
474474+475475+ const absolutePath = path.resolve(extBasePath, extPath);
476476+477477+ // Security: ensure path stays within extension folder
478478+ const normalizedBase = path.normalize(extBasePath);
479479+ if (!absolutePath.startsWith(normalizedBase)) {
480480+ console.error('Path traversal attempt blocked:', absolutePath);
481481+ return new Response('Forbidden', { status: 403 });
482482+ }
483483+484484+ const fileURL = pathToFileURL(absolutePath).toString();
485485+ return net.fetch(fileURL);
486486+ }
487487+488488+ // Handle extensions infrastructure: peek://extensions/{path}
489489+ // This serves the extension loader and other shared extension code
490490+ if (host === 'extensions') {
491491+ const absolutePath = path.resolve(__dirname, 'extensions', pathname);
492492+493493+ // Security: ensure path stays within extensions folder
494494+ const extensionsBase = path.resolve(__dirname, 'extensions');
495495+ if (!absolutePath.startsWith(extensionsBase)) {
496496+ console.error('Path traversal attempt blocked:', absolutePath);
497497+ return new Response('Forbidden', { status: 403 });
498498+ }
499499+500500+ const fileURL = pathToFileURL(absolutePath).toString();
501501+ return net.fetch(fileURL);
502502+ }
503503+449504 let relativePath = pathname;
450505451506 // Ugh, handle node_modules paths
···502557503558 // handle peek://
504559 initAppProtocol();
560560+561561+ // Register built-in extensions
562562+ // Built-in extensions live in ./extensions/ at the project root
563563+ registerExtensionPath('groups', path.join(__dirname, 'extensions', 'groups'));
564564+ // Future: registerExtensionPath('peeks', path.join(__dirname, 'extensions', 'peeks'));
565565+ // Future: registerExtensionPath('slides', path.join(__dirname, 'extensions', 'slides'));
505566506567 // Register as default handler for http/https URLs (if not already and user hasn't declined)
507568 const defaultBrowserPrefFile = path.join(profileDataPath, 'default-browser-pref.json');
···703764 // Set up DevTools if requested
704765 winDevtoolsConfig(newWin);
705766706706- // Set up modal behavior
767767+ // Set up modal behavior with delay to avoid focus race condition
707768 if (featuresMap.modal === true) {
708708- newWin.on('blur', () => {
709709- console.log('Modal window lost focus:', details.url);
710710- closeOrHideWindow(newWin.id);
711711- });
769769+ setTimeout(() => {
770770+ if (!newWin.isDestroyed()) {
771771+ newWin.on('blur', () => {
772772+ console.log('Modal window lost focus:', details.url);
773773+ closeOrHideWindow(newWin.id);
774774+ });
775775+ }
776776+ }, 100);
712777 }
713778 }
714779 });
···9351000 winDevtoolsConfig(win);
93610019371002 // Set up modal behavior if requested
10031003+ // Delay blur handler attachment to avoid race condition where focus events
10041004+ // are still settling after window creation (can cause immediate close)
9381005 if (options.modal === true) {
939939- win.on('blur', () => {
940940- console.log('window-open: blur for modal window', url);
941941- closeOrHideWindow(win.id);
942942- });
10061006+ setTimeout(() => {
10071007+ if (!win.isDestroyed()) {
10081008+ win.on('blur', () => {
10091009+ console.log('window-open: blur for modal window', url);
10101010+ closeOrHideWindow(win.id);
10111011+ });
10121012+ }
10131013+ }, 100);
9431014 }
94410159451016 // Show dock when window opens
+1
notes/extensibility.md
···1818 - Enable/disable
1919 - Activate/suspend/reload
2020 - Click to access settings
2121+- Peeks, Slides and Groups as built-in but disable-able extensions
21222223Note: The implementation will also instigate another shift, moving as much logic into the background web app as possible, vs in node.js space, so we can eventually move to other back-ends than Electron.
2324