experiments in a post-browser web
10
fork

Configure Feed

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

refactor(settings): migrate api.extensions consumers to api.features strict registry

The app/index.js feature toggle handler and the Settings > Features
list both talked through api.extensions.*, which routes to the v1
tile:extensions:* handlers backed by the legacy SQLite extensions
table plus a runtime windowList shim. Both surfaces collapse onto the
unified feature registry via api.features.*.

Notable behavioural changes callers absorb:

- Add Feature button in Settings > Features is removed. Arbitrary
folder-add wrote to the legacy extensions SQL table, which the
unified registry does not read. Users install/develop features
through the dedicated features-manager UI: tile:features:install
for atproto installs, tile:features:dev-create for local scaffolds.

- Reload button is shown unconditionally now rather than running-only.
Without tile:extensions:windowList we lose the isRunning hint; the
dev-reload IPC reports no active tiles gracefully when a feature
is not currently loaded.

- Datastore-external features toggle via features.enable / .disable
on the registry-backed path. api.extensions.load / .unload never
existed on the preload surface; that code path was already dead.

- Response shapes differ: api.features.list returns { entries: [...] }
with full FeatureRegistryEntry shape, where api.extensions.list
returned { success, data: [...] } with runtime {id, manifest, status}.
Consumer code adapts to the registry entry shape: entry.source.type,
entry.disabled, entry.name.

- browseBtn folder picker switches from api.extensions.pickFolder
returning { success, data: { path } } to api.features.devPickDirectory
returning { path } on success or { canceled: true } on cancel.

