experiments in a post-browser web
10
fork

Configure Feed

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

docs(tasks): record bootstrap IPC channel migration as deferred

+6 -1179
+5 -1179
backend/electron/tile-ipc.ts
··· 5706 5706 } 5707 5707 }); 5708 5708 5709 - ipcMain.handle('feature-browse:list-by-publisher', async (_event, args: { 5710 - token: string; 5711 - did: string; 5712 - pdsUrl: string; 5713 - cursor?: string; 5714 - }) => { 5715 - const grant = getGrantForToken(args.token); 5716 - if (!grant) return { error: 'Invalid token' }; 5717 - 5718 - if (!args.did || !args.pdsUrl) { 5719 - return { error: 'DID and PDS URL are required' }; 5720 - } 5721 - 5722 - try { 5723 - const params = new URLSearchParams({ 5724 - repo: args.did, 5725 - collection: 'space.peek.feature.release', 5726 - limit: '100', 5727 - }); 5728 - if (args.cursor) { 5729 - params.set('cursor', args.cursor); 5730 - } 5731 - 5732 - const url = `${args.pdsUrl}/xrpc/com.atproto.repo.listRecords?${params.toString()}`; 5733 - const res = await fetch(url); 5734 - 5735 - if (!res.ok) { 5736 - const body = await res.text().catch(() => ''); 5737 - return { error: `PDS returned ${res.status}: ${body}` }; 5738 - } 5739 - 5740 - const data = await res.json() as { 5741 - records: Array<{ 5742 - uri: string; 5743 - cid: string; 5744 - value: Record<string, unknown>; 5745 - }>; 5746 - cursor?: string; 5747 - }; 5748 - 5749 - // Group records by featureId and pick latest version for each 5750 - const featureMap = new Map<string, { 5751 - uri: string; 5752 - featureId: string; 5753 - name: string; 5754 - version: string; 5755 - description?: string; 5756 - categories?: string[]; 5757 - capabilities?: string[]; 5758 - createdAt: string; 5759 - }>(); 5760 - 5761 - for (const rec of data.records || []) { 5762 - const val = rec.value as Record<string, unknown>; 5763 - const featureId = val.featureId as string; 5764 - if (!featureId) continue; 5765 - 5766 - const existing = featureMap.get(featureId); 5767 - const createdAt = val.createdAt as string || ''; 5768 - 5769 - // Keep latest by createdAt 5770 - if (!existing || createdAt > existing.createdAt) { 5771 - featureMap.set(featureId, { 5772 - uri: rec.uri, 5773 - featureId, 5774 - name: (val.name as string) || featureId, 5775 - version: (val.version as string) || 'unknown', 5776 - description: val.description as string | undefined, 5777 - categories: val.categories as string[] | undefined, 5778 - capabilities: val.capabilities as string[] | undefined, 5779 - createdAt, 5780 - }); 5781 - } 5782 - } 5783 - 5784 - return { 5785 - records: Array.from(featureMap.values()), 5786 - cursor: data.cursor, 5787 - }; 5788 - } catch (err) { 5789 - const message = err instanceof Error ? err.message : String(err); 5790 - return { error: `Failed to list records: ${message}` }; 5791 - } 5792 - }); 5793 - 5794 - // ── Feature Publish ── 5795 - 5796 - ipcMain.handle('feature-publish:list-local', async (_event, args: { 5797 - token: string; 5798 - }) => { 5799 - const grant = getGrantForToken(args.token); 5800 - if (!grant) return { error: 'Invalid token' }; 5801 - 5802 - const registry = getFeatureRegistry(); 5803 - if (!registry) return { error: 'Feature registry not initialized' }; 5804 - 5805 - // Return all features (local and builtin) that could be published 5806 - const all = registry.listFeatures(); 5807 - const publishable = all.filter(e => 5808 - e.source.type === 'local' || e.source.type === 'builtin' || e.source.type === 'dev' 5809 - ); 5810 - 5811 - return { 5812 - features: publishable.map(e => ({ 5813 - id: e.id, 5814 - name: e.name, 5815 - version: e.source.version || '0.0.0', 5816 - path: e.path, 5817 - sourceType: e.source.type, 5818 - description: (e as any).description || '', 5819 - })), 5820 - }; 5821 - }); 5822 - 5823 - ipcMain.handle('feature-publish:read-feature-files', async (_event, args: { 5824 - token: string; 5825 - featureId: string; 5826 - }) => { 5827 - const grant = getGrantForToken(args.token); 5828 - if (!grant) return { error: 'Invalid token' }; 5829 - 5830 - const registry = getFeatureRegistry(); 5831 - if (!registry) return { error: 'Feature registry not initialized' }; 5832 - 5833 - const entry = registry.getFeature(args.featureId); 5834 - if (!entry) return { error: `Feature not found: ${args.featureId}` }; 5835 - 5836 - try { 5837 - const featurePath = entry.path; 5838 - if (!fs.existsSync(featurePath)) { 5839 - return { error: `Feature directory not found: ${featurePath}` }; 5840 - } 5841 - 5842 - const files: Array<{ 5843 - relativePath: string; 5844 - content: string; // base64 5845 - size: number; 5846 - mimeType: string; 5847 - }> = []; 5848 - 5849 - function readDir(dirPath: string, prefix: string): void { 5850 - const entries = fs.readdirSync(dirPath, { withFileTypes: true }); 5851 - for (const dirent of entries) { 5852 - // Skip hidden files/dirs, node_modules, .git 5853 - if (dirent.name.startsWith('.') || dirent.name === 'node_modules') continue; 5854 - 5855 - const fullPath = path.join(dirPath, dirent.name); 5856 - const relativePath = prefix ? `${prefix}/${dirent.name}` : dirent.name; 5857 - 5858 - if (dirent.isDirectory()) { 5859 - readDir(fullPath, relativePath); 5860 - } else if (dirent.isFile()) { 5861 - const data = fs.readFileSync(fullPath); 5862 - const ext = path.extname(dirent.name).toLowerCase(); 5863 - const mimeType = getMimeType(ext); 5864 - 5865 - files.push({ 5866 - relativePath, 5867 - content: data.toString('base64'), 5868 - size: data.length, 5869 - mimeType, 5870 - }); 5871 - } 5872 - } 5873 - } 5874 - 5875 - readDir(featurePath, ''); 5876 - 5877 - return { 5878 - featureId: args.featureId, 5879 - name: entry.name, 5880 - path: featurePath, 5881 - files, 5882 - totalSize: files.reduce((sum, f) => sum + f.size, 0), 5883 - }; 5884 - } catch (err) { 5885 - const message = err instanceof Error ? err.message : String(err); 5886 - return { error: `Failed to read feature files: ${message}` }; 5887 - } 5888 - }); 5889 - 5890 - // NOTE: `feature-publish:upload-blob` and `feature-publish:create-record` IPC 5891 - // handlers have been removed. The publish flow now calls lex's renderer-side 5892 - // atproto helpers (uploadBlob / xrpcPost from peek://lex/atproto.js) 5893 - // directly so that DPoP signing happens in the renderer via Web Crypto — 5894 - // no backend DPoP implementation is needed. 5895 - 5896 - // ── Feature Update ── 5897 - 5898 - ipcMain.handle('feature-update:check-all', async (_event, args: { 5899 - token: string; 5900 - }) => { 5901 - const grant = getGrantForToken(args.token); 5902 - if (!grant) return { error: 'Invalid token' }; 5903 - 5904 - const registry = getFeatureRegistry(); 5905 - if (!registry) return { error: 'Feature registry not initialized' }; 5906 - 5907 - const source = getAtprotoSource(); 5908 - if (!source) return { error: 'AtprotoSource not initialized' }; 5909 - 5910 - try { 5911 - // Get all atproto-sourced features 5912 - const all = registry.listFeatures(); 5913 - const atprotoFeatures = all.filter(e => e.source.type === 'atproto' && e.source.publisher); 5914 - 5915 - if (atprotoFeatures.length === 0) { 5916 - return { updates: [] }; 5917 - } 5918 - 5919 - // Group by publisher DID 5920 - const byPublisher = new Map<string, typeof atprotoFeatures>(); 5921 - for (const feature of atprotoFeatures) { 5922 - const did = feature.source.publisher!; 5923 - if (!byPublisher.has(did)) { 5924 - byPublisher.set(did, []); 5925 - } 5926 - byPublisher.get(did)!.push(feature); 5927 - } 5928 - 5929 - const updates: Array<{ 5930 - id: string; 5931 - name: string; 5932 - currentVersion: string; 5933 - availableVersion: string; 5934 - atUri: string; 5935 - }> = []; 5936 - 5937 - for (const [did, pubFeatures] of byPublisher) { 5938 - try { 5939 - // Resolve publisher 5940 - const { resolveDid: resolveDidFn } = await import('./atproto-source.js'); 5941 - const identity = await resolveDidFn(did); 5942 - if (!identity) continue; 5943 - 5944 - // List release records 5945 - const params = new URLSearchParams({ 5946 - repo: did, 5947 - collection: 'space.peek.feature.release', 5948 - limit: '100', 5949 - }); 5950 - const url = `${identity.pdsUrl}/xrpc/com.atproto.repo.listRecords?${params.toString()}`; 5951 - const res = await fetch(url); 5952 - if (!res.ok) continue; 5953 - 5954 - const data = await res.json() as { 5955 - records: Array<{ uri: string; value: Record<string, unknown> }>; 5956 - }; 5957 - 5958 - // Build featureId -> latest version map 5959 - const featureMap = new Map<string, { uri: string; version: string; createdAt: string }>(); 5960 - for (const rec of data.records || []) { 5961 - const val = rec.value; 5962 - const featureId = val.featureId as string; 5963 - if (!featureId) continue; 5964 - const existing = featureMap.get(featureId); 5965 - const createdAt = (val.createdAt as string) || ''; 5966 - if (!existing || createdAt > existing.createdAt) { 5967 - featureMap.set(featureId, { 5968 - uri: rec.uri, 5969 - version: (val.version as string) || '0.0.0', 5970 - createdAt, 5971 - }); 5972 - } 5973 - } 5974 - 5975 - // Compare versions 5976 - for (const feature of pubFeatures) { 5977 - const policy = feature.updatePolicy || 'auto'; 5978 - if (policy === 'pinned') continue; 5979 - 5980 - const currentVersion = feature.source.version || '0.0.0'; 5981 - const latest = featureMap.get(feature.id); 5982 - if (!latest) continue; 5983 - 5984 - if (compareSemverSimple(latest.version, currentVersion) > 0) { 5985 - // Update availableVersion in registry 5986 - const entry = registry.getFeature(feature.id); 5987 - if (entry) { 5988 - entry.availableVersion = latest.version; 5989 - entry.lastUpdateCheck = new Date().toISOString(); 5990 - registry.registerFeature(entry); 5991 - } 5992 - 5993 - updates.push({ 5994 - id: feature.id, 5995 - name: feature.name, 5996 - currentVersion, 5997 - availableVersion: latest.version, 5998 - atUri: latest.uri, 5999 - }); 6000 - } else { 6001 - // No update — clear availableVersion, update lastUpdateCheck 6002 - const entry = registry.getFeature(feature.id); 6003 - if (entry) { 6004 - entry.availableVersion = undefined; 6005 - entry.lastUpdateCheck = new Date().toISOString(); 6006 - registry.registerFeature(entry); 6007 - } 6008 - } 6009 - } 6010 - } catch (err) { 6011 - console.error(`[tile-ipc] Update check failed for publisher ${did}:`, err); 6012 - } 6013 - } 6014 - 6015 - registry.saveRegistry(); 6016 - return { updates }; 6017 - } catch (err) { 6018 - const message = err instanceof Error ? err.message : String(err); 6019 - return { error: `Update check failed: ${message}` }; 6020 - } 6021 - }); 6022 - 6023 - ipcMain.handle('feature-update:check-one', async (_event, args: { 6024 - token: string; 6025 - id: string; 6026 - }) => { 6027 - const grant = getGrantForToken(args.token); 6028 - if (!grant) return { error: 'Invalid token' }; 6029 - 6030 - const registry = getFeatureRegistry(); 6031 - if (!registry) return { error: 'Feature registry not initialized' }; 6032 - 6033 - const entry = registry.getFeature(args.id); 6034 - if (!entry) return { error: `Feature not found: ${args.id}` }; 6035 - if (entry.source.type !== 'atproto' || !entry.source.publisher) { 6036 - return { error: 'Feature is not from AT Protocol' }; 6037 - } 6038 - 6039 - try { 6040 - const { resolveDid: resolveDidFn } = await import('./atproto-source.js'); 6041 - const identity = await resolveDidFn(entry.source.publisher); 6042 - if (!identity) return { error: `Could not resolve publisher: ${entry.source.publisher}` }; 6043 - 6044 - const params = new URLSearchParams({ 6045 - repo: entry.source.publisher, 6046 - collection: 'space.peek.feature.release', 6047 - limit: '100', 6048 - }); 6049 - const url = `${identity.pdsUrl}/xrpc/com.atproto.repo.listRecords?${params.toString()}`; 6050 - const res = await fetch(url); 6051 - if (!res.ok) return { error: `PDS returned ${res.status}` }; 6052 - 6053 - const data = await res.json() as { 6054 - records: Array<{ uri: string; value: Record<string, unknown> }>; 6055 - }; 6056 - 6057 - // Find latest version for this featureId 6058 - let latestUri = ''; 6059 - let latestVersion = '0.0.0'; 6060 - let latestCreatedAt = ''; 6061 - 6062 - for (const rec of data.records || []) { 6063 - const val = rec.value; 6064 - if ((val.featureId as string) !== entry.id) continue; 6065 - const createdAt = (val.createdAt as string) || ''; 6066 - if (createdAt > latestCreatedAt) { 6067 - latestUri = rec.uri; 6068 - latestVersion = (val.version as string) || '0.0.0'; 6069 - latestCreatedAt = createdAt; 6070 - } 6071 - } 6072 - 6073 - const currentVersion = entry.source.version || '0.0.0'; 6074 - entry.lastUpdateCheck = new Date().toISOString(); 6075 - 6076 - if (compareSemverSimple(latestVersion, currentVersion) > 0) { 6077 - entry.availableVersion = latestVersion; 6078 - registry.registerFeature(entry); 6079 - registry.saveRegistry(); 6080 - return { 6081 - hasUpdate: true, 6082 - currentVersion, 6083 - availableVersion: latestVersion, 6084 - atUri: latestUri, 6085 - }; 6086 - } 6087 - 6088 - entry.availableVersion = undefined; 6089 - registry.registerFeature(entry); 6090 - registry.saveRegistry(); 6091 - return { hasUpdate: false, currentVersion }; 6092 - } catch (err) { 6093 - const message = err instanceof Error ? err.message : String(err); 6094 - return { error: `Update check failed: ${message}` }; 6095 - } 6096 - }); 6097 - 6098 - ipcMain.handle('feature-update:apply', async (_event, args: { 6099 - token: string; 6100 - id: string; 6101 - userApproved?: boolean; 6102 - }) => { 6103 - const grant = getGrantForToken(args.token); 6104 - if (!grant) return { error: 'Invalid token' }; 6105 - 6106 - const registry = getFeatureRegistry(); 6107 - if (!registry) return { error: 'Feature registry not initialized' }; 6108 - 6109 - const source = getAtprotoSource(); 6110 - if (!source) return { error: 'AtprotoSource not initialized' }; 6111 - 6112 - const entry = registry.getFeature(args.id); 6113 - if (!entry) return { error: `Feature not found: ${args.id}` }; 6114 - if (entry.source.type !== 'atproto' || !entry.source.uri) { 6115 - return { error: 'Feature is not from AT Protocol' }; 6116 - } 6117 - 6118 - try { 6119 - // Resolve to get latest metadata 6120 - const metadata = await source.resolve(entry.source.uri); 6121 - const newManifest = metadata.manifest; 6122 - const newVersion = metadata.source.version || newManifest.version; 6123 - const currentVersion = entry.source.version || '0.0.0'; 6124 - 6125 - // Validate there is actually a newer version 6126 - if (compareSemverSimple(newVersion || '0.0.0', currentVersion) <= 0) { 6127 - return { error: 'No newer version available' }; 6128 - } 6129 - 6130 - // Check for capability expansion 6131 - const capComparison = compareCapabilitiesSimple( 6132 - entry.grantedCapabilities, 6133 - newManifest.capabilities 6134 - ); 6135 - 6136 - if (capComparison.expanded && !args.userApproved) { 6137 - return { 6138 - error: 'Capabilities expanded — user approval required', 6139 - capabilitiesChanged: true, 6140 - added: capComparison.added, 6141 - removed: capComparison.removed, 6142 - }; 6143 - } 6144 - 6145 - // Fetch and install 6146 - const bundle = await source.fetch(entry.source.uri); 6147 - const installResult = installFromBundle( 6148 - bundle, 6149 - metadata.source, 6150 - registry, 6151 - { force: true, userApproved: args.userApproved || !capComparison.expanded } 6152 - ); 6153 - 6154 - if (!installResult.success) { 6155 - // Log failure 6156 - writeFeatureHistory( 6157 - args.id, 'update-failed', newVersion, 6158 - 'atproto', entry.source.uri, entry.source.publisher 6159 - ); 6160 - return { error: installResult.error }; 6161 - } 6162 - 6163 - // Clear availableVersion 6164 - const updatedEntry = registry.getFeature(args.id); 6165 - if (updatedEntry) { 6166 - updatedEntry.availableVersion = undefined; 6167 - updatedEntry.lastUpdateCheck = new Date().toISOString(); 6168 - registry.registerFeature(updatedEntry); 6169 - } 6170 - 6171 - registry.saveRegistry(); 6172 - 6173 - // Write history 6174 - writeFeatureHistory( 6175 - args.id, 'update', newVersion, 6176 - 'atproto', entry.source.uri, entry.source.publisher, 6177 - installResult.entry?.grantedCapabilities as unknown as Record<string, unknown> 6178 - ); 6179 - 6180 - // Publish event 6181 - publish('feature-registry', 'feature:updated', { 6182 - id: args.id, 6183 - name: entry.name, 6184 - previousVersion: currentVersion, 6185 - newVersion, 6186 - }); 6187 - 6188 - return { success: true, entry: installResult.entry }; 6189 - } catch (err) { 6190 - const message = err instanceof Error ? err.message : String(err); 6191 - writeFeatureHistory( 6192 - args.id, 'update-failed', undefined, 6193 - 'atproto', entry.source.uri, entry.source.publisher 6194 - ); 6195 - return { error: `Update failed: ${message}` }; 6196 - } 6197 - }); 6198 - 6199 - ipcMain.handle('feature-update:set-policy', async (_event, args: { 6200 - token: string; 6201 - id: string; 6202 - policy: 'auto' | 'notify' | 'pinned'; 6203 - }) => { 6204 - const grant = getGrantForToken(args.token); 6205 - if (!grant) return { error: 'Invalid token' }; 6206 - 6207 - const registry = getFeatureRegistry(); 6208 - if (!registry) return { error: 'Feature registry not initialized' }; 6209 - 6210 - const entry = registry.getFeature(args.id); 6211 - if (!entry) return { error: `Feature not found: ${args.id}` }; 6212 - 6213 - const validPolicies = ['auto', 'notify', 'pinned']; 6214 - if (!validPolicies.includes(args.policy)) { 6215 - return { error: `Invalid policy: ${args.policy}` }; 6216 - } 6217 - 6218 - entry.updatePolicy = args.policy; 6219 - 6220 - if (args.policy === 'pinned') { 6221 - entry.pinnedVersion = entry.source.version; 6222 - } else { 6223 - entry.pinnedVersion = undefined; 6224 - } 6225 - 6226 - registry.registerFeature(entry); 6227 - registry.saveRegistry(); 6228 - 6229 - return { success: true, policy: entry.updatePolicy, pinnedVersion: entry.pinnedVersion }; 6230 - }); 6231 - 6232 - // ── Feature Dev Workflow ── 6233 - 6234 - ipcMain.handle('feature-dev:pick-directory', async () => { 6235 - try { 6236 - const result = await dialog.showOpenDialog({ 6237 - properties: ['openDirectory', 'createDirectory'], 6238 - title: 'Choose directory for new feature', 6239 - }); 6240 - if (result.canceled || result.filePaths.length === 0) { 6241 - return { canceled: true }; 6242 - } 6243 - return { path: result.filePaths[0] }; 6244 - } catch (err) { 6245 - const message = err instanceof Error ? err.message : String(err); 6246 - return { error: `Failed to open directory picker: ${message}` }; 6247 - } 6248 - }); 6249 - 6250 - ipcMain.handle('feature-dev:create', async (_event, args: { 6251 - token: string; 6252 - directoryPath: string; 6253 - featureId: string; 6254 - featureName: string; 6255 - }) => { 6256 - const grant = getGrantForToken(args.token); 6257 - if (!grant) return { error: 'Invalid token' }; 6258 - 6259 - const registry = getFeatureRegistry(); 6260 - if (!registry) return { error: 'Feature registry not initialized' }; 6261 - 6262 - // Validate featureId: lowercase, alphanumeric + hyphens, no spaces 6263 - const idRegex = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; 6264 - if (!args.featureId || args.featureId.length < 2 || !idRegex.test(args.featureId)) { 6265 - return { error: 'Feature ID must be lowercase alphanumeric with hyphens (min 2 chars, no leading/trailing hyphens)' }; 6266 - } 6267 - 6268 - if (!args.featureName || !args.featureName.trim()) { 6269 - return { error: 'Feature name is required' }; 6270 - } 6271 - 6272 - // Check the parent directory exists 6273 - const parentDir = path.dirname(args.directoryPath); 6274 - if (!fs.existsSync(parentDir)) { 6275 - return { error: `Parent directory does not exist: ${parentDir}` }; 6276 - } 6277 - 6278 - // Check for conflicts in registry 6279 - const existing = registry.getFeature(args.featureId); 6280 - if (existing) { 6281 - return { error: `Feature "${args.featureId}" is already registered in the registry` }; 6282 - } 6283 - 6284 - try { 6285 - // Create directory if it doesn't exist 6286 - if (!fs.existsSync(args.directoryPath)) { 6287 - fs.mkdirSync(args.directoryPath, { recursive: true }); 6288 - } 6289 - 6290 - const featureId = args.featureId; 6291 - const featureName = args.featureName.trim(); 6292 - 6293 - // Write scaffold files 6294 - const manifestContent = JSON.stringify({ 6295 - manifestVersion: 3, 6296 - id: featureId, 6297 - name: featureName, 6298 - version: '0.1.0', 6299 - description: '', 6300 - tiles: [ 6301 - { 6302 - id: 'background', 6303 - url: 'background.html', 6304 - lazy: true, 6305 - }, 6306 - { 6307 - id: 'home', 6308 - url: 'home.html', 6309 - width: 800, 6310 - height: 600, 6311 - title: featureName, 6312 - }, 6313 - ], 6314 - capabilities: { 6315 - pubsub: { scopes: ['self', 'global'] }, 6316 - commands: true, 6317 - }, 6318 - commands: [ 6319 - { 6320 - name: featureId, 6321 - description: `Open ${featureName}`, 6322 - action: { type: 'window', url: 'home.html' }, 6323 - }, 6324 - ], 6325 - }, null, 2); 6326 - 6327 - const backgroundHtml = `<!DOCTYPE html> 6328 - <html> 6329 - <head> 6330 - <meta charset="UTF-8"> 6331 - <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"> 6332 - <title>${featureName} Background</title> 6333 - </head> 6334 - <body> 6335 - <script type="module"> 6336 - import { init, uninit } from './background.js'; 6337 - 6338 - const api = window.app; 6339 - const hasPeekAPI = !!api; 6340 - 6341 - if (hasPeekAPI && typeof api.initialize === 'function') { 6342 - // V2 Tile Runtime 6343 - if (api.pubsub && !api.publish) { 6344 - api.publish = api.pubsub.publish; 6345 - api.subscribe = api.pubsub.subscribe; 6346 - } 6347 - 6348 - await api.initialize(); 6349 - 6350 - if (init) await init(); 6351 - 6352 - api.onShutdown(() => { 6353 - if (uninit) uninit(); 6354 - }); 6355 - } else if (hasPeekAPI) { 6356 - // V1 Legacy Runtime 6357 - if (init) await init(); 6358 - 6359 - api.publish('ext:ready', { 6360 - id: '${featureId}', 6361 - registeredTopics: Object.keys(window._cmdHandlers || {}).map(n => \`cmd:execute:\${n}\`), 6362 - manifest: { id: '${featureId}', version: '0.1.0' } 6363 - }); 6364 - 6365 - api.subscribe('app:shutdown', () => { 6366 - if (uninit) uninit(); 6367 - }); 6368 - } 6369 - <${'/'}script> 6370 - </body> 6371 - </html>`; 6372 - 6373 - const backgroundJs = `/** 6374 - * ${featureName} — Background script 6375 - * 6376 - * This runs in a hidden background context. Use it for: 6377 - * - Registering commands 6378 - * - Subscribing to events 6379 - * - Background processing 6380 - * 6381 - * Export init() to run setup, uninit() for cleanup. 6382 - */ 6383 - 6384 - const api = window.app; 6385 - 6386 - export async function init() { 6387 - console.log('[${featureId}] Background initialized'); 6388 - 6389 - // Register commands, subscribe to events, etc. 6390 - // Example: 6391 - // api.commands.register({ 6392 - // name: '${featureId}', 6393 - // description: 'Open ${featureName}', 6394 - // execute: () => { 6395 - // api.window.open('peek://${featureId}/home.html', { 6396 - // key: '${featureId}-home', 6397 - // width: 800, 6398 - // height: 600, 6399 - // title: '${featureName}' 6400 - // }); 6401 - // } 6402 - // }); 6403 - } 6404 - 6405 - export function uninit() { 6406 - console.log('[${featureId}] Background shutdown'); 6407 - } 6408 - `; 6409 - 6410 - const homeHtml = `<!DOCTYPE html> 6411 - <html> 6412 - <head> 6413 - <meta charset="UTF-8"> 6414 - <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"> 6415 - <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1"> 6416 - <title>${featureName}</title> 6417 - <link rel="stylesheet" type="text/css" href="home.css"> 6418 - </head> 6419 - <body> 6420 - <header class="header"> 6421 - <h1 class="header-title">${featureName}</h1> 6422 - </header> 6423 - 6424 - <main class="content"> 6425 - <div class="welcome"> 6426 - <h2>Welcome to ${featureName}</h2> 6427 - <p>Edit <code>home.html</code>, <code>home.js</code>, and <code>home.css</code> to build your feature UI.</p> 6428 - <p>Use the Reload button in the Features Manager to see changes without restarting.</p> 6429 - </div> 6430 - </main> 6431 - 6432 - <script> 6433 - // V2 tile runtime compat shims 6434 - (function() { 6435 - const api = window.app; 6436 - if (!api) return; 6437 - const isTileV2 = typeof api.initialize === 'function'; 6438 - if (!isTileV2) return; 6439 - if (api.pubsub && !api.publish) { 6440 - api.publish = api.pubsub.publish; 6441 - api.subscribe = api.pubsub.subscribe; 6442 - } 6443 - })(); 6444 - <${'/'}script> 6445 - <script type="module" src="home.js"><${'/'}script> 6446 - </body> 6447 - </html>`; 6448 - 6449 - const homeJs = `/** 6450 - * ${featureName} — Home page script 6451 - * 6452 - * This is the main UI for your feature. 6453 - * The Peek API is available at window.app 6454 - */ 6455 - 6456 - const api = window.app; 6457 - const hasPeekAPI = !!api; 6458 - 6459 - async function init() { 6460 - if (hasPeekAPI && typeof api.initialize === 'function') { 6461 - await api.initialize(); 6462 - } 6463 - 6464 - console.log('[${featureId}] Home page loaded'); 6465 - 6466 - // Your UI logic here 6467 - } 6468 - 6469 - init(); 6470 - `; 6471 - 6472 - const homeCss = `/* Import Peek theme variables */ 6473 - @import url('peek://theme/variables.css'); 6474 - 6475 - * { 6476 - box-sizing: border-box; 6477 - margin: 0; 6478 - padding: 0; 6479 - } 6480 - 6481 - html { 6482 - font-family: var(--theme-font-sans); 6483 - -webkit-font-smoothing: antialiased; 6484 - font-size: 14px; 6485 - line-height: 1.5; 6486 - } 6487 - 6488 - body { 6489 - background: var(--base00); 6490 - color: var(--base05); 6491 - min-height: 100vh; 6492 - display: flex; 6493 - flex-direction: column; 6494 - } 6495 - 6496 - .header { 6497 - display: flex; 6498 - align-items: center; 6499 - padding: 10px 16px; 6500 - border-bottom: 1px solid var(--base02); 6501 - -webkit-app-region: drag; 6502 - } 6503 - 6504 - .header-title { 6505 - font-size: 15px; 6506 - font-weight: 600; 6507 - color: var(--base05); 6508 - } 6509 - 6510 - .content { 6511 - flex: 1; 6512 - padding: 24px; 6513 - overflow-y: auto; 6514 - } 6515 - 6516 - .welcome { 6517 - max-width: 480px; 6518 - margin: 40px auto; 6519 - text-align: center; 6520 - } 6521 - 6522 - .welcome h2 { 6523 - font-size: 18px; 6524 - font-weight: 600; 6525 - color: var(--base05); 6526 - margin-bottom: 12px; 6527 - } 6528 - 6529 - .welcome p { 6530 - font-size: 13px; 6531 - color: var(--base04); 6532 - margin-bottom: 8px; 6533 - } 6534 - 6535 - .welcome code { 6536 - background: var(--base01); 6537 - border: 1px solid var(--base02); 6538 - border-radius: 3px; 6539 - padding: 1px 4px; 6540 - font-family: var(--theme-font-mono, monospace); 6541 - font-size: 12px; 6542 - } 6543 - `; 6544 - 6545 - // Write all scaffold files 6546 - fs.writeFileSync(path.join(args.directoryPath, 'manifest.json'), manifestContent, 'utf-8'); 6547 - fs.writeFileSync(path.join(args.directoryPath, 'background.html'), backgroundHtml, 'utf-8'); 6548 - fs.writeFileSync(path.join(args.directoryPath, 'background.js'), backgroundJs, 'utf-8'); 6549 - fs.writeFileSync(path.join(args.directoryPath, 'home.html'), homeHtml, 'utf-8'); 6550 - fs.writeFileSync(path.join(args.directoryPath, 'home.js'), homeJs, 'utf-8'); 6551 - fs.writeFileSync(path.join(args.directoryPath, 'home.css'), homeCss, 'utf-8'); 6552 - 6553 - // Resolve capabilities (dev = all granted, like builtin) 6554 - const manifestParsed = JSON.parse(manifestContent); 6555 - const capGrant = resolveCapabilities(featureId, manifestParsed.capabilities, true); 6556 - 6557 - // Register in feature registry 6558 - const entry = { 6559 - id: featureId, 6560 - name: featureName, 6561 - path: args.directoryPath, 6562 - source: { 6563 - type: 'dev' as const, 6564 - version: '0.1.0', 6565 - }, 6566 - grantedCapabilities: capGrant.capabilities, 6567 - userApproved: true, 6568 - installedAt: new Date().toISOString(), 6569 - }; 6570 - 6571 - registry.registerFeature(entry); 6572 - registry.saveRegistry(); 6573 - 6574 - // Write history event 6575 - writeFeatureHistory(featureId, 'install', '0.1.0', 'dev'); 6576 - 6577 - // Publish installed event 6578 - publish('feature-registry', 'feature:installed', { 6579 - id: featureId, 6580 - name: featureName, 6581 - source: { type: 'dev' }, 6582 - }); 6583 - 6584 - return { success: true, entry }; 6585 - } catch (err) { 6586 - const message = err instanceof Error ? err.message : String(err); 6587 - return { error: `Failed to create feature: ${message}` }; 6588 - } 6589 - }); 6590 - 6591 - ipcMain.handle('feature-dev:reload', async (_event, args: { 6592 - token: string; 6593 - featureId: string; 6594 - }) => { 6595 - const grant = getGrantForToken(args.token); 6596 - if (!grant) return { error: 'Invalid token' }; 6597 - 6598 - const registry = getFeatureRegistry(); 6599 - if (!registry) return { error: 'Feature registry not initialized' }; 6600 - 6601 - const entry = registry.getFeature(args.featureId); 6602 - if (!entry) return { error: `Feature not found: ${args.featureId}` }; 6603 - 6604 - try { 6605 - // Strategy: find any webContents whose URL starts with peek://{featureId}/ 6606 - const urlPrefix = `peek://${args.featureId}/`; 6607 - const allContents = webContents.getAllWebContents(); 6608 - let reloaded = 0; 6609 - 6610 - for (const wc of allContents) { 6611 - const url = wc.getURL(); 6612 - if (url.startsWith(urlPrefix)) { 6613 - wc.reload(); 6614 - reloaded++; 6615 - continue; 6616 - } 6617 - 6618 - // Also check frames in extension host (iframes) 6619 - try { 6620 - const mainFrame = wc.mainFrame; 6621 - for (const frame of mainFrame.framesInSubtree) { 6622 - if (frame !== mainFrame && frame.url && frame.url.startsWith(urlPrefix)) { 6623 - // Reload the iframe by navigating it again 6624 - frame.executeJavaScript('location.reload()'); 6625 - reloaded++; 6626 - } 6627 - } 6628 - } catch { 6629 - // Some webContents may not have frames accessible 6630 - } 6631 - } 6632 - 6633 - // Also try the tile launcher's tracked windows 6634 - const manifest = entry.path ? JSON.parse( 6635 - fs.readFileSync(path.join(entry.path, 'manifest.json'), 'utf-8') 6636 - ) : null; 6637 - 6638 - if (manifest && manifest.tiles) { 6639 - for (const tile of manifest.tiles) { 6640 - const win = getTileWindow(args.featureId, tile.id); 6641 - if (win && !win.isDestroyed()) { 6642 - win.webContents.reload(); 6643 - reloaded++; 6644 - } 6645 - } 6646 - } 6647 - 6648 - if (reloaded === 0) { 6649 - return { success: true, message: 'No active tiles found to reload. Feature will load on next launch.' }; 6650 - } 6651 - 6652 - return { success: true, message: `Reloaded ${reloaded} tile(s)` }; 6653 - } catch (err) { 6654 - const message = err instanceof Error ? err.message : String(err); 6655 - return { error: `Failed to reload feature: ${message}` }; 6656 - } 6657 - }); 6658 - 6659 - ipcMain.handle('feature-dev:open-devtools', async (_event, args: { 6660 - token: string; 6661 - featureId: string; 6662 - }) => { 6663 - const grant = getGrantForToken(args.token); 6664 - if (!grant) return { error: 'Invalid token' }; 6665 - 6666 - try { 6667 - const urlPrefix = `peek://${args.featureId}/`; 6668 - const allContents = webContents.getAllWebContents(); 6669 - let opened = 0; 6670 - 6671 - // First try direct BrowserWindows with matching URLs 6672 - for (const wc of allContents) { 6673 - const url = wc.getURL(); 6674 - if (url.startsWith(urlPrefix)) { 6675 - wc.openDevTools(); 6676 - opened++; 6677 - } 6678 - } 6679 - 6680 - // If no direct match, check extension host frames 6681 - if (opened === 0) { 6682 - for (const wc of allContents) { 6683 - try { 6684 - const mainFrame = wc.mainFrame; 6685 - for (const frame of mainFrame.framesInSubtree) { 6686 - if (frame !== mainFrame && frame.url && frame.url.startsWith(urlPrefix)) { 6687 - // For iframes in extension host, open devtools on the host window 6688 - // (Electron doesn't support opening devtools on individual frames) 6689 - wc.openDevTools(); 6690 - opened++; 6691 - break; 6692 - } 6693 - } 6694 - } catch { 6695 - // Skip inaccessible webContents 6696 - } 6697 - if (opened > 0) break; 6698 - } 6699 - } 6700 - 6701 - if (opened === 0) { 6702 - return { error: 'No active tiles found. The feature may not be loaded yet.' }; 6703 - } 6704 - 6705 - return { success: true, message: `Opened DevTools for ${opened} context(s)` }; 6706 - } catch (err) { 6707 - const message = err instanceof Error ? err.message : String(err); 6708 - return { error: `Failed to open DevTools: ${message}` }; 6709 - } 6710 - }); 6711 - 6712 - // ── feature-devtools:open ── 6713 - // 6714 - // Opens DevTools for ANY tile feature (installed or dev-source), unlike 6715 - // `feature-dev:open-devtools` which is token-gated and primarily used for 6716 - // dev-source features. DevTools is a developer affordance — no capability 6717 - // check required, but we gate by caller origin so only privileged UIs 6718 - // (the main app, features-manager, etc.) can open devtools. 6719 - // 6720 - // Args: { featureId: string, entryId?: string } 6721 - // - featureId — the tile id 6722 - // - entryId — which tile entry (defaults to 'background') 6723 - ipcMain.handle('feature-devtools:open', async (ev, args: { 6724 - featureId: string; 6725 - entryId?: string; 6726 - }) => { 6727 - // Origin gate: only allow from privileged callers. The main app window 6728 - // and all builtin peek:// surfaces are trusted. External web content 6729 - // should never reach here (they can't reach ipcMain anyway), but this 6730 - // is a cheap belt-and-suspenders check. 6731 - try { 6732 - const senderUrl = ev.sender.getURL(); 6733 - if (!senderUrl.startsWith('peek://')) { 6734 - return { error: 'Unauthorized origin' }; 6735 - } 6736 - } catch { 6737 - return { error: 'Unable to verify caller origin' }; 6738 - } 6739 - 6740 - if (!args || !args.featureId) { 6741 - return { error: 'featureId is required' }; 6742 - } 6743 - 6744 - const entryId = args.entryId || 'background'; 6745 - 6746 - try { 6747 - // First try the tile launcher's tracked windows (v2 tiles — installed or dev). 6748 - const win = getTileWindow(args.featureId, entryId); 6749 - if (win && !win.isDestroyed()) { 6750 - win.webContents.openDevTools({ mode: 'detach' }); 6751 - return { success: true, message: `Opened DevTools for ${args.featureId}:${entryId}` }; 6752 - } 6753 - 6754 - // Fallback: scan webContents for any window whose URL belongs to this 6755 - // tile. Handles cases where the tile is running in a window we don't 6756 - // track (e.g. extension host iframe, or a window entry not recorded 6757 - // in the launcher map). 6758 - const urlPrefix = `peek://${args.featureId}/`; 6759 - const allContents = webContents.getAllWebContents(); 6760 - let opened = 0; 6761 - for (const wc of allContents) { 6762 - if (wc.getURL().startsWith(urlPrefix)) { 6763 - wc.openDevTools({ mode: 'detach' }); 6764 - opened++; 6765 - } 6766 - } 6767 - 6768 - // Also scan extension host iframes — same pattern as feature-dev:open-devtools. 6769 - if (opened === 0) { 6770 - for (const wc of allContents) { 6771 - try { 6772 - const mainFrame = wc.mainFrame; 6773 - for (const frame of mainFrame.framesInSubtree) { 6774 - if (frame !== mainFrame && frame.url && frame.url.startsWith(urlPrefix)) { 6775 - wc.openDevTools({ mode: 'detach' }); 6776 - opened++; 6777 - break; 6778 - } 6779 - } 6780 - } catch { 6781 - // Skip inaccessible webContents 6782 - } 6783 - if (opened > 0) break; 6784 - } 6785 - } 6786 - 6787 - if (opened === 0) { 6788 - return { error: `No active tile found for ${args.featureId}:${entryId}. The feature may not be loaded yet.` }; 6789 - } 6790 - 6791 - return { success: true, message: `Opened DevTools for ${opened} context(s)` }; 6792 - } catch (err) { 6793 - const message = err instanceof Error ? err.message : String(err); 6794 - return { error: `Failed to open DevTools: ${message}` }; 6795 - } 6796 - }); 6797 - 6798 - ipcMain.handle('feature-dev:validate', async (_event, args: { 6799 - token: string; 6800 - featureId: string; 6801 - }) => { 6802 - const grant = getGrantForToken(args.token); 6803 - if (!grant) return { error: 'Invalid token' }; 6804 - 6805 - const registry = getFeatureRegistry(); 6806 - if (!registry) return { error: 'Feature registry not initialized' }; 6807 - 6808 - const entry = registry.getFeature(args.featureId); 6809 - if (!entry) return { error: `Feature not found: ${args.featureId}` }; 6810 - 6811 - const errors: string[] = []; 6812 - const warnings: string[] = []; 6813 - 6814 - try { 6815 - const featurePath = entry.path; 6816 - 6817 - // Check directory exists 6818 - if (!fs.existsSync(featurePath)) { 6819 - return { valid: false, errors: [`Feature directory not found: ${featurePath}`], warnings: [] }; 6820 - } 6821 - 6822 - // Read and validate manifest 6823 - const manifestPath = path.join(featurePath, 'manifest.json'); 6824 - if (!fs.existsSync(manifestPath)) { 6825 - return { valid: false, errors: ['manifest.json not found'], warnings: [] }; 6826 - } 6827 - 6828 - let raw: Record<string, unknown>; 6829 - try { 6830 - const manifestJson = fs.readFileSync(manifestPath, 'utf-8'); 6831 - raw = JSON.parse(manifestJson); 6832 - } catch (parseErr) { 6833 - return { valid: false, errors: [`Invalid JSON in manifest.json: ${parseErr}`], warnings: [] }; 6834 - } 6835 - 6836 - // Check manifest version 6837 - const version = detectManifestVersion(raw); 6838 - if (version !== 'v2') { 6839 - errors.push(`Manifest is ${version}, expected v2`); 6840 - } 6841 - 6842 - // Run schema validation 6843 - const validationErrors = validateTileManifest(raw); 6844 - for (const ve of validationErrors) { 6845 - errors.push(`${ve.path}: ${ve.message}`); 6846 - } 6847 - 6848 - // Check that all files referenced in tiles[].url exist on disk 6849 - const tiles = raw.tiles as Array<{ id: string; url: string }> | undefined; 6850 - if (tiles && Array.isArray(tiles)) { 6851 - for (const tile of tiles) { 6852 - if (tile.url) { 6853 - const tilePath = path.join(featurePath, tile.url); 6854 - if (!fs.existsSync(tilePath)) { 6855 - errors.push(`Tile "${tile.id}" references "${tile.url}" which does not exist`); 6856 - } 6857 - } 6858 - } 6859 - } 6860 - 6861 - // Check settingsSchema file if declared 6862 - const settingsSchema = raw.settingsSchema as string | undefined; 6863 - if (settingsSchema) { 6864 - const schemaPath = path.join(featurePath, settingsSchema); 6865 - if (!fs.existsSync(schemaPath)) { 6866 - errors.push(`Settings schema file "${settingsSchema}" not found`); 6867 - } 6868 - } 6869 - 6870 - // Warnings for common issues 6871 - if (!raw.description || (raw.description as string).trim() === '') { 6872 - warnings.push('No description set in manifest'); 6873 - } 6874 - if (!raw.version) { 6875 - warnings.push('No version set in manifest'); 6876 - } 6877 - 6878 - return { 6879 - valid: errors.length === 0, 6880 - errors, 6881 - warnings, 6882 - }; 6883 - } catch (err) { 6884 - const message = err instanceof Error ? err.message : String(err); 6885 - return { valid: false, errors: [`Validation failed: ${message}`], warnings: [] }; 6886 - } 6887 - }); 5709 + // NOTE: bare `feature-*` ipcMain handlers (feature-browse, feature-publish, 5710 + // feature-update, feature-dev, feature-devtools) were deleted in the 5711 + // v1-removal pass. Every caller routes through the strict 5712 + // `tile:features:*` mirrors above, which enforce token + capability gating. 5713 + // See `tile-preload.cts` `api.features` for the renderer surface. 6888 5714 6889 5715 // ── tile:extensions:* strict shims ──────────────────────────────── 6890 5716 //
+1
docs/tasks.md
··· 36 36 37 37 ## Bugs 38 38 39 + - [ ] **Migrate bootstrap IPC channels to tile:lifecycle:* namespace.** `session-restore-pending` and `frontend-ready` in `backend/electron/entry.ts` are bare `ipcMain.handle()` registrations from before the tile system initializes. They're only reachable from trustedBuiltin renderers via `api.invoke()`, so the security gap is theoretical, but they're the last bare main-process IPC handlers outside `tile-ipc-gate.ts`. Decision skipped 2026-04-24: leaving them bare for now since they exist *before* any tile loads — the indirection through tile-ipc-gate would require either bootstrapping the gate earlier or having two IPC modes (bootstrap vs. post-init). Worth revisiting if tile-ipc-gate ever gains a "no token required for these specific channels" mode, or if entry.ts gets folded into a tile lifecycle. 39 40 - [ ] **Page host window jumps to wrong position after switching primary monitor.** Repro: connect external monitor, set it primary; open page host (cmd+L); disconnect/swap so the laptop becomes primary; cmd+L again — the whole window shifts to a different (off-screen / wrong-display) position. Likely cause: `computeWindowBounds`/canvas positioning caches screen bounds at first compute or reads stale `screen.getPrimaryDisplay()`/`getDisplayNearestPoint` results across display topology changes. Fix path: subscribe to Electron's `screen` `display-added`/`display-removed`/`display-metrics-changed` events and recompute the page-host window's normal+maximized bounds against current displays before show; clamp restored positions to a visible display. Surfaced 2026-04-24. 40 41 - [x] **post.nl → postnl.nl redirect shows blank page.** Fixed 2026-04-17 commit `5dfa3e7a` — `did-redirect-navigation` listener in `app/page/page.js` updates `latestNavigationUrl` so the did-fail-load(-3) handler doesn't abort the redirect target mid-load. 41 42 - [ ] **v2 tile `cmd:execute:<name>:result` doesn't reach v1 extension-host subscribers.** (Will be obsolete after v1 removal — see [v1-removal-plan.md](v1-removal-plan.md). Documented here in case a quick patch is desired before that lands.) Tests in `smoke.spec.ts` (Command Execution describe — tag/untag/tagset/widget-update tests, ~7 of them) publish `cmd:execute:tag` from bgWindow with `expectResult:true, resultTopic:'cmd:execute:tag:result'`, then subscribe to that result topic. The tags v2 tile receives the publish, runs `executeTag()`, and tile-preload sends `tile:pubsub:publish` with the result topic — BUT the bgWindow subscriber never fires. Confirmed: 2026-04-17 added `cmd:execute:*` to tags' pubsub allowlist (in `b3a80b27`) and `waitForCommand` in beforeAll (in `daa4221a`); both necessary but tests still time out at 10s. Per Opus agent triage: bgWindow is `peek://app/background.html`, NOT in `extensionHostWindow`/`extensionWindows`/`getAllTileWindows()` — `extensionBroadcaster` skips it. bgWindow subscribes via legacy `IPC_CHANNELS.SUBSCRIBE` (`ipc.ts:4466`); the in-proc callback should fire on `publish()` but doesn't. That's the next concrete investigation.