experiments in a post-browser web
10
fork

Configure Feed

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

first cut at extension infra and moving groups out as a test

+166 -778
+1 -1
app/cmd/commands/groups.js
··· 5 5 import windows from '../../windows.js'; 6 6 import api from '../../api.js'; 7 7 8 - const GROUPS_ADDRESS = 'peek://app/groups/home.html'; 8 + const GROUPS_ADDRESS = 'peek://ext/groups/home.html'; 9 9 10 10 /** 11 11 * Helper to get or create an address for a URI
+3 -2
app/config.js
··· 130 130 { id: '82de735f-a4b7-4fe6-a458-ec29939ae00d', 131 131 name: 'Groups', 132 132 description: 'View your web in groups', 133 - start_url: 'peek://app/groups/background.html', 133 + start_url: 'peek://ext/groups/background.js', // Extension 134 134 enabled: false, 135 - settings_url: 'peek://app/groups/settings.html', 135 + settings_url: 'peek://ext/groups/settings.html', 136 + extension: true, // Mark as extension-based feature 136 137 }, 137 138 { id: 'ef3bd271-d408-421f-9338-47b615571e43', 138 139 name: 'Peeks',
+33
app/datastore/schema.js
··· 153 153 updatedAt: { type: 'number' }, 154 154 lastFetchedAt: { type: 'number', default: 0 }, 155 155 enabled: { type: 'number', default: 1 } 156 + }, 157 + 158 + // Extensions registry 159 + extensions: { 160 + name: { type: 'string' }, 161 + description: { type: 'string', default: '' }, 162 + version: { type: 'string', default: '1.0.0' }, 163 + path: { type: 'string' }, // Filesystem path to extension folder 164 + backgroundUrl: { type: 'string', default: '' }, // peek://ext/{id}/background.js 165 + settingsUrl: { type: 'string', default: '' }, // peek://ext/{id}/settings.html 166 + iconPath: { type: 'string', default: '' }, 167 + builtin: { type: 'number', default: 0 }, // 1 if built-in extension 168 + enabled: { type: 'number', default: 1 }, 169 + status: { type: 'string', default: 'installed' }, // installed, running, suspended, error 170 + installedAt: { type: 'number' }, 171 + updatedAt: { type: 'number' }, 172 + lastErrorAt: { type: 'number', default: 0 }, 173 + lastError: { type: 'string', default: '' }, 174 + metadata: { type: 'string', default: '{}' } 156 175 } 157 176 }; 158 177 ··· 256 275 feeds_byEnabled: { 257 276 table: 'feeds', 258 277 on: 'enabled' 278 + }, 279 + 280 + // Extension indexes 281 + extensions_byEnabled: { 282 + table: 'extensions', 283 + on: 'enabled' 284 + }, 285 + extensions_byStatus: { 286 + table: 'extensions', 287 + on: 'status' 288 + }, 289 + extensions_byBuiltin: { 290 + table: 'extensions', 291 + on: 'builtin' 259 292 } 260 293 }; 261 294
+1 -2
app/features.js
··· 1 1 // features 2 + // Note: groups is now an extension (./extensions/groups/) 2 3 import cmd from './cmd/index.js'; 3 - import groups from './groups/index.js'; 4 4 import peeks from './peeks/index.js'; 5 5 import scripts from './scripts/index.js'; 6 6 import slides from './slides/index.js'; 7 7 8 8 const fc = {}; 9 9 fc[cmd.id] = cmd, 10 - fc[groups.id] = groups, 11 10 fc[peeks.id] = peeks, 12 11 fc[scripts.id] = scripts, 13 12 fc[slides.id] = slides
-46
app/groups/config.js
··· 1 - const id = '82de735f-a4b7-4fe6-a458-ec29939ae00d'; 2 - 3 - const labels = { 4 - name: 'Groups', 5 - prefs: { 6 - shortcutKey: 'Groups shortcut', 7 - } 8 - }; 9 - 10 - const prefsSchema = { 11 - "$schema": "https://json-schema.org/draft/2020-12/schema", 12 - "$id": "peek.groups.prefs.schema.json", 13 - "title": "Groups preferences", 14 - "description": "Peek app Groups user preferences", 15 - "type": "object", 16 - "properties": { 17 - "shortcutKey": { 18 - "description": "Global OS hotkey to open command panel", 19 - "type": "string", 20 - "default": "Option+Space" 21 - }, 22 - }, 23 - "required": [ "shortcutKey"] 24 - }; 25 - 26 - const schemas = { 27 - prefs: prefsSchema, 28 - }; 29 - 30 - const storageKeys = { 31 - PREFS: 'prefs', 32 - }; 33 - 34 - const defaults = { 35 - prefs: { 36 - shortcutKey: 'Option+g' 37 - }, 38 - }; 39 - 40 - export { 41 - id, 42 - labels, 43 - schemas, 44 - storageKeys, 45 - defaults 46 - };
-223
app/groups/home.css
··· 1 - * { 2 - box-sizing: border-box; 3 - margin: 0; 4 - padding: 0; 5 - } 6 - 7 - html { 8 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 9 - -webkit-font-smoothing: antialiased; 10 - font-size: 14px; 11 - line-height: 1.5; 12 - } 13 - 14 - body { 15 - background: #1a1a1a; 16 - color: #e0e0e0; 17 - min-height: 100vh; 18 - } 19 - 20 - /* Header */ 21 - .header { 22 - display: flex; 23 - align-items: center; 24 - justify-content: space-between; 25 - padding: 16px 24px; 26 - border-bottom: 1px solid #333; 27 - background: #222; 28 - position: sticky; 29 - top: 0; 30 - z-index: 100; 31 - } 32 - 33 - .header-title { 34 - font-size: 18px; 35 - font-weight: 600; 36 - color: #fff; 37 - flex: 1; 38 - text-align: center; 39 - } 40 - 41 - .back-btn, 42 - .new-group-btn { 43 - padding: 8px 16px; 44 - border: none; 45 - border-radius: 6px; 46 - font-size: 14px; 47 - font-weight: 500; 48 - cursor: pointer; 49 - transition: all 0.15s ease; 50 - } 51 - 52 - .back-btn { 53 - background: #333; 54 - color: #e0e0e0; 55 - } 56 - 57 - .back-btn:hover { 58 - background: #444; 59 - } 60 - 61 - .new-group-btn { 62 - background: #0066cc; 63 - color: #fff; 64 - } 65 - 66 - .new-group-btn:hover { 67 - background: #0077ee; 68 - } 69 - 70 - /* Cards container */ 71 - .cards { 72 - padding: 24px; 73 - display: grid; 74 - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); 75 - gap: 16px; 76 - } 77 - 78 - /* Card base */ 79 - .card { 80 - background: #252525; 81 - border-radius: 8px; 82 - padding: 16px; 83 - cursor: pointer; 84 - transition: all 0.15s ease; 85 - display: flex; 86 - align-items: flex-start; 87 - gap: 12px; 88 - } 89 - 90 - .card:hover { 91 - background: #2a2a2a; 92 - transform: translateY(-2px); 93 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 94 - } 95 - 96 - .card.selected { 97 - background: #333; 98 - outline: 2px solid #0066cc; 99 - outline-offset: -2px; 100 - } 101 - 102 - .card.selected:hover { 103 - background: #383838; 104 - } 105 - 106 - /* Group card */ 107 - .group-card .color-dot { 108 - width: 12px; 109 - height: 12px; 110 - border-radius: 50%; 111 - flex-shrink: 0; 112 - margin-top: 4px; 113 - } 114 - 115 - /* Address card */ 116 - .address-card .card-favicon { 117 - width: 32px; 118 - height: 32px; 119 - border-radius: 4px; 120 - flex-shrink: 0; 121 - background: #333; 122 - object-fit: contain; 123 - } 124 - 125 - /* Card content */ 126 - .card-content { 127 - flex: 1; 128 - min-width: 0; 129 - } 130 - 131 - .card-title { 132 - font-size: 15px; 133 - font-weight: 600; 134 - color: #fff; 135 - margin-bottom: 4px; 136 - white-space: nowrap; 137 - overflow: hidden; 138 - text-overflow: ellipsis; 139 - } 140 - 141 - .card-url { 142 - font-size: 12px; 143 - color: #888; 144 - white-space: nowrap; 145 - overflow: hidden; 146 - text-overflow: ellipsis; 147 - margin-bottom: 8px; 148 - } 149 - 150 - .card-meta { 151 - font-size: 12px; 152 - color: #666; 153 - } 154 - 155 - /* Empty state */ 156 - .empty-state { 157 - grid-column: 1 / -1; 158 - text-align: center; 159 - padding: 48px 24px; 160 - color: #666; 161 - font-size: 15px; 162 - } 163 - 164 - /* Dark mode support (already dark, but for consistency) */ 165 - @media (prefers-color-scheme: light) { 166 - body { 167 - background: #f5f5f5; 168 - color: #333; 169 - } 170 - 171 - .header { 172 - background: #fff; 173 - border-bottom-color: #e0e0e0; 174 - } 175 - 176 - .header-title { 177 - color: #333; 178 - } 179 - 180 - .back-btn { 181 - background: #e0e0e0; 182 - color: #333; 183 - } 184 - 185 - .back-btn:hover { 186 - background: #d0d0d0; 187 - } 188 - 189 - .card { 190 - background: #fff; 191 - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 192 - } 193 - 194 - .card:hover { 195 - background: #fafafa; 196 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 197 - } 198 - 199 - .card.selected { 200 - background: #e8f0fe; 201 - outline: 2px solid #0066cc; 202 - } 203 - 204 - .card.selected:hover { 205 - background: #dbe7fc; 206 - } 207 - 208 - .card-title { 209 - color: #333; 210 - } 211 - 212 - .card-url { 213 - color: #666; 214 - } 215 - 216 - .card-meta { 217 - color: #999; 218 - } 219 - 220 - .address-card .card-favicon { 221 - background: #f0f0f0; 222 - } 223 - }
-20
app/groups/home.html
··· 1 - <!DOCTYPE html> 2 - <html> 3 - <head> 4 - <meta charset="utf-8"> 5 - <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1"> 6 - <title>Groups</title> 7 - <link rel="stylesheet" type="text/css" href="home.css"> 8 - </head> 9 - <body> 10 - <header class="header"> 11 - <button class="back-btn" style="display: none;">Back</button> 12 - <h1 class="header-title">Groups</h1> 13 - <button class="new-group-btn">+ New Group</button> 14 - </header> 15 - 16 - <main class="cards"></main> 17 - 18 - <script type="module" src="home.js"></script> 19 - </body> 20 - </html>
-394
app/groups/home.js
··· 1 - /** 2 - * Groups - Tag-based grouping of addresses 3 - * 4 - * Groups are implemented using tags: 5 - * - Each "group" is a tag 6 - * - Addresses in a group are addresses tagged with that tag 7 - * - Creating a new group creates a new tag 8 - * - Viewing a group shows all addresses with that tag 9 - */ 10 - 11 - const api = window.app; 12 - const debug = api.debug; 13 - 14 - // View states 15 - const VIEW_GROUPS = 'groups'; 16 - const VIEW_ADDRESSES = 'addresses'; 17 - 18 - // Special pseudo-tag for untagged addresses 19 - const UNTAGGED_GROUP = { 20 - id: '__untagged__', 21 - name: 'Untagged', 22 - color: '#666666', 23 - frequency: 0, 24 - isSpecial: true 25 - }; 26 - 27 - let state = { 28 - view: VIEW_GROUPS, 29 - tags: [], 30 - currentTag: null, 31 - addresses: [], 32 - untaggedCount: 0, 33 - selectedIndex: 0 34 - }; 35 - 36 - // Handle ESC - cooperative escape handling with window manager 37 - // Returns { handled: true } if we navigated internally 38 - // Returns { handled: false } if at root (groups list) and window should close 39 - api.escape.onEscape(() => { 40 - if (state.view === VIEW_ADDRESSES) { 41 - // Navigate back to groups list 42 - showGroups(); 43 - return { handled: true }; 44 - } 45 - // At root (groups list) - let window close 46 - return { handled: false }; 47 - }); 48 - 49 - /** 50 - * Get all cards in the current view 51 - */ 52 - const getCards = () => { 53 - return Array.from(document.querySelectorAll('.cards .card')); 54 - }; 55 - 56 - /** 57 - * Update visual selection on cards 58 - */ 59 - const updateSelection = () => { 60 - const cards = getCards(); 61 - cards.forEach((card, i) => { 62 - card.classList.toggle('selected', i === state.selectedIndex); 63 - }); 64 - 65 - // Scroll selected card into view 66 - const selected = cards[state.selectedIndex]; 67 - if (selected) { 68 - selected.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); 69 - } 70 - }; 71 - 72 - /** 73 - * Activate the currently selected card 74 - */ 75 - const activateSelected = () => { 76 - const cards = getCards(); 77 - const selected = cards[state.selectedIndex]; 78 - if (selected) { 79 - selected.click(); 80 - } 81 - }; 82 - 83 - /** 84 - * Get number of columns in the grid based on card positions 85 - */ 86 - const getGridColumns = (cards) => { 87 - if (cards.length < 2) return 1; 88 - const firstTop = cards[0].getBoundingClientRect().top; 89 - for (let i = 1; i < cards.length; i++) { 90 - if (cards[i].getBoundingClientRect().top !== firstTop) { 91 - return i; 92 - } 93 - } 94 - return cards.length; // All on one row 95 - }; 96 - 97 - /** 98 - * Handle keyboard navigation (vim-style hjkl for grid movement) 99 - */ 100 - const handleKeydown = (e) => { 101 - const cards = getCards(); 102 - if (cards.length === 0) return; 103 - 104 - const cols = getGridColumns(cards); 105 - 106 - switch (e.key) { 107 - case 'j': 108 - case 'ArrowDown': 109 - e.preventDefault(); 110 - if (state.selectedIndex + cols < cards.length) { 111 - state.selectedIndex += cols; 112 - updateSelection(); 113 - } 114 - break; 115 - case 'k': 116 - case 'ArrowUp': 117 - e.preventDefault(); 118 - if (state.selectedIndex - cols >= 0) { 119 - state.selectedIndex -= cols; 120 - updateSelection(); 121 - } 122 - break; 123 - case 'h': 124 - case 'ArrowLeft': 125 - e.preventDefault(); 126 - if (state.selectedIndex > 0) { 127 - state.selectedIndex--; 128 - updateSelection(); 129 - } 130 - break; 131 - case 'l': 132 - case 'ArrowRight': 133 - e.preventDefault(); 134 - if (state.selectedIndex < cards.length - 1) { 135 - state.selectedIndex++; 136 - updateSelection(); 137 - } 138 - break; 139 - case 'Enter': 140 - e.preventDefault(); 141 - activateSelected(); 142 - break; 143 - } 144 - }; 145 - 146 - const init = async () => { 147 - debug && console.log('Groups init'); 148 - 149 - // Load tags from datastore 150 - await loadTags(); 151 - 152 - // Set up event listeners 153 - document.querySelector('.new-group-btn').addEventListener('click', createNewGroup); 154 - document.querySelector('.back-btn').addEventListener('click', showGroups); 155 - 156 - // Keyboard navigation 157 - document.addEventListener('keydown', handleKeydown); 158 - 159 - // Show groups view 160 - showGroups(); 161 - }; 162 - 163 - /** 164 - * Load all tags sorted by frecency, and count untagged addresses 165 - */ 166 - const loadTags = async () => { 167 - const result = await api.datastore.getTagsByFrecency(); 168 - if (result.success) { 169 - state.tags = result.data; 170 - debug && console.log('Loaded tags:', state.tags.length); 171 - } else { 172 - console.error('Failed to load tags:', result.error); 173 - state.tags = []; 174 - } 175 - 176 - // Get count of untagged addresses 177 - const untaggedResult = await api.datastore.getUntaggedAddresses(); 178 - if (untaggedResult.success) { 179 - state.untaggedCount = untaggedResult.data.length; 180 - debug && console.log('Untagged addresses:', state.untaggedCount); 181 - } else { 182 - state.untaggedCount = 0; 183 - } 184 - }; 185 - 186 - /** 187 - * Load addresses for a specific tag 188 - */ 189 - const loadAddressesForTag = async (tagId) => { 190 - const result = await api.datastore.getAddressesByTag(tagId); 191 - if (result.success) { 192 - state.addresses = result.data; 193 - debug && console.log('Loaded addresses for tag:', state.addresses.length); 194 - } else { 195 - console.error('Failed to load addresses:', result.error); 196 - state.addresses = []; 197 - } 198 - }; 199 - 200 - /** 201 - * Create a new group (tag) 202 - */ 203 - const createNewGroup = async () => { 204 - const name = prompt('Enter group name:'); 205 - if (!name || !name.trim()) return; 206 - 207 - const result = await api.datastore.getOrCreateTag(name.trim()); 208 - if (result.success) { 209 - debug && console.log('Created tag:', result.data); 210 - await loadTags(); 211 - showGroups(); 212 - } else { 213 - console.error('Failed to create tag:', result.error); 214 - } 215 - }; 216 - 217 - /** 218 - * Show the groups (tags) view 219 - */ 220 - const showGroups = async () => { 221 - state.view = VIEW_GROUPS; 222 - state.currentTag = null; 223 - 224 - // Refresh tags 225 - await loadTags(); 226 - 227 - // Update UI 228 - document.querySelector('.header-title').textContent = 'Groups'; 229 - document.querySelector('.back-btn').style.display = 'none'; 230 - document.querySelector('.new-group-btn').style.display = 'block'; 231 - 232 - // Clear and populate cards 233 - const container = document.querySelector('.cards'); 234 - container.innerHTML = ''; 235 - 236 - // Always show Untagged group first if there are untagged addresses 237 - if (state.untaggedCount > 0) { 238 - const untaggedCard = createGroupCard({ ...UNTAGGED_GROUP, frequency: state.untaggedCount }); 239 - container.appendChild(untaggedCard); 240 - } 241 - 242 - if (state.tags.length === 0 && state.untaggedCount === 0) { 243 - container.innerHTML = '<div class="empty-state">No groups yet. Create one to get started.</div>'; 244 - return; 245 - } 246 - 247 - state.tags.forEach(tag => { 248 - const card = createGroupCard(tag); 249 - container.appendChild(card); 250 - }); 251 - 252 - // Reset selection 253 - state.selectedIndex = 0; 254 - updateSelection(); 255 - }; 256 - 257 - /** 258 - * Show addresses in a group (tag) 259 - */ 260 - const showAddresses = async (tag) => { 261 - state.view = VIEW_ADDRESSES; 262 - state.currentTag = tag; 263 - 264 - // Load addresses - handle special untagged group 265 - if (tag.isSpecial && tag.id === '__untagged__') { 266 - const result = await api.datastore.getUntaggedAddresses(); 267 - if (result.success) { 268 - state.addresses = result.data; 269 - } else { 270 - state.addresses = []; 271 - } 272 - } else { 273 - await loadAddressesForTag(tag.id); 274 - } 275 - 276 - // Update UI 277 - document.querySelector('.header-title').textContent = tag.name; 278 - document.querySelector('.back-btn').style.display = 'block'; 279 - document.querySelector('.new-group-btn').style.display = 'none'; 280 - 281 - // Clear and populate cards 282 - const container = document.querySelector('.cards'); 283 - container.innerHTML = ''; 284 - 285 - if (state.addresses.length === 0) { 286 - container.innerHTML = '<div class="empty-state">No addresses in this group yet.</div>'; 287 - return; 288 - } 289 - 290 - state.addresses.forEach(address => { 291 - const card = createAddressCard(address); 292 - container.appendChild(card); 293 - }); 294 - 295 - // Reset selection 296 - state.selectedIndex = 0; 297 - updateSelection(); 298 - }; 299 - 300 - /** 301 - * Create a card element for a group (tag) 302 - */ 303 - const createGroupCard = (tag) => { 304 - const card = document.createElement('div'); 305 - card.className = 'card group-card'; 306 - if (tag.isSpecial) { 307 - card.classList.add('special-group'); 308 - } 309 - card.dataset.tagId = tag.id; 310 - 311 - const colorDot = document.createElement('div'); 312 - colorDot.className = 'color-dot'; 313 - colorDot.style.backgroundColor = tag.color || '#999'; 314 - 315 - const content = document.createElement('div'); 316 - content.className = 'card-content'; 317 - 318 - const title = document.createElement('h2'); 319 - title.className = 'card-title'; 320 - title.textContent = tag.name; 321 - 322 - const meta = document.createElement('div'); 323 - meta.className = 'card-meta'; 324 - if (tag.isSpecial) { 325 - meta.textContent = `${tag.frequency || 0} addresses`; 326 - } else { 327 - meta.textContent = `Used ${tag.frequency || 0} times`; 328 - } 329 - 330 - content.appendChild(title); 331 - content.appendChild(meta); 332 - 333 - card.appendChild(colorDot); 334 - card.appendChild(content); 335 - 336 - // Click to view addresses in this group 337 - card.addEventListener('click', () => showAddresses(tag)); 338 - 339 - return card; 340 - }; 341 - 342 - /** 343 - * Create a card element for an address 344 - */ 345 - const createAddressCard = (address) => { 346 - const card = document.createElement('div'); 347 - card.className = 'card address-card'; 348 - card.dataset.addressId = address.id; 349 - 350 - const favicon = document.createElement('img'); 351 - favicon.className = 'card-favicon'; 352 - favicon.src = address.favicon || 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🌐</text></svg>'; 353 - favicon.onerror = () => { 354 - favicon.src = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🌐</text></svg>'; 355 - }; 356 - 357 - const content = document.createElement('div'); 358 - content.className = 'card-content'; 359 - 360 - const title = document.createElement('h2'); 361 - title.className = 'card-title'; 362 - title.textContent = address.title || address.uri; 363 - 364 - const url = document.createElement('div'); 365 - url.className = 'card-url'; 366 - url.textContent = address.uri; 367 - 368 - const meta = document.createElement('div'); 369 - meta.className = 'card-meta'; 370 - const lastVisit = address.lastVisitAt ? new Date(address.lastVisitAt).toLocaleDateString() : 'Never'; 371 - meta.textContent = `${address.visitCount || 0} visits · Last: ${lastVisit}`; 372 - 373 - content.appendChild(title); 374 - content.appendChild(url); 375 - content.appendChild(meta); 376 - 377 - card.appendChild(favicon); 378 - card.appendChild(content); 379 - 380 - // Click to open address 381 - card.addEventListener('click', async () => { 382 - debug && console.log('Opening address:', address.uri); 383 - const result = await api.window.open(address.uri, { 384 - width: 800, 385 - height: 600 386 - }); 387 - debug && console.log('Window opened:', result); 388 - }); 389 - 390 - return card; 391 - }; 392 - 393 - // Initialize when DOM is ready 394 - document.addEventListener('DOMContentLoaded', init);
-77
app/groups/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 - const address = 'peek://app/groups/home.html'; 14 - 15 - const openGroupsWindow = () => { 16 - const height = 600; 17 - const width = 800; 18 - 19 - const params = { 20 - key: address, 21 - height, 22 - width, 23 - escapeMode: 'navigate' // Allow internal navigation before closing 24 - }; 25 - 26 - // Use the window creation API 27 - windows.createWindow(address, params) 28 - .then(window => { 29 - console.log('Groups window opened:', window); 30 - }) 31 - .catch(error => { 32 - console.error('Failed to open groups window:', error); 33 - }); 34 - }; 35 - 36 - const initShortcut = shortcut => { 37 - api.shortcuts.register(shortcut, () => { 38 - openGroupsWindow(); 39 - }); 40 - }; 41 - 42 - const initItems = (prefs, items) => { 43 - const cmdPrefix = prefs.shortcutKeyPrefix; 44 - 45 - items.forEach(item => { 46 - const shortcut = `${cmdPrefix}${item.keyNum}`; 47 - 48 - api.shortcuts.register(shortcut, () => { 49 - executeItem(item); 50 - }); 51 - }); 52 - }; 53 - 54 - const init = () => { 55 - console.log('init'); 56 - 57 - const prefs = () => store.get(storageKeys.PREFS); 58 - 59 - initShortcut(prefs().shortcutKey); 60 - 61 - /* 62 - const items = () => store.get(storageKeys.ITEMS); 63 - 64 - if (items().length > 0) { 65 - initItems(prefs(), items()); 66 - } 67 - */ 68 - }; 69 - 70 - export default { 71 - defaults, 72 - id, 73 - init, 74 - labels, 75 - schemas, 76 - storageKeys 77 - };
+47 -4
app/index.js
··· 3 3 import windowManager from "./windows.js"; 4 4 import api from './api.js'; 5 5 import fc from './features.js'; 6 + // Use absolute peek:// URL since relative paths stay within the app host 7 + import extensionLoader from 'peek://extensions/loader.js'; 6 8 7 9 const { id, labels, schemas, storageKeys, defaults } = appConfig; 8 10 ··· 72 74 return; 73 75 } 74 76 77 + // Skip extension-based features (they're loaded by the extension loader) 78 + if (f.extension) { 79 + debug && console.log('skipping extension-based feature:', f.name); 80 + return; 81 + } 82 + 83 + // Check if feature exists in the features collection 84 + if (!fc[f.id]) { 85 + console.log('feature not found in collection:', f.name, f.id); 86 + return; 87 + } 88 + 75 89 console.log('initializing feature ', f); 76 90 77 91 fc[f.id].init(); ··· 151 165 } 152 166 153 167 // feature enable/disable 154 - api.subscribe(topicFeatureToggle, msg => { 168 + api.subscribe(topicFeatureToggle, async msg => { 155 169 console.log('feature toggle', msg) 156 170 157 171 const f = features().find(f => f.id == msg.featureId); 158 172 if (f) { 159 173 console.log('feature toggle', f); 174 + 175 + // Check if this feature is backed by an extension 176 + const extId = f.name.toLowerCase(); 177 + const isExtension = extensionLoader.builtinExtensions.some(e => e.id === extId); 178 + 160 179 if (msg.enabled == false) { 161 180 console.log('disabling', f.name); 162 - uninitFeature(f); 181 + if (isExtension) { 182 + await extensionLoader.unloadExtension(extId); 183 + } else { 184 + uninitFeature(f); 185 + } 163 186 } 164 187 else if (msg.enabled == true) { 165 188 console.log('enabling', f.name); 166 - initFeature(f); 189 + if (isExtension) { 190 + const ext = extensionLoader.builtinExtensions.find(e => e.id === extId); 191 + if (ext) { 192 + await extensionLoader.loadExtension(ext); 193 + } 194 + } else { 195 + initFeature(f); 196 + } 167 197 } 168 198 } 169 199 else { 170 - console.log('feature toggle - no feature found for', f.name); 200 + console.log('feature toggle - no feature found for', msg.featureId); 171 201 } 172 202 }); 173 203 174 204 initSettingsShortcut(p); 175 205 176 206 features().forEach(initFeature); 207 + 208 + // Load extensions 209 + // Helper to check if an extension (by name) is enabled in features 210 + const isExtensionEnabled = (extId) => { 211 + const featureList = features(); 212 + // Match extension ID to feature name (case-insensitive) 213 + const feature = featureList.find(f => 214 + f.name.toLowerCase() === extId.toLowerCase() 215 + ); 216 + return feature ? feature.enabled : false; 217 + }; 218 + 219 + await extensionLoader.loadBuiltinExtensions(isExtensionEnabled); 177 220 178 221 //features.forEach(initIframeFeature); 179 222
+80 -9
index.js
··· 435 435 } 436 436 }]); 437 437 438 + // Extension path cache: extensionId -> filesystem path 439 + const extensionPaths = new Map(); 440 + 441 + // Register a built-in extension path 442 + const registerExtensionPath = (id, fsPath) => { 443 + extensionPaths.set(id, fsPath); 444 + DEBUG && console.log('Registered extension path:', id, fsPath); 445 + }; 446 + 447 + // Get extension filesystem path by ID 448 + const getExtensionPath = (id) => { 449 + return extensionPaths.get(id); 450 + }; 451 + 438 452 // TODO: unhack all this trash fire 439 453 const initAppProtocol = () => { 440 454 protocol.handle(APP_SCHEME, req => { ··· 446 460 // trim trailing slash 447 461 pathname = pathname.replace(/^\//, ''); 448 462 463 + // Handle extension content: peek://ext/{ext-id}/{path} 464 + if (host === 'ext') { 465 + const parts = pathname.split('/'); 466 + const extId = parts[0]; 467 + const extPath = parts.slice(1).join('/') || 'index.html'; 468 + 469 + const extBasePath = getExtensionPath(extId); 470 + if (!extBasePath) { 471 + DEBUG && console.log('Extension not found:', extId); 472 + return new Response('Extension not found', { status: 404 }); 473 + } 474 + 475 + const absolutePath = path.resolve(extBasePath, extPath); 476 + 477 + // Security: ensure path stays within extension folder 478 + const normalizedBase = path.normalize(extBasePath); 479 + if (!absolutePath.startsWith(normalizedBase)) { 480 + console.error('Path traversal attempt blocked:', absolutePath); 481 + return new Response('Forbidden', { status: 403 }); 482 + } 483 + 484 + const fileURL = pathToFileURL(absolutePath).toString(); 485 + return net.fetch(fileURL); 486 + } 487 + 488 + // Handle extensions infrastructure: peek://extensions/{path} 489 + // This serves the extension loader and other shared extension code 490 + if (host === 'extensions') { 491 + const absolutePath = path.resolve(__dirname, 'extensions', pathname); 492 + 493 + // Security: ensure path stays within extensions folder 494 + const extensionsBase = path.resolve(__dirname, 'extensions'); 495 + if (!absolutePath.startsWith(extensionsBase)) { 496 + console.error('Path traversal attempt blocked:', absolutePath); 497 + return new Response('Forbidden', { status: 403 }); 498 + } 499 + 500 + const fileURL = pathToFileURL(absolutePath).toString(); 501 + return net.fetch(fileURL); 502 + } 503 + 449 504 let relativePath = pathname; 450 505 451 506 // Ugh, handle node_modules paths ··· 502 557 503 558 // handle peek:// 504 559 initAppProtocol(); 560 + 561 + // Register built-in extensions 562 + // Built-in extensions live in ./extensions/ at the project root 563 + registerExtensionPath('groups', path.join(__dirname, 'extensions', 'groups')); 564 + // Future: registerExtensionPath('peeks', path.join(__dirname, 'extensions', 'peeks')); 565 + // Future: registerExtensionPath('slides', path.join(__dirname, 'extensions', 'slides')); 505 566 506 567 // Register as default handler for http/https URLs (if not already and user hasn't declined) 507 568 const defaultBrowserPrefFile = path.join(profileDataPath, 'default-browser-pref.json'); ··· 703 764 // Set up DevTools if requested 704 765 winDevtoolsConfig(newWin); 705 766 706 - // Set up modal behavior 767 + // Set up modal behavior with delay to avoid focus race condition 707 768 if (featuresMap.modal === true) { 708 - newWin.on('blur', () => { 709 - console.log('Modal window lost focus:', details.url); 710 - closeOrHideWindow(newWin.id); 711 - }); 769 + setTimeout(() => { 770 + if (!newWin.isDestroyed()) { 771 + newWin.on('blur', () => { 772 + console.log('Modal window lost focus:', details.url); 773 + closeOrHideWindow(newWin.id); 774 + }); 775 + } 776 + }, 100); 712 777 } 713 778 } 714 779 }); ··· 935 1000 winDevtoolsConfig(win); 936 1001 937 1002 // Set up modal behavior if requested 1003 + // Delay blur handler attachment to avoid race condition where focus events 1004 + // are still settling after window creation (can cause immediate close) 938 1005 if (options.modal === true) { 939 - win.on('blur', () => { 940 - console.log('window-open: blur for modal window', url); 941 - closeOrHideWindow(win.id); 942 - }); 1006 + setTimeout(() => { 1007 + if (!win.isDestroyed()) { 1008 + win.on('blur', () => { 1009 + console.log('window-open: blur for modal window', url); 1010 + closeOrHideWindow(win.id); 1011 + }); 1012 + } 1013 + }, 100); 943 1014 } 944 1015 945 1016 // Show dock when window opens
+1
notes/extensibility.md
··· 18 18 - Enable/disable 19 19 - Activate/suspend/reload 20 20 - Click to access settings 21 + - Peeks, Slides and Groups as built-in but disable-able extensions 21 22 22 23 Note: 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. 23 24