@linxin666/dsh-remote-web-ui 0.1.20 → 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 +37 -10
- package/lib/client.js.map +1 -1
- package/lib/index.js +290 -53
- package/lib/mobile.js +278 -17
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +6 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +5 -3
- package/lib/types/client/RemoteEntry.js +2 -2
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +1 -1
- package/lib/types/client/UpdateEntry.d.ts.map +1 -1
- package/lib/types/client/UpdateEntry.js +36 -3
- package/lib/types/client/deep-link.js +1 -1
- 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/gate.d.ts +9 -0
- package/lib/types/gate.d.ts.map +1 -1
- package/lib/types/gate.js +15 -4
- package/lib/types/index.d.ts +25 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +30 -13
- 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/index.js +14 -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/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/views/App.d.ts +12 -5
- package/lib/types/mobile/views/App.d.ts.map +1 -1
- package/lib/types/mobile/views/App.js +34 -7
- 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-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 +115 -30
- 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.js +1 -1
- package/lib/types/update.d.ts +3 -3
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +34 -11
- package/package.json +12 -12
- package/src/client/PluginSettingsCard.tsx +10 -0
- package/src/client/RemoteEntry.tsx +2 -2
- 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/index.ts +46 -15
- 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/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 -6
- package/src/mobile-pwa.ts +19 -0
- package/src/mobile-routes.ts +123 -30
- 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 +1 -1
- package/src/update.ts +40 -12
package/lib/types/update.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Remote update support for the dsh-web-ui family — host half. Detects the
|
|
3
|
-
* installed aggregate package (@linxin666/dsh-web-ui-all)
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* installed aggregate package (@linxin666/dsh-web-ui-all), or the directly
|
|
4
|
+
* installed family packages when the aggregate is absent, probes npm for newer
|
|
5
|
+
* releases, and runs `pnpm update --latest` inside the owning dsh profile.
|
|
6
6
|
*
|
|
7
7
|
* Pure logic with injected seams (manifest reading, registry fetches, process
|
|
8
8
|
* spawning) so the whole surface is unit-testable without touching disk,
|
|
@@ -173,10 +173,13 @@ export function resolveUpdateTarget(deps) {
|
|
|
173
173
|
const spec = profileManifest?.dependencies?.[anchor];
|
|
174
174
|
if (isLinkedSpec(spec))
|
|
175
175
|
return { error: 'link' };
|
|
176
|
+
// Standalone installs carry no aggregate: the anchor's own dependency
|
|
177
|
+
// list misses every sibling @linxin666/* plugin installed directly into
|
|
178
|
+
// the profile, so union the profile's direct family deps (#377).
|
|
176
179
|
return {
|
|
177
180
|
profileName: profile.name,
|
|
178
181
|
profileDir: profile.dir,
|
|
179
|
-
packages:
|
|
182
|
+
packages: familyUpdatePackages(anchor, manifest, profileManifest),
|
|
180
183
|
};
|
|
181
184
|
}
|
|
182
185
|
/** Family children of the anchor: its dependencies under the family scope. */
|
|
@@ -191,6 +194,18 @@ export function familyChildren(anchorManifest) {
|
|
|
191
194
|
}
|
|
192
195
|
return names;
|
|
193
196
|
}
|
|
197
|
+
/** Registry-managed family packages covered by one update operation. */
|
|
198
|
+
function familyUpdatePackages(anchor, anchorManifest, profileManifest) {
|
|
199
|
+
const names = new Set([anchor, ...familyChildren(anchorManifest)]);
|
|
200
|
+
const dependencies = profileManifest?.dependencies;
|
|
201
|
+
if (typeof dependencies !== 'object' || dependencies === null)
|
|
202
|
+
return [...names];
|
|
203
|
+
for (const [name, spec] of Object.entries(dependencies)) {
|
|
204
|
+
if (name.startsWith(FAMILY_SCOPE) && typeof spec === 'string' && !isLinkedSpec(spec))
|
|
205
|
+
names.add(name);
|
|
206
|
+
}
|
|
207
|
+
return [...names];
|
|
208
|
+
}
|
|
194
209
|
/**
|
|
195
210
|
* Probe the npm registry for one package's latest release.
|
|
196
211
|
* @param name - the package name (scope slash URL-encoded).
|
|
@@ -228,15 +243,20 @@ export async function fetchLatestVersion(name, fetchImpl, timeoutMs = 10_000) {
|
|
|
228
243
|
*/
|
|
229
244
|
const VERSION_UNKNOWN = '0.0.0';
|
|
230
245
|
/** The resolved current version of one family package (probe failure tolerated). */
|
|
231
|
-
function readInstalledVersion(resolve, name) {
|
|
246
|
+
function readInstalledVersion(resolve, name, profileDir) {
|
|
232
247
|
try {
|
|
233
248
|
const path = resolve(name + '/package.json');
|
|
234
249
|
const version = path === undefined ? undefined : readManifest(path)?.version;
|
|
235
|
-
|
|
250
|
+
if (typeof version === 'string')
|
|
251
|
+
return version;
|
|
236
252
|
}
|
|
237
|
-
catch {
|
|
253
|
+
catch { /* fall through to the profile's direct dependency path */ }
|
|
254
|
+
// Names originate in the profile dependency map, but still validate the
|
|
255
|
+
// npm package shape before using one as path segments.
|
|
256
|
+
if (profileDir === undefined || !/^@linxin666\/[a-z0-9][a-z0-9._-]*$/.test(name))
|
|
238
257
|
return VERSION_UNKNOWN;
|
|
239
|
-
|
|
258
|
+
const version = readManifest(join(profileDir, 'node_modules', ...name.split('/'), 'package.json'))?.version;
|
|
259
|
+
return typeof version === 'string' ? version : VERSION_UNKNOWN;
|
|
240
260
|
}
|
|
241
261
|
/**
|
|
242
262
|
* Build the update status: locate the anchor, detect the install mode, and
|
|
@@ -267,7 +287,10 @@ export async function checkUpdates(deps) {
|
|
|
267
287
|
if (profile === undefined) {
|
|
268
288
|
return { mode: 'link', packages: [], outdated: false };
|
|
269
289
|
}
|
|
270
|
-
|
|
290
|
+
// Union the profile's direct family deps so standalone installs (no
|
|
291
|
+
// aggregate) still check every installed @linxin666/* plugin (#377);
|
|
292
|
+
// familyUpdatePackages skips link:/file: development dependencies.
|
|
293
|
+
const names = familyUpdatePackages(anchor, manifest, profileManifest);
|
|
271
294
|
// The registry probes are independent: run them together instead of
|
|
272
295
|
// serializing up to N x 10s of registry latency behind one status call.
|
|
273
296
|
const latestList = await Promise.all(names.map(name => deps.fetchLatest(name)));
|
|
@@ -277,7 +300,7 @@ export async function checkUpdates(deps) {
|
|
|
277
300
|
const latest = latestList[index];
|
|
278
301
|
if (latest === undefined)
|
|
279
302
|
probeFailures++;
|
|
280
|
-
const current = readInstalledVersion(deps.resolve, name);
|
|
303
|
+
const current = readInstalledVersion(deps.resolve, name, profile.dir);
|
|
281
304
|
packages.push({
|
|
282
305
|
name,
|
|
283
306
|
current,
|
|
@@ -494,7 +517,7 @@ export async function runUpdateVerified(deps) {
|
|
|
494
517
|
// green exit that left everything in place could be mistaken for success.
|
|
495
518
|
const before = new Map();
|
|
496
519
|
for (const name of deps.run.packages) {
|
|
497
|
-
const version = readInstalledVersion(deps.check.resolve, name);
|
|
520
|
+
const version = readInstalledVersion(deps.check.resolve, name, deps.run.profileDir);
|
|
498
521
|
if (version !== VERSION_UNKNOWN)
|
|
499
522
|
before.set(name, version);
|
|
500
523
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-remote-web-ui",
|
|
3
3
|
"description": "Mobile remote control for the dsh web GUI: scan-to-pair QR entry beside the settings button, one-time pairing tokens, live device status, and revocable mobile sessions",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
|
@@ -61,17 +61,17 @@
|
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
64
|
-
"@deepseek-ai/dsh-client-connection": "^0.1.0-rc.
|
|
65
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.
|
|
66
|
-
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.
|
|
67
|
-
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.
|
|
68
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.
|
|
69
|
-
"@deepseek-ai/dsh-client-ui-sidebar": "^0.1.0-rc.
|
|
70
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.
|
|
71
|
-
"@deepseek-ai/dsh-host-apiproxy": "^0.1.0-rc.
|
|
72
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.
|
|
73
|
-
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.
|
|
74
|
-
"@deepseek-ai/dsh-settings": "^0.1.0-rc.
|
|
64
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.0-rc.7",
|
|
65
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.7",
|
|
66
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.7",
|
|
67
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.7",
|
|
68
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.7",
|
|
69
|
+
"@deepseek-ai/dsh-client-ui-sidebar": "^0.1.0-rc.7",
|
|
70
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.7",
|
|
71
|
+
"@deepseek-ai/dsh-host-apiproxy": "^0.1.0-rc.7",
|
|
72
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.7",
|
|
73
|
+
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.7",
|
|
74
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
|
|
75
75
|
"@testing-library/dom": "^10.4.1",
|
|
76
76
|
"@testing-library/react": "^16.3.2",
|
|
77
77
|
"@types/node": "^22.20.0",
|
|
@@ -56,6 +56,12 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
|
|
|
56
56
|
* already provides the selection.
|
|
57
57
|
*/
|
|
58
58
|
alwaysOpen?: boolean
|
|
59
|
+
/**
|
|
60
|
+
* Hide the save/discard footer: cards whose body applies its own changes
|
|
61
|
+
* immediately (an embedded external settings section) have no staged
|
|
62
|
+
* edits, so the footer would sit there permanently disabled.
|
|
63
|
+
*/
|
|
64
|
+
hideFooter?: boolean
|
|
59
65
|
/** The plugin's controls. */
|
|
60
66
|
children: ReactNode
|
|
61
67
|
}
|
|
@@ -140,6 +146,9 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
|
|
|
140
146
|
<div className={css.body}>
|
|
141
147
|
{!state.writable ? <p className={css.readOnly} role="status">{props.t('settings.readOnly')}</p> : null}
|
|
142
148
|
{props.children}
|
|
149
|
+
{props.hideFooter === true
|
|
150
|
+
? null
|
|
151
|
+
: (
|
|
143
152
|
<div className={css.footer}>
|
|
144
153
|
{state.failed
|
|
145
154
|
? (
|
|
@@ -165,6 +174,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
|
|
|
165
174
|
{props.t(!state.saving ? 'settings.save' : 'settings.saving')}
|
|
166
175
|
</button>
|
|
167
176
|
</div>
|
|
177
|
+
)}
|
|
168
178
|
</div>
|
|
169
179
|
)
|
|
170
180
|
: null}
|
|
@@ -222,13 +222,13 @@ export function RemoteEntry({ wide, useWorkspaces, t }: RemoteEntryProps) {
|
|
|
222
222
|
)
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
/** The trigger: an icon
|
|
225
|
+
/** The trigger: an icon-only control with a persistent accessible label. */
|
|
226
226
|
function TooltipAnchor({ wide, label, onClick }: { wide: boolean; label: string; onClick: () => void }) {
|
|
227
227
|
return (
|
|
228
228
|
<button
|
|
229
229
|
type="button"
|
|
230
230
|
className={css.trigger}
|
|
231
|
-
data-wide={wide ?
|
|
231
|
+
data-wide={wide ? 'wide' : 'rail'}
|
|
232
232
|
aria-label={label}
|
|
233
233
|
title={label}
|
|
234
234
|
onClick={onClick}
|
|
@@ -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/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'
|
|
@@ -89,6 +90,14 @@ export interface Config {
|
|
|
89
90
|
* warning (LAN-only behavior preserved). Ignored while `autoTunnel` is on.
|
|
90
91
|
*/
|
|
91
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
|
|
92
101
|
/**
|
|
93
102
|
* When true, the plugin runs its own Cloudflare quick tunnel (the
|
|
94
103
|
* cloudflared binary ships with the package — no user-side install) and
|
|
@@ -115,6 +124,7 @@ export const Config: z<Config> = z.object({
|
|
|
115
124
|
cookieName: z.string().min(1).default('dsh_pair'),
|
|
116
125
|
requirePairingForLan: z.boolean().default(true),
|
|
117
126
|
publicBaseUrl: z.string(),
|
|
127
|
+
devicesFile: z.string(),
|
|
118
128
|
autoTunnel: z.boolean().default(false),
|
|
119
129
|
mobileEnterToSend: z.boolean().default(true),
|
|
120
130
|
enabled: z.boolean().default(true),
|
|
@@ -128,7 +138,29 @@ const SWEEP_INTERVAL_MS = 10_000
|
|
|
128
138
|
* which legitimately resolves to `undefined` when unset (the schema keeps it
|
|
129
139
|
* optional, so `Required` alone would over-narrow it to `string`).
|
|
130
140
|
*/
|
|
131
|
-
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
|
+
}
|
|
132
164
|
|
|
133
165
|
/** Schema defaults, re-read for hand-built test contexts (the loader applies them normally). */
|
|
134
166
|
const DEFAULTS: ResolvedConfig = {
|
|
@@ -138,6 +170,7 @@ const DEFAULTS: ResolvedConfig = {
|
|
|
138
170
|
cookieName: 'dsh_pair',
|
|
139
171
|
requirePairingForLan: true,
|
|
140
172
|
publicBaseUrl: undefined,
|
|
173
|
+
devicesFile: undefined,
|
|
141
174
|
autoTunnel: false,
|
|
142
175
|
mobileEnterToSend: true,
|
|
143
176
|
enabled: true,
|
|
@@ -158,6 +191,7 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
158
191
|
cookieName: config?.cookieName ?? DEFAULTS.cookieName,
|
|
159
192
|
requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
|
|
160
193
|
publicBaseUrl: config?.publicBaseUrl,
|
|
194
|
+
devicesFile: config?.devicesFile,
|
|
161
195
|
autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
|
|
162
196
|
mobileEnterToSend: config?.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
|
|
163
197
|
enabled: config?.enabled ?? DEFAULTS.enabled,
|
|
@@ -175,17 +209,13 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
175
209
|
cookieName: value.cookieName ?? DEFAULTS.cookieName,
|
|
176
210
|
requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
|
|
177
211
|
publicBaseUrl: value.publicBaseUrl,
|
|
212
|
+
devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
|
|
178
213
|
autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
|
|
179
214
|
mobileEnterToSend: value.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
|
|
180
215
|
enabled: value.enabled ?? DEFAULTS.enabled,
|
|
181
216
|
}
|
|
182
217
|
}
|
|
183
|
-
const service = new PairingService(
|
|
184
|
-
tokenTtlMs: resolved.tokenTtlMs,
|
|
185
|
-
offlineAfterMs: resolved.offlineAfterMs,
|
|
186
|
-
maxDevices: resolved.maxDevices,
|
|
187
|
-
cookieName: resolved.cookieName,
|
|
188
|
-
})
|
|
218
|
+
const service = new PairingService(pairingConfigOf(resolved))
|
|
189
219
|
|
|
190
220
|
// ── auto tunnel ─────────────────────────────────────────────────────────
|
|
191
221
|
// The minted public URL becomes the QR base (and the pairing fence's
|
|
@@ -312,14 +342,15 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
312
342
|
]
|
|
313
343
|
const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled)
|
|
314
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
|
+
})
|
|
315
351
|
const sync = (): void => {
|
|
316
352
|
const value = resolve()
|
|
317
|
-
service.config =
|
|
318
|
-
tokenTtlMs: value.tokenTtlMs,
|
|
319
|
-
offlineAfterMs: value.offlineAfterMs,
|
|
320
|
-
maxDevices: value.maxDevices,
|
|
321
|
-
cookieName: value.cookieName,
|
|
322
|
-
}
|
|
353
|
+
service.config = pairingConfigOf(value)
|
|
323
354
|
// The auto tunnel owns the public base while enabled: the minted URL
|
|
324
355
|
// lands in the service through the tunnel's phase listener. The manual
|
|
325
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
|
+
}
|
|
@@ -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
|
`
|