@rmrdeveloper/sideroom-pi 8.12.0 → 8.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +16 -0
  2. package/assets/visual-context/browser/INSTALL.md +69 -0
  3. package/assets/visual-context/browser/background.js +578 -0
  4. package/assets/visual-context/browser/content.js +198 -0
  5. package/assets/visual-context/browser/icons/README.md +9 -0
  6. package/assets/visual-context/browser/icons/icon-128.png +0 -0
  7. package/assets/visual-context/browser/icons/icon-16.png +0 -0
  8. package/assets/visual-context/browser/icons/icon-32.png +0 -0
  9. package/assets/visual-context/browser/icons/icon-48.png +0 -0
  10. package/assets/visual-context/browser/manifest.json +20 -0
  11. package/assets/visual-context/browser/popup.css +19 -0
  12. package/assets/visual-context/browser/popup.html +27 -0
  13. package/assets/visual-context/browser/popup.js +141 -0
  14. package/extensions/visual-context/bridge.ts +240 -0
  15. package/extensions/visual-context/broker.ts +292 -0
  16. package/extensions/visual-context/envelope.ts +34 -0
  17. package/extensions/visual-context/index.ts +328 -0
  18. package/extensions/visual-context/literal.ts +34 -0
  19. package/extensions/visual-context/markup.ts +104 -0
  20. package/extensions/visual-context/match.ts +222 -0
  21. package/extensions/visual-context/model.ts +180 -0
  22. package/extensions/visual-context/pairing.ts +114 -0
  23. package/extensions/visual-context/parse-astro.ts +166 -0
  24. package/extensions/visual-context/parse-html.ts +72 -0
  25. package/extensions/visual-context/parse-jsx.ts +157 -0
  26. package/extensions/visual-context/parse-svelte.ts +128 -0
  27. package/extensions/visual-context/parse-vue.ts +133 -0
  28. package/extensions/visual-context/session.ts +192 -0
  29. package/extensions/visual-context/source.ts +197 -0
  30. package/extensions/visual-context/ui.ts +57 -0
  31. package/package.json +16 -7
  32. package/skills/sideroom-codebase-design/SKILL.md +28 -1
package/README.md CHANGED
@@ -141,6 +141,22 @@ session. The compact view shows at most five recent paths below an active
141
141
  board; the extended `F8` view scrolls through everything. Paths outside the
142
142
  project are labelled `external`. A new session starts clean.
143
143
 
