@linxin666/dsh-remote-web-ui 0.1.19 → 0.2.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.
Files changed (144) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +41 -19
  3. package/README.zh.md +17 -12
  4. package/assets/mobile-icon-192.png +0 -0
  5. package/assets/mobile-icon-512.png +0 -0
  6. package/assets/mobile-service-worker.js +64 -0
  7. package/lib/client.js +57 -13
  8. package/lib/client.js.map +1 -1
  9. package/lib/index.js +408 -130
  10. package/lib/mobile.js +296 -22
  11. package/lib/mobile.js.map +1 -1
  12. package/lib/types/client/FooterRemoteEntry.js +1 -1
  13. package/lib/types/client/PluginSettingsCard.d.ts +7 -1
  14. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  15. package/lib/types/client/PluginSettingsCard.js +5 -3
  16. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  17. package/lib/types/client/RemoteEntry.js +43 -13
  18. package/lib/types/client/RemotePanel.d.ts +1 -0
  19. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  20. package/lib/types/client/RemotePanel.js +2 -2
  21. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  22. package/lib/types/client/RemoteSettingsCard.js +3 -3
  23. package/lib/types/client/UpdateEntry.d.ts.map +1 -1
  24. package/lib/types/client/UpdateEntry.js +37 -4
  25. package/lib/types/client/UpdatePanel.js +1 -1
  26. package/lib/types/client/deep-link.js +2 -2
  27. package/lib/types/client/index.js +7 -7
  28. package/lib/types/client/locales.d.ts +2 -0
  29. package/lib/types/client/locales.d.ts.map +1 -1
  30. package/lib/types/client/locales.js +2 -0
  31. package/lib/types/client/settings-form.d.ts.map +1 -1
  32. package/lib/types/client/update-api.d.ts.map +1 -1
  33. package/lib/types/gate.d.ts +9 -0
  34. package/lib/types/gate.d.ts.map +1 -1
  35. package/lib/types/gate.js +16 -5
  36. package/lib/types/http.d.ts +14 -0
  37. package/lib/types/http.d.ts.map +1 -0
  38. package/lib/types/http.js +27 -0
  39. package/lib/types/index.d.ts +28 -1
  40. package/lib/types/index.d.ts.map +1 -1
  41. package/lib/types/index.js +41 -22
  42. package/lib/types/invariant.d.ts.map +1 -1
  43. package/lib/types/mobile/PairRequiredView.d.ts +7 -0
  44. package/lib/types/mobile/PairRequiredView.d.ts.map +1 -0
  45. package/lib/types/mobile/PairRequiredView.js +28 -0
  46. package/lib/types/mobile/PairRequiredView.test.d.ts +2 -0
  47. package/lib/types/mobile/PairRequiredView.test.d.ts.map +1 -0
  48. package/lib/types/mobile/PairRequiredView.test.js +24 -0
  49. package/lib/types/mobile/api.js +1 -1
  50. package/lib/types/mobile/index.js +17 -4
  51. package/lib/types/mobile/markdown.d.ts +6 -4
  52. package/lib/types/mobile/markdown.d.ts.map +1 -1
  53. package/lib/types/mobile/markdown.js +8 -4
  54. package/lib/types/mobile/markdown.test.js +5 -1
  55. package/lib/types/mobile/messages.d.ts.map +1 -1
  56. package/lib/types/mobile/messages.js +14 -1
  57. package/lib/types/mobile/messages.test.js +1 -1
  58. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  59. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  60. package/lib/types/mobile/mobile-styles.js +48 -0
  61. package/lib/types/mobile/mobile-theme.test.js +2 -2
  62. package/lib/types/mobile/mux.d.ts.map +1 -1
  63. package/lib/types/mobile/mux.js +1 -1
  64. package/lib/types/mobile/mux.test.js +1 -1
  65. package/lib/types/mobile/pairing.d.ts +31 -0
  66. package/lib/types/mobile/pairing.d.ts.map +1 -0
  67. package/lib/types/mobile/pairing.js +62 -0
  68. package/lib/types/mobile/rpc.d.ts.map +1 -1
  69. package/lib/types/mobile/theme-toggle.js +1 -1
  70. package/lib/types/mobile/views/App.d.ts +12 -16
  71. package/lib/types/mobile/views/App.d.ts.map +1 -1
  72. package/lib/types/mobile/views/App.js +39 -12
  73. package/lib/types/mobile/views/App.test.d.ts +2 -0
  74. package/lib/types/mobile/views/App.test.d.ts.map +1 -0
  75. package/lib/types/mobile/views/App.test.js +52 -0
  76. package/lib/types/mobile/views/ChatView.js +6 -6
  77. package/lib/types/mobile/views/ChatView.test.js +3 -3
  78. package/lib/types/mobile/views/SessionListView.js +3 -3
  79. package/lib/types/mobile/views/SessionListView.test.js +2 -2
  80. package/lib/types/mobile/views/WorkspaceView.js +3 -3
  81. package/lib/types/mobile/views/WorkspaceView.test.js +3 -3
  82. package/lib/types/mobile-api.d.ts +1 -1
  83. package/lib/types/mobile-api.d.ts.map +1 -1
  84. package/lib/types/mobile-api.js +11 -18
  85. package/lib/types/mobile-pwa.d.ts +6 -0
  86. package/lib/types/mobile-pwa.d.ts.map +1 -0
  87. package/lib/types/mobile-pwa.js +17 -0
  88. package/lib/types/mobile-routes.d.ts +5 -8
  89. package/lib/types/mobile-routes.d.ts.map +1 -1
  90. package/lib/types/mobile-routes.js +130 -39
  91. package/lib/types/mount-once.d.ts +25 -0
  92. package/lib/types/mount-once.d.ts.map +1 -0
  93. package/lib/types/mount-once.js +42 -0
  94. package/lib/types/pairing-access.d.ts +28 -0
  95. package/lib/types/pairing-access.d.ts.map +1 -0
  96. package/lib/types/pairing-access.js +26 -0
  97. package/lib/types/pairing.d.ts +27 -0
  98. package/lib/types/pairing.d.ts.map +1 -1
  99. package/lib/types/pairing.js +68 -0
  100. package/lib/types/pairing.persist.test.d.ts +2 -0
  101. package/lib/types/pairing.persist.test.d.ts.map +1 -0
  102. package/lib/types/pairing.persist.test.js +128 -0
  103. package/lib/types/routes.d.ts +6 -6
  104. package/lib/types/routes.d.ts.map +1 -1
  105. package/lib/types/routes.js +15 -20
  106. package/lib/types/tunnel.d.ts.map +1 -1
  107. package/lib/types/update-routes.d.ts +2 -2
  108. package/lib/types/update-routes.d.ts.map +1 -1
  109. package/lib/types/update-routes.js +1 -6
  110. package/lib/types/update.d.ts +3 -3
  111. package/lib/types/update.d.ts.map +1 -1
  112. package/lib/types/update.js +40 -14
  113. package/package.json +12 -12
  114. package/src/client/PluginSettingsCard.tsx +10 -0
  115. package/src/client/RemoteEntry.tsx +40 -8
  116. package/src/client/RemotePanel.tsx +1 -1
  117. package/src/client/RemoteSettingsCard.tsx +1 -0
  118. package/src/client/UpdateEntry.tsx +36 -4
  119. package/src/client/deep-link.ts +1 -1
  120. package/src/client/locales.ts +2 -0
  121. package/src/client/remote.module.css +26 -0
  122. package/src/gate.ts +15 -3
  123. package/src/http.ts +30 -0
  124. package/src/index.ts +50 -16
  125. package/src/mobile/PairRequiredView.test.tsx +28 -0
  126. package/src/mobile/PairRequiredView.tsx +58 -0
  127. package/src/mobile/index.tsx +15 -1
  128. package/src/mobile/markdown.test.ts +5 -0
  129. package/src/mobile/markdown.ts +7 -4
  130. package/src/mobile/messages.ts +14 -1
  131. package/src/mobile/mobile-styles.ts +48 -0
  132. package/src/mobile/pairing.ts +77 -0
  133. package/src/mobile/views/App.test.tsx +62 -0
  134. package/src/mobile/views/App.tsx +53 -18
  135. package/src/mobile-api.ts +9 -17
  136. package/src/mobile-pwa.ts +19 -0
  137. package/src/mobile-routes.ts +137 -38
  138. package/src/mount-once.ts +48 -0
  139. package/src/pairing-access.ts +36 -0
  140. package/src/pairing.persist.test.ts +145 -0
  141. package/src/pairing.ts +70 -1
  142. package/src/routes.ts +12 -18
  143. package/src/update-routes.ts +1 -7
  144. package/src/update.ts +46 -15
