@xnng/browser-relay 1.6.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/LICENSE +22 -0
- package/README.md +445 -0
- package/docs/README.zh-CN.md +421 -0
- package/docs/benchmarks/browser-gaps-codex-ax.json +418 -0
- package/docs/benchmarks/browser-gaps-relay-after.json +655 -0
- package/docs/benchmarks/browser-gaps-relay-baseline.json +679 -0
- package/docs/benchmarks/browser-readiness-cost.json +106 -0
- package/docs/benchmarks/browser-runtime-balanced-headed.json +412 -0
- package/docs/benchmarks/browser-runtime-balanced-rtt50.json +412 -0
- package/docs/benchmarks/browser-runtime-balanced.json +412 -0
- package/docs/benchmarks/browser-runtime-rtt50.json +194 -0
- package/docs/benchmarks/browser-runtime.json +254 -0
- package/docs/benchmarks/browser-use-parity.json +54 -0
- package/docs/benchmarks/codex-extension-audit.json +131 -0
- package/docs/benchmarks/codex-native-protocol.md +69 -0
- package/docs/benchmarks/codex-native-replay.js +116 -0
- package/docs/benchmarks/codex-native-status.json +81 -0
- package/docs/benchmarks/codex-native.json +1003 -0
- package/docs/benchmarks/extension-sessions.png +0 -0
- package/docs/benchmarks/extension-tasks.png +0 -0
- package/docs/benchmarks/iframe-routing-regression.json +37 -0
- package/docs/browser-use-comparison.md +331 -0
- package/docs/browser-use-gap-audit.md +141 -0
- package/docs/browser-use-parity.md +105 -0
- package/docs/demo/intranet.html +103 -0
- package/docs/releases/v1.5.0.md +59 -0
- package/docs/releases/v1.5.1.md +16 -0
- package/docs/releases/v1.5.2.md +42 -0
- package/docs/releases/v1.5.3.md +33 -0
- package/docs/releases/v1.5.4.md +42 -0
- package/docs/releases/v1.6.0.md +16 -0
- package/docs/remote-control-hub.md +523 -0
- package/extension/activity.js +328 -0
- package/extension/automation.js +1839 -0
- package/extension/background.js +1854 -0
- package/extension/i18n.js +149 -0
- package/extension/icons/icon128.png +0 -0
- package/extension/icons/icon16.png +0 -0
- package/extension/icons/icon32.png +0 -0
- package/extension/icons/icon48.png +0 -0
- package/extension/manifest.json +47 -0
- package/extension/observations.js +109 -0
- package/extension/options.html +289 -0
- package/extension/options.js +269 -0
- package/extension/popup.html +74 -0
- package/extension/popup.js +105 -0
- package/extension/protocol.js +45 -0
- package/extension/remote-auth.js +18 -0
- package/extension/sessions.js +134 -0
- package/extension/snapshot.js +161 -0
- package/extension/task-groups.js +108 -0
- package/extension/tasks.js +186 -0
- package/extension/wait.js +89 -0
- package/hub/README.md +42 -0
- package/hub/package-lock.json +1544 -0
- package/hub/package.json +13 -0
- package/hub/src/rpc.js +41 -0
- package/hub/src/worker.js +322 -0
- package/hub/wrangler.example.toml +19 -0
- package/package.json +83 -0
- package/server/cdp-bridge.js +200 -0
- package/server/cli.js +1798 -0
- package/server/hub-server.js +258 -0
- package/server/install.js +250 -0
- package/server/mcp-server.js +504 -0
- package/server/npx-runner.js +96 -0
- package/server/relay-server.js +1356 -0
- package/server/remote-protocol.js +76 -0
- package/server/runtime-worker.js +166 -0
- package/server/script-runtime.js +189 -0
- package/server/sdk.js +307 -0
- package/server/service-state.js +103 -0
- package/server/snapshot.js +161 -0
- package/server/uninstall.js +61 -0
- package/server/windows-service-entry.js +58 -0
- package/server/windows-service.js +360 -0
- package/skills/browser-relay/SKILL.md +192 -0
- package/skills/browser-relay/references/legacy-api.md +163 -0
- package/skills/browser-relay/references/runtime.md +240 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
const DEFAULT_IDLE_DETACH_SECONDS = 600
|
|
2
|
+
const IDLE_DETACH_DEFAULT_MIGRATION_KEY = 'idleDetachDefaultMigratedTo600'
|
|
3
|
+
const DEFAULT_REMOTE_HOST = 'https://relay.linso.ai'
|
|
4
|
+
|
|
5
|
+
const t = (key) => window.I18N.t(key)
|
|
6
|
+
|
|
7
|
+
const els = {
|
|
8
|
+
relayPort: document.getElementById('relayPort'),
|
|
9
|
+
idleDetachSeconds: document.getElementById('idleDetachSeconds'),
|
|
10
|
+
uiLang: document.getElementById('uiLang'),
|
|
11
|
+
save: document.getElementById('save'),
|
|
12
|
+
status: document.getElementById('status'),
|
|
13
|
+
remoteToggle: document.getElementById('remoteToggle'),
|
|
14
|
+
remoteState: document.getElementById('remoteState'),
|
|
15
|
+
remoteDetails: document.getElementById('remoteDetails'),
|
|
16
|
+
remoteHost: document.getElementById('remoteHost'),
|
|
17
|
+
regenerateDevice: document.getElementById('regenerateDevice'),
|
|
18
|
+
regenRow: document.getElementById('regenRow'),
|
|
19
|
+
regenConfirm: document.getElementById('regenConfirm'),
|
|
20
|
+
regenApply: document.getElementById('regenApply'),
|
|
21
|
+
regenCancel: document.getElementById('regenCancel'),
|
|
22
|
+
remoteDeviceId: document.getElementById('remoteDeviceId'),
|
|
23
|
+
copyRemoteDeviceId: document.getElementById('copyRemoteDeviceId'),
|
|
24
|
+
remoteCommand: document.getElementById('remoteCommand'),
|
|
25
|
+
copyRemoteCommand: document.getElementById('copyRemoteCommand'),
|
|
26
|
+
remoteStatus: document.getElementById('remoteStatus'),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function relayPortValue() {
|
|
30
|
+
return parseInt(els.relayPort.value, 10) || 18795
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeRemoteHost(value) {
|
|
34
|
+
const trimmed = String(value || '').trim().replace(/\/+$/, '')
|
|
35
|
+
return trimmed || DEFAULT_REMOTE_HOST
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function setStatus(el, kind, message) {
|
|
39
|
+
if (!message) {
|
|
40
|
+
el.className = 'status'
|
|
41
|
+
el.textContent = ''
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
el.className = `status ${kind}`
|
|
45
|
+
el.textContent = message
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function setRemoteState(kind, label) {
|
|
49
|
+
els.remoteState.className = `lbl is-${kind}`
|
|
50
|
+
els.remoteState.textContent = label
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Compact capability: `br-<secret>`. The routeId is derived from the secret
|
|
54
|
+
// (below) instead of stored in the id, so it never appears. secret = 96-bit.
|
|
55
|
+
function randomBase64Url(bytes) {
|
|
56
|
+
const arr = new Uint8Array(bytes)
|
|
57
|
+
crypto.getRandomValues(arr)
|
|
58
|
+
let bin = ''
|
|
59
|
+
for (const b of arr) bin += String.fromCharCode(b)
|
|
60
|
+
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// SHA-256(secret) → base64url → first 16 chars. Must match remote-protocol.deriveRouteId.
|
|
64
|
+
async function deriveRouteId(secret) {
|
|
65
|
+
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret))
|
|
66
|
+
let bin = ''
|
|
67
|
+
for (const b of new Uint8Array(buf)) bin += String.fromCharCode(b)
|
|
68
|
+
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '').slice(0, 16)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function newRemoteCapability() {
|
|
72
|
+
const secret = randomBase64Url(12)
|
|
73
|
+
const routeId = await deriveRouteId(secret)
|
|
74
|
+
return { routeId, secret, remoteDeviceId: `br-${secret}` }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function commandFor(remoteDeviceId, remoteHost) {
|
|
78
|
+
return `browser-relay tabs --remote-device-id ${remoteDeviceId} --remote-host ${remoteHost}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function renderRemote(config = {}) {
|
|
82
|
+
const host = normalizeRemoteHost(config.remoteHost)
|
|
83
|
+
const enabled = !!config.remoteControlEnabled && !!config.remoteDeviceId
|
|
84
|
+
els.remoteHost.value = host
|
|
85
|
+
els.remoteToggle.checked = !!config.remoteControlEnabled
|
|
86
|
+
els.remoteDetails.classList.toggle('hidden', !enabled)
|
|
87
|
+
showRegenConfirm(false)
|
|
88
|
+
|
|
89
|
+
if (enabled) {
|
|
90
|
+
setRemoteState('on', t('stateOn'))
|
|
91
|
+
els.remoteDeviceId.textContent = config.remoteDeviceId
|
|
92
|
+
els.remoteCommand.textContent = commandFor(config.remoteDeviceId, host)
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
setRemoteState('off', t('stateOff'))
|
|
97
|
+
els.remoteDeviceId.textContent = ''
|
|
98
|
+
els.remoteCommand.textContent = ''
|
|
99
|
+
setStatus(els.remoteStatus, '', '')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function saveLocalSettings() {
|
|
103
|
+
const port = relayPortValue()
|
|
104
|
+
const idleRaw = els.idleDetachSeconds.value
|
|
105
|
+
let idleDetachSeconds = parseInt(idleRaw, 10)
|
|
106
|
+
if (!Number.isFinite(idleDetachSeconds) || idleDetachSeconds < 0) idleDetachSeconds = DEFAULT_IDLE_DETACH_SECONDS
|
|
107
|
+
|
|
108
|
+
await chrome.storage.local.set({ relayPort: port, idleDetachSeconds, [IDLE_DETACH_DEFAULT_MIGRATION_KEY]: true })
|
|
109
|
+
return { port, idleDetachSeconds }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
els.save.addEventListener('click', async () => {
|
|
113
|
+
const { port } = await saveLocalSettings()
|
|
114
|
+
|
|
115
|
+
const url = `http://127.0.0.1:${port}/`
|
|
116
|
+
try {
|
|
117
|
+
const res = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(3000) })
|
|
118
|
+
if (res.ok || res.status === 200) {
|
|
119
|
+
setStatus(els.status, 'ok', t('statusConnectedLocal'))
|
|
120
|
+
} else {
|
|
121
|
+
setStatus(els.status, 'err', `${t('statusRelayStatus')} (${res.status})`)
|
|
122
|
+
}
|
|
123
|
+
} catch (err) {
|
|
124
|
+
setStatus(els.status, 'err', `${t('statusRelayUnreachable')} (${err.message})`)
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// Mint a fresh capability, persist it, and ask the background to connect to the hub.
|
|
129
|
+
// Shared by the toggle (turning on) and the Regenerate button.
|
|
130
|
+
async function enableRemote() {
|
|
131
|
+
await saveLocalSettings()
|
|
132
|
+
const remoteHost = normalizeRemoteHost(els.remoteHost.value)
|
|
133
|
+
const { routeId, secret, remoteDeviceId } = await newRemoteCapability()
|
|
134
|
+
|
|
135
|
+
await chrome.storage.local.set({
|
|
136
|
+
remoteControlEnabled: true,
|
|
137
|
+
remoteHost,
|
|
138
|
+
remoteRouteId: routeId,
|
|
139
|
+
remoteSecret: secret,
|
|
140
|
+
remoteDeviceId,
|
|
141
|
+
})
|
|
142
|
+
renderRemote({ remoteControlEnabled: true, remoteHost, remoteDeviceId })
|
|
143
|
+
setStatus(els.remoteStatus, 'info', t('statusConnectingHub'))
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const data = await chrome.runtime.sendMessage({ type: 'enableRemoteControl', remoteHost, routeId, secret, remoteDeviceId })
|
|
147
|
+
if (data?.connected) {
|
|
148
|
+
setRemoteState('on', t('stateOn'))
|
|
149
|
+
setStatus(els.remoteStatus, 'ok', t('statusRemoteConnected'))
|
|
150
|
+
} else {
|
|
151
|
+
setRemoteState('err', t('stateOn'))
|
|
152
|
+
setStatus(els.remoteStatus, 'err', t('statusRemoteNoHub') + (data?.lastError || 'unknown error'))
|
|
153
|
+
}
|
|
154
|
+
} catch (err) {
|
|
155
|
+
setRemoteState('err', t('stateOn'))
|
|
156
|
+
setStatus(els.remoteStatus, 'err', t('statusRemoteNoReach') + err.message)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function disableRemote() {
|
|
161
|
+
try {
|
|
162
|
+
await chrome.runtime.sendMessage({ type: 'disableRemoteControl' })
|
|
163
|
+
} catch {
|
|
164
|
+
// Background may be waking up; still clear extension-side state.
|
|
165
|
+
}
|
|
166
|
+
const stored = await chrome.storage.local.get(['remoteHost'])
|
|
167
|
+
await chrome.storage.local.remove(['remoteRouteId', 'remoteSecret', 'remoteDeviceId'])
|
|
168
|
+
await chrome.storage.local.set({ remoteControlEnabled: false })
|
|
169
|
+
renderRemote({ remoteControlEnabled: false, remoteHost: stored.remoteHost || DEFAULT_REMOTE_HOST })
|
|
170
|
+
setStatus(els.remoteStatus, 'info', t('statusRemoteOff'))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
els.remoteToggle.addEventListener('change', async () => {
|
|
174
|
+
els.remoteToggle.disabled = true
|
|
175
|
+
try {
|
|
176
|
+
if (els.remoteToggle.checked) await enableRemote()
|
|
177
|
+
else await disableRemote()
|
|
178
|
+
} finally {
|
|
179
|
+
els.remoteToggle.disabled = false
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// Inline (not a browser confirm dialog) two-step confirmation for regenerate.
|
|
184
|
+
function showRegenConfirm(show) {
|
|
185
|
+
els.regenRow.classList.toggle('hidden', show)
|
|
186
|
+
els.regenConfirm.classList.toggle('hidden', !show)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
els.regenerateDevice.addEventListener('click', () => showRegenConfirm(true))
|
|
190
|
+
els.regenCancel.addEventListener('click', () => showRegenConfirm(false))
|
|
191
|
+
els.regenApply.addEventListener('click', async () => {
|
|
192
|
+
els.regenApply.disabled = true
|
|
193
|
+
try {
|
|
194
|
+
await enableRemote()
|
|
195
|
+
showRegenConfirm(false)
|
|
196
|
+
} finally {
|
|
197
|
+
els.regenApply.disabled = false
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
function flashButton(button, text) {
|
|
202
|
+
const label = text || t('copied')
|
|
203
|
+
const original = button.textContent
|
|
204
|
+
button.textContent = label
|
|
205
|
+
setTimeout(() => { button.textContent = original }, 1200)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function copyText(text, button) {
|
|
209
|
+
if (!text) return
|
|
210
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
211
|
+
if (button) flashButton(button)
|
|
212
|
+
}).catch(() => {})
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
els.copyRemoteDeviceId.addEventListener('click', () => copyText(els.remoteDeviceId.textContent.trim(), els.copyRemoteDeviceId))
|
|
216
|
+
els.remoteDeviceId.addEventListener('click', () => copyText(els.remoteDeviceId.textContent.trim(), els.copyRemoteDeviceId))
|
|
217
|
+
els.remoteCommand.addEventListener('click', () => copyText(els.remoteCommand.textContent.trim(), els.copyRemoteCommand))
|
|
218
|
+
els.copyRemoteCommand.addEventListener('click', () => copyText(els.remoteCommand.textContent.trim(), els.copyRemoteCommand))
|
|
219
|
+
|
|
220
|
+
els.uiLang.addEventListener('change', async () => {
|
|
221
|
+
await chrome.storage.local.set({ uiLang: els.uiLang.value })
|
|
222
|
+
await initFromStorage()
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
async function initFromStorage() {
|
|
226
|
+
const result = await chrome.storage.local.get([
|
|
227
|
+
'relayPort',
|
|
228
|
+
'idleDetachSeconds',
|
|
229
|
+
IDLE_DETACH_DEFAULT_MIGRATION_KEY,
|
|
230
|
+
'remoteControlEnabled',
|
|
231
|
+
'remoteHost',
|
|
232
|
+
'remoteDeviceId',
|
|
233
|
+
'uiLang',
|
|
234
|
+
])
|
|
235
|
+
|
|
236
|
+
window.I18N.setLang(result.uiLang || 'auto')
|
|
237
|
+
els.uiLang.value = result.uiLang || 'auto'
|
|
238
|
+
window.I18N.apply()
|
|
239
|
+
|
|
240
|
+
if (result.relayPort) els.relayPort.value = result.relayPort
|
|
241
|
+
|
|
242
|
+
let idleDetachSeconds = result.idleDetachSeconds
|
|
243
|
+
if (Number.parseInt(String(idleDetachSeconds), 10) === 30 && !result[IDLE_DETACH_DEFAULT_MIGRATION_KEY]) {
|
|
244
|
+
idleDetachSeconds = DEFAULT_IDLE_DETACH_SECONDS
|
|
245
|
+
await chrome.storage.local.set({ idleDetachSeconds, [IDLE_DETACH_DEFAULT_MIGRATION_KEY]: true })
|
|
246
|
+
}
|
|
247
|
+
if (result.idleDetachSeconds !== undefined && result.idleDetachSeconds !== null) {
|
|
248
|
+
els.idleDetachSeconds.value = idleDetachSeconds
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
renderRemote({
|
|
252
|
+
remoteControlEnabled: !!result.remoteControlEnabled,
|
|
253
|
+
remoteHost: result.remoteHost || DEFAULT_REMOTE_HOST,
|
|
254
|
+
remoteDeviceId: result.remoteDeviceId,
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
if (!result.remoteControlEnabled) return
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
const data = await chrome.runtime.sendMessage({ type: 'getRemoteControlStatus' })
|
|
261
|
+
setRemoteState(data?.connected ? 'on' : 'err', t('stateOn'))
|
|
262
|
+
setStatus(els.remoteStatus, data?.connected ? 'ok' : 'err', data?.connected ? t('statusRemoteConnectedShort') : t('statusRemoteDisconnected') + (data?.lastError || 'not connected'))
|
|
263
|
+
} catch {
|
|
264
|
+
setRemoteState('err', t('stateOn'))
|
|
265
|
+
setStatus(els.remoteStatus, 'err', t('statusRemoteBgOffline'))
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
initFromStorage()
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<style>
|
|
6
|
+
:root { color-scheme: light; }
|
|
7
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; width: 320px; margin: 0; padding: 14px 16px; color: #0f172a; background: #fff; }
|
|
8
|
+
.row { display: flex; align-items: center; gap: 10px; }
|
|
9
|
+
.dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
|
10
|
+
.dot.on { background: #22c55e; box-shadow: 0 0 0 3px rgba(34,197,94,0.15); }
|
|
11
|
+
.dot.off { background: #94a3b8; }
|
|
12
|
+
.dot.err { background: #ef4444; box-shadow: 0 0 0 3px rgba(239,68,68,0.15); }
|
|
13
|
+
.dot.connecting { background: #f59e0b; animation: pulse 1.2s infinite; }
|
|
14
|
+
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
15
|
+
.label { font-weight: 600; font-size: 14px; line-height: 1.2; }
|
|
16
|
+
.meta { font-size: 12px; color: #64748b; margin-top: 3px; }
|
|
17
|
+
.actions { display: flex; gap: 8px; margin-top: 14px; }
|
|
18
|
+
button { flex: 1; padding: 8px 10px; font-size: 13px; border: 1px solid #e2e8f0; background: white; border-radius: 6px; cursor: pointer; color: #0f172a; font-family: inherit; }
|
|
19
|
+
button.primary { background: #22c55e; color: white; border-color: #22c55e; font-weight: 500; }
|
|
20
|
+
button:hover { filter: brightness(0.97); }
|
|
21
|
+
button.primary:hover { filter: brightness(0.95); }
|
|
22
|
+
.install { margin-top: 12px; padding: 10px 12px; background: #fff7ed; border: 1px solid #fed7aa; border-radius: 6px; font-size: 12px; display: none; }
|
|
23
|
+
.install.show { display: block; }
|
|
24
|
+
.install-title { font-weight: 600; color: #9a3412; margin-bottom: 8px; font-size: 12px; }
|
|
25
|
+
code { display: block; padding: 6px 10px; background: #1e293b; color: #f1f5f9; border-radius: 4px; font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 11px; margin-bottom: 5px; cursor: pointer; position: relative; user-select: all; }
|
|
26
|
+
code::after { content: 'copy'; position: absolute; right: 8px; top: 50%; transform: translateY(-50%); font-size: 10px; opacity: 0.5; }
|
|
27
|
+
code.copied::after { content: 'copied ✓'; opacity: 1; color: #86efac; }
|
|
28
|
+
.hint { font-size: 11px; color: #78716c; margin-top: 6px; }
|
|
29
|
+
.session { display:flex; align-items:center; gap:8px; margin-top:8px; }
|
|
30
|
+
.session span { flex:1; min-width:0; font-size:12px; overflow-wrap:anywhere; }
|
|
31
|
+
.session button { flex:0 0 auto; }
|
|
32
|
+
#session-list { max-height:190px; overflow:auto; }
|
|
33
|
+
footer { margin-top: 14px; padding-top: 10px; border-top: 1px solid #e2e8f0; display: flex; justify-content: space-between; font-size: 11px; color: #94a3b8; }
|
|
34
|
+
footer a { color: #64748b; text-decoration: none; cursor: pointer; }
|
|
35
|
+
footer a:hover { color: #0f172a; }
|
|
36
|
+
</style>
|
|
37
|
+
</head>
|
|
38
|
+
<body>
|
|
39
|
+
<div class="row">
|
|
40
|
+
<div class="dot" id="dot"></div>
|
|
41
|
+
<div style="flex:1">
|
|
42
|
+
<div class="label" id="label" data-i18n="popupLoading">Loading…</div>
|
|
43
|
+
<div class="meta" id="meta"></div>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div class="actions">
|
|
48
|
+
<button id="reconnect" class="primary" data-i18n="popupReconnect">Reconnect</button>
|
|
49
|
+
<button id="options" data-i18n="popupOptions">Options</button>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<div class="install" id="install">
|
|
53
|
+
<div class="install-title" data-i18n="popupInstallTitle">Relay server not running — install and check it:</div>
|
|
54
|
+
<code id="cmd1">npm install -g @xnng/browser-relay</code>
|
|
55
|
+
<code id="cmd2">browser-relay status</code>
|
|
56
|
+
<div class="hint" data-i18n-html="popupInstallHint">Already installed? Check with <code style="display:inline; padding:1px 5px; cursor:text; font-size:11px">browser-relay status</code></div>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div id="task-controls" hidden>
|
|
60
|
+
<p class="meta" id="task-status" role="status"></p>
|
|
61
|
+
<button id="cancel-tasks" data-i18n="popupCancelTasks">Cancel current tasks</button>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<section id="sessions" hidden><p class="meta" data-i18n="popupSessions">Active sessions</p><div id="session-list"></div></section>
|
|
65
|
+
|
|
66
|
+
<footer>
|
|
67
|
+
<span id="version">—</span>
|
|
68
|
+
<a id="docs" data-i18n="popupDocs">Docs</a>
|
|
69
|
+
</footer>
|
|
70
|
+
|
|
71
|
+
<script src="i18n.js"></script>
|
|
72
|
+
<script src="popup.js"></script>
|
|
73
|
+
</body>
|
|
74
|
+
</html>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
const t = (key) => window.I18N.t(key)
|
|
2
|
+
|
|
3
|
+
const els = {
|
|
4
|
+
dot: document.getElementById('dot'),
|
|
5
|
+
label: document.getElementById('label'),
|
|
6
|
+
meta: document.getElementById('meta'),
|
|
7
|
+
reconnect: document.getElementById('reconnect'),
|
|
8
|
+
options: document.getElementById('options'),
|
|
9
|
+
install: document.getElementById('install'),
|
|
10
|
+
version: document.getElementById('version'),
|
|
11
|
+
docs: document.getElementById('docs'),
|
|
12
|
+
cmd1: document.getElementById('cmd1'),
|
|
13
|
+
cmd2: document.getElementById('cmd2'),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function render(state) {
|
|
17
|
+
const { connected, connecting, port, attachedCount, lastError, version } = state
|
|
18
|
+
const host = `127.0.0.1:${port}`
|
|
19
|
+
|
|
20
|
+
if (connected) {
|
|
21
|
+
els.dot.className = 'dot on'
|
|
22
|
+
els.label.textContent = t('popupConnected')
|
|
23
|
+
els.meta.textContent = `${host} · ${attachedCount} ${t('popupTabsAttached')}`
|
|
24
|
+
els.install.classList.remove('show')
|
|
25
|
+
els.reconnect.textContent = t('popupReattach')
|
|
26
|
+
} else if (connecting) {
|
|
27
|
+
els.dot.className = 'dot connecting'
|
|
28
|
+
els.label.textContent = t('popupConnecting')
|
|
29
|
+
els.meta.textContent = t('popupTrying') + host
|
|
30
|
+
els.install.classList.remove('show')
|
|
31
|
+
els.reconnect.textContent = t('popupRetry')
|
|
32
|
+
} else {
|
|
33
|
+
els.dot.className = 'dot err'
|
|
34
|
+
els.label.textContent = t('popupNotConnected')
|
|
35
|
+
els.meta.textContent = lastError || (t('popupNoRelay') + host)
|
|
36
|
+
els.install.classList.add('show')
|
|
37
|
+
els.reconnect.textContent = t('popupRetry')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
els.version.textContent = version ? `v${version}` : ''
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function fetchStatus() {
|
|
44
|
+
try {
|
|
45
|
+
const res = await chrome.runtime.sendMessage({ type: 'getStatus' })
|
|
46
|
+
if (res) render(res)
|
|
47
|
+
const {tasks=[],claims=[]} = await chrome.runtime.sendMessage({type:'getAutomationTasks'})
|
|
48
|
+
document.getElementById('sessions').hidden=claims.length===0
|
|
49
|
+
const owners=new Map()
|
|
50
|
+
for(const claim of claims){const group=owners.get(claim.sessionId)||{label:claim.label||claim.sessionId,count:0};group.count++;owners.set(claim.sessionId,group)}
|
|
51
|
+
const list=document.getElementById('session-list');list.replaceChildren()
|
|
52
|
+
for(const [sessionId,group] of owners){
|
|
53
|
+
const row=document.createElement('div');row.className='session'
|
|
54
|
+
const label=document.createElement('span');label.textContent=`${group.label} · ${group.count} ${t('popupTabsAttached')}`
|
|
55
|
+
const stop=document.createElement('button');stop.textContent=t('popupStopSession');stop.addEventListener('click',async()=>{stop.disabled=true;await chrome.runtime.sendMessage({type:'stopAutomationSession',sessionId});await fetchStatus()})
|
|
56
|
+
row.append(label,stop);list.append(row)
|
|
57
|
+
}
|
|
58
|
+
document.getElementById('task-controls').hidden = tasks.length === 0
|
|
59
|
+
document.getElementById('task-status').textContent = `${tasks.length} ${t('popupActiveTasks')}`
|
|
60
|
+
} catch {
|
|
61
|
+
// background may be waking up
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
els.reconnect.addEventListener('click', async () => {
|
|
66
|
+
els.dot.className = 'dot connecting'
|
|
67
|
+
els.label.textContent = t('popupConnecting')
|
|
68
|
+
els.meta.textContent = ''
|
|
69
|
+
try { await chrome.runtime.sendMessage({ type: 'reconnect' }) } catch {}
|
|
70
|
+
setTimeout(fetchStatus, 500)
|
|
71
|
+
setTimeout(fetchStatus, 1500)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
els.options.addEventListener('click', () => {
|
|
75
|
+
chrome.runtime.openOptionsPage()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
document.getElementById('cancel-tasks').addEventListener('click',async()=>{
|
|
79
|
+
await chrome.runtime.sendMessage({type:'cancelAutomationTasks'})
|
|
80
|
+
document.getElementById('task-status').textContent=t('popupCancellingTasks')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
els.docs.addEventListener('click', (e) => {
|
|
84
|
+
e.preventDefault()
|
|
85
|
+
chrome.tabs.create({ url: 'https://www.npmjs.com/package/@xnng/browser-relay' })
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
function copyCmd(el) {
|
|
89
|
+
navigator.clipboard.writeText(el.textContent.trim()).then(() => {
|
|
90
|
+
el.classList.add('copied')
|
|
91
|
+
setTimeout(() => el.classList.remove('copied'), 1200)
|
|
92
|
+
}).catch(() => {})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
els.cmd1.addEventListener('click', () => copyCmd(els.cmd1))
|
|
96
|
+
els.cmd2.addEventListener('click', () => copyCmd(els.cmd2))
|
|
97
|
+
|
|
98
|
+
;(async () => {
|
|
99
|
+
const { uiLang } = await chrome.storage.local.get(['uiLang'])
|
|
100
|
+
window.I18N.setLang(uiLang || 'auto')
|
|
101
|
+
window.I18N.apply()
|
|
102
|
+
fetchStatus()
|
|
103
|
+
})()
|
|
104
|
+
const pollTimer = setInterval(fetchStatus, 1500)
|
|
105
|
+
window.addEventListener('unload', () => clearInterval(pollTimer))
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export const PROTOCOL_VERSION = 2;
|
|
2
|
+
export const isTaskRequest = (method, path) =>
|
|
3
|
+
["/api/read", "/api/observe"].includes(path) ||
|
|
4
|
+
(method === "POST" && ["/api/actions", "/api/evaluate", "/api/tabs/focus"].includes(path));
|
|
5
|
+
export const FEATURES = [
|
|
6
|
+
"observe",
|
|
7
|
+
"read",
|
|
8
|
+
"ax",
|
|
9
|
+
"refs",
|
|
10
|
+
"frames",
|
|
11
|
+
"shadow-dom",
|
|
12
|
+
"diff",
|
|
13
|
+
"actions",
|
|
14
|
+
"tasks",
|
|
15
|
+
"coordinates",
|
|
16
|
+
"drag",
|
|
17
|
+
"hover",
|
|
18
|
+
"screenshot-mapping",
|
|
19
|
+
"scoped-targets",
|
|
20
|
+
"action-readiness-wait",
|
|
21
|
+
"aria-check",
|
|
22
|
+
"tabs",
|
|
23
|
+
"observation-pages",
|
|
24
|
+
"full-links",
|
|
25
|
+
"sessions",
|
|
26
|
+
"task-groups",
|
|
27
|
+
"handoff",
|
|
28
|
+
"focus",
|
|
29
|
+
];
|
|
30
|
+
export const isAutomationPath = (path) =>
|
|
31
|
+
[
|
|
32
|
+
"/api/observe",
|
|
33
|
+
"/api/read",
|
|
34
|
+
"/api/actions",
|
|
35
|
+
"/api/capabilities",
|
|
36
|
+
"/api/sessions",
|
|
37
|
+
"/api/session/check",
|
|
38
|
+
"/api/evaluate",
|
|
39
|
+
"/api/tabs/create",
|
|
40
|
+
"/api/tabs/close",
|
|
41
|
+
"/api/tabs/focus",
|
|
42
|
+
"/api/tabs/claim",
|
|
43
|
+
"/api/tabs/release",
|
|
44
|
+
"/api/tabs/handoff",
|
|
45
|
+
].includes(path) || path.startsWith("/api/tasks/");
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function createRemoteAuthMessageHandler({ onAuthenticated, onMessage }) {
|
|
2
|
+
let authenticated = false
|
|
3
|
+
|
|
4
|
+
return (text) => {
|
|
5
|
+
let msg
|
|
6
|
+
try { msg = JSON.parse(String(text || '')) } catch { return }
|
|
7
|
+
|
|
8
|
+
if (!authenticated) {
|
|
9
|
+
if (msg?.type !== 'device.authenticated') return
|
|
10
|
+
authenticated = true
|
|
11
|
+
onAuthenticated()
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (msg?.type === 'device.authenticated') return
|
|
16
|
+
onMessage(String(text || ''), msg)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { TaskError } from "./tasks.js";
|
|
2
|
+
|
|
3
|
+
// Leases coordinate trusted clients sharing Chrome. They are not an access
|
|
4
|
+
// control sandbox against another local debugger or the user themselves.
|
|
5
|
+
export function createSessions({
|
|
6
|
+
now = Date.now,
|
|
7
|
+
ttlMs = 120000,
|
|
8
|
+
cancelTab = () => {},
|
|
9
|
+
cancelSession = () => {},
|
|
10
|
+
active = () => [],
|
|
11
|
+
} = {}) {
|
|
12
|
+
const sessions = new Map(),
|
|
13
|
+
claims = new Map(),
|
|
14
|
+
stopped = new Map();
|
|
15
|
+
const valid = (id) => {
|
|
16
|
+
if (typeof id !== "string" || !/^[\w.-]{1,128}$/.test(id))
|
|
17
|
+
throw new TaskError(
|
|
18
|
+
"invalid_session",
|
|
19
|
+
"Use a sessionId with 1–128 letters, digits, dots, dashes or underscores",
|
|
20
|
+
);
|
|
21
|
+
return id;
|
|
22
|
+
};
|
|
23
|
+
const publicClaim = (claim) => claim && { ...claim };
|
|
24
|
+
function stop(id, reason = "session_stopped") {
|
|
25
|
+
valid(id);
|
|
26
|
+
cancelSession(id, reason);
|
|
27
|
+
const released = [];
|
|
28
|
+
for (const [tabId, claim] of claims)
|
|
29
|
+
if (claim.sessionId === id) {
|
|
30
|
+
cancelTab(tabId, reason);
|
|
31
|
+
released.push(publicClaim(claim));
|
|
32
|
+
claims.delete(tabId);
|
|
33
|
+
}
|
|
34
|
+
sessions.delete(id);
|
|
35
|
+
stopped.set(id, reason);
|
|
36
|
+
if (stopped.size > 500) stopped.delete(stopped.keys().next().value);
|
|
37
|
+
return released;
|
|
38
|
+
}
|
|
39
|
+
function sweep() {
|
|
40
|
+
for (const [id, session] of sessions)
|
|
41
|
+
if (session.expiresAt <= now()) stop(id, "session_expired");
|
|
42
|
+
}
|
|
43
|
+
function touch(id) {
|
|
44
|
+
valid(id);
|
|
45
|
+
sweep();
|
|
46
|
+
if (stopped.has(id))
|
|
47
|
+
throw new TaskError(
|
|
48
|
+
stopped.get(id),
|
|
49
|
+
"Session ended; start a new session explicitly",
|
|
50
|
+
409,
|
|
51
|
+
);
|
|
52
|
+
if (!sessions.has(id) && sessions.size >= 100)
|
|
53
|
+
throw new TaskError("session_limit", "Too many active sessions", 429);
|
|
54
|
+
const session = { sessionId: id, expiresAt: now() + ttlMs };
|
|
55
|
+
sessions.set(id, session);
|
|
56
|
+
return session;
|
|
57
|
+
}
|
|
58
|
+
function check(tabId, id) {
|
|
59
|
+
sweep();
|
|
60
|
+
const claim = claims.get(tabId);
|
|
61
|
+
if (claim && claim.sessionId !== id)
|
|
62
|
+
throw new TaskError(
|
|
63
|
+
"tab_claimed",
|
|
64
|
+
`Tab is owned by session ${claim.sessionId}; request a handoff or wait for release`,
|
|
65
|
+
409,
|
|
66
|
+
);
|
|
67
|
+
if (id) touch(id);
|
|
68
|
+
return claim;
|
|
69
|
+
}
|
|
70
|
+
function claim(tabId, id, { created = false, label = "" } = {}) {
|
|
71
|
+
valid(id);
|
|
72
|
+
check(tabId, id);
|
|
73
|
+
if (!claims.has(tabId))
|
|
74
|
+
claims.set(tabId, {
|
|
75
|
+
tabId,
|
|
76
|
+
sessionId: id,
|
|
77
|
+
created,
|
|
78
|
+
label: String(label).slice(0, 100),
|
|
79
|
+
claimedAt: now(),
|
|
80
|
+
});
|
|
81
|
+
if (created) claims.get(tabId).created = true;
|
|
82
|
+
if (label) claims.get(tabId).label = String(label).slice(0, 100);
|
|
83
|
+
return {
|
|
84
|
+
...publicClaim(claims.get(tabId)),
|
|
85
|
+
expiresAt: sessions.get(id).expiresAt,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function release(tabId, id) {
|
|
89
|
+
check(tabId, id);
|
|
90
|
+
if (active().some((job) => job.tabId === tabId))
|
|
91
|
+
throw new TaskError(
|
|
92
|
+
"tab_busy",
|
|
93
|
+
"Cancel and await pending tasks before releasing this tab",
|
|
94
|
+
409,
|
|
95
|
+
);
|
|
96
|
+
const result = publicClaim(claims.get(tabId));
|
|
97
|
+
claims.delete(tabId);
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
function handoff(tabId, id, toSessionId) {
|
|
101
|
+
valid(toSessionId);
|
|
102
|
+
check(tabId, id);
|
|
103
|
+
if (!claims.has(tabId))
|
|
104
|
+
throw new TaskError(
|
|
105
|
+
"tab_unclaimed",
|
|
106
|
+
"Claim the tab before handing it off",
|
|
107
|
+
409,
|
|
108
|
+
);
|
|
109
|
+
// Validate the receiver before releasing the owner.
|
|
110
|
+
touch(toSessionId);
|
|
111
|
+
const previous = release(tabId, id);
|
|
112
|
+
return claim(tabId, toSessionId, previous);
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
claim,
|
|
116
|
+
check,
|
|
117
|
+
release,
|
|
118
|
+
handoff,
|
|
119
|
+
touch,
|
|
120
|
+
stop,
|
|
121
|
+
sweep,
|
|
122
|
+
forget: (tabId) => claims.delete(tabId),
|
|
123
|
+
list: () => {
|
|
124
|
+
sweep();
|
|
125
|
+
return [...claims.values()].map((c) => ({
|
|
126
|
+
...c,
|
|
127
|
+
expiresAt: sessions.get(c.sessionId)?.expiresAt,
|
|
128
|
+
}));
|
|
129
|
+
},
|
|
130
|
+
stopAll: (reason) => {
|
|
131
|
+
for (const id of [...sessions.keys()]) stop(id, reason);
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|