@linxin666/dsh-remote-web-ui 0.1.1

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.
Files changed (118) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +346 -0
  3. package/cordis.patch.yml +10 -0
  4. package/lib/client.js +2817 -0
  5. package/lib/index.js +1455 -0
  6. package/lib/invariant.js +21 -0
  7. package/lib/types/client/PairFailedNotice.d.ts +12 -0
  8. package/lib/types/client/PairFailedNotice.d.ts.map +1 -0
  9. package/lib/types/client/PhoneIcon.d.ts +19 -0
  10. package/lib/types/client/PhoneIcon.d.ts.map +1 -0
  11. package/lib/types/client/PluginSettingsCard.d.ts +78 -0
  12. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
  13. package/lib/types/client/RemoteEntry.d.ts +10 -0
  14. package/lib/types/client/RemoteEntry.d.ts.map +1 -0
  15. package/lib/types/client/RemotePanel.d.ts +50 -0
  16. package/lib/types/client/RemotePanel.d.ts.map +1 -0
  17. package/lib/types/client/RemoteSettingsCard.d.ts +75 -0
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -0
  19. package/lib/types/client/deep-link.d.ts +40 -0
  20. package/lib/types/client/deep-link.d.ts.map +1 -0
  21. package/lib/types/client/index.d.ts +54 -0
  22. package/lib/types/client/index.d.ts.map +1 -0
  23. package/lib/types/client/locales.d.ts +144 -0
  24. package/lib/types/client/locales.d.ts.map +1 -0
  25. package/lib/types/client/pair-api.d.ts +100 -0
  26. package/lib/types/client/pair-api.d.ts.map +1 -0
  27. package/lib/types/client/settings-form.d.ts +119 -0
  28. package/lib/types/client/settings-form.d.ts.map +1 -0
  29. package/lib/types/gate.d.ts +58 -0
  30. package/lib/types/gate.d.ts.map +1 -0
  31. package/lib/types/index.d.ts +78 -0
  32. package/lib/types/index.d.ts.map +1 -0
  33. package/lib/types/invariant.d.ts +16 -0
  34. package/lib/types/invariant.d.ts.map +1 -0
  35. package/lib/types/lan.d.ts +15 -0
  36. package/lib/types/lan.d.ts.map +1 -0
  37. package/lib/types/mobile/api.d.ts +64 -0
  38. package/lib/types/mobile/api.d.ts.map +1 -0
  39. package/lib/types/mobile/index.d.ts +10 -0
  40. package/lib/types/mobile/index.d.ts.map +1 -0
  41. package/lib/types/mobile/messages.d.ts +103 -0
  42. package/lib/types/mobile/messages.d.ts.map +1 -0
  43. package/lib/types/mobile/messages.test.d.ts +2 -0
  44. package/lib/types/mobile/messages.test.d.ts.map +1 -0
  45. package/lib/types/mobile/mobile-styles.d.ts +6 -0
  46. package/lib/types/mobile/mobile-styles.d.ts.map +1 -0
  47. package/lib/types/mobile/mobile-theme.d.ts +22 -0
  48. package/lib/types/mobile/mobile-theme.d.ts.map +1 -0
  49. package/lib/types/mobile/mobile-theme.test.d.ts +2 -0
  50. package/lib/types/mobile/mobile-theme.test.d.ts.map +1 -0
  51. package/lib/types/mobile/mux.d.ts +52 -0
  52. package/lib/types/mobile/mux.d.ts.map +1 -0
  53. package/lib/types/mobile/rpc.d.ts +38 -0
  54. package/lib/types/mobile/rpc.d.ts.map +1 -0
  55. package/lib/types/mobile/theme-toggle.d.ts +12 -0
  56. package/lib/types/mobile/theme-toggle.d.ts.map +1 -0
  57. package/lib/types/mobile/views/App.d.ts +59 -0
  58. package/lib/types/mobile/views/App.d.ts.map +1 -0
  59. package/lib/types/mobile/views/ChatView.d.ts +39 -0
  60. package/lib/types/mobile/views/ChatView.d.ts.map +1 -0
  61. package/lib/types/mobile/views/ChatView.test.d.ts +2 -0
  62. package/lib/types/mobile/views/ChatView.test.d.ts.map +1 -0
  63. package/lib/types/mobile/views/SessionListView.d.ts +27 -0
  64. package/lib/types/mobile/views/SessionListView.d.ts.map +1 -0
  65. package/lib/types/mobile/views/SessionListView.test.d.ts +2 -0
  66. package/lib/types/mobile/views/SessionListView.test.d.ts.map +1 -0
  67. package/lib/types/mobile/views/WorkspaceView.d.ts +19 -0
  68. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -0
  69. package/lib/types/mobile-api.d.ts +41 -0
  70. package/lib/types/mobile-api.d.ts.map +1 -0
  71. package/lib/types/mobile-routes.d.ts +16 -0
  72. package/lib/types/mobile-routes.d.ts.map +1 -0
  73. package/lib/types/pairing.d.ts +215 -0
  74. package/lib/types/pairing.d.ts.map +1 -0
  75. package/lib/types/routes.d.ts +58 -0
  76. package/lib/types/routes.d.ts.map +1 -0
  77. package/lib/types/tunnel.d.ts +99 -0
  78. package/lib/types/tunnel.d.ts.map +1 -0
  79. package/package.json +104 -0
  80. package/src/client/PairFailedNotice.tsx +33 -0
  81. package/src/client/PhoneIcon.tsx +31 -0
  82. package/src/client/PluginSettingsCard.tsx +207 -0
  83. package/src/client/RemoteEntry.tsx +202 -0
  84. package/src/client/RemotePanel.tsx +186 -0
  85. package/src/client/RemoteSettingsCard.tsx +220 -0
  86. package/src/client/deep-link.ts +137 -0
  87. package/src/client/index.ts +167 -0
  88. package/src/client/locales.ts +149 -0
  89. package/src/client/pair-api.ts +176 -0
  90. package/src/client/remote.module.css +422 -0
  91. package/src/client/settings-card.module.css +296 -0
  92. package/src/client/settings-form.ts +294 -0
  93. package/src/css-modules.d.ts +6 -0
  94. package/src/gate.ts +121 -0
  95. package/src/index.ts +299 -0
  96. package/src/invariant.ts +32 -0
  97. package/src/lan.ts +21 -0
  98. package/src/mobile/api.ts +106 -0
  99. package/src/mobile/index.tsx +27 -0
  100. package/src/mobile/messages.test.ts +222 -0
  101. package/src/mobile/messages.ts +555 -0
  102. package/src/mobile/mobile-styles.ts +853 -0
  103. package/src/mobile/mobile-theme.test.ts +84 -0
  104. package/src/mobile/mobile-theme.ts +76 -0
  105. package/src/mobile/mux.ts +126 -0
  106. package/src/mobile/rpc.ts +90 -0
  107. package/src/mobile/theme-toggle.tsx +47 -0
  108. package/src/mobile/views/App.tsx +152 -0
  109. package/src/mobile/views/ChatView.test.tsx +243 -0
  110. package/src/mobile/views/ChatView.tsx +650 -0
  111. package/src/mobile/views/SessionListView.test.tsx +139 -0
  112. package/src/mobile/views/SessionListView.tsx +186 -0
  113. package/src/mobile/views/WorkspaceView.tsx +108 -0
  114. package/src/mobile-api.ts +271 -0
  115. package/src/mobile-routes.ts +77 -0
  116. package/src/pairing.ts +390 -0
  117. package/src/routes.ts +346 -0
  118. package/src/tunnel.ts +264 -0
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Phone-side boot flow: the QR link's `pair` + `workspace` parameters.
3
+ * Runs from the client apply on every page load, on any device:
4
+ * - `pair` present → accept the token, then reload so the whole SPA boots
5
+ * with the device cookie (the accept endpoint is exempt from the pairing
6
+ * gate; every other /api request needs the cookie).
7
+ * - `workspace` present (and paired) → deep-link: connect that workspace's
8
+ * session and open it, then strip the parameter.
9
+ * Failure of accept leaves a sessionStorage marker the entry renders as a
10
+ * one-time notice.
11
+ */
12
+
13
+ import type { Context } from '@deepseek-ai/cordis'
14
+ import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
15
+ import { acceptPair, readPairParams } from './pair-api.ts'
16
+
17
+ /** sessionStorage key for the failed-pair notice. */
18
+ export const PAIR_FAILED_MARKER = 'dsh-remote-pair-failed'
19
+
20
+ /** Poll budget for the runtime services (activation order is unconstrained). */
21
+ const SERVICE_WAIT_MS = 10_000
22
+
23
+ /**
24
+ * The page-navigation surface the boot flow drives. Browser pages use the
25
+ * default (window.location/history); tests inject a fake.
26
+ */
27
+ export interface PageSurface {
28
+ /** The current page URL (read fresh on each access). */
29
+ href: string
30
+ /** Replace the URL without reloading. */
31
+ replaceState(url: string): void
32
+ /** Navigate to a URL (a fresh page load). */
33
+ navigate(url: string): void
34
+ /** Reload the page. */
35
+ reload(): void
36
+ }
37
+
38
+ /** The browser implementation of {@link PageSurface}. */
39
+ export const browserPage: PageSurface = {
40
+ get href(): string {
41
+ return window.location.href
42
+ },
43
+ replaceState(url: string): void {
44
+ window.history.replaceState(null, '', url)
45
+ },
46
+ navigate(url: string): void {
47
+ window.location.assign(url)
48
+ },
49
+ reload(): void {
50
+ window.location.reload()
51
+ },
52
+ }
53
+
54
+ /** Whether this browser looks like a phone/tablet (the simplified mobile surface). */
55
+ export function isMobileSurface(): boolean {
56
+ if (typeof navigator === 'undefined') return false
57
+ return /Android|iPhone|iPad|iPod|Mobile|mobile/i.test(navigator.userAgent)
58
+ }
59
+
60
+ /**
61
+ * Run the pair/workspace boot flow for this page load.
62
+ * @param ctx - client root context (workspaces/sessions read at need time).
63
+ * @param search - the current location.search.
64
+ * @param page - the page surface (defaults to the browser).
65
+ */
66
+ export function runPairBootFlow(ctx: Context, search: string, page: PageSurface = browserPage): void {
67
+ const params = readPairParams(search)
68
+ if (params.pair !== undefined) {
69
+ void runAccept(params.pair, page)
70
+ return
71
+ }
72
+ if (params.workspace !== undefined) {
73
+ void runDeepLink(ctx, params.workspace, page)
74
+ }
75
+ }
76
+
77
+ /** Accept the token, then reload (the workspace param survives the reload). */
78
+ async function runAccept(token: string, page: PageSurface): Promise<void> {
79
+ let ok = false
80
+ try {
81
+ const result = await acceptPair(token)
82
+ ok = result.ok
83
+ if (!ok) sessionStorage.setItem(PAIR_FAILED_MARKER, 'failed')
84
+ } catch {
85
+ sessionStorage.setItem(PAIR_FAILED_MARKER, 'failed')
86
+ }
87
+ // Drop the token from the URL either way: an accepted token is consumed
88
+ // (a re-scan would 409), and a failed one must not loop.
89
+ const url = new URL(page.href)
90
+ url.searchParams.delete('pair')
91
+ page.replaceState(`${url.pathname}${url.search}${url.hash}`)
92
+ if (ok) {
93
+ // Phones land on the standalone simplified surface (the full desktop UI
94
+ // is not built for small screens); desktops stay on the full UI.
95
+ if (isMobileSurface()) page.navigate('/m')
96
+ else page.reload()
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Connect the deep-linked workspace and open its session. Waits for the
102
+ * runtime services AND for the target workspace to appear in the workspace
103
+ * list (both are asynchronous after boot), then connects and opens; gives
104
+ * up silently within the budget — the workspace param is stripped either
105
+ * way, so a late failure cannot loop.
106
+ * @param ctx - client root context.
107
+ * @param workspaceId - the target workspace.
108
+ * @param page - the page surface.
109
+ */
110
+ async function runDeepLink(ctx: Context, workspaceId: string, page: PageSurface): Promise<void> {
111
+ const target = workspaceId as WorkspaceId
112
+ const deadline = Date.now() + SERVICE_WAIT_MS
113
+ while (Date.now() < deadline) {
114
+ const workspaces = ctx.get('workspaces')
115
+ const sessions = ctx.get('sessions') as { open(id: string): void } | undefined
116
+ if (workspaces !== undefined && sessions !== undefined) {
117
+ const items = workspaces.list.getSnapshot().items
118
+ if (items.some(item => item.workspaceId === target)) {
119
+ try {
120
+ // Open unconditionally: a host-side "current" session may already
121
+ // exist (multi-client mirroring), but the QR's workspace target is
122
+ // explicit user intent and must win.
123
+ const sessionId = await workspaces.connectWorkspace(target)
124
+ sessions.open(sessionId)
125
+ } catch {
126
+ // Unknown workspace or a failed connect: fall through to the
127
+ // runtime's own default initial selection.
128
+ }
129
+ break
130
+ }
131
+ }
132
+ await new Promise(resolve => setTimeout(resolve, 200))
133
+ }
134
+ const url = new URL(page.href)
135
+ url.searchParams.delete('workspace')
136
+ page.replaceState(`${url.pathname}${url.search}${url.hash}`)
137
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Mobile remote control — browser half. Registers the `remote` dictionaries,
3
+ * the sidebar-foot entry (phone trigger + pairing panel) into the
4
+ * ui-sidebar-declared `sidebar.remote` seat, and runs the phone-side boot
5
+ * flow (pair accept + workspace deep-link + presence heartbeats) plus the
6
+ * one-time failed-pair notice. Export discipline: packages/client/AGENTS.md
7
+ * — the /client surface carries only what cordis loading needs plus types.
8
+ */
9
+ import { createElement } from 'react'
10
+ import { createRoot } from 'react-dom/client'
11
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
12
+ // Type-only: pulls the locale plugin's Context merge (ctx.locale) and the
13
+ // ui-sidebar SlotMap merge (the 'sidebar.remote' hole).
14
+ import type {} from '@deepseek-ai/dsh-client-locale/client'
15
+ // Type-only: pulls the settings-surface SlotMap merge (the 'settings.section'
16
+ // entry) and the ctx.settingsScope Context merge.
17
+ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
18
+ import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
19
+ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
20
+ import { RemoteEntry } from './RemoteEntry.tsx'
21
+ import { PairFailedNotice } from './PairFailedNotice.tsx'
22
+ import { RemoteSettingsCard, RemoteSettingsCardController, type RemoteSettings } from './RemoteSettingsCard.tsx'
23
+ import { en, zh, type RemoteKey } from './locales.ts'
24
+ import { PAIR_FAILED_MARKER, runPairBootFlow } from './deep-link.ts'
25
+ import { sendHeartbeat } from './pair-api.ts'
26
+
27
+ export type { RemoteEntryProps } from './RemoteEntry.tsx'
28
+ export type { PanelState, RemotePanelProps } from './RemotePanel.tsx'
29
+ export type { PairFailedNoticeProps } from './PairFailedNotice.tsx'
30
+ export type { RemoteKey } from './locales.ts'
31
+ export type { RemoteSettingsCardFace, RemoteSettingsCardState } from './RemoteSettingsCard.tsx'
32
+
33
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
34
+ interface LocaleNamespaceMap {
35
+ /** Mobile remote-control surface copy. */
36
+ remote: RemoteKey
37
+ }
38
+
39
+ interface SlotMap {
40
+ /**
41
+ * The sidebar foot seat beside the settings trigger, declared by the
42
+ * sidebar shell on deployments that carry the feature seat; the shell
43
+ * passes only its column display state.
44
+ */
45
+ 'sidebar.remote': { kind: 'single'; scope: 'root'; owner: SidebarRemoteOwnerProps }
46
+ /**
47
+ * The child slot the Web UI plugin group declares; this card registers
48
+ * into the group instead of the top-level `settings.plugin.item` list.
49
+ * Spelled here with the same shape so this package can register without
50
+ * depending on the sibling UI package.
51
+ */
52
+ 'web-ui.plugin.item': { kind: 'list'; scope: 'root'; owner: SettingsPluginItemOwnerProps }
53
+ }
54
+ }
55
+
56
+ /** Owner share of the sidebar remote-control seat: the column display state the trigger renders against. */
57
+ export interface SidebarRemoteOwnerProps {
58
+ /** Whether the sidebar renders wide content (false = 56px rail). */
59
+ wide: boolean
60
+ }
61
+
62
+ /** Owner share of a plugin card (the section supplies nothing). */
63
+ export interface SettingsPluginItemOwnerProps {
64
+ /** Marker field: card owner props are intentionally empty. */
65
+ children?: never
66
+ }
67
+
68
+ /** Dictionary namespace owned by this plugin. */
69
+ const NS = 'remote'
70
+
71
+ /** Settings namespace the remote-control card edits (the Host plugin registers it). */
72
+ const REMOTE_WEB_UI_NS = 'remote-web-ui'
73
+
74
+ /** Heartbeat cadence from a paired phone (presence + revocation liveness). */
75
+ const HEARTBEAT_INTERVAL_MS = 10_000
76
+
77
+ /** Services required by this plugin. */
78
+ export const inject = ['slots', 'locale', 'connection', 'settingsScope', 'remote']
79
+
80
+ /**
81
+ * Register the remote-control surface.
82
+ * @param ctx - client root context.
83
+ */
84
+ export function apply(ctx: ClientContext): void {
85
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'remote-web-ui: dictionaries')
86
+
87
+ const t = ctx.locale.bind(NS)
88
+ const settingsScope = ctx.settingsScope.bind<RemoteSettings>({ namespace: REMOTE_WEB_UI_NS })
89
+ const enabled = (): boolean => {
90
+ const snapshot = settingsScope.getSnapshot()
91
+ return snapshot.status === 'ready'
92
+ ? snapshot.value?.enabled ?? true
93
+ : snapshot.status === 'unavailable'
94
+ }
95
+
96
+ // Sidebar foot entry: the shell declares 'sidebar.remote' in unconstrained
97
+ // order, so registration is declaration-aware — slots.inject waits on the
98
+ // declaration, removes the contribution when it collapses, and re-runs
99
+ // after a redeclaration. The entry follows the plugin's enabled setting:
100
+ // toggling it off removes the trigger, toggling it back on re-registers it.
101
+ ctx.slots.inject('sidebar.remote', () => {
102
+ let disposeEntry: (() => void) | undefined
103
+ const syncEntry = (): void => {
104
+ if (enabled() && disposeEntry === undefined) {
105
+ disposeEntry = ctx.slots.register({ name: 'sidebar.remote', locale: NS }, RemoteEntry)
106
+ } else if (!enabled() && disposeEntry !== undefined) {
107
+ disposeEntry()
108
+ disposeEntry = undefined
109
+ }
110
+ }
111
+ const unsubscribe = settingsScope.subscribe(syncEntry)
112
+ syncEntry()
113
+ return () => {
114
+ unsubscribe()
115
+ disposeEntry?.()
116
+ }
117
+ })
118
+
119
+ // Plugin configuration card: one staged form over the `remote-web-ui`
120
+ // settings namespace, contributed to the Web UI plugin group.
121
+ const remoteSettings = new RemoteSettingsCardController(settingsScope)
122
+ ctx.slots.inject('web-ui.plugin.item', () => ctx.slots.register({
123
+ name: 'web-ui.plugin.item',
124
+ id: 'remote-web-ui',
125
+ order: 90,
126
+ locale: NS,
127
+ inject: () => remoteSettings.inject(),
128
+ }, RemoteSettingsCard))
129
+
130
+ // Phone-side boot flow + heartbeats. Loopback pages (the desktop) never
131
+ // heartbeat; the server ignores unpaired heartbeats anyway. Both run only
132
+ // while the plugin is enabled.
133
+ let disposeRuntime: (() => void) | undefined
134
+ const syncRuntime = (): void => {
135
+ if (enabled() && disposeRuntime === undefined) {
136
+ disposeRuntime = ctx.effect(() => {
137
+ const connection = ctx.get('connection') as ConnectionHandle | undefined
138
+ const loopback = connection?.isLoopback ?? true
139
+ runPairBootFlow(ctx, window.location.search)
140
+ if (loopback) return () => {}
141
+ const timer = window.setInterval(() => { void sendHeartbeat().catch(() => {}) }, HEARTBEAT_INTERVAL_MS)
142
+ return () => { window.clearInterval(timer) }
143
+ }, 'remote-web-ui: pair flow + heartbeats')
144
+ } else if (!enabled() && disposeRuntime !== undefined) {
145
+ disposeRuntime()
146
+ disposeRuntime = undefined
147
+ }
148
+ }
149
+ settingsScope.subscribe(syncRuntime)
150
+ syncRuntime()
151
+
152
+ // One-time failed-pair toast. The accept result lands asynchronously, so
153
+ // the marker check is deferred past the accept round trip.
154
+ ctx.effect(() => {
155
+ const timer = window.setTimeout(() => {
156
+ if (sessionStorage.getItem(PAIR_FAILED_MARKER) === null) return
157
+ sessionStorage.removeItem(PAIR_FAILED_MARKER)
158
+ const mount = document.createElement('div')
159
+ document.body.appendChild(mount)
160
+ const root = createRoot(mount)
161
+ root.render(createElement(PairFailedNotice, { t }))
162
+ // The toast owns its dismissal; the root lives for the page lifetime.
163
+ void root
164
+ }, 1500)
165
+ return () => { window.clearTimeout(timer) }
166
+ }, 'remote-web-ui: failed-pair notice')
167
+ }
@@ -0,0 +1,149 @@
1
+ /** `remote` namespace dictionaries: the mobile remote-control surface copy. */
2
+
3
+ /** Simplified Chinese dictionary (the key-set source of truth). */
4
+ export const zh = {
5
+ 'entry.label': '移动端远程控制',
6
+ 'title': '移动端远程控制',
7
+ 'subtitle': '扫码或在手机上打开链接,即可远程控制当前工作区',
8
+ 'card.title': '手机扫码连接',
9
+ 'public.badge': '公网',
10
+ 'status.waiting': '等待手机连接',
11
+ 'status.connected': '已连接 {n} 台设备',
12
+ 'status.disconnected': '已配对设备离线',
13
+ 'status.stopped': '已停止移动端访问',
14
+ 'status.lanRequired': '此功能需要以 --host 0.0.0.0 启动 dsh web,或配置公网地址才能使用',
15
+ 'status.lanRequiredHint': '当前服务仅绑定在 127.0.0.1 且未配置公网地址,手机无法访问。请用 dsh web --host 0.0.0.0 重新启动,或在设置中填写内网穿透的公网地址。',
16
+ 'status.loopbackRequired': '配对面板仅限本机使用',
17
+ 'status.loopbackRequiredHint': '请通过 http://127.0.0.1 打开此页面后重试;手机请使用配对链接访问。',
18
+ 'status.unreachable': '无法连接配对服务',
19
+ 'status.unreachableHint': '请刷新页面后重试。',
20
+ 'pair.hint': '无法扫码?可以在手机上打开链接',
21
+ 'pair.publicHint': '公网链接:手机无需与电脑在同一网络',
22
+ 'address.label': '选择二维码指向的网络',
23
+ 'address.public': '公网地址',
24
+ 'address.lan': '局域网',
25
+ 'address.hint': '公网地址适合手机不在同一网络时使用;局域网地址仅在手机与电脑同一网络时可用。',
26
+ 'pair.expires': '二维码有效至 {time}',
27
+ 'pair.expired': '二维码已过期,请刷新',
28
+ 'pair.failed.title': '配对失败',
29
+ 'pair.failed.detail': '链接无效或已使用,请回到电脑端刷新二维码后重新扫码。',
30
+ 'action.stop': '停止',
31
+ 'action.refresh': '刷新二维码',
32
+ 'action.copy': '复制链接',
33
+ 'action.copied': '已复制',
34
+ 'stopped.hint': '已停止移动端访问。点击"刷新二维码"重新开启。',
35
+ 'tunnel.starting': '公网隧道启动中,二维码将自动变为公网链接…',
36
+ 'tunnel.failed': '公网隧道启动失败:{error}',
37
+ 'tunnel.unknownError': '未知错误',
38
+ 'close.label': '关闭移动端远程控制面板',
39
+ // 插件设置卡片(settings.plugin.item 席位)。
40
+ 'settings.title': '移动端远程控制',
41
+ 'settings.description': '配对安全与设备限额。',
42
+ 'settings.enabled': '启用移动端远程控制',
43
+ 'settings.enabledHint': '关闭后移除侧边栏入口并停用配对路由与局域网栅栏。',
44
+ 'settings.tokenTtlMs': '配对令牌有效期(毫秒)',
45
+ 'settings.tokenTtlMsHint': '新生成的二维码链接在此时间后失效。',
46
+ 'settings.offlineAfterMs': '设备离线判定(毫秒)',
47
+ 'settings.offlineAfterMsHint': '配对设备超过该时长未上报心跳即视为离线。',
48
+ 'settings.maxDevices': '已配对设备上限',
49
+ 'settings.maxDevicesHint': '超过上限时淘汰最旧的设备会话。',
50
+ 'settings.cookieName': '设备 Cookie 名',
51
+ 'settings.cookieNameHint': '携带已配对设备标识的 Cookie 名称。',
52
+ 'settings.requirePairingForLan': '局域网访问要求配对',
53
+ 'settings.requirePairingForLanHint': '开启:所有非本机回环的 /api 请求必须携带有效配对 Cookie;关闭:局域网栅栏放开,配对仅用于令牌与状态管理。',
54
+ 'settings.publicBaseUrl': '公网地址(可选)',
55
+ 'settings.publicBaseUrlHint': '内网穿透的公网地址,如 Cloudflare Tunnel:https://xxx.trycloudflare.com。填写后二维码将生成公网链接,手机不在同一网络也能配对;留空则仅局域网可用。',
56
+ 'settings.autoTunnel': '自动公网隧道',
57
+ 'settings.autoTunnelHint': '开启后插件自动启动 Cloudflare quick tunnel(无需安装任何工具),并自动更新公网地址与信任配置,手机随时可用公网配对;关闭时使用上方手动填写的公网地址。',
58
+ 'settings.inherit': '继承',
59
+ 'settings.on': '开',
60
+ 'settings.off': '关',
61
+ 'settings.overridden': '已覆盖',
62
+ 'settings.reset': '恢复默认',
63
+ 'settings.readOnly': '当前部署的设置只读。',
64
+ 'settings.expand': '展开设置',
65
+ 'settings.collapse': '收起设置',
66
+ 'settings.save': '保存',
67
+ 'settings.saving': '保存中…',
68
+ 'settings.discard': '放弃',
69
+ 'settings.unsaved': '未保存',
70
+ 'settings.saveFailed': '部署未接受这些值,已保留供你修改。',
71
+ 'settings.invalidNumber': '请输入数字,留空则使用默认值。',
72
+ } satisfies Record<string, string>
73
+
74
+ /** The remote namespace key union. */
75
+ export type RemoteKey = keyof typeof zh
76
+
77
+ /** The settings-card slice of the remote dictionary. */
78
+ export type SettingsCardKey = RemoteKey
79
+
80
+ /** English dictionary, checked complete against the zh key set. */
81
+ export const en = {
82
+ 'entry.label': 'Mobile remote control',
83
+ 'title': 'Mobile remote control',
84
+ 'subtitle': 'Scan the QR code or open the link on your phone to control this workspace remotely',
85
+ 'card.title': 'Scan to connect',
86
+ 'public.badge': 'Public',
87
+ 'status.waiting': 'Waiting for a phone',
88
+ 'status.connected': '{n} device(s) connected',
89
+ 'status.disconnected': 'Paired devices offline',
90
+ 'status.stopped': 'Mobile access stopped',
91
+ 'status.lanRequired': 'This feature needs dsh web started with --host 0.0.0.0, or a configured public address',
92
+ 'status.lanRequiredHint': 'The server is bound to 127.0.0.1 and no public address is configured, so a phone cannot reach it. Restart with dsh web --host 0.0.0.0, or set the tunneled public address in settings.',
93
+ 'status.loopbackRequired': 'The pairing panel works on this machine only',
94
+ 'status.loopbackRequiredHint': 'Open this page at http://127.0.0.1 to mint a QR code; phones use the paired link.',
95
+ 'status.unreachable': 'Cannot reach the pairing service',
96
+ 'status.unreachableHint': 'Refresh the page and try again.',
97
+ 'pair.hint': 'Cannot scan? Open the link on your phone',
98
+ 'pair.publicHint': 'Public link: the phone does not need to be on the same network',
99
+ 'address.label': 'Network the QR code points to',
100
+ 'address.public': 'Public',
101
+ 'address.lan': 'LAN',
102
+ 'address.hint': 'Use the public address when the phone is not on the same network; LAN addresses only work on the same network.',
103
+ 'pair.expires': 'QR code valid until {time}',
104
+ 'pair.expired': 'QR code expired — refresh it',
105
+ 'pair.failed.title': 'Pairing failed',
106
+ 'pair.failed.detail': 'The link is invalid or was already used. Refresh the QR code on your computer and scan again.',
107
+ 'action.stop': 'Stop',
108
+ 'action.refresh': 'Refresh QR',
109
+ 'action.copy': 'Copy link',
110
+ 'action.copied': 'Copied',
111
+ 'stopped.hint': 'Mobile access is stopped. Click "Refresh QR" to re-enable it.',
112
+ 'tunnel.starting': 'The public tunnel is starting; the QR code will switch to a public link shortly…',
113
+ 'tunnel.failed': 'The public tunnel failed to start: {error}',
114
+ 'tunnel.unknownError': 'unknown error',
115
+ 'close.label': 'Close mobile remote control panel',
116
+ // Plugin settings card (the `settings.plugin.item` seat).
117
+ 'settings.title': 'Mobile remote control',
118
+ 'settings.description': 'Pairing security and device limits.',
119
+ 'settings.enabled': 'Enable mobile remote control',
120
+ 'settings.enabledHint': 'When off, the sidebar entry is removed and pairing routes plus the LAN fence stop.',
121
+ 'settings.tokenTtlMs': 'Pairing token lifetime (ms)',
122
+ 'settings.tokenTtlMsHint': 'How long a minted QR link stays valid before it dies.',
123
+ 'settings.offlineAfterMs': 'Device offline threshold (ms)',
124
+ 'settings.offlineAfterMsHint': 'A paired device flips to offline when it has not been seen for this long.',
125
+ 'settings.maxDevices': 'Paired device cap',
126
+ 'settings.maxDevicesHint': 'Hard cap on paired device sessions; the oldest is evicted when full.',
127
+ 'settings.cookieName': 'Device cookie name',
128
+ 'settings.cookieNameHint': 'Cookie that carries the paired device id.',
129
+ 'settings.requirePairingForLan': 'Require pairing for LAN access',
130
+ 'settings.requirePairingForLanHint': 'On: every non-loopback /api request must carry a live paired-device cookie. Off: the LAN fence is open and pairing only manages tokens/status.',
131
+ 'settings.publicBaseUrl': 'Public address (optional)',
132
+ 'settings.publicBaseUrlHint': 'The public URL of a tunnel in front of this server, e.g. a Cloudflare Tunnel: https://xxx.trycloudflare.com. When set, the QR link is built from it so a phone anywhere can pair; leave blank for LAN-only usage.',
133
+ 'settings.autoTunnel': 'Auto public tunnel',
134
+ 'settings.autoTunnelHint': 'When on, the plugin runs its own Cloudflare quick tunnel (no tool installation needed) and keeps the public address and trust config in sync automatically, so a phone anywhere can pair at any time; when off, the manually entered public address above applies.',
135
+ 'settings.inherit': 'Inherit',
136
+ 'settings.on': 'On',
137
+ 'settings.off': 'Off',
138
+ 'settings.overridden': 'Overridden',
139
+ 'settings.reset': 'Reset to default',
140
+ 'settings.readOnly': 'This deployment stores settings read-only.',
141
+ 'settings.expand': 'Show settings',
142
+ 'settings.collapse': 'Hide settings',
143
+ 'settings.save': 'Save',
144
+ 'settings.saving': 'Saving…',
145
+ 'settings.discard': 'Discard',
146
+ 'settings.unsaved': 'Unsaved',
147
+ 'settings.saveFailed': 'The deployment did not accept these values; they were left for you to correct.',
148
+ 'settings.invalidNumber': 'Enter a number, or leave blank to use the default.',
149
+ } satisfies Record<RemoteKey, string>
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Browser-side wire helpers for the /api/pair surface. Plain fetch over
3
+ * same-origin /api (like the connection client); JSON bodies, string
4
+ * responses for the error codes.
5
+ */
6
+
7
+ /** issue() response. */
8
+ export interface IssueResult {
9
+ ok: true
10
+ url: string
11
+ token: string
12
+ expiresAt: number
13
+ /** Every constructible LAN base address (interface order). */
14
+ lanAddresses: string[]
15
+ /** The configured public (tunneled) base URL, when present. */
16
+ publicBaseUrl?: string
17
+ }
18
+
19
+ /** issue() refusal: the server is not LAN-reachable. */
20
+ export interface IssueLanRequired {
21
+ ok: false
22
+ code: 'lan-required'
23
+ }
24
+
25
+ /** issue() refusal: the requested LAN address is not constructible. */
26
+ export interface IssueUnknownAddress {
27
+ ok: false
28
+ code: 'unknown-address'
29
+ }
30
+
31
+ /** issue() refusal: the loopback-only fence rejected this origin. */
32
+ export interface IssueLoopbackRequired {
33
+ ok: false
34
+ code: 'forbidden'
35
+ }
36
+
37
+ export type IssueResponse = IssueResult | IssueLanRequired | IssueUnknownAddress | IssueLoopbackRequired
38
+
39
+ /** accept() refusal codes. */
40
+ export type AcceptFailure = { ok: false; code: 'invalid' | 'used' | 'forbidden' }
41
+
42
+ /** One auto-tunnel status frame (absent while the feature is off). */
43
+ export interface TunnelStatusFrame {
44
+ state: 'starting' | 'running' | 'failed'
45
+ url?: string
46
+ error?: string
47
+ }
48
+
49
+ /** One /api/pair/events frame. */
50
+ export interface PairStateFrame {
51
+ type: 'state'
52
+ phase: 'lan-required' | 'stopped' | 'waiting' | 'connected' | 'disconnected'
53
+ lanAvailable: boolean
54
+ tokenId?: string
55
+ tokenExpiresAt?: number
56
+ deviceCount: number
57
+ onlineCount: number
58
+ /** Auto-tunnel status, while the auto-tunnel feature is active. */
59
+ tunnel?: TunnelStatusFrame
60
+ }
61
+
62
+ /**
63
+ * Mint a fresh pairing token (one active token at a time — this invalidates
64
+ * any previous link).
65
+ * @param workspaceId - optional current workspace to deep-link the phone into.
66
+ * @param address - optional LAN IP literal the QR must be built from (the
67
+ * default is the first interface); unknown literals refuse with
68
+ * 'unknown-address'.
69
+ * @returns the issued link, the lan-required refusal (server never bound
70
+ * 0.0.0.0), or the forbidden refusal (the loopback-only fence rejected this
71
+ * origin — the panel is a desktop control endpoint).
72
+ */
73
+ export async function issuePair(workspaceId?: string, address?: string): Promise<IssueResponse> {
74
+ const response = await fetch('/api/pair/issue', {
75
+ method: 'POST',
76
+ headers: { 'content-type': 'application/json' },
77
+ body: JSON.stringify({
78
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
79
+ ...(address !== undefined ? { address } : {}),
80
+ }),
81
+ })
82
+ if (!response.ok) {
83
+ if (response.status === 409) return { ok: false, code: 'lan-required' }
84
+ if (response.status === 403) return { ok: false, code: 'forbidden' }
85
+ if (response.status === 400) return { ok: false, code: 'unknown-address' }
86
+ throw new Error(`remote-web-ui: issue failed with ${String(response.status)}`)
87
+ }
88
+ return await response.json() as IssueResult
89
+ }
90
+
91
+ /**
92
+ * Accept a pairing token (the phone's first open of the QR link). Success
93
+ * sets the device cookie; the page then reloads to boot with it.
94
+ * @param token - the token from the URL.
95
+ * @returns the wire result.
96
+ */
97
+ export async function acceptPair(token: string): Promise<{ ok: true } | AcceptFailure> {
98
+ const response = await fetch('/api/pair/accept', {
99
+ method: 'POST',
100
+ headers: { 'content-type': 'application/json' },
101
+ body: JSON.stringify({ token }),
102
+ })
103
+ if (response.ok) return { ok: true }
104
+ if (response.status === 404) return { ok: false, code: 'invalid' }
105
+ if (response.status === 409) return { ok: false, code: 'used' }
106
+ return { ok: false, code: 'forbidden' }
107
+ }
108
+
109
+ /** Revoke mobile access (paired devices + the current token). */
110
+ export async function stopPair(): Promise<void> {
111
+ const response = await fetch('/api/pair/stop', { method: 'POST' })
112
+ if (!response.ok) throw new Error(`remote-web-ui: stop failed with ${String(response.status)}`)
113
+ }
114
+
115
+ /** Presence heartbeat from a paired phone (unpaired heartbeats 401 harmlessly). */
116
+ export async function sendHeartbeat(): Promise<void> {
117
+ await fetch('/api/pair/heartbeat', { method: 'POST' })
118
+ }
119
+
120
+ /** Whether the current page URL carries a pairing token / workspace target. */
121
+ export function readPairParams(search: string): { pair?: string; workspace?: string } {
122
+ const params = new URLSearchParams(search)
123
+ const pair = params.get('pair')
124
+ const workspace = params.get('workspace')
125
+ return {
126
+ ...(pair !== null && pair !== '' ? { pair } : {}),
127
+ ...(workspace !== null && workspace !== '' ? { workspace } : {}),
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Strip one query parameter from the current URL without reloading.
133
+ * @param name - the parameter to remove.
134
+ * @returns the new search string ('' when empty).
135
+ */
136
+ export function stripParam(name: string): string {
137
+ const url = new URL(window.location.href)
138
+ url.searchParams.delete(name)
139
+ return url.search
140
+ }/** Human-readable expiry clock, e.g. "10:35". */
141
+ export function formatClock(epochMs: number): string {
142
+ const date = new Date(epochMs)
143
+ const hours = String(date.getHours()).padStart(2, '0')
144
+ const minutes = String(date.getMinutes()).padStart(2, '0')
145
+ return `${hours}:${minutes}`
146
+ }
147
+
148
+ /**
149
+ * Copy text to the clipboard with a fallback for insecure contexts
150
+ * (plain-HTTP LAN origins lack navigator.clipboard).
151
+ * @param text - the text to copy.
152
+ * @returns whether the copy succeeded.
153
+ */
154
+ export async function copyText(text: string): Promise<boolean> {
155
+ if (typeof navigator !== 'undefined' && navigator.clipboard !== undefined) {
156
+ try {
157
+ await navigator.clipboard.writeText(text)
158
+ return true
159
+ } catch {
160
+ // fall through to the execCommand path
161
+ }
162
+ }
163
+ try {
164
+ const area = document.createElement('textarea')
165
+ area.value = text
166
+ area.style.position = 'fixed'
167
+ area.style.opacity = '0'
168
+ document.body.appendChild(area)
169
+ area.select()
170
+ const ok = document.execCommand('copy')
171
+ area.remove()
172
+ return ok
173
+ } catch {
174
+ return false
175
+ }
176
+ }