experiments in a post-browser web
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};