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/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 = connectionOf()?.requestRejection?.(req)
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
- const QR_ROUTE = '/mobile-link/qr'
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
- sendJson(res, 200, { ok: true, ...minted })
136
- } catch (error) {
137
- sendJson(res, 502, { ok: false, code: 'relay/unavailable', message: String(error?.message ?? error) })
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.4.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
  }