@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.
- package/README.md +16 -0
- package/assets/visual-context/browser/INSTALL.md +69 -0
- package/assets/visual-context/browser/background.js +578 -0
- package/assets/visual-context/browser/content.js +198 -0
- package/assets/visual-context/browser/icons/README.md +9 -0
- package/assets/visual-context/browser/icons/icon-128.png +0 -0
- package/assets/visual-context/browser/icons/icon-16.png +0 -0
- package/assets/visual-context/browser/icons/icon-32.png +0 -0
- package/assets/visual-context/browser/icons/icon-48.png +0 -0
- package/assets/visual-context/browser/manifest.json +20 -0
- package/assets/visual-context/browser/popup.css +19 -0
- package/assets/visual-context/browser/popup.html +27 -0
- package/assets/visual-context/browser/popup.js +141 -0
- package/extensions/visual-context/bridge.ts +240 -0
- package/extensions/visual-context/broker.ts +292 -0
- package/extensions/visual-context/envelope.ts +34 -0
- package/extensions/visual-context/index.ts +328 -0
- package/extensions/visual-context/literal.ts +34 -0
- package/extensions/visual-context/markup.ts +104 -0
- package/extensions/visual-context/match.ts +222 -0
- package/extensions/visual-context/model.ts +180 -0
- package/extensions/visual-context/pairing.ts +114 -0
- package/extensions/visual-context/parse-astro.ts +166 -0
- package/extensions/visual-context/parse-html.ts +72 -0
- package/extensions/visual-context/parse-jsx.ts +157 -0
- package/extensions/visual-context/parse-svelte.ts +128 -0
- package/extensions/visual-context/parse-vue.ts +133 -0
- package/extensions/visual-context/session.ts +192 -0
- package/extensions/visual-context/source.ts +197 -0
- package/extensions/visual-context/ui.ts +57 -0
- package/package.json +16 -7
- 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
|
+
});
|