@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.
- package/README.i18n.yaml +2 -2
- package/README.md +41 -19
- package/README.zh.md +17 -12
- package/assets/mobile-icon-192.png +0 -0
- package/assets/mobile-icon-512.png +0 -0
- package/assets/mobile-service-worker.js +64 -0
- package/lib/client.js +57 -13
- package/lib/client.js.map +1 -1
- package/lib/index.js +408 -130
- package/lib/mobile.js +296 -22
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/FooterRemoteEntry.js +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +7 -1
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +5 -3
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +43 -13
- package/lib/types/client/RemotePanel.d.ts +1 -0
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +2 -2
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +3 -3
- package/lib/types/client/UpdateEntry.d.ts.map +1 -1
- package/lib/types/client/UpdateEntry.js +37 -4
- package/lib/types/client/UpdatePanel.js +1 -1
- package/lib/types/client/deep-link.js +2 -2
- package/lib/types/client/index.js +7 -7
- package/lib/types/client/locales.d.ts +2 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +2 -0
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/update-api.d.ts.map +1 -1
- package/lib/types/gate.d.ts +9 -0
- package/lib/types/gate.d.ts.map +1 -1
- package/lib/types/gate.js +16 -5
- package/lib/types/http.d.ts +14 -0
- package/lib/types/http.d.ts.map +1 -0
- package/lib/types/http.js +27 -0
- package/lib/types/index.d.ts +28 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +41 -22
- package/lib/types/invariant.d.ts.map +1 -1
- package/lib/types/mobile/PairRequiredView.d.ts +7 -0
- package/lib/types/mobile/PairRequiredView.d.ts.map +1 -0
- package/lib/types/mobile/PairRequiredView.js +28 -0
- package/lib/types/mobile/PairRequiredView.test.d.ts +2 -0
- package/lib/types/mobile/PairRequiredView.test.d.ts.map +1 -0
- package/lib/types/mobile/PairRequiredView.test.js +24 -0
- package/lib/types/mobile/api.js +1 -1
- package/lib/types/mobile/index.js +17 -4
- package/lib/types/mobile/markdown.d.ts +6 -4
- package/lib/types/mobile/markdown.d.ts.map +1 -1
- package/lib/types/mobile/markdown.js +8 -4
- package/lib/types/mobile/markdown.test.js +5 -1
- package/lib/types/mobile/messages.d.ts.map +1 -1
- package/lib/types/mobile/messages.js +14 -1
- package/lib/types/mobile/messages.test.js +1 -1
- package/lib/types/mobile/mobile-styles.d.ts +1 -1
- package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
- package/lib/types/mobile/mobile-styles.js +48 -0
- package/lib/types/mobile/mobile-theme.test.js +2 -2
- package/lib/types/mobile/mux.d.ts.map +1 -1
- package/lib/types/mobile/mux.js +1 -1
- package/lib/types/mobile/mux.test.js +1 -1
- package/lib/types/mobile/pairing.d.ts +31 -0
- package/lib/types/mobile/pairing.d.ts.map +1 -0
- package/lib/types/mobile/pairing.js +62 -0
- package/lib/types/mobile/rpc.d.ts.map +1 -1
- package/lib/types/mobile/theme-toggle.js +1 -1
- package/lib/types/mobile/views/App.d.ts +12 -16
- package/lib/types/mobile/views/App.d.ts.map +1 -1
- package/lib/types/mobile/views/App.js +39 -12
- package/lib/types/mobile/views/App.test.d.ts +2 -0
- package/lib/types/mobile/views/App.test.d.ts.map +1 -0
- package/lib/types/mobile/views/App.test.js +52 -0
- package/lib/types/mobile/views/ChatView.js +6 -6
- package/lib/types/mobile/views/ChatView.test.js +3 -3
- package/lib/types/mobile/views/SessionListView.js +3 -3
- package/lib/types/mobile/views/SessionListView.test.js +2 -2
- package/lib/types/mobile/views/WorkspaceView.js +3 -3
- package/lib/types/mobile/views/WorkspaceView.test.js +3 -3
- package/lib/types/mobile-api.d.ts +1 -1
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +11 -18
- package/lib/types/mobile-pwa.d.ts +6 -0
- package/lib/types/mobile-pwa.d.ts.map +1 -0
- package/lib/types/mobile-pwa.js +17 -0
- package/lib/types/mobile-routes.d.ts +5 -8
- package/lib/types/mobile-routes.d.ts.map +1 -1
- package/lib/types/mobile-routes.js +130 -39
- package/lib/types/mount-once.d.ts +25 -0
- package/lib/types/mount-once.d.ts.map +1 -0
- package/lib/types/mount-once.js +42 -0
- package/lib/types/pairing-access.d.ts +28 -0
- package/lib/types/pairing-access.d.ts.map +1 -0
- package/lib/types/pairing-access.js +26 -0
- package/lib/types/pairing.d.ts +27 -0
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +68 -0
- package/lib/types/pairing.persist.test.d.ts +2 -0
- package/lib/types/pairing.persist.test.d.ts.map +1 -0
- package/lib/types/pairing.persist.test.js +128 -0
- package/lib/types/routes.d.ts +6 -6
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +15 -20
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/update-routes.d.ts +2 -2
- package/lib/types/update-routes.d.ts.map +1 -1
- package/lib/types/update-routes.js +1 -6
- package/lib/types/update.d.ts +3 -3
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +40 -14
- package/package.json +12 -12
- package/src/client/PluginSettingsCard.tsx +10 -0
- package/src/client/RemoteEntry.tsx +40 -8
- package/src/client/RemotePanel.tsx +1 -1
- package/src/client/RemoteSettingsCard.tsx +1 -0
- package/src/client/UpdateEntry.tsx +36 -4
- package/src/client/deep-link.ts +1 -1
- package/src/client/locales.ts +2 -0
- package/src/client/remote.module.css +26 -0
- package/src/gate.ts +15 -3
- package/src/http.ts +30 -0
- package/src/index.ts +50 -16
- package/src/mobile/PairRequiredView.test.tsx +28 -0
- package/src/mobile/PairRequiredView.tsx +58 -0
- package/src/mobile/index.tsx +15 -1
- package/src/mobile/markdown.test.ts +5 -0
- package/src/mobile/markdown.ts +7 -4
- package/src/mobile/messages.ts +14 -1
- package/src/mobile/mobile-styles.ts +48 -0
- package/src/mobile/pairing.ts +77 -0
- package/src/mobile/views/App.test.tsx +62 -0
- package/src/mobile/views/App.tsx +53 -18
- package/src/mobile-api.ts +9 -17
- package/src/mobile-pwa.ts +19 -0
- package/src/mobile-routes.ts +137 -38
- package/src/mount-once.ts +48 -0
- package/src/pairing-access.ts +36 -0
- package/src/pairing.persist.test.ts +145 -0
- package/src/pairing.ts +70 -1
- package/src/routes.ts +12 -18
- package/src/update-routes.ts +1 -7
- 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
|
-
//
|
|
82
|
-
useEffect(() =>
|
|
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
|
-
|
|
91
|
-
|
|
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} />
|
package/src/client/deep-link.ts
CHANGED
|
@@ -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()
|
package/src/client/locales.ts
CHANGED
|
@@ -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
|
-
|
|
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'>> & {
|
|
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
|
|
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
|
+
}
|
package/src/mobile/index.tsx
CHANGED
|
@@ -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
|
-
|
|
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', () => {
|
package/src/mobile/markdown.ts
CHANGED
|
@@ -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
|
-
*
|
|
27
|
-
* — javascript:, data:, vbscript:, etc. —
|
|
28
|
-
*
|
|
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()
|
package/src/mobile/messages.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|