@@ -29,14 +29,31 @@ export interface UpdateEntryProps {
29
29
  export function UpdateEntry({ wide, t }: UpdateEntryProps) {
30
30
  const [open, setOpen] = useState(false)
31
31
  const [view, setView] = useState<UpdateView>({ kind: "checking" })
32
+ const [updateAvailable, setUpdateAvailable] = useState(false)
32
33
  const runToken = useRef(0)
34
+ const availabilityToken = useRef(0)
35
+ const mounted = useRef(false)
36
+
37
+ const probeAvailability = useCallback(async (): Promise<void> => {
38
+ const token = ++availabilityToken.current
39
+ try {
40
+ const status = await fetchUpdateStatus()
41
+ if (token === availabilityToken.current) {
42
+ setUpdateAvailable(status.mode === "npm" && status.outdated)
43
+ }
44
+ } catch {
45
+ if (token === availabilityToken.current) setUpdateAvailable(false)
46
+ }
47
+ }, [])
33
48
 
34
49
  const check = useCallback(async (): Promise<void> => {
50
+ const availabilityCheck = ++availabilityToken.current
35
51
  setView({ kind: "checking" })
36
52
  let status: UpdateStatus
37
53
  try {
38
54
  status = await fetchUpdateStatus()
39
55
  } catch (error) {
56
+ if (availabilityCheck === availabilityToken.current) setUpdateAvailable(false)
40
57
  // HTTP 404: the update route is not mounted — the host process runs an
41
58
  // older plugin build (client refreshed, host did not). Restarting dsh
42
59
  // web loads the new plugin; a plain network failure gets the generic
@@ -48,6 +65,9 @@ export function UpdateEntry({ wide, t }: UpdateEntryProps) {
48
65
  setView({ kind: "error", message: t("update.offline"), detail: t("update.offlineDetail") })
49
66
  return
50
67
  }
68
+ if (availabilityCheck === availabilityToken.current) {
69
+ setUpdateAvailable(status.mode === "npm" && status.outdated)
70
+ }
51
71
  if (status.error === "registry-unreachable") {
52
72
  setView({ kind: "result", status })
53
73
  return
@@ -60,6 +80,7 @@ export function UpdateEntry({ wide, t }: UpdateEntryProps) {
60
80
  const token = ++runToken.current
61
81
  try {
62
82
  const result = await runUpdate()
83
+ if (result.ok && mounted.current) setUpdateAvailable(false)
63
84
  if (token !== runToken.current) return
64
85
  setView({ kind: "done", result })
65
86
  } catch {
@@ -78,8 +99,18 @@ export function UpdateEntry({ wide, t }: UpdateEntryProps) {
78
99
  setOpen(false)
79
100
  }, [])
80
101
 
81
- // Unmount safety: an in-flight update must not land on a dead component.
82
- useEffect(() => () => { runToken.current++ }, [])
102
+ // Check once after mount without opening the panel or starting an update.
103
+ useEffect(() => {
104
+ mounted.current = true
105
+ void probeAvailability()
106
+ return () => {
107
+ mounted.current = false
108
+ runToken.current++
109
+ availabilityToken.current++
110
+ }
111
+ }, [probeAvailability])
112
+
113
+ const updateLabel = updateAvailable ? t("update.availableLabel") : t("update.label")
83
114
 
84
115
  return (
85
116
  <>
@@ -87,8 +118,9 @@ export function UpdateEntry({ wide, t }: UpdateEntryProps) {
87
118
  type="button"
88
119
  className={css.trigger}
89
120
  data-wide={wide ? undefined : "rail"}
90
- aria-label={t("update.label")}
91
- title={t("update.label")}
121
+ data-update-available={updateAvailable ? "true" : undefined}
122
+ aria-label={updateLabel}
123
+ title={updateLabel}
92
124
  onClick={openPanel}
93
125
  >
94
126
  <IconDownloadOutline16 size={wide ? 16 : 18} />
@@ -95,7 +95,7 @@ async function runAccept(token: string, page: PageSurface): Promise<void> {
95
95
  // mobile surface can open the intended workspace instead of losing the
96
96
  // QR context at this navigation boundary.
97
97
  if (isMobileSurface()) {
98
- url.pathname = '/m'
98
+ url.pathname = '/m/'
99
99
  page.navigate(`${url.pathname}${url.search}${url.hash}`)
100
100
  } else {
101
101
  page.reload()
@@ -74,6 +74,7 @@ export const zh = {
74
74
  'settings.invalidNumber': '请输入数字,留空则使用默认值。',
75
75
  // 更新面板(侧边栏更新按钮)。
76
76
  'update.label': '检查更新',
77
+ 'update.availableLabel': '发现新版本,检查更新',
77
78
  'update.title': '检查更新',
78
79
  'update.checking': '正在检查远程仓库中的新版本…',
79
80
  'update.upToDate': '当前已是最新版本',
@@ -187,6 +188,7 @@ export const en = {
187
188
  'settings.invalidNumber': 'Enter a number, or leave blank to use the default.',
188
189
  // Update panel (the sidebar update trigger).
189
190
  'update.label': 'Check for updates',
191
+ 'update.availableLabel': 'New version available. Check for updates',
190
192
  'update.title': 'Check for updates',
191
193
  'update.checking': 'Checking the remote repository for newer releases…',
192
194
  'update.upToDate': 'Everything is up to date',
@@ -21,6 +21,7 @@
21
21
  /* Trigger: settings row height beside the trigger row (wide), or the 36px
22
22
  rail circle; the seat's owner (ui-sidebar) lays out the geometry. */
23
23
  .trigger {
24
+ position: relative;
24
25
  flex: none;
25
26
  display: inline-flex;
26
27
  align-items: center;
@@ -37,6 +38,30 @@
37
38
  transition: background-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
38
39
  }
39
40
 
41
+ .trigger[data-update-available]::after {
42
+ position: absolute;
43
+ top: 4px;
44
+ right: 4px;
45
+ width: 8px;
46
+ height: 8px;
47
+ box-sizing: border-box;
48
+ border: 2px solid var(--dsw-alias-bg-layer-1);
49
+ border-radius: 50%;
50
+ background: var(--dsw-alias-brand-primary);
51
+ content: '';
52
+ pointer-events: none;
53
+ }
54
+
55
+ .trigger[data-wide='wide'] {
56
+ flex: 1 1 auto;
57
+ justify-content: flex-start;
58
+ width: auto;
59
+ min-width: 0;
60
+ border-radius: 8px;
61
+ gap: 8px;
62
+ padding: 0 10px;
63
+ }
64
+
40
65
  .trigger:hover:not(:disabled) {
41
66
  background: var(--dsw-alias-interactive-bg-hover);
42
67
  color: var(--dsw-alias-label-primary);
@@ -429,6 +454,7 @@
429
454
  display: flex;
430
455
  align-items: center;
431
456
  gap: 6px;
457
+ min-width: 0;
432
458
  }
433
459
 
434
460
  .entryRow[data-rail='rail'] {
package/src/gate.ts CHANGED
@@ -89,8 +89,20 @@ export function makeGateListener(
89
89
  if (!active) return false
90
90
  const require = typeof requirePairingForLan === 'function' ? requirePairingForLan() : requirePairingForLan
91
91
  if (!require) return next()
92
- const deviceId = readCookie(request.headers.cookie, service.config.cookieName)
93
- if (deviceId === undefined) return false
94
- return service.touchDevice(deviceId) ? next() : false
92
+ return isPairedDeviceRequest(service, request) ? next() : false
95
93
  }
96
94
  }
95
+
96
+ /**
97
+ * Whether a request carries a live, non-revoked paired-device cookie for
98
+ * this service. Sibling host routes outside /api (aionui-panel, etc.) use
99
+ * the same check via the remoteWebUiPairing service.
100
+ * @param service - the pairing service that owns the device table.
101
+ * @param request - the incoming HTTP request.
102
+ * @returns true when the cookie names a live session (and lastSeenAt was refreshed).
103
+ */
104
+ export function isPairedDeviceRequest(service: PairingService, request: IncomingMessage): boolean {
105
+ const deviceId = readCookie(request.headers.cookie, service.config.cookieName)
106
+ if (deviceId === undefined) return false
107
+ return service.touchDevice(deviceId)
108
+ }
package/src/http.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared HTTP helpers for the route families: one JSON writer and one
3
+ * bounded JSON body reader. Previously copy-pasted across routes.ts,
4
+ * update-routes.ts, and mobile-api.ts with drifting failure contracts.
5
+ */
6
+
7
+ import type { IncomingMessage, ServerResponse } from 'node:http'
8
+
9
+ /** One JSON response. */
10
+ export function writeJson(res: ServerResponse, status: number, body: unknown): void {
11
+ const payload = JSON.stringify(body)
12
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
13
+ res.end(payload)
14
+ }
15
+
16
+ /**
17
+ * Read a request body up to maxBytes and parse it as JSON.
18
+ * @throws 'body too large' beyond the cap, or the JSON.parse error.
19
+ */
20
+ export async function readBoundedJson(req: IncomingMessage, maxBytes: number): Promise<unknown> {
21
+ const chunks: Buffer[] = []
22
+ let size = 0
23
+ for await (const chunk of req) {
24
+ const buffer = chunk as Buffer
25
+ size += buffer.length
26
+ if (size > maxBytes) throw new Error('body too large')
27
+ chunks.push(buffer)
28
+ }
29
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
30
+ }
package/src/index.ts CHANGED
@@ -15,8 +15,9 @@ import type { Context } from '@deepseek-ai/cordis'
15
15
  import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
16
16
  import z from 'schemastery'
17
17
  import type {} from '@deepseek-ai/dsh-host-webserver'
18
- import { PairingService } from './pairing.ts'
19
- import { makeGateListener } from './gate.ts'
18
+ import { PairingService, type PairingConfig } from './pairing.ts'
19
+ import { isPairedDeviceRequest, makeGateListener } from './gate.ts'
20
+ import { RemoteWebUiPairing } from './pairing-access.ts'
20
21
  import { isTrustedApiRequest, makeRoutes } from './routes.ts'
21
22
  import { makeMobileRoutes } from './mobile-routes.ts'
22
23
  import { makeMobileApiRoutes } from './mobile-api.ts'
@@ -31,6 +32,7 @@ import {
31
32
  type UpdateRunResult,
32
33
  } from './update.ts'
33
34
  import { makeUpdateRoutes } from './update-routes.ts'
35
+ import { mountOnce } from './mount-once.ts'
34
36
 
35
37
  declare module '@deepseek-ai/cordis' {
36
38
  interface Events {
@@ -88,6 +90,14 @@ export interface Config {
88
90
  * warning (LAN-only behavior preserved). Ignored while `autoTunnel` is on.
89
91
  */
90
92
  publicBaseUrl?: string
93
+ /**
94
+ * Absolute path to a JSON file where paired device sessions are persisted.
95
+ * When set, a paired phone keeps its session across `dsh web` restarts
96
+ * (the phone-side cookie already lives 365 days), so re-scanning the QR
97
+ * after each restart is unnecessary. Unset keeps sessions memory-only
98
+ * (previous behavior).
99
+ */
100
+ devicesFile?: string
91
101
  /**
92
102
  * When true, the plugin runs its own Cloudflare quick tunnel (the
93
103
  * cloudflared binary ships with the package — no user-side install) and
@@ -114,6 +124,7 @@ export const Config: z<Config> = z.object({
114
124
  cookieName: z.string().min(1).default('dsh_pair'),
115
125
  requirePairingForLan: z.boolean().default(true),
116
126
  publicBaseUrl: z.string(),
127
+ devicesFile: z.string(),
117
128
  autoTunnel: z.boolean().default(false),
118
129
  mobileEnterToSend: z.boolean().default(true),
119
130
  enabled: z.boolean().default(true),
@@ -127,7 +138,29 @@ const SWEEP_INTERVAL_MS = 10_000
127
138
  * which legitimately resolves to `undefined` when unset (the schema keeps it
128
139
  * optional, so `Required` alone would over-narrow it to `string`).
129
140
  */
130
- type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl'>> & { publicBaseUrl: string | undefined }
141
+ type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'devicesFile'>> & {
142
+ publicBaseUrl: string | undefined
143
+ devicesFile: string | undefined
144
+ }
145
+
146
+ /**
147
+ * The single mapping from resolved plugin config to the pairing service
148
+ * config. Both the constructed service and every live settings sync reuse
149
+ * it, so no field can be silently dropped when the web settings surface
150
+ * pushes a new value into the running service.
151
+ */
152
+ export function pairingConfigOf(resolved: Pick<
153
+ ResolvedConfig,
154
+ 'tokenTtlMs' | 'offlineAfterMs' | 'maxDevices' | 'cookieName' | 'devicesFile'
155
+ >): PairingConfig {
156
+ return {
157
+ tokenTtlMs: resolved.tokenTtlMs,
158
+ offlineAfterMs: resolved.offlineAfterMs,
159
+ maxDevices: resolved.maxDevices,
160
+ cookieName: resolved.cookieName,
161
+ devicesFile: resolved.devicesFile,
162
+ }
163
+ }
131
164
 
132
165
  /** Schema defaults, re-read for hand-built test contexts (the loader applies them normally). */
133
166
  const DEFAULTS: ResolvedConfig = {
@@ -137,6 +170,7 @@ const DEFAULTS: ResolvedConfig = {
137
170
  cookieName: 'dsh_pair',
138
171
  requirePairingForLan: true,
139
172
  publicBaseUrl: undefined,
173
+ devicesFile: undefined,
140
174
  autoTunnel: false,
141
175
  mobileEnterToSend: true,
142
176
  enabled: true,
@@ -147,7 +181,9 @@ const DEFAULTS: ResolvedConfig = {
147
181
  * @param ctx - host plugin context carrying webServer.
148
182
  * @param config - resolved plugin config (schema defaults applied by the loader).
149
183
  */
150
- export function apply(ctx: Context, config?: Config): void {
184
+ export const apply = mountOnce('@linxin666/dsh-remote-web-ui', applyImpl)
185
+
186
+ function applyImpl(ctx: Context, config?: Config): void {
151
187
  const resolved: ResolvedConfig = {
152
188
  tokenTtlMs: config?.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
153
189
  offlineAfterMs: config?.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
@@ -155,6 +191,7 @@ export function apply(ctx: Context, config?: Config): void {
155
191
  cookieName: config?.cookieName ?? DEFAULTS.cookieName,
156
192
  requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
157
193
  publicBaseUrl: config?.publicBaseUrl,
194
+ devicesFile: config?.devicesFile,
158
195
  autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
159
196
  mobileEnterToSend: config?.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
160
197
  enabled: config?.enabled ?? DEFAULTS.enabled,
@@ -172,17 +209,13 @@ export function apply(ctx: Context, config?: Config): void {
172
209
  cookieName: value.cookieName ?? DEFAULTS.cookieName,
173
210
  requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
174
211
  publicBaseUrl: value.publicBaseUrl,
212
+ devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
175
213
  autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
176
214
  mobileEnterToSend: value.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
177
215
  enabled: value.enabled ?? DEFAULTS.enabled,
178
216
  }
179
217
  }
180
- const service = new PairingService({
181
- tokenTtlMs: resolved.tokenTtlMs,
182
- offlineAfterMs: resolved.offlineAfterMs,
183
- maxDevices: resolved.maxDevices,
184
- cookieName: resolved.cookieName,
185
- })
218
+ const service = new PairingService(pairingConfigOf(resolved))
186
219
 
187
220
  // ── auto tunnel ─────────────────────────────────────────────────────────
188
221
  // The minted public URL becomes the QR base (and the pairing fence's
@@ -309,14 +342,15 @@ export function apply(ctx: Context, config?: Config): void {
309
342
  ]
310
343
  const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled)
311
344
  ctx.effect(() => ctx.on('api/gate', gate), 'remote-web-ui: api gate')
345
+ // Sibling plugins (aionui-panel, …) look this up by name. Absent when this
346
+ // plugin is not installed; stop() / enabled=false still refuse cookies.
347
+ new RemoteWebUiPairing(ctx, (request) => {
348
+ if (!resolve().enabled) return false
349
+ return isPairedDeviceRequest(service, request)
350
+ })
312
351
  const sync = (): void => {
313
352
  const value = resolve()
314
- service.config = {
315
- tokenTtlMs: value.tokenTtlMs,
316
- offlineAfterMs: value.offlineAfterMs,
317
- maxDevices: value.maxDevices,
318
- cookieName: value.cookieName,
319
- }
353
+ service.config = pairingConfigOf(value)
320
354
  // The auto tunnel owns the public base while enabled: the minted URL
321
355
  // lands in the service through the tunnel's phase listener. The manual
322
356
  // publicBaseUrl applies only when the auto tunnel is off.
@@ -0,0 +1,28 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+ import { PairRequiredView } from './PairRequiredView.tsx'
5
+
6
+ afterEach(() => {
7
+ cleanup()
8
+ vi.unstubAllGlobals()
9
+ })
10
+
11
+ describe('PairRequiredView', () => {
12
+ it('accepts a pasted link in the installed app context', async () => {
13
+ const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }))
14
+ vi.stubGlobal('fetch', fetch)
15
+ const onPaired = vi.fn()
16
+ render(<PairRequiredView onPaired={onPaired} />)
17
+
18
+ fireEvent.change(screen.getByLabelText('配对链接'), { target: { value: 'https://phone.example/m/?pair=tok-1&workspace=ws-7' } })
19
+ fireEvent.click(screen.getByRole('button', { name: '配对' }))
20
+
21
+ await waitFor(() => expect(onPaired).toHaveBeenCalledWith('/m/?workspace=ws-7'))
22
+ })
23
+
24
+ it('shows an initial QR failure without starting the mobile data channel', () => {
25
+ render(<PairRequiredView initialError="配对链接已被使用。" onPaired={vi.fn()} />)
26
+ expect(screen.getByRole('alert').textContent).toContain('配对链接已被使用。')
27
+ })
28
+ })
@@ -0,0 +1,58 @@
1
+ /** Device-pairing gate for installed mobile web-app contexts with isolated storage. */
2
+ import { type FormEvent, useState } from 'react'
3
+ import { acceptMobilePair, mobilePairPath, parseMobilePairInput } from './pairing.ts'
4
+
5
+ export interface PairRequiredViewProps {
6
+ initialError?: string
7
+ onPaired(path: string): void
8
+ }
9
+
10
+ /** Collect a fresh desktop-issued pairing link when this client has no paired cookie. */
11
+ export function PairRequiredView({ initialError, onPaired }: PairRequiredViewProps) {
12
+ const [value, setValue] = useState('')
13
+ const [error, setError] = useState<string | undefined>(initialError)
14
+ const [submitting, setSubmitting] = useState(false)
15
+
16
+ const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
17
+ event.preventDefault()
18
+ const input = parseMobilePairInput(value)
19
+ if (input === undefined) {
20
+ setError('请输入有效的配对链接。')
21
+ return
22
+ }
23
+
24
+ setSubmitting(true)
25
+ setError(undefined)
26
+ const result = await acceptMobilePair(input.token)
27
+ setSubmitting(false)
28
+ if (!result.ok) {
29
+ setError(result.message)
30
+ return
31
+ }
32
+
33
+ onPaired(mobilePairPath(input.workspaceId))
34
+ }
35
+
36
+ return (
37
+ <main className="mobile mobile-pair" aria-labelledby="mobile-pair-title">
38
+ <form className="mobile-pairCard" onSubmit={(event) => { void submit(event) }}>
39
+ <h1 id="mobile-pair-title" className="mobile-title">设备配对</h1>
40
+ <p className="mobile-muted">粘贴桌面端复制的配对链接以连接此设备。</p>
41
+ <label className="mobile-pairLabel" htmlFor="mobile-pair-link">配对链接</label>
42
+ <input
43
+ id="mobile-pair-link"
44
+ className="mobile-pairInput"
45
+ value={value}
46
+ onChange={(event) => { setValue(event.target.value) }}
47
+ autoComplete="off"
48
+ autoCapitalize="off"
49
+ spellCheck={false}
50
+ />
51
+ {error === undefined ? null : <p className="mobile-error" role="alert">{error}</p>}
52
+ <button className="mobile-new mobile-pairSubmit" type="submit" disabled={submitting}>
53
+ {submitting ? '正在配对' : '配对'}
54
+ </button>
55
+ </form>
56
+ </main>
57
+ )
58
+ }
@@ -11,6 +11,8 @@ import { createRoot } from 'react-dom/client'
11
11
  import { App } from './views/App.tsx'
12
12
  import { mobileCss } from './mobile-styles.ts'
13
13
  import { initMobileTheme } from './mobile-theme.ts'
14
+ import { registerMobilePwa } from '../mobile-pwa.ts'
15
+ import { consumeMobilePairUrl } from './pairing.ts'
14
16
 
15
17
  // Apply the persisted (or default light) theme before first paint, so the
16
18
  // page never flashes the wrong palette.
@@ -24,4 +26,16 @@ document.head.appendChild(style)
24
26
 
25
27
  const root = document.getElementById('root')
26
28
  if (root === null) throw new Error('mobile: #root missing')
27
- createRoot(root).render(<App />)
29
+ void bootMobile(root)
30
+
31
+ async function bootMobile(mount: HTMLElement): Promise<void> {
32
+ const pair = await consumeMobilePairUrl(window.location.href)
33
+ if (pair.kind === 'accepted') {
34
+ window.location.replace(pair.path)
35
+ return
36
+ }
37
+ if (pair.kind === 'failed') window.history.replaceState(null, '', pair.path)
38
+
39
+ void registerMobilePwa()
40
+ createRoot(mount).render(<App initialPairError={pair.kind === 'failed' ? pair.message : undefined} />)
41
+ }
@@ -22,6 +22,11 @@ describe('safeUrl', () => {
22
22
  expect(safeUrl('')).toBeNull()
23
23
  expect(safeUrl(' ')).toBeNull()
24
24
  })
25
+
26
+ it('rejects protocol-relative targets that escape the origin', () => {
27
+ expect(safeUrl('//attacker.example/x')).toBeNull()
28
+ expect(safeUrl(' //attacker.example/track.png')).toBeNull()
29
+ })
25
30
  })
26
31
 
27
32
  describe('renderInline', () => {
@@ -22,15 +22,18 @@ export function escapeHtml(text: string): string {
22
22
 
23
23
  /**
24
24
  * Guard a raw link/image target against dangerous protocols. Returns the
25
- * (trimmed) raw string when safe, else null. Only http:, https:, mailto:
26
- * and fragment anchors (#...) are allowed; anything with another scheme
27
- * — javascript:, data:, vbscript:, etc. — is rejected so the value never
28
- * reaches dangerouslySetInnerHTML.
25
+ * (trimmed) raw string when safe, else null. Only http:, https:, mailto:,
26
+ * fragment anchors (#...) and strictly relative paths are allowed; anything
27
+ * with another scheme — javascript:, data:, vbscript:, etc. — or a
28
+ * protocol-relative //host target (the browser resolves it against the
29
+ * current scheme, reaching an arbitrary origin) is rejected so the value
30
+ * never reaches dangerouslySetInnerHTML.
29
31
  */
30
32
  export function safeUrl(raw: string): string | null {
31
33
  const trimmed = raw.trim()
32
34
  if (trimmed === '') return null
33
35
  if (trimmed.startsWith('#')) return trimmed
36
+ if (trimmed.startsWith('//')) return null
34
37
  const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(trimmed)
35
38
  if (scheme === null) return trimmed
36
39
  const name = scheme[1].toLowerCase()
@@ -615,5 +615,18 @@ export function foldEvents(events: readonly WireEvent[], existing?: readonly Ren
615
615
  if (event.seq <= watermark) continue
616
616
  applyEvent(state, event)
617
617
  }
618
- return [...state.messages].sort((a, b) => a.seq - b.seq || (a.id < b.id ? -1 : 1))
618
+ // Incremental tails (the live-stream hot path) are appended in seq order
619
+ // or updated in place, so the list is usually ordered already — verify in
620
+ // O(n) and skip the O(n log n) re-sort when it is.
621
+ const out = state.messages
622
+ let ordered = true
623
+ for (let index = 1; index < out.length; index += 1) {
624
+ const prev = out[index - 1]!
625
+ const current = out[index]!
626
+ if (prev.seq > current.seq || (prev.seq === current.seq && prev.id >= current.id)) {
627
+ ordered = false
628
+ break
629
+ }
630
+ }
631
+ return ordered ? out : [...out].sort((a, b) => a.seq - b.seq || (a.id < b.id ? -1 : 1))
619
632
  }
@@ -1020,4 +1020,52 @@ body {
1020
1020
  transition: none;
1021
1021
  }
1022
1022
  }
1023
+
1024
+ /* ── installed-app pairing ───────────────────────────────────────────── */
1025
+
1026
+ .mobile-pair {
1027
+ min-height: 100dvh;
1028
+ display: grid;
1029
+ place-items: center;
1030
+ padding: 24px;
1031
+ }
1032
+
1033
+ .mobile-pairCard {
1034
+ display: grid;
1035
+ width: min(100%, 420px);
1036
+ gap: 12px;
1037
+ padding: 24px;
1038
+ border: 1px solid var(--m-border);
1039
+ border-radius: 12px;
1040
+ background: var(--m-bg-raised);
1041
+ box-shadow: var(--m-shadow-raise);
1042
+ }
1043
+
1044
+ .mobile-pairLabel {
1045
+ color: var(--m-text-secondary);
1046
+ font-size: 13px;
1047
+ font-weight: 600;
1048
+ }
1049
+
1050
+ .mobile-pairInput {
1051
+ width: 100%;
1052
+ min-height: 42px;
1053
+ box-sizing: border-box;
1054
+ padding: 9px 12px;
1055
+ border: 1px solid var(--m-border);
1056
+ border-radius: 8px;
1057
+ background: var(--m-bg-input);
1058
+ color: var(--m-text);
1059
+ font: inherit;
1060
+ outline: none;
1061
+ }
1062
+
1063
+ .mobile-pairInput:focus-visible {
1064
+ border-color: var(--m-accent);
1065
+ box-shadow: 0 0 0 2px var(--m-bg), 0 0 0 4px var(--m-accent);
1066
+ }
1067
+
1068
+ .mobile-pairSubmit {
1069
+ width: 100%;
1070
+ }
1023
1071
  `
@@ -0,0 +1,77 @@
1
+ /** Pairing helpers owned by the standalone /m mobile surface. */
2
+ export type PairFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
3
+
4
+ export interface MobilePairInput {
5
+ token: string
6
+ workspaceId?: string
7
+ }
8
+
9
+ export type MobilePairAccept =
10
+ | { ok: true }
11
+ | { ok: false; message: string }
12
+
13
+ export type MobilePairBootstrap =
14
+ | { kind: 'none' }
15
+ | { kind: 'accepted'; path: string }
16
+ | { kind: 'failed'; path: string; message: string }
17
+
18
+ const MOBILE_ROOT = '/m/'
19
+
20
+ /** Parse a pairing token or a copied pairing link without following its origin. */
21
+ export function parseMobilePairInput(value: string): MobilePairInput | undefined {
22
+ const trimmed = value.trim()
23
+ if (trimmed === '') return undefined
24
+
25
+ try {
26
+ const url = new URL(trimmed)
27
+ const token = url.searchParams.get('pair')
28
+ if (token === null || token === '') return undefined
29
+ const workspaceId = url.searchParams.get('workspace')
30
+ return {
31
+ token,
32
+ ...(workspaceId !== null && workspaceId !== '' ? { workspaceId } : {}),
33
+ }
34
+ } catch {
35
+ return { token: trimmed }
36
+ }
37
+ }
38
+
39
+ /** Build the safe, token-free mobile destination after successful pairing. */
40
+ export function mobilePairPath(workspaceId?: string): string {
41
+ return workspaceId === undefined ? MOBILE_ROOT : MOBILE_ROOT + '?workspace=' + encodeURIComponent(workspaceId)
42
+ }
43
+
44
+ /** Accept one pairing token on this exact browser or installed-web-app context. */
45
+ export async function acceptMobilePair(token: string, fetcher: PairFetch = fetch): Promise<MobilePairAccept> {
46
+ try {
47
+ const response = await fetcher('/api/pair/accept', {
48
+ method: 'POST',
49
+ headers: { 'content-type': 'application/json' },
50
+ body: JSON.stringify({ token }),
51
+ })
52
+ if (response.ok) return { ok: true }
53
+ if (response.status === 404) return { ok: false, message: '配对链接无效或已过期。' }
54
+ if (response.status === 409) return { ok: false, message: '配对链接已被使用。' }
55
+ return { ok: false, message: '此设备无法使用该配对链接。' }
56
+ } catch {
57
+ return { ok: false, message: '无法连接到配对服务。' }
58
+ }
59
+ }
60
+
61
+ /** Consume a QR pairing token before the mobile application starts making RPC calls. */
62
+ export async function consumeMobilePairUrl(href: string, fetcher: PairFetch = fetch): Promise<MobilePairBootstrap> {
63
+ let url: URL
64
+ try {
65
+ url = new URL(href)
66
+ } catch {
67
+ return { kind: 'none' }
68
+ }
69
+
70
+ const token = url.searchParams.get('pair')
71
+ if (token === null || token === '') return { kind: 'none' }
72
+
73
+ const workspaceId = url.searchParams.get('workspace')
74
+ const path = mobilePairPath(workspaceId === null || workspaceId === '' ? undefined : workspaceId)
75
+ const result = await acceptMobilePair(token, fetcher)
76
+ return result.ok ? { kind: 'accepted', path } : { kind: 'failed', path, message: result.message }
77
+ }