experiments in a post-browser web
at main 360 lines 9.2 kB view raw
1/** 2 * Scripts Extension Background Script 3 * 4 * Manages userscripts/content scripts system 5 * 6 * Features: 7 * - Script storage and management 8 * - Pattern matching and execution 9 * - Scripts manager UI 10 * - Command registration 11 * 12 * Runs in isolated extension process (peek://ext/scripts/background.html) 13 */ 14 15import { scriptExecutor } from './script-executor.js'; 16import { registerNoun, unregisterNoun } from 'peek://ext/cmd/nouns.js'; 17 18const api = window.app; 19 20// ===== BroadcastChannel for intra-extension messaging ===== 21// Falls back to IPC pubsub if BroadcastChannel is unavailable (e.g., custom protocol origins) 22 23let scriptsChannel = null; 24const scriptsChannelHandlers = {}; 25 26try { 27 scriptsChannel = new BroadcastChannel('scripts'); 28 scriptsChannel.onmessage = (e) => { 29 const { topic, data } = e.data; 30 if (scriptsChannelHandlers[topic]) { 31 for (const handler of scriptsChannelHandlers[topic]) { 32 handler(data); 33 } 34 } 35 }; 36} catch (err) { 37 console.warn('[scripts] BroadcastChannel unavailable, using IPC fallback:', err.message); 38} 39 40function onChannel(topic, handler) { 41 if (!scriptsChannelHandlers[topic]) scriptsChannelHandlers[topic] = []; 42 scriptsChannelHandlers[topic].push(handler); 43 if (!scriptsChannel) api.subscribe(topic, handler, api.scopes.GLOBAL); 44} 45 46function emitChannel(topic, data) { 47 if (scriptsChannel) { 48 scriptsChannel.postMessage({ topic, data }); 49 } else { 50 api.publish(topic, data, api.scopes.GLOBAL); 51 } 52} 53 54// Default settings 55const defaults = { 56 scripts: [] 57}; 58 59// In-memory cache of scripts 60let currentSettings = { scripts: [] }; 61 62/** 63 * Load scripts from datastore 64 */ 65const loadSettings = async () => { 66 const result = await api.settings.get(); 67 if (result.success && result.data) { 68 return { 69 scripts: result.data.scripts || defaults.scripts 70 }; 71 } 72 return defaults; 73}; 74 75/** 76 * Save scripts to datastore 77 */ 78const saveSettings = async (settings) => { 79 const result = await api.settings.set(settings); 80 if (!result.success) { 81 console.error('[ext:scripts] Failed to save settings:', result.error); 82 } 83 return result; 84}; 85 86/** 87 * Generate unique ID for script 88 */ 89const generateId = () => { 90 return `script_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; 91}; 92 93/** 94 * Open scripts manager UI 95 */ 96const openScriptsManager = (params = {}) => { 97 let url = 'peek://scripts/manager.html'; 98 if (params.scriptId) { 99 url += `?scriptId=${params.scriptId}`; 100 } 101 102 api.window.open(url, { 103 key: 'scripts-manager', 104 width: 1200, 105 height: 800, 106 title: 'Scripts Manager' 107 }); 108}; 109 110/** 111 * Create a new script 112 */ 113const createScript = async (scriptData) => { 114 const script = { 115 id: generateId(), 116 name: scriptData.name || 'Untitled Script', 117 description: scriptData.description || '', 118 code: scriptData.code || '// Your script here\nreturn { success: true };', 119 matchPatterns: scriptData.matchPatterns || ['*://*/*'], 120 excludePatterns: scriptData.excludePatterns || [], 121 runAt: scriptData.runAt || 'document-end', 122 enabled: scriptData.enabled !== undefined ? scriptData.enabled : true, 123 createdAt: Date.now(), 124 updatedAt: Date.now(), 125 lastExecutedAt: null 126 }; 127 128 currentSettings.scripts.push(script); 129 await saveSettings(currentSettings); 130 131 // Notify listeners via BroadcastChannel 132 emitChannel('scripts:created', { script }); 133 134 return { success: true, data: script }; 135}; 136 137/** 138 * Update an existing script 139 */ 140const updateScript = async (scriptId, updates) => { 141 const scriptIndex = currentSettings.scripts.findIndex(s => s.id === scriptId); 142 if (scriptIndex === -1) { 143 return { success: false, error: 'Script not found' }; 144 } 145 146 currentSettings.scripts[scriptIndex] = { 147 ...currentSettings.scripts[scriptIndex], 148 ...updates, 149 updatedAt: Date.now() 150 }; 151 152 await saveSettings(currentSettings); 153 154 // Notify listeners via BroadcastChannel 155 emitChannel('scripts:updated', { scriptId, script: currentSettings.scripts[scriptIndex] }); 156 157 return { success: true, data: currentSettings.scripts[scriptIndex] }; 158}; 159 160/** 161 * Delete a script 162 */ 163const deleteScript = async (scriptId) => { 164 const scriptIndex = currentSettings.scripts.findIndex(s => s.id === scriptId); 165 if (scriptIndex === -1) { 166 return { success: false, error: 'Script not found' }; 167 } 168 169 currentSettings.scripts.splice(scriptIndex, 1); 170 await saveSettings(currentSettings); 171 172 // Notify listeners via BroadcastChannel 173 emitChannel('scripts:deleted', { scriptId }); 174 175 return { success: true }; 176}; 177 178/** 179 * Execute a script against a URL 180 */ 181const executeScript = async (scriptId, executionContext) => { 182 const script = currentSettings.scripts.find(s => s.id === scriptId); 183 if (!script) { 184 return { success: false, error: 'Script not found' }; 185 } 186 187 if (!script.enabled) { 188 return { success: false, error: 'Script is disabled' }; 189 } 190 191 try { 192 const result = await scriptExecutor.executeScript(script, executionContext); 193 194 // Update last executed time 195 await updateScript(scriptId, { lastExecutedAt: Date.now() }); 196 197 // Publish execution result 198 api.publish('scripts:executed', { 199 scriptId, 200 result, 201 url: executionContext.url 202 }, api.scopes.GLOBAL); 203 204 return { success: true, data: result }; 205 } catch (error) { 206 console.error('[ext:scripts] Execution error:', error); 207 return { 208 success: false, 209 error: error.message, 210 stack: error.stack 211 }; 212 } 213}; 214 215/** 216 * Get all scripts 217 */ 218const getScripts = async () => { 219 return { success: true, data: currentSettings.scripts }; 220}; 221 222/** 223 * Get a single script 224 */ 225const getScript = async (scriptId) => { 226 const script = currentSettings.scripts.find(s => s.id === scriptId); 227 if (!script) { 228 return { success: false, error: 'Script not found' }; 229 } 230 return { success: true, data: script }; 231}; 232 233/** 234 * Register commands via noun API 235 */ 236const registerCommands = () => { 237 registerNoun({ 238 name: 'scripts', 239 singular: 'script', 240 description: 'Userscripts and content scripts', 241 242 query: async ({ search }) => { 243 const result = await getScripts(); 244 if (!result.success) return { success: false }; 245 let scripts = result.data; 246 if (search) { 247 const s = search.toLowerCase(); 248 scripts = scripts.filter(sc => sc.name.toLowerCase().includes(s)); 249 } 250 if (scripts.length === 0) { 251 return { output: 'No scripts found.', mimeType: 'text/plain' }; 252 } 253 return { 254 success: true, 255 output: { 256 data: scripts.map(sc => ({ 257 id: sc.id, 258 name: sc.name, 259 description: sc.description, 260 enabled: sc.enabled, 261 matchPatterns: sc.matchPatterns 262 })), 263 mimeType: 'application/json', 264 title: `Scripts (${scripts.length})` 265 } 266 }; 267 }, 268 269 browse: async () => { openScriptsManager(); }, 270 271 open: async (ctx) => { 272 if (ctx.search) { 273 const result = await getScripts(); 274 if (result.success) { 275 const match = result.data.find(s => s.name.toLowerCase().includes(ctx.search.toLowerCase())); 276 if (match) { 277 openScriptsManager({ scriptId: match.id }); 278 return; 279 } 280 } 281 } 282 openScriptsManager(); 283 }, 284 285 create: async ({ search }) => { 286 const result = await createScript({ name: search || undefined }); 287 if (result.success) { 288 openScriptsManager({ scriptId: result.data.id }); 289 } 290 return result; 291 }, 292 293 produces: 'application/json' 294 }); 295 296}; 297 298/** 299 * Initialize the extension 300 */ 301const init = async () => { 302 // Load scripts from datastore 303 currentSettings = await loadSettings(); 304 305 // Register commands (cmd loads first with its subscribers ready via 100ms head start) 306 registerCommands(); 307 308 // Register global shortcut Command+Shift+S 309 api.shortcuts.register('Command+Shift+S', () => openScriptsManager()); 310 311 // API for manager UI via BroadcastChannel 312 onChannel('scripts:create', async (msg) => { 313 const result = await createScript(msg); 314 emitChannel('scripts:create:response', result); 315 }); 316 317 onChannel('scripts:update', async (msg) => { 318 const result = await updateScript(msg.scriptId, msg.updates); 319 emitChannel('scripts:update:response', result); 320 }); 321 322 onChannel('scripts:delete', async (msg) => { 323 const result = await deleteScript(msg.scriptId); 324 emitChannel('scripts:delete:response', result); 325 }); 326 327 onChannel('scripts:get-all', async () => { 328 const result = await getScripts(); 329 emitChannel('scripts:get-all:response', result); 330 }); 331 332 onChannel('scripts:get', async (msg) => { 333 const result = await getScript(msg.scriptId); 334 emitChannel('scripts:get:response', result); 335 }); 336 337 // Keep scripts:execute on IPC for cross-extension access 338 api.subscribe('scripts:execute', async (msg) => { 339 const result = await executeScript(msg.scriptId, msg.context); 340 api.publish('scripts:execute:response', result, api.scopes.GLOBAL); 341 }, api.scopes.GLOBAL); 342 343}; 344 345/** 346 * Cleanup 347 */ 348const uninit = () => { 349 unregisterNoun('scripts'); 350 api.shortcuts.unregister('Command+Shift+S'); 351}; 352 353export default { 354 id: 'scripts', 355 labels: { 356 name: 'Scripts' 357 }, 358 init, 359 uninit 360};