experiments in a post-browser web
10
fork

Configure Feed

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

refactor(tile-preload,ipc): collapse feature-* fallbacks + delete legacy (Phase 3.7b)

Remove the V1-compat dual-path for the 10 feature-registry/install/browse
channels in tile-preload.cts: drop featuresCompat object, hasFeaturesCapability()
helper, and all ternary branches in api.features — every call now routes
directly through the strict tile:features:* channel.

Delete the corresponding 10 legacy ipcMain.handle blocks from tile-ipc.ts
(feature-registry:list/get/disable/enable/remove/history,
feature-install:resolve/preview-capabilities/install,
feature-browse:resolve-publisher). The feature-browse:list-by-publisher
handler is retained — it was already strict-only with no compat branch.

feature-settings-* handlers in ipc.ts are unchanged (out of scope).

+27 -470
-369
backend/electron/tile-ipc.ts
··· 4788 4788 } 4789 4789 }); 4790 4790 4791 - // ── Feature Registry ── 4792 - 4793 - ipcMain.handle('feature-registry:list', async (_event, args: { 4794 - token: string; 4795 - }) => { 4796 - const grant = getGrantForToken(args.token); 4797 - if (!grant) return { error: 'Invalid token' }; 4798 - 4799 - const registry = getFeatureRegistry(); 4800 - if (!registry) return { error: 'Feature registry not initialized' }; 4801 - 4802 - // Return all non-builtin entries 4803 - const all = registry.listFeatures(); 4804 - const nonBuiltin = all.filter(e => e.source.type !== 'builtin'); 4805 - return { entries: nonBuiltin }; 4806 - }); 4807 - 4808 - ipcMain.handle('feature-registry:get', async (_event, args: { 4809 - token: string; 4810 - id: string; 4811 - }) => { 4812 - const grant = getGrantForToken(args.token); 4813 - if (!grant) return { error: 'Invalid token' }; 4814 - 4815 - const registry = getFeatureRegistry(); 4816 - if (!registry) return { error: 'Feature registry not initialized' }; 4817 - 4818 - const entry = registry.getFeature(args.id); 4819 - if (!entry) return { error: `Feature not found: ${args.id}` }; 4820 - return { entry }; 4821 - }); 4822 - 4823 - ipcMain.handle('feature-registry:disable', async (_event, args: { 4824 - token: string; 4825 - id: string; 4826 - }) => { 4827 - const grant = getGrantForToken(args.token); 4828 - if (!grant) return { error: 'Invalid token' }; 4829 - 4830 - const registry = getFeatureRegistry(); 4831 - if (!registry) return { error: 'Feature registry not initialized' }; 4832 - 4833 - const entry = registry.getFeature(args.id); 4834 - if (!entry) return { error: `Feature not found: ${args.id}` }; 4835 - if (entry.source.type === 'builtin') return { error: 'Cannot disable builtin features' }; 4836 - 4837 - const result = registry.disableFeature(args.id); 4838 - if (result) { 4839 - registry.saveRegistry(); 4840 - 4841 - // Tear down any running tile windows for this feature. The registry 4842 - // update alone does not stop a tile that is already running — its 4843 - // BrowserWindow keeps responding to events until closed. Send the 4844 - // shutdown signal + close the window via unloadTile(). 4845 - const manifest = getTileManifest(args.id); 4846 - if (manifest) { 4847 - for (const t of manifest.tiles) { 4848 - if (isTileLoaded(args.id, t.id)) { 4849 - try { 4850 - await unloadTile(args.id, t.id); 4851 - } catch (err) { 4852 - console.error(`[tile-ipc] Failed to unload ${args.id}:${t.id} on disable:`, err); 4853 - } 4854 - } 4855 - } 4856 - } 4857 - 4858 - publish('feature-registry', scopes.GLOBAL, 'feature:disabled', { id: args.id }); 4859 - } 4860 - return { success: result }; 4861 - }); 4862 - 4863 - ipcMain.handle('feature-registry:enable', async (_event, args: { 4864 - token: string; 4865 - id: string; 4866 - /** Path to the tile preload script (main process provides this). */ 4867 - tilePreloadPath?: string; 4868 - }) => { 4869 - const grant = getGrantForToken(args.token); 4870 - if (!grant) return { error: 'Invalid token' }; 4871 - 4872 - const registry = getFeatureRegistry(); 4873 - if (!registry) return { error: 'Feature registry not initialized' }; 4874 - 4875 - const entry = registry.getFeature(args.id); 4876 - if (!entry) return { error: `Feature not found: ${args.id}` }; 4877 - 4878 - const result = registry.enableFeature(args.id); 4879 - if (result) { 4880 - registry.saveRegistry(); 4881 - 4882 - // If the feature is a builtin that we would have auto-loaded at 4883 - // startup, relaunch its background tile now. Lazy features load on 4884 - // first invocation and don't need a restart here. 4885 - if (entry.source.type === 'builtin' && args.tilePreloadPath) { 4886 - try { 4887 - const manifestPath = path.join(entry.path, 'manifest.json'); 4888 - const parsed = parseManifestFile(manifestPath); 4889 - if (parsed?.version === 'v2' && parsed.v2) { 4890 - const residentTile = parsed.v2.tiles.find(t => t.resident === true || t.type === 'background'); 4891 - if (residentTile && !isTileLoaded(args.id, residentTile.id)) { 4892 - launchTile({ 4893 - tilePath: entry.path, 4894 - manifest: parsed.v2, 4895 - preloadPath: args.tilePreloadPath, 4896 - entryId: residentTile.id, 4897 - }); 4898 - } 4899 - } 4900 - } catch (err) { 4901 - console.error(`[tile-ipc] Failed to relaunch ${args.id} on enable:`, err); 4902 - } 4903 - } 4904 - 4905 - publish('feature-registry', scopes.GLOBAL, 'feature:enabled', { id: args.id }); 4906 - } 4907 - return { success: result }; 4908 - }); 4909 - 4910 - ipcMain.handle('feature-registry:remove', async (_event, args: { 4911 - token: string; 4912 - id: string; 4913 - }) => { 4914 - const grant = getGrantForToken(args.token); 4915 - if (!grant) return { error: 'Invalid token' }; 4916 - 4917 - const registry = getFeatureRegistry(); 4918 - if (!registry) return { error: 'Feature registry not initialized' }; 4919 - 4920 - const entry = registry.getFeature(args.id); 4921 - if (!entry) return { error: `Feature not found: ${args.id}` }; 4922 - if (entry.source.type === 'builtin') return { error: 'Cannot remove builtin features' }; 4923 - 4924 - // Tear down any running tile windows BEFORE deleting files / registry. 4925 - // Closing the window AFTER rm -rf would strand a live renderer loading 4926 - // assets that no longer exist. unloadTile() sends the shutdown signal 4927 - // so the tile's api.onShutdown() callbacks can run. 4928 - const manifest = getTileManifest(args.id); 4929 - if (manifest) { 4930 - for (const t of manifest.tiles) { 4931 - if (isTileLoaded(args.id, t.id)) { 4932 - try { 4933 - await unloadTile(args.id, t.id); 4934 - } catch (err) { 4935 - console.error(`[tile-ipc] Failed to unload ${args.id}:${t.id} on remove:`, err); 4936 - } 4937 - } 4938 - } 4939 - } 4940 - 4941 - // Remove from registry 4942 - const result = registry.unregisterFeature(args.id); 4943 - if (result) { 4944 - // Delete feature files (non-builtin, non-dev only) 4945 - // Dev features: source files owned by developer, only unregister 4946 - if (entry.source.type !== 'dev') { 4947 - try { 4948 - if (fs.existsSync(entry.path)) { 4949 - fs.rmSync(entry.path, { recursive: true }); 4950 - } 4951 - } catch (err) { 4952 - console.error(`[tile-ipc] Failed to delete feature files at ${entry.path}:`, err); 4953 - } 4954 - } 4955 - registry.saveRegistry(); 4956 - 4957 - // Write history event 4958 - writeFeatureHistory( 4959 - args.id, 4960 - 'uninstall', 4961 - entry.source.version, 4962 - entry.source.type, 4963 - entry.source.uri, 4964 - entry.source.publisher, 4965 - entry.grantedCapabilities as unknown as Record<string, unknown> 4966 - ); 4967 - 4968 - publish('feature-registry', scopes.GLOBAL, 'feature:removed', { id: args.id }); 4969 - } 4970 - return { success: result }; 4971 - }); 4972 - 4973 - ipcMain.handle('feature-registry:history', async (_event, args: { 4974 - token: string; 4975 - featureId?: string; 4976 - limit?: number; 4977 - }) => { 4978 - const grant = getGrantForToken(args.token); 4979 - if (!grant) return { error: 'Invalid token' }; 4980 - 4981 - try { 4982 - const db = getDb(); 4983 - let sql = 'SELECT * FROM features_history'; 4984 - const params: unknown[] = []; 4985 - 4986 - if (args.featureId) { 4987 - sql += ' WHERE featureId = ?'; 4988 - params.push(args.featureId); 4989 - } 4990 - 4991 - sql += ' ORDER BY timestamp DESC'; 4992 - 4993 - if (args.limit) { 4994 - sql += ' LIMIT ?'; 4995 - params.push(args.limit); 4996 - } 4997 - 4998 - const rows = db.prepare(sql).all(...params); 4999 - return { entries: rows }; 5000 - } catch (err) { 5001 - const message = err instanceof Error ? err.message : String(err); 5002 - return { error: `Failed to query history: ${message}` }; 5003 - } 5004 - }); 5005 - 5006 - // ── Feature Install (AT Protocol) ── 5007 - 5008 - ipcMain.handle('feature-install:resolve', async (_event, args: { 5009 - token: string; 5010 - atUri: string; 5011 - }) => { 5012 - const grant = getGrantForToken(args.token); 5013 - if (!grant) return { error: 'Invalid token' }; 5014 - 5015 - const source = getAtprotoSource(); 5016 - if (!source) return { error: 'AtprotoSource not initialized' }; 5017 - 5018 - try { 5019 - const metadata = await source.resolve(args.atUri); 5020 - return { 5021 - manifest: metadata.manifest, 5022 - source: metadata.source, 5023 - }; 5024 - } catch (err) { 5025 - const message = err instanceof Error ? err.message : String(err); 5026 - return { error: `Failed to resolve AT URI: ${message}` }; 5027 - } 5028 - }); 5029 - 5030 - ipcMain.handle('feature-install:preview-capabilities', async (_event, args: { 5031 - token: string; 5032 - manifestCapabilities: TileCapabilities; 5033 - featureId: string; 5034 - }) => { 5035 - const grant = getGrantForToken(args.token); 5036 - if (!grant) return { error: 'Invalid token' }; 5037 - 5038 - try { 5039 - const result = resolveCapabilities(args.featureId, args.manifestCapabilities, false); 5040 - return { 5041 - granted: result.capabilities, 5042 - denied: result.denied, 5043 - }; 5044 - } catch (err) { 5045 - const message = err instanceof Error ? err.message : String(err); 5046 - return { error: `Failed to resolve capabilities: ${message}` }; 5047 - } 5048 - }); 5049 - 5050 - ipcMain.handle('feature-install:install', async (_event, args: { 5051 - token: string; 5052 - atUri: string; 5053 - userApproved: boolean; 5054 - }) => { 5055 - const grant = getGrantForToken(args.token); 5056 - if (!grant) return { error: 'Invalid token' }; 5057 - 5058 - if (!args.userApproved) { 5059 - return { error: 'User approval required before installation' }; 5060 - } 5061 - 5062 - const source = getAtprotoSource(); 5063 - if (!source) return { error: 'AtprotoSource not initialized' }; 5064 - 5065 - const registry = getFeatureRegistry(); 5066 - if (!registry) return { error: 'Feature registry not initialized' }; 5067 - 5068 - try { 5069 - // 1. Fetch the full bundle 5070 - const metadata = await source.resolve(args.atUri); 5071 - const bundle = await source.fetch(args.atUri); 5072 - 5073 - // 2. Install from bundle 5074 - const result = installFromBundle( 5075 - bundle, 5076 - metadata.source, 5077 - registry, 5078 - { force: true, userApproved: true } 5079 - ); 5080 - 5081 - if (!result.success) { 5082 - return { error: result.error }; 5083 - } 5084 - 5085 - // 3. Save registry 5086 - registry.saveRegistry(); 5087 - 5088 - // 4. Write history event 5089 - writeFeatureHistory( 5090 - result.entry!.id, 5091 - 'install', 5092 - metadata.source.version, 5093 - 'atproto', 5094 - args.atUri, 5095 - metadata.source.publisher, 5096 - result.entry!.grantedCapabilities as unknown as Record<string, unknown> 5097 - ); 5098 - 5099 - // 5. Publish installed event 5100 - publish('feature-registry', scopes.GLOBAL, 'feature:installed', { 5101 - id: result.entry!.id, 5102 - name: result.entry!.name, 5103 - source: metadata.source, 5104 - }); 5105 - 5106 - return { success: true, entry: result.entry }; 5107 - } catch (err) { 5108 - const message = err instanceof Error ? err.message : String(err); 5109 - return { error: `Installation failed: ${message}` }; 5110 - } 5111 - }); 5112 - 5113 - // ── Feature Browse (AT Protocol) ── 5114 - 5115 - ipcMain.handle('feature-browse:resolve-publisher', async (_event, args: { 5116 - token: string; 5117 - query: string; // handle or DID 5118 - }) => { 5119 - const grant = getGrantForToken(args.token); 5120 - if (!grant) return { error: 'Invalid token' }; 5121 - 5122 - if (!args.query || !args.query.trim()) { 5123 - return { error: 'Handle or DID is required' }; 5124 - } 5125 - 5126 - const { resolveHandleToDid, resolveDid } = await import('./atproto-source.js'); 5127 - 5128 - try { 5129 - const query = args.query.trim(); 5130 - let did: string; 5131 - 5132 - if (query.startsWith('did:')) { 5133 - did = query; 5134 - } else { 5135 - // Resolve handle to DID 5136 - const resolved = await resolveHandleToDid(query); 5137 - if (!resolved) { 5138 - return { error: `Could not resolve handle "${query}"` }; 5139 - } 5140 - did = resolved; 5141 - } 5142 - 5143 - // Resolve DID to get PDS URL and handle 5144 - const identity = await resolveDid(did); 5145 - if (!identity) { 5146 - return { error: `Could not resolve DID: ${did}` }; 5147 - } 5148 - 5149 - return { 5150 - did, 5151 - handle: identity.handle, 5152 - pdsUrl: identity.pdsUrl, 5153 - }; 5154 - } catch (err) { 5155 - const message = err instanceof Error ? err.message : String(err); 5156 - return { error: `Failed to resolve publisher: ${message}` }; 5157 - } 5158 - }); 5159 - 5160 4791 ipcMain.handle('feature-browse:list-by-publisher', async (_event, args: { 5161 4792 token: string; 5162 4793 did: string;
+27 -101
backend/electron/tile-preload.cts
··· 1473 1473 1474 1474 // ── Features ────────────────────────────────────────────────────── 1475 1475 // 1476 - // Dual-path implementation mirroring api.shortcuts / api.context: 1477 - // 1478 - // - STRICT: when the tile's manifest declared a `features` capability 1479 - // (`true` or `{ read?, install?, manage?, sources? }`), route through 1480 - // `tile:features:*`. Main-process handlers validate the token + 1481 - // capability shape + per-action gates + source allowlist. 1482 - // 1483 - // - V1-COMPAT: when no features capability was declared, fall back 1484 - // to the legacy `feature-registry:*` / `feature-install:*` / 1485 - // `feature-browse:*` channels. Those remain available through the 1486 - // migration window and the settings UI continues to use them. 1487 - // 1488 - // The decision is made per-call because `grantedCapabilities` is 1489 - // populated asynchronously by `initialize()`. 1490 - 1491 - function hasFeaturesCapability(): boolean { 1492 - const fc = grantedCapabilities?.features; 1493 - if (fc === true) return true; 1494 - if (fc && typeof fc === 'object') return true; 1495 - return false; 1496 - } 1497 - 1498 - const featuresCompat = { 1499 - list: (sourceType?: string) => 1500 - ipcRenderer.invoke('feature-registry:list', { token: tileToken, sourceType }), 1501 - get: (id: string) => 1502 - ipcRenderer.invoke('feature-registry:get', { token: tileToken, id }), 1503 - history: (featureId?: string, limit?: number) => 1504 - ipcRenderer.invoke('feature-registry:history', { token: tileToken, featureId, limit }), 1505 - enable: (id: string, tilePreloadPath?: string) => 1506 - ipcRenderer.invoke('feature-registry:enable', { token: tileToken, id, tilePreloadPath }), 1507 - disable: (id: string) => 1508 - ipcRenderer.invoke('feature-registry:disable', { token: tileToken, id }), 1509 - remove: (id: string) => 1510 - ipcRenderer.invoke('feature-registry:remove', { token: tileToken, id }), 1511 - installResolve: (atUri: string) => 1512 - ipcRenderer.invoke('feature-install:resolve', { token: tileToken, atUri }), 1513 - previewCapabilities: (manifestCapabilities: unknown, featureId: string) => 1514 - ipcRenderer.invoke('feature-install:preview-capabilities', { 1515 - token: tileToken, 1516 - manifestCapabilities, 1517 - featureId, 1518 - }), 1519 - install: (atUri: string, userApproved: boolean) => 1520 - ipcRenderer.invoke('feature-install:install', { token: tileToken, atUri, userApproved }), 1521 - resolvePublisher: (query: string) => 1522 - ipcRenderer.invoke('feature-browse:resolve-publisher', { token: tileToken, query }), 1523 - }; 1476 + // All calls route through the strict `tile:features:*` channel. 1477 + // Main-process handlers validate the token + capability shape + 1478 + // per-action gates + source allowlist. Tiles without the `features` 1479 + // capability will receive a capability-gate rejection from the handler 1480 + // (correct behaviour — no v1-compat fallback). 1524 1481 1525 1482 const featuresStrict = { 1526 1483 list: (sourceType?: string) => ··· 1552 1509 }; 1553 1510 1554 1511 api.features = { 1555 - list: (sourceType: unknown) => { 1556 - return hasFeaturesCapability() 1557 - ? featuresStrict.list(sourceType as string | undefined) 1558 - : featuresCompat.list(sourceType as string | undefined); 1559 - }, 1560 - get: (id: unknown) => { 1561 - return hasFeaturesCapability() 1562 - ? featuresStrict.get(id as string) 1563 - : featuresCompat.get(id as string); 1564 - }, 1565 - history: (featureId: unknown, limit: unknown) => { 1566 - return hasFeaturesCapability() 1567 - ? featuresStrict.history(featureId as string | undefined, limit as number | undefined) 1568 - : featuresCompat.history(featureId as string | undefined, limit as number | undefined); 1569 - }, 1570 - enable: (id: unknown, tilePreloadPath: unknown) => { 1571 - return hasFeaturesCapability() 1572 - ? featuresStrict.enable(id as string, tilePreloadPath as string | undefined) 1573 - : featuresCompat.enable(id as string, tilePreloadPath as string | undefined); 1574 - }, 1575 - disable: (id: unknown) => { 1576 - return hasFeaturesCapability() 1577 - ? featuresStrict.disable(id as string) 1578 - : featuresCompat.disable(id as string); 1579 - }, 1580 - remove: (id: unknown) => { 1581 - return hasFeaturesCapability() 1582 - ? featuresStrict.remove(id as string) 1583 - : featuresCompat.remove(id as string); 1584 - }, 1585 - installResolve: (atUri: unknown) => { 1586 - return hasFeaturesCapability() 1587 - ? featuresStrict.installResolve(atUri as string) 1588 - : featuresCompat.installResolve(atUri as string); 1589 - }, 1590 - previewCapabilities: (mc: unknown, id: unknown) => { 1591 - return hasFeaturesCapability() 1592 - ? featuresStrict.previewCapabilities(mc, id as string) 1593 - : featuresCompat.previewCapabilities(mc, id as string); 1594 - }, 1595 - install: (atUri: unknown, userApproved: unknown) => { 1596 - return hasFeaturesCapability() 1597 - ? featuresStrict.install(atUri as string, userApproved as boolean) 1598 - : featuresCompat.install(atUri as string, userApproved as boolean); 1599 - }, 1600 - resolvePublisher: (query: unknown) => { 1601 - return hasFeaturesCapability() 1602 - ? featuresStrict.resolvePublisher(query as string) 1603 - : featuresCompat.resolvePublisher(query as string); 1604 - }, 1605 - settingsSchema: (id: unknown) => { 1606 - return featuresStrict.settingsSchema(id as string); 1607 - }, 1512 + list: (sourceType: unknown) => 1513 + featuresStrict.list(sourceType as string | undefined), 1514 + get: (id: unknown) => 1515 + featuresStrict.get(id as string), 1516 + history: (featureId: unknown, limit: unknown) => 1517 + featuresStrict.history(featureId as string | undefined, limit as number | undefined), 1518 + enable: (id: unknown, tilePreloadPath: unknown) => 1519 + featuresStrict.enable(id as string, tilePreloadPath as string | undefined), 1520 + disable: (id: unknown) => 1521 + featuresStrict.disable(id as string), 1522 + remove: (id: unknown) => 1523 + featuresStrict.remove(id as string), 1524 + installResolve: (atUri: unknown) => 1525 + featuresStrict.installResolve(atUri as string), 1526 + previewCapabilities: (mc: unknown, id: unknown) => 1527 + featuresStrict.previewCapabilities(mc, id as string), 1528 + install: (atUri: unknown, userApproved: unknown) => 1529 + featuresStrict.install(atUri as string, userApproved as boolean), 1530 + resolvePublisher: (query: unknown) => 1531 + featuresStrict.resolvePublisher(query as string), 1532 + settingsSchema: (id: unknown) => 1533 + featuresStrict.settingsSchema(id as string), 1608 1534 1609 1535 // ── Admin surfaces (strict-only) ───────────────────────────────── 1610 1536 //