@oddessentials/agent-guild 0.26.0 → 0.27.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 +3 -3
- package/bin/agent-guild.mjs +2 -2
- package/node_modules/qrcode-generator/README.md +101 -0
- package/node_modules/qrcode-generator/dist/qrcode.d.ts +57 -0
- package/node_modules/qrcode-generator/dist/qrcode.js +2297 -0
- package/node_modules/qrcode-generator/dist/qrcode.mjs +2237 -0
- package/node_modules/qrcode-generator/dist/qrcode_SJIS.js +30 -0
- package/node_modules/qrcode-generator/dist/qrcode_SJIS.mjs +23 -0
- package/node_modules/qrcode-generator/dist/qrcode_UTF8.js +26 -0
- package/node_modules/qrcode-generator/dist/qrcode_UTF8.mjs +50 -0
- package/node_modules/qrcode-generator/experiment/vt/package-lock.json +6184 -0
- package/node_modules/qrcode-generator/experiment/vt/package.json +19 -0
- package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode.ts +2311 -0
- package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode_SJIS.ts +25 -0
- package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode_UTF8.ts +50 -0
- package/node_modules/qrcode-generator/experiment/vt/src/main/types.ts +80 -0
- package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode-test-impl.js +353 -0
- package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode.test.mjs +5 -0
- package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode_SJIS.test.mjs +10 -0
- package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode_UTF8.test.mjs +9 -0
- package/node_modules/qrcode-generator/experiment/vt/tsconfig.json +25 -0
- package/node_modules/qrcode-generator/experiment/vt/vite.config.js +31 -0
- package/node_modules/qrcode-generator/experiment/vt/web-test-runner.config.mjs +7 -0
- package/node_modules/qrcode-generator/package.json +37 -0
- package/node_modules/qrcode-generator/test/qrcode-test-impl.js +353 -0
- package/node_modules/qrcode-generator/test/qrcode.test.html +18 -0
- package/node_modules/qrcode-generator/test/qrcode.test.mjs +5 -0
- package/node_modules/qrcode-generator/test/qrcode_SJIS.test.html +21 -0
- package/node_modules/qrcode-generator/test/qrcode_SJIS.test.mjs +10 -0
- package/node_modules/qrcode-generator/test/qrcode_UTF8.test.html +19 -0
- package/node_modules/qrcode-generator/test/qrcode_UTF8.test.mjs +9 -0
- package/node_modules/qrcode-generator/web-test-runner.config.mjs +8 -0
- package/package.json +2 -1
- package/src/manager/access-policy.mjs +39 -0
- package/src/manager/config.mjs +1 -0
- package/src/manager/main.mjs +11 -9
- package/src/manager/remote-access.mjs +286 -0
- package/src/manager/server.mjs +45 -27
- package/src/manager/tailscale.mjs +201 -0
- package/web/app.js +18 -4
- package/web/index.html +65 -0
- package/web/remote-access.js +236 -0
- package/web/styles.css +23 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { resolveCommand } from './command-resolver.mjs';
|
|
5
|
+
import { parseAllowedHosts } from './access-policy.mjs';
|
|
6
|
+
|
|
7
|
+
const object = (value) => value && typeof value === 'object' && !Array.isArray(value);
|
|
8
|
+
export const remoteError = (code, message, status = 409) => Object.assign(new Error(message), { code, status });
|
|
9
|
+
|
|
10
|
+
export function findTailscale(env = process.env, platform = process.platform, exists = fs.existsSync) {
|
|
11
|
+
const onPath = resolveCommand(platform === 'win32' ? 'tailscale.exe' : 'tailscale', env, platform);
|
|
12
|
+
const candidates = [onPath];
|
|
13
|
+
if (platform === 'win32') {
|
|
14
|
+
for (const dir of [env.ProgramFiles, env['ProgramFiles(x86)'], 'C:\\Program Files']) {
|
|
15
|
+
if (dir) candidates.push(path.win32.join(dir, 'Tailscale', 'tailscale.exe'));
|
|
16
|
+
}
|
|
17
|
+
} else if (platform === 'darwin') {
|
|
18
|
+
candidates.push('/Applications/Tailscale.app/Contents/MacOS/Tailscale', '/usr/local/bin/tailscale', '/opt/homebrew/bin/tailscale');
|
|
19
|
+
} else candidates.push('/usr/bin/tailscale', '/usr/local/bin/tailscale');
|
|
20
|
+
return candidates.find((file) => file && (platform !== 'win32' || /\.exe$/i.test(file)) && exists(file)) || null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function approvalLink(output) {
|
|
24
|
+
for (const match of output.matchAll(/https:\/\/[^\s<>"']+/g)) {
|
|
25
|
+
try {
|
|
26
|
+
const url = new URL(match[0]);
|
|
27
|
+
if (url.hostname === 'login.tailscale.com' && !url.username && !url.password && !url.port) return url.href;
|
|
28
|
+
} catch {}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function runTailscale(file, args, { env = process.env, signal, onApproval, timeout = 15000 } = {}) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
let output = '';
|
|
36
|
+
const child = execFile(file, args, {
|
|
37
|
+
env: { ...env, TAILSCALE_BE_CLI: '1' }, windowsHide: true, timeout, maxBuffer: 1024 * 1024, encoding: 'utf8', signal,
|
|
38
|
+
}, (error, stdout, stderr) => {
|
|
39
|
+
if (!error) return resolve(stdout);
|
|
40
|
+
const detail = `${stderr || ''}\n${stdout || ''}`.trim().slice(-2000);
|
|
41
|
+
const url = approvalLink(detail || output);
|
|
42
|
+
if (url) return reject(Object.assign(remoteError('approval_required', 'Approve HTTPS in Tailscale, then continue setup.'), { approvalUrl: url }));
|
|
43
|
+
if (/access denied|permission denied|must be.*(?:root|admin)|sudo tailscale|not permitted|serve config denied/i.test(detail)) {
|
|
44
|
+
return reject(remoteError('permission_required', 'Tailscale needs permission to configure remote access.'));
|
|
45
|
+
}
|
|
46
|
+
if (error.killed || error.code === 'ABORT_ERR') return reject(remoteError('tailscale_timeout', 'Tailscale did not finish. Check its connection and try again.'));
|
|
47
|
+
reject(remoteError('tailscale_error', detail || 'Could not communicate with Tailscale. Open Tailscale and check its connection.'));
|
|
48
|
+
});
|
|
49
|
+
const collect = (chunk) => {
|
|
50
|
+
output = (output + chunk).slice(-8192);
|
|
51
|
+
const url = approvalLink(output);
|
|
52
|
+
if (url) onApproval?.(url);
|
|
53
|
+
};
|
|
54
|
+
child.stdout?.on('data', collect);
|
|
55
|
+
child.stderr?.on('data', collect);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseJson(raw) {
|
|
60
|
+
let value;
|
|
61
|
+
try { value = JSON.parse(raw); } catch {}
|
|
62
|
+
if (value === null) return {};
|
|
63
|
+
if (!object(value)) throw remoteError('tailscale_response', 'Tailscale returned an unreadable status. Update Tailscale and try again.');
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function validateServeConfig(config) {
|
|
68
|
+
if (!object(config)) throw remoteError('tailscale_response', 'Tailscale returned an unreadable Serve configuration.');
|
|
69
|
+
for (const key of ['TCP', 'Web', 'AllowFunnel', 'Foreground', 'Services']) {
|
|
70
|
+
if (config[key] !== undefined && config[key] !== null && !object(config[key])) {
|
|
71
|
+
throw remoteError('tailscale_response', 'Tailscale returned an unsupported Serve configuration.');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
for (const [port, value] of Object.entries(config.TCP || {})) {
|
|
75
|
+
if (!/^[1-9]\d{0,4}$/.test(port) || Number(port) > 65535 || !object(value)) {
|
|
76
|
+
throw remoteError('tailscale_response', 'Tailscale returned an unsupported TCP configuration.');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const value of Object.values(config.Web || {})) {
|
|
80
|
+
if (!object(value) || !object(value.Handlers) || Object.values(value.Handlers).some((handler) => !object(handler))) {
|
|
81
|
+
throw remoteError('tailscale_response', 'Tailscale returned an unsupported web configuration.');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (Object.values(config.AllowFunnel || {}).some((value) => typeof value !== 'boolean')) {
|
|
85
|
+
throw remoteError('tailscale_response', 'Tailscale returned an unsupported Funnel configuration.');
|
|
86
|
+
}
|
|
87
|
+
for (const value of Object.values(config.Foreground || {})) {
|
|
88
|
+
if (!object(value) || value.Foreground) throw remoteError('tailscale_response', 'Tailscale returned an unsupported foreground configuration.');
|
|
89
|
+
validateServeConfig(value);
|
|
90
|
+
}
|
|
91
|
+
return config;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const ordered = (value) => Array.isArray(value) ? value.map(ordered)
|
|
95
|
+
: object(value) ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, ordered(value[key])])) : value;
|
|
96
|
+
export const sameConfig = (a, b) => JSON.stringify(ordered(a)) === JSON.stringify(ordered(b));
|
|
97
|
+
export const routeAuthority = (route) => `${route.host}:${route.port}`;
|
|
98
|
+
export const routeUrl = (route) => `https://${route.host}${route.port === 443 ? '' : `:${route.port}`}`;
|
|
99
|
+
|
|
100
|
+
function foregroundPort(config, port) {
|
|
101
|
+
return Object.values(config.Foreground || {}).some((entry) => entry.TCP?.[port] || Object.keys(entry.Web || {}).some((key) => key.endsWith(`:${port}`)));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function portOccupied(config, port) {
|
|
105
|
+
return Boolean(config.TCP?.[port] || Object.keys(config.Web || {}).some((key) => key.endsWith(`:${port}`))
|
|
106
|
+
|| Object.keys(config.AllowFunnel || {}).some((key) => key.endsWith(`:${port}`)) || foregroundPort(config, port));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function routeMatches(config, route) {
|
|
110
|
+
const authority = routeAuthority(route);
|
|
111
|
+
const web = config.Web?.[authority];
|
|
112
|
+
const handler = web?.Handlers?.['/'];
|
|
113
|
+
if (!sameConfig(config.TCP?.[route.port], { HTTPS: true }) || !object(web) || !object(handler)
|
|
114
|
+
|| Object.entries(config.AllowFunnel || {}).some(([key, value]) => value && key.endsWith(`:${route.port}`)) || foregroundPort(config, route.port)
|
|
115
|
+
|| Object.keys(web.Handlers).length !== 1 || Object.keys(web).some((key) => key !== 'Handlers')
|
|
116
|
+
|| Object.keys(handler).some((key) => key !== 'Proxy')
|
|
117
|
+
|| Object.keys(config.Web || {}).some((key) => key !== authority && key.endsWith(`:${route.port}`))) return false;
|
|
118
|
+
try {
|
|
119
|
+
const target = new URL(handler.Proxy);
|
|
120
|
+
return target.origin === new URL(route.target).origin && target.pathname === '/' && !target.search && !target.hash && !target.username && !target.password;
|
|
121
|
+
} catch { return false; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function chooseRoute(info, target, previous = null) {
|
|
125
|
+
if (previous) {
|
|
126
|
+
if (previous.nodeId !== info.nodeId || previous.host !== info.host) {
|
|
127
|
+
throw remoteError('network_changed', 'This computer’s Tailscale identity or address changed. Disable the saved connection before setting up the new address.');
|
|
128
|
+
}
|
|
129
|
+
if (portOccupied(info.config, previous.port) && !routeMatches(info.config, previous)) {
|
|
130
|
+
throw remoteError('route_changed', 'The saved Tailscale route was changed outside Agent Guild. Review it in Tailscale before continuing.');
|
|
131
|
+
}
|
|
132
|
+
return { ...previous, target };
|
|
133
|
+
}
|
|
134
|
+
for (const key of Object.keys(info.config.TCP || {})) {
|
|
135
|
+
const port = Number(key);
|
|
136
|
+
const route = { nodeId: info.nodeId, host: info.host, port, target };
|
|
137
|
+
if (Number.isInteger(port) && port > 0 && port <= 65535 && routeMatches(info.config, route)) return route;
|
|
138
|
+
}
|
|
139
|
+
const port = [443, ...Array.from({ length: 32 }, (_, i) => 8443 + i)].find((candidate) => !portOccupied(info.config, candidate));
|
|
140
|
+
if (!port) throw remoteError('no_port', 'No available HTTPS port was found. Free a Serve port in Tailscale and try again.');
|
|
141
|
+
return { nodeId: info.nodeId, host: info.host, port, target };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export class Tailscale {
|
|
145
|
+
constructor({ env = process.env, platform = process.platform, find = findTailscale, run = runTailscale } = {}) {
|
|
146
|
+
Object.assign(this, { env, platform, find, run });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async command(args, options = {}) {
|
|
150
|
+
const file = this.find(this.env, this.platform);
|
|
151
|
+
if (!file) throw remoteError('not_installed', 'Install Tailscale on this computer to connect it to your network.');
|
|
152
|
+
return this.run(file, args, { env: this.env, ...options });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async inspect(options = {}) {
|
|
156
|
+
const version = (await this.command(['version'], options)).trim().split(/\s/)[0];
|
|
157
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version);
|
|
158
|
+
if (!match || Number(match[1]) < 1 || (Number(match[1]) === 1 && Number(match[2]) < 52)) {
|
|
159
|
+
throw remoteError('update_required', 'Update Tailscale to version 1.52 or later to set up remote access.');
|
|
160
|
+
}
|
|
161
|
+
const status = parseJson(await this.command(['status', '--json', '--peers=false'], options));
|
|
162
|
+
if (!['Running', 'Starting'].includes(status.BackendState)) {
|
|
163
|
+
throw remoteError('not_connected', 'Open Tailscale and connect this computer to your network, then continue.');
|
|
164
|
+
}
|
|
165
|
+
const host = typeof status.Self?.DNSName === 'string' ? status.Self.DNSName.replace(/\.$/, '').toLowerCase() : null;
|
|
166
|
+
const nodeId = status.Self?.ID;
|
|
167
|
+
if (!host || typeof nodeId !== 'string' || !nodeId || !host.endsWith('.ts.net')) {
|
|
168
|
+
throw remoteError('address_unavailable', 'Tailscale has not assigned this computer an HTTPS address yet. Check its connection and MagicDNS settings.');
|
|
169
|
+
}
|
|
170
|
+
try { parseAllowedHosts(host); } catch { throw remoteError('address_unavailable', 'Tailscale returned an invalid HTTPS address.'); }
|
|
171
|
+
const config = validateServeConfig(parseJson(await this.command(['serve', 'status', '--json'], options)));
|
|
172
|
+
return { version, host, nodeId, config, httpsReady: Array.isArray(status.CertDomains) && status.CertDomains.includes(host) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async enable(route, previous, options = {}) {
|
|
176
|
+
const before = await this.inspect(options);
|
|
177
|
+
if (before.nodeId !== route.nodeId || before.host !== route.host) throw remoteError('network_changed', 'Tailscale changed networks during setup. Check the connection and try again.');
|
|
178
|
+
if (routeMatches(before.config, route)) return;
|
|
179
|
+
if (portOccupied(before.config, route.port) && !(previous && routeMatches(before.config, previous))) {
|
|
180
|
+
throw remoteError('route_changed', 'The selected HTTPS port is now in use. Check the connection and try again.');
|
|
181
|
+
}
|
|
182
|
+
await this.command(['serve', '--bg', `--https=${route.port}`, '--set-path=/', route.target], { ...options, timeout: 30000 });
|
|
183
|
+
const after = await this.inspect(options);
|
|
184
|
+
if (after.nodeId !== route.nodeId || after.host !== route.host || !routeMatches(after.config, route)) {
|
|
185
|
+
throw remoteError('setup_incomplete', 'Tailscale has not finished setting up the private HTTPS route. Complete any approval, then continue.');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async remove(route, options = {}) {
|
|
190
|
+
const info = await this.inspect(options);
|
|
191
|
+
if (info.nodeId !== route.nodeId || info.host !== route.host) throw remoteError('network_changed', 'Connect Tailscale to the original network to remove its saved route.');
|
|
192
|
+
if (!portOccupied(info.config, route.port)) return info;
|
|
193
|
+
if (!routeMatches(info.config, route)) throw remoteError('route_changed', 'This Serve route was changed outside Agent Guild. It has been left in place; review it in Tailscale.');
|
|
194
|
+
await this.command(['serve', '--bg', `--https=${route.port}`, '--set-path=/', 'off'], options);
|
|
195
|
+
const after = await this.inspect(options);
|
|
196
|
+
if (after.nodeId !== route.nodeId || after.host !== route.host || portOccupied(after.config, route.port)) {
|
|
197
|
+
throw remoteError('cleanup_incomplete', 'The Serve route is still present. Check Tailscale and retry cleanup.');
|
|
198
|
+
}
|
|
199
|
+
return after;
|
|
200
|
+
}
|
|
201
|
+
}
|
package/web/app.js
CHANGED
|
@@ -6,6 +6,7 @@ import { TerminalCopy } from './terminal-copy.js';
|
|
|
6
6
|
import { topbarInline, dockMode, clampDockWidth, stageBesideDock, splitMode, clampRatio, bindSplitter, DOCK_MIN, SPLIT_RATIO_MIN } from './layout.js';
|
|
7
7
|
import { highlightParts, rankRepos, recentFirst, remember, repoForOrigin, repoKey } from './repo-search.js';
|
|
8
8
|
import { createActivityFavicon, isSessionWorking } from './activity-favicon.js';
|
|
9
|
+
import { createRemoteAccessUI } from './remote-access.js';
|
|
9
10
|
|
|
10
11
|
const TOKEN_KEY = 'agentGuild.token';
|
|
11
12
|
const CWD_KEY = 'agentGuild.cwd';
|
|
@@ -120,6 +121,7 @@ function setConnection(kind, label) {
|
|
|
120
121
|
el.title = label;
|
|
121
122
|
// The manager can only be stopped, restarted or upgraded while the page can reach it.
|
|
122
123
|
state.connected = kind === 'ok';
|
|
124
|
+
state.remoteAccessUI?.connectionChanged();
|
|
123
125
|
$('manager').hidden = !state.connected;
|
|
124
126
|
if (!state.connected) closeMenu($('manager-menu'));
|
|
125
127
|
renderFolderTools();
|
|
@@ -4257,13 +4259,13 @@ class TerminalView {
|
|
|
4257
4259
|
}
|
|
4258
4260
|
|
|
4259
4261
|
connect() {
|
|
4260
|
-
if (this.disposed) return;
|
|
4262
|
+
if (this.disposed || state.remoteRevoked) return;
|
|
4261
4263
|
const ws = new WebSocket(wsUrl(`/sessions/${this.id}/terminal`));
|
|
4262
4264
|
this.ws = ws;
|
|
4263
4265
|
ws.onopen = () => { this.retry = 0; this.sent = { cols: 0, rows: 0 }; this.sendSize(); };
|
|
4264
4266
|
ws.onmessage = (event) => this.onMessage(JSON.parse(event.data));
|
|
4265
4267
|
ws.onclose = (event) => {
|
|
4266
|
-
if (this.disposed || event.code === 4404 || event.code === 4410) return;
|
|
4268
|
+
if (this.disposed || event.code === 4403 || event.code === 4404 || event.code === 4410) return;
|
|
4267
4269
|
const delay = Math.min(5000, 300 * 2 ** this.retry++);
|
|
4268
4270
|
setTimeout(() => this.connect(), delay);
|
|
4269
4271
|
};
|
|
@@ -4860,7 +4862,7 @@ function showManagerUnavailable() {
|
|
|
4860
4862
|
}
|
|
4861
4863
|
|
|
4862
4864
|
function connectEvents() {
|
|
4863
|
-
if (state.pageAway) return;
|
|
4865
|
+
if (state.pageAway || state.remoteRevoked) return;
|
|
4864
4866
|
const ws = new WebSocket(wsUrl('/events'));
|
|
4865
4867
|
state.eventsSocket = ws;
|
|
4866
4868
|
ws.onopen = () => {
|
|
@@ -4881,6 +4883,7 @@ function connectEvents() {
|
|
|
4881
4883
|
state.launcher = typeof msg.launcher === 'string' ? msg.launcher : null;
|
|
4882
4884
|
state.folderOpener = msg.folderOpener || null;
|
|
4883
4885
|
state.folderPicker = msg.folderPicker || null;
|
|
4886
|
+
state.remoteAccessUI?.setAvailable(msg.remoteAccess);
|
|
4884
4887
|
renderVersion();
|
|
4885
4888
|
setConnection('ok', 'Connected to session manager');
|
|
4886
4889
|
state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
|
|
@@ -4895,6 +4898,8 @@ function connectEvents() {
|
|
|
4895
4898
|
// A changelog.updated sent while the socket was down is lost; catch up the open panel.
|
|
4896
4899
|
if ($('changelog').open) loadChangelog();
|
|
4897
4900
|
if (dockShows('github')) loadGitHub();
|
|
4901
|
+
} else if (msg.type === 'remote-access.updated') {
|
|
4902
|
+
state.remoteAccessUI?.updated();
|
|
4898
4903
|
} else if (msg.type === 'news.updated') {
|
|
4899
4904
|
loadNews();
|
|
4900
4905
|
} else if (msg.type === 'github.updated') {
|
|
@@ -4921,8 +4926,15 @@ function connectEvents() {
|
|
|
4921
4926
|
scheduleStats();
|
|
4922
4927
|
}
|
|
4923
4928
|
};
|
|
4924
|
-
ws.onclose = () => {
|
|
4929
|
+
ws.onclose = (event) => {
|
|
4925
4930
|
if (state.pageAway || state.eventsSocket !== ws) return;
|
|
4931
|
+
if (event?.code === 4403) {
|
|
4932
|
+
state.remoteRevoked = true;
|
|
4933
|
+
managerLoss.cancel();
|
|
4934
|
+
state.remoteAccessUI?.close();
|
|
4935
|
+
setConnection('down', 'Remote access changed. Your terminals are still running. Reopen an enabled address to reconnect.');
|
|
4936
|
+
return;
|
|
4937
|
+
}
|
|
4926
4938
|
if (state.managerUnavailable) {
|
|
4927
4939
|
showManagerUnavailable();
|
|
4928
4940
|
} else if (state.stopping && state.stopRemaining !== null) {
|
|
@@ -4964,6 +4976,7 @@ let statsInterval;
|
|
|
4964
4976
|
let newsTimer;
|
|
4965
4977
|
|
|
4966
4978
|
function showAuth(message = '') {
|
|
4979
|
+
state.remoteAccessUI?.setAvailable(null);
|
|
4967
4980
|
managerLoss.cancel();
|
|
4968
4981
|
closePanel();
|
|
4969
4982
|
closeModels();
|
|
@@ -5380,5 +5393,6 @@ addEventListener('scroll', publishTopbarHeight, { passive: true });
|
|
|
5380
5393
|
publishTopbarHeight();
|
|
5381
5394
|
applyDockLayout();
|
|
5382
5395
|
|
|
5396
|
+
state.remoteAccessUI = createRemoteAccessUI({ api, getToken: () => state.token, isConnected: () => state.connected, onAuthError: showAuth });
|
|
5383
5397
|
state.token = readTokenFromHash() || load(TOKEN_KEY);
|
|
5384
5398
|
boot();
|
package/web/index.html
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
<button id="notes-open" class="btn notes-open" type="button" aria-controls="dock" aria-pressed="false">Notes</button>
|
|
36
36
|
<button id="settings" class="btn settings menu-button" type="button" popovertarget="settings-menu" aria-haspopup="dialog">Settings</button>
|
|
37
37
|
<div id="settings-menu" class="settings-menu menu" popover role="dialog" aria-label="Settings">
|
|
38
|
+
<button id="remote-access-open" class="btn" type="button" aria-haspopup="dialog" hidden>Remote access</button>
|
|
38
39
|
<fieldset id="skin-choices"><legend>Skin</legend></fieldset>
|
|
39
40
|
<fieldset>
|
|
40
41
|
<legend>Mode</legend>
|
|
@@ -259,6 +260,70 @@
|
|
|
259
260
|
</div>
|
|
260
261
|
</dialog>
|
|
261
262
|
|
|
263
|
+
<dialog id="remote-access" class="models remote-access" aria-labelledby="remote-access-title">
|
|
264
|
+
<div class="models-bar">
|
|
265
|
+
<div class="models-heading">
|
|
266
|
+
<h2 id="remote-access-title">Remote access</h2>
|
|
267
|
+
<p class="sub">Use Agent Guild on another device</p>
|
|
268
|
+
</div>
|
|
269
|
+
<button id="remote-close" class="btn" type="button">Close</button>
|
|
270
|
+
</div>
|
|
271
|
+
<div class="models-body remote-body">
|
|
272
|
+
<p>Access your running terminals through your Tailscale network. Your computer must be awake with Agent Guild and Tailscale running.</p>
|
|
273
|
+
<div class="remote-status" role="status" aria-live="polite" aria-atomic="true">
|
|
274
|
+
<strong id="remote-status-title">Checking connection…</strong>
|
|
275
|
+
<p id="remote-status-text"></p>
|
|
276
|
+
</div>
|
|
277
|
+
<p id="remote-error" class="remote-error" role="alert" hidden></p>
|
|
278
|
+
<div id="remote-address-row" hidden>
|
|
279
|
+
<label for="remote-address">Your remote address</label>
|
|
280
|
+
<input id="remote-address" class="remote-address" readonly spellcheck="false">
|
|
281
|
+
<div class="remote-actions">
|
|
282
|
+
<button id="remote-connect" class="btn primary" type="button" aria-expanded="false" aria-controls="remote-share">Connect another device</button>
|
|
283
|
+
<button id="remote-copy-address" class="btn" type="button">Copy address</button>
|
|
284
|
+
<a id="remote-open-address" class="btn" target="_blank" rel="noopener noreferrer">Open</a>
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
<div id="remote-share" class="remote-share" hidden>
|
|
288
|
+
<p>Connect the other device to Tailscale, then scan this code or open the sign-in link.</p>
|
|
289
|
+
<canvas id="remote-qr" role="img" aria-label="QR code to sign into Agent Guild" hidden></canvas>
|
|
290
|
+
<p id="remote-qr-note" class="sub"></p>
|
|
291
|
+
<label for="remote-signin">Anyone with this code or link can use your terminals.</label>
|
|
292
|
+
<input id="remote-signin" class="remote-address" readonly spellcheck="false" autocomplete="off">
|
|
293
|
+
<button id="remote-copy-signin" class="btn" type="button">Copy sign-in link</button>
|
|
294
|
+
</div>
|
|
295
|
+
<div class="remote-actions">
|
|
296
|
+
<button id="remote-primary" class="btn primary" type="button">Enable remote access</button>
|
|
297
|
+
<a id="remote-help" class="btn primary" target="_blank" rel="noopener noreferrer" hidden></a>
|
|
298
|
+
<button id="remote-check" class="btn" type="button">Check connection</button>
|
|
299
|
+
<button id="remote-disable" class="btn danger" type="button" hidden>Disable remote access</button>
|
|
300
|
+
</div>
|
|
301
|
+
<div id="remote-disable-confirm" class="remote-confirm" hidden>
|
|
302
|
+
<p>Disconnect remote browsers? Your terminals will keep running. Local access stays available.</p>
|
|
303
|
+
<div class="remote-actions">
|
|
304
|
+
<button id="remote-disable-yes" class="btn danger" type="button">Disable remote access</button>
|
|
305
|
+
<button id="remote-disable-no" class="btn" type="button">Keep enabled</button>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
<details id="remote-details">
|
|
309
|
+
<summary>Connection details</summary>
|
|
310
|
+
<p id="remote-diagnostics" class="sub"></p>
|
|
311
|
+
<button id="remote-forget" class="btn" type="button" hidden>Leave the changed route in Tailscale</button>
|
|
312
|
+
</details>
|
|
313
|
+
<details id="remote-advanced">
|
|
314
|
+
<summary>Use another reverse proxy</summary>
|
|
315
|
+
<p id="remote-custom-note">Configure your proxy separately, then enter the hosts and browser origins it uses. Saving applies immediately and replaces environment settings.</p>
|
|
316
|
+
<form id="remote-custom-form">
|
|
317
|
+
<label for="remote-hosts">Allowed hosts, separated by commas</label>
|
|
318
|
+
<input id="remote-hosts" placeholder="guild.example.com" spellcheck="false" autocomplete="off" maxlength="16384">
|
|
319
|
+
<label for="remote-origins">Allowed origins, separated by commas</label>
|
|
320
|
+
<input id="remote-origins" placeholder="https://guild.example.com" spellcheck="false" autocomplete="off" maxlength="16384">
|
|
321
|
+
<button id="remote-custom-save" class="btn" type="submit">Save proxy settings</button>
|
|
322
|
+
</form>
|
|
323
|
+
</details>
|
|
324
|
+
</div>
|
|
325
|
+
</dialog>
|
|
326
|
+
|
|
262
327
|
<dialog id="models" class="models" aria-labelledby="models-title">
|
|
263
328
|
<div class="models-bar">
|
|
264
329
|
<span id="models-icon" class="provider-icon small"></span>
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
export function remotePresentation(data) {
|
|
2
|
+
if (!data) return { title: 'Checking connection…', text: 'Looking for Tailscale on this computer.', label: 'Enable remote access' };
|
|
3
|
+
const problem = data.problem;
|
|
4
|
+
if (data.busy && problem?.code !== 'approval_required') return {
|
|
5
|
+
title: data.busy === 'checking' ? 'Checking connection…' : data.busy === 'disabling' ? 'Disabling remote access…' : 'Preparing remote access…',
|
|
6
|
+
text: 'Your terminal sessions keep running.', label: 'Please wait…',
|
|
7
|
+
};
|
|
8
|
+
if (data.pending === 'disable') return { title: 'Tailscale cleanup needed', text: problem?.code === 'permission_required'
|
|
9
|
+
? 'Remote access is blocked. Run the cleanup command in Connection details from a terminal with permission to manage Tailscale, then retry cleanup.'
|
|
10
|
+
: problem?.message || 'Access through the saved address is blocked. Retry to remove its Serve route.', label: 'Retry cleanup', action: 'disable',
|
|
11
|
+
...(['not_installed', 'update_required'].includes(problem?.code) ? { help: 'https://tailscale.com/download', helpLabel: 'Get Tailscale' } : {}) };
|
|
12
|
+
if (problem?.approvalUrl) return { title: 'One step needed in Tailscale', text: problem.message, help: problem.approvalUrl, helpLabel: 'Open Tailscale approval', label: 'Continue setup' };
|
|
13
|
+
if (problem?.code === 'not_installed') return { title: 'Connect this computer to Tailscale', text: problem.message, help: 'https://tailscale.com/download', helpLabel: 'Install Tailscale', label: 'Continue setup' };
|
|
14
|
+
if (problem?.code === 'update_required') return { title: 'Update Tailscale', text: problem.message, help: 'https://tailscale.com/download', helpLabel: 'Get Tailscale', label: 'Continue setup' };
|
|
15
|
+
if (problem?.code === 'not_connected' || problem?.code === 'address_unavailable') return { title: 'Connect Tailscale', text: problem.message, label: 'Continue setup' };
|
|
16
|
+
if (problem?.code === 'permission_required') return { title: 'Tailscale needs permission', text: 'Open a terminal on this computer with permission to manage Tailscale. The connection details below show the command to run, then choose Continue setup.', label: 'Continue setup' };
|
|
17
|
+
if (problem) return { title: data.pending === 'enable' ? 'Finish setting up remote access' : 'Connection needs attention', text: problem.message, label: data.mode === 'tailscale' ? 'Repair connection' : data.candidate?.existing ? 'Use existing route' : 'Continue setup' };
|
|
18
|
+
if (data.mode === 'tailscale') return {
|
|
19
|
+
title: 'Remote access enabled', text: data.connection?.ok ? 'The secure address reaches this manager. Connect another device to start using it.'
|
|
20
|
+
: data.connection ? 'The route is configured, but this computer could not verify the secure connection. Check Tailscale, then try the address on your other device.' : 'Your saved Tailscale address is ready to check.',
|
|
21
|
+
label: 'Repair connection', hidePrimary: true,
|
|
22
|
+
};
|
|
23
|
+
if (data.candidate?.existing) return { title: 'Your Tailscale route is ready', text: 'A private route already points to this manager. Use it to let Agent Guild save and manage this connection.', label: 'Use existing route' };
|
|
24
|
+
if (data.mode === 'custom') return { title: 'Custom proxy access configured', text: data.source === 'environment' ? 'These settings came from the launch environment. Save them below to keep them across launches.' : 'Your proxy settings are saved and active.', label: 'Set up Tailscale' };
|
|
25
|
+
if (!data.tailscale) return { title: 'Set up remote access', text: 'Check this computer’s Tailscale connection to get started.', label: 'Enable remote access' };
|
|
26
|
+
return { title: 'Tailscale connected', text: 'Enable a private address for your running terminals. Your settings will be saved for future launches.', label: 'Enable remote access' };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createRemoteAccessUI({ api, getToken, isConnected, onAuthError }) {
|
|
30
|
+
const $ = (id) => document.getElementById(id);
|
|
31
|
+
const dialog = $('remote-access');
|
|
32
|
+
let data = null, request = 0, loading = false, posting = false, timer = null, sharing = false, shareGeneration = 0, editRevision = null, dirty = false;
|
|
33
|
+
let available = false, waitingForApproval = false;
|
|
34
|
+
|
|
35
|
+
function clearShare() {
|
|
36
|
+
sharing = false;
|
|
37
|
+
shareGeneration++;
|
|
38
|
+
$('remote-share').hidden = true;
|
|
39
|
+
$('remote-connect').setAttribute('aria-expanded', 'false');
|
|
40
|
+
$('remote-signin').value = '';
|
|
41
|
+
const canvas = $('remote-qr');
|
|
42
|
+
canvas.getContext('2d')?.clearRect(0, 0, canvas.width, canvas.height);
|
|
43
|
+
canvas.hidden = true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function render() {
|
|
47
|
+
const presentation = remotePresentation(data);
|
|
48
|
+
const busy = loading || posting || Boolean(data?.busy) || !isConnected();
|
|
49
|
+
$('remote-status-title').textContent = presentation.title;
|
|
50
|
+
$('remote-status-text').textContent = isConnected() ? presentation.text : 'Reconnect to the manager to change remote access.';
|
|
51
|
+
$('remote-status-title').parentElement.dataset.ready = String(data?.mode === 'tailscale' && !data.problem && !data.busy && Boolean(data.connection?.ok));
|
|
52
|
+
$('remote-primary').textContent = presentation.label;
|
|
53
|
+
$('remote-primary').hidden = Boolean(presentation.hidePrimary || (presentation.help && !presentation.action));
|
|
54
|
+
$('remote-primary').disabled = busy || !data;
|
|
55
|
+
$('remote-check').disabled = busy || !data;
|
|
56
|
+
$('remote-disable').disabled = busy || !data;
|
|
57
|
+
$('remote-disable').hidden = !data || (data.mode === 'off' && !data.pending);
|
|
58
|
+
$('remote-disable-yes').disabled = busy;
|
|
59
|
+
$('remote-custom-save').disabled = busy || !data || data.mode === 'tailscale' || Boolean(data.pending);
|
|
60
|
+
for (const id of ['remote-hosts', 'remote-origins']) $(id).disabled = !data || data.mode === 'tailscale' || Boolean(data.pending);
|
|
61
|
+
$('remote-custom-note').textContent = data?.mode === 'tailscale' || data?.pending
|
|
62
|
+
? 'Disable Tailscale remote access and finish any cleanup before switching to another proxy.'
|
|
63
|
+
: 'Configure your proxy separately, then enter the hosts and browser origins it uses. Saving applies immediately and replaces environment settings.';
|
|
64
|
+
$('remote-help').hidden = !presentation.help;
|
|
65
|
+
if (presentation.help) { $('remote-help').href = presentation.help; $('remote-help').textContent = presentation.helpLabel; }
|
|
66
|
+
$('remote-address-row').hidden = !data?.url;
|
|
67
|
+
$('remote-address').value = data?.url || '';
|
|
68
|
+
$('remote-open-address').href = data?.url || '#';
|
|
69
|
+
$('remote-connect').disabled = busy;
|
|
70
|
+
$('remote-copy-address').disabled = busy;
|
|
71
|
+
$('remote-copy-signin').disabled = busy;
|
|
72
|
+
if (!data?.url || !isConnected() || data.busy === 'disabling') clearShare();
|
|
73
|
+
$('remote-forget').hidden = data?.pending !== 'disable' || !['route_changed', 'network_changed'].includes(data.problem?.code);
|
|
74
|
+
$('remote-forget').disabled = busy;
|
|
75
|
+
const diagnostics = [data?.tailscale ? `Tailscale ${data.tailscale.version} · ${data.tailscale.hostname}` : '',
|
|
76
|
+
data?.source === 'saved' ? 'Settings are saved on the manager computer.' : data?.source === 'environment' ? 'Settings currently come from the launch environment.' : '',
|
|
77
|
+
data?.connection?.checkedAt ? `Last connection check: ${new Date(data.connection.checkedAt).toLocaleString()}` : '',
|
|
78
|
+
data?.command ? `On the manager computer: ${data.command}` : '',
|
|
79
|
+
].filter(Boolean);
|
|
80
|
+
$('remote-diagnostics').textContent = diagnostics.join('\n');
|
|
81
|
+
if (data && !dirty) {
|
|
82
|
+
$('remote-hosts').value = data.hosts.join(', ');
|
|
83
|
+
$('remote-origins').value = data.origins.join(', ');
|
|
84
|
+
editRevision = data.revision;
|
|
85
|
+
}
|
|
86
|
+
if (data?.problem?.approvalUrl) waitingForApproval = true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function showError(error) {
|
|
90
|
+
if (error.name === 'AuthError' || error.message === 'The access token was rejected.') { close(); onAuthError(error.message); return; }
|
|
91
|
+
$('remote-error').textContent = error.message || 'Could not change remote access. Try again.';
|
|
92
|
+
$('remote-error').hidden = false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function schedule() {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
if (dialog.open && available && isConnected()) timer = setTimeout(() => refresh(!data?.busy), data?.busy ? 800 : 30000);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function refresh(check = false) {
|
|
101
|
+
if (!dialog.open || !available || !isConnected() || loading || posting) return;
|
|
102
|
+
const generation = ++request;
|
|
103
|
+
loading = true;
|
|
104
|
+
if (check) $('remote-error').hidden = true;
|
|
105
|
+
render();
|
|
106
|
+
try {
|
|
107
|
+
const result = await api(check ? 'POST' : 'GET', check ? '/remote-access/check' : '/remote-access');
|
|
108
|
+
if (generation !== request || !dialog.open) return;
|
|
109
|
+
if (data?.url !== result.remoteAccess.url) clearShare();
|
|
110
|
+
data = result.remoteAccess;
|
|
111
|
+
} catch (error) { if (generation === request && dialog.open) showError(error); }
|
|
112
|
+
finally { if (generation === request) { loading = false; render(); schedule(); } }
|
|
113
|
+
if (waitingForApproval && !data?.busy && data?.pending === 'enable' && data?.tailscale?.httpsReady && dialog.open) {
|
|
114
|
+
waitingForApproval = false;
|
|
115
|
+
await change('enable');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function change(action, extra = {}) {
|
|
120
|
+
if (!data || posting || data.busy || !isConnected()) return;
|
|
121
|
+
posting = true;
|
|
122
|
+
const generation = ++request;
|
|
123
|
+
$('remote-error').hidden = true;
|
|
124
|
+
$('remote-disable-confirm').hidden = true;
|
|
125
|
+
if (action !== 'enable') clearShare();
|
|
126
|
+
render();
|
|
127
|
+
try {
|
|
128
|
+
const result = await api('PUT', '/remote-access', { revision: data.revision, action, adopt: Boolean(data.candidate?.existing), ...extra });
|
|
129
|
+
if (generation !== request || !dialog.open) return;
|
|
130
|
+
data = result.remoteAccess;
|
|
131
|
+
if (action === 'custom') dirty = false;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (generation === request && dialog.open) {
|
|
134
|
+
showError(error);
|
|
135
|
+
if (error.code === 'stale_settings') {
|
|
136
|
+
dirty = false;
|
|
137
|
+
try { data = (await api('GET', '/remote-access')).remoteAccess; } catch (refreshError) { showError(refreshError); }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} finally { if (generation === request) { posting = false; render(); schedule(); } }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function copy(input, button) {
|
|
144
|
+
try {
|
|
145
|
+
if (!navigator.clipboard) throw new Error('Clipboard unavailable');
|
|
146
|
+
await navigator.clipboard.writeText(input.value);
|
|
147
|
+
const label = button.textContent;
|
|
148
|
+
button.textContent = 'Copied';
|
|
149
|
+
setTimeout(() => { button.textContent = label; }, 1600);
|
|
150
|
+
} catch {
|
|
151
|
+
input.focus(); input.select();
|
|
152
|
+
$('remote-error').textContent = 'The link is selected. Use your browser’s Copy command.';
|
|
153
|
+
$('remote-error').hidden = false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function toggleShare() {
|
|
158
|
+
if (sharing) { clearShare(); return; }
|
|
159
|
+
if (!data?.url || !getToken() || !isConnected()) return;
|
|
160
|
+
sharing = true;
|
|
161
|
+
const generation = ++shareGeneration;
|
|
162
|
+
const url = new URL(data.url);
|
|
163
|
+
url.hash = new URLSearchParams({ token: getToken() }).toString();
|
|
164
|
+
$('remote-signin').value = url.href;
|
|
165
|
+
$('remote-share').hidden = false;
|
|
166
|
+
$('remote-connect').setAttribute('aria-expanded', 'true');
|
|
167
|
+
$('remote-qr-note').textContent = 'Preparing QR code…';
|
|
168
|
+
try {
|
|
169
|
+
const { default: qrcode } = await import('./vendor/qrcode.mjs');
|
|
170
|
+
if (!sharing || generation !== shareGeneration || !dialog.open) return;
|
|
171
|
+
const code = qrcode(0, 'M');
|
|
172
|
+
code.addData(url.href, 'Byte');
|
|
173
|
+
code.make();
|
|
174
|
+
const count = code.getModuleCount(), scale = 5, border = 4;
|
|
175
|
+
const canvas = $('remote-qr');
|
|
176
|
+
canvas.width = canvas.height = (count + border * 2) * scale;
|
|
177
|
+
const context = canvas.getContext('2d');
|
|
178
|
+
context.fillStyle = '#ffffff'; context.fillRect(0, 0, canvas.width, canvas.height);
|
|
179
|
+
context.fillStyle = '#000000';
|
|
180
|
+
for (let row = 0; row < count; row++) for (let col = 0; col < count; col++) {
|
|
181
|
+
if (code.isDark(row, col)) context.fillRect((col + border) * scale, (row + border) * scale, scale, scale);
|
|
182
|
+
}
|
|
183
|
+
canvas.hidden = false;
|
|
184
|
+
$('remote-qr-note').textContent = '';
|
|
185
|
+
} catch { if (generation === shareGeneration) $('remote-qr-note').textContent = 'The QR code could not load. Use the sign-in link below.'; }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function close() {
|
|
189
|
+
request++; loading = false; posting = false;
|
|
190
|
+
clearTimeout(timer); clearShare();
|
|
191
|
+
$('remote-disable-confirm').hidden = true;
|
|
192
|
+
if (dialog.open) dialog.close();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
$('remote-access-open').addEventListener('click', async () => {
|
|
196
|
+
$('settings-menu').hidePopover?.();
|
|
197
|
+
$('topbar-menu').hidePopover?.();
|
|
198
|
+
if (!dialog.open) dialog.showModal();
|
|
199
|
+
$('remote-error').hidden = true;
|
|
200
|
+
dirty = false;
|
|
201
|
+
await refresh(false);
|
|
202
|
+
await refresh(true);
|
|
203
|
+
});
|
|
204
|
+
$('remote-close').addEventListener('click', close);
|
|
205
|
+
dialog.addEventListener('click', (event) => { if (event.target === dialog && event.clientX !== 0) {
|
|
206
|
+
const rect = dialog.getBoundingClientRect();
|
|
207
|
+
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) close();
|
|
208
|
+
} });
|
|
209
|
+
dialog.addEventListener('close', () => { close(); ($('settings').checkVisibility() ? $('settings') : $('menu-toggle')).focus(); });
|
|
210
|
+
$('remote-primary').addEventListener('click', () => change(remotePresentation(data).action || 'enable'));
|
|
211
|
+
$('remote-check').addEventListener('click', () => refresh(true));
|
|
212
|
+
$('remote-disable').addEventListener('click', () => { $('remote-disable-confirm').hidden = false; $('remote-disable-yes').focus(); });
|
|
213
|
+
$('remote-disable-yes').addEventListener('click', () => change('disable'));
|
|
214
|
+
$('remote-disable-no').addEventListener('click', () => { $('remote-disable-confirm').hidden = true; $('remote-disable').focus(); });
|
|
215
|
+
$('remote-forget').addEventListener('click', () => change('forget'));
|
|
216
|
+
$('remote-connect').addEventListener('click', toggleShare);
|
|
217
|
+
$('remote-copy-address').addEventListener('click', () => copy($('remote-address'), $('remote-copy-address')));
|
|
218
|
+
$('remote-copy-signin').addEventListener('click', () => copy($('remote-signin'), $('remote-copy-signin')));
|
|
219
|
+
for (const id of ['remote-hosts', 'remote-origins']) $(id).addEventListener('input', () => { dirty = true; });
|
|
220
|
+
$('remote-custom-form').addEventListener('submit', (event) => {
|
|
221
|
+
event.preventDefault();
|
|
222
|
+
const list = (id) => $(id).value.split(',').map((value) => value.trim()).filter(Boolean);
|
|
223
|
+
change('custom', { revision: editRevision, hosts: list('remote-hosts'), origins: list('remote-origins') });
|
|
224
|
+
});
|
|
225
|
+
addEventListener('focus', () => { if (dialog.open && !data?.busy) refresh(true); });
|
|
226
|
+
document.addEventListener('visibilitychange', () => {
|
|
227
|
+
if (document.visibilityState === 'hidden') clearShare();
|
|
228
|
+
else if (dialog.open && !data?.busy) refresh(true);
|
|
229
|
+
});
|
|
230
|
+
return {
|
|
231
|
+
setAvailable(capability) { available = Boolean(capability?.available); $('remote-access-open').hidden = !available; if (!available) close(); },
|
|
232
|
+
updated() { if (dialog.open) refresh(false); },
|
|
233
|
+
connectionChanged() { render(); if (!isConnected()) { clearTimeout(timer); clearShare(); } else schedule(); },
|
|
234
|
+
close,
|
|
235
|
+
};
|
|
236
|
+
}
|
package/web/styles.css
CHANGED
|
@@ -559,6 +559,29 @@ body:has(#app[hidden]):has(#auth[hidden]):has(#stopped[hidden])::after {
|
|
|
559
559
|
.models-heading { flex: 1; min-width: 0; }
|
|
560
560
|
.models-heading .sub { margin: 2px 0 0; color: var(--muted); font-size: 0.78rem; }
|
|
561
561
|
.models-body { flex: 1; min-height: 0; overflow: auto; padding: 0 14px 14px; }
|
|
562
|
+
.remote-access { width: min(560px, calc(100vw - 24px)); max-height: calc(100dvh - 24px); }
|
|
563
|
+
.remote-body { padding: 4px 20px 20px; font-size: 0.9rem; }
|
|
564
|
+
.remote-body > p { color: var(--muted); line-height: 1.6; }
|
|
565
|
+
.remote-status { padding: 16px; margin: 16px 0; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-2); }
|
|
566
|
+
.remote-status[data-ready="true"] { border-color: var(--ok); }
|
|
567
|
+
.remote-status strong { font-size: 1rem; }
|
|
568
|
+
.remote-status p { margin: 6px 0 0; color: var(--muted); line-height: 1.5; }
|
|
569
|
+
.remote-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
|
|
570
|
+
.remote-body .btn { min-height: 42px; }
|
|
571
|
+
.remote-body a.btn { display: inline-flex; align-items: center; justify-content: center; text-decoration: none; }
|
|
572
|
+
.remote-body label { display: block; margin: 12px 0 6px; font-size: 0.8rem; }
|
|
573
|
+
.remote-body input { width: 100%; min-width: 0; box-sizing: border-box; padding: 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); color: var(--text); font: inherit; font-size: 0.8rem; }
|
|
574
|
+
.remote-body input:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
575
|
+
.remote-body details { border-top: 1px solid var(--border); margin-top: 18px; padding-top: 14px; }
|
|
576
|
+
.remote-body summary { cursor: pointer; color: var(--muted); }
|
|
577
|
+
.remote-body .sub { font-size: 0.8rem; color: var(--muted); line-height: 1.5; white-space: pre-line; overflow-wrap: anywhere; }
|
|
578
|
+
.remote-body form .btn { margin-top: 12px; }
|
|
579
|
+
.remote-body .remote-error { color: var(--danger); overflow-wrap: anywhere; }
|
|
580
|
+
.remote-share, .remote-confirm { padding: 14px; border: 1px solid var(--border); border-radius: var(--radius); margin: 14px 0; }
|
|
581
|
+
.remote-share p { margin-top: 0; line-height: 1.5; }
|
|
582
|
+
.remote-share canvas { display: block; width: min(100%, 260px); height: auto; margin: 16px auto; image-rendering: pixelated; }
|
|
583
|
+
.remote-share canvas[hidden] { display: none; }
|
|
584
|
+
.remote-share .btn { margin-top: 12px; }
|
|
562
585
|
.model-columns, .model-toggle { display: grid; grid-template-columns: minmax(0, 1fr) repeat(3, 6.5rem) 4.5rem; align-items: center; gap: 8px; }
|
|
563
586
|
.model-columns {
|
|
564
587
|
position: sticky; top: 0; z-index: 1; padding: 10px 8px 6px; background: var(--surface);
|