experiments in a post-browser web
10
fork

Configure Feed

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

extensions in settings app

+768 -124
+5 -1
app/index.js
··· 223 223 api.subscribe(topicFeatureToggle, async msg => { 224 224 console.log('feature toggle', msg) 225 225 226 - const f = features().find(f => f.id == msg.featureId); 226 + // Find feature by ID (UUID) or by name (extension ID like "groups") 227 + const f = features().find(f => 228 + f.id == msg.featureId || 229 + f.name.toLowerCase() === msg.featureId?.toLowerCase() 230 + ); 227 231 if (f) { 228 232 console.log('feature toggle', f); 229 233
+383 -122
app/settings/settings.js
··· 215 215 const renderExtensionsSettings = async () => { 216 216 const container = document.createElement('div'); 217 217 218 - // Loading state 219 - const loading = document.createElement('div'); 220 - loading.className = 'help-text'; 221 - loading.textContent = 'Loading extensions...'; 222 - container.appendChild(loading); 218 + // Add Extension button at top 219 + const addSection = document.createElement('div'); 220 + addSection.className = 'form-section'; 221 + addSection.style.marginBottom = '24px'; 223 222 224 - try { 225 - const result = await api.extensions.list(); 223 + const addBtn = document.createElement('button'); 224 + addBtn.textContent = '+ Add Extension'; 225 + addBtn.style.cssText = ` 226 + padding: 10px 16px; 227 + font-size: 13px; 228 + background: var(--bg-tertiary); 229 + border: 1px solid var(--border-primary); 230 + border-radius: 6px; 231 + color: var(--text-primary); 232 + cursor: pointer; 233 + width: 100%; 234 + `; 235 + addBtn.addEventListener('mouseenter', () => { 236 + addBtn.style.background = 'var(--bg-hover)'; 237 + }); 238 + addBtn.addEventListener('mouseleave', () => { 239 + addBtn.style.background = 'var(--bg-tertiary)'; 240 + }); 241 + addBtn.addEventListener('click', async () => { 242 + addBtn.textContent = 'Selecting folder...'; 243 + addBtn.disabled = true; 226 244 227 - // Remove loading 228 - loading.remove(); 245 + try { 246 + // Open folder picker 247 + const pickResult = await api.extensions.pickFolder(); 248 + if (!pickResult.success || pickResult.canceled) { 249 + addBtn.textContent = '+ Add Extension'; 250 + addBtn.disabled = false; 251 + return; 252 + } 253 + 254 + const folderPath = pickResult.data.path; 255 + addBtn.textContent = 'Validating...'; 256 + 257 + // Validate folder 258 + const validateResult = await api.extensions.validateFolder(folderPath); 229 259 230 - if (!result.success) { 231 - const error = document.createElement('div'); 232 - error.className = 'help-text'; 233 - error.textContent = `Error: ${result.error}`; 234 - container.appendChild(error); 235 - return container; 260 + // Add even if invalid (disabled), so user can fix and retry 261 + const manifest = validateResult.manifest || {}; 262 + const isValid = validateResult.valid === true; 263 + 264 + addBtn.textContent = 'Adding...'; 265 + 266 + // Add to datastore (disabled if invalid) 267 + const addResult = await api.extensions.add(folderPath, manifest, false); 268 + 269 + if (addResult.success) { 270 + addBtn.textContent = isValid ? 'Added!' : 'Added (disabled - has errors)'; 271 + 272 + // Refresh the list 273 + setTimeout(() => { 274 + addBtn.textContent = '+ Add Extension'; 275 + addBtn.disabled = false; 276 + refreshExtensionsList(); 277 + }, 1500); 278 + } else { 279 + addBtn.textContent = `Error: ${addResult.error}`; 280 + setTimeout(() => { 281 + addBtn.textContent = '+ Add Extension'; 282 + addBtn.disabled = false; 283 + }, 3000); 284 + } 285 + } catch (err) { 286 + console.error('Add extension error:', err); 287 + addBtn.textContent = 'Error adding extension'; 288 + setTimeout(() => { 289 + addBtn.textContent = '+ Add Extension'; 290 + addBtn.disabled = false; 291 + }, 2000); 236 292 } 293 + }); 294 + addSection.appendChild(addBtn); 295 + container.appendChild(addSection); 237 296 238 - const extensions = result.data || []; 297 + // Extensions list container (for refresh) 298 + const listContainer = document.createElement('div'); 299 + listContainer.id = 'extensions-list-container'; 300 + container.appendChild(listContainer); 301 + 302 + // Function to refresh extensions list 303 + const refreshExtensionsList = async () => { 304 + listContainer.innerHTML = ''; 305 + 306 + const loading = document.createElement('div'); 307 + loading.className = 'help-text'; 308 + loading.textContent = 'Loading extensions...'; 309 + listContainer.appendChild(loading); 310 + 311 + try { 312 + // Get features list to check enabled state for builtins 313 + const store = openStore(appConfig.id, appConfig.defaults, false); 314 + const features = store.get(appConfig.storageKeys.ITEMS) || []; 315 + 316 + // Get both running extensions and datastore extensions 317 + const [runningResult, datastoreResult] = await Promise.all([ 318 + api.extensions.list(), 319 + api.extensions.getAll() 320 + ]); 239 321 240 - if (extensions.length === 0) { 241 - const empty = document.createElement('div'); 242 - empty.className = 'help-text'; 243 - empty.textContent = 'No extensions loaded.'; 244 - container.appendChild(empty); 245 - return container; 246 - } 322 + loading.remove(); 247 323 248 - const extSection = document.createElement('div'); 249 - extSection.className = 'form-section'; 324 + const runningExts = runningResult.success ? runningResult.data || [] : []; 325 + const datastoreExts = datastoreResult.success ? datastoreResult.data || [] : []; 250 326 251 - const title = document.createElement('h3'); 252 - title.className = 'form-section-title'; 253 - title.textContent = 'Installed Extensions'; 254 - extSection.appendChild(title); 327 + // Merge: builtin running + datastore external 328 + // Running extensions have manifest info, datastore has persisted info 329 + const runningById = new Map(runningExts.map(e => [e.id, e])); 255 330 256 - extensions.forEach(ext => { 257 - const manifest = ext.manifest || {}; 331 + // Build combined list 332 + const allExtensions = []; 258 333 259 - const card = document.createElement('div'); 260 - card.className = 'item-card'; 334 + // Get all builtin extension IDs from the loader 335 + const builtinExtIds = ['groups', 'peeks', 'slides']; 261 336 262 - const header = document.createElement('div'); 263 - header.className = 'item-card-header'; 337 + // Add builtin extensions (whether running or not) 338 + builtinExtIds.forEach(extId => { 339 + const running = runningById.get(extId); 340 + // Find matching feature to get enabled state 341 + const feature = features.find(f => f.name.toLowerCase() === extId); 342 + const isEnabled = feature ? feature.enabled : false; 264 343 265 - const cardTitle = document.createElement('div'); 266 - cardTitle.className = 'item-card-title'; 344 + if (running && running.manifest?.builtin) { 345 + allExtensions.push({ 346 + ...running, 347 + source: 'builtin', 348 + isRunning: true, 349 + enabled: isEnabled 350 + }); 351 + } else { 352 + // Extension not running - show it as disabled 353 + allExtensions.push({ 354 + id: extId, 355 + manifest: { 356 + id: extId, 357 + name: extId.charAt(0).toUpperCase() + extId.slice(1), 358 + shortname: extId, 359 + builtin: true 360 + }, 361 + source: 'builtin', 362 + isRunning: false, 363 + enabled: isEnabled 364 + }); 365 + } 366 + }); 267 367 268 - const nameSpan = document.createElement('span'); 269 - nameSpan.textContent = manifest.name || ext.id; 270 - cardTitle.appendChild(nameSpan); 368 + // Add datastore extensions (external) 369 + datastoreExts.forEach(ext => { 370 + const running = runningById.get(ext.id); 371 + allExtensions.push({ 372 + id: ext.id, 373 + manifest: { 374 + id: ext.id, 375 + name: ext.name, 376 + shortname: JSON.parse(ext.metadata || '{}').shortname || ext.id, 377 + description: ext.description, 378 + version: ext.version, 379 + builtin: ext.builtin === 1 380 + }, 381 + path: ext.path, 382 + source: 'datastore', 383 + isRunning: !!running, 384 + enabled: ext.enabled === 1, 385 + status: ext.status, 386 + lastError: ext.lastError 387 + }); 388 + }); 271 389 272 - if (manifest.version) { 273 - const versionSpan = document.createElement('span'); 274 - versionSpan.className = 'extension-version'; 275 - versionSpan.textContent = `v${manifest.version}`; 276 - versionSpan.style.cssText = 'margin-left: 8px; font-size: 11px; color: var(--text-tertiary);'; 277 - cardTitle.appendChild(versionSpan); 390 + if (allExtensions.length === 0) { 391 + const empty = document.createElement('div'); 392 + empty.className = 'help-text'; 393 + empty.textContent = 'No extensions installed. Click "Add Extension" to install one.'; 394 + listContainer.appendChild(empty); 395 + return; 278 396 } 279 397 280 - if (manifest.builtin) { 281 - const builtinBadge = document.createElement('span'); 282 - builtinBadge.className = 'extension-badge'; 283 - builtinBadge.textContent = 'built-in'; 284 - builtinBadge.style.cssText = 'margin-left: 8px; font-size: 10px; padding: 2px 6px; background: var(--bg-tertiary); border-radius: 4px; color: var(--text-tertiary);'; 285 - cardTitle.appendChild(builtinBadge); 286 - } 398 + const extSection = document.createElement('div'); 399 + extSection.className = 'form-section'; 400 + 401 + const title = document.createElement('h3'); 402 + title.className = 'form-section-title'; 403 + title.textContent = 'Installed Extensions'; 404 + extSection.appendChild(title); 405 + 406 + allExtensions.forEach(ext => { 407 + const manifest = ext.manifest || {}; 408 + const isBuiltin = manifest.builtin || ext.source === 'builtin'; 409 + 410 + const card = document.createElement('div'); 411 + card.className = 'item-card'; 412 + 413 + const header = document.createElement('div'); 414 + header.className = 'item-card-header'; 415 + 416 + // Left side: checkbox + name 417 + const leftSide = document.createElement('div'); 418 + leftSide.style.cssText = 'display: flex; align-items: center; gap: 12px;'; 419 + 420 + // Enable/disable checkbox 421 + const checkbox = document.createElement('input'); 422 + checkbox.type = 'checkbox'; 423 + checkbox.checked = ext.enabled; 424 + checkbox.style.cssText = 'width: 16px; height: 16px; cursor: pointer;'; 425 + checkbox.addEventListener('change', async (e) => { 426 + const newEnabled = e.target.checked; 427 + checkbox.disabled = true; 428 + 429 + if (isBuiltin) { 430 + // Update features storage for persistence 431 + const store = openStore(appConfig.id, appConfig.defaults, false); 432 + const featuresList = store.get(appConfig.storageKeys.ITEMS) || []; 433 + const featureIndex = featuresList.findIndex(f => f.name.toLowerCase() === ext.id); 434 + if (featureIndex >= 0) { 435 + featuresList[featureIndex].enabled = newEnabled; 436 + store.set(appConfig.storageKeys.ITEMS, featuresList); 437 + } 438 + 439 + // Use the feature toggle mechanism to load/unload 440 + // This triggers the core:feature:toggle handler in app/index.js 441 + api.publish('core:feature:toggle', { 442 + featureId: ext.id, 443 + enabled: newEnabled 444 + }); 445 + } else if (ext.source === 'datastore') { 446 + // Update in datastore 447 + await api.extensions.update(ext.id, { 448 + enabled: newEnabled ? 1 : 0, 449 + status: newEnabled ? 'installed' : 'disabled' 450 + }); 287 451 288 - header.appendChild(cardTitle); 452 + // Load or unload the extension 453 + if (newEnabled) { 454 + await api.extensions.load(ext.id); 455 + } else { 456 + await api.extensions.unload(ext.id); 457 + } 458 + } 459 + 460 + checkbox.disabled = false; 461 + // Small delay to let the extension load/unload 462 + setTimeout(refreshExtensionsList, 500); 463 + }); 464 + leftSide.appendChild(checkbox); 465 + 466 + const cardTitle = document.createElement('div'); 467 + cardTitle.className = 'item-card-title'; 468 + cardTitle.style.margin = '0'; 289 469 290 - // Actions (reload button) 291 - const actions = document.createElement('div'); 292 - actions.className = 'extension-actions'; 293 - actions.style.cssText = 'display: flex; gap: 8px;'; 470 + const nameSpan = document.createElement('span'); 471 + nameSpan.textContent = manifest.name || ext.id; 472 + cardTitle.appendChild(nameSpan); 294 473 295 - const reloadBtn = document.createElement('button'); 296 - reloadBtn.textContent = 'Reload'; 297 - reloadBtn.style.cssText = ` 298 - padding: 4px 8px; 299 - font-size: 11px; 300 - background: var(--bg-tertiary); 301 - border: 1px solid var(--border-primary); 302 - border-radius: 4px; 303 - color: var(--text-secondary); 304 - cursor: pointer; 305 - `; 306 - reloadBtn.addEventListener('click', async () => { 307 - reloadBtn.textContent = 'Reloading...'; 308 - reloadBtn.disabled = true; 309 - try { 310 - const reloadResult = await api.extensions.reload(ext.id); 311 - if (reloadResult.success) { 312 - reloadBtn.textContent = 'Reloaded!'; 474 + if (manifest.version) { 475 + const versionSpan = document.createElement('span'); 476 + versionSpan.style.cssText = 'margin-left: 8px; font-size: 11px; color: var(--text-tertiary);'; 477 + versionSpan.textContent = `v${manifest.version}`; 478 + cardTitle.appendChild(versionSpan); 479 + } 480 + 481 + if (isBuiltin) { 482 + const badge = document.createElement('span'); 483 + badge.style.cssText = 'margin-left: 8px; font-size: 10px; padding: 2px 6px; background: var(--bg-tertiary); border-radius: 4px; color: var(--text-tertiary);'; 484 + badge.textContent = 'built-in'; 485 + cardTitle.appendChild(badge); 486 + } 487 + 488 + // Status indicator: running or stopped 489 + const statusBadge = document.createElement('span'); 490 + if (ext.isRunning) { 491 + statusBadge.style.cssText = 'margin-left: 8px; font-size: 10px; padding: 2px 6px; background: #22c55e; border-radius: 4px; color: white;'; 492 + statusBadge.textContent = 'running'; 493 + } else { 494 + statusBadge.style.cssText = 'margin-left: 8px; font-size: 10px; padding: 2px 6px; background: #6b7280; border-radius: 4px; color: white;'; 495 + statusBadge.textContent = 'stopped'; 496 + } 497 + cardTitle.appendChild(statusBadge); 498 + 499 + leftSide.appendChild(cardTitle); 500 + header.appendChild(leftSide); 501 + 502 + // Right side: actions 503 + const actions = document.createElement('div'); 504 + actions.style.cssText = 'display: flex; gap: 8px; align-items: center;'; 505 + 506 + // Reload button (only when running) 507 + if (ext.isRunning) { 508 + const reloadBtn = document.createElement('button'); 509 + reloadBtn.textContent = 'Reload'; 510 + reloadBtn.style.cssText = ` 511 + padding: 4px 8px; 512 + font-size: 11px; 513 + background: var(--bg-tertiary); 514 + border: 1px solid var(--border-primary); 515 + border-radius: 4px; 516 + color: var(--text-secondary); 517 + cursor: pointer; 518 + `; 519 + reloadBtn.addEventListener('click', async () => { 520 + reloadBtn.textContent = '...'; 521 + reloadBtn.disabled = true; 522 + try { 523 + const result = await api.extensions.reload(ext.id); 524 + reloadBtn.textContent = result.success ? '✓' : '✗'; 525 + } catch (err) { 526 + reloadBtn.textContent = '✗'; 527 + } 313 528 setTimeout(() => { 314 529 reloadBtn.textContent = 'Reload'; 315 530 reloadBtn.disabled = false; 531 + refreshExtensionsList(); 316 532 }, 1000); 317 - } else { 318 - reloadBtn.textContent = 'Error'; 319 - console.error('Reload failed:', reloadResult.error); 320 - setTimeout(() => { 321 - reloadBtn.textContent = 'Reload'; 322 - reloadBtn.disabled = false; 323 - }, 2000); 324 - } 325 - } catch (err) { 326 - console.error('Reload error:', err); 327 - reloadBtn.textContent = 'Error'; 328 - setTimeout(() => { 329 - reloadBtn.textContent = 'Reload'; 330 - reloadBtn.disabled = false; 331 - }, 2000); 533 + }); 534 + actions.appendChild(reloadBtn); 332 535 } 333 - }); 334 - actions.appendChild(reloadBtn); 335 536 336 - header.appendChild(actions); 337 - card.appendChild(header); 537 + // Remove button (only for external extensions) 538 + if (!isBuiltin) { 539 + const removeBtn = document.createElement('button'); 540 + removeBtn.textContent = 'Remove'; 541 + removeBtn.style.cssText = ` 542 + padding: 4px 8px; 543 + font-size: 11px; 544 + background: var(--bg-tertiary); 545 + border: 1px solid #ef4444; 546 + border-radius: 4px; 547 + color: #ef4444; 548 + cursor: pointer; 549 + `; 550 + removeBtn.addEventListener('click', async () => { 551 + if (!confirm(`Remove extension "${manifest.name || ext.id}"?`)) return; 338 552 339 - // Body with details 340 - const body = document.createElement('div'); 341 - body.className = 'item-card-body'; 553 + removeBtn.textContent = '...'; 554 + removeBtn.disabled = true; 342 555 343 - if (manifest.description) { 344 - const desc = document.createElement('div'); 345 - desc.className = 'help-text'; 346 - desc.style.marginBottom = '8px'; 347 - desc.textContent = manifest.description; 348 - body.appendChild(desc); 349 - } 556 + // Unload if running 557 + if (ext.isRunning) { 558 + await api.extensions.unload(ext.id); 559 + } 350 560 351 - // Show shortname/URL 352 - const urlInfo = document.createElement('div'); 353 - urlInfo.className = 'help-text'; 354 - urlInfo.style.cssText = 'font-family: monospace; font-size: 11px;'; 355 - urlInfo.textContent = `peek://ext/${manifest.shortname || ext.id}/`; 356 - body.appendChild(urlInfo); 561 + // Remove from datastore 562 + const result = await api.extensions.remove(ext.id); 563 + if (result.success) { 564 + refreshExtensionsList(); 565 + } else { 566 + removeBtn.textContent = 'Error'; 567 + setTimeout(() => { 568 + removeBtn.textContent = 'Remove'; 569 + removeBtn.disabled = false; 570 + }, 2000); 571 + } 572 + }); 573 + actions.appendChild(removeBtn); 574 + } 357 575 358 - card.appendChild(body); 359 - extSection.appendChild(card); 360 - }); 576 + header.appendChild(actions); 577 + card.appendChild(header); 578 + 579 + // Body 580 + const body = document.createElement('div'); 581 + body.className = 'item-card-body'; 582 + 583 + if (manifest.description) { 584 + const desc = document.createElement('div'); 585 + desc.className = 'help-text'; 586 + desc.style.marginBottom = '8px'; 587 + desc.textContent = manifest.description; 588 + body.appendChild(desc); 589 + } 590 + 591 + // Show path for external extensions 592 + if (ext.path && !isBuiltin) { 593 + const pathInfo = document.createElement('div'); 594 + pathInfo.className = 'help-text'; 595 + pathInfo.style.cssText = 'font-family: monospace; font-size: 11px; margin-bottom: 4px;'; 596 + pathInfo.textContent = ext.path; 597 + body.appendChild(pathInfo); 598 + } 599 + 600 + // Show URL 601 + const urlInfo = document.createElement('div'); 602 + urlInfo.className = 'help-text'; 603 + urlInfo.style.cssText = 'font-family: monospace; font-size: 11px;'; 604 + urlInfo.textContent = `peek://ext/${manifest.shortname || ext.id}/`; 605 + body.appendChild(urlInfo); 606 + 607 + // Show error if any 608 + if (ext.lastError) { 609 + const errorInfo = document.createElement('div'); 610 + errorInfo.style.cssText = 'margin-top: 8px; padding: 8px; background: #fef2f2; border: 1px solid #fecaca; border-radius: 4px; font-size: 12px; color: #dc2626;'; 611 + errorInfo.textContent = `Error: ${ext.lastError}`; 612 + body.appendChild(errorInfo); 613 + } 614 + 615 + card.appendChild(body); 616 + extSection.appendChild(card); 617 + }); 618 + 619 + listContainer.appendChild(extSection); 620 + } catch (err) { 621 + loading.remove(); 622 + const error = document.createElement('div'); 623 + error.className = 'help-text'; 624 + error.textContent = `Error loading extensions: ${err.message}`; 625 + listContainer.appendChild(error); 626 + } 627 + }; 361 628 362 - container.appendChild(extSection); 363 - } catch (err) { 364 - loading.remove(); 365 - const error = document.createElement('div'); 366 - error.className = 'help-text'; 367 - error.textContent = `Error loading extensions: ${err.message}`; 368 - container.appendChild(error); 369 - } 629 + // Initial load 630 + await refreshExtensionsList(); 370 631 371 632 return container; 372 633 };
+237 -1
index.js
··· 3 3 import { 4 4 app, 5 5 BrowserWindow, 6 + dialog, 6 7 globalShortcut, 7 8 ipcMain, 8 9 Menu, ··· 20 21 import { createSqlite3Persister } from 'tinybase/persisters/persister-sqlite3'; 21 22 import sqlite3 from 'sqlite3'; 22 23 import { schema, indexes, relationships, metrics } from './app/datastore/schema.js'; 24 + import unhandled from 'electron-unhandled'; 25 + 26 + // Catch unhandled errors and promise rejections without showing alert dialogs 27 + unhandled({ 28 + showDialog: false, 29 + logger: (error) => { 30 + console.error('Unhandled error:', error); 31 + } 32 + }); 23 33 24 34 const __dirname = import.meta.dirname; 25 35 ··· 471 481 }; 472 482 473 483 // Get extension filesystem path by ID 484 + // First checks built-in extensions, then datastore for external extensions 474 485 const getExtensionPath = (id) => { 475 - return extensionPaths.get(id); 486 + // Check built-in extensions first 487 + const builtinPath = extensionPaths.get(id); 488 + if (builtinPath) return builtinPath; 489 + 490 + // Check datastore for external extensions 491 + if (datastoreStore) { 492 + const ext = datastoreStore.getRow('extensions', id); 493 + if (ext && ext.path) { 494 + return ext.path; 495 + } 496 + 497 + // Also check by shortname (stored in metadata) 498 + const allExts = datastoreStore.getTable('extensions'); 499 + for (const [extId, extData] of Object.entries(allExts)) { 500 + try { 501 + const metadata = JSON.parse(extData.metadata || '{}'); 502 + if (metadata.shortname === id && extData.path) { 503 + return extData.path; 504 + } 505 + } catch (e) { 506 + // Ignore JSON parse errors 507 + } 508 + } 509 + } 510 + 511 + return null; 476 512 }; 477 513 478 514 // TODO: unhack all this trash fire ··· 1860 1896 return { success: false, error: error.message }; 1861 1897 } 1862 1898 }); 1899 + 1900 + // ==================== Extension Management ==================== 1901 + 1902 + // Open folder picker dialog for adding an extension 1903 + ipcMain.handle('extension-pick-folder', async (ev) => { 1904 + try { 1905 + const result = await dialog.showOpenDialog({ 1906 + properties: ['openDirectory'], 1907 + title: 'Select Extension Folder', 1908 + message: 'Select a folder containing a Peek extension (must have manifest.json)' 1909 + }); 1910 + 1911 + if (result.canceled || !result.filePaths.length) { 1912 + return { success: false, canceled: true }; 1913 + } 1914 + 1915 + const folderPath = result.filePaths[0]; 1916 + return { success: true, data: { path: folderPath } }; 1917 + } catch (error) { 1918 + console.error('extension-pick-folder error:', error); 1919 + return { success: false, error: error.message }; 1920 + } 1921 + }); 1922 + 1923 + // Validate an extension folder (check for manifest.json and parse it) 1924 + ipcMain.handle('extension-validate-folder', async (ev, data) => { 1925 + const { folderPath } = data; 1926 + 1927 + try { 1928 + const manifestPath = path.join(folderPath, 'manifest.json'); 1929 + 1930 + // Check if manifest exists 1931 + if (!fs.existsSync(manifestPath)) { 1932 + return { 1933 + success: false, 1934 + valid: false, 1935 + error: 'No manifest.json found in folder' 1936 + }; 1937 + } 1938 + 1939 + // Read and parse manifest 1940 + const manifestContent = fs.readFileSync(manifestPath, 'utf-8'); 1941 + let manifest; 1942 + try { 1943 + manifest = JSON.parse(manifestContent); 1944 + } catch (parseError) { 1945 + return { 1946 + success: true, 1947 + valid: false, 1948 + error: `Invalid JSON in manifest.json: ${parseError.message}`, 1949 + manifest: null 1950 + }; 1951 + } 1952 + 1953 + // Validate required fields 1954 + const errors = []; 1955 + if (!manifest.id) errors.push('Missing required field: id'); 1956 + if (!manifest.shortname) errors.push('Missing required field: shortname'); 1957 + if (!manifest.name) errors.push('Missing required field: name'); 1958 + 1959 + // Check shortname format 1960 + if (manifest.shortname && !/^[a-z0-9-]+$/.test(manifest.shortname)) { 1961 + errors.push('Invalid shortname format: must be lowercase alphanumeric with hyphens'); 1962 + } 1963 + 1964 + // Check for background script 1965 + const backgroundScript = manifest.background || 'background.js'; 1966 + const backgroundPath = path.join(folderPath, backgroundScript); 1967 + if (!fs.existsSync(backgroundPath)) { 1968 + errors.push(`Background script not found: ${backgroundScript}`); 1969 + } 1970 + 1971 + return { 1972 + success: true, 1973 + valid: errors.length === 0, 1974 + errors: errors.length > 0 ? errors : null, 1975 + manifest 1976 + }; 1977 + } catch (error) { 1978 + console.error('extension-validate-folder error:', error); 1979 + return { success: false, error: error.message }; 1980 + } 1981 + }); 1982 + 1983 + // Add extension to datastore 1984 + ipcMain.handle('extension-add', async (ev, data) => { 1985 + const { folderPath, manifest, enabled = false } = data; 1986 + 1987 + try { 1988 + const now = Date.now(); 1989 + const id = manifest?.id || `ext-${now}`; 1990 + 1991 + // Check if extension with this ID already exists 1992 + const existing = datastoreStore.getRow('extensions', id); 1993 + if (existing && Object.keys(existing).length > 0) { 1994 + return { success: false, error: `Extension with ID '${id}' already exists` }; 1995 + } 1996 + 1997 + // Add to extensions table 1998 + datastoreStore.setRow('extensions', id, { 1999 + name: manifest?.name || path.basename(folderPath), 2000 + description: manifest?.description || '', 2001 + version: manifest?.version || '0.0.0', 2002 + path: folderPath, 2003 + backgroundUrl: `peek://ext/${manifest?.shortname || id}/background.js`, 2004 + settingsUrl: manifest?.settings_url || '', 2005 + iconPath: manifest?.icon || '', 2006 + builtin: 0, 2007 + enabled: enabled ? 1 : 0, 2008 + status: enabled ? 'installed' : 'disabled', 2009 + installedAt: now, 2010 + updatedAt: now, 2011 + lastErrorAt: 0, 2012 + lastError: '', 2013 + metadata: JSON.stringify({ shortname: manifest?.shortname || id }) 2014 + }); 2015 + 2016 + console.log(`Extension added: ${id} at ${folderPath}`); 2017 + return { success: true, data: { id } }; 2018 + } catch (error) { 2019 + console.error('extension-add error:', error); 2020 + return { success: false, error: error.message }; 2021 + } 2022 + }); 2023 + 2024 + // Remove extension from datastore 2025 + ipcMain.handle('extension-remove', async (ev, data) => { 2026 + const { id } = data; 2027 + 2028 + try { 2029 + const existing = datastoreStore.getRow('extensions', id); 2030 + if (!existing || Object.keys(existing).length === 0) { 2031 + return { success: false, error: `Extension '${id}' not found` }; 2032 + } 2033 + 2034 + // Don't allow removing builtin extensions 2035 + if (existing.builtin === 1) { 2036 + return { success: false, error: 'Cannot remove built-in extensions' }; 2037 + } 2038 + 2039 + datastoreStore.delRow('extensions', id); 2040 + console.log(`Extension removed: ${id}`); 2041 + return { success: true }; 2042 + } catch (error) { 2043 + console.error('extension-remove error:', error); 2044 + return { success: false, error: error.message }; 2045 + } 2046 + }); 2047 + 2048 + // Update extension (enable/disable, update error status, etc.) 2049 + ipcMain.handle('extension-update', async (ev, data) => { 2050 + const { id, updates } = data; 2051 + 2052 + try { 2053 + const existing = datastoreStore.getRow('extensions', id); 2054 + if (!existing || Object.keys(existing).length === 0) { 2055 + return { success: false, error: `Extension '${id}' not found` }; 2056 + } 2057 + 2058 + // Apply updates 2059 + const updatedRow = { ...existing, ...updates, updatedAt: Date.now() }; 2060 + datastoreStore.setRow('extensions', id, updatedRow); 2061 + 2062 + console.log(`Extension updated: ${id}`, updates); 2063 + return { success: true, data: updatedRow }; 2064 + } catch (error) { 2065 + console.error('extension-update error:', error); 2066 + return { success: false, error: error.message }; 2067 + } 2068 + }); 2069 + 2070 + // Get all extensions from datastore 2071 + ipcMain.handle('extension-get-all', async (ev) => { 2072 + try { 2073 + const table = datastoreStore.getTable('extensions'); 2074 + const extensions = Object.entries(table).map(([id, row]) => ({ id, ...row })); 2075 + return { success: true, data: extensions }; 2076 + } catch (error) { 2077 + console.error('extension-get-all error:', error); 2078 + return { success: false, error: error.message }; 2079 + } 2080 + }); 2081 + 2082 + // Get single extension from datastore 2083 + ipcMain.handle('extension-get', async (ev, data) => { 2084 + const { id } = data; 2085 + 2086 + try { 2087 + const row = datastoreStore.getRow('extensions', id); 2088 + if (!row || Object.keys(row).length === 0) { 2089 + return { success: false, error: `Extension '${id}' not found` }; 2090 + } 2091 + return { success: true, data: { id, ...row } }; 2092 + } catch (error) { 2093 + console.error('extension-get error:', error); 2094 + return { success: false, error: error.message }; 2095 + } 2096 + }); 2097 + 2098 + // ==================== End Extension Management ==================== 1863 2099 1864 2100 const modWindow = (bw, params) => { 1865 2101 if (params.action == 'close') {
+1
package.json
··· 33 33 "help": "electron --help" 34 34 }, 35 35 "dependencies": { 36 + "electron-unhandled": "^5.0.0", 36 37 "lil-gui": "^0.19.2", 37 38 "sqlite3": "^5.1.7", 38 39 "tinybase": "^6.7.2"
+75
preload.js
··· 585 585 resolve({ success: false, error: 'Timeout getting manifest' }); 586 586 }, 5000); 587 587 }); 588 + }, 589 + 590 + // ===== Datastore-backed extension management (persisted) ===== 591 + 592 + /** 593 + * Open folder picker dialog to select an extension folder 594 + * @returns {Promise<{success: boolean, canceled?: boolean, data?: {path: string}, error?: string}>} 595 + */ 596 + pickFolder: () => { 597 + return ipcRenderer.invoke('extension-pick-folder'); 598 + }, 599 + 600 + /** 601 + * Validate an extension folder (checks manifest.json) 602 + * @param {string} folderPath - Path to extension folder 603 + * @returns {Promise<{success: boolean, valid: boolean, errors?: string[], manifest?: object, error?: string}>} 604 + */ 605 + validateFolder: (folderPath) => { 606 + return ipcRenderer.invoke('extension-validate-folder', { folderPath }); 607 + }, 608 + 609 + /** 610 + * Add extension to datastore (persisted) 611 + * @param {string} folderPath - Path to extension folder 612 + * @param {object} manifest - Parsed manifest (can be partial/invalid) 613 + * @param {boolean} enabled - Whether to enable immediately 614 + * @returns {Promise<{success: boolean, data?: {id: string}, error?: string}>} 615 + */ 616 + add: (folderPath, manifest, enabled = false) => { 617 + if (!api.extensions._hasPermission()) { 618 + return Promise.resolve({ success: false, error: 'Permission denied' }); 619 + } 620 + return ipcRenderer.invoke('extension-add', { folderPath, manifest, enabled }); 621 + }, 622 + 623 + /** 624 + * Remove extension from datastore 625 + * @param {string} id - Extension ID 626 + * @returns {Promise<{success: boolean, error?: string}>} 627 + */ 628 + remove: (id) => { 629 + if (!api.extensions._hasPermission()) { 630 + return Promise.resolve({ success: false, error: 'Permission denied' }); 631 + } 632 + return ipcRenderer.invoke('extension-remove', { id }); 633 + }, 634 + 635 + /** 636 + * Update extension in datastore (enable/disable, etc.) 637 + * @param {string} id - Extension ID 638 + * @param {object} updates - Fields to update 639 + * @returns {Promise<{success: boolean, data?: object, error?: string}>} 640 + */ 641 + update: (id, updates) => { 642 + if (!api.extensions._hasPermission()) { 643 + return Promise.resolve({ success: false, error: 'Permission denied' }); 644 + } 645 + return ipcRenderer.invoke('extension-update', { id, updates }); 646 + }, 647 + 648 + /** 649 + * Get all extensions from datastore (includes non-running) 650 + * @returns {Promise<{success: boolean, data?: Array, error?: string}>} 651 + */ 652 + getAll: () => { 653 + return ipcRenderer.invoke('extension-get-all'); 654 + }, 655 + 656 + /** 657 + * Get single extension from datastore 658 + * @param {string} id - Extension ID 659 + * @returns {Promise<{success: boolean, data?: object, error?: string}>} 660 + */ 661 + get: (id) => { 662 + return ipcRenderer.invoke('extension-get', { id }); 588 663 } 589 664 }; 590 665
+67
yarn.lock
··· 445 445 "@electron/fuses": "npm:^1.8.0" 446 446 electron: "npm:^35.7.5" 447 447 electron-builder: "npm:26.0.12" 448 + electron-unhandled: "npm:^5.0.0" 448 449 lil-gui: "npm:^0.19.2" 449 450 sqlite3: "npm:^5.1.7" 450 451 tinybase: "npm:^6.7.2" ··· 956 957 languageName: node 957 958 linkType: hard 958 959 960 + "clean-stack@npm:^5.2.0": 961 + version: 5.3.0 962 + resolution: "clean-stack@npm:5.3.0" 963 + dependencies: 964 + escape-string-regexp: "npm:5.0.0" 965 + checksum: 10c0/1aa8b6772eed1f678a9dcf6e02c74c59f26b6fdad26eaaca1dc6a367ff19c924315836b6143484c2686366758e05396f1ac0f32aaa70481b11d8e23790947ca0 966 + languageName: node 967 + linkType: hard 968 + 959 969 "cli-cursor@npm:^3.1.0": 960 970 version: 3.1.0 961 971 resolution: "cli-cursor@npm:3.1.0" ··· 1344 1354 languageName: node 1345 1355 linkType: hard 1346 1356 1357 + "electron-is-dev@npm:^3.0.1": 1358 + version: 3.0.1 1359 + resolution: "electron-is-dev@npm:3.0.1" 1360 + checksum: 10c0/80d37d61d44b8b7af0af90eab622b09e0f165afbc2560297c760356efa93fcb54dbbd13d4d6522da6474e261d630b76b804c86385564a1f3062d28fc59c340bd 1361 + languageName: node 1362 + linkType: hard 1363 + 1347 1364 "electron-publish@npm:26.0.11": 1348 1365 version: 26.0.11 1349 1366 resolution: "electron-publish@npm:26.0.11" ··· 1357 1374 lazy-val: "npm:^1.0.5" 1358 1375 mime: "npm:^2.5.2" 1359 1376 checksum: 10c0/5bf626709ca35bf4f29b1ee5d7d8c7062687f07f249773cf267bfc09409802fd6594d87a068a37421969b6461855b42b979eb296e416adb719921488448ee27e 1377 + languageName: node 1378 + linkType: hard 1379 + 1380 + "electron-unhandled@npm:^5.0.0": 1381 + version: 5.0.0 1382 + resolution: "electron-unhandled@npm:5.0.0" 1383 + dependencies: 1384 + clean-stack: "npm:^5.2.0" 1385 + electron-is-dev: "npm:^3.0.1" 1386 + ensure-error: "npm:^4.0.0" 1387 + lodash.debounce: "npm:^4.0.8" 1388 + serialize-error: "npm:^11.0.3" 1389 + checksum: 10c0/5d2d6698417103c7e684f73777d96f020f6d041912ee263162e94bdc13aac24b9881ec279f8c09c1b40489703d986f2700947f82fea26d87a16fae28ed9bce34 1360 1390 languageName: node 1361 1391 linkType: hard 1362 1392 ··· 1414 1444 languageName: node 1415 1445 linkType: hard 1416 1446 1447 + "ensure-error@npm:^4.0.0": 1448 + version: 4.0.0 1449 + resolution: "ensure-error@npm:4.0.0" 1450 + checksum: 10c0/79a986b54574c221923fafb00e25f86963a73195d5dd0d67d0c9ca9ab35a7cbc10b634e6954d1912206b9e18eabbc4ebd18af9200e521b2a603c3d388b98fa08 1451 + languageName: node 1452 + linkType: hard 1453 + 1417 1454 "env-paths@npm:^2.2.0": 1418 1455 version: 2.2.1 1419 1456 resolution: "env-paths@npm:2.2.1" ··· 1474 1511 version: 3.1.1 1475 1512 resolution: "escalade@npm:3.1.1" 1476 1513 checksum: 10c0/afd02e6ca91ffa813e1108b5e7756566173d6bc0d1eb951cb44d6b21702ec17c1cf116cfe75d4a2b02e05acb0b808a7a9387d0d1ca5cf9c04ad03a8445c3e46d 1514 + languageName: node 1515 + linkType: hard 1516 + 1517 + "escape-string-regexp@npm:5.0.0": 1518 + version: 5.0.0 1519 + resolution: "escape-string-regexp@npm:5.0.0" 1520 + checksum: 10c0/6366f474c6f37a802800a435232395e04e9885919873e382b157ab7e8f0feb8fed71497f84a6f6a81a49aab41815522f5839112bd38026d203aea0c91622df95 1477 1521 languageName: node 1478 1522 linkType: hard 1479 1523 ··· 2332 2376 version: 0.19.2 2333 2377 resolution: "lil-gui@npm:0.19.2" 2334 2378 checksum: 10c0/382062222f11393ca2748ac7cdd9af0dd2711530f0c85a070470b48902ee51c66a1ddfec0928570870c940fe2b790a7e557e207b774d1cee0e3718e1b1cd32c3 2379 + languageName: node 2380 + linkType: hard 2381 + 2382 + "lodash.debounce@npm:^4.0.8": 2383 + version: 4.0.8 2384 + resolution: "lodash.debounce@npm:4.0.8" 2385 + checksum: 10c0/762998a63e095412b6099b8290903e0a8ddcb353ac6e2e0f2d7e7d03abd4275fe3c689d88960eb90b0dde4f177554d51a690f22a343932ecbc50a5d111849987 2335 2386 languageName: node 2336 2387 linkType: hard 2337 2388 ··· 3302 3353 languageName: node 3303 3354 linkType: hard 3304 3355 3356 + "serialize-error@npm:^11.0.3": 3357 + version: 11.0.3 3358 + resolution: "serialize-error@npm:11.0.3" 3359 + dependencies: 3360 + type-fest: "npm:^2.12.2" 3361 + checksum: 10c0/7263603883b8936650819f0fd5150d41427b317432678b21722c54b85367ae15b8552865eb7f3f39ba71a32a003730a2e2e971e6909431eb54db70a3ef8eca17 3362 + languageName: node 3363 + linkType: hard 3364 + 3305 3365 "serialize-error@npm:^7.0.1": 3306 3366 version: 7.0.1 3307 3367 resolution: "serialize-error@npm:7.0.1" ··· 3790 3850 version: 0.13.1 3791 3851 resolution: "type-fest@npm:0.13.1" 3792 3852 checksum: 10c0/0c0fa07ae53d4e776cf4dac30d25ad799443e9eef9226f9fddbb69242db86b08584084a99885cfa5a9dfe4c063ebdc9aa7b69da348e735baede8d43f1aeae93b 3853 + languageName: node 3854 + linkType: hard 3855 + 3856 + "type-fest@npm:^2.12.2": 3857 + version: 2.19.0 3858 + resolution: "type-fest@npm:2.19.0" 3859 + checksum: 10c0/a5a7ecf2e654251613218c215c7493574594951c08e52ab9881c9df6a6da0aeca7528c213c622bc374b4e0cb5c443aa3ab758da4e3c959783ce884c3194e12cb 3793 3860 languageName: node 3794 3861 linkType: hard 3795 3862