@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,149 @@
|
|
|
1
|
+
// Lightweight UI i18n with a manual override. Default follows the browser
|
|
2
|
+
// language; the options page lets the user force English or 中文 (stored as
|
|
3
|
+
// `uiLang`). Classic script (not a module): exposes window.I18N.
|
|
4
|
+
;(function () {
|
|
5
|
+
const MESSAGES = {
|
|
6
|
+
en: {
|
|
7
|
+
popupSessions: 'Active sessions',
|
|
8
|
+
popupStopSession: 'Stop',
|
|
9
|
+
popupActiveTasks: 'browser tasks pending',
|
|
10
|
+
popupCancelTasks: 'Cancel current tasks',
|
|
11
|
+
popupCancellingTasks: 'Stopping pending actions…',
|
|
12
|
+
controlConsole: 'Control Console',
|
|
13
|
+
tagline: 'Bridge Chrome to<br>agents · local & remote',
|
|
14
|
+
localRelay: 'Local Relay',
|
|
15
|
+
localDesc: 'Bridges your Chrome tabs to the local Browser Relay daemon on <code>127.0.0.1</code> for direct, on-device control. Remote Relay below runs on its own and does not need this.',
|
|
16
|
+
relayPort: 'Relay port',
|
|
17
|
+
idleDetach: 'Idle auto-detach <span class="note">(sec · 0 = never)</span>',
|
|
18
|
+
saveTest: 'Save & Test Connection',
|
|
19
|
+
remoteRelay: 'Remote Relay',
|
|
20
|
+
remoteDesc: 'Control <strong>this</strong> browser from anywhere through a public relay — no open ports, no exposed server. Mints a secret Device ID that works until you switch off. Prefer your own hub? <a class="lnk" href="https://deploy.workers.cloudflare.com/?url=https://github.com/reliefeai/browser-relay/tree/main/hub" target="_blank" rel="noopener">Deploy to Cloudflare →</a>',
|
|
21
|
+
remoteHub: 'Public relay',
|
|
22
|
+
deviceId: 'Device ID <span class="note">— secret · treat it like a password</span>',
|
|
23
|
+
copy: 'Copy',
|
|
24
|
+
copyCommand: 'Copy-paste command',
|
|
25
|
+
regenerate: 'Regenerate Device ID',
|
|
26
|
+
regenWarn: 'This invalidates the current Device ID immediately — anything using the old one must switch to the new ID.',
|
|
27
|
+
regenConfirm: 'Regenerate',
|
|
28
|
+
cancel: 'Cancel',
|
|
29
|
+
quickStart: 'Quick start',
|
|
30
|
+
step1: 'Install the CLI — <code>npm i -g @xnng/browser-relay</code>',
|
|
31
|
+
step2: 'Check it — <code>browser-relay status</code> (auto-starts on login)',
|
|
32
|
+
step3: 'Save the Local Relay settings above',
|
|
33
|
+
step4: 'Flip Remote Relay on, copy the command, run it from anywhere',
|
|
34
|
+
language: 'Language',
|
|
35
|
+
langAuto: 'Auto',
|
|
36
|
+
stateOn: 'On',
|
|
37
|
+
stateOff: 'Off',
|
|
38
|
+
statusConnectedLocal: 'Connected to relay server. Extension will auto-attach tabs.',
|
|
39
|
+
statusRelayStatus: 'Relay server responded with an error status. Check if the server is running.',
|
|
40
|
+
statusRelayUnreachable: 'Cannot connect to the local relay. Is the server running?',
|
|
41
|
+
statusConnectingHub: 'Connecting the extension to the relay…',
|
|
42
|
+
statusRemoteConnected: 'Remote Relay is on and connected to the relay.',
|
|
43
|
+
statusRemoteNoHub: "Device ID generated, but the relay isn't connected yet: ",
|
|
44
|
+
statusRemoteNoReach: "Device ID generated, but the extension didn't reach the relay: ",
|
|
45
|
+
statusRemoteOff: 'Remote Relay is off. Flip it on to generate a new Device ID.',
|
|
46
|
+
statusRemoteConnectedShort: 'Remote Relay is on and connected.',
|
|
47
|
+
statusRemoteDisconnected: 'Remote Relay is on but disconnected: ',
|
|
48
|
+
statusRemoteBgOffline: 'Remote Relay is on, but the extension background is not responding yet.',
|
|
49
|
+
copied: 'Copied',
|
|
50
|
+
popupConnected: 'Connected',
|
|
51
|
+
popupReattach: 'Re-attach tabs',
|
|
52
|
+
popupConnecting: 'Connecting…',
|
|
53
|
+
popupRetry: 'Retry',
|
|
54
|
+
popupNotConnected: 'Not connected',
|
|
55
|
+
popupReconnect: 'Reconnect',
|
|
56
|
+
popupOptions: 'Options',
|
|
57
|
+
popupDocs: 'Docs',
|
|
58
|
+
popupTabsAttached: 'attached',
|
|
59
|
+
popupNoRelay: 'No relay server on ',
|
|
60
|
+
popupTrying: 'Trying ',
|
|
61
|
+
popupLoading: 'Loading…',
|
|
62
|
+
popupInstallTitle: 'Relay server not running — install and check it:',
|
|
63
|
+
popupInstallHint: 'Already installed? Check with <code style="display:inline; padding:1px 5px; cursor:text; font-size:11px">browser-relay status</code>',
|
|
64
|
+
},
|
|
65
|
+
zh_CN: {
|
|
66
|
+
popupSessions: '正在控制浏览器的会话',
|
|
67
|
+
popupStopSession: '停止',
|
|
68
|
+
popupActiveTasks: '个浏览器任务待完成',
|
|
69
|
+
popupCancelTasks: '取消当前任务',
|
|
70
|
+
popupCancellingTasks: '正在停止后续操作…',
|
|
71
|
+
controlConsole: '控制台',
|
|
72
|
+
tagline: '把 Chrome 接到<br>agent · 本地与远程',
|
|
73
|
+
localRelay: '本地中继',
|
|
74
|
+
localDesc: '把你的 Chrome 标签桥接到本机 <code>127.0.0.1</code> 上的 Browser Relay 守护进程,用于本机直连控制。下面的 Remote Relay 独立运行,不需要它。',
|
|
75
|
+
relayPort: '中继端口',
|
|
76
|
+
idleDetach: '空闲自动断开 <span class="note">(秒 · 0 = 从不)</span>',
|
|
77
|
+
saveTest: '保存并测试连接',
|
|
78
|
+
remoteRelay: '远程中继',
|
|
79
|
+
remoteDesc: '从任何地方通过公网 Relay 服务控制<strong>这个</strong>浏览器 —— 不开放端口、不暴露本地服务。会生成一个保密的 Device ID,关闭前一直有效。想用自己的 Relay 服务?<a class="lnk" href="https://deploy.workers.cloudflare.com/?url=https://github.com/reliefeai/browser-relay/tree/main/hub" target="_blank" rel="noopener">部署到 Cloudflare →</a>',
|
|
80
|
+
remoteHub: '公网 Relay 服务',
|
|
81
|
+
deviceId: 'Device ID <span class="note">— 保密 · 请像密码一样保管</span>',
|
|
82
|
+
copy: '复制',
|
|
83
|
+
copyCommand: '可直接粘贴的命令',
|
|
84
|
+
regenerate: '重新生成 Device ID',
|
|
85
|
+
regenWarn: '这会立即作废当前 Device ID —— 仍在用旧 ID 的一切都必须换成新的。',
|
|
86
|
+
regenConfirm: '重新生成',
|
|
87
|
+
cancel: '取消',
|
|
88
|
+
quickStart: '快速开始',
|
|
89
|
+
step1: '安装 CLI —— <code>npm i -g @xnng/browser-relay</code>',
|
|
90
|
+
step2: '检查 —— <code>browser-relay status</code>(登录时自动启动)',
|
|
91
|
+
step3: '保存上面的本地中继设置',
|
|
92
|
+
step4: '打开 Remote Relay,复制命令,在任何地方运行',
|
|
93
|
+
language: '语言',
|
|
94
|
+
langAuto: '自动',
|
|
95
|
+
stateOn: '开',
|
|
96
|
+
stateOff: '关',
|
|
97
|
+
statusConnectedLocal: '已连接到中继服务器,插件会自动接管标签。',
|
|
98
|
+
statusRelayStatus: '中继服务器返回了错误状态,检查它是否在运行。',
|
|
99
|
+
statusRelayUnreachable: '连不上本地中继,服务器在运行吗?',
|
|
100
|
+
statusConnectingHub: '正在把插件连接到公网 Relay 服务…',
|
|
101
|
+
statusRemoteConnected: 'Remote Relay 已开启并连上公网 Relay 服务。',
|
|
102
|
+
statusRemoteNoHub: '已生成 Device ID,但公网 Relay 服务还没连上:',
|
|
103
|
+
statusRemoteNoReach: '已生成 Device ID,但插件没连上公网 Relay 服务:',
|
|
104
|
+
statusRemoteOff: 'Remote Relay 已关闭。打开它可生成新的 Device ID。',
|
|
105
|
+
statusRemoteConnectedShort: 'Remote Relay 已开启并连接。',
|
|
106
|
+
statusRemoteDisconnected: 'Remote Relay 已开启但未连接:',
|
|
107
|
+
statusRemoteBgOffline: 'Remote Relay 已开启,但插件后台还没响应。',
|
|
108
|
+
copied: '已复制',
|
|
109
|
+
popupConnected: '已连接',
|
|
110
|
+
popupReattach: '重新接管标签',
|
|
111
|
+
popupConnecting: '连接中…',
|
|
112
|
+
popupRetry: '重试',
|
|
113
|
+
popupNotConnected: '未连接',
|
|
114
|
+
popupReconnect: '重连',
|
|
115
|
+
popupOptions: '选项',
|
|
116
|
+
popupDocs: '文档',
|
|
117
|
+
popupTabsAttached: '个标签已接入',
|
|
118
|
+
popupNoRelay: '没有中继服务器:',
|
|
119
|
+
popupTrying: '正在连接 ',
|
|
120
|
+
popupLoading: '加载中…',
|
|
121
|
+
popupInstallTitle: '中继服务器未运行 —— 安装并检查:',
|
|
122
|
+
popupInstallHint: '已安装?用 <code style="display:inline; padding:1px 5px; cursor:text; font-size:11px">browser-relay status</code> 检查',
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let lang = 'en'
|
|
127
|
+
|
|
128
|
+
function resolve(override) {
|
|
129
|
+
if (override === 'en' || override === 'zh_CN') return override
|
|
130
|
+
const ui = String((chrome.i18n && chrome.i18n.getUILanguage && chrome.i18n.getUILanguage()) || navigator.language || 'en').toLowerCase()
|
|
131
|
+
return ui.startsWith('zh') ? 'zh_CN' : 'en'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function t(key) {
|
|
135
|
+
const dict = MESSAGES[lang] || MESSAGES.en
|
|
136
|
+
return dict[key] != null ? dict[key] : (MESSAGES.en[key] != null ? MESSAGES.en[key] : key)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function apply(root) {
|
|
140
|
+
root = root || document
|
|
141
|
+
root.querySelectorAll('[data-i18n]').forEach((el) => { el.textContent = t(el.dataset.i18n) })
|
|
142
|
+
root.querySelectorAll('[data-i18n-html]').forEach((el) => { el.innerHTML = t(el.dataset.i18nHtml) })
|
|
143
|
+
root.querySelectorAll('[data-i18n-ph]').forEach((el) => { el.placeholder = t(el.dataset.i18nPh) })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function setLang(override) { lang = resolve(override) }
|
|
147
|
+
|
|
148
|
+
window.I18N = { t, apply, setLang, get lang() { return lang } }
|
|
149
|
+
})()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": 3,
|
|
3
|
+
"name": "Browser Relay",
|
|
4
|
+
"version": "1.6.0",
|
|
5
|
+
"description": "Let AI agents share your everyday Chrome locally or across multiple machines.",
|
|
6
|
+
"icons": {
|
|
7
|
+
"16": "icons/icon16.png",
|
|
8
|
+
"32": "icons/icon32.png",
|
|
9
|
+
"48": "icons/icon48.png",
|
|
10
|
+
"128": "icons/icon128.png"
|
|
11
|
+
},
|
|
12
|
+
"permissions": [
|
|
13
|
+
"debugger",
|
|
14
|
+
"tabs",
|
|
15
|
+
"tabGroups",
|
|
16
|
+
"favicon",
|
|
17
|
+
"activeTab",
|
|
18
|
+
"storage",
|
|
19
|
+
"alarms",
|
|
20
|
+
"downloads",
|
|
21
|
+
"webNavigation"
|
|
22
|
+
],
|
|
23
|
+
"host_permissions": [
|
|
24
|
+
"http://127.0.0.1/*",
|
|
25
|
+
"http://localhost/*",
|
|
26
|
+
"https://relay.linso.ai/*"
|
|
27
|
+
],
|
|
28
|
+
"background": {
|
|
29
|
+
"service_worker": "background.js",
|
|
30
|
+
"type": "module"
|
|
31
|
+
},
|
|
32
|
+
"action": {
|
|
33
|
+
"default_title": "Browser Relay",
|
|
34
|
+
"default_popup": "popup.html",
|
|
35
|
+
"default_icon": {
|
|
36
|
+
"16": "icons/icon16.png",
|
|
37
|
+
"32": "icons/icon32.png",
|
|
38
|
+
"48": "icons/icon48.png",
|
|
39
|
+
"128": "icons/icon128.png"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"options_ui": {
|
|
43
|
+
"page": "options.html",
|
|
44
|
+
"open_in_tab": true
|
|
45
|
+
},
|
|
46
|
+
"version_name": "1.6.0"
|
|
47
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Presentation is separate from targeting: complete AX names remain available
|
|
2
|
+
// for locators even when a repeated container summary is omitted from output.
|
|
3
|
+
const containers = new Set([
|
|
4
|
+
"RootWebArea",
|
|
5
|
+
"Iframe",
|
|
6
|
+
"IframePresentational",
|
|
7
|
+
"main",
|
|
8
|
+
"article",
|
|
9
|
+
"region",
|
|
10
|
+
"group",
|
|
11
|
+
"form",
|
|
12
|
+
"dialog",
|
|
13
|
+
"navigation",
|
|
14
|
+
"complementary",
|
|
15
|
+
"banner",
|
|
16
|
+
"contentinfo",
|
|
17
|
+
"list",
|
|
18
|
+
"listitem",
|
|
19
|
+
"table",
|
|
20
|
+
"row",
|
|
21
|
+
"cell",
|
|
22
|
+
]);
|
|
23
|
+
const whitespace = (value) =>
|
|
24
|
+
String(value ?? "")
|
|
25
|
+
.replace(/\s+/g, " ")
|
|
26
|
+
.trim();
|
|
27
|
+
|
|
28
|
+
export function observationRecords(data, { mode = "snapshot", rootRef } = {}) {
|
|
29
|
+
const byRef = new Map(data.nodes.filter((n) => n.ref).map((n) => [n.ref, n]));
|
|
30
|
+
const children = new Map();
|
|
31
|
+
for (const node of data.nodes) {
|
|
32
|
+
const parent = byRef.has(node.parentRef) ? node.parentRef : null;
|
|
33
|
+
if (!children.has(parent)) children.set(parent, []);
|
|
34
|
+
children.get(parent).push(node);
|
|
35
|
+
}
|
|
36
|
+
const roots = rootRef
|
|
37
|
+
? [byRef.get(rootRef)].filter(Boolean)
|
|
38
|
+
: mode === "read" && data.nodes.some((n) => n.role === "main" && n.frameId === data.frames[0]?.id)
|
|
39
|
+
? data.nodes.filter((n) => n.role === "main" && n.frameId === data.frames[0]?.id)
|
|
40
|
+
: children.get(null) || [];
|
|
41
|
+
const records = new Map(),
|
|
42
|
+
visited = new Set();
|
|
43
|
+
function visit(node, depth = 0) {
|
|
44
|
+
if (visited.has(node)) return;
|
|
45
|
+
visited.add(node);
|
|
46
|
+
const descendants = children.get(node.ref) || [];
|
|
47
|
+
const redundant = data.redundantRefs.has(node.ref);
|
|
48
|
+
const structural = containers.has(node.role);
|
|
49
|
+
// Article/cell/group accessible names can contain their entire descendants.
|
|
50
|
+
// Emit the actual descendants once, keeping a semantic boundary and ref.
|
|
51
|
+
const repeatedSummary =
|
|
52
|
+
descendants.length &&
|
|
53
|
+
structural &&
|
|
54
|
+
node.name &&
|
|
55
|
+
(whitespace(node.name) === whitespace(descendants.map((child) => child.name || "").join(" ")) ||
|
|
56
|
+
descendants.some((child) => whitespace(child.name) === whitespace(node.name)));
|
|
57
|
+
const name = repeatedSummary ? "" : whitespace(node.name);
|
|
58
|
+
const hasLine =
|
|
59
|
+
!redundant &&
|
|
60
|
+
(name ||
|
|
61
|
+
structural ||
|
|
62
|
+
node.url ||
|
|
63
|
+
[
|
|
64
|
+
"textbox",
|
|
65
|
+
"searchbox",
|
|
66
|
+
"button",
|
|
67
|
+
"checkbox",
|
|
68
|
+
"radio",
|
|
69
|
+
"combobox",
|
|
70
|
+
].includes(node.role));
|
|
71
|
+
if (hasLine) {
|
|
72
|
+
const attrs = Object.entries(node).filter(
|
|
73
|
+
([k, v]) =>
|
|
74
|
+
!["ref", "role", "name", "frameId", "parentRef", "within"].includes(
|
|
75
|
+
k,
|
|
76
|
+
) && v !== undefined,
|
|
77
|
+
);
|
|
78
|
+
const line =
|
|
79
|
+
`${" ".repeat(Math.min(depth, 24))}[${node.ref || "-"}] ${node.role}${name ? " " + JSON.stringify(name) : ""}` +
|
|
80
|
+
attrs.map(([k, v]) => ` ${k}=${JSON.stringify(v)}`).join("") +
|
|
81
|
+
(node.frameId !== data.frames[0]?.id ? ` frame=${node.frameId}` : "");
|
|
82
|
+
records.set(node.ref || `text_${records.size}`, line);
|
|
83
|
+
}
|
|
84
|
+
for (const child of descendants)
|
|
85
|
+
visit(child, depth + (hasLine && structural ? 1 : 0));
|
|
86
|
+
}
|
|
87
|
+
for (const node of roots) visit(node);
|
|
88
|
+
return records;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function textPage(text, offset, maxLength) {
|
|
92
|
+
let end = Math.min(text.length, offset + maxLength);
|
|
93
|
+
// Keep ordinary nodes intact; very long text nodes are explicitly continued.
|
|
94
|
+
if (end < text.length) {
|
|
95
|
+
const newline = text.lastIndexOf("\n", end);
|
|
96
|
+
if (newline > offset) end = newline + 1;
|
|
97
|
+
}
|
|
98
|
+
if (
|
|
99
|
+
end < text.length &&
|
|
100
|
+
/[\uD800-\uDBFF]/.test(text[end - 1]) &&
|
|
101
|
+
/[\uDC00-\uDFFF]/.test(text[end])
|
|
102
|
+
)
|
|
103
|
+
end--;
|
|
104
|
+
return {
|
|
105
|
+
snapshot: text.slice(offset, end),
|
|
106
|
+
end,
|
|
107
|
+
truncated: end < text.length,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Browser Relay · Console</title>
|
|
6
|
+
<style>
|
|
7
|
+
:root {
|
|
8
|
+
color-scheme: dark light;
|
|
9
|
+
--bg: #0a0e0c;
|
|
10
|
+
--panel: #111815;
|
|
11
|
+
--inset: #0b100e;
|
|
12
|
+
--readout: #0a0f0d;
|
|
13
|
+
--line: rgba(158, 224, 130, 0.11);
|
|
14
|
+
--line-strong: rgba(158, 224, 130, 0.22);
|
|
15
|
+
--ink: #e9f1ec;
|
|
16
|
+
--muted: #8b9a92;
|
|
17
|
+
--faint: #56635c;
|
|
18
|
+
--accent: #a3e635;
|
|
19
|
+
--accent-soft: rgba(163, 230, 53, 0.14);
|
|
20
|
+
--accent-text: #c4ef6a;
|
|
21
|
+
--on-accent: #0a0e0c;
|
|
22
|
+
--danger: #fb7185;
|
|
23
|
+
--radius: 11px;
|
|
24
|
+
--mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, "Cascadia Code", monospace;
|
|
25
|
+
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
26
|
+
}
|
|
27
|
+
@media (prefers-color-scheme: light) {
|
|
28
|
+
:root {
|
|
29
|
+
--bg: #eaeeea;
|
|
30
|
+
--panel: #ffffff;
|
|
31
|
+
--inset: #f3f6f2;
|
|
32
|
+
--readout: #0d1210;
|
|
33
|
+
--line: rgba(30, 55, 40, 0.11);
|
|
34
|
+
--line-strong: rgba(30, 55, 40, 0.20);
|
|
35
|
+
--ink: #132018;
|
|
36
|
+
--muted: #5c6b62;
|
|
37
|
+
--faint: #93a29a;
|
|
38
|
+
--accent: #7bbf1e;
|
|
39
|
+
--accent-soft: rgba(123, 191, 30, 0.14);
|
|
40
|
+
--accent-text: #4d7c0f;
|
|
41
|
+
--on-accent: #0a1204;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
* { box-sizing: border-box; }
|
|
46
|
+
html, body { height: 100%; }
|
|
47
|
+
body {
|
|
48
|
+
margin: 0;
|
|
49
|
+
background: var(--bg);
|
|
50
|
+
color: var(--ink);
|
|
51
|
+
font-family: var(--sans);
|
|
52
|
+
font-size: 13px;
|
|
53
|
+
line-height: 1.5;
|
|
54
|
+
-webkit-font-smoothing: antialiased;
|
|
55
|
+
}
|
|
56
|
+
body::before {
|
|
57
|
+
content: "";
|
|
58
|
+
position: fixed; inset: 0; z-index: 0;
|
|
59
|
+
background-image:
|
|
60
|
+
linear-gradient(var(--line) 1px, transparent 1px),
|
|
61
|
+
linear-gradient(90deg, var(--line) 1px, transparent 1px);
|
|
62
|
+
background-size: 30px 30px;
|
|
63
|
+
-webkit-mask-image: radial-gradient(120% 80% at 50% -10%, #000 20%, transparent 72%);
|
|
64
|
+
mask-image: radial-gradient(120% 80% at 50% -10%, #000 20%, transparent 72%);
|
|
65
|
+
opacity: .7; pointer-events: none;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.page { position: relative; z-index: 1; max-width: 780px; margin: 0 auto; padding: 30px 26px 40px; }
|
|
69
|
+
|
|
70
|
+
.masthead { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
|
|
71
|
+
.brand { display: flex; align-items: center; gap: 11px; }
|
|
72
|
+
.sigil { width: 30px; height: 30px; flex: none; display: grid; place-items: center; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--accent-soft); }
|
|
73
|
+
.sigil span { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 10px var(--accent); }
|
|
74
|
+
.brand h1 { margin: 0; font-family: var(--mono); font-size: 14px; font-weight: 600; letter-spacing: .28em; text-transform: uppercase; }
|
|
75
|
+
.brand .sub { margin: 1px 0 0; font-family: var(--mono); font-size: 10px; letter-spacing: .2em; color: var(--faint); text-transform: uppercase; }
|
|
76
|
+
.masthead .tag { font-family: var(--mono); font-size: 10px; letter-spacing: .18em; color: var(--faint); text-transform: uppercase; text-align: right; line-height: 1.5; }
|
|
77
|
+
.masthead-right { display: flex; flex-direction: column; align-items: flex-end; gap: 9px; }
|
|
78
|
+
.lang-select { font-family: var(--mono); font-size: 11px; letter-spacing: .04em; color: var(--muted); background: var(--inset); border: 1px solid var(--line-strong); border-radius: 8px; padding: 5px 9px; cursor: pointer; }
|
|
79
|
+
.lang-select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
|
80
|
+
|
|
81
|
+
.panel {
|
|
82
|
+
position: relative; margin-top: 16px;
|
|
83
|
+
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);
|
|
84
|
+
padding: 18px 20px 20px;
|
|
85
|
+
opacity: 0; transform: translateY(8px);
|
|
86
|
+
animation: rise .5s cubic-bezier(.22,.61,.36,1) forwards;
|
|
87
|
+
}
|
|
88
|
+
.panel.p1 { animation-delay: .05s; }
|
|
89
|
+
.panel.p2 { animation-delay: .13s; }
|
|
90
|
+
@keyframes rise { to { opacity: 1; transform: none; } }
|
|
91
|
+
@media (prefers-reduced-motion: reduce) { .panel { animation: none; opacity: 1; transform: none; } }
|
|
92
|
+
|
|
93
|
+
.phead { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
|
94
|
+
.eyebrow { display: flex; align-items: center; gap: 9px; font-family: var(--mono); font-size: 10.5px; letter-spacing: .22em; text-transform: uppercase; color: var(--faint); }
|
|
95
|
+
.eyebrow b { color: var(--accent-text); font-weight: 600; }
|
|
96
|
+
.eyebrow .idx { color: var(--faint); }
|
|
97
|
+
.desc { margin: 9px 0 0; color: var(--muted); font-size: 12.5px; max-width: 68ch; }
|
|
98
|
+
.desc strong { color: var(--ink); font-weight: 640; }
|
|
99
|
+
a.lnk { color: var(--accent-text); text-decoration: none; border-bottom: 1px solid var(--accent-soft); white-space: nowrap; transition: border-color .15s ease; }
|
|
100
|
+
a.lnk:hover { border-color: var(--accent); }
|
|
101
|
+
|
|
102
|
+
label.field { display: block; margin: 15px 0 6px; font-family: var(--mono); font-size: 10.5px; letter-spacing: .16em; text-transform: uppercase; color: var(--muted); }
|
|
103
|
+
label.field .note { color: var(--faint); letter-spacing: .04em; text-transform: none; }
|
|
104
|
+
input[type=number], input[type=url], input[type=text] {
|
|
105
|
+
width: 100%; padding: 10px 12px;
|
|
106
|
+
background: var(--inset); border: 1px solid var(--line-strong); border-radius: 8px;
|
|
107
|
+
color: var(--ink); font-family: var(--mono); font-size: 13px;
|
|
108
|
+
transition: border-color .15s ease, box-shadow .15s ease;
|
|
109
|
+
}
|
|
110
|
+
input::placeholder { color: var(--faint); }
|
|
111
|
+
input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
|
112
|
+
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
|
113
|
+
code { font-family: var(--mono); font-size: .92em; padding: 1.5px 5px; border-radius: 5px; background: var(--accent-soft); color: var(--accent-text); }
|
|
114
|
+
|
|
115
|
+
.actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 16px; }
|
|
116
|
+
button {
|
|
117
|
+
font-family: var(--mono); font-size: 11.5px; font-weight: 600; letter-spacing: .06em;
|
|
118
|
+
border: 1px solid transparent; border-radius: 8px; padding: 9px 15px; cursor: pointer;
|
|
119
|
+
transition: transform .12s ease, background .15s ease, border-color .15s ease, color .15s ease;
|
|
120
|
+
}
|
|
121
|
+
button:active { transform: translateY(1px); }
|
|
122
|
+
.btn-primary { background: var(--accent); color: var(--on-accent); }
|
|
123
|
+
.btn-primary:hover { filter: brightness(1.06); box-shadow: 0 0 18px var(--accent-soft); }
|
|
124
|
+
.btn-ghost { background: transparent; color: var(--muted); border-color: var(--line-strong); }
|
|
125
|
+
.btn-ghost:hover { color: var(--ink); border-color: var(--accent); }
|
|
126
|
+
.btn-warn { background: transparent; color: var(--danger); border-color: color-mix(in srgb, var(--danger) 40%, transparent); }
|
|
127
|
+
.btn-warn:hover { background: color-mix(in srgb, var(--danger) 14%, transparent); border-color: var(--danger); }
|
|
128
|
+
button[hidden], .hidden { display: none !important; }
|
|
129
|
+
|
|
130
|
+
.status { display: none; margin-top: 14px; padding: 9px 12px 9px 32px; border-radius: 8px; font-size: 12px; line-height: 1.45; position: relative; border: 1px solid var(--line-strong); background: var(--inset); }
|
|
131
|
+
.status::before { content: ""; position: absolute; left: 13px; top: 50%; width: 7px; height: 7px; margin-top: -3.5px; border-radius: 50%; }
|
|
132
|
+
.status.ok { display: block; color: var(--accent-text); } .status.ok::before { background: var(--accent); box-shadow: 0 0 8px var(--accent); }
|
|
133
|
+
.status.err { display: block; color: var(--danger); } .status.err::before { background: var(--danger); box-shadow: 0 0 8px var(--danger); }
|
|
134
|
+
.status.info { display: block; color: var(--muted); } .status.info::before { background: var(--muted); }
|
|
135
|
+
|
|
136
|
+
.switchwrap { display: flex; align-items: center; gap: 12px; flex: none; }
|
|
137
|
+
.lbl { font-family: var(--mono); font-size: 11px; letter-spacing: .16em; text-transform: uppercase; display: inline-flex; align-items: center; gap: 7px; }
|
|
138
|
+
.lbl::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); }
|
|
139
|
+
.lbl.is-on { color: var(--accent-text); } .lbl.is-on::before { background: var(--accent); box-shadow: 0 0 9px var(--accent); animation: pulse 2s ease-in-out infinite; }
|
|
140
|
+
.lbl.is-off { color: var(--faint); }
|
|
141
|
+
.lbl.is-err { color: var(--danger); } .lbl.is-err::before { background: var(--danger); box-shadow: 0 0 9px var(--danger); }
|
|
142
|
+
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .45; } }
|
|
143
|
+
|
|
144
|
+
.switch { position: relative; display: inline-flex; cursor: pointer; }
|
|
145
|
+
.switch input { position: absolute; opacity: 0; width: 0; height: 0; }
|
|
146
|
+
.switch .track { position: relative; width: 46px; height: 26px; border-radius: 999px; background: var(--inset); border: 1px solid var(--line-strong); transition: background .2s ease, border-color .2s ease; }
|
|
147
|
+
.switch .thumb { position: absolute; left: 3px; top: 3px; width: 18px; height: 18px; border-radius: 50%; background: var(--faint); transition: transform .2s cubic-bezier(.5,1.6,.5,1), background .2s ease, box-shadow .2s ease; }
|
|
148
|
+
.switch input:checked + .track { background: var(--accent-soft); border-color: var(--accent); }
|
|
149
|
+
.switch input:checked + .track .thumb { transform: translateX(20px); background: var(--accent); box-shadow: 0 0 12px var(--accent); }
|
|
150
|
+
.switch input:disabled + .track { opacity: .5; }
|
|
151
|
+
.switch input:focus-visible + .track { box-shadow: 0 0 0 3px var(--accent-soft); }
|
|
152
|
+
|
|
153
|
+
.readout-row { display: grid; grid-template-columns: 1fr auto; gap: 9px; margin-top: 6px; align-items: start; }
|
|
154
|
+
.device-id {
|
|
155
|
+
min-height: 42px; padding: 11px 13px; border-radius: 8px;
|
|
156
|
+
background: var(--readout); border: 1px solid var(--line-strong);
|
|
157
|
+
color: #b7f24a; font-family: var(--mono); font-size: 11.5px; line-height: 1.5;
|
|
158
|
+
word-break: break-all; user-select: all; cursor: pointer;
|
|
159
|
+
box-shadow: inset 0 0 24px rgba(163, 230, 53, 0.05);
|
|
160
|
+
}
|
|
161
|
+
.command {
|
|
162
|
+
padding: 11px 13px; border-radius: 8px;
|
|
163
|
+
background: var(--inset); border: 1px dashed var(--line-strong);
|
|
164
|
+
color: var(--muted); font-family: var(--mono); font-size: 11.5px; line-height: 1.55;
|
|
165
|
+
word-break: break-all; cursor: pointer;
|
|
166
|
+
transition: border-color .15s ease;
|
|
167
|
+
}
|
|
168
|
+
.command:hover { border-color: var(--accent); }
|
|
169
|
+
.copybtn { align-self: start; width: 84px; text-align: center; padding-left: 0; padding-right: 0; }
|
|
170
|
+
|
|
171
|
+
.confirm { margin-top: 12px; padding: 12px 14px; border-radius: 9px; border: 1px solid color-mix(in srgb, var(--danger) 45%, transparent); background: color-mix(in srgb, var(--danger) 9%, transparent); }
|
|
172
|
+
.confirm-msg { display: block; color: var(--danger); font-size: 12px; line-height: 1.5; }
|
|
173
|
+
.confirm-actions { display: flex; gap: 10px; margin-top: 11px; }
|
|
174
|
+
|
|
175
|
+
.foot { margin-top: 18px; padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius); background: color-mix(in srgb, var(--panel) 60%, transparent); }
|
|
176
|
+
.foot .k { font-family: var(--mono); font-size: 10.5px; letter-spacing: .2em; text-transform: uppercase; color: var(--accent-text); }
|
|
177
|
+
.foot ol { margin: 8px 0 0; padding-left: 18px; color: var(--muted); font-size: 12px; line-height: 1.85; }
|
|
178
|
+
.foot code { font-size: .9em; }
|
|
179
|
+
</style>
|
|
180
|
+
</head>
|
|
181
|
+
<body>
|
|
182
|
+
<main class="page">
|
|
183
|
+
<header class="masthead">
|
|
184
|
+
<div class="brand">
|
|
185
|
+
<div class="sigil"><span></span></div>
|
|
186
|
+
<div>
|
|
187
|
+
<h1>Browser Relay</h1>
|
|
188
|
+
<p class="sub" data-i18n="controlConsole">Control Console</p>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
<div class="masthead-right">
|
|
192
|
+
<select id="uiLang" class="lang-select" title="Language / 语言">
|
|
193
|
+
<option value="auto" data-i18n="langAuto">Auto</option>
|
|
194
|
+
<option value="en">English</option>
|
|
195
|
+
<option value="zh_CN">中文</option>
|
|
196
|
+
</select>
|
|
197
|
+
<p class="tag" data-i18n-html="tagline">Bridge Chrome to<br>agents · local & remote</p>
|
|
198
|
+
</div>
|
|
199
|
+
</header>
|
|
200
|
+
|
|
201
|
+
<!-- ================= LOCAL ================= -->
|
|
202
|
+
<section class="panel p1">
|
|
203
|
+
<div class="phead">
|
|
204
|
+
<div>
|
|
205
|
+
<div class="eyebrow"><span class="idx">01</span><b data-i18n="localRelay">Local Relay</b></div>
|
|
206
|
+
<p class="desc" data-i18n-html="localDesc">Bridges your Chrome tabs to the local Browser Relay daemon on <code>127.0.0.1</code> for direct, on-device control. Remote Relay below runs on its own and does not need this.</p>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
<div class="row2">
|
|
211
|
+
<div>
|
|
212
|
+
<label class="field" for="relayPort" data-i18n="relayPort">Relay port</label>
|
|
213
|
+
<input type="number" id="relayPort" placeholder="18795" min="1" max="65535">
|
|
214
|
+
</div>
|
|
215
|
+
<div>
|
|
216
|
+
<label class="field" for="idleDetachSeconds" data-i18n-html="idleDetach">Idle auto-detach <span class="note">(sec · 0 = never)</span></label>
|
|
217
|
+
<input type="number" id="idleDetachSeconds" placeholder="600" min="0" max="86400">
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
<div class="actions">
|
|
222
|
+
<button id="save" class="btn-primary" data-i18n="saveTest">Save & Test Connection</button>
|
|
223
|
+
</div>
|
|
224
|
+
<div id="status" class="status"></div>
|
|
225
|
+
</section>
|
|
226
|
+
|
|
227
|
+
<!-- ================= REMOTE ================= -->
|
|
228
|
+
<section class="panel p2">
|
|
229
|
+
<div class="phead">
|
|
230
|
+
<div>
|
|
231
|
+
<div class="eyebrow"><span class="idx">02</span><b data-i18n="remoteRelay">Remote Relay</b></div>
|
|
232
|
+
<p class="desc" data-i18n-html="remoteDesc">Control <strong>this</strong> browser from anywhere through the hub — no open ports, no exposed server. Mints a secret Device ID that works until you switch off. Prefer your own hub? <a class="lnk" href="https://deploy.workers.cloudflare.com/?url=https://github.com/reliefeai/browser-relay/tree/main/hub" target="_blank" rel="noopener">Deploy to Cloudflare →</a></p>
|
|
233
|
+
</div>
|
|
234
|
+
<div class="switchwrap">
|
|
235
|
+
<span class="lbl is-off" id="remoteState" data-i18n="stateOff">Off</span>
|
|
236
|
+
<label class="switch" title="Turn Remote Relay on or off">
|
|
237
|
+
<input type="checkbox" id="remoteToggle">
|
|
238
|
+
<span class="track"><span class="thumb"></span></span>
|
|
239
|
+
</label>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<label class="field" for="remoteHost" data-i18n="remoteHub">Remote hub</label>
|
|
244
|
+
<input type="url" id="remoteHost" placeholder="https://relay.linso.ai">
|
|
245
|
+
|
|
246
|
+
<div id="remoteDetails" class="hidden">
|
|
247
|
+
<label class="field" for="remoteDeviceId" data-i18n-html="deviceId">Device ID <span class="note">— secret · treat it like a password</span></label>
|
|
248
|
+
<div class="readout-row">
|
|
249
|
+
<div id="remoteDeviceId" class="device-id" title="Click to copy"></div>
|
|
250
|
+
<button id="copyRemoteDeviceId" class="btn-ghost copybtn" type="button" data-i18n="copy">Copy</button>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
<label class="field" data-i18n="copyCommand">Copy-paste command</label>
|
|
254
|
+
<div class="readout-row">
|
|
255
|
+
<div id="remoteCommand" class="command" title="Click to copy"></div>
|
|
256
|
+
<button id="copyRemoteCommand" class="btn-ghost copybtn" type="button" data-i18n="copy">Copy</button>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
<div class="actions" id="regenRow">
|
|
260
|
+
<button id="regenerateDevice" class="btn-warn" type="button" data-i18n="regenerate">Regenerate Device ID</button>
|
|
261
|
+
</div>
|
|
262
|
+
<div id="regenConfirm" class="confirm hidden">
|
|
263
|
+
<span class="confirm-msg" data-i18n="regenWarn">This invalidates the current Device ID immediately — anything using the old one must switch to the new ID.</span>
|
|
264
|
+
<div class="confirm-actions">
|
|
265
|
+
<button id="regenApply" class="btn-warn" type="button" data-i18n="regenConfirm">Regenerate</button>
|
|
266
|
+
<button id="regenCancel" class="btn-ghost" type="button" data-i18n="cancel">Cancel</button>
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
<div id="remoteStatus" class="status"></div>
|
|
272
|
+
</section>
|
|
273
|
+
|
|
274
|
+
<!-- ================= FOOTER ================= -->
|
|
275
|
+
<div class="foot">
|
|
276
|
+
<span class="k" data-i18n="quickStart">Quick start</span>
|
|
277
|
+
<ol>
|
|
278
|
+
<li data-i18n-html="step1">Install the CLI — <code>npm i -g @xnng/browser-relay</code></li>
|
|
279
|
+
<li data-i18n-html="step2">Check it — <code>browser-relay status</code> (auto-starts on login)</li>
|
|
280
|
+
<li data-i18n="step3">Save the Local Relay settings above</li>
|
|
281
|
+
<li data-i18n="step4">Flip Remote Relay on, copy the command, run it from anywhere</li>
|
|
282
|
+
</ol>
|
|
283
|
+
</div>
|
|
284
|
+
</main>
|
|
285
|
+
|
|
286
|
+
<script src="i18n.js"></script>
|
|
287
|
+
<script src="options.js"></script>
|
|
288
|
+
</body>
|
|
289
|
+
</html>
|