+130 -270
+53 -57
app/index.js
··· 179 179 } 180 180 }); 181 181 182 - // Reload extension command (uses main process IPC) 182 + // Reload feature command (uses main process IPC) 183 183 api.commands.register({ 184 - name: 'extension reload', 185 - description: 'Reload an extension by name', 184 + name: 'feature reload', 185 + description: 'Reload a feature by id', 186 186 execute: async (ctx) => { 187 - const extName = ctx.search?.trim(); 188 - if (!extName) { 189 - log('core', 'extension reload: no extension name provided'); 187 + const featureId = ctx.search?.trim(); 188 + if (!featureId) { 189 + log('core', 'feature reload: no feature id provided'); 190 190 return; 191 191 } 192 192 193 - log('core', `Reloading extension: ${extName}`); 194 - const result = await api.extensions.reload(extName.toLowerCase()); 195 - if (result.success) { 196 - log('core', `Extension reloaded: ${extName}`); 193 + log('core', `Reloading feature: ${featureId}`); 194 + const result = await api.features.devReload(featureId.toLowerCase()); 195 + if (result && !result.error) { 196 + log('core', `Feature reloaded: ${featureId}`); 197 197 } else { 198 - log.error('core', `Failed to reload extension: ${result.error}`); 198 + log.error('core', `Failed to reload feature: ${result?.error || 'unknown error'}`); 199 199 } 200 200 } 201 201 }); 202 202 203 - // List extensions command (uses main process IPC) 203 + // List features command (uses main process IPC) 204 204 api.commands.register({ 205 - name: 'extensions', 206 - description: 'List running extensions', 207 - execute: async (ctx) => { 208 - const listResult = await api.extensions.list(); 209 - if (listResult.success && listResult.data) { 210 - log('core', 'Running extensions:'); 211 - listResult.data.forEach(ext => { 212 - const manifest = ext.manifest || {}; 213 - log('core', ` - ${manifest.name || ext.id} (${ext.id}) v${manifest.version || '?'}`); 205 + name: 'features', 206 + description: 'List installed features', 207 + execute: async () => { 208 + const listResult = await api.features.list(); 209 + const entries = listResult?.entries || []; 210 + if (entries.length > 0) { 211 + log('core', 'Installed features:'); 212 + entries.forEach(entry => { 213 + const version = entry.source?.version || '?'; 214 + log('core', ` - ${entry.name || entry.id} (${entry.id}) v${version}`); 214 215 }); 215 216 } else { 216 - log('core', 'No extensions running'); 217 + log('core', 'No features installed'); 217 218 } 218 219 219 - // Open settings to Extensions section 220 + // Open settings to Features section 220 221 const p = prefs(); 221 222 await openSettingsWindow(p); 222 223 } ··· 448 449 } 449 450 }); 450 451 451 - // ---- Extension Commands ---- 452 + // ---- Feature Commands ---- 452 453 453 454 api.commands.register({ 454 - name: 'reload extension', 455 - description: 'Reload an external extension by ID', 455 + name: 'reload feature', 456 + description: 'Reload an installed feature by ID', 456 457 execute: async (ctx) => { 457 - // Get running extensions to show which can be reloaded 458 - const result = await api.extensions.list(); 459 - if (!result.success) { 460 - return { output: 'Failed to get extensions list', mimeType: 'text/plain' }; 461 - } 462 - 463 - // Filter to only external extensions (not consolidated) 464 - const external = result.data.running?.filter(ext => !ext.isConsolidated) || []; 458 + // Get features list to show which can be reloaded 459 + const result = await api.features.list(); 460 + const entries = result?.entries || []; 461 + // Filter out builtins — only externally-installed features can usefully be reloaded. 462 + const external = entries.filter(e => e.source?.type !== 'builtin'); 465 463 if (external.length === 0) { 466 - return { output: 'No external extensions running to reload', mimeType: 'text/plain' }; 464 + return { output: 'No external features installed to reload', mimeType: 'text/plain' }; 467 465 } 468 466 469 - // If input provided, try to reload that extension 467 + // If input provided, try to reload that feature 470 468 const input = ctx?.input?.trim(); 471 469 if (input) { 472 - const ext = external.find(e => e.id === input || e.id.includes(input)); 473 - if (ext) { 474 - const reloadResult = await api.extensions.reload(ext.id); 475 - if (reloadResult.success) { 476 - return { output: `Reloaded extension: ${ext.id}`, mimeType: 'text/plain' }; 470 + const feature = external.find(e => e.id === input || e.id.includes(input)); 471 + if (feature) { 472 + const reloadResult = await api.features.devReload(feature.id); 473 + if (reloadResult && !reloadResult.error) { 474 + return { output: `Reloaded feature: ${feature.id}`, mimeType: 'text/plain' }; 477 475 } 478 - return { output: `Failed to reload: ${reloadResult.error}`, mimeType: 'text/plain' }; 476 + return { output: `Failed to reload: ${reloadResult?.error || 'unknown error'}`, mimeType: 'text/plain' }; 479 477 } 480 - return { output: `Extension not found: ${input}`, mimeType: 'text/plain' }; 478 + return { output: `Feature not found: ${input}`, mimeType: 'text/plain' }; 481 479 } 482 480 483 - // No input - show available extensions 481 + // No input - show available features 484 482 const list = external.map(e => e.id).join('\n'); 485 - return { output: `External extensions (type ID to reload):\n${list}`, mimeType: 'text/plain' }; 483 + return { output: `External features (type ID to reload):\n${list}`, mimeType: 'text/plain' }; 486 484 } 487 485 }); 488 486 ··· 780 778 }); 781 779 782 780 // Feature enable/disable handler 783 - // Extensions are now managed by main process ExtensionManager via IPC 781 + // Features are managed by main process via the strict tile:features:* IPC. 784 782 api.subscribe(topicFeatureToggle, async msg => { 785 783 log('core', 'feature toggle', msg) 786 784 787 - // Find feature by ID (UUID) or by name (extension ID like "groups") 785 + // Find feature by ID (UUID) or by name (feature ID like "groups") 788 786 const f = features().find(f => 789 787 f.id == msg.featureId || 790 788 f.name.toLowerCase() === msg.featureId?.toLowerCase() ··· 792 790 if (f) { 793 791 log('core', 'feature toggle', f); 794 792 795 - // Check if this feature is backed by an extension 796 - const extId = f.name.toLowerCase(); 797 - const isExtension = builtinExtensions.includes(extId); 793 + // Check if this feature is backed by a tile-feature 794 + const featureId = f.name.toLowerCase(); 795 + const isFeatureTile = builtinExtensions.includes(featureId); 798 796 799 797 if (msg.enabled == false) { 800 798 log('core', 'disabling', f.name); 801 - if (isExtension) { 802 - // Use main process IPC to unload extension 803 - await api.extensions.unload(extId); 799 + if (isFeatureTile) { 800 + await api.features.disable(featureId); 804 801 } else { 805 - log('core', 'uninit non-extension feature not implemented:', f.name); 802 + log('core', 'uninit non-feature toggle not implemented:', f.name); 806 803 } 807 804 } 808 805 else if (msg.enabled == true) { 809 806 log('core', 'enabling', f.name); 810 - if (isExtension) { 811 - // Use main process IPC to load extension 812 - await api.extensions.load(extId); 807 + if (isFeatureTile) { 808 + await api.features.enable(featureId); 813 809 } else { 814 810 initFeature(f); 815 811 }
+77 -213
app/settings/settings.js
··· 180 180 browseBtn.textContent = 'Browse'; 181 181 browseBtn.addEventListener('click', async () => { 182 182 try { 183 - const result = await api.extensions.pickFolder(); 184 - if (result.success && result.data && result.data.path) { 185 - input.value = result.data.path; 186 - onChange(result.data.path); 183 + const result = await api.features.devPickDirectory(); 184 + if (result && !result.canceled && !result.error && result.path) { 185 + input.value = result.path; 186 + onChange(result.path); 187 187 } 188 188 } catch (err) { 189 189 console.error('Folder picker error:', err); ··· 1733 1733 return container; 1734 1734 }; 1735 1735 1736 - // Render features settings (Peek's own extensions: cmd, groups, peeks, slides, windows) 1736 + // Render features settings (Peek's own features: cmd, groups, peeks, slides, windows) 1737 1737 const renderExtensionsSettings = async () => { 1738 1738 const container = document.createElement('div'); 1739 1739 1740 - // Add Extension button at top 1741 - const addSection = document.createElement('div'); 1742 - addSection.className = 'form-section'; 1743 - addSection.style.marginBottom = '24px'; 1740 + // Note: feature install (Add Feature) is now handled by the dedicated 1741 + // features-manager UI, which uses tile:features:install (atproto) + 1742 + // tile:features:dev-create (local scaffold). The settings pane only 1743 + // surfaces enable/disable/reload/remove for already-registered features. 1744 + const installNote = document.createElement('div'); 1745 + installNote.className = 'help-text'; 1746 + installNote.style.cssText = 'margin-bottom: 16px;'; 1747 + installNote.textContent = 'To install or develop new features, open the Features Manager.'; 1748 + container.appendChild(installNote); 1744 1749 1745 - const addBtn = document.createElement('button'); 1746 - addBtn.textContent = '+ Add Feature'; 1747 - addBtn.style.cssText = ` 1748 - padding: 10px 16px; 1749 - font-size: 13px; 1750 - background: var(--bg-tertiary); 1751 - border: 1px solid var(--border-primary); 1752 - border-radius: 6px; 1753 - color: var(--text-primary); 1754 - cursor: pointer; 1755 - width: 100%; 1756 - `; 1757 - addBtn.addEventListener('mouseenter', () => { 1758 - addBtn.style.background = 'var(--bg-hover)'; 1759 - }); 1760 - addBtn.addEventListener('mouseleave', () => { 1761 - addBtn.style.background = 'var(--bg-tertiary)'; 1762 - }); 1763 - addBtn.addEventListener('click', async () => { 1764 - addBtn.textContent = 'Selecting folder...'; 1765 - addBtn.disabled = true; 1766 - 1767 - try { 1768 - // Open folder picker 1769 - const pickResult = await api.extensions.pickFolder(); 1770 - if (!pickResult.success || pickResult.canceled) { 1771 - addBtn.textContent = '+ Add Feature'; 1772 - addBtn.disabled = false; 1773 - return; 1774 - } 1775 - 1776 - const folderPath = pickResult.data.path; 1777 - addBtn.textContent = 'Validating...'; 1778 - 1779 - // Validate folder 1780 - const validateResult = await api.extensions.validateFolder(folderPath); 1781 - 1782 - // Add even if invalid (disabled), so user can fix and retry 1783 - // Handle both Electron format (data.manifest) and flat format 1784 - const manifest = validateResult.data?.manifest || validateResult.manifest || {}; 1785 - const isValid = validateResult.success && !validateResult.error; 1786 - const validationError = !isValid ? (validateResult.error || 'Unknown validation error') : null; 1787 - 1788 - addBtn.textContent = 'Adding...'; 1789 - 1790 - // Add to datastore (disabled if invalid, with error message) 1791 - const addResult = await api.extensions.add(folderPath, manifest, false, validationError); 1792 - 1793 - if (addResult.success) { 1794 - addBtn.textContent = isValid ? 'Added!' : 'Added (disabled - has errors)'; 1795 - 1796 - // Refresh the list 1797 - setTimeout(() => { 1798 - addBtn.textContent = '+ Add Feature'; 1799 - addBtn.disabled = false; 1800 - refreshExtensionsList(); 1801 - }, 1500); 1802 - } else { 1803 - addBtn.textContent = `Error: ${addResult.error}`; 1804 - setTimeout(() => { 1805 - addBtn.textContent = '+ Add Feature'; 1806 - addBtn.disabled = false; 1807 - }, 3000); 1808 - } 1809 - } catch (err) { 1810 - console.error('Add extension error:', err); 1811 - addBtn.textContent = 'Error adding extension'; 1812 - setTimeout(() => { 1813 - addBtn.textContent = '+ Add Feature'; 1814 - addBtn.disabled = false; 1815 - }, 2000); 1816 - } 1817 - }); 1818 - addSection.appendChild(addBtn); 1819 - container.appendChild(addSection); 1820 - 1821 - // Extensions list container (for refresh) 1750 + // Features list container (for refresh) 1822 1751 const listContainer = document.createElement('div'); 1823 1752 listContainer.id = 'extensions-list-container'; 1824 1753 container.appendChild(listContainer); 1825 1754 1826 - // Function to refresh extensions list 1755 + // Function to refresh features list 1827 1756 const refreshExtensionsList = async () => { 1828 1757 listContainer.innerHTML = ''; 1829 1758 1830 1759 const loading = document.createElement('div'); 1831 1760 loading.className = 'help-text'; 1832 - loading.textContent = 'Loading extensions...'; 1761 + loading.textContent = 'Loading features...'; 1833 1762 listContainer.appendChild(loading); 1834 1763 1835 1764 try { 1836 - // Get features list to check enabled state for builtins 1837 - // Use pre-loaded core store if available, otherwise load fresh 1765 + // The features storage list still drives builtin enable/disable persistence 1766 + // (the toggle handler in app/index.js subscribes to core:feature:toggle and 1767 + // reads from this list to decide whether the feature is currently enabled). 1838 1768 const coreStore = _coreStore || await createDatastoreStore('core', appConfig.defaults); 1839 1769 const features = coreStore.get(appConfig.storageKeys.ITEMS) || []; 1840 1770 1841 - // Get running, all registered (discovered), and datastore extensions 1842 - const [runningResult, registeredResult, datastoreResult] = await Promise.all([ 1843 - api.extensions.list(), 1844 - api.extensions.listAllRegistered(), 1845 - api.extensions.getAll() 1771 + // Pull non-builtin features (default filter) and builtins separately, 1772 + // then merge. tile:features:list filters out builtins unless an 1773 + // explicit sourceType is passed, so we issue both calls. 1774 + const [nonBuiltinResult, builtinResult] = await Promise.all([ 1775 + api.features.list(), 1776 + api.features.list('builtin'), 1846 1777 ]); 1847 1778 1848 1779 loading.remove(); 1849 1780 1850 - const runningExts = runningResult.success ? runningResult.data || [] : []; 1851 - const registeredExts = registeredResult.success ? registeredResult.data || [] : []; 1852 - const datastoreExts = datastoreResult.success ? datastoreResult.data || [] : []; 1853 - 1854 - // Merge: builtin registered + datastore external 1855 - // Registered extensions include ALL discovered builtins (running or not) 1856 - const runningById = new Map(runningExts.map(e => [e.id, e])); 1781 + const allEntries = [ 1782 + ...(nonBuiltinResult?.entries || []), 1783 + ...(builtinResult?.entries || []), 1784 + ]; 1857 1785 1858 - // Build combined list 1859 - const allExtensions = []; 1860 - 1861 - // Use all registered (discovered) extensions as builtins, excluding datastore externals 1862 - const datastoreIds = new Set(datastoreExts.map(e => e.id)); 1863 - const builtinExtIds = registeredExts 1864 - .filter(e => !datastoreIds.has(e.id)) 1865 - .map(e => e.id); 1866 - 1867 - // Build a map of registered extensions for manifest lookup 1868 - const registeredById = new Map(registeredExts.map(e => [e.id, e])); 1869 - 1870 - // Add builtin extensions (whether running or not) 1871 - builtinExtIds.forEach(extId => { 1872 - const running = runningById.get(extId); 1873 - const registered = registeredById.get(extId); 1874 - // Find matching feature to get enabled state 1875 - // For builtins: if running, they're enabled. Otherwise check stored state, default true. 1876 - const feature = features.find(f => f.name.toLowerCase() === extId); 1877 - const isEnabled = running ? true : (feature ? feature.enabled !== false : true); 1786 + const allExtensions = allEntries.map(entry => { 1787 + const isBuiltin = entry.source?.type === 'builtin'; 1788 + const feature = features.find(f => f.name.toLowerCase() === entry.id); 1789 + // Builtins respect the local features-list "enabled" flag 1790 + // (defaults true). Non-builtins use the registry's `disabled` flag. 1791 + const enabled = isBuiltin 1792 + ? (feature ? feature.enabled !== false : true) 1793 + : !entry.disabled; 1878 1794 1879 - // Use manifest from running or registered data (registered always has it from discovery) 1880 - const manifest = (running && running.manifest) || (registered && registered.manifest) || { 1881 - id: extId, 1882 - name: extId.charAt(0).toUpperCase() + extId.slice(1), 1883 - shortname: extId, 1884 - builtin: true 1795 + // The registry stores a flat entry — adapt to the manifest-shaped 1796 + // object the renderer below expects. 1797 + const manifest = { 1798 + id: entry.id, 1799 + name: entry.name || entry.id, 1800 + shortname: entry.id, 1801 + description: '', 1802 + version: entry.source?.version || '', 1803 + builtin: isBuiltin, 1804 + author: entry.source?.publisherName || entry.source?.publisher || '', 1885 1805 }; 1886 1806 1887 - allExtensions.push({ 1888 - ...(running || {}), 1889 - id: extId, 1807 + return { 1808 + id: entry.id, 1890 1809 manifest, 1891 - source: 'builtin', 1892 - isRunning: !!running, 1893 - enabled: isEnabled 1894 - }); 1895 - }); 1896 - 1897 - // Add datastore extensions (external) 1898 - // Handle both Electron format (flat fields) and Tauri format (with manifest object) 1899 - datastoreExts.forEach(ext => { 1900 - const running = runningById.get(ext.id); 1901 - // Tauri returns manifest object, Electron returns flat fields 1902 - const manifestData = ext.manifest || {}; 1903 - const name = ext.name || manifestData.name || ext.id; 1904 - const description = ext.description || manifestData.description || ''; 1905 - const version = ext.version || manifestData.version || ''; 1906 - const shortname = (ext.metadata ? JSON.parse(ext.metadata).shortname : manifestData.shortname) || ext.id; 1907 - const builtin = ext.builtin === 1 || ext.builtin === true || manifestData.builtin; 1908 - const author = ext.author || manifestData.author || ''; 1909 - // Handle both snake_case (Tauri JSON) and direct field names 1910 - const lastError = ext.lastError || ext.last_error || null; 1911 - 1912 - // If running, merge schemas/storageKeys/defaults from running manifest 1913 - const runningManifest = running?.manifest || {}; 1914 - allExtensions.push({ 1915 - id: ext.id, 1916 - manifest: { 1917 - id: ext.id, 1918 - name, 1919 - shortname, 1920 - description, 1921 - version, 1922 - builtin, 1923 - author: author || runningManifest.author, 1924 - schemas: runningManifest.schemas, 1925 - storageKeys: runningManifest.storageKeys, 1926 - defaults: runningManifest.defaults, 1927 - labels: runningManifest.labels 1928 - }, 1929 - path: ext.path, 1930 - source: 'datastore', 1931 - isRunning: !!running, 1932 - enabled: ext.enabled === 1 || ext.enabled === true, 1933 - status: ext.status, 1934 - lastError 1935 - }); 1810 + path: entry.path, 1811 + source: isBuiltin ? 'builtin' : 'datastore', 1812 + // Without windowList we can't tell whether the feature has a live 1813 + // tile window — surface it as not-running so we don't show a 1814 + // misleading Reload button. Reload still works on demand via the 1815 + // dev-reload IPC, which gracefully reports "no active tiles". 1816 + isRunning: false, 1817 + enabled, 1818 + status: entry.disabled ? 'disabled' : 'installed', 1819 + lastError: null, 1820 + }; 1936 1821 }); 1937 1822 1938 1823 if (allExtensions.length === 0) { ··· 1996 1881 enabled: newEnabled 1997 1882 }); 1998 1883 } else if (ext.source === 'datastore') { 1999 - // Load or unload the extension first 2000 - let loadResult = { success: true }; 2001 - if (newEnabled) { 2002 - loadResult = await api.extensions.load(ext.id); 2003 - } else { 2004 - loadResult = await api.extensions.unload(ext.id); 2005 - } 2006 - 2007 - if (loadResult.success) { 2008 - // Update in datastore only if load/unload succeeded 2009 - await api.extensions.update(ext.id, { 2010 - enabled: newEnabled ? 1 : 0, 2011 - status: newEnabled ? 'installed' : 'disabled', 2012 - lastError: '', 2013 - lastErrorAt: 0 2014 - }); 2015 - } else { 2016 - // Load/unload failed - store the error and keep disabled 2017 - const errorMsg = loadResult.error || 'Failed to load extension'; 2018 - await api.extensions.update(ext.id, { 2019 - enabled: 0, 2020 - status: 'error', 2021 - lastError: errorMsg, 2022 - lastErrorAt: Date.now() 2023 - }); 2024 - console.error(`[settings] Extension ${ext.id} load failed:`, errorMsg); 1884 + // Toggle via the unified registry — enable / disable mark the 1885 + // entry in registry.json and broadcast feature:enabled / 1886 + // feature:disabled. Tile windows are launched / unloaded by 1887 + // the main process side-effect inside the strict handler. 1888 + const toggleResult = newEnabled 1889 + ? await api.features.enable(ext.id) 1890 + : await api.features.disable(ext.id); 1891 + if (toggleResult?.error) { 1892 + console.error(`[settings] Feature ${ext.id} toggle failed:`, toggleResult.error); 2025 1893 } 2026 1894 } 2027 1895 ··· 2085 1953 reloadBtn.textContent = '...'; 2086 1954 reloadBtn.disabled = true; 2087 1955 try { 2088 - const result = await api.extensions.reload(ext.id); 2089 - reloadBtn.textContent = result.success ? '✓' : '✗'; 1956 + const result = await api.features.devReload(ext.id); 1957 + reloadBtn.textContent = result && !result.error ? '✓' : '✗'; 2090 1958 } catch (err) { 2091 1959 reloadBtn.textContent = '✗'; 2092 1960 } ··· 2113 1981 cursor: pointer; 2114 1982 `; 2115 1983 removeBtn.addEventListener('click', async () => { 2116 - if (!confirm(`Remove extension "${manifest.name || ext.id}"?`)) return; 1984 + if (!confirm(`Remove feature "${manifest.name || ext.id}"?`)) return; 2117 1985 2118 1986 removeBtn.textContent = '...'; 2119 1987 removeBtn.disabled = true; 2120 1988 2121 - // Unload if running 2122 - if (ext.isRunning) { 2123 - await api.extensions.unload(ext.id); 2124 - } 2125 - 2126 - // Remove from datastore 2127 - const result = await api.extensions.remove(ext.id); 2128 - if (result.success) { 1989 + // Remove from the unified registry — the strict handler 1990 + // tears down any running tile windows before deleting files. 1991 + const result = await api.features.remove(ext.id); 1992 + if (result && !result.error) { 2129 1993 refreshExtensionsList(); 2130 1994 } else { 2131 1995 removeBtn.textContent = 'Error';