@linxin666/dsh-remote-web-ui 0.3.3 → 0.3.5
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/assets/mobile-service-worker.js +1 -2
- package/lib/client.js +95 -6
- package/lib/client.js.map +1 -1
- package/lib/index.js +122 -8
- package/lib/mobile.js +2 -6
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/FenceNotice.d.ts +12 -9
- package/lib/types/client/FenceNotice.d.ts.map +1 -1
- package/lib/types/client/FenceNotice.js +67 -2
- package/lib/types/client/PluginSettingsCard.d.ts +2 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +2 -2
- package/lib/types/client/locales.d.ts +12 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +12 -0
- package/lib/types/dsh-home.d.ts.map +1 -1
- package/lib/types/dsh-home.js +9 -3
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +24 -2
- package/lib/types/mobile/api.d.ts.map +1 -1
- package/lib/types/mobile/api.js +2 -3
- package/lib/types/mobile-api.d.ts +14 -0
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +53 -1
- package/lib/types/mobile-routes.d.ts.map +1 -1
- package/lib/types/mobile-routes.js +25 -2
- package/package.json +3 -1
- package/src/client/FenceNotice.tsx +81 -1
- package/src/client/PluginSettingsCard.tsx +4 -2
- package/src/client/locales.ts +12 -0
- package/src/client/remote.module.css +72 -4
- package/src/client/settings-card.module.css +5 -0
- package/src/dsh-home.ts +9 -3
- package/src/index.ts +26 -2
- package/src/mobile/api.ts +2 -3
- package/src/mobile-api.ts +59 -0
- package/src/mobile-routes.ts +24 -2
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* torn down (requirePairingForLan off / plugin disabled), so it never
|
|
7
7
|
* outlives the unpaired state it describes.
|
|
8
8
|
*/
|
|
9
|
+
import { useState, type FormEvent } from 'react'
|
|
9
10
|
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
|
11
|
+
import { acceptPair } from './pair-api.ts'
|
|
10
12
|
import css from './remote.module.css'
|
|
11
13
|
|
|
12
14
|
/** Notice props: localized copy. */
|
|
@@ -14,6 +16,24 @@ export interface FenceNoticeProps {
|
|
|
14
16
|
t: TranslateNS<'remote'>
|
|
15
17
|
/** Retry after the user has opened a freshly issued computer pairing link. */
|
|
16
18
|
onRetry: () => void
|
|
19
|
+
/** Optional custom pairing acceptor for testing / dependency injection. */
|
|
20
|
+
onAccept?: (token: string) => Promise<{ ok: true } | { ok: false; code: 'invalid' | 'used' | 'forbidden' }>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Extract a pairing token from either a raw token string or a copied pairing link URL.
|
|
25
|
+
*/
|
|
26
|
+
export function extractPairToken(input: string): string | undefined {
|
|
27
|
+
const trimmed = input.trim()
|
|
28
|
+
if (trimmed === '') return undefined
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(trimmed)
|
|
31
|
+
const token = url.searchParams.get('pair')
|
|
32
|
+
if (token !== null && token !== '') return token
|
|
33
|
+
} catch {
|
|
34
|
+
// Plain token string
|
|
35
|
+
}
|
|
36
|
+
return trimmed
|
|
17
37
|
}
|
|
18
38
|
|
|
19
39
|
/**
|
|
@@ -21,7 +41,39 @@ export interface FenceNoticeProps {
|
|
|
21
41
|
* @param props - localized copy.
|
|
22
42
|
* @returns the notice element.
|
|
23
43
|
*/
|
|
24
|
-
export function FenceNotice({ t, onRetry }: FenceNoticeProps) {
|
|
44
|
+
export function FenceNotice({ t, onRetry, onAccept = acceptPair }: FenceNoticeProps) {
|
|
45
|
+
const [tokenInput, setTokenInput] = useState('')
|
|
46
|
+
const [submitting, setSubmitting] = useState(false)
|
|
47
|
+
const [errorMsg, setErrorMsg] = useState<string | undefined>(undefined)
|
|
48
|
+
|
|
49
|
+
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
50
|
+
event.preventDefault()
|
|
51
|
+
const token = extractPairToken(tokenInput)
|
|
52
|
+
if (!token) return
|
|
53
|
+
|
|
54
|
+
setSubmitting(true)
|
|
55
|
+
setErrorMsg(undefined)
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const result = await onAccept(token)
|
|
59
|
+
if (result.ok) {
|
|
60
|
+
onRetry()
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
if (result.code === 'invalid') {
|
|
64
|
+
setErrorMsg(t('fence.unpaired.tokenInvalid'))
|
|
65
|
+
} else if (result.code === 'used') {
|
|
66
|
+
setErrorMsg(t('fence.unpaired.tokenUsed'))
|
|
67
|
+
} else {
|
|
68
|
+
setErrorMsg(t('fence.unpaired.tokenFailed'))
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
setErrorMsg(t('fence.unpaired.tokenFailed'))
|
|
72
|
+
} finally {
|
|
73
|
+
setSubmitting(false)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
25
77
|
return (
|
|
26
78
|
<div className={css.fencePage} role="dialog" aria-modal="true" aria-labelledby="remote-fence-title">
|
|
27
79
|
<main className={css.fenceCard} data-dsh-plugin="remote-web-ui">
|
|
@@ -34,6 +86,34 @@ export function FenceNotice({ t, onRetry }: FenceNoticeProps) {
|
|
|
34
86
|
<li>{t('fence.unpaired.stepLink')}</li>
|
|
35
87
|
<li>{t('fence.unpaired.stepOpen')}</li>
|
|
36
88
|
</ol>
|
|
89
|
+
|
|
90
|
+
<form className={css.fenceForm} onSubmit={handleSubmit}>
|
|
91
|
+
<div className={css.fenceInputRow}>
|
|
92
|
+
<input
|
|
93
|
+
type="text"
|
|
94
|
+
className={css.fenceInput}
|
|
95
|
+
placeholder={t('fence.unpaired.tokenPlaceholder')}
|
|
96
|
+
value={tokenInput}
|
|
97
|
+
onChange={event => {
|
|
98
|
+
setTokenInput(event.target.value)
|
|
99
|
+
if (errorMsg !== undefined) setErrorMsg(undefined)
|
|
100
|
+
}}
|
|
101
|
+
disabled={submitting}
|
|
102
|
+
aria-label={t('fence.unpaired.tokenPlaceholder')}
|
|
103
|
+
/>
|
|
104
|
+
<button
|
|
105
|
+
className={css.fencePairButton}
|
|
106
|
+
type="submit"
|
|
107
|
+
disabled={submitting || tokenInput.trim() === ''}
|
|
108
|
+
>
|
|
109
|
+
{submitting ? t('fence.unpaired.pairing') : t('fence.unpaired.pairAction')}
|
|
110
|
+
</button>
|
|
111
|
+
</div>
|
|
112
|
+
{errorMsg !== undefined && (
|
|
113
|
+
<p className={css.fenceError} role="alert">{errorMsg}</p>
|
|
114
|
+
)}
|
|
115
|
+
</form>
|
|
116
|
+
|
|
37
117
|
<button className={css.fenceRetry} type="button" onClick={onRetry}>
|
|
38
118
|
{t('fence.unpaired.retry')}
|
|
39
119
|
</button>
|
|
@@ -39,6 +39,8 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
|
|
|
39
39
|
titleKey: TKey
|
|
40
40
|
/** Locale key of the line describing what this plugin's settings govern. */
|
|
41
41
|
descriptionKey: TKey
|
|
42
|
+
/** Optional rich header description rendered instead of the plain t(descriptionKey) text (the plain text stays as the tooltip). */
|
|
43
|
+
descriptionNode?: ReactNode
|
|
42
44
|
/** The card's form state: availability, writability, and what a save would do. */
|
|
43
45
|
state: CardShell
|
|
44
46
|
/** Write every staged edit. */
|
|
@@ -87,7 +89,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
|
|
|
87
89
|
<div className={css.headerStatic}>
|
|
88
90
|
<span className={css.headText}>
|
|
89
91
|
<span className={css.name} title={title}>{title}</span>
|
|
90
|
-
<span className={css.description} title={description}>{description}</span>
|
|
92
|
+
<span className={css.description} title={description}>{props.descriptionNode ?? description}</span>
|
|
91
93
|
</span>
|
|
92
94
|
{state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
|
|
93
95
|
</div>
|
|
@@ -102,7 +104,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
|
|
|
102
104
|
>
|
|
103
105
|
<span className={css.headText}>
|
|
104
106
|
<span className={css.name} title={title}>{title}</span>
|
|
105
|
-
<span className={css.description} title={description}>{description}</span>
|
|
107
|
+
<span className={css.description} title={description}>{props.descriptionNode ?? description}</span>
|
|
106
108
|
</span>
|
|
107
109
|
{state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
|
|
108
110
|
<svg
|
package/src/client/locales.ts
CHANGED
|
@@ -37,6 +37,12 @@ export const zh = {
|
|
|
37
37
|
'fence.unpaired.stepLink': '在“设备配对”中复制电脑配对链接。',
|
|
38
38
|
'fence.unpaired.stepOpen': '在当前浏览器打开该链接,完成授权后即可进入。',
|
|
39
39
|
'fence.unpaired.retry': '重新检测',
|
|
40
|
+
'fence.unpaired.tokenPlaceholder': '或在此直接粘贴配对链接 / Token',
|
|
41
|
+
'fence.unpaired.pairAction': '立即配对',
|
|
42
|
+
'fence.unpaired.pairing': '配对中…',
|
|
43
|
+
'fence.unpaired.tokenInvalid': '配对链接或 Token 无效已过期',
|
|
44
|
+
'fence.unpaired.tokenUsed': '该配对链接已被使用',
|
|
45
|
+
'fence.unpaired.tokenFailed': '配对失败,请检查网络或重新获取链接',
|
|
40
46
|
'fence.unpaired.footnote': '请勿使用他人提供的配对链接;管理员可随时取消此设备的授权。',
|
|
41
47
|
'posture.exposed': '/api 通道对未配对设备敞开',
|
|
42
48
|
'posture.exposedHint': '以下来源的请求未经配对即可访问完整桌面 API:{hosts}。请移除对应来源的 --trusted-host(配对机制已覆盖远程访问),或改为仅绑定 127.0.0.1 并使用隧道。',
|
|
@@ -184,6 +190,12 @@ export const en = {
|
|
|
184
190
|
'fence.unpaired.stepLink': 'Copy the computer pairing link under Pair a device.',
|
|
185
191
|
'fence.unpaired.stepOpen': 'Open that link in this browser. After authorization, the workspace will become available.',
|
|
186
192
|
'fence.unpaired.retry': 'Check again',
|
|
193
|
+
'fence.unpaired.tokenPlaceholder': 'Or paste pairing link / token here',
|
|
194
|
+
'fence.unpaired.pairAction': 'Pair Now',
|
|
195
|
+
'fence.unpaired.pairing': 'Pairing…',
|
|
196
|
+
'fence.unpaired.tokenInvalid': 'Pairing link or token is invalid or expired',
|
|
197
|
+
'fence.unpaired.tokenUsed': 'This pairing link has already been used',
|
|
198
|
+
'fence.unpaired.tokenFailed': 'Pairing failed, please check network or issue a new link',
|
|
187
199
|
'fence.unpaired.footnote': 'Do not use pairing links from people you do not trust. An administrator can revoke this device at any time.',
|
|
188
200
|
'posture.exposed': 'The /api channel is open to unpaired devices',
|
|
189
201
|
'posture.exposedHint': 'Requests from {hosts} reach the full desktop API without pairing. Remove --trusted-host for them (pairing already covers remote access), or bind loopback only and use the tunnel.',
|
|
@@ -484,23 +484,91 @@
|
|
|
484
484
|
margin-top: 8px;
|
|
485
485
|
}
|
|
486
486
|
|
|
487
|
-
.
|
|
487
|
+
.fenceForm {
|
|
488
|
+
margin-top: 20px;
|
|
488
489
|
width: 100%;
|
|
489
|
-
|
|
490
|
-
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
.fenceInputRow {
|
|
493
|
+
display: flex;
|
|
494
|
+
gap: 8px;
|
|
495
|
+
width: 100%;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
.fenceInput {
|
|
499
|
+
flex: 1;
|
|
500
|
+
min-width: 0;
|
|
501
|
+
padding: 10px 14px;
|
|
502
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
503
|
+
border-radius: 10px;
|
|
504
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
505
|
+
color: var(--dsw-alias-label-primary);
|
|
506
|
+
font: inherit;
|
|
507
|
+
font-size: 13px;
|
|
508
|
+
transition: border-color 120ms ease, box-shadow 120ms ease;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
.fenceInput:focus {
|
|
512
|
+
outline: none;
|
|
513
|
+
border-color: var(--dsw-alias-brand-primary);
|
|
514
|
+
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.15);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
.fencePairButton {
|
|
518
|
+
flex-shrink: 0;
|
|
519
|
+
padding: 10px 18px;
|
|
491
520
|
border: 1px solid var(--dsw-alias-brand-primary);
|
|
492
521
|
border-radius: 10px;
|
|
493
522
|
background: var(--dsw-alias-brand-primary);
|
|
494
523
|
color: #fff;
|
|
495
524
|
font: inherit;
|
|
525
|
+
font-size: 13px;
|
|
496
526
|
font-weight: 500;
|
|
497
527
|
cursor: pointer;
|
|
528
|
+
transition: filter 120ms ease, opacity 120ms ease;
|
|
498
529
|
}
|
|
499
530
|
|
|
500
|
-
.
|
|
531
|
+
.fencePairButton:hover:not(:disabled) {
|
|
501
532
|
filter: brightness(0.96);
|
|
502
533
|
}
|
|
503
534
|
|
|
535
|
+
.fencePairButton:disabled {
|
|
536
|
+
opacity: 0.55;
|
|
537
|
+
cursor: not-allowed;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
.fencePairButton:focus-visible {
|
|
541
|
+
outline: 2px solid var(--dsw-alias-brand-primary);
|
|
542
|
+
outline-offset: 2px;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
.fenceError {
|
|
546
|
+
margin: 10px 0 0;
|
|
547
|
+
color: var(--dsw-alias-state-error-primary);
|
|
548
|
+
font-size: 13px;
|
|
549
|
+
line-height: 1.4;
|
|
550
|
+
text-align: left;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
.fenceRetry {
|
|
554
|
+
width: 100%;
|
|
555
|
+
margin-top: 12px;
|
|
556
|
+
padding: 10px 16px;
|
|
557
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
558
|
+
border-radius: 10px;
|
|
559
|
+
background: transparent;
|
|
560
|
+
color: var(--dsw-alias-label-secondary);
|
|
561
|
+
font: inherit;
|
|
562
|
+
font-weight: 500;
|
|
563
|
+
cursor: pointer;
|
|
564
|
+
transition: background-color 120ms ease, color 120ms ease;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
.fenceRetry:hover {
|
|
568
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
569
|
+
color: var(--dsw-alias-label-primary);
|
|
570
|
+
}
|
|
571
|
+
|
|
504
572
|
.fenceRetry:focus-visible {
|
|
505
573
|
outline: 2px solid var(--dsw-alias-brand-primary);
|
|
506
574
|
outline-offset: 3px;
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
.header {
|
|
25
25
|
appearance: none;
|
|
26
26
|
width: 100%;
|
|
27
|
+
/* The suite does not force a global border-box, so width:100% + padding
|
|
28
|
+
would stretch the row 32px past the card and push the trailing pending
|
|
29
|
+
badge (last flex item) outside the card border. */
|
|
30
|
+
box-sizing: border-box;
|
|
27
31
|
font: inherit;
|
|
28
32
|
color: inherit;
|
|
29
33
|
text-align: left;
|
|
@@ -44,6 +48,7 @@
|
|
|
44
48
|
|
|
45
49
|
.headerStatic {
|
|
46
50
|
width: 100%;
|
|
51
|
+
box-sizing: border-box;
|
|
47
52
|
border-radius: 12px;
|
|
48
53
|
align-items: center;
|
|
49
54
|
gap: 12px;
|
package/src/dsh-home.ts
CHANGED
|
@@ -7,11 +7,14 @@
|
|
|
7
7
|
|
|
8
8
|
import { homedir } from 'node:os'
|
|
9
9
|
import { isAbsolute, join } from 'node:path'
|
|
10
|
+
import { isAbsolute as posixIsAbsolute, join as posixJoin } from 'node:path/posix'
|
|
10
11
|
|
|
11
12
|
/** Expand a leading ~ (or ~user) in a path, platform-style. */
|
|
12
13
|
export function expandHome(path: string, home: string = homedir()): string {
|
|
14
|
+
const isPosix = home.startsWith('/')
|
|
15
|
+
const j = isPosix ? posixJoin : join
|
|
13
16
|
if (path === '~') return home
|
|
14
|
-
if (path.startsWith('~/') || path.startsWith('~\\')) return
|
|
17
|
+
if (path.startsWith('~/') || path.startsWith('~\\')) return j(home, path.slice(2))
|
|
15
18
|
return path
|
|
16
19
|
}
|
|
17
20
|
|
|
@@ -22,12 +25,15 @@ export function expandHome(path: string, home: string = homedir()): string {
|
|
|
22
25
|
* @returns the absolute DSH home path.
|
|
23
26
|
*/
|
|
24
27
|
export function resolveDshHome(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string {
|
|
28
|
+
const isPosix = home.startsWith('/')
|
|
29
|
+
const j = isPosix ? posixJoin : join
|
|
30
|
+
const isAbs = isPosix ? posixIsAbsolute : isAbsolute
|
|
25
31
|
const raw = env.DSH_HOME
|
|
26
32
|
if (raw !== undefined && raw.trim() !== '') {
|
|
27
33
|
const expanded = expandHome(raw.trim(), home)
|
|
28
|
-
return
|
|
34
|
+
return isAbs(expanded) ? expanded : j(process.cwd(), expanded)
|
|
29
35
|
}
|
|
30
|
-
return
|
|
36
|
+
return j(home, '.dsh')
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
/** Resolve the DSH home directory from the live environment. */
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,8 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
16
16
|
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
17
17
|
import z from 'schemastery'
|
|
18
18
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
19
|
+
import type {} from '@deepseek-ai/dsh-agent'
|
|
20
|
+
import type {} from '@deepseek-ai/dsh-commands'
|
|
19
21
|
import { DEFAULT_IDLE_EXPIRE_MS, PairingService, type PairingConfig } from './pairing.ts'
|
|
20
22
|
import { dshHome } from './dsh-home.ts'
|
|
21
23
|
import { isPairedDeviceRequest, makeGateListener } from './gate.ts'
|
|
@@ -66,7 +68,7 @@ declare module '@deepseek-ai/cordis' {
|
|
|
66
68
|
export const name = 'remote-web-ui'
|
|
67
69
|
|
|
68
70
|
/** Services required before the pairing surfaces can mount. */
|
|
69
|
-
export const inject = ['webServer', 'apiProxy']
|
|
71
|
+
export const inject = ['webServer', 'apiProxy', 'commands', 'agents']
|
|
70
72
|
|
|
71
73
|
/**
|
|
72
74
|
* Settings namespace of the remote-control capability — the section the web
|
|
@@ -374,7 +376,23 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
374
376
|
...makeRoutes({ service, lanAddresses, requirePairingForLan: () => resolve().requirePairingForLan }),
|
|
375
377
|
...makeMobileRoutes(),
|
|
376
378
|
...(apiProxy !== undefined
|
|
377
|
-
? makeMobileApiRoutes({
|
|
379
|
+
? makeMobileApiRoutes({
|
|
380
|
+
service,
|
|
381
|
+
apiProxy,
|
|
382
|
+
pendingTracker: new PendingTracker(),
|
|
383
|
+
mobileEnterToSend: () => resolve().mobileEnterToSend,
|
|
384
|
+
commandDispatcher: ctx.commands !== undefined && ctx.agents !== undefined
|
|
385
|
+
? {
|
|
386
|
+
async execute(sessionId, line, signal) {
|
|
387
|
+
const agent = ctx.agents.get(sessionId as never)
|
|
388
|
+
if (agent === undefined) return { error: 'session-not-found' as const }
|
|
389
|
+
const execution = await ctx.commands.execute(agent, line, [], signal)
|
|
390
|
+
if (execution === undefined) return { error: 'unknown-command' as const }
|
|
391
|
+
return { ok: true as const, result: execution.result }
|
|
392
|
+
},
|
|
393
|
+
}
|
|
394
|
+
: undefined,
|
|
395
|
+
})
|
|
378
396
|
: []),
|
|
379
397
|
...(apiProxy !== undefined ? makePairedModelCatalogRoutes({ service, apiProxy, lanAddresses }) : []),
|
|
380
398
|
// The remote desktop channel: policy-gated `/remote` prefix that
|
|
@@ -444,6 +462,12 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
444
462
|
if (!resolve().enabled) return false
|
|
445
463
|
return isPairedDeviceRequest(service, request)
|
|
446
464
|
})
|
|
465
|
+
|
|
466
|
+
if (lanAddresses.length > 0) {
|
|
467
|
+
const urls = lanAddresses.map(ip => `http://${ip}:${String(ctx.webServer.port)}/m/`).join(' , ')
|
|
468
|
+
console.log(`remote-web-ui: mobile UI reachable on LAN at ${urls}`)
|
|
469
|
+
}
|
|
470
|
+
|
|
447
471
|
const sync = (): void => {
|
|
448
472
|
const value = resolve()
|
|
449
473
|
service.config = pairingConfigOf(value)
|
package/src/mobile/api.ts
CHANGED
|
@@ -105,10 +105,9 @@ export async function prompt(sessionId: string, text: string): Promise<void> {
|
|
|
105
105
|
|
|
106
106
|
/** Send one slash command line (e.g. `/permission workspace-write`). */
|
|
107
107
|
export async function sendCommand(sessionId: string, line: string): Promise<unknown> {
|
|
108
|
-
return await callUnary<unknown>('
|
|
108
|
+
return await callUnary<unknown>('mobile.command', {
|
|
109
109
|
sessionId,
|
|
110
|
-
|
|
111
|
-
content: [{ type: 'text', text: line }],
|
|
110
|
+
line,
|
|
112
111
|
})
|
|
113
112
|
}
|
|
114
113
|
|
package/src/mobile-api.ts
CHANGED
|
@@ -64,6 +64,8 @@ const MOBILE_ALLOWLIST = new Set([
|
|
|
64
64
|
const MOBILE_PREFERENCES_METHOD = 'mobile.preferences'
|
|
65
65
|
const MOBILE_PENDING_METHOD = 'mobile.pending'
|
|
66
66
|
const MOBILE_RESPOND_METHOD = 'mobile.respond'
|
|
67
|
+
/** Execute a slash command through the host command registry (#1125). */
|
|
68
|
+
const MOBILE_COMMAND_METHOD = 'mobile.command'
|
|
67
69
|
|
|
68
70
|
/** One session.list page (thin phones load incrementally). */
|
|
69
71
|
const SESSION_PAGE_SIZE = 20
|
|
@@ -102,6 +104,19 @@ export interface MobileApiDeps {
|
|
|
102
104
|
mobileEnterToSend: () => boolean
|
|
103
105
|
/** SSE keep-alive ping cadence for the mux stream (default 15000 ms; test seam). */
|
|
104
106
|
eventsHeartbeatMs?: number
|
|
107
|
+
/**
|
|
108
|
+
* Optional command dispatcher for executing slash commands on the mobile
|
|
109
|
+
* surface. When absent, `mobile.command` returns an error; the mobile UI
|
|
110
|
+
* can still fall back to `session.prompt` (which sends the line to the
|
|
111
|
+
* model, not the command registry).
|
|
112
|
+
*/
|
|
113
|
+
commandDispatcher?: {
|
|
114
|
+
execute(
|
|
115
|
+
sessionId: string,
|
|
116
|
+
line: string,
|
|
117
|
+
signal: AbortSignal,
|
|
118
|
+
): Promise<{ ok: true; result: unknown } | { error: string }>
|
|
119
|
+
}
|
|
105
120
|
}
|
|
106
121
|
|
|
107
122
|
/** Mobile API route paths. */
|
|
@@ -161,6 +176,7 @@ export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
|
|
|
161
176
|
const local = method === MOBILE_PREFERENCES_METHOD
|
|
162
177
|
|| method === MOBILE_PENDING_METHOD
|
|
163
178
|
|| method === MOBILE_RESPOND_METHOD
|
|
179
|
+
|| method === MOBILE_COMMAND_METHOD
|
|
164
180
|
if (!MOBILE_ALLOWLIST.has(method) && !local) {
|
|
165
181
|
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: `method ${method} is not exposed to the mobile surface` } })
|
|
166
182
|
return
|
|
@@ -213,6 +229,49 @@ export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
|
|
|
213
229
|
result: { ok: false, error: { code: 'internal', message } },
|
|
214
230
|
})
|
|
215
231
|
}
|
|
232
|
+
} else if (method === MOBILE_COMMAND_METHOD) {
|
|
233
|
+
const payload = parsed.payload as { sessionId?: string; line?: string } | undefined
|
|
234
|
+
const sessionId = payload?.sessionId
|
|
235
|
+
const line = payload?.line
|
|
236
|
+
if (typeof sessionId !== 'string' || typeof line !== 'string') {
|
|
237
|
+
writeJson(res, 200, {
|
|
238
|
+
type: 'server-response',
|
|
239
|
+
rpcId,
|
|
240
|
+
result: { ok: false, error: { code: 'bad-request', message: 'missing sessionId or line' } },
|
|
241
|
+
})
|
|
242
|
+
} else if (deps.commandDispatcher === undefined) {
|
|
243
|
+
writeJson(res, 200, {
|
|
244
|
+
type: 'server-response',
|
|
245
|
+
rpcId,
|
|
246
|
+
result: { ok: false, error: { code: 'unavailable', message: 'command dispatcher is not available' } },
|
|
247
|
+
})
|
|
248
|
+
} else {
|
|
249
|
+
try {
|
|
250
|
+
const abort = new AbortController()
|
|
251
|
+
res.on('close', () => { if (!res.writableEnded) abort.abort() })
|
|
252
|
+
const outcome = await deps.commandDispatcher.execute(sessionId, line, abort.signal)
|
|
253
|
+
if ('error' in outcome) {
|
|
254
|
+
writeJson(res, 200, {
|
|
255
|
+
type: 'server-response',
|
|
256
|
+
rpcId,
|
|
257
|
+
result: { ok: false, error: { code: outcome.error, message: outcome.error } },
|
|
258
|
+
})
|
|
259
|
+
} else {
|
|
260
|
+
writeJson(res, 200, {
|
|
261
|
+
type: 'server-response',
|
|
262
|
+
rpcId,
|
|
263
|
+
result: { ok: true, value: { matched: true, result: outcome.result } },
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
} catch (error) {
|
|
267
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
268
|
+
writeJson(res, 200, {
|
|
269
|
+
type: 'server-response',
|
|
270
|
+
rpcId,
|
|
271
|
+
result: { ok: false, error: { code: 'internal', message } },
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
}
|
|
216
275
|
}
|
|
217
276
|
return
|
|
218
277
|
}
|
package/src/mobile-routes.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { readFile } from 'node:fs/promises'
|
|
9
9
|
import { existsSync } from 'node:fs'
|
|
10
10
|
import { fileURLToPath } from 'node:url'
|
|
11
|
+
import { gzipSync } from 'node:zlib'
|
|
11
12
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
12
13
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
13
14
|
|
|
@@ -147,7 +148,8 @@ export function makeMobileRoutes(): WebRoute[] {
|
|
|
147
148
|
// The bundle is immutable for the process lifetime (a rebuild requires a
|
|
148
149
|
// host restart), so the body is read from disk once, not per phone.
|
|
149
150
|
let bundleBody: string | undefined
|
|
150
|
-
|
|
151
|
+
let bundleGzip: Buffer | undefined
|
|
152
|
+
const handleBundle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
151
153
|
if (bundleBody === undefined) {
|
|
152
154
|
const path = mobileBundlePath()
|
|
153
155
|
if (!existsSync(path)) {
|
|
@@ -156,12 +158,32 @@ export function makeMobileRoutes(): WebRoute[] {
|
|
|
156
158
|
}
|
|
157
159
|
try {
|
|
158
160
|
bundleBody = await readFile(path, 'utf8')
|
|
161
|
+
bundleGzip = gzipSync(Buffer.from(bundleBody, 'utf8'))
|
|
159
162
|
} catch {
|
|
160
163
|
writeStatic(res, 500, 'text/plain', 'failed to read the mobile bundle')
|
|
161
164
|
return
|
|
162
165
|
}
|
|
163
166
|
}
|
|
164
|
-
|
|
167
|
+
const acceptEncoding = req.headers['accept-encoding'] ?? ''
|
|
168
|
+
const acceptsGzip = typeof acceptEncoding === 'string' && acceptEncoding.includes('gzip') && bundleGzip !== undefined
|
|
169
|
+
if (acceptsGzip) {
|
|
170
|
+
res.writeHead(200, {
|
|
171
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
172
|
+
'content-encoding': 'gzip',
|
|
173
|
+
'cache-control': 'no-cache',
|
|
174
|
+
vary: 'Accept-Encoding',
|
|
175
|
+
'referrer-policy': 'no-referrer',
|
|
176
|
+
})
|
|
177
|
+
res.end(bundleGzip)
|
|
178
|
+
} else {
|
|
179
|
+
res.writeHead(200, {
|
|
180
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
181
|
+
'cache-control': 'no-cache',
|
|
182
|
+
vary: 'Accept-Encoding',
|
|
183
|
+
'referrer-policy': 'no-referrer',
|
|
184
|
+
})
|
|
185
|
+
res.end(bundleBody)
|
|
186
|
+
}
|
|
165
187
|
}
|
|
166
188
|
|
|
167
189
|
let workerBody: string | undefined
|