dsh-plugin-mobile-link 0.4.0 → 0.6.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.md +2 -1
- package/cordis.patch.yml +23 -0
- package/lib/cli.js +6 -6
- package/lib/client.js +493 -0
- package/lib/hello.js +15 -2
- package/lib/history/probe.js +195 -0
- package/lib/history/records.js +97 -0
- package/lib/history/shared.js +25 -0
- package/lib/history/window.js +90 -0
- package/lib/history.js +355 -0
- package/lib/index.js +29 -6
- package/lib/link.js +88 -0
- package/lib/qr.js +34 -5
- package/lib/router.js +22 -0
- package/lib/routes.js +58 -55
- package/package.json +5 -1
package/lib/router.js
CHANGED
|
@@ -16,6 +16,7 @@ import { FileInbox, FILE_BEGIN, FILE_CHUNK, FILE_END, isFileMethod } from './fil
|
|
|
16
16
|
import { FileFetcher, FSGET_CANCEL } from './files-out.js'
|
|
17
17
|
import { GitBridge, isGitMethod } from './git.js'
|
|
18
18
|
import { isHelloMethod, helloPayload, parseClientInfo } from './hello.js'
|
|
19
|
+
import { isHistoryMethod } from './history.js'
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* 后台大文件上传的桥接帧(relay → 连接器),名字见 `docs/relay-contract.json`
|
|
@@ -109,6 +110,9 @@ export class DeviceRouter {
|
|
|
109
110
|
logger,
|
|
110
111
|
})
|
|
111
112
|
this.git = new GitBridge(logger)
|
|
113
|
+
// 历史窗口/检索(spec 007):由 index.js 注入宿主 `sessionQuery` 后交给它。
|
|
114
|
+
// 没绑上就回 `link/unsupported`,手机端退回 `session/page`。
|
|
115
|
+
this.history = undefined
|
|
112
116
|
}
|
|
113
117
|
|
|
114
118
|
#changed() {
|
|
@@ -433,6 +437,24 @@ export class DeviceRouter {
|
|
|
433
437
|
return
|
|
434
438
|
}
|
|
435
439
|
|
|
440
|
+
// History reads run here too (spec 007): the Host owns a seq-exact reader but
|
|
441
|
+
// does not expose it on the remote surface the phone talks to. Answered
|
|
442
|
+
// locally when the injected `sessionQuery` is present; otherwise the phone is
|
|
443
|
+
// told plainly that the capability is missing, so it can keep using
|
|
444
|
+
// `session/page` instead of reading the failure as "no older history".
|
|
445
|
+
if (isHistoryMethod(frame.method)) {
|
|
446
|
+
let result
|
|
447
|
+
try {
|
|
448
|
+
result = this.history === undefined
|
|
449
|
+
? { ok: false, error: errorObject('link/unsupported', 'history reads are unavailable on this connector') }
|
|
450
|
+
: await this.history.handle(frame.method, frame.args ?? {})
|
|
451
|
+
} catch (error) {
|
|
452
|
+
result = { ok: false, error: errorObject(error?.code ?? 'link/failed', messageOf(error), error?.details ?? {}) }
|
|
453
|
+
}
|
|
454
|
+
this.send(deviceId, resultFrame(frame.id, result, deviceId))
|
|
455
|
+
return
|
|
456
|
+
}
|
|
457
|
+
|
|
436
458
|
let result
|
|
437
459
|
try {
|
|
438
460
|
result = await this.dsh.rpc(frame.method, frame.args ?? {})
|
package/lib/routes.js
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
* HTTP routes under `/mobile-link`, registered on DSH's own web server.
|
|
3
3
|
*
|
|
4
4
|
* GET /mobile-link/status connection + device inventory + setup state
|
|
5
|
-
* POST /mobile-link/pair-code mint a pairing code (and its QR payload)
|
|
6
|
-
* GET /mobile-link/qr mint a pairing code and render it as a QR SVG
|
|
5
|
+
* POST /mobile-link/pair-code mint a pairing code (and its QR payload + SVG)
|
|
7
6
|
*
|
|
8
7
|
* All sit behind DSH's own request fence, like every other plugin route:
|
|
9
8
|
* `ctx.get('connection')?.requestRejection?.(req)` performs the Host/Origin check
|
|
@@ -23,13 +22,6 @@ export function sendJson(res, status, payload) {
|
|
|
23
22
|
res.end(JSON.stringify(payload))
|
|
24
23
|
}
|
|
25
24
|
|
|
26
|
-
export function sendSvg(res, status, svg) {
|
|
27
|
-
res.statusCode = status
|
|
28
|
-
res.setHeader('content-type', 'image/svg+xml; charset=utf-8')
|
|
29
|
-
res.setHeader('cache-control', 'no-store')
|
|
30
|
-
res.end(svg)
|
|
31
|
-
}
|
|
32
|
-
|
|
33
25
|
export async function readJsonBody(req) {
|
|
34
26
|
const chunks = []
|
|
35
27
|
let size = 0
|
|
@@ -55,9 +47,25 @@ export async function readJsonBody(req) {
|
|
|
55
47
|
export function makeGuard(ctx, logger) {
|
|
56
48
|
const connectionOf = () => (typeof ctx.get === 'function' ? ctx.get('connection') : undefined)
|
|
57
49
|
return function guard(req, res, { requireFence = false } = {}) {
|
|
50
|
+
const connection = connectionOf()
|
|
51
|
+
const fence = connection?.requestRejection
|
|
52
|
+
// DSH's contract is "a status when refused, **undefined** when the request
|
|
53
|
+
// may proceed" — so undefined is a pass, not an absence. Reading it as an
|
|
54
|
+
// absence is what made every privileged route below answer 403 to everyone
|
|
55
|
+
// with a perfectly authenticated request: the pairing page and
|
|
56
|
+
// `POST /pair-code` never got past the guard. The service's own presence is
|
|
57
|
+
// the only thing that can tell "no fence to consult" apart from "fence says
|
|
58
|
+
// yes", so that is what is checked here.
|
|
59
|
+
if (typeof fence !== 'function') {
|
|
60
|
+
if (requireFence) {
|
|
61
|
+
sendJson(res, 403, { ok: false, message: 'the DSH connection service is unavailable; refusing a privileged request' })
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
58
66
|
let rejection
|
|
59
67
|
try {
|
|
60
|
-
rejection =
|
|
68
|
+
rejection = fence.call(connection, req)
|
|
61
69
|
} catch (error) {
|
|
62
70
|
logger.warn?.(`mobile-link: request fence failed: ${error}`)
|
|
63
71
|
sendJson(res, 403, { ok: false, message: 'request rejected by the DSH fence' })
|
|
@@ -68,10 +76,6 @@ export function makeGuard(ctx, logger) {
|
|
|
68
76
|
res.end()
|
|
69
77
|
return true
|
|
70
78
|
}
|
|
71
|
-
if (requireFence && rejection === undefined) {
|
|
72
|
-
sendJson(res, 403, { ok: false, message: 'the DSH connection service is unavailable; refusing a privileged request' })
|
|
73
|
-
return true
|
|
74
|
-
}
|
|
75
79
|
return false
|
|
76
80
|
}
|
|
77
81
|
}
|
|
@@ -86,13 +90,44 @@ function ttlFrom(value, fallbackMs) {
|
|
|
86
90
|
* Register the routes.
|
|
87
91
|
* @returns {Array<() => void>} disposers (already wrapped by the caller in ctx.effect).
|
|
88
92
|
*/
|
|
89
|
-
export function registerMobileLinkRoutes(ctx, { agent, logger, config = {} }) {
|
|
93
|
+
export function registerMobileLinkRoutes(ctx, { agent, logger, config = {}, history }) {
|
|
90
94
|
const guard = makeGuard(ctx, logger)
|
|
91
95
|
const STATUS_ROUTE = '/mobile-link/status'
|
|
92
96
|
const PAIR_ROUTE = '/mobile-link/pair-code'
|
|
93
|
-
|
|
97
|
+
/// SPIKE(spec 007 / T008):只读测量宿主窗口与检索 API 的语义。
|
|
98
|
+
/// 默认关闭,只有 `DSH_MOBILE_LINK_HISTORY_PROBE=1` 时注册——它是一次性测量工具,
|
|
99
|
+
/// 不是产品面(结论已写回 specs/007-transcript-history/plan.md 的 Phase 0)。
|
|
100
|
+
const PROBE_ROUTE = '/mobile-link/history-probe'
|
|
94
101
|
const disposers = []
|
|
95
102
|
|
|
103
|
+
if (history !== undefined && process.env.DSH_MOBILE_LINK_HISTORY_PROBE === '1') {
|
|
104
|
+
disposers.push(ctx.webServer.register({
|
|
105
|
+
kind: 'exact',
|
|
106
|
+
path: PROBE_ROUTE,
|
|
107
|
+
handler: async (req, res) => {
|
|
108
|
+
if (guard(req, res)) return
|
|
109
|
+
if (req.method !== 'GET') {
|
|
110
|
+
res.statusCode = 405
|
|
111
|
+
res.setHeader('allow', 'GET')
|
|
112
|
+
res.end()
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
116
|
+
try {
|
|
117
|
+
const result = await history.probe({
|
|
118
|
+
sessionId: url.searchParams.get('sessionId') ?? '',
|
|
119
|
+
seq: url.searchParams.get('seq') ?? undefined,
|
|
120
|
+
n: url.searchParams.get('n') ?? undefined,
|
|
121
|
+
text: url.searchParams.get('text') ?? undefined,
|
|
122
|
+
})
|
|
123
|
+
sendJson(res, 200, result)
|
|
124
|
+
} catch (error) {
|
|
125
|
+
sendJson(res, 500, { ok: false, error: String(error?.message ?? error) })
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
}))
|
|
129
|
+
}
|
|
130
|
+
|
|
96
131
|
disposers.push(ctx.webServer.register({
|
|
97
132
|
kind: 'exact',
|
|
98
133
|
path: STATUS_ROUTE,
|
|
@@ -132,48 +167,16 @@ export function registerMobileLinkRoutes(ctx, { agent, logger, config = {} }) {
|
|
|
132
167
|
try {
|
|
133
168
|
const minted = await agent.mintPairCode({ ttlMs })
|
|
134
169
|
logger.info?.(`mobile-link: minted a pairing code for ${minted.agentId}`)
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
// The QR a phone camera can actually read. Rendered here rather than in the
|
|
143
|
-
// desktop UI so the pairing screen is one URL: anyone can open
|
|
144
|
-
// `http://127.0.0.1:<port>/mobile-link/qr` (with DSH's own authentication)
|
|
145
|
-
// and see a scannable code, with no client-side work and no extra dependency.
|
|
146
|
-
//
|
|
147
|
-
// `?format=json` returns the payload and the code without the picture, which
|
|
148
|
-
// is what the automated tests use.
|
|
149
|
-
disposers.push(ctx.webServer.register({
|
|
150
|
-
kind: 'exact',
|
|
151
|
-
path: QR_ROUTE,
|
|
152
|
-
handler: async (req, res) => {
|
|
153
|
-
if (guard(req, res, { requireFence: true })) return
|
|
154
|
-
if (req.method !== 'GET') {
|
|
155
|
-
res.statusCode = 405
|
|
156
|
-
res.setHeader('allow', 'GET')
|
|
157
|
-
res.end()
|
|
158
|
-
return
|
|
159
|
-
}
|
|
160
|
-
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
161
|
-
const ttlMs = ttlFrom(url.searchParams.get('ttlMs'), Number(config.pairTtlMs) || 10 * 60 * 1000)
|
|
162
|
-
let minted
|
|
163
|
-
try {
|
|
164
|
-
minted = await agent.mintPairCode({ ttlMs })
|
|
170
|
+
// The picture travels with the code. It used to be a route of its own
|
|
171
|
+
// (`GET /mobile-link/qr`) that a person opened in a browser; the pairing
|
|
172
|
+
// entry is now the connector's own panel in the DSH client, so the one
|
|
173
|
+
// caller left is that panel, and it wants the code and the symbol in the
|
|
174
|
+
// same answer. No caption: the panel runs its own countdown, and a
|
|
175
|
+
// server-rendered "还剩 N 分钟" freezes the moment the window is up.
|
|
176
|
+
sendJson(res, 200, { ok: true, ...minted, svg: qrSvg(minted.qrPayload) })
|
|
165
177
|
} catch (error) {
|
|
166
178
|
sendJson(res, 502, { ok: false, code: 'relay/unavailable', message: String(error?.message ?? error) })
|
|
167
|
-
return
|
|
168
|
-
}
|
|
169
|
-
const payload = minted.qrPayload
|
|
170
|
-
const wantsJson = (url.searchParams.get('format') ?? '').toLowerCase() === 'json'
|
|
171
|
-
logger.info?.(`mobile-link: rendered a pairing QR for ${minted.agentId}`)
|
|
172
|
-
if (wantsJson) {
|
|
173
|
-
sendJson(res, 200, { ok: true, ...minted, svg: qrSvg(payload) })
|
|
174
|
-
return
|
|
175
179
|
}
|
|
176
|
-
sendSvg(res, 200, qrSvg(payload))
|
|
177
180
|
},
|
|
178
181
|
}))
|
|
179
182
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-mobile-link",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Put your PC's DeepSeek Harness in your pocket: dials a relay over WSS so the native iOS client reaches this machine even without a public IP.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"exports": {
|
|
32
32
|
".": "./lib/index.js",
|
|
33
|
+
"./client": "./lib/client.js",
|
|
33
34
|
"./dlp": "./lib/dlp.js",
|
|
34
35
|
"./link": "./lib/link.js",
|
|
35
36
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
@@ -49,6 +50,9 @@
|
|
|
49
50
|
"dsh": {
|
|
50
51
|
"bundle": {
|
|
51
52
|
"patch": "./cordis.patch.yml"
|
|
53
|
+
},
|
|
54
|
+
"client": {
|
|
55
|
+
"platform": "web"
|
|
52
56
|
}
|
|
53
57
|
}
|
|
54
58
|
}
|