dsh-computers 0.0.0-stage → 1.1.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 +21 -0
- package/PROTOCOL.md +559 -0
- package/README.md +147 -2
- package/agent/README.md +276 -0
- package/agent/dsh-computers-agent.mjs +1993 -0
- package/client.js +3219 -0
- package/cordis.patch.yml +23 -0
- package/docs/deploy.md +103 -0
- package/icon.svg +8 -0
- package/index.js +211 -0
- package/lib/http.js +761 -0
- package/lib/hub.js +381 -0
- package/lib/keys.js +134 -0
- package/lib/routes.js +697 -0
- package/lib/runtime.js +469 -0
- package/lib/selection.js +205 -0
- package/lib/state.js +181 -0
- package/lib/tools.js +394 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
package/client.js
ADDED
|
@@ -0,0 +1,3219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-computers — browser half (Settings → 电脑).
|
|
3
|
+
*
|
|
4
|
+
* A self-contained DSH Web client bundle: the settings page that lists the
|
|
5
|
+
* user's connected machines and the "connect a computer" modal that mints a
|
|
6
|
+
* one-time enrollment key, shows the one-line installer and waits for the
|
|
7
|
+
* machine to come online.
|
|
8
|
+
*
|
|
9
|
+
* Contract: PROTOCOL.md §4 (browser → host API, document-relative paths) and
|
|
10
|
+
* §7 (this surface). The module envelope and the `slots` / `locale` service
|
|
11
|
+
* usage follow the cordis-plugin-development templates; every visible string
|
|
12
|
+
* is localized here and no client-ui-* package is imported.
|
|
13
|
+
*/
|
|
14
|
+
window.__ModuleLoader__.load({
|
|
15
|
+
id: 'dsh-computers',
|
|
16
|
+
factory(require) {
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const React = require('react');
|
|
20
|
+
const h = React.createElement;
|
|
21
|
+
|
|
22
|
+
/* `react-dom` is a platform seed word of the Web client, so the dialogs
|
|
23
|
+
* portal to <body> and no ancestor can clip or re-anchor them. A shell that
|
|
24
|
+
* withholds it degrades to an in-place `position:fixed` layer. */
|
|
25
|
+
let ReactDOM = null;
|
|
26
|
+
try {
|
|
27
|
+
const reactDom = require('react-dom');
|
|
28
|
+
if (reactDom && typeof reactDom.createPortal === 'function') ReactDOM = reactDom;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
ReactDOM = null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const NS = 'dsh-computers';
|
|
34
|
+
const SECTION_ID = 'computers';
|
|
35
|
+
const SECTION_ORDER = 45;
|
|
36
|
+
const TARGET_SLOT_ID = 'computers-target';
|
|
37
|
+
const TARGET_SLOT_ORDER = 20;
|
|
38
|
+
const STYLE_ID = 'dsh-computers/client.css';
|
|
39
|
+
const STATE_POLL_MS = 5000;
|
|
40
|
+
const KEY_POLL_MS = 2000;
|
|
41
|
+
const NOTICE_MS = 4200;
|
|
42
|
+
|
|
43
|
+
/* Document-relative on purpose: the GUI is served with <base href="./">, so
|
|
44
|
+
* a root-absolute "/api/..." escapes a sub-path deployment (PROTOCOL §4). */
|
|
45
|
+
const API = {
|
|
46
|
+
state: 'api/dsh-computers/state',
|
|
47
|
+
keys: 'api/dsh-computers/keys',
|
|
48
|
+
keyStatus: 'api/dsh-computers/key-status',
|
|
49
|
+
remove: 'api/dsh-computers/delete',
|
|
50
|
+
rename: 'api/dsh-computers/rename',
|
|
51
|
+
/* Runtime mode (PROTOCOL §10.5): this machine joining a hub. */
|
|
52
|
+
runtime: 'api/dsh-computers/runtime',
|
|
53
|
+
runtimeConnect: 'api/dsh-computers/runtime/connect',
|
|
54
|
+
runtimeEnable: 'api/dsh-computers/runtime/enable',
|
|
55
|
+
runtimeDisconnect: 'api/dsh-computers/runtime/disconnect',
|
|
56
|
+
/* Per-session target computer for the composer chip (task-9 host side). */
|
|
57
|
+
selection: 'api/dsh-computers/selection',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// #region locales
|
|
61
|
+
|
|
62
|
+
/* Chinese is the key source; en mirrors every key (PROTOCOL §7). */
|
|
63
|
+
const zh = {
|
|
64
|
+
nav: '电脑',
|
|
65
|
+
title: '电脑',
|
|
66
|
+
description: '把你自己电脑接进来,agent 就能在它上面直接执行命令。',
|
|
67
|
+
'connect.action': '连接电脑',
|
|
68
|
+
'badge.bots': '{used}/{total} Bot',
|
|
69
|
+
online: '在线',
|
|
70
|
+
offline: '离线',
|
|
71
|
+
'rename.action': '重命名',
|
|
72
|
+
'delete.action': '删除',
|
|
73
|
+
loading: '正在加载电脑列表…',
|
|
74
|
+
'empty.title': '还没有连接任何电脑',
|
|
75
|
+
'empty.hint': '点击右上角「连接电脑」,用一行命令把 Mac、Linux 或 Windows 接进来。',
|
|
76
|
+
'error.load': '无法读取电脑列表',
|
|
77
|
+
'error.loadDetail': '{title}:{message}',
|
|
78
|
+
'error.network': '无法连接到服务器,请检查网络后重试。',
|
|
79
|
+
'error.badResponse': '服务器返回了无法识别的响应。',
|
|
80
|
+
'error.generic': '操作失败,请稍后重试。',
|
|
81
|
+
'error.http': '请求失败(HTTP {status})',
|
|
82
|
+
retry: '重试',
|
|
83
|
+
'render.error': '这个页面遇到了意外错误。',
|
|
84
|
+
'render.reload': '重新加载',
|
|
85
|
+
|
|
86
|
+
'rename.title': '重命名这台电脑',
|
|
87
|
+
'rename.field': '显示名称',
|
|
88
|
+
'rename.placeholder': '例如:客厅 Mac mini',
|
|
89
|
+
'rename.hint': '1–64 个字符,只影响这份列表和 agent 看到的名称。',
|
|
90
|
+
'rename.failed': '重命名失败:{message}',
|
|
91
|
+
'renamed.notice': '已重命名为「{name}」',
|
|
92
|
+
|
|
93
|
+
'delete.title': '删除这台电脑?',
|
|
94
|
+
'delete.body': '「{name}」会从列表移除,与它之间的连接会断开。需要时可以重新连接。',
|
|
95
|
+
'delete.confirm': '删除',
|
|
96
|
+
'delete.failed': '删除失败:{message}',
|
|
97
|
+
'deleted.notice': '已删除「{name}」',
|
|
98
|
+
|
|
99
|
+
'dialog.close': '关闭',
|
|
100
|
+
cancel: '取消',
|
|
101
|
+
save: '保存',
|
|
102
|
+
saving: '保存中…',
|
|
103
|
+
deleting: '删除中…',
|
|
104
|
+
|
|
105
|
+
'connect.title': '连接电脑',
|
|
106
|
+
'connect.subtitle': '在这台电脑的 DSH 里打开 设置 → 电脑 → 本机运行时,填入下面的服务器地址和加入密钥',
|
|
107
|
+
'connect.osGroup': '选择电脑的系统',
|
|
108
|
+
'connect.os.windows': 'Windows',
|
|
109
|
+
'connect.os.macos': 'macOS',
|
|
110
|
+
'connect.os.linux': 'Linux',
|
|
111
|
+
'connect.replace.title': '覆盖这台电脑已保存的连接',
|
|
112
|
+
'connect.replace.hint': '终端提示已有其他连接时开启,再重新复制命令',
|
|
113
|
+
'connect.command.label': '终端命令',
|
|
114
|
+
'connect.copy': '复制命令',
|
|
115
|
+
'connect.copied': '已复制',
|
|
116
|
+
'connect.copyFailed': '复制失败,请手动选中命令文本',
|
|
117
|
+
'connect.creating': '正在生成命令…',
|
|
118
|
+
'connect.createFailed': '命令生成失败:{message}',
|
|
119
|
+
'connect.waiting': '等待电脑连接…',
|
|
120
|
+
'connect.connected': '电脑已连接',
|
|
121
|
+
'connect.expired': '命令已过期,请重新生成',
|
|
122
|
+
'connect.used': '该命令已被使用,请重新生成',
|
|
123
|
+
'connect.unknown': '命令已失效,请重新生成',
|
|
124
|
+
'connect.regenerate': '重新生成',
|
|
125
|
+
'connect.connected.notice': '已连接「{name}」',
|
|
126
|
+
'connect.connected.noticeAnonymous': '电脑已连接',
|
|
127
|
+
'connect.pendingCommand': '命令准备中…',
|
|
128
|
+
'connect.serverUrl': '服务器地址',
|
|
129
|
+
'connect.serverUrlHint': '本机运行时卡片里填这个地址',
|
|
130
|
+
'connect.enrollmentKey': '加入密钥',
|
|
131
|
+
'connect.enrollmentKeyHint': '15 分钟内有效,只能用一次',
|
|
132
|
+
'connect.copyField': '复制{field}',
|
|
133
|
+
'connect.cliToggle': '没有 DSH 的机器?用命令行接入',
|
|
134
|
+
'connect.cliHint': '在要连接的电脑上安装 Node.js 22+,然后在终端运行下方命令',
|
|
135
|
+
|
|
136
|
+
'computers.group': '已连接的电脑',
|
|
137
|
+
'runtime.title': '本机运行时',
|
|
138
|
+
'runtime.desc': '把这台电脑接到一个 DSH 服务器,它就会出现在那台服务器的电脑列表里。',
|
|
139
|
+
'runtime.hint': '加入密钥来自服务器「连接电脑」弹窗里的命令。',
|
|
140
|
+
'runtime.serverLabel': '服务器地址',
|
|
141
|
+
'runtime.serverPlaceholder': 'https://dsh.example.com',
|
|
142
|
+
'runtime.keyLabel': '加入密钥',
|
|
143
|
+
'runtime.keyPlaceholder': 'dshc_…',
|
|
144
|
+
'runtime.connect': '连接',
|
|
145
|
+
'runtime.connecting': '连接中…',
|
|
146
|
+
'runtime.target': '目标服务器',
|
|
147
|
+
'runtime.enable': '启用',
|
|
148
|
+
'runtime.disconnect': '断开',
|
|
149
|
+
'runtime.disconnectTitle': '断开这台电脑?',
|
|
150
|
+
'runtime.disconnectBody': '这台电脑会停止向 {server} 保持连接,并在那台服务器的电脑列表里变为离线。记录会保留(可在服务器上删除),之后也可以随时重新连接。',
|
|
151
|
+
'runtime.disconnectFailed': '断开失败:{message}',
|
|
152
|
+
'runtime.enableFailed': '切换启用状态失败:{message}',
|
|
153
|
+
'runtime.connectFailed': '连接失败:{message}',
|
|
154
|
+
'runtime.loading': '正在读取本机状态…',
|
|
155
|
+
'runtime.loadFailed': '无法读取本机状态',
|
|
156
|
+
'runtime.nameUnknown': '未命名的这台电脑',
|
|
157
|
+
'runtime.status.running': '运行中',
|
|
158
|
+
'runtime.status.stopped': '已停止',
|
|
159
|
+
'runtime.status.connecting': '连接中…',
|
|
160
|
+
'runtime.status.failed': '连接失败',
|
|
161
|
+
'runtime.lastError': '最近错误:{message}',
|
|
162
|
+
'runtime.connected.notice': '本机已接入 {server}',
|
|
163
|
+
'runtime.disconnected.notice': '已断开本机运行时',
|
|
164
|
+
|
|
165
|
+
'target.label': '目标电脑',
|
|
166
|
+
'target.cloud': '云端电脑',
|
|
167
|
+
'target.cloudHint': '服务器本机',
|
|
168
|
+
'target.menuLabel': '选择这台会话使用的电脑',
|
|
169
|
+
'target.chipLabel': '这台对话使用的电脑:{name}',
|
|
170
|
+
'target.manage': '在 设置 → 电脑 中管理',
|
|
171
|
+
'target.offline': '离线',
|
|
172
|
+
'target.failed': '切换失败:{message}',
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const en = {
|
|
176
|
+
nav: 'Computers',
|
|
177
|
+
title: 'Computers',
|
|
178
|
+
description: 'Bring your own machines in so the agent can run commands on them.',
|
|
179
|
+
'connect.action': 'Connect computer',
|
|
180
|
+
'badge.bots': '{used}/{total} Bot',
|
|
181
|
+
online: 'Online',
|
|
182
|
+
offline: 'Offline',
|
|
183
|
+
'rename.action': 'Rename',
|
|
184
|
+
'delete.action': 'Delete',
|
|
185
|
+
loading: 'Loading computers…',
|
|
186
|
+
'empty.title': 'No computers connected yet',
|
|
187
|
+
'empty.hint': 'Use “Connect computer” in the top right and run one line in a terminal on a Mac, Linux or Windows machine.',
|
|
188
|
+
'error.load': 'Could not load the computer list',
|
|
189
|
+
'error.loadDetail': '{title}: {message}',
|
|
190
|
+
'error.network': 'Could not reach the server. Check the network and try again.',
|
|
191
|
+
'error.badResponse': 'The server returned an unrecognized response.',
|
|
192
|
+
'error.generic': 'That did not work. Try again in a moment.',
|
|
193
|
+
'error.http': 'Request failed (HTTP {status})',
|
|
194
|
+
retry: 'Retry',
|
|
195
|
+
'render.error': 'This page hit an unexpected error.',
|
|
196
|
+
'render.reload': 'Reload',
|
|
197
|
+
|
|
198
|
+
'rename.title': 'Rename this computer',
|
|
199
|
+
'rename.field': 'Display name',
|
|
200
|
+
'rename.placeholder': 'e.g. Living room Mac mini',
|
|
201
|
+
'rename.hint': '1–64 characters. Only this list and the name the agent sees change.',
|
|
202
|
+
'rename.failed': 'Rename failed: {message}',
|
|
203
|
+
'renamed.notice': 'Renamed to “{name}”',
|
|
204
|
+
|
|
205
|
+
'delete.title': 'Delete this computer?',
|
|
206
|
+
'delete.body': '“{name}” leaves this list and its connection is dropped. You can connect it again later.',
|
|
207
|
+
'delete.confirm': 'Delete',
|
|
208
|
+
'delete.failed': 'Delete failed: {message}',
|
|
209
|
+
'deleted.notice': 'Deleted “{name}”',
|
|
210
|
+
|
|
211
|
+
'dialog.close': 'Close',
|
|
212
|
+
cancel: 'Cancel',
|
|
213
|
+
save: 'Save',
|
|
214
|
+
saving: 'Saving…',
|
|
215
|
+
deleting: 'Deleting…',
|
|
216
|
+
|
|
217
|
+
'connect.title': 'Connect computer',
|
|
218
|
+
'connect.subtitle': 'Open Settings → Computers → This computer in the DSH on that machine and paste the server URL and join key below',
|
|
219
|
+
'connect.osGroup': 'Choose the system of that computer',
|
|
220
|
+
'connect.os.windows': 'Windows',
|
|
221
|
+
'connect.os.macos': 'macOS',
|
|
222
|
+
'connect.os.linux': 'Linux',
|
|
223
|
+
'connect.replace.title': 'Replace the connection saved on this computer',
|
|
224
|
+
'connect.replace.hint': 'Turn this on when the terminal says another connection already exists, then copy the command again',
|
|
225
|
+
'connect.command.label': 'Terminal command',
|
|
226
|
+
'connect.copy': 'Copy command',
|
|
227
|
+
'connect.copied': 'Copied',
|
|
228
|
+
'connect.copyFailed': 'Copy failed — select the command text manually',
|
|
229
|
+
'connect.creating': 'Generating command…',
|
|
230
|
+
'connect.createFailed': 'Could not generate the command: {message}',
|
|
231
|
+
'connect.waiting': 'Waiting for the computer to connect…',
|
|
232
|
+
'connect.connected': 'Connected',
|
|
233
|
+
'connect.expired': 'This command expired — generate a new one',
|
|
234
|
+
'connect.used': 'This command was already used — generate a new one',
|
|
235
|
+
'connect.unknown': 'This command is no longer valid — generate a new one',
|
|
236
|
+
'connect.regenerate': 'Generate again',
|
|
237
|
+
'connect.connected.notice': 'Connected “{name}”',
|
|
238
|
+
'connect.connected.noticeAnonymous': 'Computer connected',
|
|
239
|
+
'connect.pendingCommand': 'Preparing the command…',
|
|
240
|
+
'connect.serverUrl': 'Server URL',
|
|
241
|
+
'connect.serverUrlHint': 'Paste this into the “This computer” card',
|
|
242
|
+
'connect.enrollmentKey': 'Join key',
|
|
243
|
+
'connect.enrollmentKeyHint': 'Valid for 15 minutes, single use',
|
|
244
|
+
'connect.copyField': 'Copy {field}',
|
|
245
|
+
'connect.cliToggle': 'No DSH on that machine? Use the command line',
|
|
246
|
+
'connect.cliHint': 'Install Node.js 22+ on that machine, then run the command below in a terminal',
|
|
247
|
+
|
|
248
|
+
'computers.group': 'Connected computers',
|
|
249
|
+
'runtime.title': 'This computer',
|
|
250
|
+
'runtime.desc': 'Connect this machine to a DSH server and it shows up in that server’s computer list.',
|
|
251
|
+
'runtime.hint': 'The join key comes from the “Connect computer” dialog on that server.',
|
|
252
|
+
'runtime.serverLabel': 'Server URL',
|
|
253
|
+
'runtime.serverPlaceholder': 'https://dsh.example.com',
|
|
254
|
+
'runtime.keyLabel': 'Join key',
|
|
255
|
+
'runtime.keyPlaceholder': 'dshc_…',
|
|
256
|
+
'runtime.connect': 'Connect',
|
|
257
|
+
'runtime.connecting': 'Connecting…',
|
|
258
|
+
'runtime.target': 'Target server',
|
|
259
|
+
'runtime.enable': 'Enabled',
|
|
260
|
+
'runtime.disconnect': 'Disconnect',
|
|
261
|
+
'runtime.disconnectTitle': 'Disconnect this computer?',
|
|
262
|
+
'runtime.disconnectBody': 'This machine stops connecting to {server} and goes offline in that server’s computer list. The record stays until you delete it there, and you can connect again at any time.',
|
|
263
|
+
'runtime.disconnectFailed': 'Disconnect failed: {message}',
|
|
264
|
+
'runtime.enableFailed': 'Could not change the enabled state: {message}',
|
|
265
|
+
'runtime.connectFailed': 'Connect failed: {message}',
|
|
266
|
+
'runtime.loading': 'Reading this computer’s status…',
|
|
267
|
+
'runtime.loadFailed': 'Could not read this computer’s status',
|
|
268
|
+
'runtime.nameUnknown': 'This computer',
|
|
269
|
+
'runtime.status.running': 'Running',
|
|
270
|
+
'runtime.status.stopped': 'Stopped',
|
|
271
|
+
'runtime.status.connecting': 'Connecting…',
|
|
272
|
+
'runtime.status.failed': 'Connection failed',
|
|
273
|
+
'runtime.lastError': 'Last error: {message}',
|
|
274
|
+
'runtime.connected.notice': 'This computer joined {server}',
|
|
275
|
+
'runtime.disconnected.notice': 'This computer’s runtime is disconnected',
|
|
276
|
+
|
|
277
|
+
'target.label': 'Target computer',
|
|
278
|
+
'target.cloud': 'Cloud computer',
|
|
279
|
+
'target.cloudHint': 'Server itself',
|
|
280
|
+
'target.menuLabel': 'Choose the computer this session uses',
|
|
281
|
+
'target.chipLabel': 'Computer for this chat: {name}',
|
|
282
|
+
'target.manage': 'Manage in Settings → Computers',
|
|
283
|
+
'target.offline': 'Offline',
|
|
284
|
+
'target.failed': 'Could not switch: {message}',
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/* Active translate seat, wired by apply() to the locale service. The
|
|
288
|
+
* dictionary fallback keeps the surface readable when the seat is missing
|
|
289
|
+
* (module-scope callers, services not yet up, structural tests). */
|
|
290
|
+
let runtimeT = null;
|
|
291
|
+
let localeFace = null;
|
|
292
|
+
|
|
293
|
+
function activeLocale() {
|
|
294
|
+
let id = null;
|
|
295
|
+
try {
|
|
296
|
+
if (localeFace && typeof localeFace.getLocale === 'function') id = localeFace.getLocale().active;
|
|
297
|
+
} catch (error) {
|
|
298
|
+
id = null;
|
|
299
|
+
}
|
|
300
|
+
if (typeof id !== 'string' || id === '') {
|
|
301
|
+
try {
|
|
302
|
+
if (typeof document !== 'undefined' && document.documentElement) id = document.documentElement.lang;
|
|
303
|
+
} catch (error) {
|
|
304
|
+
id = null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return typeof id === 'string' ? id.toLowerCase() : '';
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function dictionary() {
|
|
311
|
+
return activeLocale().startsWith('en') ? en : zh;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function interpolate(text, values) {
|
|
315
|
+
if (typeof text !== 'string' || values === undefined || values === null) return text;
|
|
316
|
+
return text.replace(/\{(\w+)\}/g, (match, name) => (
|
|
317
|
+
Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match
|
|
318
|
+
));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Translate one key for the current locale.
|
|
323
|
+
* @param {string} key - dictionary key.
|
|
324
|
+
* @param {object} [values] - `{name}` template values.
|
|
325
|
+
* @returns {string} localized text.
|
|
326
|
+
*/
|
|
327
|
+
function t(key, values) {
|
|
328
|
+
let text;
|
|
329
|
+
if (runtimeT !== null) {
|
|
330
|
+
try {
|
|
331
|
+
const translated = runtimeT(key);
|
|
332
|
+
if (typeof translated === 'string' && translated !== key) text = translated;
|
|
333
|
+
} catch (error) {
|
|
334
|
+
text = undefined;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (text === undefined) {
|
|
338
|
+
const dict = dictionary();
|
|
339
|
+
text = dict[key] !== undefined ? dict[key] : (zh[key] !== undefined ? zh[key] : key);
|
|
340
|
+
}
|
|
341
|
+
return interpolate(text, values);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// #endregion
|
|
345
|
+
|
|
346
|
+
// #region icons
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Inline SVG icon in the shared 24px / 1.6px-stroke language.
|
|
350
|
+
* @param {object} props - `size`, `shapes` ([tag, attrs] pairs).
|
|
351
|
+
* @returns {object} svg element.
|
|
352
|
+
*/
|
|
353
|
+
function SvgIcon(props) {
|
|
354
|
+
const size = props.size === undefined ? 16 : props.size;
|
|
355
|
+
const shapes = props.shapes === undefined ? [] : props.shapes;
|
|
356
|
+
return h('svg', {
|
|
357
|
+
width: size,
|
|
358
|
+
height: size,
|
|
359
|
+
viewBox: '0 0 24 24',
|
|
360
|
+
fill: 'none',
|
|
361
|
+
stroke: 'currentColor',
|
|
362
|
+
strokeWidth: 1.6,
|
|
363
|
+
strokeLinecap: 'round',
|
|
364
|
+
strokeLinejoin: 'round',
|
|
365
|
+
'aria-hidden': 'true',
|
|
366
|
+
focusable: 'false',
|
|
367
|
+
className: props.className,
|
|
368
|
+
}, shapes.map((shape, index) => h(shape[0], Object.assign({ key: `s${index}` }, shape[1]))));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function IconPlus(props) {
|
|
372
|
+
return h(SvgIcon, { size: props.size, shapes: [['path', { d: 'M12 5v14' }], ['path', { d: 'M5 12h14' }]] });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function IconClose(props) {
|
|
376
|
+
return h(SvgIcon, { size: props.size, shapes: [['path', { d: 'M18 6 6 18' }], ['path', { d: 'm6 6 12 12' }]] });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function IconCheck(props) {
|
|
380
|
+
return h(SvgIcon, { size: props.size, shapes: [['path', { d: 'M20 6 9 17l-5-5' }]] });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function IconGear(props) {
|
|
384
|
+
return h(SvgIcon, {
|
|
385
|
+
size: props.size,
|
|
386
|
+
shapes: [
|
|
387
|
+
['path', { d: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z' }],
|
|
388
|
+
['circle', { cx: 12, cy: 12, r: 3 }],
|
|
389
|
+
],
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function IconTrash(props) {
|
|
394
|
+
return h(SvgIcon, {
|
|
395
|
+
size: props.size,
|
|
396
|
+
shapes: [
|
|
397
|
+
['path', { d: 'M3 6h18' }],
|
|
398
|
+
['path', { d: 'M8 6V4h8v2' }],
|
|
399
|
+
['path', { d: 'M19 6l-1 14H6L5 6' }],
|
|
400
|
+
['path', { d: 'M10 11v6' }],
|
|
401
|
+
['path', { d: 'M14 11v6' }],
|
|
402
|
+
],
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function IconCopy(props) {
|
|
407
|
+
return h(SvgIcon, {
|
|
408
|
+
size: props.size,
|
|
409
|
+
shapes: [
|
|
410
|
+
['rect', { x: 9, y: 9, width: 11, height: 11, rx: 2 }],
|
|
411
|
+
['path', { d: 'M5 15V5a2 2 0 0 1 2-2h10' }],
|
|
412
|
+
],
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function IconChevron(props) {
|
|
417
|
+
return h(SvgIcon, {
|
|
418
|
+
size: props.size,
|
|
419
|
+
className: props.className,
|
|
420
|
+
shapes: [['path', { d: 'm6 9 6 6 6-6' }]],
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function IconCloud(props) {
|
|
425
|
+
return h(SvgIcon, {
|
|
426
|
+
size: props.size,
|
|
427
|
+
shapes: [[
|
|
428
|
+
'path',
|
|
429
|
+
{ d: 'M17.5 19a4.5 4.5 0 1 0-1.4-8.8A6 6 0 0 0 4.6 12.7 3.5 3.5 0 0 0 6 19h11.5Z' },
|
|
430
|
+
]],
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function IconMonitor(props) {
|
|
435
|
+
return h(SvgIcon, {
|
|
436
|
+
size: props.size,
|
|
437
|
+
shapes: [
|
|
438
|
+
['rect', { x: 2.5, y: 3.5, width: 19, height: 13, rx: 2 }],
|
|
439
|
+
['path', { d: 'M8.5 20.5h7' }],
|
|
440
|
+
['path', { d: 'M12 16.5v4' }],
|
|
441
|
+
],
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// #endregion
|
|
446
|
+
|
|
447
|
+
// #region styles
|
|
448
|
+
|
|
449
|
+
/* Only theme aliases carry color: light and dark resolve through the same
|
|
450
|
+
* declarations, and every token falls back to its contract sibling rather
|
|
451
|
+
* than to a hard-coded page color. */
|
|
452
|
+
const CSS = String.raw`
|
|
453
|
+
.dshc-root {
|
|
454
|
+
box-sizing: border-box;
|
|
455
|
+
display: flex;
|
|
456
|
+
flex-direction: column;
|
|
457
|
+
min-width: 0;
|
|
458
|
+
padding: 4px 0 12px;
|
|
459
|
+
color: var(--dsw-alias-label-primary);
|
|
460
|
+
font-family: inherit;
|
|
461
|
+
font-size: 14px;
|
|
462
|
+
line-height: 22px;
|
|
463
|
+
}
|
|
464
|
+
.dshc-root *, .dshc-overlay * { box-sizing: border-box; }
|
|
465
|
+
.dshc-head {
|
|
466
|
+
display: flex;
|
|
467
|
+
align-items: flex-start;
|
|
468
|
+
justify-content: space-between;
|
|
469
|
+
gap: 16px;
|
|
470
|
+
padding: 2px 0 16px;
|
|
471
|
+
}
|
|
472
|
+
.dshc-headMain { min-width: 0; flex: 1 1 auto; }
|
|
473
|
+
.dshc-title {
|
|
474
|
+
margin: 0;
|
|
475
|
+
font-size: 20px;
|
|
476
|
+
line-height: 28px;
|
|
477
|
+
font-weight: 600;
|
|
478
|
+
letter-spacing: -0.01em;
|
|
479
|
+
color: var(--dsw-alias-label-primary);
|
|
480
|
+
}
|
|
481
|
+
.dshc-desc {
|
|
482
|
+
margin: 4px 0 0;
|
|
483
|
+
font-size: 13px;
|
|
484
|
+
line-height: 20px;
|
|
485
|
+
color: var(--dsw-alias-label-secondary);
|
|
486
|
+
}
|
|
487
|
+
.dshc-primary {
|
|
488
|
+
flex: none;
|
|
489
|
+
display: inline-flex;
|
|
490
|
+
align-items: center;
|
|
491
|
+
justify-content: center;
|
|
492
|
+
gap: 4px;
|
|
493
|
+
height: 34px;
|
|
494
|
+
margin-top: 1px;
|
|
495
|
+
padding: 0 14px 0 11px;
|
|
496
|
+
border: none;
|
|
497
|
+
border-radius: 999px;
|
|
498
|
+
background: var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary));
|
|
499
|
+
color: var(--dsw-alias-label-primary-foreground, var(--dsw-alias-brand-primary-invert, #fff));
|
|
500
|
+
font: inherit;
|
|
501
|
+
font-size: 13.5px;
|
|
502
|
+
line-height: 20px;
|
|
503
|
+
font-weight: 500;
|
|
504
|
+
white-space: nowrap;
|
|
505
|
+
cursor: pointer;
|
|
506
|
+
}
|
|
507
|
+
@media (hover: hover) {
|
|
508
|
+
.dshc-primary:hover:not(:disabled) {
|
|
509
|
+
background: var(--dsw-alias-button-primary-hover, var(--dsw-alias-brand-primary));
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
.dshc-primary:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
513
|
+
.dshc-primary:focus-visible {
|
|
514
|
+
outline: 2px solid var(--dsw-alias-brand-primary);
|
|
515
|
+
outline-offset: 2px;
|
|
516
|
+
}
|
|
517
|
+
.dshc-primary svg { flex: none; }
|
|
518
|
+
|
|
519
|
+
.dshc-notice {
|
|
520
|
+
display: inline-flex;
|
|
521
|
+
align-items: center;
|
|
522
|
+
gap: 6px;
|
|
523
|
+
align-self: flex-start;
|
|
524
|
+
max-width: 100%;
|
|
525
|
+
margin: 0 0 14px;
|
|
526
|
+
padding: 6px 11px;
|
|
527
|
+
border: 1px solid var(--dsw-alias-border-l1);
|
|
528
|
+
border-radius: 9px;
|
|
529
|
+
background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2));
|
|
530
|
+
color: var(--dsw-alias-label-secondary);
|
|
531
|
+
font-size: 12.5px;
|
|
532
|
+
line-height: 18px;
|
|
533
|
+
overflow-wrap: anywhere;
|
|
534
|
+
}
|
|
535
|
+
.dshc-notice svg { flex: none; }
|
|
536
|
+
.dshc-notice[data-kind="success"] svg { color: var(--dsw-alias-state-success-primary); }
|
|
537
|
+
.dshc-notice[data-kind="error"] { color: var(--dsw-alias-state-error-primary); }
|
|
538
|
+
|
|
539
|
+
.dshc-list {
|
|
540
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
541
|
+
border-radius: 16px;
|
|
542
|
+
background: var(--dsw-alias-bg-layer-1);
|
|
543
|
+
overflow: hidden;
|
|
544
|
+
}
|
|
545
|
+
.dshc-row {
|
|
546
|
+
display: flex;
|
|
547
|
+
align-items: center;
|
|
548
|
+
gap: 10px;
|
|
549
|
+
min-height: 56px;
|
|
550
|
+
padding: 8px 10px 8px 16px;
|
|
551
|
+
}
|
|
552
|
+
.dshc-row + .dshc-row { border-top: 1px solid var(--dsw-alias-border-l1); }
|
|
553
|
+
.dshc-rowMain {
|
|
554
|
+
display: flex;
|
|
555
|
+
align-items: center;
|
|
556
|
+
gap: 8px;
|
|
557
|
+
min-width: 0;
|
|
558
|
+
flex: 1 1 auto;
|
|
559
|
+
}
|
|
560
|
+
.dshc-name {
|
|
561
|
+
min-width: 0;
|
|
562
|
+
overflow: hidden;
|
|
563
|
+
text-overflow: ellipsis;
|
|
564
|
+
white-space: nowrap;
|
|
565
|
+
font-size: 15.5px;
|
|
566
|
+
line-height: 22px;
|
|
567
|
+
font-weight: 500;
|
|
568
|
+
letter-spacing: -0.005em;
|
|
569
|
+
color: var(--dsw-alias-label-primary);
|
|
570
|
+
}
|
|
571
|
+
.dshc-badge {
|
|
572
|
+
flex: none;
|
|
573
|
+
padding: 1px 7px;
|
|
574
|
+
border-radius: 6px;
|
|
575
|
+
background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2));
|
|
576
|
+
color: var(--dsw-alias-label-secondary);
|
|
577
|
+
font-size: 11.5px;
|
|
578
|
+
line-height: 16px;
|
|
579
|
+
font-weight: 500;
|
|
580
|
+
white-space: nowrap;
|
|
581
|
+
}
|
|
582
|
+
.dshc-status {
|
|
583
|
+
flex: none;
|
|
584
|
+
display: inline-flex;
|
|
585
|
+
align-items: center;
|
|
586
|
+
gap: 6px;
|
|
587
|
+
font-size: 12.5px;
|
|
588
|
+
line-height: 18px;
|
|
589
|
+
color: var(--dsw-alias-label-secondary);
|
|
590
|
+
white-space: nowrap;
|
|
591
|
+
}
|
|
592
|
+
.dshc-dot {
|
|
593
|
+
flex: none;
|
|
594
|
+
width: 8px;
|
|
595
|
+
height: 8px;
|
|
596
|
+
border-radius: 50%;
|
|
597
|
+
background: var(--dsw-alias-state-idle-primary);
|
|
598
|
+
}
|
|
599
|
+
.dshc-row[data-online="true"] .dshc-dot { background: var(--dsw-alias-state-success-primary); }
|
|
600
|
+
.dshc-rowActions { display: flex; align-items: center; gap: 2px; flex: none; }
|
|
601
|
+
.dshc-iconBtn {
|
|
602
|
+
display: inline-flex;
|
|
603
|
+
align-items: center;
|
|
604
|
+
justify-content: center;
|
|
605
|
+
width: 28px;
|
|
606
|
+
height: 28px;
|
|
607
|
+
padding: 0;
|
|
608
|
+
border: none;
|
|
609
|
+
border-radius: 8px;
|
|
610
|
+
background: transparent;
|
|
611
|
+
color: var(--dsw-alias-label-secondary);
|
|
612
|
+
cursor: pointer;
|
|
613
|
+
}
|
|
614
|
+
@media (hover: hover) {
|
|
615
|
+
.dshc-iconBtn:hover:not(:disabled) {
|
|
616
|
+
background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2));
|
|
617
|
+
color: var(--dsw-alias-label-primary);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
.dshc-iconBtn:active:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2)); }
|
|
621
|
+
.dshc-iconBtn:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
622
|
+
.dshc-iconBtn:focus-visible {
|
|
623
|
+
outline: 2px solid var(--dsw-alias-brand-primary);
|
|
624
|
+
outline-offset: 1px;
|
|
625
|
+
}
|
|
626
|
+
.dshc-trash:hover:not(:disabled) { color: var(--dsw-alias-state-error-primary); }
|
|
627
|
+
.dshc-trash:active:not(:disabled) { color: var(--dsw-alias-state-error-primary); }
|
|
628
|
+
|
|
629
|
+
.dshc-empty {
|
|
630
|
+
display: flex;
|
|
631
|
+
flex-direction: column;
|
|
632
|
+
align-items: center;
|
|
633
|
+
gap: 6px;
|
|
634
|
+
padding: 44px 24px 46px;
|
|
635
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
636
|
+
border-radius: 16px;
|
|
637
|
+
background: var(--dsw-alias-bg-layer-1);
|
|
638
|
+
text-align: center;
|
|
639
|
+
}
|
|
640
|
+
.dshc-emptyIcon {
|
|
641
|
+
display: flex;
|
|
642
|
+
align-items: center;
|
|
643
|
+
justify-content: center;
|
|
644
|
+
width: 40px;
|
|
645
|
+
height: 40px;
|
|
646
|
+
margin-bottom: 6px;
|
|
647
|
+
border-radius: 12px;
|
|
648
|
+
background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2));
|
|
649
|
+
color: var(--dsw-alias-label-secondary);
|
|
650
|
+
}
|
|
651
|
+
.dshc-emptyIcon svg { display: block; }
|
|
652
|
+
.dshc-emptyTitle {
|
|
653
|
+
font-size: 14px;
|
|
654
|
+
line-height: 22px;
|
|
655
|
+
font-weight: 500;
|
|
656
|
+
color: var(--dsw-alias-label-primary);
|
|
657
|
+
}
|
|
658
|
+
.dshc-emptyHint {
|
|
659
|
+
max-width: 380px;
|
|
660
|
+
margin: 0;
|
|
661
|
+
font-size: 12.5px;
|
|
662
|
+
line-height: 19px;
|
|
663
|
+
color: var(--dsw-alias-label-secondary);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
.dshc-skeleton {
|
|
667
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
668
|
+
border-radius: 16px;
|
|
669
|
+
background: var(--dsw-alias-bg-layer-1);
|
|
670
|
+
overflow: hidden;
|
|
671
|
+
}
|
|
672
|
+
.dshc-skeletonRow {
|
|
673
|
+
display: flex;
|
|
674
|
+
align-items: center;
|
|
675
|
+
gap: 12px;
|
|
676
|
+
min-height: 56px;
|
|
677
|
+
padding: 0 16px;
|
|
678
|
+
}
|
|
679
|
+
.dshc-skeletonRow + .dshc-skeletonRow { border-top: 1px solid var(--dsw-alias-border-l1); }
|
|
680
|
+
.dshc-skeletonBar {
|
|
681
|
+
height: 12px;
|
|
682
|
+
border-radius: 6px;
|
|
683
|
+
background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2));
|
|
684
|
+
animation: dshc-pulse 1.5s ease-in-out infinite;
|
|
685
|
+
}
|
|
686
|
+
.dshc-skeletonBar[data-wide="true"] { width: 34%; }
|
|
687
|
+
.dshc-skeletonBar[data-narrow="true"] { width: 18%; margin-left: auto; }
|
|
688
|
+
|
|
689
|
+
/* Runtime-mode card (PROTOCOL §10.6): this machine joining a hub. */
|
|
690
|
+
.dshc-groupTitle {
|
|
691
|
+
margin: 0 0 10px;
|
|
692
|
+
font-size: 13px;
|
|
693
|
+
line-height: 20px;
|
|
694
|
+
font-weight: 500;
|
|
695
|
+
color: var(--dsw-alias-label-secondary);
|
|
696
|
+
}
|
|
697
|
+
.dshc-card2 {
|
|
698
|
+
margin-bottom: 18px;
|
|
699
|
+
padding: 15px 16px 14px;
|
|
700
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
701
|
+
border-radius: 16px;
|
|
702
|
+
background: var(--dsw-alias-bg-layer-1);
|
|
703
|
+
}
|
|
704
|
+
.dshc-card2Head {
|
|
705
|
+
display: flex;
|
|
706
|
+
align-items: center;
|
|
707
|
+
justify-content: space-between;
|
|
708
|
+
gap: 12px;
|
|
709
|
+
}
|
|
710
|
+
.dshc-card2Title {
|
|
711
|
+
font-size: 12.5px;
|
|
712
|
+
line-height: 18px;
|
|
713
|
+
font-weight: 600;
|
|
714
|
+
letter-spacing: 0.01em;
|
|
715
|
+
color: var(--dsw-alias-label-secondary);
|
|
716
|
+
}
|
|
717
|
+
.dshc-runtimeName {
|
|
718
|
+
margin-top: 9px;
|
|
719
|
+
overflow: hidden;
|
|
720
|
+
text-overflow: ellipsis;
|
|
721
|
+
white-space: nowrap;
|
|
722
|
+
font-size: 15.5px;
|
|
723
|
+
line-height: 22px;
|
|
724
|
+
font-weight: 500;
|
|
725
|
+
color: var(--dsw-alias-label-primary);
|
|
726
|
+
}
|
|
727
|
+
.dshc-runtimeMeta {
|
|
728
|
+
display: flex;
|
|
729
|
+
gap: 6px;
|
|
730
|
+
min-width: 0;
|
|
731
|
+
margin-top: 2px;
|
|
732
|
+
font-size: 12.5px;
|
|
733
|
+
line-height: 18px;
|
|
734
|
+
color: var(--dsw-alias-label-secondary);
|
|
735
|
+
}
|
|
736
|
+
.dshc-runtimeMetaKey { flex: none; }
|
|
737
|
+
.dshc-runtimeMetaValue {
|
|
738
|
+
min-width: 0;
|
|
739
|
+
overflow-wrap: anywhere;
|
|
740
|
+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
741
|
+
font-size: 12px;
|
|
742
|
+
line-height: 18px;
|
|
743
|
+
}
|
|
744
|
+
.dshc-runtimeActions {
|
|
745
|
+
display: flex;
|
|
746
|
+
align-items: center;
|
|
747
|
+
justify-content: space-between;
|
|
748
|
+
gap: 12px;
|
|
749
|
+
margin-top: 13px;
|
|
750
|
+
padding-top: 12px;
|
|
751
|
+
border-top: 1px solid var(--dsw-alias-border-l1);
|
|
752
|
+
}
|
|
753
|
+
.dshc-switchLabel {
|
|
754
|
+
display: inline-flex;
|
|
755
|
+
align-items: center;
|
|
756
|
+
gap: 8px;
|
|
757
|
+
min-width: 0;
|
|
758
|
+
font-size: 12.5px;
|
|
759
|
+
line-height: 18px;
|
|
760
|
+
color: var(--dsw-alias-label-secondary);
|
|
761
|
+
}
|
|
762
|
+
.dshc-textBtn {
|
|
763
|
+
flex: none;
|
|
764
|
+
padding: 4px 9px;
|
|
765
|
+
border: none;
|
|
766
|
+
border-radius: 7px;
|
|
767
|
+
background: transparent;
|
|
768
|
+
color: var(--dsw-alias-label-secondary);
|
|
769
|
+
font: inherit;
|
|
770
|
+
font-size: 12.5px;
|
|
771
|
+
line-height: 18px;
|
|
772
|
+
cursor: pointer;
|
|
773
|
+
}
|
|
774
|
+
@media (hover: hover) {
|
|
775
|
+
.dshc-textBtn:hover:not(:disabled) {
|
|
776
|
+
background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2));
|
|
777
|
+
color: var(--dsw-alias-label-primary);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
.dshc-textBtn:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
781
|
+
.dshc-textBtn:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
|
|
782
|
+
.dshc-formRow {
|
|
783
|
+
display: flex;
|
|
784
|
+
flex-wrap: wrap;
|
|
785
|
+
align-items: flex-end;
|
|
786
|
+
gap: 10px;
|
|
787
|
+
margin-top: 12px;
|
|
788
|
+
}
|
|
789
|
+
.dshc-field {
|
|
790
|
+
display: flex;
|
|
791
|
+
flex: 1 1 175px;
|
|
792
|
+
flex-direction: column;
|
|
793
|
+
min-width: 0;
|
|
794
|
+
}
|
|
795
|
+
.dshc-field .dshc-fieldLabel { margin-bottom: 5px; }
|
|
796
|
+
.dshc-formSubmit {
|
|
797
|
+
flex: none;
|
|
798
|
+
height: 36px;
|
|
799
|
+
padding: 0 16px;
|
|
800
|
+
border-radius: 9px;
|
|
801
|
+
}
|
|
802
|
+
.dshc-dot[data-state="running"] { background: var(--dsw-alias-state-success-primary); }
|
|
803
|
+
.dshc-dot[data-state="connecting"] { background: var(--dsw-alias-state-warn-primary); }
|
|
804
|
+
.dshc-dot[data-state="failed"] { background: var(--dsw-alias-state-error-primary); }
|
|
805
|
+
|
|
806
|
+
/* Composer target-computer chip + menu (slot conversation.input.left). */
|
|
807
|
+
.dshc-targetWrap {
|
|
808
|
+
display: inline-flex;
|
|
809
|
+
align-items: center;
|
|
810
|
+
gap: 6px;
|
|
811
|
+
min-width: 0;
|
|
812
|
+
}
|
|
813
|
+
.dshc-target {
|
|
814
|
+
display: inline-flex;
|
|
815
|
+
align-items: center;
|
|
816
|
+
gap: 5px;
|
|
817
|
+
max-width: 200px;
|
|
818
|
+
height: 28px;
|
|
819
|
+
padding: 0 7px;
|
|
820
|
+
border: none;
|
|
821
|
+
border-radius: 8px;
|
|
822
|
+
background: transparent;
|
|
823
|
+
color: var(--dsw-alias-label-secondary);
|
|
824
|
+
font: inherit;
|
|
825
|
+
font-size: 12.5px;
|
|
826
|
+
line-height: 18px;
|
|
827
|
+
cursor: pointer;
|
|
828
|
+
}
|
|
829
|
+
.dshc-target svg { flex: none; }
|
|
830
|
+
@media (hover: hover) {
|
|
831
|
+
.dshc-target:hover {
|
|
832
|
+
background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2));
|
|
833
|
+
color: var(--dsw-alias-label-primary);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
.dshc-target[data-open="true"] {
|
|
837
|
+
background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2));
|
|
838
|
+
color: var(--dsw-alias-label-primary);
|
|
839
|
+
}
|
|
840
|
+
.dshc-target:focus-visible {
|
|
841
|
+
outline: 2px solid var(--dsw-alias-brand-primary);
|
|
842
|
+
outline-offset: 1px;
|
|
843
|
+
}
|
|
844
|
+
.dshc-targetLabel {
|
|
845
|
+
min-width: 0;
|
|
846
|
+
overflow: hidden;
|
|
847
|
+
text-overflow: ellipsis;
|
|
848
|
+
white-space: nowrap;
|
|
849
|
+
}
|
|
850
|
+
.dshc-targetChevron { transition: transform 120ms ease; opacity: 0.75; }
|
|
851
|
+
.dshc-target[data-open="true"] .dshc-targetChevron { transform: rotate(180deg); }
|
|
852
|
+
.dshc-targetError {
|
|
853
|
+
min-width: 0;
|
|
854
|
+
max-width: 190px;
|
|
855
|
+
overflow: hidden;
|
|
856
|
+
text-overflow: ellipsis;
|
|
857
|
+
white-space: nowrap;
|
|
858
|
+
color: var(--dsw-alias-state-error-primary);
|
|
859
|
+
font-size: 11.5px;
|
|
860
|
+
line-height: 16px;
|
|
861
|
+
}
|
|
862
|
+
.dshc-menu {
|
|
863
|
+
position: fixed;
|
|
864
|
+
z-index: 1200;
|
|
865
|
+
min-width: 240px;
|
|
866
|
+
max-width: min(280px, calc(100vw - 16px));
|
|
867
|
+
padding: 4px;
|
|
868
|
+
overflow-y: auto;
|
|
869
|
+
border: 0.5px solid var(--dsw-alias-border-l1);
|
|
870
|
+
border-radius: 11px;
|
|
871
|
+
background: var(--dsw-alias-bg-layer-3, var(--dsw-alias-bg-overlay, var(--dsw-alias-bg-layer-2)));
|
|
872
|
+
box-shadow: var(--dsw-elevation-prominent, 0 16px 40px rgba(0, 0, 0, 0.24));
|
|
873
|
+
color: var(--dsw-alias-label-primary);
|
|
874
|
+
}
|
|
875
|
+
.dshc-menuItem {
|
|
876
|
+
display: flex;
|
|
877
|
+
align-items: center;
|
|
878
|
+
gap: 7px;
|
|
879
|
+
width: 100%;
|
|
880
|
+
min-height: 30px;
|
|
881
|
+
padding: 4px 7px;
|
|
882
|
+
border: none;
|
|
883
|
+
border-radius: 7px;
|
|
884
|
+
background: transparent;
|
|
885
|
+
color: var(--dsw-alias-label-primary);
|
|
886
|
+
font: inherit;
|
|
887
|
+
font-size: 12.5px;
|
|
888
|
+
line-height: 18px;
|
|
889
|
+
text-align: left;
|
|
890
|
+
cursor: pointer;
|
|
891
|
+
}
|
|
892
|
+
.dshc-menuItem:disabled { cursor: not-allowed; opacity: 0.45; }
|
|
893
|
+
@media (hover: hover) {
|
|
894
|
+
.dshc-menuItem:hover:not(:disabled) {
|
|
895
|
+
background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2));
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
.dshc-menuItem:focus-visible {
|
|
899
|
+
background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2));
|
|
900
|
+
outline: none;
|
|
901
|
+
}
|
|
902
|
+
.dshc-menuItem svg { flex: none; }
|
|
903
|
+
.dshc-menuName {
|
|
904
|
+
min-width: 0;
|
|
905
|
+
flex: 1 1 auto;
|
|
906
|
+
overflow: hidden;
|
|
907
|
+
text-overflow: ellipsis;
|
|
908
|
+
white-space: nowrap;
|
|
909
|
+
}
|
|
910
|
+
.dshc-menuItem[data-offline="true"] .dshc-menuName { color: var(--dsw-alias-label-secondary); }
|
|
911
|
+
.dshc-menuHint {
|
|
912
|
+
flex: none;
|
|
913
|
+
color: var(--dsw-alias-label-secondary);
|
|
914
|
+
font-size: 11.5px;
|
|
915
|
+
line-height: 16px;
|
|
916
|
+
}
|
|
917
|
+
.dshc-menuDot {
|
|
918
|
+
flex: none;
|
|
919
|
+
width: 6px;
|
|
920
|
+
height: 6px;
|
|
921
|
+
border-radius: 50%;
|
|
922
|
+
background: var(--dsw-alias-state-success-primary);
|
|
923
|
+
}
|
|
924
|
+
.dshc-menuDot[data-offline="true"] { background: var(--dsw-alias-state-idle-primary); }
|
|
925
|
+
.dshc-menuCheck { flex: none; color: var(--dsw-alias-brand-primary); }
|
|
926
|
+
.dshc-menuFooter {
|
|
927
|
+
margin: 4px 2px 0;
|
|
928
|
+
padding: 6px 7px 2px;
|
|
929
|
+
border-top: 1px solid var(--dsw-alias-border-l1);
|
|
930
|
+
color: var(--dsw-alias-label-secondary);
|
|
931
|
+
font-size: 11.5px;
|
|
932
|
+
line-height: 16px;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
.dshc-errorCard {
|
|
936
|
+
display: flex;
|
|
937
|
+
align-items: center;
|
|
938
|
+
justify-content: space-between;
|
|
939
|
+
gap: 12px;
|
|
940
|
+
padding: 14px 16px;
|
|
941
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
942
|
+
border-radius: 12px;
|
|
943
|
+
background: var(--dsw-alias-bg-layer-1);
|
|
944
|
+
}
|
|
945
|
+
.dshc-errorText {
|
|
946
|
+
min-width: 0;
|
|
947
|
+
font-size: 13px;
|
|
948
|
+
line-height: 20px;
|
|
949
|
+
color: var(--dsw-alias-state-error-primary);
|
|
950
|
+
overflow-wrap: anywhere;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
.dshc-ghost {
|
|
954
|
+
display: inline-flex;
|
|
955
|
+
align-items: center;
|
|
956
|
+
justify-content: center;
|
|
957
|
+
flex: none;
|
|
958
|
+
height: 32px;
|
|
959
|
+
padding: 0 14px;
|
|
960
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
961
|
+
border-radius: 9px;
|
|
962
|
+
background: transparent;
|
|
963
|
+
color: var(--dsw-alias-label-primary);
|
|
964
|
+
font: inherit;
|
|
965
|
+
font-size: 13px;
|
|
966
|
+
line-height: 20px;
|
|
967
|
+
cursor: pointer;
|
|
968
|
+
}
|
|
969
|
+
@media (hover: hover) {
|
|
970
|
+
.dshc-ghost:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2)); }
|
|
971
|
+
}
|
|
972
|
+
.dshc-ghost:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
973
|
+
.dshc-ghost:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; }
|
|
974
|
+
|
|
975
|
+
.dshc-overlay {
|
|
976
|
+
position: fixed;
|
|
977
|
+
inset: 0;
|
|
978
|
+
z-index: 1200;
|
|
979
|
+
display: flex;
|
|
980
|
+
align-items: center;
|
|
981
|
+
justify-content: center;
|
|
982
|
+
padding: 24px;
|
|
983
|
+
}
|
|
984
|
+
.dshc-mask {
|
|
985
|
+
position: absolute;
|
|
986
|
+
inset: 0;
|
|
987
|
+
background: var(--dsw-alias-bg-mask-1, rgba(0, 0, 0, 0.35));
|
|
988
|
+
}
|
|
989
|
+
.dshc-card {
|
|
990
|
+
position: relative;
|
|
991
|
+
width: 100%;
|
|
992
|
+
max-width: 560px;
|
|
993
|
+
max-height: calc(100vh - 48px);
|
|
994
|
+
overflow-y: auto;
|
|
995
|
+
padding: 24px;
|
|
996
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
997
|
+
border-radius: 16px;
|
|
998
|
+
background: var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1));
|
|
999
|
+
box-shadow: var(--dsw-elevation-prominent, 0 24px 64px rgba(0, 0, 0, 0.28));
|
|
1000
|
+
outline: none;
|
|
1001
|
+
}
|
|
1002
|
+
.dshc-card[data-size="sm"] { max-width: 420px; }
|
|
1003
|
+
.dshc-cardHead {
|
|
1004
|
+
display: flex;
|
|
1005
|
+
align-items: flex-start;
|
|
1006
|
+
justify-content: space-between;
|
|
1007
|
+
gap: 12px;
|
|
1008
|
+
margin-bottom: 6px;
|
|
1009
|
+
}
|
|
1010
|
+
.dshc-cardTitle {
|
|
1011
|
+
margin: 0;
|
|
1012
|
+
font-size: 17px;
|
|
1013
|
+
line-height: 24px;
|
|
1014
|
+
font-weight: 600;
|
|
1015
|
+
letter-spacing: -0.01em;
|
|
1016
|
+
color: var(--dsw-alias-label-primary);
|
|
1017
|
+
}
|
|
1018
|
+
.dshc-cardSub {
|
|
1019
|
+
margin: 2px 0 18px;
|
|
1020
|
+
font-size: 13px;
|
|
1021
|
+
line-height: 20px;
|
|
1022
|
+
color: var(--dsw-alias-label-secondary);
|
|
1023
|
+
}
|
|
1024
|
+
.dshc-dialogBody {
|
|
1025
|
+
margin: 0;
|
|
1026
|
+
font-size: 13px;
|
|
1027
|
+
line-height: 20px;
|
|
1028
|
+
color: var(--dsw-alias-label-secondary);
|
|
1029
|
+
overflow-wrap: anywhere;
|
|
1030
|
+
}
|
|
1031
|
+
.dshc-closeBtn { margin: -4px -6px 0 0; }
|
|
1032
|
+
.dshc-footer {
|
|
1033
|
+
display: flex;
|
|
1034
|
+
justify-content: flex-end;
|
|
1035
|
+
align-items: center;
|
|
1036
|
+
gap: 8px;
|
|
1037
|
+
margin-top: 20px;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
.dshc-seg {
|
|
1041
|
+
display: flex;
|
|
1042
|
+
gap: 2px;
|
|
1043
|
+
padding: 3px;
|
|
1044
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
1045
|
+
border-radius: 11px;
|
|
1046
|
+
background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-1));
|
|
1047
|
+
}
|
|
1048
|
+
.dshc-segBtn {
|
|
1049
|
+
flex: 1 1 0;
|
|
1050
|
+
min-width: 0;
|
|
1051
|
+
height: 30px;
|
|
1052
|
+
padding: 0 8px;
|
|
1053
|
+
border: none;
|
|
1054
|
+
border-radius: 8px;
|
|
1055
|
+
background: transparent;
|
|
1056
|
+
color: var(--dsw-alias-label-secondary);
|
|
1057
|
+
font: inherit;
|
|
1058
|
+
font-size: 12.5px;
|
|
1059
|
+
line-height: 18px;
|
|
1060
|
+
font-weight: 500;
|
|
1061
|
+
white-space: nowrap;
|
|
1062
|
+
overflow: hidden;
|
|
1063
|
+
text-overflow: ellipsis;
|
|
1064
|
+
cursor: pointer;
|
|
1065
|
+
}
|
|
1066
|
+
.dshc-segBtn[data-active="true"] {
|
|
1067
|
+
background: var(--dsw-alias-bg-layer-3, var(--dsw-alias-bg-layer-1));
|
|
1068
|
+
color: var(--dsw-alias-label-primary);
|
|
1069
|
+
box-shadow:
|
|
1070
|
+
0 1px 2px rgba(0, 0, 0, 0.1),
|
|
1071
|
+
inset 0 0 0 0.5px var(--dsw-alias-border-l3, var(--dsw-alias-border-l2));
|
|
1072
|
+
}
|
|
1073
|
+
@media (hover: hover) {
|
|
1074
|
+
.dshc-segBtn[data-active="false"]:hover { color: var(--dsw-alias-label-primary); }
|
|
1075
|
+
}
|
|
1076
|
+
.dshc-segBtn:focus-visible {
|
|
1077
|
+
outline: 2px solid var(--dsw-alias-brand-primary);
|
|
1078
|
+
outline-offset: -1px;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
.dshc-switchRow {
|
|
1082
|
+
display: flex;
|
|
1083
|
+
align-items: flex-start;
|
|
1084
|
+
justify-content: space-between;
|
|
1085
|
+
gap: 16px;
|
|
1086
|
+
margin: 14px 0;
|
|
1087
|
+
padding: 11px 14px;
|
|
1088
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
1089
|
+
border-radius: 12px;
|
|
1090
|
+
}
|
|
1091
|
+
.dshc-switchText { min-width: 0; }
|
|
1092
|
+
.dshc-switchTitle {
|
|
1093
|
+
font-size: 13px;
|
|
1094
|
+
line-height: 20px;
|
|
1095
|
+
font-weight: 500;
|
|
1096
|
+
color: var(--dsw-alias-label-primary);
|
|
1097
|
+
}
|
|
1098
|
+
.dshc-switchHint {
|
|
1099
|
+
margin-top: 2px;
|
|
1100
|
+
font-size: 12px;
|
|
1101
|
+
line-height: 18px;
|
|
1102
|
+
color: var(--dsw-alias-label-secondary);
|
|
1103
|
+
}
|
|
1104
|
+
.dshc-switch {
|
|
1105
|
+
position: relative;
|
|
1106
|
+
flex: none;
|
|
1107
|
+
width: 36px;
|
|
1108
|
+
height: 20px;
|
|
1109
|
+
margin-top: 1px;
|
|
1110
|
+
padding: 2px;
|
|
1111
|
+
border: none;
|
|
1112
|
+
border-radius: 999px;
|
|
1113
|
+
background: var(--dsw-alias-border-l2);
|
|
1114
|
+
cursor: pointer;
|
|
1115
|
+
}
|
|
1116
|
+
.dshc-switch[aria-checked="true"] { background: var(--dsw-alias-brand-primary); }
|
|
1117
|
+
.dshc-switch:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; }
|
|
1118
|
+
.dshc-switchThumb {
|
|
1119
|
+
display: block;
|
|
1120
|
+
width: 16px;
|
|
1121
|
+
height: 16px;
|
|
1122
|
+
border-radius: 50%;
|
|
1123
|
+
background: var(--dsw-alias-switch-thumb, var(--dsw-alias-bg-layer-1));
|
|
1124
|
+
transition: transform 120ms ease;
|
|
1125
|
+
}
|
|
1126
|
+
.dshc-switch[aria-checked="true"] .dshc-switchThumb {
|
|
1127
|
+
transform: translateX(16px);
|
|
1128
|
+
background: var(--dsw-alias-label-primary-foreground, #fff);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
.dshc-cmd {
|
|
1132
|
+
position: relative;
|
|
1133
|
+
padding: 12px 44px 12px 14px;
|
|
1134
|
+
border-radius: 10px;
|
|
1135
|
+
background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-1));
|
|
1136
|
+
}
|
|
1137
|
+
.dshc-cmdLabel {
|
|
1138
|
+
display: block;
|
|
1139
|
+
margin-bottom: 5px;
|
|
1140
|
+
color: var(--dsw-alias-label-secondary);
|
|
1141
|
+
font-size: 11px;
|
|
1142
|
+
line-height: 16px;
|
|
1143
|
+
font-weight: 500;
|
|
1144
|
+
letter-spacing: 0.02em;
|
|
1145
|
+
text-transform: uppercase;
|
|
1146
|
+
}
|
|
1147
|
+
.dshc-cmdText {
|
|
1148
|
+
display: block;
|
|
1149
|
+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
1150
|
+
font-size: 12.5px;
|
|
1151
|
+
line-height: 19px;
|
|
1152
|
+
color: var(--dsw-alias-label-primary);
|
|
1153
|
+
white-space: pre-wrap;
|
|
1154
|
+
overflow-wrap: anywhere;
|
|
1155
|
+
word-break: break-word;
|
|
1156
|
+
}
|
|
1157
|
+
.dshc-cmdText[data-placeholder="true"] { color: var(--dsw-alias-label-secondary); }
|
|
1158
|
+
.dshc-cmdCopy {
|
|
1159
|
+
position: absolute;
|
|
1160
|
+
top: 7px;
|
|
1161
|
+
right: 7px;
|
|
1162
|
+
display: inline-flex;
|
|
1163
|
+
align-items: center;
|
|
1164
|
+
justify-content: center;
|
|
1165
|
+
width: 28px;
|
|
1166
|
+
height: 28px;
|
|
1167
|
+
padding: 0;
|
|
1168
|
+
border: none;
|
|
1169
|
+
border-radius: 8px;
|
|
1170
|
+
background: transparent;
|
|
1171
|
+
color: var(--dsw-alias-label-secondary);
|
|
1172
|
+
cursor: pointer;
|
|
1173
|
+
}
|
|
1174
|
+
@media (hover: hover) {
|
|
1175
|
+
.dshc-cmdCopy:hover:not(:disabled) {
|
|
1176
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
1177
|
+
color: var(--dsw-alias-label-primary);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
.dshc-cmdCopy:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
1181
|
+
.dshc-cmdCopy:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
|
|
1182
|
+
.dshc-cmdCopy[data-copied="true"] { color: var(--dsw-alias-state-success-primary); }
|
|
1183
|
+
|
|
1184
|
+
.dshc-strip {
|
|
1185
|
+
display: flex;
|
|
1186
|
+
align-items: center;
|
|
1187
|
+
gap: 8px;
|
|
1188
|
+
margin-top: 14px;
|
|
1189
|
+
padding: 9px 12px;
|
|
1190
|
+
border-radius: 10px;
|
|
1191
|
+
background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-1));
|
|
1192
|
+
color: var(--dsw-alias-label-secondary);
|
|
1193
|
+
font-size: 12.5px;
|
|
1194
|
+
line-height: 18px;
|
|
1195
|
+
}
|
|
1196
|
+
.dshc-strip svg { flex: none; }
|
|
1197
|
+
.dshc-stripText { min-width: 0; overflow-wrap: anywhere; }
|
|
1198
|
+
.dshc-strip[data-state="connected"] { color: var(--dsw-alias-state-success-primary); }
|
|
1199
|
+
.dshc-strip[data-state="expired"] { color: var(--dsw-alias-state-warn-primary); }
|
|
1200
|
+
.dshc-strip[data-state="used"] { color: var(--dsw-alias-state-warn-primary); }
|
|
1201
|
+
.dshc-strip[data-state="error"] { color: var(--dsw-alias-state-error-primary); }
|
|
1202
|
+
.dshc-stripAction {
|
|
1203
|
+
margin-left: auto;
|
|
1204
|
+
flex: none;
|
|
1205
|
+
padding: 3px 9px;
|
|
1206
|
+
border: 1px solid currentColor;
|
|
1207
|
+
border-radius: 7px;
|
|
1208
|
+
background: transparent;
|
|
1209
|
+
color: inherit;
|
|
1210
|
+
font: inherit;
|
|
1211
|
+
font-size: 12px;
|
|
1212
|
+
line-height: 16px;
|
|
1213
|
+
font-weight: 500;
|
|
1214
|
+
cursor: pointer;
|
|
1215
|
+
}
|
|
1216
|
+
.dshc-stripAction:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; }
|
|
1217
|
+
|
|
1218
|
+
.dshc-spinner {
|
|
1219
|
+
flex: none;
|
|
1220
|
+
width: 13px;
|
|
1221
|
+
height: 13px;
|
|
1222
|
+
border: 2px solid var(--dsw-alias-border-l2);
|
|
1223
|
+
border-top-color: var(--dsw-alias-brand-primary);
|
|
1224
|
+
border-radius: 50%;
|
|
1225
|
+
animation: dshc-spin 0.72s linear infinite;
|
|
1226
|
+
}
|
|
1227
|
+
.dshc-fieldLabel {
|
|
1228
|
+
display: block;
|
|
1229
|
+
margin-bottom: 6px;
|
|
1230
|
+
font-size: 12.5px;
|
|
1231
|
+
line-height: 18px;
|
|
1232
|
+
font-weight: 500;
|
|
1233
|
+
color: var(--dsw-alias-label-primary);
|
|
1234
|
+
}
|
|
1235
|
+
.dshc-input {
|
|
1236
|
+
display: block;
|
|
1237
|
+
width: 100%;
|
|
1238
|
+
height: 36px;
|
|
1239
|
+
padding: 0 11px;
|
|
1240
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
1241
|
+
border-radius: 9px;
|
|
1242
|
+
background: var(--dsw-alias-bg-layer-1);
|
|
1243
|
+
color: var(--dsw-alias-label-primary);
|
|
1244
|
+
font: inherit;
|
|
1245
|
+
font-size: 13.5px;
|
|
1246
|
+
line-height: 22px;
|
|
1247
|
+
}
|
|
1248
|
+
.dshc-input:focus-visible {
|
|
1249
|
+
outline: 2px solid var(--dsw-alias-brand-primary);
|
|
1250
|
+
outline-offset: -1px;
|
|
1251
|
+
border-color: transparent;
|
|
1252
|
+
}
|
|
1253
|
+
.dshc-hint {
|
|
1254
|
+
margin: 6px 0 0;
|
|
1255
|
+
font-size: 12px;
|
|
1256
|
+
line-height: 18px;
|
|
1257
|
+
color: var(--dsw-alias-label-secondary);
|
|
1258
|
+
}
|
|
1259
|
+
.dshc-formError {
|
|
1260
|
+
margin: 10px 0 0;
|
|
1261
|
+
font-size: 12.5px;
|
|
1262
|
+
line-height: 18px;
|
|
1263
|
+
color: var(--dsw-alias-state-error-primary);
|
|
1264
|
+
overflow-wrap: anywhere;
|
|
1265
|
+
}
|
|
1266
|
+
/* Hub modal: the desktop-path block (address + key for the local runtime card). */
|
|
1267
|
+
.dshc-details { margin-top: 12px; }
|
|
1268
|
+
.dshc-detailsSummary {
|
|
1269
|
+
display: flex;
|
|
1270
|
+
align-items: center;
|
|
1271
|
+
gap: 4px;
|
|
1272
|
+
padding: 3px 0;
|
|
1273
|
+
color: var(--dsw-alias-label-secondary);
|
|
1274
|
+
font-size: 12.5px;
|
|
1275
|
+
line-height: 18px;
|
|
1276
|
+
cursor: pointer;
|
|
1277
|
+
list-style: none;
|
|
1278
|
+
}
|
|
1279
|
+
.dshc-detailsLabel {
|
|
1280
|
+
min-width: 0;
|
|
1281
|
+
overflow: hidden;
|
|
1282
|
+
text-overflow: ellipsis;
|
|
1283
|
+
white-space: nowrap;
|
|
1284
|
+
}
|
|
1285
|
+
@media (hover: hover) {
|
|
1286
|
+
.dshc-detailsSummary:hover { color: var(--dsw-alias-label-primary); }
|
|
1287
|
+
}
|
|
1288
|
+
.dshc-detailsSummary::-webkit-details-marker { display: none; }
|
|
1289
|
+
.dshc-detailsSummary::marker { content: ''; }
|
|
1290
|
+
.dshc-detailsSummary::after {
|
|
1291
|
+
content: '\203A';
|
|
1292
|
+
display: inline-block;
|
|
1293
|
+
margin-left: 4px;
|
|
1294
|
+
transition: transform 120ms ease;
|
|
1295
|
+
}
|
|
1296
|
+
.dshc-details[open] > .dshc-detailsSummary::after { transform: rotate(90deg); }
|
|
1297
|
+
.dshc-detailsSummary:focus-visible {
|
|
1298
|
+
outline: 2px solid var(--dsw-alias-brand-primary);
|
|
1299
|
+
outline-offset: 2px;
|
|
1300
|
+
border-radius: 6px;
|
|
1301
|
+
}
|
|
1302
|
+
.dshc-detailsBody {
|
|
1303
|
+
display: flex;
|
|
1304
|
+
flex-direction: column;
|
|
1305
|
+
gap: 6px;
|
|
1306
|
+
margin-top: 8px;
|
|
1307
|
+
}
|
|
1308
|
+
.dshc-cliBody {
|
|
1309
|
+
display: flex;
|
|
1310
|
+
flex-direction: column;
|
|
1311
|
+
gap: 10px;
|
|
1312
|
+
margin-top: 10px;
|
|
1313
|
+
}
|
|
1314
|
+
.dshc-cliBody .dshc-switchRow { margin: 0; }
|
|
1315
|
+
.dshc-copyStack {
|
|
1316
|
+
display: flex;
|
|
1317
|
+
flex-direction: column;
|
|
1318
|
+
gap: 6px;
|
|
1319
|
+
margin-top: 2px;
|
|
1320
|
+
}
|
|
1321
|
+
.dshc-copyRow {
|
|
1322
|
+
display: flex;
|
|
1323
|
+
flex-direction: column;
|
|
1324
|
+
gap: 2px;
|
|
1325
|
+
min-width: 0;
|
|
1326
|
+
padding: 8px 6px 8px 10px;
|
|
1327
|
+
border: 1px solid var(--dsw-alias-border-l1);
|
|
1328
|
+
border-radius: 10px;
|
|
1329
|
+
background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-1));
|
|
1330
|
+
}
|
|
1331
|
+
.dshc-copyRowTop {
|
|
1332
|
+
display: flex;
|
|
1333
|
+
align-items: center;
|
|
1334
|
+
gap: 8px;
|
|
1335
|
+
min-width: 0;
|
|
1336
|
+
}
|
|
1337
|
+
.dshc-copyRowKey {
|
|
1338
|
+
flex: 0 0 auto;
|
|
1339
|
+
min-width: 56px;
|
|
1340
|
+
color: var(--dsw-alias-label-secondary);
|
|
1341
|
+
font-size: 11.5px;
|
|
1342
|
+
line-height: 16px;
|
|
1343
|
+
}
|
|
1344
|
+
.dshc-copyRowHint {
|
|
1345
|
+
margin: 0;
|
|
1346
|
+
padding-left: 64px;
|
|
1347
|
+
color: var(--dsw-alias-label-secondary);
|
|
1348
|
+
font-size: 11.5px;
|
|
1349
|
+
line-height: 16px;
|
|
1350
|
+
overflow-wrap: anywhere;
|
|
1351
|
+
}
|
|
1352
|
+
.dshc-copyRowValue {
|
|
1353
|
+
flex: 1 1 auto;
|
|
1354
|
+
min-width: 0;
|
|
1355
|
+
overflow-wrap: anywhere;
|
|
1356
|
+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
1357
|
+
font-size: 12px;
|
|
1358
|
+
line-height: 17px;
|
|
1359
|
+
color: var(--dsw-alias-label-primary);
|
|
1360
|
+
}
|
|
1361
|
+
.dshc-copyRowBtn {
|
|
1362
|
+
flex: none;
|
|
1363
|
+
display: inline-flex;
|
|
1364
|
+
align-items: center;
|
|
1365
|
+
justify-content: center;
|
|
1366
|
+
width: 24px;
|
|
1367
|
+
height: 24px;
|
|
1368
|
+
padding: 0;
|
|
1369
|
+
border: none;
|
|
1370
|
+
border-radius: 6px;
|
|
1371
|
+
background: transparent;
|
|
1372
|
+
color: var(--dsw-alias-label-secondary);
|
|
1373
|
+
cursor: pointer;
|
|
1374
|
+
}
|
|
1375
|
+
@media (hover: hover) {
|
|
1376
|
+
.dshc-copyRowBtn:hover:not(:disabled) {
|
|
1377
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
1378
|
+
color: var(--dsw-alias-label-primary);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
.dshc-copyRowBtn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
1382
|
+
.dshc-copyRowBtn:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
|
|
1383
|
+
.dshc-copyRowBtn[data-copied="true"] { color: var(--dsw-alias-state-success-primary); }
|
|
1384
|
+
|
|
1385
|
+
.dshc-sr {
|
|
1386
|
+
position: absolute;
|
|
1387
|
+
width: 1px;
|
|
1388
|
+
height: 1px;
|
|
1389
|
+
margin: -1px;
|
|
1390
|
+
padding: 0;
|
|
1391
|
+
overflow: hidden;
|
|
1392
|
+
clip: rect(0 0 0 0);
|
|
1393
|
+
white-space: nowrap;
|
|
1394
|
+
border: 0;
|
|
1395
|
+
}
|
|
1396
|
+
@keyframes dshc-spin { to { transform: rotate(360deg); } }
|
|
1397
|
+
@keyframes dshc-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }
|
|
1398
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1399
|
+
.dshc-spinner { animation-duration: 2.4s; }
|
|
1400
|
+
.dshc-skeletonBar { animation: none; }
|
|
1401
|
+
.dshc-switchThumb { transition: none; }
|
|
1402
|
+
}
|
|
1403
|
+
@media (max-width: 480px) {
|
|
1404
|
+
.dshc-badge { display: none; }
|
|
1405
|
+
.dshc-overlay { padding: 16px; }
|
|
1406
|
+
.dshc-card { padding: 20px 16px; border-radius: 14px; }
|
|
1407
|
+
.dshc-row { padding-left: 13px; }
|
|
1408
|
+
.dshc-formSubmit { flex: 1 1 100%; }
|
|
1409
|
+
}
|
|
1410
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1411
|
+
.dshc-detailsSummary::after { transition: none; }
|
|
1412
|
+
}
|
|
1413
|
+
`;
|
|
1414
|
+
|
|
1415
|
+
let styleRefs = 0;
|
|
1416
|
+
let styleElement = null;
|
|
1417
|
+
|
|
1418
|
+
/** Install the bundle stylesheet once; the last unmount removes it. */
|
|
1419
|
+
function acquireStyles() {
|
|
1420
|
+
if (typeof document === 'undefined' || !document.head) return () => {};
|
|
1421
|
+
if (styleElement === null) {
|
|
1422
|
+
const existing = typeof document.getElementById === 'function' ? document.getElementById(STYLE_ID) : null;
|
|
1423
|
+
if (existing) {
|
|
1424
|
+
styleElement = existing;
|
|
1425
|
+
} else {
|
|
1426
|
+
const tag = document.createElement('style');
|
|
1427
|
+
tag.id = STYLE_ID;
|
|
1428
|
+
tag.setAttribute('data-plugin', 'dsh-computers');
|
|
1429
|
+
tag.textContent = CSS;
|
|
1430
|
+
document.head.appendChild(tag);
|
|
1431
|
+
styleElement = tag;
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
styleRefs += 1;
|
|
1435
|
+
let released = false;
|
|
1436
|
+
return () => {
|
|
1437
|
+
if (released) return;
|
|
1438
|
+
released = true;
|
|
1439
|
+
styleRefs = Math.max(0, styleRefs - 1);
|
|
1440
|
+
if (styleRefs > 0 || styleElement === null) return;
|
|
1441
|
+
const node = styleElement;
|
|
1442
|
+
styleElement = null;
|
|
1443
|
+
try {
|
|
1444
|
+
if (node.parentNode) node.parentNode.removeChild(node);
|
|
1445
|
+
} catch (error) {
|
|
1446
|
+
/* A detached document is not an error worth surfacing. */
|
|
1447
|
+
}
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// #endregion
|
|
1452
|
+
|
|
1453
|
+
// #region http
|
|
1454
|
+
|
|
1455
|
+
/**
|
|
1456
|
+
* One same-origin JSON request. Failures carry the host's message.
|
|
1457
|
+
* @param {string} url - document-relative route.
|
|
1458
|
+
* @param {object} [options] - `method`, `body` (JSON), `signal`.
|
|
1459
|
+
* @returns {Promise<object|null>} parsed payload.
|
|
1460
|
+
*/
|
|
1461
|
+
async function requestJson(url, options) {
|
|
1462
|
+
const init = {
|
|
1463
|
+
method: 'GET',
|
|
1464
|
+
credentials: 'same-origin',
|
|
1465
|
+
cache: 'no-store',
|
|
1466
|
+
headers: { accept: 'application/json' },
|
|
1467
|
+
};
|
|
1468
|
+
if (options !== undefined && options !== null) {
|
|
1469
|
+
if (options.method) init.method = options.method;
|
|
1470
|
+
if (options.signal) init.signal = options.signal;
|
|
1471
|
+
if (options.body !== undefined) {
|
|
1472
|
+
init.headers['content-type'] = 'application/json; charset=utf-8';
|
|
1473
|
+
init.body = JSON.stringify(options.body);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
let response;
|
|
1477
|
+
try {
|
|
1478
|
+
response = await fetch(url, init);
|
|
1479
|
+
} catch (error) {
|
|
1480
|
+
if (error && error.name === 'AbortError') throw error;
|
|
1481
|
+
throw new Error(t('error.network'));
|
|
1482
|
+
}
|
|
1483
|
+
let raw = '';
|
|
1484
|
+
try {
|
|
1485
|
+
raw = await response.text();
|
|
1486
|
+
} catch (error) {
|
|
1487
|
+
raw = '';
|
|
1488
|
+
}
|
|
1489
|
+
let payload = null;
|
|
1490
|
+
if (raw) {
|
|
1491
|
+
try {
|
|
1492
|
+
payload = JSON.parse(raw);
|
|
1493
|
+
} catch (error) {
|
|
1494
|
+
payload = null;
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
if (!response.ok) {
|
|
1498
|
+
const code = payload && typeof payload.error === 'string' ? payload.error : null;
|
|
1499
|
+
const message = payload && typeof payload.message === 'string' && payload.message
|
|
1500
|
+
? payload.message
|
|
1501
|
+
: (code || t('error.http', { status: response.status }));
|
|
1502
|
+
const failure = new Error(message);
|
|
1503
|
+
failure.status = response.status;
|
|
1504
|
+
failure.code = code;
|
|
1505
|
+
throw failure;
|
|
1506
|
+
}
|
|
1507
|
+
if (payload === null && raw) throw new Error(t('error.badResponse'));
|
|
1508
|
+
return payload;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
const api = {
|
|
1512
|
+
readState: (signal) => requestJson(API.state, { signal }),
|
|
1513
|
+
createKey: (os, replace, signal) => requestJson(API.keys, {
|
|
1514
|
+
method: 'POST',
|
|
1515
|
+
body: { os, replace, label: null },
|
|
1516
|
+
signal,
|
|
1517
|
+
}),
|
|
1518
|
+
readKeyStatus: (key, signal) => requestJson(`${API.keyStatus}?key=${encodeURIComponent(key)}`, { signal }),
|
|
1519
|
+
rename: (id, name, signal) => requestJson(API.rename, { method: 'POST', body: { id, name }, signal }),
|
|
1520
|
+
remove: (id, signal) => requestJson(API.remove, { method: 'POST', body: { id }, signal }),
|
|
1521
|
+
readRuntime: (signal) => requestJson(API.runtime, { signal }),
|
|
1522
|
+
runtimeConnect: (serverUrl, key, signal) => requestJson(API.runtimeConnect, {
|
|
1523
|
+
method: 'POST',
|
|
1524
|
+
body: { serverUrl, key },
|
|
1525
|
+
signal,
|
|
1526
|
+
}),
|
|
1527
|
+
runtimeEnable: (enabled, signal) => requestJson(API.runtimeEnable, {
|
|
1528
|
+
method: 'POST',
|
|
1529
|
+
body: { enabled },
|
|
1530
|
+
signal,
|
|
1531
|
+
}),
|
|
1532
|
+
runtimeDisconnect: (signal) => requestJson(API.runtimeDisconnect, { method: 'POST', body: {}, signal }),
|
|
1533
|
+
readSelection: (sessionId, signal) => requestJson(
|
|
1534
|
+
`${API.selection}?sessionId=${encodeURIComponent(sessionId)}`,
|
|
1535
|
+
{ signal },
|
|
1536
|
+
),
|
|
1537
|
+
selectTarget: (sessionId, computerId, signal) => requestJson(API.selection, {
|
|
1538
|
+
method: 'POST',
|
|
1539
|
+
body: { sessionId, computerId },
|
|
1540
|
+
signal,
|
|
1541
|
+
}),
|
|
1542
|
+
};
|
|
1543
|
+
|
|
1544
|
+
/** @param {object|null} data - a /state payload. @returns {string} 'hub' | 'runtime' | 'both'. */
|
|
1545
|
+
function roleOf(data) {
|
|
1546
|
+
if (data && typeof data.role === 'string' && data.role !== '') return data.role;
|
|
1547
|
+
/* A host that predates §10.1 registers no runtime routes: assume hub. */
|
|
1548
|
+
return 'hub';
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
/** @param {unknown} error - thrown value. @returns {string} user-facing detail. */
|
|
1552
|
+
function messageOf(error) {
|
|
1553
|
+
if (error && typeof error.message === 'string' && error.message) return error.message;
|
|
1554
|
+
return t('error.generic');
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
/**
|
|
1558
|
+
* Track in-flight requests so unmount can abort them.
|
|
1559
|
+
* @returns {{run: Function, abortAll: Function}} request scope.
|
|
1560
|
+
*/
|
|
1561
|
+
function createScope() {
|
|
1562
|
+
const controllers = new Set();
|
|
1563
|
+
return {
|
|
1564
|
+
run(task) {
|
|
1565
|
+
let controller = null;
|
|
1566
|
+
try {
|
|
1567
|
+
if (typeof AbortController === 'function') controller = new AbortController();
|
|
1568
|
+
} catch (error) {
|
|
1569
|
+
controller = null;
|
|
1570
|
+
}
|
|
1571
|
+
if (controller !== null) controllers.add(controller);
|
|
1572
|
+
const signal = controller === null ? undefined : controller.signal;
|
|
1573
|
+
return Promise.resolve()
|
|
1574
|
+
.then(() => task(signal))
|
|
1575
|
+
.finally(() => {
|
|
1576
|
+
if (controller !== null) controllers.delete(controller);
|
|
1577
|
+
});
|
|
1578
|
+
},
|
|
1579
|
+
abortAll() {
|
|
1580
|
+
for (const controller of controllers) {
|
|
1581
|
+
try {
|
|
1582
|
+
controller.abort();
|
|
1583
|
+
} catch (error) {
|
|
1584
|
+
/* ignore */
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
controllers.clear();
|
|
1588
|
+
},
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
/**
|
|
1593
|
+
* Copy text to the clipboard with an execCommand fallback.
|
|
1594
|
+
* @param {string} text - text to copy.
|
|
1595
|
+
* @returns {Promise<void>} resolves once the copy was issued.
|
|
1596
|
+
*/
|
|
1597
|
+
async function copyText(text) {
|
|
1598
|
+
const clipboard = typeof navigator !== 'undefined' ? navigator.clipboard : undefined;
|
|
1599
|
+
if (clipboard && typeof clipboard.writeText === 'function') {
|
|
1600
|
+
await clipboard.writeText(text);
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
if (typeof document === 'undefined' || !document.body) throw new Error('clipboard-unavailable');
|
|
1604
|
+
const area = document.createElement('textarea');
|
|
1605
|
+
area.value = text;
|
|
1606
|
+
area.setAttribute('readonly', '');
|
|
1607
|
+
area.style.position = 'fixed';
|
|
1608
|
+
area.style.top = '0';
|
|
1609
|
+
area.style.opacity = '0';
|
|
1610
|
+
document.body.appendChild(area);
|
|
1611
|
+
try {
|
|
1612
|
+
if (typeof area.select === 'function') area.select();
|
|
1613
|
+
const ok = typeof document.execCommand === 'function' && document.execCommand('copy');
|
|
1614
|
+
if (!ok) throw new Error('clipboard-unavailable');
|
|
1615
|
+
} finally {
|
|
1616
|
+
document.body.removeChild(area);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
// #endregion
|
|
1621
|
+
|
|
1622
|
+
// #region hooks
|
|
1623
|
+
|
|
1624
|
+
/** Mount the bundle stylesheet for this panel's lifetime. */
|
|
1625
|
+
function useStyles() {
|
|
1626
|
+
React.useEffect(() => acquireStyles(), []);
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/** Re-render on locale switches and late dictionary registration. */
|
|
1630
|
+
function useLocaleRevision() {
|
|
1631
|
+
const pair = React.useState(0);
|
|
1632
|
+
const setRevision = pair[1];
|
|
1633
|
+
React.useEffect(() => {
|
|
1634
|
+
if (localeFace === null || typeof localeFace.subscribe !== 'function') return undefined;
|
|
1635
|
+
try {
|
|
1636
|
+
return localeFace.subscribe(() => setRevision((value) => value + 1));
|
|
1637
|
+
} catch (error) {
|
|
1638
|
+
return undefined;
|
|
1639
|
+
}
|
|
1640
|
+
}, []);
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
const FOCUSABLE = 'button:not(:disabled), input:not(:disabled), textarea:not(:disabled), select:not(:disabled), a[href], [tabindex="0"]';
|
|
1644
|
+
|
|
1645
|
+
/**
|
|
1646
|
+
* Modal keyboard ownership: Escape closes, Tab cycles inside the card, and
|
|
1647
|
+
* focus returns to the control that opened it.
|
|
1648
|
+
*
|
|
1649
|
+
* The listeners are capture-phase on `document`, and Tab is fully handled
|
|
1650
|
+
* here. That matters twice over: the settings shell owns Escape for its own
|
|
1651
|
+
* dialog on document bubble, and the dialog itself portals outside the
|
|
1652
|
+
* shell's panel element — which is exactly the case where the shell's own
|
|
1653
|
+
* Tab trap would otherwise pull focus back into the settings page.
|
|
1654
|
+
* @param {object} cardRef - ref to the dialog element.
|
|
1655
|
+
* @param {Function} onClose - close callback.
|
|
1656
|
+
*/
|
|
1657
|
+
function useModalLayer(cardRef, onClose) {
|
|
1658
|
+
const closeRef = React.useRef(onClose);
|
|
1659
|
+
closeRef.current = onClose;
|
|
1660
|
+
React.useEffect(() => {
|
|
1661
|
+
if (typeof document === 'undefined') return undefined;
|
|
1662
|
+
const doc = document;
|
|
1663
|
+
const previous = doc.activeElement;
|
|
1664
|
+
const card = cardRef && cardRef.current ? cardRef.current : null;
|
|
1665
|
+
let initial = card;
|
|
1666
|
+
try {
|
|
1667
|
+
if (card && typeof card.querySelector === 'function') {
|
|
1668
|
+
initial = card.querySelector('[data-dshc-autofocus]') || card;
|
|
1669
|
+
}
|
|
1670
|
+
} catch (error) {
|
|
1671
|
+
initial = card;
|
|
1672
|
+
}
|
|
1673
|
+
try {
|
|
1674
|
+
if (initial && typeof initial.focus === 'function') initial.focus({ preventScroll: true });
|
|
1675
|
+
} catch (error) {
|
|
1676
|
+
/* focus is best effort */
|
|
1677
|
+
}
|
|
1678
|
+
const focusables = () => {
|
|
1679
|
+
const items = [];
|
|
1680
|
+
if (card === null || typeof card.querySelectorAll !== 'function') return items;
|
|
1681
|
+
try {
|
|
1682
|
+
const found = card.querySelectorAll(FOCUSABLE);
|
|
1683
|
+
for (let index = 0; index < found.length; index += 1) {
|
|
1684
|
+
const item = found[index];
|
|
1685
|
+
if (item.closest && item.closest('[inert], [hidden]')) continue;
|
|
1686
|
+
items.push(item);
|
|
1687
|
+
}
|
|
1688
|
+
} catch (error) {
|
|
1689
|
+
return items;
|
|
1690
|
+
}
|
|
1691
|
+
return items;
|
|
1692
|
+
};
|
|
1693
|
+
const onKeyDown = (event) => {
|
|
1694
|
+
if (event.defaultPrevented) return;
|
|
1695
|
+
if (event.key === 'Escape' && !event.shiftKey) {
|
|
1696
|
+
event.preventDefault();
|
|
1697
|
+
event.stopPropagation();
|
|
1698
|
+
closeRef.current();
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
if (event.key !== 'Tab') return;
|
|
1702
|
+
event.preventDefault();
|
|
1703
|
+
event.stopPropagation();
|
|
1704
|
+
const items = focusables();
|
|
1705
|
+
if (items.length === 0) {
|
|
1706
|
+
try {
|
|
1707
|
+
if (card && typeof card.focus === 'function') card.focus({ preventScroll: true });
|
|
1708
|
+
} catch (error) {
|
|
1709
|
+
/* ignore */
|
|
1710
|
+
}
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
const active = doc.activeElement;
|
|
1714
|
+
const current = items.indexOf(active);
|
|
1715
|
+
if (current === -1) {
|
|
1716
|
+
(event.shiftKey ? items[items.length - 1] : items[0]).focus();
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
const next = event.shiftKey
|
|
1720
|
+
? (current - 1 + items.length) % items.length
|
|
1721
|
+
: (current + 1) % items.length;
|
|
1722
|
+
items[next].focus();
|
|
1723
|
+
};
|
|
1724
|
+
doc.addEventListener('keydown', onKeyDown, true);
|
|
1725
|
+
return () => {
|
|
1726
|
+
doc.removeEventListener('keydown', onKeyDown, true);
|
|
1727
|
+
try {
|
|
1728
|
+
if (previous && previous.isConnected && typeof previous.focus === 'function') {
|
|
1729
|
+
previous.focus({ preventScroll: true });
|
|
1730
|
+
}
|
|
1731
|
+
} catch (error) {
|
|
1732
|
+
/* ignore */
|
|
1733
|
+
}
|
|
1734
|
+
};
|
|
1735
|
+
}, []);
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
/**
|
|
1739
|
+
* Give an array of statically known children keys so React never warns
|
|
1740
|
+
* about a list rendered from a passed-down `props.children` array.
|
|
1741
|
+
* @param {*} children - one child or an array.
|
|
1742
|
+
* @returns {Array} children with keys.
|
|
1743
|
+
*/
|
|
1744
|
+
function keyed(children) {
|
|
1745
|
+
const list = Array.isArray(children) ? children : (children === undefined || children === null ? [] : [children]);
|
|
1746
|
+
return list.map((child, index) => {
|
|
1747
|
+
if (child === null || typeof child !== 'object') return child;
|
|
1748
|
+
if (child.key !== undefined && child.key !== null) return child;
|
|
1749
|
+
if (typeof React.cloneElement !== 'function') return child;
|
|
1750
|
+
try {
|
|
1751
|
+
return React.cloneElement(child, { key: `dshc-child-${index}` });
|
|
1752
|
+
} catch (error) {
|
|
1753
|
+
return child;
|
|
1754
|
+
}
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
// #endregion
|
|
1759
|
+
|
|
1760
|
+
// #region surfaces
|
|
1761
|
+
|
|
1762
|
+
function Spinner() {
|
|
1763
|
+
return h('span', { className: 'dshc-spinner', 'aria-hidden': 'true' });
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
function Notice(props) {
|
|
1767
|
+
return h('div', { className: 'dshc-notice', 'data-kind': props.kind, role: 'status', 'aria-live': 'polite' },
|
|
1768
|
+
props.kind === 'success' ? h(IconCheck, { size: 14 }) : null,
|
|
1769
|
+
h('span', null, props.text));
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
function EmptyState() {
|
|
1773
|
+
return h('div', { className: 'dshc-empty' },
|
|
1774
|
+
h('div', { className: 'dshc-emptyIcon', 'aria-hidden': 'true' }, h(IconMonitor, { size: 20 })),
|
|
1775
|
+
h('div', { className: 'dshc-emptyTitle' }, t('empty.title')),
|
|
1776
|
+
h('p', { className: 'dshc-emptyHint' }, t('empty.hint')));
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
/**
|
|
1780
|
+
* One copyable value row — the primary content of the connect modal
|
|
1781
|
+
* (server URL and enrollment key).
|
|
1782
|
+
* @param {object} props - `label`, `value`, optional `hint`.
|
|
1783
|
+
* @returns {object} row element.
|
|
1784
|
+
*/
|
|
1785
|
+
function CopyRow(props) {
|
|
1786
|
+
const [copied, setCopied] = React.useState(false);
|
|
1787
|
+
const [failed, setFailed] = React.useState(false);
|
|
1788
|
+
const alive = React.useRef(true);
|
|
1789
|
+
React.useEffect(() => () => { alive.current = false; }, []);
|
|
1790
|
+
const copy = async () => {
|
|
1791
|
+
if (typeof props.value !== 'string' || props.value === '') return;
|
|
1792
|
+
try {
|
|
1793
|
+
await copyText(props.value);
|
|
1794
|
+
if (!alive.current) return;
|
|
1795
|
+
setCopied(true);
|
|
1796
|
+
setFailed(false);
|
|
1797
|
+
setTimeout(() => {
|
|
1798
|
+
if (alive.current) setCopied(false);
|
|
1799
|
+
}, 1600);
|
|
1800
|
+
} catch (error) {
|
|
1801
|
+
if (alive.current) setFailed(true);
|
|
1802
|
+
}
|
|
1803
|
+
};
|
|
1804
|
+
const action = t('connect.copyField', { field: props.label });
|
|
1805
|
+
return h('div', { className: 'dshc-copyRow' },
|
|
1806
|
+
h('div', { className: 'dshc-copyRowTop' },
|
|
1807
|
+
h('span', { className: 'dshc-copyRowKey' }, props.label),
|
|
1808
|
+
h('span', { className: 'dshc-copyRowValue' }, typeof props.value === 'string' && props.value !== '' ? props.value : '—'),
|
|
1809
|
+
h('button', {
|
|
1810
|
+
type: 'button',
|
|
1811
|
+
className: 'dshc-copyRowBtn',
|
|
1812
|
+
'data-copied': copied ? 'true' : 'false',
|
|
1813
|
+
'aria-label': action,
|
|
1814
|
+
title: copied ? t('connect.copied') : action,
|
|
1815
|
+
disabled: typeof props.value !== 'string' || props.value === '',
|
|
1816
|
+
onClick: () => { void copy(); },
|
|
1817
|
+
}, copied ? h(IconCheck, { size: 13 }) : h(IconCopy, { size: 13 }))),
|
|
1818
|
+
props.hint ? h('p', { className: 'dshc-copyRowHint' }, props.hint) : null,
|
|
1819
|
+
/* The accessible name stays stable ("copy <field>"); the outcome is
|
|
1820
|
+
* announced here, so a screen reader hears the confirmation too. */
|
|
1821
|
+
h('span', { className: 'dshc-sr', role: 'status', 'aria-live': 'polite' },
|
|
1822
|
+
copied ? t('connect.copied') : (failed ? t('connect.copyFailed') : '')));
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
function Skeleton() {
|
|
1826
|
+
return h('div', { className: 'dshc-skeleton', 'aria-hidden': 'true' },
|
|
1827
|
+
h('div', { className: 'dshc-skeletonRow' },
|
|
1828
|
+
h('span', { className: 'dshc-skeletonBar', 'data-wide': 'true' }),
|
|
1829
|
+
h('span', { className: 'dshc-skeletonBar', 'data-narrow': 'true' })),
|
|
1830
|
+
h('div', { className: 'dshc-skeletonRow', key: 'second' },
|
|
1831
|
+
h('span', { className: 'dshc-skeletonBar', 'data-wide': 'true' }),
|
|
1832
|
+
h('span', { className: 'dshc-skeletonBar', 'data-narrow': 'true' })));
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
function ComputerRow(props) {
|
|
1836
|
+
const computer = props.computer;
|
|
1837
|
+
const online = computer.online === true;
|
|
1838
|
+
const bots = computer.bots && typeof computer.bots === 'object' ? computer.bots : null;
|
|
1839
|
+
const used = bots && typeof bots.used === 'number' ? bots.used : 1;
|
|
1840
|
+
const total = bots && typeof bots.total === 'number' ? bots.total : 1;
|
|
1841
|
+
const name = typeof computer.name === 'string' && computer.name !== ''
|
|
1842
|
+
? computer.name
|
|
1843
|
+
: (typeof computer.hostname === 'string' ? computer.hostname : '');
|
|
1844
|
+
return h('div', { className: 'dshc-row', 'data-online': online ? 'true' : 'false' },
|
|
1845
|
+
h('div', { className: 'dshc-rowMain' },
|
|
1846
|
+
h('span', { className: 'dshc-name', title: name }, name),
|
|
1847
|
+
h('span', { className: 'dshc-badge' }, t('badge.bots', { used, total }))),
|
|
1848
|
+
h('span', { className: 'dshc-status' },
|
|
1849
|
+
h('span', { className: 'dshc-dot', 'aria-hidden': 'true' }),
|
|
1850
|
+
online ? t('online') : t('offline')),
|
|
1851
|
+
h('div', { className: 'dshc-rowActions' },
|
|
1852
|
+
h('button', {
|
|
1853
|
+
type: 'button',
|
|
1854
|
+
className: 'dshc-iconBtn',
|
|
1855
|
+
'aria-label': t('rename.action'),
|
|
1856
|
+
title: t('rename.action'),
|
|
1857
|
+
onClick: () => props.onRename(computer),
|
|
1858
|
+
}, h(IconGear, { size: 16 })),
|
|
1859
|
+
h('button', {
|
|
1860
|
+
type: 'button',
|
|
1861
|
+
className: 'dshc-iconBtn dshc-trash',
|
|
1862
|
+
'aria-label': t('delete.action'),
|
|
1863
|
+
title: t('delete.action'),
|
|
1864
|
+
onClick: () => props.onDelete(computer),
|
|
1865
|
+
}, h(IconTrash, { size: 16 }))));
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
/**
|
|
1869
|
+
* Portal a dialog layer to <body> when react-dom is available.
|
|
1870
|
+
* @param {object} node - dialog element.
|
|
1871
|
+
* @returns {object} element to render.
|
|
1872
|
+
*/
|
|
1873
|
+
function renderLayer(node) {
|
|
1874
|
+
if (ReactDOM !== null && typeof ReactDOM.createPortal === 'function' && typeof document !== 'undefined' && document.body) {
|
|
1875
|
+
try {
|
|
1876
|
+
return ReactDOM.createPortal(node, document.body);
|
|
1877
|
+
} catch (error) {
|
|
1878
|
+
return node;
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
return node;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
/**
|
|
1885
|
+
* Shared dialog chrome: mask, centered card, close button, footer.
|
|
1886
|
+
* @param {object} props - `title`, `subtitle`, `onClose`, `footer`, `size`, `children`.
|
|
1887
|
+
* @returns {object} dialog layer.
|
|
1888
|
+
*/
|
|
1889
|
+
function Dialog(props) {
|
|
1890
|
+
const cardRef = React.useRef(null);
|
|
1891
|
+
useModalLayer(cardRef, props.onClose);
|
|
1892
|
+
const children = keyed(props.children);
|
|
1893
|
+
const footer = keyed(props.footer);
|
|
1894
|
+
return renderLayer(h('div', { className: 'dshc-overlay' },
|
|
1895
|
+
h('div', { className: 'dshc-mask', 'aria-hidden': 'true', onClick: props.onClose }),
|
|
1896
|
+
h('div', {
|
|
1897
|
+
ref: cardRef,
|
|
1898
|
+
className: 'dshc-card',
|
|
1899
|
+
'data-size': props.size === 'sm' ? 'sm' : undefined,
|
|
1900
|
+
role: 'dialog',
|
|
1901
|
+
'aria-modal': 'true',
|
|
1902
|
+
'aria-label': props.title,
|
|
1903
|
+
tabIndex: -1,
|
|
1904
|
+
onClick: (event) => event.stopPropagation(),
|
|
1905
|
+
},
|
|
1906
|
+
h('div', { className: 'dshc-cardHead' },
|
|
1907
|
+
h('h2', { className: 'dshc-cardTitle' }, props.title),
|
|
1908
|
+
h('button', {
|
|
1909
|
+
type: 'button',
|
|
1910
|
+
className: 'dshc-iconBtn dshc-closeBtn',
|
|
1911
|
+
'aria-label': t('dialog.close'),
|
|
1912
|
+
onClick: props.onClose,
|
|
1913
|
+
}, h(IconClose, { size: 16 }))),
|
|
1914
|
+
props.subtitle ? h('p', { className: 'dshc-cardSub' }, props.subtitle) : null,
|
|
1915
|
+
h('div', null, children),
|
|
1916
|
+
footer.length > 0 ? h('div', { className: 'dshc-footer' }, footer) : null)));
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
function RenameDialog(props) {
|
|
1920
|
+
const computer = props.computer;
|
|
1921
|
+
const [value, setValue] = React.useState(typeof computer.name === 'string' ? computer.name : '');
|
|
1922
|
+
const [busy, setBusy] = React.useState(false);
|
|
1923
|
+
const [failure, setFailure] = React.useState(null);
|
|
1924
|
+
const scope = React.useRef(null);
|
|
1925
|
+
if (scope.current === null) scope.current = createScope();
|
|
1926
|
+
React.useEffect(() => () => scope.current.abortAll(), []);
|
|
1927
|
+
|
|
1928
|
+
const submit = async () => {
|
|
1929
|
+
const name = value.trim();
|
|
1930
|
+
if (name === '' || busy) return;
|
|
1931
|
+
setBusy(true);
|
|
1932
|
+
setFailure(null);
|
|
1933
|
+
try {
|
|
1934
|
+
const result = await scope.current.run((signal) => api.rename(computer.id, name, signal));
|
|
1935
|
+
props.onRenamed(result && typeof result.name === 'string' ? result.name : name);
|
|
1936
|
+
} catch (error) {
|
|
1937
|
+
if (error && error.name === 'AbortError') return;
|
|
1938
|
+
setFailure(messageOf(error));
|
|
1939
|
+
setBusy(false);
|
|
1940
|
+
}
|
|
1941
|
+
};
|
|
1942
|
+
|
|
1943
|
+
return h(Dialog, {
|
|
1944
|
+
title: t('rename.title'),
|
|
1945
|
+
size: 'sm',
|
|
1946
|
+
onClose: () => { if (!busy) props.onClose(); },
|
|
1947
|
+
footer: [
|
|
1948
|
+
h('button', {
|
|
1949
|
+
key: 'cancel',
|
|
1950
|
+
type: 'button',
|
|
1951
|
+
className: 'dshc-ghost',
|
|
1952
|
+
disabled: busy,
|
|
1953
|
+
onClick: props.onClose,
|
|
1954
|
+
}, t('cancel')),
|
|
1955
|
+
h('button', {
|
|
1956
|
+
key: 'save',
|
|
1957
|
+
type: 'button',
|
|
1958
|
+
className: 'dshc-primary',
|
|
1959
|
+
disabled: busy || value.trim() === '',
|
|
1960
|
+
onClick: () => { void submit(); },
|
|
1961
|
+
}, busy ? t('saving') : t('save')),
|
|
1962
|
+
],
|
|
1963
|
+
},
|
|
1964
|
+
h('label', { className: 'dshc-fieldLabel', htmlFor: 'dshc-rename-input' }, t('rename.field')),
|
|
1965
|
+
h('input', {
|
|
1966
|
+
id: 'dshc-rename-input',
|
|
1967
|
+
className: 'dshc-input',
|
|
1968
|
+
'data-dshc-autofocus': '',
|
|
1969
|
+
type: 'text',
|
|
1970
|
+
value,
|
|
1971
|
+
maxLength: 64,
|
|
1972
|
+
placeholder: t('rename.placeholder'),
|
|
1973
|
+
disabled: busy,
|
|
1974
|
+
onChange: (event) => setValue(event.target.value),
|
|
1975
|
+
onKeyDown: (event) => {
|
|
1976
|
+
if (event.key === 'Enter') {
|
|
1977
|
+
event.preventDefault();
|
|
1978
|
+
void submit();
|
|
1979
|
+
}
|
|
1980
|
+
},
|
|
1981
|
+
}),
|
|
1982
|
+
h('p', { className: 'dshc-hint' }, t('rename.hint')),
|
|
1983
|
+
failure !== null ? h('p', { className: 'dshc-formError', role: 'alert' }, t('rename.failed', { message: failure })) : null);
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
function DeleteDialog(props) {
|
|
1987
|
+
const computer = props.computer;
|
|
1988
|
+
const [busy, setBusy] = React.useState(false);
|
|
1989
|
+
const [failure, setFailure] = React.useState(null);
|
|
1990
|
+
const scope = React.useRef(null);
|
|
1991
|
+
if (scope.current === null) scope.current = createScope();
|
|
1992
|
+
React.useEffect(() => () => scope.current.abortAll(), []);
|
|
1993
|
+
|
|
1994
|
+
const confirm = async () => {
|
|
1995
|
+
if (busy) return;
|
|
1996
|
+
setBusy(true);
|
|
1997
|
+
setFailure(null);
|
|
1998
|
+
try {
|
|
1999
|
+
await scope.current.run((signal) => api.remove(computer.id, signal));
|
|
2000
|
+
props.onDeleted(computer.name || '');
|
|
2001
|
+
} catch (error) {
|
|
2002
|
+
if (error && error.name === 'AbortError') return;
|
|
2003
|
+
setFailure(messageOf(error));
|
|
2004
|
+
setBusy(false);
|
|
2005
|
+
}
|
|
2006
|
+
};
|
|
2007
|
+
|
|
2008
|
+
return h(Dialog, {
|
|
2009
|
+
title: t('delete.title'),
|
|
2010
|
+
size: 'sm',
|
|
2011
|
+
onClose: () => { if (!busy) props.onClose(); },
|
|
2012
|
+
footer: [
|
|
2013
|
+
h('button', {
|
|
2014
|
+
key: 'cancel',
|
|
2015
|
+
type: 'button',
|
|
2016
|
+
className: 'dshc-ghost',
|
|
2017
|
+
disabled: busy,
|
|
2018
|
+
onClick: props.onClose,
|
|
2019
|
+
}, t('cancel')),
|
|
2020
|
+
h('button', {
|
|
2021
|
+
key: 'delete',
|
|
2022
|
+
type: 'button',
|
|
2023
|
+
className: 'dshc-primary',
|
|
2024
|
+
disabled: busy,
|
|
2025
|
+
onClick: () => { void confirm(); },
|
|
2026
|
+
}, busy ? t('deleting') : t('delete.confirm')),
|
|
2027
|
+
],
|
|
2028
|
+
},
|
|
2029
|
+
h('p', { className: 'dshc-dialogBody' }, t('delete.body', { name: computer.name || '' })),
|
|
2030
|
+
failure !== null ? h('p', { className: 'dshc-formError', role: 'alert' }, t('delete.failed', { message: failure })) : null);
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
/**
|
|
2034
|
+
* The connect-computer modal: mint a key, show the installer, poll until the
|
|
2035
|
+
* machine appears (PROTOCOL §4.2–§4.3).
|
|
2036
|
+
* @param {object} props - `serverUrl`, `onClose`, `onConnected(computer|null)`.
|
|
2037
|
+
* @returns {object} dialog layer.
|
|
2038
|
+
*/
|
|
2039
|
+
function ConnectModal(props) {
|
|
2040
|
+
const [os, setOs] = React.useState('macos');
|
|
2041
|
+
const [replace, setReplace] = React.useState(false);
|
|
2042
|
+
const [nonce, setNonce] = React.useState(0);
|
|
2043
|
+
const [keyInfo, setKeyInfo] = React.useState(null);
|
|
2044
|
+
const [creating, setCreating] = React.useState(true);
|
|
2045
|
+
const [createFailure, setCreateFailure] = React.useState(null);
|
|
2046
|
+
const [status, setStatus] = React.useState('waiting');
|
|
2047
|
+
const [copied, setCopied] = React.useState(false);
|
|
2048
|
+
const [copyFailure, setCopyFailure] = React.useState(false);
|
|
2049
|
+
const alive = React.useRef(true);
|
|
2050
|
+
const message = React.useRef(props.onConnected);
|
|
2051
|
+
message.current = props.onConnected;
|
|
2052
|
+
|
|
2053
|
+
React.useEffect(() => () => { alive.current = false; }, []);
|
|
2054
|
+
|
|
2055
|
+
/* One key per (os, replace, regenerate). A newer request supersedes the
|
|
2056
|
+
* older one; the switcher aborts the in-flight request entirely. */
|
|
2057
|
+
React.useEffect(() => {
|
|
2058
|
+
let cancelled = false;
|
|
2059
|
+
let controller = null;
|
|
2060
|
+
try {
|
|
2061
|
+
if (typeof AbortController === 'function') controller = new AbortController();
|
|
2062
|
+
} catch (error) {
|
|
2063
|
+
controller = null;
|
|
2064
|
+
}
|
|
2065
|
+
setCreating(true);
|
|
2066
|
+
setCreateFailure(null);
|
|
2067
|
+
setKeyInfo(null);
|
|
2068
|
+
setCopied(false);
|
|
2069
|
+
setCopyFailure(false);
|
|
2070
|
+
setStatus('waiting');
|
|
2071
|
+
api.createKey(os, replace, controller === null ? undefined : controller.signal)
|
|
2072
|
+
.then((info) => {
|
|
2073
|
+
if (cancelled || !alive.current) return;
|
|
2074
|
+
if (!info || typeof info.key !== 'string') {
|
|
2075
|
+
setCreating(false);
|
|
2076
|
+
setCreateFailure(t('error.badResponse'));
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
setKeyInfo(info);
|
|
2080
|
+
setCreating(false);
|
|
2081
|
+
})
|
|
2082
|
+
.catch((error) => {
|
|
2083
|
+
if (cancelled || !alive.current) return;
|
|
2084
|
+
if (error && error.name === 'AbortError') return;
|
|
2085
|
+
setCreating(false);
|
|
2086
|
+
setCreateFailure(messageOf(error));
|
|
2087
|
+
});
|
|
2088
|
+
return () => {
|
|
2089
|
+
cancelled = true;
|
|
2090
|
+
if (controller !== null) {
|
|
2091
|
+
try {
|
|
2092
|
+
controller.abort();
|
|
2093
|
+
} catch (error) {
|
|
2094
|
+
/* ignore */
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
};
|
|
2098
|
+
}, [os, replace, nonce]);
|
|
2099
|
+
|
|
2100
|
+
/* Poll the minted key every 2 s until connected / expired / used. */
|
|
2101
|
+
React.useEffect(() => {
|
|
2102
|
+
if (keyInfo === null || typeof keyInfo.key !== 'string') return undefined;
|
|
2103
|
+
const key = keyInfo.key;
|
|
2104
|
+
let cancelled = false;
|
|
2105
|
+
let timer = null;
|
|
2106
|
+
let controller = null;
|
|
2107
|
+
const tick = async () => {
|
|
2108
|
+
timer = null;
|
|
2109
|
+
try {
|
|
2110
|
+
if (typeof AbortController === 'function') controller = new AbortController();
|
|
2111
|
+
} catch (error) {
|
|
2112
|
+
controller = null;
|
|
2113
|
+
}
|
|
2114
|
+
try {
|
|
2115
|
+
const result = await api.readKeyStatus(key, controller === null ? undefined : controller.signal);
|
|
2116
|
+
if (cancelled || !alive.current) return;
|
|
2117
|
+
controller = null;
|
|
2118
|
+
const next = result && typeof result.status === 'string' ? result.status : 'unknown';
|
|
2119
|
+
if (next === 'connected') {
|
|
2120
|
+
setStatus('connected');
|
|
2121
|
+
message.current(result && result.computer ? result.computer : null);
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
if (next === 'expired' || next === 'used' || next === 'unknown') {
|
|
2125
|
+
setStatus(next);
|
|
2126
|
+
return;
|
|
2127
|
+
}
|
|
2128
|
+
timer = setTimeout(() => { void tick(); }, KEY_POLL_MS);
|
|
2129
|
+
} catch (error) {
|
|
2130
|
+
if (cancelled || !alive.current) return;
|
|
2131
|
+
controller = null;
|
|
2132
|
+
if (error && error.name === 'AbortError') return;
|
|
2133
|
+
timer = setTimeout(() => { void tick(); }, KEY_POLL_MS * 2);
|
|
2134
|
+
}
|
|
2135
|
+
};
|
|
2136
|
+
timer = setTimeout(() => { void tick(); }, 900);
|
|
2137
|
+
return () => {
|
|
2138
|
+
cancelled = true;
|
|
2139
|
+
if (timer !== null) {
|
|
2140
|
+
clearTimeout(timer);
|
|
2141
|
+
timer = null;
|
|
2142
|
+
}
|
|
2143
|
+
if (controller !== null) {
|
|
2144
|
+
try {
|
|
2145
|
+
controller.abort();
|
|
2146
|
+
} catch (error) {
|
|
2147
|
+
/* ignore */
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
};
|
|
2151
|
+
}, [keyInfo]);
|
|
2152
|
+
|
|
2153
|
+
const command = keyInfo === null
|
|
2154
|
+
? ''
|
|
2155
|
+
: (os === 'windows'
|
|
2156
|
+
? (typeof keyInfo.windowsCommand === 'string' ? keyInfo.windowsCommand : '')
|
|
2157
|
+
: (typeof keyInfo.command === 'string' ? keyInfo.command : ''));
|
|
2158
|
+
|
|
2159
|
+
const copy = async () => {
|
|
2160
|
+
if (command === '') return;
|
|
2161
|
+
try {
|
|
2162
|
+
await copyText(command);
|
|
2163
|
+
if (!alive.current) return;
|
|
2164
|
+
setCopied(true);
|
|
2165
|
+
setCopyFailure(false);
|
|
2166
|
+
setTimeout(() => {
|
|
2167
|
+
if (alive.current) setCopied(false);
|
|
2168
|
+
}, 1600);
|
|
2169
|
+
} catch (error) {
|
|
2170
|
+
if (!alive.current) return;
|
|
2171
|
+
setCopyFailure(true);
|
|
2172
|
+
}
|
|
2173
|
+
};
|
|
2174
|
+
|
|
2175
|
+
const regenerate = () => {
|
|
2176
|
+
setCopyFailure(false);
|
|
2177
|
+
setNonce((value) => value + 1);
|
|
2178
|
+
};
|
|
2179
|
+
|
|
2180
|
+
let stripText;
|
|
2181
|
+
let stripState = status;
|
|
2182
|
+
if (createFailure !== null) {
|
|
2183
|
+
stripText = t('connect.createFailed', { message: createFailure });
|
|
2184
|
+
stripState = 'error';
|
|
2185
|
+
} else if (creating) {
|
|
2186
|
+
stripText = t('connect.creating');
|
|
2187
|
+
stripState = 'waiting';
|
|
2188
|
+
} else if (status === 'connected') stripText = t('connect.connected');
|
|
2189
|
+
else if (status === 'expired') stripText = t('connect.expired');
|
|
2190
|
+
else if (status === 'used') stripText = t('connect.used');
|
|
2191
|
+
else if (status === 'unknown') stripText = t('connect.unknown');
|
|
2192
|
+
else stripText = t('connect.waiting');
|
|
2193
|
+
const canRegenerate = !creating
|
|
2194
|
+
&& (status === 'expired' || status === 'used' || status === 'unknown' || createFailure !== null);
|
|
2195
|
+
|
|
2196
|
+
const osOptions = [
|
|
2197
|
+
{ id: 'windows', label: t('connect.os.windows') },
|
|
2198
|
+
{ id: 'macos', label: t('connect.os.macos') },
|
|
2199
|
+
{ id: 'linux', label: t('connect.os.linux') },
|
|
2200
|
+
];
|
|
2201
|
+
|
|
2202
|
+
const primaryServerUrl = keyInfo && typeof keyInfo.serverUrl === 'string' && keyInfo.serverUrl !== ''
|
|
2203
|
+
? keyInfo.serverUrl
|
|
2204
|
+
: (typeof props.serverUrl === 'string' ? props.serverUrl : '');
|
|
2205
|
+
const primaryKey = keyInfo && typeof keyInfo.key === 'string' ? keyInfo.key : '';
|
|
2206
|
+
|
|
2207
|
+
return h(Dialog, {
|
|
2208
|
+
title: t('connect.title'),
|
|
2209
|
+
subtitle: t('connect.subtitle'),
|
|
2210
|
+
onClose: props.onClose,
|
|
2211
|
+
footer: [
|
|
2212
|
+
h('button', {
|
|
2213
|
+
key: 'cancel',
|
|
2214
|
+
type: 'button',
|
|
2215
|
+
className: 'dshc-ghost',
|
|
2216
|
+
onClick: props.onClose,
|
|
2217
|
+
}, t('cancel')),
|
|
2218
|
+
],
|
|
2219
|
+
},
|
|
2220
|
+
/* PRIMARY (task-8): the plugin path — paste these into 本机运行时. */
|
|
2221
|
+
h('div', { className: 'dshc-copyStack' },
|
|
2222
|
+
h(CopyRow, {
|
|
2223
|
+
key: 'server',
|
|
2224
|
+
label: t('connect.serverUrl'),
|
|
2225
|
+
hint: t('connect.serverUrlHint'),
|
|
2226
|
+
value: primaryServerUrl,
|
|
2227
|
+
}),
|
|
2228
|
+
h(CopyRow, {
|
|
2229
|
+
key: 'key',
|
|
2230
|
+
label: t('connect.enrollmentKey'),
|
|
2231
|
+
hint: t('connect.enrollmentKeyHint'),
|
|
2232
|
+
value: primaryKey,
|
|
2233
|
+
})),
|
|
2234
|
+
/* Waiting / connected / expired — the 2 s key-status poll drives it. */
|
|
2235
|
+
h('div', { className: 'dshc-strip', 'data-state': stripState, role: 'status', 'aria-live': 'polite' },
|
|
2236
|
+
stripState === 'waiting' ? h(Spinner) : null,
|
|
2237
|
+
stripState === 'connected' ? h(IconCheck, { size: 14 }) : null,
|
|
2238
|
+
h('span', { className: 'dshc-stripText' }, stripText),
|
|
2239
|
+
canRegenerate
|
|
2240
|
+
? h('button', { type: 'button', className: 'dshc-stripAction', onClick: regenerate }, t('connect.regenerate'))
|
|
2241
|
+
: null),
|
|
2242
|
+
/* SECONDARY: machines without DSH — the terminal one-liner, collapsed. */
|
|
2243
|
+
h('details', { className: 'dshc-details' },
|
|
2244
|
+
h('summary', { className: 'dshc-detailsSummary' },
|
|
2245
|
+
h('span', { className: 'dshc-detailsLabel' }, t('connect.cliToggle'))),
|
|
2246
|
+
h('div', { className: 'dshc-cliBody' },
|
|
2247
|
+
h('p', { className: 'dshc-hint', style: { margin: '0' } }, t('connect.cliHint')),
|
|
2248
|
+
h('div', { className: 'dshc-seg', role: 'group', 'aria-label': t('connect.osGroup') },
|
|
2249
|
+
osOptions.map((option) => h('button', {
|
|
2250
|
+
key: option.id,
|
|
2251
|
+
type: 'button',
|
|
2252
|
+
className: 'dshc-segBtn',
|
|
2253
|
+
'data-active': option.id === os ? 'true' : 'false',
|
|
2254
|
+
'aria-pressed': option.id === os ? 'true' : 'false',
|
|
2255
|
+
onClick: () => setOs(option.id),
|
|
2256
|
+
}, option.label))),
|
|
2257
|
+
h('div', { className: 'dshc-switchRow' },
|
|
2258
|
+
h('div', { className: 'dshc-switchText' },
|
|
2259
|
+
h('div', { className: 'dshc-switchTitle' }, t('connect.replace.title')),
|
|
2260
|
+
h('div', { className: 'dshc-switchHint' }, t('connect.replace.hint'))),
|
|
2261
|
+
h('button', {
|
|
2262
|
+
type: 'button',
|
|
2263
|
+
className: 'dshc-switch',
|
|
2264
|
+
role: 'switch',
|
|
2265
|
+
'aria-checked': replace ? 'true' : 'false',
|
|
2266
|
+
'aria-label': t('connect.replace.title'),
|
|
2267
|
+
onClick: () => setReplace((value) => !value),
|
|
2268
|
+
}, h('span', { className: 'dshc-switchThumb' }))),
|
|
2269
|
+
h('div', { className: 'dshc-cmd' },
|
|
2270
|
+
h('span', { className: 'dshc-cmdLabel' }, t('connect.command.label')),
|
|
2271
|
+
h('button', {
|
|
2272
|
+
type: 'button',
|
|
2273
|
+
className: 'dshc-cmdCopy',
|
|
2274
|
+
'data-copied': copied ? 'true' : 'false',
|
|
2275
|
+
'aria-label': copied ? t('connect.copied') : t('connect.copy'),
|
|
2276
|
+
title: copied ? t('connect.copied') : t('connect.copy'),
|
|
2277
|
+
disabled: command === '',
|
|
2278
|
+
onClick: () => { void copy(); },
|
|
2279
|
+
}, copied ? h(IconCheck, { size: 15 }) : h(IconCopy, { size: 15 })),
|
|
2280
|
+
h('code', {
|
|
2281
|
+
className: 'dshc-cmdText',
|
|
2282
|
+
'data-placeholder': command === '' ? 'true' : 'false',
|
|
2283
|
+
}, command === '' ? t('connect.pendingCommand') : command),
|
|
2284
|
+
h('span', { className: 'dshc-sr', role: 'status', 'aria-live': 'polite' },
|
|
2285
|
+
copied ? t('connect.copied') : (copyFailure ? t('connect.copyFailed') : ''))),
|
|
2286
|
+
copyFailure ? h('p', { className: 'dshc-formError', role: 'alert' }, t('connect.copyFailed')) : null)));
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
/**
|
|
2290
|
+
* Which of the four §10.6 runtime states the status object describes.
|
|
2291
|
+
* @param {object|null} status - §10.3 status() plus serverUrl.
|
|
2292
|
+
* @returns {string} 'running' | 'stopped' | 'connecting' | 'failed'.
|
|
2293
|
+
*/
|
|
2294
|
+
function runtimePhaseOf(status) {
|
|
2295
|
+
if (!status || status.configured !== true) return 'stopped';
|
|
2296
|
+
if (status.connected === true) return 'running';
|
|
2297
|
+
if (status.enabled === true) return status.lastError ? 'failed' : 'connecting';
|
|
2298
|
+
return 'stopped';
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
/**
|
|
2302
|
+
* The 本机运行时 card (PROTOCOL §10.6): this machine joining a hub.
|
|
2303
|
+
* @param {object} props - `view` (runtime request state), `onStatus`, `onRetry`.
|
|
2304
|
+
* @returns {object} card element.
|
|
2305
|
+
*/
|
|
2306
|
+
function RuntimeCard(props) {
|
|
2307
|
+
const view = props.view;
|
|
2308
|
+
const status = view.data;
|
|
2309
|
+
const phase = runtimePhaseOf(status);
|
|
2310
|
+
const configured = Boolean(status && status.configured === true);
|
|
2311
|
+
const [serverUrl, setServerUrl] = React.useState('');
|
|
2312
|
+
const [key, setKey] = React.useState('');
|
|
2313
|
+
const [busy, setBusy] = React.useState(false);
|
|
2314
|
+
const [failure, setFailure] = React.useState(null);
|
|
2315
|
+
const [confirming, setConfirming] = React.useState(false);
|
|
2316
|
+
const alive = React.useRef(true);
|
|
2317
|
+
const scope = React.useRef(null);
|
|
2318
|
+
if (scope.current === null) scope.current = createScope();
|
|
2319
|
+
React.useEffect(() => () => {
|
|
2320
|
+
alive.current = false;
|
|
2321
|
+
scope.current.abortAll();
|
|
2322
|
+
}, []);
|
|
2323
|
+
|
|
2324
|
+
const submit = async () => {
|
|
2325
|
+
const url = serverUrl.trim();
|
|
2326
|
+
const enrollmentKey = key.trim();
|
|
2327
|
+
if (url === '' || enrollmentKey === '' || busy) return;
|
|
2328
|
+
setBusy(true);
|
|
2329
|
+
setFailure(null);
|
|
2330
|
+
try {
|
|
2331
|
+
const next = await scope.current.run((signal) => api.runtimeConnect(url, enrollmentKey, signal));
|
|
2332
|
+
if (!alive.current) return;
|
|
2333
|
+
setServerUrl('');
|
|
2334
|
+
setKey('');
|
|
2335
|
+
props.onStatus(next, t('runtime.connected.notice', { server: url }));
|
|
2336
|
+
} catch (error) {
|
|
2337
|
+
if (!alive.current) return;
|
|
2338
|
+
if (error && error.name === 'AbortError') return;
|
|
2339
|
+
setFailure(t('runtime.connectFailed', { message: messageOf(error) }));
|
|
2340
|
+
} finally {
|
|
2341
|
+
if (alive.current) setBusy(false);
|
|
2342
|
+
}
|
|
2343
|
+
};
|
|
2344
|
+
|
|
2345
|
+
const toggle = async (next) => {
|
|
2346
|
+
if (busy) return;
|
|
2347
|
+
setBusy(true);
|
|
2348
|
+
setFailure(null);
|
|
2349
|
+
try {
|
|
2350
|
+
const result = await scope.current.run((signal) => api.runtimeEnable(next, signal));
|
|
2351
|
+
if (!alive.current) return;
|
|
2352
|
+
props.onStatus(result, null);
|
|
2353
|
+
} catch (error) {
|
|
2354
|
+
if (!alive.current) return;
|
|
2355
|
+
if (error && error.name === 'AbortError') return;
|
|
2356
|
+
setFailure(t('runtime.enableFailed', { message: messageOf(error) }));
|
|
2357
|
+
} finally {
|
|
2358
|
+
if (alive.current) setBusy(false);
|
|
2359
|
+
}
|
|
2360
|
+
};
|
|
2361
|
+
|
|
2362
|
+
const enterSubmits = (event) => {
|
|
2363
|
+
if (event.key === 'Enter') {
|
|
2364
|
+
event.preventDefault();
|
|
2365
|
+
void submit();
|
|
2366
|
+
}
|
|
2367
|
+
};
|
|
2368
|
+
|
|
2369
|
+
let body;
|
|
2370
|
+
if (status === null && (view.phase === 'loading' || view.phase === 'idle')) {
|
|
2371
|
+
body = h('div', { className: 'dshc-runtimeMeta' }, h(Spinner), h('span', null, t('runtime.loading')));
|
|
2372
|
+
} else if (status === null) {
|
|
2373
|
+
body = h('div', { className: 'dshc-errorCard', role: 'alert' },
|
|
2374
|
+
h('span', { className: 'dshc-errorText' },
|
|
2375
|
+
t('error.loadDetail', { title: t('runtime.loadFailed'), message: view.error ? messageOf(view.error) : '' })),
|
|
2376
|
+
h('button', { type: 'button', className: 'dshc-ghost', onClick: props.onRetry }, t('retry')));
|
|
2377
|
+
} else if (!configured) {
|
|
2378
|
+
body = h('div', null,
|
|
2379
|
+
h('p', { className: 'dshc-hint', style: { margin: '10px 0 0' } }, t('runtime.desc')),
|
|
2380
|
+
h('div', { className: 'dshc-formRow' },
|
|
2381
|
+
h('label', { className: 'dshc-field' },
|
|
2382
|
+
h('span', { className: 'dshc-fieldLabel' }, t('runtime.serverLabel')),
|
|
2383
|
+
h('input', {
|
|
2384
|
+
className: 'dshc-input',
|
|
2385
|
+
type: 'url',
|
|
2386
|
+
inputMode: 'url',
|
|
2387
|
+
autoComplete: 'off',
|
|
2388
|
+
spellCheck: false,
|
|
2389
|
+
value: serverUrl,
|
|
2390
|
+
placeholder: t('runtime.serverPlaceholder'),
|
|
2391
|
+
disabled: busy,
|
|
2392
|
+
onChange: (event) => setServerUrl(event.target.value),
|
|
2393
|
+
onKeyDown: enterSubmits,
|
|
2394
|
+
})),
|
|
2395
|
+
h('label', { className: 'dshc-field' },
|
|
2396
|
+
h('span', { className: 'dshc-fieldLabel' }, t('runtime.keyLabel')),
|
|
2397
|
+
h('input', {
|
|
2398
|
+
className: 'dshc-input',
|
|
2399
|
+
type: 'text',
|
|
2400
|
+
autoComplete: 'off',
|
|
2401
|
+
spellCheck: false,
|
|
2402
|
+
value: key,
|
|
2403
|
+
placeholder: t('runtime.keyPlaceholder'),
|
|
2404
|
+
disabled: busy,
|
|
2405
|
+
onChange: (event) => setKey(event.target.value),
|
|
2406
|
+
onKeyDown: enterSubmits,
|
|
2407
|
+
})),
|
|
2408
|
+
h('button', {
|
|
2409
|
+
type: 'button',
|
|
2410
|
+
className: 'dshc-primary dshc-formSubmit',
|
|
2411
|
+
disabled: busy || serverUrl.trim() === '' || key.trim() === '',
|
|
2412
|
+
onClick: () => { void submit(); },
|
|
2413
|
+
}, busy ? t('runtime.connecting') : t('runtime.connect'))),
|
|
2414
|
+
h('p', { className: 'dshc-hint' }, t('runtime.hint')));
|
|
2415
|
+
} else {
|
|
2416
|
+
const name = typeof status.name === 'string' && status.name !== '' ? status.name : t('runtime.nameUnknown');
|
|
2417
|
+
body = h('div', null,
|
|
2418
|
+
h('div', { className: 'dshc-runtimeName', title: name }, name),
|
|
2419
|
+
h('div', { className: 'dshc-runtimeMeta' },
|
|
2420
|
+
h('span', { className: 'dshc-runtimeMetaKey' }, t('runtime.target')),
|
|
2421
|
+
h('span', { className: 'dshc-runtimeMetaValue' }, typeof status.serverUrl === 'string' ? status.serverUrl : '')),
|
|
2422
|
+
h('div', { className: 'dshc-runtimeActions' },
|
|
2423
|
+
h('div', { className: 'dshc-switchLabel' },
|
|
2424
|
+
h('button', {
|
|
2425
|
+
type: 'button',
|
|
2426
|
+
className: 'dshc-switch',
|
|
2427
|
+
role: 'switch',
|
|
2428
|
+
'aria-checked': status.enabled === true ? 'true' : 'false',
|
|
2429
|
+
'aria-label': t('runtime.enable'),
|
|
2430
|
+
disabled: busy,
|
|
2431
|
+
onClick: () => { void toggle(status.enabled !== true); },
|
|
2432
|
+
}, h('span', { className: 'dshc-switchThumb' })),
|
|
2433
|
+
h('span', null, t('runtime.enable'))),
|
|
2434
|
+
h('button', {
|
|
2435
|
+
type: 'button',
|
|
2436
|
+
className: 'dshc-textBtn',
|
|
2437
|
+
disabled: busy,
|
|
2438
|
+
onClick: () => setConfirming(true),
|
|
2439
|
+
}, t('runtime.disconnect'))),
|
|
2440
|
+
status.lastError
|
|
2441
|
+
? h('p', { className: 'dshc-formError', role: 'status' },
|
|
2442
|
+
t('runtime.lastError', { message: String(status.lastError) }))
|
|
2443
|
+
: null);
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
return h('div', { className: 'dshc-card2' },
|
|
2447
|
+
h('div', { className: 'dshc-card2Head' },
|
|
2448
|
+
h('span', { className: 'dshc-card2Title' }, t('runtime.title')),
|
|
2449
|
+
configured
|
|
2450
|
+
? h('span', { className: 'dshc-status', 'data-state': phase },
|
|
2451
|
+
h('span', { className: 'dshc-dot', 'data-state': phase, 'aria-hidden': 'true' }),
|
|
2452
|
+
t(`runtime.status.${phase}`))
|
|
2453
|
+
: null),
|
|
2454
|
+
body,
|
|
2455
|
+
failure !== null ? h('p', { className: 'dshc-formError', role: 'alert' }, failure) : null,
|
|
2456
|
+
confirming
|
|
2457
|
+
? h(RuntimeDisconnectDialog, {
|
|
2458
|
+
runtime: status,
|
|
2459
|
+
onClose: () => setConfirming(false),
|
|
2460
|
+
onDone: (next) => {
|
|
2461
|
+
setConfirming(false);
|
|
2462
|
+
props.onStatus(next, t('runtime.disconnected.notice'));
|
|
2463
|
+
},
|
|
2464
|
+
})
|
|
2465
|
+
: null);
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
/**
|
|
2469
|
+
* Confirm + perform §10.5 disconnect for the local runtime.
|
|
2470
|
+
* @param {object} props - `runtime`, `onClose`, `onDone(status)`.
|
|
2471
|
+
* @returns {object} dialog layer.
|
|
2472
|
+
*/
|
|
2473
|
+
function RuntimeDisconnectDialog(props) {
|
|
2474
|
+
const [busy, setBusy] = React.useState(false);
|
|
2475
|
+
const [failure, setFailure] = React.useState(null);
|
|
2476
|
+
const scope = React.useRef(null);
|
|
2477
|
+
if (scope.current === null) scope.current = createScope();
|
|
2478
|
+
React.useEffect(() => () => scope.current.abortAll(), []);
|
|
2479
|
+
|
|
2480
|
+
const confirm = async () => {
|
|
2481
|
+
if (busy) return;
|
|
2482
|
+
setBusy(true);
|
|
2483
|
+
setFailure(null);
|
|
2484
|
+
try {
|
|
2485
|
+
const next = await scope.current.run((signal) => api.runtimeDisconnect(signal));
|
|
2486
|
+
props.onDone(next && typeof next === 'object' ? next : { configured: false });
|
|
2487
|
+
} catch (error) {
|
|
2488
|
+
if (error && error.name === 'AbortError') return;
|
|
2489
|
+
setFailure(t('runtime.disconnectFailed', { message: messageOf(error) }));
|
|
2490
|
+
setBusy(false);
|
|
2491
|
+
}
|
|
2492
|
+
};
|
|
2493
|
+
|
|
2494
|
+
const server = props.runtime && typeof props.runtime.serverUrl === 'string' ? props.runtime.serverUrl : '';
|
|
2495
|
+
return h(Dialog, {
|
|
2496
|
+
title: t('runtime.disconnectTitle'),
|
|
2497
|
+
size: 'sm',
|
|
2498
|
+
onClose: () => { if (!busy) props.onClose(); },
|
|
2499
|
+
footer: [
|
|
2500
|
+
h('button', {
|
|
2501
|
+
key: 'cancel',
|
|
2502
|
+
type: 'button',
|
|
2503
|
+
className: 'dshc-ghost',
|
|
2504
|
+
disabled: busy,
|
|
2505
|
+
onClick: props.onClose,
|
|
2506
|
+
}, t('cancel')),
|
|
2507
|
+
h('button', {
|
|
2508
|
+
key: 'disconnect',
|
|
2509
|
+
type: 'button',
|
|
2510
|
+
className: 'dshc-primary',
|
|
2511
|
+
disabled: busy,
|
|
2512
|
+
onClick: () => { void confirm(); },
|
|
2513
|
+
}, t('runtime.disconnect')),
|
|
2514
|
+
],
|
|
2515
|
+
},
|
|
2516
|
+
h('p', { className: 'dshc-dialogBody' }, t('runtime.disconnectBody', { server })),
|
|
2517
|
+
failure !== null ? h('p', { className: 'dshc-formError', role: 'alert' }, failure) : null);
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
/**
|
|
2521
|
+
* The page body: the 本机运行时 card, the 已连接的电脑 list, and their states.
|
|
2522
|
+
* @returns {object} the 电脑 page.
|
|
2523
|
+
*/
|
|
2524
|
+
function ComputersBoard() {
|
|
2525
|
+
useStyles();
|
|
2526
|
+
useLocaleRevision();
|
|
2527
|
+
const [view, setView] = React.useState({ phase: 'loading', data: null });
|
|
2528
|
+
const [runtime, setRuntime] = React.useState({ phase: 'idle', data: null });
|
|
2529
|
+
const [dialog, setDialog] = React.useState(null);
|
|
2530
|
+
const [notice, setNotice] = React.useState(null);
|
|
2531
|
+
const alive = React.useRef(true);
|
|
2532
|
+
const scope = React.useRef(null);
|
|
2533
|
+
const noticeTimer = React.useRef(null);
|
|
2534
|
+
const runtimeRequested = React.useRef(false);
|
|
2535
|
+
if (scope.current === null) scope.current = createScope();
|
|
2536
|
+
|
|
2537
|
+
const loadRuntime = React.useCallback(async () => {
|
|
2538
|
+
try {
|
|
2539
|
+
const data = await scope.current.run((signal) => api.readRuntime(signal));
|
|
2540
|
+
if (!alive.current) return;
|
|
2541
|
+
setRuntime({ phase: 'ready', data: data && typeof data === 'object' ? data : null });
|
|
2542
|
+
} catch (error) {
|
|
2543
|
+
if (!alive.current) return;
|
|
2544
|
+
if (error && error.name === 'AbortError') return;
|
|
2545
|
+
setRuntime((current) => ({ phase: current.data === null ? 'error' : 'ready', data: current.data, error }));
|
|
2546
|
+
}
|
|
2547
|
+
}, []);
|
|
2548
|
+
|
|
2549
|
+
const load = React.useCallback(async (options) => {
|
|
2550
|
+
const initial = options !== undefined && options !== null && options.initial === true;
|
|
2551
|
+
if (initial) setView((current) => ({ phase: current.data === null ? 'loading' : 'ready', data: current.data }));
|
|
2552
|
+
try {
|
|
2553
|
+
const data = await scope.current.run((signal) => api.readState(signal));
|
|
2554
|
+
if (!alive.current) return;
|
|
2555
|
+
const next = data && typeof data === 'object' ? data : { computers: [] };
|
|
2556
|
+
setView({ phase: 'ready', data: next });
|
|
2557
|
+
/* The local runtime card exists only where §10.5 routes do (§10.1). */
|
|
2558
|
+
if (roleOf(next) !== 'hub' && !runtimeRequested.current) {
|
|
2559
|
+
runtimeRequested.current = true;
|
|
2560
|
+
void loadRuntime();
|
|
2561
|
+
}
|
|
2562
|
+
} catch (error) {
|
|
2563
|
+
if (!alive.current) return;
|
|
2564
|
+
if (error && error.name === 'AbortError') return;
|
|
2565
|
+
setView((current) => ({ phase: current.data === null ? 'error' : 'ready', data: current.data, error }));
|
|
2566
|
+
}
|
|
2567
|
+
}, [loadRuntime]);
|
|
2568
|
+
|
|
2569
|
+
const showNotice = React.useCallback((kind, text) => {
|
|
2570
|
+
setNotice({ kind, text });
|
|
2571
|
+
if (noticeTimer.current !== null) clearTimeout(noticeTimer.current);
|
|
2572
|
+
noticeTimer.current = setTimeout(() => {
|
|
2573
|
+
noticeTimer.current = null;
|
|
2574
|
+
if (alive.current) setNotice(null);
|
|
2575
|
+
}, NOTICE_MS);
|
|
2576
|
+
}, []);
|
|
2577
|
+
|
|
2578
|
+
React.useEffect(() => {
|
|
2579
|
+
alive.current = true;
|
|
2580
|
+
void load({ initial: true });
|
|
2581
|
+
const poll = setInterval(() => {
|
|
2582
|
+
if (!alive.current) return;
|
|
2583
|
+
void load({});
|
|
2584
|
+
if (runtimeRequested.current) void loadRuntime();
|
|
2585
|
+
}, STATE_POLL_MS);
|
|
2586
|
+
return () => {
|
|
2587
|
+
alive.current = false;
|
|
2588
|
+
clearInterval(poll);
|
|
2589
|
+
if (noticeTimer.current !== null) {
|
|
2590
|
+
clearTimeout(noticeTimer.current);
|
|
2591
|
+
noticeTimer.current = null;
|
|
2592
|
+
}
|
|
2593
|
+
scope.current.abortAll();
|
|
2594
|
+
};
|
|
2595
|
+
}, [load, loadRuntime]);
|
|
2596
|
+
|
|
2597
|
+
const handleConnected = React.useCallback((computer) => {
|
|
2598
|
+
setDialog(null);
|
|
2599
|
+
const name = computer && typeof computer.name === 'string' ? computer.name : '';
|
|
2600
|
+
showNotice('success', name ? t('connect.connected.notice', { name }) : t('connect.connected.noticeAnonymous'));
|
|
2601
|
+
void load({});
|
|
2602
|
+
}, [load, showNotice]);
|
|
2603
|
+
|
|
2604
|
+
const handleRenamed = React.useCallback((name) => {
|
|
2605
|
+
setDialog(null);
|
|
2606
|
+
showNotice('success', t('renamed.notice', { name }));
|
|
2607
|
+
void load({});
|
|
2608
|
+
}, [load, showNotice]);
|
|
2609
|
+
|
|
2610
|
+
const handleDeleted = React.useCallback((name) => {
|
|
2611
|
+
setDialog(null);
|
|
2612
|
+
showNotice('success', t('deleted.notice', { name }));
|
|
2613
|
+
void load({});
|
|
2614
|
+
}, [load, showNotice]);
|
|
2615
|
+
|
|
2616
|
+
const handleRuntimeStatus = React.useCallback((status, message) => {
|
|
2617
|
+
if (status && typeof status === 'object') setRuntime({ phase: 'ready', data: status });
|
|
2618
|
+
else void loadRuntime();
|
|
2619
|
+
if (typeof message === 'string' && message !== '') showNotice('success', message);
|
|
2620
|
+
}, [loadRuntime, showNotice]);
|
|
2621
|
+
|
|
2622
|
+
const data = view.data;
|
|
2623
|
+
const role = data === null ? null : roleOf(data);
|
|
2624
|
+
const computers = data && Array.isArray(data.computers) ? data.computers : null;
|
|
2625
|
+
const showRuntime = role !== null && role !== 'hub';
|
|
2626
|
+
const showComputers = role !== 'runtime';
|
|
2627
|
+
const canConnect = role !== 'runtime';
|
|
2628
|
+
|
|
2629
|
+
let body;
|
|
2630
|
+
if (view.phase === 'loading' && computers === null) body = h(Skeleton);
|
|
2631
|
+
else if (view.phase === 'error') {
|
|
2632
|
+
body = h('div', { className: 'dshc-errorCard', role: 'alert' },
|
|
2633
|
+
h('span', { className: 'dshc-errorText' },
|
|
2634
|
+
t('error.loadDetail', { title: t('error.load'), message: view.error ? messageOf(view.error) : '' })),
|
|
2635
|
+
h('button', {
|
|
2636
|
+
type: 'button',
|
|
2637
|
+
className: 'dshc-ghost',
|
|
2638
|
+
onClick: () => { void load({ initial: true }); },
|
|
2639
|
+
}, t('retry')));
|
|
2640
|
+
} else if (computers === null || computers.length === 0) body = h(EmptyState);
|
|
2641
|
+
else {
|
|
2642
|
+
body = h('div', { className: 'dshc-list' }, computers.map((computer) => h(ComputerRow, {
|
|
2643
|
+
key: computer.id || computer.name,
|
|
2644
|
+
computer,
|
|
2645
|
+
onRename: (target) => setDialog({ kind: 'rename', computer: target }),
|
|
2646
|
+
onDelete: (target) => setDialog({ kind: 'delete', computer: target }),
|
|
2647
|
+
})));
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
let layer = null;
|
|
2651
|
+
if (dialog !== null && dialog.kind === 'connect') {
|
|
2652
|
+
layer = h(ConnectModal, {
|
|
2653
|
+
serverUrl: data && typeof data.serverUrl === 'string' ? data.serverUrl : '',
|
|
2654
|
+
onClose: () => setDialog(null),
|
|
2655
|
+
onConnected: handleConnected,
|
|
2656
|
+
});
|
|
2657
|
+
} else if (dialog !== null && dialog.kind === 'rename' && dialog.computer) {
|
|
2658
|
+
layer = h(RenameDialog, {
|
|
2659
|
+
computer: dialog.computer,
|
|
2660
|
+
onClose: () => setDialog(null),
|
|
2661
|
+
onRenamed: handleRenamed,
|
|
2662
|
+
});
|
|
2663
|
+
} else if (dialog !== null && dialog.kind === 'delete' && dialog.computer) {
|
|
2664
|
+
layer = h(DeleteDialog, {
|
|
2665
|
+
computer: dialog.computer,
|
|
2666
|
+
onClose: () => setDialog(null),
|
|
2667
|
+
onDeleted: handleDeleted,
|
|
2668
|
+
});
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
return h('section', { className: 'dshc-root', 'aria-label': t('nav') },
|
|
2672
|
+
h('div', { className: 'dshc-head' },
|
|
2673
|
+
h('div', { className: 'dshc-headMain' },
|
|
2674
|
+
h('h2', { className: 'dshc-title' }, t('title')),
|
|
2675
|
+
h('p', { className: 'dshc-desc' }, t('description'))),
|
|
2676
|
+
canConnect
|
|
2677
|
+
? h('button', {
|
|
2678
|
+
type: 'button',
|
|
2679
|
+
className: 'dshc-primary',
|
|
2680
|
+
onClick: () => setDialog({ kind: 'connect' }),
|
|
2681
|
+
}, h(IconPlus, { size: 15 }), t('connect.action'))
|
|
2682
|
+
: null),
|
|
2683
|
+
notice !== null ? h(Notice, { kind: notice.kind, text: notice.text }) : null,
|
|
2684
|
+
showRuntime
|
|
2685
|
+
? h(RuntimeCard, {
|
|
2686
|
+
view: runtime,
|
|
2687
|
+
onStatus: handleRuntimeStatus,
|
|
2688
|
+
onRetry: () => { void loadRuntime(); },
|
|
2689
|
+
})
|
|
2690
|
+
: null,
|
|
2691
|
+
showComputers
|
|
2692
|
+
? h('div', null,
|
|
2693
|
+
h('div', { className: 'dshc-groupTitle' }, t('computers.group')),
|
|
2694
|
+
body)
|
|
2695
|
+
: null,
|
|
2696
|
+
layer);
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
// #endregion
|
|
2700
|
+
|
|
2701
|
+
// #region composer target chip
|
|
2702
|
+
|
|
2703
|
+
/**
|
|
2704
|
+
* Composer target-computer chip (slot `conversation.input.left`, session scope).
|
|
2705
|
+
*
|
|
2706
|
+
* Shows which machine this session is aimed at and lets the user switch it:
|
|
2707
|
+
* 云端电脑 (the server itself) or one of the connected computers. The menu is
|
|
2708
|
+
* portaled to <body> because the composer clips its own overflow.
|
|
2709
|
+
* @param {object} props - `sessionId` plus whatever the composer passes.
|
|
2710
|
+
* @returns {object|null} chip element, or null when the chip does not apply.
|
|
2711
|
+
*/
|
|
2712
|
+
function TargetChip(props) {
|
|
2713
|
+
useStyles();
|
|
2714
|
+
useLocaleRevision();
|
|
2715
|
+
const sessionId = props && typeof props.sessionId === 'string' ? props.sessionId : '';
|
|
2716
|
+
const [view, setView] = React.useState({ phase: 'loading', role: null, computers: [] });
|
|
2717
|
+
const [selection, setSelection] = React.useState({ computerId: null, computer: null });
|
|
2718
|
+
const [open, setOpen] = React.useState(false);
|
|
2719
|
+
const [anchor, setAnchor] = React.useState(null);
|
|
2720
|
+
const [failure, setFailure] = React.useState(null);
|
|
2721
|
+
const alive = React.useRef(true);
|
|
2722
|
+
const scope = React.useRef(null);
|
|
2723
|
+
const chipRef = React.useRef(null);
|
|
2724
|
+
const menuRef = React.useRef(null);
|
|
2725
|
+
const pending = React.useRef(false);
|
|
2726
|
+
const initialFocus = React.useRef(0);
|
|
2727
|
+
const failureTimer = React.useRef(null);
|
|
2728
|
+
if (scope.current === null) scope.current = createScope();
|
|
2729
|
+
|
|
2730
|
+
const applySelection = React.useCallback((result) => {
|
|
2731
|
+
setSelection({
|
|
2732
|
+
computerId: result && typeof result.computerId === 'string' && result.computerId !== '' ? result.computerId : null,
|
|
2733
|
+
computer: result && result.computer && typeof result.computer === 'object' ? result.computer : null,
|
|
2734
|
+
});
|
|
2735
|
+
}, []);
|
|
2736
|
+
|
|
2737
|
+
/* One /state + /selection round per tick; a state failure hides the chip
|
|
2738
|
+
* (there is no honest target to display). */
|
|
2739
|
+
const load = React.useCallback(async () => {
|
|
2740
|
+
if (sessionId === '') return;
|
|
2741
|
+
let state;
|
|
2742
|
+
try {
|
|
2743
|
+
state = await scope.current.run((signal) => api.readState(signal));
|
|
2744
|
+
} catch (error) {
|
|
2745
|
+
if (!alive.current || (error && error.name === 'AbortError')) return;
|
|
2746
|
+
setView({ phase: 'hidden', role: null, computers: [] });
|
|
2747
|
+
return;
|
|
2748
|
+
}
|
|
2749
|
+
if (!alive.current) return;
|
|
2750
|
+
const role = roleOf(state);
|
|
2751
|
+
if (role === 'runtime') {
|
|
2752
|
+
setView({ phase: 'hidden', role, computers: [] });
|
|
2753
|
+
return;
|
|
2754
|
+
}
|
|
2755
|
+
setView({
|
|
2756
|
+
phase: 'ready',
|
|
2757
|
+
role,
|
|
2758
|
+
computers: state && Array.isArray(state.computers) ? state.computers : [],
|
|
2759
|
+
});
|
|
2760
|
+
try {
|
|
2761
|
+
const current = await scope.current.run((signal) => api.readSelection(sessionId, signal));
|
|
2762
|
+
if (!alive.current || pending.current) return;
|
|
2763
|
+
applySelection(current);
|
|
2764
|
+
} catch (error) {
|
|
2765
|
+
/* A selection read failure keeps the last known target on screen. */
|
|
2766
|
+
}
|
|
2767
|
+
}, [applySelection, sessionId]);
|
|
2768
|
+
|
|
2769
|
+
React.useEffect(() => {
|
|
2770
|
+
alive.current = true;
|
|
2771
|
+
if (sessionId === '') {
|
|
2772
|
+
setView({ phase: 'hidden', role: null, computers: [] });
|
|
2773
|
+
return () => { alive.current = false; };
|
|
2774
|
+
}
|
|
2775
|
+
void load();
|
|
2776
|
+
const poll = setInterval(() => {
|
|
2777
|
+
if (alive.current) void load();
|
|
2778
|
+
}, STATE_POLL_MS);
|
|
2779
|
+
return () => {
|
|
2780
|
+
alive.current = false;
|
|
2781
|
+
clearInterval(poll);
|
|
2782
|
+
if (failureTimer.current !== null) {
|
|
2783
|
+
clearTimeout(failureTimer.current);
|
|
2784
|
+
failureTimer.current = null;
|
|
2785
|
+
}
|
|
2786
|
+
if (scope.current) scope.current.abortAll();
|
|
2787
|
+
};
|
|
2788
|
+
}, [load, sessionId]);
|
|
2789
|
+
|
|
2790
|
+
const computers = view.computers;
|
|
2791
|
+
const selectedId = selection.computerId;
|
|
2792
|
+
const selectedComputer = selection.computer;
|
|
2793
|
+
const currentLabel = selectedId === null || selectedId === ''
|
|
2794
|
+
? t('target.cloud')
|
|
2795
|
+
: (selectedComputer && typeof selectedComputer.name === 'string' && selectedComputer.name !== ''
|
|
2796
|
+
? selectedComputer.name
|
|
2797
|
+
: t('target.cloud'));
|
|
2798
|
+
|
|
2799
|
+
const menuRows = [
|
|
2800
|
+
{
|
|
2801
|
+
key: 'cloud',
|
|
2802
|
+
id: null,
|
|
2803
|
+
label: t('target.cloud'),
|
|
2804
|
+
hint: t('target.cloudHint'),
|
|
2805
|
+
computer: null,
|
|
2806
|
+
offline: false,
|
|
2807
|
+
},
|
|
2808
|
+
].concat(computers.map((computer) => ({
|
|
2809
|
+
key: String(computer.id || computer.name || ''),
|
|
2810
|
+
id: computer.id,
|
|
2811
|
+
label: typeof computer.name === 'string' && computer.name !== ''
|
|
2812
|
+
? computer.name
|
|
2813
|
+
: (typeof computer.hostname === 'string' && computer.hostname !== '' ? computer.hostname : String(computer.id || '')),
|
|
2814
|
+
hint: typeof computer.platformLabel === 'string' ? computer.platformLabel : '',
|
|
2815
|
+
computer,
|
|
2816
|
+
offline: computer.online !== true,
|
|
2817
|
+
})));
|
|
2818
|
+
|
|
2819
|
+
const menuItems = () => {
|
|
2820
|
+
if (menuRef.current === null || typeof menuRef.current.querySelectorAll !== 'function') return [];
|
|
2821
|
+
try {
|
|
2822
|
+
return Array.prototype.slice.call(menuRef.current.querySelectorAll('[role="menuitemradio"]'));
|
|
2823
|
+
} catch (error) {
|
|
2824
|
+
return [];
|
|
2825
|
+
}
|
|
2826
|
+
};
|
|
2827
|
+
|
|
2828
|
+
const focusRow = (index) => {
|
|
2829
|
+
const items = menuItems();
|
|
2830
|
+
if (items.length === 0) return;
|
|
2831
|
+
const next = ((index % items.length) + items.length) % items.length;
|
|
2832
|
+
try {
|
|
2833
|
+
items[next].focus();
|
|
2834
|
+
} catch (error) {
|
|
2835
|
+
/* ignore */
|
|
2836
|
+
}
|
|
2837
|
+
};
|
|
2838
|
+
|
|
2839
|
+
const openMenu = (preferLast) => {
|
|
2840
|
+
const checked = menuRows.findIndex((row) => (row.id === null ? selectedId === null : row.id === selectedId));
|
|
2841
|
+
initialFocus.current = preferLast ? menuRows.length - 1 : (checked >= 0 ? checked : 0);
|
|
2842
|
+
setFailure(null);
|
|
2843
|
+
setOpen(true);
|
|
2844
|
+
};
|
|
2845
|
+
|
|
2846
|
+
const closeMenu = (restoreFocus) => {
|
|
2847
|
+
setOpen(false);
|
|
2848
|
+
if (!restoreFocus) return;
|
|
2849
|
+
try {
|
|
2850
|
+
if (chipRef.current && typeof chipRef.current.focus === 'function') chipRef.current.focus();
|
|
2851
|
+
} catch (error) {
|
|
2852
|
+
/* ignore */
|
|
2853
|
+
}
|
|
2854
|
+
};
|
|
2855
|
+
|
|
2856
|
+
/* Anchor above the chip, focus the checked row, and own the outside
|
|
2857
|
+
* dismissals while open. */
|
|
2858
|
+
React.useEffect(() => {
|
|
2859
|
+
if (!open || typeof document === 'undefined') return undefined;
|
|
2860
|
+
const doc = document;
|
|
2861
|
+
const position = () => {
|
|
2862
|
+
const chip = chipRef.current;
|
|
2863
|
+
if (!chip || typeof chip.getBoundingClientRect !== 'function') return;
|
|
2864
|
+
const rect = chip.getBoundingClientRect();
|
|
2865
|
+
const viewportWidth = typeof window !== 'undefined' && window.innerWidth ? window.innerWidth : 1024;
|
|
2866
|
+
const viewportHeight = typeof window !== 'undefined' && window.innerHeight ? window.innerHeight : 768;
|
|
2867
|
+
const left = Math.max(8, Math.min(rect.left, viewportWidth - 248 - 8));
|
|
2868
|
+
const bottom = Math.max(8, viewportHeight - rect.top + 6);
|
|
2869
|
+
setAnchor({
|
|
2870
|
+
left,
|
|
2871
|
+
bottom,
|
|
2872
|
+
maxHeight: Math.max(160, Math.min(340, viewportHeight - bottom - 12)),
|
|
2873
|
+
});
|
|
2874
|
+
};
|
|
2875
|
+
position();
|
|
2876
|
+
focusRow(initialFocus.current);
|
|
2877
|
+
const onPointerDown = (event) => {
|
|
2878
|
+
const menu = menuRef.current;
|
|
2879
|
+
const chip = chipRef.current;
|
|
2880
|
+
if (menu && menu.contains && menu.contains(event.target)) return;
|
|
2881
|
+
if (chip && chip.contains && chip.contains(event.target)) return;
|
|
2882
|
+
setOpen(false);
|
|
2883
|
+
};
|
|
2884
|
+
doc.addEventListener('mousedown', onPointerDown, true);
|
|
2885
|
+
doc.addEventListener('touchstart', onPointerDown, true);
|
|
2886
|
+
doc.addEventListener('resize', position, true);
|
|
2887
|
+
doc.addEventListener('scroll', position, true);
|
|
2888
|
+
return () => {
|
|
2889
|
+
doc.removeEventListener('mousedown', onPointerDown, true);
|
|
2890
|
+
doc.removeEventListener('touchstart', onPointerDown, true);
|
|
2891
|
+
doc.removeEventListener('resize', position, true);
|
|
2892
|
+
doc.removeEventListener('scroll', position, true);
|
|
2893
|
+
};
|
|
2894
|
+
}, [open]);
|
|
2895
|
+
|
|
2896
|
+
const onMenuKeyDown = (event) => {
|
|
2897
|
+
if (event.key === 'Escape') {
|
|
2898
|
+
event.preventDefault();
|
|
2899
|
+
event.stopPropagation();
|
|
2900
|
+
closeMenu(true);
|
|
2901
|
+
return;
|
|
2902
|
+
}
|
|
2903
|
+
const items = menuItems();
|
|
2904
|
+
if (event.key === 'Tab') {
|
|
2905
|
+
event.preventDefault();
|
|
2906
|
+
event.stopPropagation();
|
|
2907
|
+
if (items.length === 0) return;
|
|
2908
|
+
const active = typeof document !== 'undefined' ? document.activeElement : null;
|
|
2909
|
+
const index = items.indexOf(active);
|
|
2910
|
+
const next = event.shiftKey
|
|
2911
|
+
? (index <= 0 ? items.length - 1 : index - 1)
|
|
2912
|
+
: (index < 0 || index === items.length - 1 ? 0 : index + 1);
|
|
2913
|
+
try {
|
|
2914
|
+
items[next].focus();
|
|
2915
|
+
} catch (error) {
|
|
2916
|
+
/* ignore */
|
|
2917
|
+
}
|
|
2918
|
+
return;
|
|
2919
|
+
}
|
|
2920
|
+
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Home' && event.key !== 'End') return;
|
|
2921
|
+
event.preventDefault();
|
|
2922
|
+
if (items.length === 0) return;
|
|
2923
|
+
const active = typeof document !== 'undefined' ? document.activeElement : null;
|
|
2924
|
+
const index = items.indexOf(active);
|
|
2925
|
+
let next = index;
|
|
2926
|
+
if (event.key === 'Home') next = 0;
|
|
2927
|
+
else if (event.key === 'End') next = items.length - 1;
|
|
2928
|
+
else if (event.key === 'ArrowDown') next = index < 0 ? 0 : (index + 1) % items.length;
|
|
2929
|
+
else next = index <= 0 ? items.length - 1 : index - 1;
|
|
2930
|
+
try {
|
|
2931
|
+
items[next].focus();
|
|
2932
|
+
} catch (error) {
|
|
2933
|
+
/* ignore */
|
|
2934
|
+
}
|
|
2935
|
+
};
|
|
2936
|
+
|
|
2937
|
+
/* Optimistic switch: the chip moves first, then reverts on failure. */
|
|
2938
|
+
const choose = async (row) => {
|
|
2939
|
+
if (view.phase !== 'ready' || sessionId === '' || pending.current) return;
|
|
2940
|
+
const previous = selection;
|
|
2941
|
+
const nextId = row.id;
|
|
2942
|
+
const nextComputer = row.computer;
|
|
2943
|
+
setOpen(false);
|
|
2944
|
+
setFailure(null);
|
|
2945
|
+
setSelection({ computerId: nextId, computer: nextComputer });
|
|
2946
|
+
pending.current = true;
|
|
2947
|
+
try {
|
|
2948
|
+
const result = await scope.current.run((signal) => api.selectTarget(sessionId, nextId, signal));
|
|
2949
|
+
if (!alive.current) return;
|
|
2950
|
+
applySelection(result && typeof result === 'object' ? result : { computerId: nextId, computer: nextComputer });
|
|
2951
|
+
} catch (error) {
|
|
2952
|
+
if (!alive.current) return;
|
|
2953
|
+
if (error && error.name === 'AbortError') return;
|
|
2954
|
+
setSelection(previous);
|
|
2955
|
+
setFailure(t('target.failed', { message: messageOf(error) }));
|
|
2956
|
+
if (failureTimer.current !== null) clearTimeout(failureTimer.current);
|
|
2957
|
+
failureTimer.current = setTimeout(() => {
|
|
2958
|
+
failureTimer.current = null;
|
|
2959
|
+
if (alive.current) setFailure(null);
|
|
2960
|
+
}, 5000);
|
|
2961
|
+
} finally {
|
|
2962
|
+
pending.current = false;
|
|
2963
|
+
}
|
|
2964
|
+
};
|
|
2965
|
+
|
|
2966
|
+
if (view.phase !== 'ready') return null;
|
|
2967
|
+
|
|
2968
|
+
const chip = h('button', {
|
|
2969
|
+
ref: chipRef,
|
|
2970
|
+
type: 'button',
|
|
2971
|
+
className: 'dshc-target',
|
|
2972
|
+
'data-open': open ? 'true' : 'false',
|
|
2973
|
+
'aria-haspopup': 'menu',
|
|
2974
|
+
'aria-expanded': open ? 'true' : 'false',
|
|
2975
|
+
'aria-label': t('target.chipLabel', { name: currentLabel }),
|
|
2976
|
+
title: currentLabel,
|
|
2977
|
+
onClick: () => {
|
|
2978
|
+
if (open) closeMenu(false);
|
|
2979
|
+
else openMenu(false);
|
|
2980
|
+
},
|
|
2981
|
+
onKeyDown: (event) => {
|
|
2982
|
+
if (open) return;
|
|
2983
|
+
if (event.key === 'ArrowDown') {
|
|
2984
|
+
event.preventDefault();
|
|
2985
|
+
openMenu(false);
|
|
2986
|
+
} else if (event.key === 'ArrowUp') {
|
|
2987
|
+
event.preventDefault();
|
|
2988
|
+
openMenu(true);
|
|
2989
|
+
}
|
|
2990
|
+
},
|
|
2991
|
+
},
|
|
2992
|
+
h(IconMonitor, { size: 14 }),
|
|
2993
|
+
h('span', { className: 'dshc-targetLabel' }, currentLabel),
|
|
2994
|
+
h(IconChevron, { size: 12, className: 'dshc-targetChevron' }));
|
|
2995
|
+
|
|
2996
|
+
const menu = open && anchor !== null
|
|
2997
|
+
? renderLayer(h('div', {
|
|
2998
|
+
ref: menuRef,
|
|
2999
|
+
className: 'dshc-menu',
|
|
3000
|
+
role: 'menu',
|
|
3001
|
+
'aria-label': t('target.menuLabel'),
|
|
3002
|
+
style: {
|
|
3003
|
+
left: `${anchor.left}px`,
|
|
3004
|
+
bottom: `${anchor.bottom}px`,
|
|
3005
|
+
maxHeight: `${anchor.maxHeight}px`,
|
|
3006
|
+
},
|
|
3007
|
+
onKeyDown: onMenuKeyDown,
|
|
3008
|
+
/* React portals bubble through the REACT tree, so a row click would
|
|
3009
|
+
* reach the composer's own handlers without this stop (the shipped
|
|
3010
|
+
* Menu primitive does the same). */
|
|
3011
|
+
onClick: (event) => event.stopPropagation(),
|
|
3012
|
+
onMouseDown: (event) => event.stopPropagation(),
|
|
3013
|
+
},
|
|
3014
|
+
menuRows.map((row) => {
|
|
3015
|
+
const checked = row.id === null ? selectedId === null : row.id === selectedId;
|
|
3016
|
+
return h('button', {
|
|
3017
|
+
key: row.key,
|
|
3018
|
+
type: 'button',
|
|
3019
|
+
role: 'menuitemradio',
|
|
3020
|
+
'aria-checked': checked ? 'true' : 'false',
|
|
3021
|
+
className: 'dshc-menuItem',
|
|
3022
|
+
'data-checked': checked ? 'true' : 'false',
|
|
3023
|
+
'data-offline': row.offline ? 'true' : 'false',
|
|
3024
|
+
tabIndex: -1,
|
|
3025
|
+
onClick: () => { void choose(row); },
|
|
3026
|
+
},
|
|
3027
|
+
row.id === null ? h(IconCloud, { size: 14 }) : h(IconMonitor, { size: 14 }),
|
|
3028
|
+
h('span', { className: 'dshc-menuName' }, row.label),
|
|
3029
|
+
row.hint ? h('span', { className: 'dshc-menuHint' }, row.hint) : null,
|
|
3030
|
+
row.id === null ? null : h('span', { className: 'dshc-menuDot', 'data-offline': row.offline ? 'true' : 'false' }),
|
|
3031
|
+
row.offline ? h('span', { className: 'dshc-menuHint' }, t('target.offline')) : null,
|
|
3032
|
+
checked ? h(IconCheck, { size: 13, className: 'dshc-menuCheck' }) : null);
|
|
3033
|
+
}),
|
|
3034
|
+
h('div', { className: 'dshc-menuFooter' }, t('target.manage'))))
|
|
3035
|
+
: null;
|
|
3036
|
+
|
|
3037
|
+
return h('span', { className: 'dshc-targetWrap' },
|
|
3038
|
+
chip,
|
|
3039
|
+
failure !== null
|
|
3040
|
+
? h('span', { className: 'dshc-targetError', role: 'status', title: failure }, failure)
|
|
3041
|
+
: null,
|
|
3042
|
+
menu);
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
// #endregion
|
|
3046
|
+
|
|
3047
|
+
// #region error boundary
|
|
3048
|
+
|
|
3049
|
+
/**
|
|
3050
|
+
* Keep a render failure from blanking the whole settings slot.
|
|
3051
|
+
* @returns {object} boundary component (passthrough when React.Component is absent).
|
|
3052
|
+
*/
|
|
3053
|
+
const PanelBoundary = (() => {
|
|
3054
|
+
const Component = React.Component;
|
|
3055
|
+
if (typeof Component !== 'function') {
|
|
3056
|
+
return function PanelBoundaryPassthrough(props) {
|
|
3057
|
+
return props.children;
|
|
3058
|
+
};
|
|
3059
|
+
}
|
|
3060
|
+
return class PanelBoundary extends Component {
|
|
3061
|
+
constructor(props) {
|
|
3062
|
+
super(props);
|
|
3063
|
+
this.state = { failure: null };
|
|
3064
|
+
this.reset = this.reset.bind(this);
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
static getDerivedStateFromError(failure) {
|
|
3068
|
+
return { failure };
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
componentDidCatch(failure) {
|
|
3072
|
+
try {
|
|
3073
|
+
console.warn('[dsh-computers] panel render failed:', failure);
|
|
3074
|
+
} catch (error) {
|
|
3075
|
+
/* ignore */
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
|
|
3079
|
+
reset() {
|
|
3080
|
+
this.setState({ failure: null });
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
render() {
|
|
3084
|
+
if (this.state.failure !== null && this.state.failure !== undefined) {
|
|
3085
|
+
return h('section', { className: 'dshc-root', 'aria-label': t('nav') },
|
|
3086
|
+
h('div', { className: 'dshc-errorCard', role: 'alert' },
|
|
3087
|
+
h('span', { className: 'dshc-errorText' }, t('render.error')),
|
|
3088
|
+
h('button', { type: 'button', className: 'dshc-ghost', onClick: this.reset }, t('render.reload'))));
|
|
3089
|
+
}
|
|
3090
|
+
return this.props.children;
|
|
3091
|
+
}
|
|
3092
|
+
};
|
|
3093
|
+
})();
|
|
3094
|
+
|
|
3095
|
+
// #endregion
|
|
3096
|
+
|
|
3097
|
+
// #region plugin
|
|
3098
|
+
|
|
3099
|
+
/**
|
|
3100
|
+
* The registered settings page: the board behind a render-error boundary.
|
|
3101
|
+
* @param {object} props - owner props `{ close }`; unused here.
|
|
3102
|
+
* @returns {object} the 电脑 page.
|
|
3103
|
+
*/
|
|
3104
|
+
function ComputersPanel(props) {
|
|
3105
|
+
return h(PanelBoundary, null, h(ComputersBoard, { close: props ? props.close : undefined }));
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
/**
|
|
3109
|
+
* Mount the settings page (PROTOCOL §7).
|
|
3110
|
+
* @param {object} ctx - client root context (services: slots, locale).
|
|
3111
|
+
*/
|
|
3112
|
+
function apply(ctx) {
|
|
3113
|
+
if (ctx && ctx.locale) {
|
|
3114
|
+
localeFace = ctx.locale;
|
|
3115
|
+
const register = () => ctx.locale.register(NS, { zh, en });
|
|
3116
|
+
const runRegister = () => {
|
|
3117
|
+
try {
|
|
3118
|
+
if (typeof ctx.effect === 'function') {
|
|
3119
|
+
ctx.effect(() => register(), 'dsh-computers: dictionaries');
|
|
3120
|
+
return null;
|
|
3121
|
+
}
|
|
3122
|
+
return register();
|
|
3123
|
+
} catch (error) {
|
|
3124
|
+
try {
|
|
3125
|
+
console.warn('[dsh-computers] locale registration failed:', error);
|
|
3126
|
+
} catch (ignored) {
|
|
3127
|
+
/* ignore */
|
|
3128
|
+
}
|
|
3129
|
+
return null;
|
|
3130
|
+
}
|
|
3131
|
+
};
|
|
3132
|
+
const dispose = runRegister();
|
|
3133
|
+
if (typeof dispose === 'function' && typeof ctx.on === 'function') {
|
|
3134
|
+
try {
|
|
3135
|
+
ctx.on('dispose', () => {
|
|
3136
|
+
try {
|
|
3137
|
+
dispose();
|
|
3138
|
+
} catch (error) {
|
|
3139
|
+
/* ignore */
|
|
3140
|
+
}
|
|
3141
|
+
});
|
|
3142
|
+
} catch (error) {
|
|
3143
|
+
/* ignore */
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
try {
|
|
3147
|
+
runtimeT = ctx.locale.bind(NS);
|
|
3148
|
+
} catch (error) {
|
|
3149
|
+
runtimeT = null;
|
|
3150
|
+
}
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
/* Both seats are declared by shell plugins this package does not depend
|
|
3154
|
+
* on, so each registration goes through slots.inject: a shell without the
|
|
3155
|
+
* seat leaves that surface simply absent instead of failing boot. */
|
|
3156
|
+
const mount = (slotName, register) => {
|
|
3157
|
+
try {
|
|
3158
|
+
const stop = ctx.slots.inject(slotName, register);
|
|
3159
|
+
return typeof stop === 'function' ? stop : null;
|
|
3160
|
+
} catch (error) {
|
|
3161
|
+
try {
|
|
3162
|
+
console.warn(`[dsh-computers] ${slotName} registration failed:`, error);
|
|
3163
|
+
} catch (ignored) {
|
|
3164
|
+
/* ignore */
|
|
3165
|
+
}
|
|
3166
|
+
return null;
|
|
3167
|
+
}
|
|
3168
|
+
};
|
|
3169
|
+
|
|
3170
|
+
const mountAll = () => {
|
|
3171
|
+
const stops = [];
|
|
3172
|
+
const settingsStop = mount('settings.section', () => ctx.slots.register({
|
|
3173
|
+
name: 'settings.section',
|
|
3174
|
+
id: SECTION_ID,
|
|
3175
|
+
order: SECTION_ORDER,
|
|
3176
|
+
label: () => t('nav'),
|
|
3177
|
+
}, ComputersPanel));
|
|
3178
|
+
if (settingsStop !== null) stops.push(settingsStop);
|
|
3179
|
+
const targetStop = mount('conversation.input.left', () => ctx.slots.register({
|
|
3180
|
+
name: 'conversation.input.left',
|
|
3181
|
+
id: TARGET_SLOT_ID,
|
|
3182
|
+
order: TARGET_SLOT_ORDER,
|
|
3183
|
+
label: () => t('target.label'),
|
|
3184
|
+
}, TargetChip));
|
|
3185
|
+
if (targetStop !== null) stops.push(targetStop);
|
|
3186
|
+
return () => {
|
|
3187
|
+
for (const stop of stops) {
|
|
3188
|
+
try {
|
|
3189
|
+
stop();
|
|
3190
|
+
} catch (error) {
|
|
3191
|
+
/* ignore */
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
};
|
|
3195
|
+
};
|
|
3196
|
+
|
|
3197
|
+
try {
|
|
3198
|
+
if (typeof ctx.effect === 'function') {
|
|
3199
|
+
ctx.effect(mountAll, 'dsh-computers: slots');
|
|
3200
|
+
} else {
|
|
3201
|
+
mountAll();
|
|
3202
|
+
}
|
|
3203
|
+
} catch (error) {
|
|
3204
|
+
try {
|
|
3205
|
+
console.warn('[dsh-computers] slot registration failed:', error);
|
|
3206
|
+
} catch (ignored) {
|
|
3207
|
+
/* ignore */
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
return {
|
|
3213
|
+
inject: ['slots', 'locale'],
|
|
3214
|
+
apply,
|
|
3215
|
+
};
|
|
3216
|
+
|
|
3217
|
+
// #endregion
|
|
3218
|
+
},
|
|
3219
|
+
});
|