@skillpie/dsh-remote 0.0.0-stage → 1.0.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/lib/client.js ADDED
@@ -0,0 +1,493 @@
1
+ /**
2
+ * dsh-remote — Web client half (module-loader bundle).
3
+ *
4
+ * The sidebar foot gains a compact "远程控制" action button (icon when the
5
+ * rail is collapsed, icon + label when wide). Clicking it opens an
6
+ * application-level dialog (shell.overlay) that carries the owner panel:
7
+ * the 8-digit code, shareable links, relay/direct status, the activity log,
8
+ * and the owner controls (regenerate code, allow/deny visitor control,
9
+ * enable/disable remote access).
10
+ *
11
+ * No sidebar.panellist entry and no `main` view: the dialog is the surface.
12
+ *
13
+ * Host conventions: theme tokens only, locale dictionaries for visible
14
+ * text, React from the module table, no DOM writes outside the React tree,
15
+ * components with hooks rendered through h(Component, props).
16
+ */
17
+ window.__ModuleLoader__.load({
18
+ id: '@skillpie/dsh-remote',
19
+ factory(require) {
20
+ var React = require('react')
21
+ var h = React.createElement
22
+ var NS = '@skillpie/dsh-remote'
23
+ var API = '/dsh-remote/api'
24
+
25
+ var zh = {
26
+ title: '远程控制',
27
+ codeLabel: '远程码',
28
+ relayUrl: '中转链接',
29
+ directUrl: '本机直连',
30
+ copy: '复制',
31
+ copied: '已复制',
32
+ relay: '中转站',
33
+ direct: '直连',
34
+ online: '已连接',
35
+ offline: '未连接',
36
+ disabled: '远程控制已关闭',
37
+ visitors: '在线访客',
38
+ none: '暂无',
39
+ allowControl: '允许访客控制会话(关闭为只读)',
40
+ enabledToggle: '启用远程控制',
41
+ regenerate: '重新生成远程码',
42
+ log: '活动记录',
43
+ loading: '正在读取状态…',
44
+ actionFailed: '操作失败',
45
+ confirmRegenerate: '重新生成后旧链接立即失效,确认?',
46
+ relayFaceHosts: '中转面已注册宿主机',
47
+ offlineHost: '离线',
48
+ visitorAt: '接入于',
49
+ close: '关闭',
50
+ }
51
+ var en = {
52
+ title: 'Remote control',
53
+ codeLabel: 'Remote code',
54
+ relayUrl: 'Relay link',
55
+ directUrl: 'Direct link',
56
+ copy: 'Copy',
57
+ copied: 'Copied',
58
+ relay: 'Relay',
59
+ direct: 'Direct',
60
+ online: 'connected',
61
+ offline: 'not connected',
62
+ disabled: 'Remote control is disabled',
63
+ visitors: 'Visitors online',
64
+ none: 'none',
65
+ allowControl: 'Allow visitors to control sessions (off = read-only)',
66
+ enabledToggle: 'Enable remote control',
67
+ regenerate: 'Regenerate remote code',
68
+ log: 'Activity',
69
+ loading: 'Loading status…',
70
+ actionFailed: 'Action failed',
71
+ confirmRegenerate: 'The old link stops working immediately. Regenerate?',
72
+ relayFaceHosts: 'Relay face registered hosts',
73
+ offlineHost: 'offline',
74
+ visitorAt: 'connected at',
75
+ close: 'Close',
76
+ }
77
+ var pageT = function (key) { return key }
78
+
79
+ /* ── shared dialog open state (footer button ⇄ overlay) ───────────── */
80
+
81
+ var openStore = { open: false, listeners: new Set() }
82
+ function setPanelOpen(value) {
83
+ openStore.open = value
84
+ openStore.listeners.forEach(function (listener) { listener(value) })
85
+ }
86
+ function usePanelOpen() {
87
+ var pair = React.useState(openStore.open)
88
+ var open = pair[0]
89
+ var setLocal = pair[1]
90
+ React.useEffect(function () {
91
+ var listener = function (value) { setLocal(value) }
92
+ openStore.listeners.add(listener)
93
+ return function () { openStore.listeners.delete(listener) }
94
+ }, [])
95
+ return [open, setPanelOpen]
96
+ }
97
+
98
+ /* ── icon ─────────────────────────────────────────────────────────── */
99
+
100
+ // ZCode 同款远程控制图标(lucide MonitorSmartphone,24 网格 stroke 风格)。
101
+ function RemoteIcon(props) {
102
+ var size = props && props.size ? props.size : 16
103
+ return h('svg', {
104
+ viewBox: '0 0 24 24', width: size, height: size, 'aria-hidden': true,
105
+ fill: 'none', stroke: 'currentColor', 'stroke-width': 2,
106
+ 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
107
+ style: { display: 'block', flex: 'none' },
108
+ },
109
+ h('path', { d: 'M18 8V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h8' }),
110
+ h('path', { d: 'M10 19v-3.96 3.15' }),
111
+ h('path', { d: 'M7 19h5' }),
112
+ h('rect', { width: 6, height: 10, x: 16, y: 12, rx: 2 }),
113
+ )
114
+ }
115
+
116
+ /* ── sidebar foot action button ───────────────────────────────────── */
117
+
118
+ function FooterAction(props) {
119
+ var t = pageT
120
+ var wide = !!(props && props.wide)
121
+ var pair = usePanelOpen()
122
+ var hoverState = React.useState(false)
123
+ var hovered = hoverState[0]
124
+ var setHovered = hoverState[1]
125
+ // ZCode 观感:图标与账号行同行、位于其右端。账号行本身是宿主占用的
126
+ // settingsArea(插件无法插入同排元素),这里用零高覆盖层把 28px 图标
127
+ // 按钮精确叠到账号行的右侧空白处(宽模式 trigger 高 42px + 上下 4px
128
+ // 外边距;收起模式 36x36、上 8px)。
129
+ var wrapperStyle = wide
130
+ ? { position: 'relative', height: 0, width: '100%', zIndex: 2, pointerEvents: 'none' }
131
+ : { position: 'relative', height: 0, width: '36px', margin: '0 auto', zIndex: 2, pointerEvents: 'none' }
132
+ var buttonStyle = wide
133
+ ? { position: 'absolute', top: 11, right: 12 }
134
+ : { position: 'absolute', top: 12, left: '50%', transform: 'translateX(-50%)' }
135
+ return h('div', { style: wrapperStyle },
136
+ h('button', {
137
+ type: 'button',
138
+ title: t('title'),
139
+ 'aria-label': t('title'),
140
+ onClick: function () { setPanelOpen(true) },
141
+ onMouseEnter: function () { setHovered(true) },
142
+ onMouseLeave: function () { setHovered(false) },
143
+ onFocus: function () { setHovered(true) },
144
+ onBlur: function () { setHovered(false) },
145
+ style: Object.assign({
146
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
147
+ width: 28, height: 28,
148
+ borderRadius: 8,
149
+ border: '1px solid ' + (hovered ? 'var(--dsw-alias-border-l1)' : 'transparent'),
150
+ background: hovered ? 'var(--dsw-alias-bg-layer-1)' : 'transparent',
151
+ color: hovered ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-secondary)',
152
+ cursor: 'pointer', flex: 'none',
153
+ pointerEvents: 'auto',
154
+ }, buttonStyle),
155
+ },
156
+ h(RemoteIcon, { size: 16 }),
157
+ ),
158
+ )
159
+ }
160
+
161
+ /* ── status polling ───────────────────────────────────────────────── */
162
+
163
+ function useStatus() {
164
+ var state = React.useState(undefined)
165
+ var status = state[0]
166
+ var setStatus = state[1]
167
+ React.useEffect(function () {
168
+ var stopped = false
169
+ var tick = function () {
170
+ fetch(API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'status' }) })
171
+ .then(function (res) { return res.json() })
172
+ .then(function (data) { if (!stopped && data && data.ok) setStatus(data.status) })
173
+ .catch(function () { /* transient; next tick retries */ })
174
+ }
175
+ tick()
176
+ var timer = setInterval(tick, 3000)
177
+ return function () { stopped = true; clearInterval(timer) }
178
+ }, [])
179
+ return status
180
+ }
181
+
182
+ function post(body) {
183
+ return fetch(API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
184
+ .then(function (res) { return res.json() })
185
+ }
186
+
187
+ /* ── owner panel pieces ───────────────────────────────────────────── */
188
+
189
+ var cardStyle = {
190
+ background: 'var(--dsw-alias-bg-layer-1)',
191
+ border: '1px solid var(--dsw-alias-border-l1)',
192
+ borderRadius: 12,
193
+ padding: '16px 18px',
194
+ }
195
+ var labelStyle = { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginBottom: 6 }
196
+ var buttonStyle = {
197
+ display: 'inline-flex', alignItems: 'center', gap: 6, height: 28, padding: '0 12px',
198
+ borderRadius: 8, border: '1px solid var(--dsw-alias-border-l1)', cursor: 'pointer',
199
+ background: 'var(--dsw-alias-bg-layer-1)', color: 'var(--dsw-alias-label-primary)', fontSize: 12,
200
+ }
201
+ var codeStyle = {
202
+ fontFamily: 'var(--dsw-font-mono, ui-monospace, Menlo, monospace)',
203
+ fontSize: 30, letterSpacing: 6, fontWeight: 700,
204
+ color: 'var(--dsw-alias-label-primary)',
205
+ }
206
+
207
+ function StatusChip(props) {
208
+ var on = props.on
209
+ var text = props.text
210
+ return h('span', {
211
+ style: {
212
+ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12,
213
+ color: on ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-secondary)',
214
+ border: '1px solid var(--dsw-alias-border-l1)', borderRadius: 999,
215
+ padding: '2px 10px',
216
+ },
217
+ },
218
+ h('span', { style: { width: 7, height: 7, borderRadius: 999, background: on ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-border-l1)' } }),
219
+ text,
220
+ )
221
+ }
222
+
223
+ function LinkRow(props) {
224
+ var t = props.t
225
+ var label = props.label
226
+ var url = props.url
227
+ var state = React.useState(false)
228
+ var copied = state[0]
229
+ var setCopied = state[1]
230
+ if (!url) return null
231
+ var copy = function () {
232
+ var done = function () { setCopied(true); setTimeout(function () { setCopied(false) }, 1500) }
233
+ if (navigator.clipboard && navigator.clipboard.writeText) {
234
+ navigator.clipboard.writeText(url).then(done, done)
235
+ } else {
236
+ var ta = document.createElement('textarea')
237
+ ta.value = url
238
+ document.body.appendChild(ta)
239
+ ta.select()
240
+ try { document.execCommand('copy') } catch (e) { /* noop */ }
241
+ document.body.removeChild(ta)
242
+ done()
243
+ }
244
+ }
245
+ return h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 } },
246
+ h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', flex: 'none', width: 62 } }, label),
247
+ h('code', {
248
+ style: {
249
+ flex: 1, fontSize: 12, fontFamily: 'var(--dsw-font-mono, ui-monospace, Menlo, monospace)',
250
+ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
251
+ color: 'var(--dsw-alias-label-primary)',
252
+ },
253
+ }, url),
254
+ h('button', { type: 'button', onClick: copy, style: buttonStyle }, copied ? t('copied') : t('copy')),
255
+ )
256
+ }
257
+
258
+ function Toggle(props) {
259
+ var checked = props.checked
260
+ var onChange = props.onChange
261
+ return h('span', {
262
+ role: 'switch', 'aria-checked': checked ? 'true' : 'false', tabIndex: 0,
263
+ onClick: function () { onChange(!checked) },
264
+ onKeyDown: function (ev) { if (ev.key === 'Enter' || ev.key === ' ') onChange(!checked) },
265
+ style: {
266
+ width: 34, height: 20, borderRadius: 999, padding: 2, boxSizing: 'border-box',
267
+ background: checked ? 'var(--dsw-alias-brand-primary)' : 'var(--dsw-alias-bg-layer-2, var(--dsw-alias-border-l1))',
268
+ display: 'inline-block', transition: 'background 0.15s', flex: 'none', cursor: 'pointer',
269
+ },
270
+ },
271
+ h('span', {
272
+ style: {
273
+ display: 'block', width: 16, height: 16, borderRadius: 999, background: '#ffffff',
274
+ transform: checked ? 'translateX(14px)' : 'translateX(0)', transition: 'transform 0.15s',
275
+ boxShadow: '0 1px 2px rgba(0,0,0,0.2)',
276
+ },
277
+ }),
278
+ )
279
+ }
280
+
281
+ function OwnerPanel() {
282
+ var t = pageT
283
+ var status = useStatus()
284
+ var errState = React.useState('')
285
+ var err = errState[0]
286
+ var setErr = errState[1]
287
+
288
+ var act = function (body) {
289
+ return function () {
290
+ post(body).then(function (data) {
291
+ if (!data.ok) setErr(data.error || t('actionFailed'))
292
+ else setErr('')
293
+ }).catch(function (error) { setErr(String(error)) })
294
+ }
295
+ }
296
+
297
+ if (!status) {
298
+ return h('div', { style: { padding: 24, color: 'var(--dsw-alias-label-secondary)', fontSize: 13 } }, t('loading'))
299
+ }
300
+ var relayText = status.enabled ? (status.relay && status.relay.connected ? t('online') : t('offline')) : t('disabled')
301
+ return h('div', {
302
+ style: {
303
+ minHeight: 0, boxSizing: 'border-box',
304
+ padding: 16, display: 'flex', flexDirection: 'column', gap: 14,
305
+ color: 'var(--dsw-alias-label-primary)', fontSize: 13,
306
+ },
307
+ },
308
+ // Header chips
309
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
310
+ StatusChip({ on: status.enabled && status.relay && status.relay.connected, text: t('relay') + ': ' + relayText }),
311
+ status.direct ? StatusChip({ on: true, text: t('direct') }) : null,
312
+ ),
313
+ // Code card
314
+ h('div', { style: cardStyle },
315
+ h('div', { style: labelStyle }, t('codeLabel') + '(访问 https://…/' + status.code + ')'),
316
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 14 } },
317
+ h('span', { style: codeStyle }, status.code),
318
+ h('span', { style: { flex: 1 } }),
319
+ h('button', {
320
+ type: 'button', style: buttonStyle,
321
+ onClick: function () {
322
+ if (window.confirm(t('confirmRegenerate'))) act({ action: 'regenerate' })()
323
+ },
324
+ }, t('regenerate')),
325
+ ),
326
+ h(LinkRow, { key: 'relay', t: t, label: t('relayUrl'), url: status.enabled ? status.relayUrlPublic : undefined }),
327
+ h(LinkRow, { key: 'direct', t: t, label: t('directUrl'), url: status.directUrl }),
328
+ status.enabled ? null : h('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, t('disabled')),
329
+ ),
330
+ // Controls card
331
+ h('div', { style: cardStyle },
332
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: 12 } },
333
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } },
334
+ h('span', null, t('enabledToggle')),
335
+ Toggle({ checked: status.enabled, onChange: act({ action: 'set-enabled', value: !status.enabled }) }),
336
+ ),
337
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } },
338
+ h('span', null, t('allowControl')),
339
+ Toggle({ checked: status.allowControl, onChange: act({ action: 'set-control', value: !status.allowControl }) }),
340
+ ),
341
+ ),
342
+ ),
343
+ // Visitors card
344
+ h('div', { style: cardStyle },
345
+ h('div', { style: labelStyle }, t('visitors') + ':' + (status.visitors.length ? String(status.visitors.length) : t('none'))),
346
+ status.visitors.length
347
+ ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } }, status.visitors.map(function (v) {
348
+ return h('div', { key: v.id, style: { fontSize: 12, display: 'flex', gap: 8 } },
349
+ h('code', { style: { fontFamily: 'var(--dsw-font-mono, monospace)' } }, v.id),
350
+ h('span', { style: { color: 'var(--dsw-alias-label-secondary)' } },
351
+ t('visitorAt') + ' ' + new Date(v.connectedAt).toLocaleTimeString() +
352
+ (v.sessions && v.sessions.length ? ' · ' + v.sessions.length + ' 会话' : '')),
353
+ )
354
+ }))
355
+ : null,
356
+ status.relayFace && status.relayFace.hosts && status.relayFace.hosts.length
357
+ ? h('div', { style: { marginTop: 12 } },
358
+ h('div', { style: labelStyle }, t('relayFaceHosts')),
359
+ status.relayFace.hosts.map(function (host) {
360
+ return h('div', { key: host.hostId, style: { fontSize: 12, display: 'flex', gap: 8, marginBottom: 4 } },
361
+ h('span', null, host.online ? '🟢' : '⚪'),
362
+ h('span', null, host.name + ' · ' + host.code + (host.online ? '' : '(' + t('offlineHost') + ')')),
363
+ )
364
+ }),
365
+ )
366
+ : null,
367
+ ),
368
+ // Log card
369
+ h('div', { style: cardStyle },
370
+ h('div', { style: labelStyle }, t('log')),
371
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: 3, maxHeight: 220, overflowY: 'auto' } },
372
+ (status.log || []).slice().reverse().map(function (entry, index) {
373
+ return h('div', { key: index, style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', display: 'flex', gap: 8 } },
374
+ h('span', { style: { flex: 'none', fontFamily: 'var(--dsw-font-mono, monospace)' } }, new Date(entry.time).toLocaleTimeString()),
375
+ h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, entry.text),
376
+ )
377
+ }),
378
+ ),
379
+ ),
380
+ err ? h('div', { style: { fontSize: 12, color: 'var(--dsw-alias-state-error-primary)' } }, t('actionFailed') + ':' + err) : null,
381
+ )
382
+ }
383
+
384
+ /* ── dialog (shell.overlay) ───────────────────────────────────────── */
385
+
386
+ function RemotePanelDialog() {
387
+ var t = pageT
388
+ var pair = usePanelOpen()
389
+ var open = pair[0]
390
+ var setOpen = pair[1]
391
+ var closeRef = React.useRef(null)
392
+ React.useEffect(function () {
393
+ if (!open) return undefined
394
+ var onKey = function (ev) {
395
+ if (ev.key === 'Escape') { ev.stopPropagation(); setOpen(false) }
396
+ }
397
+ document.addEventListener('keydown', onKey)
398
+ var timer = setTimeout(function () { if (closeRef.current) closeRef.current.focus() }, 0)
399
+ return function () {
400
+ document.removeEventListener('keydown', onKey)
401
+ clearTimeout(timer)
402
+ }
403
+ }, [open])
404
+ if (!open) return null
405
+ return h('div', {
406
+ style: {
407
+ position: 'absolute', inset: 0, zIndex: 10,
408
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
409
+ background: 'rgba(0,0,0,0.45)',
410
+ },
411
+ onClick: function () { setOpen(false) },
412
+ },
413
+ h('div', {
414
+ role: 'dialog', 'aria-modal': 'true', 'aria-label': t('title'),
415
+ style: {
416
+ width: 'min(680px, calc(100vw - 64px))', maxHeight: 'calc(100vh - 96px)',
417
+ display: 'flex', flexDirection: 'column',
418
+ background: 'var(--dsw-alias-bg-overlay)',
419
+ border: '1px solid var(--dsw-alias-border-l1)',
420
+ borderRadius: 14, boxShadow: '0 18px 48px rgba(0,0,0,0.35)',
421
+ overflow: 'hidden',
422
+ },
423
+ onClick: function (ev) { ev.stopPropagation() },
424
+ },
425
+ // Header
426
+ h('div', {
427
+ style: {
428
+ display: 'flex', alignItems: 'center', gap: 10, padding: '14px 16px',
429
+ borderBottom: '1px solid var(--dsw-alias-border-l1)', flex: 'none',
430
+ },
431
+ },
432
+ h(RemoteIcon, { size: 18 }),
433
+ h('span', { style: { fontSize: 15, fontWeight: 700, color: 'var(--dsw-alias-label-primary)' } }, t('title')),
434
+ h('span', { style: { flex: 1 } }),
435
+ h('button', {
436
+ type: 'button', ref: closeRef, 'aria-label': t('close'), title: t('close'),
437
+ onClick: function () { setOpen(false) },
438
+ style: {
439
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
440
+ width: 28, height: 28, borderRadius: 8, border: '1px solid var(--dsw-alias-border-l1)',
441
+ background: 'var(--dsw-alias-bg-layer-1)', color: 'var(--dsw-alias-label-primary)',
442
+ cursor: 'pointer', fontSize: 14,
443
+ },
444
+ }, '✕'),
445
+ ),
446
+ // Body
447
+ h('div', { style: { overflowY: 'auto', minHeight: 0 } },
448
+ h(OwnerPanel),
449
+ ),
450
+ ),
451
+ )
452
+ }
453
+
454
+ return {
455
+ name: '@skillpie/dsh-remote',
456
+ inject: ['slots', 'layout', 'locale'],
457
+ apply: function (ctx) {
458
+ ctx.effect(function () {
459
+ return ctx.locale.register(NS, { zh: zh, en: en })
460
+ }, 'dsh-remote: dictionaries')
461
+
462
+ pageT = (ctx.locale && typeof ctx.locale.bind === 'function')
463
+ ? ctx.locale.bind(NS)
464
+ : function (key) { return (zh[key] != null ? zh[key] : (en[key] != null ? en[key] : key)) }
465
+
466
+ // Sidebar foot: compact action button beside Settings.
467
+ // 经中转透传访问的访客页(relay 注入 __DSH_REMOTE_VISITOR__)不挂远程入口:
468
+ // 属主 API 对访客是跨源拒绝的,面板对他们没有用处,只有桌面端展示。
469
+ if (window.__DSH_REMOTE_VISITOR__ === true) return
470
+
471
+ ctx.slots.inject('sidebar.footer.action', function () {
472
+ return ctx.slots.register({
473
+ name: 'sidebar.footer.action',
474
+ id: 'dsh-remote-footer',
475
+ order: 10,
476
+ locale: NS,
477
+ label: function () { return pageT('title') },
478
+ }, FooterAction)
479
+ })
480
+
481
+ // Application-level floating layer: the dialog itself.
482
+ ctx.slots.inject('shell.overlay', function () {
483
+ return ctx.slots.register({
484
+ name: 'shell.overlay',
485
+ id: 'dsh-remote-overlay',
486
+ order: 20,
487
+ locale: NS,
488
+ }, RemotePanelDialog)
489
+ })
490
+ },
491
+ }
492
+ },
493
+ })