144
+ ### Browser context — point to the element
145
+
146
+ Press `F7` to authorize the bundled browser companion once per Pi installation.
147
+ It reconnects automatically and remembers Windows and WSL independently; choose
148
+ a destination if several Pi sessions are open. Press `Alt+Shift+S` to select
149
+ several elements without opening the popup; Escape or the shortcut exits.
150
+ Review their local source candidates in Pi and send your
151
+ instruction. The click prepares context; it never starts the agent. No project
152
+ configuration or framework plugin is needed. Source ranges are real, but the
153
+ DOM-to-source relationship is inferred and stays labelled as a candidate.
154
+ Download [the companion ZIP](https://github.com/RMRdeveloper/sideroom-pi/releases/latest/download/sideroom-browser-extension.zip)
155
+ from GitHub Releases, extract it, and load its folder in your browser's developer mode.
156
+ No extension store is required; installation and updates are manual. The same
157
+ companion ships in the npm package. See [browser context](docs/extensions/visual-context.md)
158
+ for installation, Windows/WSL pairing and supported source searches.
159
+
144
160
  ### Grill — settle the words first
145
161
 
146
162
  An on-demand skill for fuzzy plans. It interviews you in `sideroom_ask`
@@ -0,0 +1,69 @@
1
+ # Sideroom Visual Context
2
+
3
+ ## English
4
+
5
+ This is a manually installed companion for Chromium-based browsers 116 or
6
+ later, such as Chrome, Edge or Brave. It is not a store installation. Firefox is
7
+ not supported.
8
+
9
+ 1. Extract the ZIP. Keep the `sideroom-browser-extension` folder in a permanent
10
+ location; the browser loads the extension from this directory.
11
+ 2. Open `chrome://extensions` (or `edge://extensions`), enable developer mode,
12
+ choose **Load unpacked**, and select the folder containing `manifest.json`.
13
+ 3. Install Sideroom Pi with `pi install npm:@rmrdeveloper/sideroom-pi`. Start Pi
14
+ in your project, press `F7`, and choose **Pair browser**.
15
+ 4. Open the companion, paste the private pairing address from Pi, and choose
16
+ **Connect to Pi**. Keep that address private. Choose a destination if several
17
+ Pi sessions are open. Repeat pairing for a separate Windows or WSL installation.
18
+ 5. On a normal web page, press `Alt+Shift+S` to start selecting. Click several
19
+ elements. Press the shortcut again or Escape to return to normal browsing.
20
+ Review pending elements with `F7` in Pi, then send your instruction.
21
+
22
+ Change or assign the shortcut in `chrome://extensions/shortcuts` or
23
+ `edge://extensions/shortcuts`. Browser and operating-system shortcuts can take
24
+ priority. Internal browser pages and other protected pages cannot be inspected.
25
+ Managed browsers can prohibit developer mode.
26
+
27
+ Updates are manual. Download the ZIP from the new Sideroom GitHub Release,
28
+ replace the files in the same folder, and reload the extension from the browser's
29
+ extensions page. Do not remove and reinstall it unless you want to pair again.
30
+ The extension connects only to authorized local Pi installations; it captures
31
+ DOM descriptions, not form values or editable text. Source locations are inferred
32
+ candidates, never proven mappings.
33
+
34
+ ## Español
35
+
36
+ Esta extensión se instala manualmente en navegadores basados en Chromium 116 o
37
+ posterior, como Chrome, Edge o Brave. No se instala desde una tienda. Firefox no
38
+ está soportado.
39
+
40
+ 1. Descomprime el ZIP. Guarda la carpeta `sideroom-browser-extension` en una
41
+ ubicación permanente; el navegador carga la extensión desde esa carpeta.
42
+ 2. Abre `chrome://extensions` (o `edge://extensions`), activa el modo
43
+ desarrollador, elige **Cargar desempaquetada** y selecciona la carpeta que
44
+ contiene `manifest.json`.
45
+ 3. Instala Sideroom Pi con `pi install npm:@rmrdeveloper/sideroom-pi`. Inicia Pi
46
+ en tu proyecto, pulsa `F7` y elige **Pair browser**.
47
+ 4. Abre la extensión, pega la dirección privada de emparejamiento de Pi y elige
48
+ **Connect to Pi**. No compartas esa dirección. Elige un destino si hay varias
49
+ sesiones abiertas. Repite el emparejamiento para otra instalación de Windows o WSL.
50
+ 5. En una página web normal, pulsa `Alt+Shift+S` para seleccionar. Haz clic en
51
+ varios elementos. Pulsa de nuevo el atajo o Escape para navegar normalmente.
52
+ Revisa los elementos con `F7` en Pi y envía tu instrucción.
53
+
54
+ Cambia o asigna el atajo en `chrome://extensions/shortcuts` o
55
+ `edge://extensions/shortcuts`. Los atajos del navegador o del sistema pueden
56
+ tener prioridad. No puedes seleccionar elementos de páginas internas del
57
+ navegador ni de otras páginas protegidas. Un navegador administrado puede
58
+ prohibir el modo desarrollador.
59
+
60
+ Las actualizaciones son manuales. Descarga el ZIP de la nueva versión de Sideroom
61
+ en GitHub Releases, reemplaza los archivos en la misma carpeta y recarga la
62
+ extensión desde la página de extensiones del navegador. No la elimines y
63
+ reinstales salvo que quieras volver a emparejarla.
64
+ La extensión solo se conecta a instalaciones locales de Pi que hayas autorizado;
65
+ recoge descripciones de los elementos, no valores de formularios ni texto editable.
66
+ Los archivos de origen son candidatos inferidos, no correspondencias demostradas.
67
+
68
+ Full guide / Guía completa:
69
+ https://github.com/RMRdeveloper/sideroom-pi/blob/main/docs/extensions/visual-context.md
@@ -0,0 +1,578 @@
1
+ const PROTOCOL_VERSION = 2;
2
+ const BRIDGE_PATH = '/sideroom-visual-context';
3
+ const HEARTBEAT_MS = 20_000;
4
+ const RESPONSE_TIMEOUT_MS = 10_000;
5
+ const RETRY_MS = 2_000;
6
+ const RETRY_ALARM = 'sideroom-reconnect';
7
+ const MAX_INSTALLATIONS = 4;
8
+ const INSPECTOR_COMMAND = 'toggle-inspector';
9
+ const IDLE_TITLE = 'Select an element for Pi';
10
+ const ACTIVE_TITLE =
11
+ 'Selecting elements for Pi. Press Escape or the selection shortcut to stop.';
12
+ let inspectorCommands = Promise.resolve();
13
+ const MESSAGE = {
14
+ authenticate: 'authenticate',
15
+ ready: 'ready',
16
+ sessions: 'sessions',
17
+ selection: 'selection',
18
+ received: 'received',
19
+ error: 'error',
20
+ ping: 'ping',
21
+ };
22
+ const WAITING_NOTICE = 'Waiting for Pi. Reconnection is automatic.';
23
+ const PAUSED_NOTICE = 'Disconnected. Your authorizations are remembered.';
24
+ const activeTabs = new Map();
25
+ const receipts = new Map();
26
+ const connections = new Map();
27
+ let paused = false;
28
+ let sessions = [];
29
+ let selected;
30
+ let preferredId;
31
+ let automaticSelection = false;
32
+ let lastAddress = '';
33
+ let lastNotice = 'Pair once with each Pi installation to select elements.';
34
+ const isConnected = () =>
35
+ [...connections.values()].some((entry) => entry.connected);
36
+ const availableSessions = () =>
37
+ [...connections.values()].flatMap((entry) => entry.sessions);
38
+
39
+ function parseUrl(address, failureMessage) {
40
+ try {
41
+ return new URL(address);
42
+ } catch (cause) {
43
+ throw new Error(failureMessage, { cause });
44
+ }
45
+ }
46
+
47
+ function parsePairingAddress(address) {
48
+ const uri = parseUrl(
49
+ address.trim(),
50
+ 'Paste the local pairing address shown by Pi (F7).',
51
+ );
52
+ if (
53
+ uri.protocol !== 'ws:' ||
54
+ !['127.0.0.1', 'localhost'].includes(uri.hostname) ||
55
+ !uri.port ||
56
+ uri.pathname !== BRIDGE_PATH ||
57
+ uri.search ||
58
+ uri.username ||
59
+ uri.password ||
60
+ !/^#[a-f0-9]{64}$/u.test(uri.hash)
61
+ ) {
62
+ throw new Error('Paste the local pairing address shown by Pi (F7).');
63
+ }
64
+ const token = uri.hash.slice(1);
65
+ uri.hash = '';
66
+ return { endpoint: uri.href, token };
67
+ }
68
+
69
+ async function sendToTab(tabId, message) {
70
+ try {
71
+ await chrome.tabs.sendMessage(tabId, message);
72
+ } catch (cause) {
73
+ lastNotice = `Inspector stopped: ${cause.message}`;
74
+ activeTabs.delete(tabId);
75
+ }
76
+ }
77
+
78
+ async function showInspectorIndicator(tabId, { text, title }) {
79
+ await chrome.action.setBadgeBackgroundColor({ tabId, color: '#ffb340' });
80
+ await chrome.action.setBadgeText({ tabId, text });
81
+ await chrome.action.setTitle({ tabId, title });
82
+ }
83
+
84
+ async function stopInspector(tabId) {
85
+ activeTabs.delete(tabId);
86
+ await sendToTab(tabId, { action: 'stop' });
87
+ await showInspectorIndicator(tabId, { text: '', title: IDLE_TITLE });
88
+ }
89
+
90
+ async function stopInspectors() {
91
+ const tabIds = [...activeTabs.keys()];
92
+ activeTabs.clear();
93
+ receipts.clear();
94
+ for (const tabId of tabIds) {
95
+ await stopInspector(tabId);
96
+ }
97
+ }
98
+
99
+ async function startInspector(tab) {
100
+ if (!selected || !isConnected()) {
101
+ throw new Error('Choose an available Pi session first.');
102
+ }
103
+ if (!tab?.id || !/^https?:\/\//u.test(tab.url ?? '')) {
104
+ throw new Error('Open the web page you want to inspect.');
105
+ }
106
+ const target = selected;
107
+ await chrome.scripting.executeScript({
108
+ target: { tabId: tab.id },
109
+ files: ['content.js'],
110
+ });
111
+ const commands = await chrome.commands.getAll();
112
+ const shortcut = commands.find(
113
+ (command) => command.name === INSPECTOR_COMMAND,
114
+ )?.shortcut;
115
+ if (selected !== target) {
116
+ throw new Error('The destination changed. Start the inspector again.');
117
+ }
118
+ activeTabs.set(tab.id, target);
119
+ try {
120
+ await chrome.tabs.sendMessage(tab.id, { action: 'start', shortcut });
121
+ await showInspectorIndicator(tab.id, { text: 'ON', title: ACTIVE_TITLE });
122
+ } catch (cause) {
123
+ await stopInspector(tab.id);
124
+ throw cause;
125
+ }
126
+ }
127
+
128
+ async function toggleInspector(commandTab) {
129
+ let tab = commandTab;
130
+ try {
131
+ await initialized;
132
+ if (!tab) {
133
+ [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
134
+ }
135
+ if (tab?.id && activeTabs.has(tab.id)) {
136
+ await stopInspector(tab.id);
137
+ return;
138
+ }
139
+ await startInspector(tab);
140
+ } catch (cause) {
141
+ lastNotice = `${cause.message} Open the Sideroom extension for details.`;
142
+ await showInspectorIndicator(tab?.id, { text: '!', title: lastNotice });
143
+ }
144
+ }
145
+
146
+ function updateSessions(available) {
147
+ const previous = selected;
148
+ sessions = available;
149
+ selected = sessions.find(
150
+ (session) => session.id === (preferredId ?? previous?.id),
151
+ );
152
+ if (automaticSelection && sessions.length > 1) {
153
+ selected = undefined;
154
+ automaticSelection = false;
155
+ }
156
+ if (!selected && !preferredId && sessions.length === 1) {
157
+ selected = sessions[0];
158
+ automaticSelection = true;
159
+ }
160
+ if (
161
+ previous?.id !== selected?.id ||
162
+ previous?.revision !== selected?.revision
163
+ ) {
164
+ void stopInspectors().catch((cause) => {
165
+ console.error('Sideroom could not stop its inspectors:', cause);
166
+ });
167
+ }
168
+ lastNotice = selected
169
+ ? `Connected to ${selected.name}.`
170
+ : sessions.length
171
+ ? 'Choose which Pi session receives your selections.'
172
+ : WAITING_NOTICE;
173
+ }
174
+
175
+ function closeConnection(entry) {
176
+ const previous = entry.socket;
177
+ entry.socket = undefined;
178
+ entry.connected = false;
179
+ entry.sessions = [];
180
+ clearInterval(entry.heartbeat);
181
+ clearTimeout(entry.retry);
182
+ entry.cancel?.(new Error('The connection was closed.'));
183
+ entry.cancel = undefined;
184
+ previous?.close();
185
+ }
186
+
187
+ async function disconnect() {
188
+ for (const entry of connections.values()) {
189
+ closeConnection(entry);
190
+ }
191
+ sessions = [];
192
+ selected = undefined;
193
+ preferredId = undefined;
194
+ await stopInspectors();
195
+ }
196
+
197
+ function scheduleReconnect(entry) {
198
+ if (paused || entry.blocked || connections.get(entry.endpoint) !== entry) {
199
+ return;
200
+ }
201
+ clearTimeout(entry.retry);
202
+ entry.retry = setTimeout(() => {
203
+ void reconnect(entry);
204
+ }, RETRY_MS);
205
+ // Alarms wake the worker after it has been suspended while Pi is closed.
206
+ void chrome.alarms.create(RETRY_ALARM, { periodInMinutes: 1 });
207
+ }
208
+
209
+ async function openConnection(entry) {
210
+ closeConnection(entry);
211
+ const socket = new WebSocket(entry.endpoint);
212
+ entry.socket = socket;
213
+ await new Promise((resolve, reject) => {
214
+ entry.cancel = reject;
215
+ const timeout = setTimeout(() => {
216
+ socket.close();
217
+ reject(new Error(WAITING_NOTICE));
218
+ }, RESPONSE_TIMEOUT_MS);
219
+ socket.onopen = () =>
220
+ socket.send(
221
+ JSON.stringify({
222
+ type: MESSAGE.authenticate,
223
+ role: 'browser',
224
+ version: PROTOCOL_VERSION,
225
+ token: entry.token,
226
+ }),
227
+ );
228
+ socket.onmessage = async (event) => {
229
+ if (entry.socket !== socket) {
230
+ return;
231
+ }
232
+ try {
233
+ const message = JSON.parse(event.data);
234
+ if (message.type === MESSAGE.ready) {
235
+ if (message.version !== PROTOCOL_VERSION) {
236
+ throw new Error('Reload the updated Sideroom browser extension.');
237
+ }
238
+ clearTimeout(timeout);
239
+ entry.connected = true;
240
+ entry.cancel = undefined;
241
+ entry.sessions = message.sessions;
242
+ updateSessions(availableSessions());
243
+ if (
244
+ [...connections.values()].every(
245
+ (connection) => connection.connected,
246
+ )
247
+ ) {
248
+ await chrome.alarms.clear(RETRY_ALARM);
249
+ }
250
+ entry.heartbeat = setInterval(() => {
251
+ if (socket.readyState === WebSocket.OPEN) {
252
+ socket.send(JSON.stringify({ type: MESSAGE.ping }));
253
+ }
254
+ }, HEARTBEAT_MS);
255
+ resolve();
256
+ }
257
+ if (message.type === MESSAGE.sessions) {
258
+ entry.sessions = message.sessions;
259
+ updateSessions(availableSessions());
260
+ }
261
+ if (message.type === MESSAGE.received) {
262
+ lastNotice = `${String(message.candidates)} source candidates. Review in Pi (F7).`;
263
+ const receipt = receipts.get(message.id);
264
+ receipts.delete(message.id);
265
+ if (receipt?.endpoint === entry.endpoint) {
266
+ await sendToTab(receipt.tabId, {
267
+ action: 'receipt',
268
+ message: lastNotice,
269
+ });
270
+ }
271
+ }
272
+ if (message.type === MESSAGE.error) {
273
+ lastNotice = message.message;
274
+ for (const [selectionId, receipt] of receipts) {
275
+ if (receipt.endpoint === entry.endpoint) {
276
+ receipts.delete(selectionId);
277
+ await sendToTab(receipt.tabId, {
278
+ action: 'receipt',
279
+ message: lastNotice,
280
+ });
281
+ }
282
+ }
283
+ }
284
+ } catch (cause) {
285
+ lastNotice = `Invalid bridge response: ${cause.message}`;
286
+ clearTimeout(timeout);
287
+ socket.close();
288
+ reject(new Error(lastNotice));
289
+ }
290
+ };
291
+ socket.onerror = () => {
292
+ clearTimeout(timeout);
293
+ reject(
294
+ new Error(
295
+ 'Waiting for Pi. Windows must be able to reach WSL localhost when using Pi in WSL.',
296
+ ),
297
+ );
298
+ };
299
+ socket.onclose = (event) => {
300
+ clearTimeout(timeout);
301
+ if (entry.socket !== socket) {
302
+ return;
303
+ }
304
+ entry.socket = undefined;
305
+ entry.connected = false;
306
+ entry.sessions = [];
307
+ entry.blocked = event?.code === 1008;
308
+ clearInterval(entry.heartbeat);
309
+ for (const [selectionId, receipt] of receipts) {
310
+ if (receipt.endpoint === entry.endpoint) {
311
+ receipts.delete(selectionId);
312
+ }
313
+ }
314
+ updateSessions(availableSessions());
315
+ reject(
316
+ new Error(
317
+ entry.blocked
318
+ ? 'Authorization rejected. Pair that installation again from F7.'
319
+ : WAITING_NOTICE,
320
+ ),
321
+ );
322
+ scheduleReconnect(entry);
323
+ };
324
+ });
325
+ }
326
+
327
+ async function reconnect(entry) {
328
+ if (paused || entry.blocked || entry.connected) {
329
+ return;
330
+ }
331
+ if (!entry.attempt) {
332
+ entry.attempt = openConnection(entry)
333
+ .catch((cause) => {
334
+ if (!selected) {
335
+ lastNotice = cause.message;
336
+ }
337
+ scheduleReconnect(entry);
338
+ })
339
+ .finally(() => {
340
+ entry.attempt = undefined;
341
+ });
342
+ }
343
+ await entry.attempt;
344
+ }
345
+
346
+ function remember(address) {
347
+ const pairing = parsePairingAddress(address);
348
+ const existing = connections.get(pairing.endpoint);
349
+ if (existing && existing.token === pairing.token) {
350
+ existing.blocked = false;
351
+ return existing;
352
+ }
353
+ if (!existing && connections.size >= MAX_INSTALLATIONS) {
354
+ throw new Error(
355
+ 'Four installations are already authorized. Forget them before adding another.',
356
+ );
357
+ }
358
+ if (existing) {
359
+ closeConnection(existing);
360
+ }
361
+ const entry = {
362
+ ...pairing,
363
+ address: address.trim(),
364
+ connected: false,
365
+ sessions: [],
366
+ blocked: false,
367
+ };
368
+ connections.set(entry.endpoint, entry);
369
+ return entry;
370
+ }
371
+
372
+ const initialized = chrome.storage.local
373
+ .setAccessLevel({ accessLevel: 'TRUSTED_CONTEXTS' })
374
+ .then(async () => {
375
+ const saved = await chrome.storage.local.get([
376
+ 'pairingAddresses',
377
+ 'pairingAddress',
378
+ 'paused',
379
+ ]);
380
+ const addresses =
381
+ saved.pairingAddresses ??
382
+ (saved.pairingAddress ? [saved.pairingAddress] : []);
383
+ paused = saved.paused === true;
384
+ for (const address of addresses) {
385
+ const entry = remember(address);
386
+ lastAddress = address;
387
+ void reconnect(entry);
388
+ }
389
+ if (paused) {
390
+ lastNotice = PAUSED_NOTICE;
391
+ }
392
+ });
393
+
394
+ async function handleMessage(message, sender) {
395
+ if (sender.id !== chrome.runtime.id) {
396
+ throw new Error('Unknown inspector sender.');
397
+ }
398
+ await initialized;
399
+ const fromPopup = sender.url === chrome.runtime.getURL('popup.html');
400
+ if (!fromPopup) {
401
+ const target = activeTabs.get(sender.tab?.id);
402
+ if (message.action === 'stopped') {
403
+ if (sender.tab?.id) {
404
+ activeTabs.delete(sender.tab.id);
405
+ await showInspectorIndicator(sender.tab.id, {
406
+ text: '',
407
+ title: IDLE_TITLE,
408
+ });
409
+ }
410
+ return { ok: true };
411
+ }
412
+ if (message.action !== 'selection' || !target || !isConnected()) {
413
+ throw new Error('Connect to Pi and start the inspector first.');
414
+ }
415
+ if (target.id !== selected?.id || target.revision !== selected?.revision) {
416
+ throw new Error('The destination changed. Start the inspector again.');
417
+ }
418
+ const entry = [...connections.values()].find(
419
+ (connection) =>
420
+ connection.connected &&
421
+ connection.sessions.some(
422
+ (session) =>
423
+ session.id === target.id && session.revision === target.revision,
424
+ ),
425
+ );
426
+ if (!entry || entry.socket?.readyState !== WebSocket.OPEN) {
427
+ throw new Error('The destination is no longer available.');
428
+ }
429
+ if (receipts.size >= 8) {
430
+ throw new Error('Pi is still processing selections. Try again shortly.');
431
+ }
432
+ const page = parseUrl(
433
+ sender.tab.url,
434
+ 'The selected page has an invalid address.',
435
+ );
436
+ const selection = {
437
+ ...message.selection,
438
+ page: `${page.origin}${page.pathname}`,
439
+ };
440
+ receipts.set(selection.id, {
441
+ tabId: sender.tab.id,
442
+ endpoint: entry.endpoint,
443
+ });
444
+ entry.socket.send(
445
+ JSON.stringify({ type: MESSAGE.selection, selection, target }),
446
+ );
447
+ return { ok: true };
448
+ }
449
+ if (message.action === 'status') {
450
+ for (const entry of connections.values()) {
451
+ void reconnect(entry);
452
+ }
453
+ return {
454
+ ok: true,
455
+ connected: isConnected(),
456
+ notice: lastNotice,
457
+ address: lastAddress,
458
+ sessions,
459
+ selectedId: selected?.id ?? '',
460
+ paused,
461
+ installations: connections.size,
462
+ };
463
+ }
464
+ if (message.action === 'connect') {
465
+ const entry = remember(message.address);
466
+ lastAddress = entry.address;
467
+ paused = false;
468
+ await chrome.storage.local.set({
469
+ pairingAddresses: [...connections.values()].map(
470
+ (connection) => connection.address,
471
+ ),
472
+ paused,
473
+ });
474
+ await chrome.storage.local.remove('pairingAddress');
475
+ await reconnect(entry);
476
+ for (const connection of connections.values()) {
477
+ if (connection !== entry) {
478
+ void reconnect(connection);
479
+ }
480
+ }
481
+ return {
482
+ ok: true,
483
+ connected: isConnected(),
484
+ notice: lastNotice,
485
+ sessions,
486
+ selectedId: selected?.id ?? '',
487
+ address: lastAddress,
488
+ };
489
+ }
490
+ if (message.action === 'disconnect' || message.action === 'forget') {
491
+ paused = true;
492
+ await disconnect();
493
+ await chrome.alarms.clear(RETRY_ALARM);
494
+ if (message.action === 'forget') {
495
+ connections.clear();
496
+ lastAddress = '';
497
+ await chrome.storage.local.remove(['pairingAddress', 'pairingAddresses']);
498
+ }
499
+ await chrome.storage.local.set({ paused });
500
+ lastNotice = lastAddress
501
+ ? PAUSED_NOTICE
502
+ : 'Authorizations forgotten. Pair again from F7.';
503
+ return {
504
+ ok: true,
505
+ connected: false,
506
+ notice: lastNotice,
507
+ address: lastAddress,
508
+ sessions: [],
509
+ selectedId: '',
510
+ };
511
+ }
512
+ if (message.action === 'choose') {
513
+ const destination = sessions.find((session) => session.id === message.id);
514
+ if (!destination) {
515
+ throw new Error('That Pi session is no longer available.');
516
+ }
517
+ await stopInspectors();
518
+ selected = destination;
519
+ preferredId = destination.id;
520
+ automaticSelection = false;
521
+ lastNotice = `Connected to ${selected.name}.`;
522
+ return {
523
+ ok: true,
524
+ connected: isConnected(),
525
+ notice: lastNotice,
526
+ sessions,
527
+ selectedId: selected.id,
528
+ };
529
+ }
530
+ if (message.action === 'inspect') {
531
+ const [tab] = await chrome.tabs.query({
532
+ active: true,
533
+ currentWindow: true,
534
+ });
535
+ await startInspector(tab);
536
+ return { ok: true };
537
+ }
538
+ throw new Error('Unknown inspector action.');
539
+ }
540
+
541
+ chrome.runtime.onMessage.addListener((message, sender, respond) => {
542
+ handleMessage(message, sender).then(respond, (cause) => {
543
+ lastNotice = cause.message;
544
+ respond({ ok: false, message: lastNotice });
545
+ });
546
+ return true;
547
+ });
548
+ chrome.commands.onCommand.addListener((command, tab) => {
549
+ if (command !== INSPECTOR_COMMAND) {
550
+ return;
551
+ }
552
+ inspectorCommands = inspectorCommands
553
+ .then(() => toggleInspector(tab))
554
+ .catch((cause) => {
555
+ console.error('Sideroom could not update its inspector:', cause);
556
+ });
557
+ return inspectorCommands;
558
+ });
559
+ chrome.tabs.onRemoved.addListener((tabId) => activeTabs.delete(tabId));
560
+ chrome.tabs.onUpdated.addListener((tabId, change) => {
561
+ if (change.status === 'loading' && activeTabs.has(tabId)) {
562
+ void stopInspector(tabId).catch((cause) => {
563
+ console.error(
564
+ 'Sideroom could not clear its inspector after navigation:',
565
+ cause,
566
+ );
567
+ });
568
+ }
569
+ });
570
+ chrome.alarms.onAlarm.addListener((alarm) => {
571
+ if (alarm.name === RETRY_ALARM) {
572
+ void initialized
573
+ .then(() => Promise.all([...connections.values()].map(reconnect)))
574
+ .catch((cause) => {
575
+ lastNotice = cause.message;
576
+ });
577
+ }
578
+ });