dsh-remote-bridge 2.7.1 → 2.8.1

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.
@@ -0,0 +1,437 @@
1
+ package ai.deepseek.dsh.workspace
2
+
3
+ import java.io.Closeable
4
+ import java.util.Base64
5
+ import java.util.concurrent.CompletableFuture
6
+ import java.util.concurrent.ConcurrentHashMap
7
+ import java.util.concurrent.Executors
8
+ import java.util.concurrent.ScheduledFuture
9
+ import java.util.concurrent.TimeUnit
10
+ import java.util.concurrent.atomic.AtomicBoolean
11
+ import java.util.concurrent.atomic.AtomicLong
12
+ import kotlinx.serialization.Serializable
13
+ import kotlinx.serialization.json.Json
14
+ import kotlinx.serialization.json.JsonElement
15
+ import kotlinx.serialization.json.JsonObject
16
+ import kotlinx.serialization.json.JsonPrimitive
17
+ import kotlinx.serialization.json.buildJsonObject
18
+ import kotlinx.serialization.json.jsonObject
19
+ import kotlinx.serialization.json.jsonPrimitive
20
+ import kotlinx.serialization.json.put
21
+ import okhttp3.HttpUrl
22
+ import okhttp3.HttpUrl.Companion.toHttpUrl
23
+ import okhttp3.OkHttpClient
24
+ import okhttp3.Request
25
+ import okhttp3.Response
26
+ import okhttp3.WebSocket
27
+ import okhttp3.WebSocketListener
28
+
29
+ /** `/ws/mobile` 的子协议名。握手时**必须**排在子协议列表第一位,服务端据此判断协议版本。 */
30
+ const val MOBILE_PROTOCOL: String = "dsh-mobile-v1"
31
+
32
+ /** hello 里宣告的协议修订号。与会话快照/问答审批的 `question-approval-v2` 是两码事。 */
33
+ const val MOBILE_HELLO_PROTOCOL: Int = 3
34
+
35
+ /** 服务端主动断开时用的 close code。4001 = 鉴权/配对失败或设备已被吊销,**重连无意义**。 */
36
+ const val MOBILE_CLOSE_AUTH_FAILED: Int = 4001
37
+
38
+ /**
39
+ * `/ws/mobile` 的凭证载体。三种二选一,与服务端 `onConnection` 的判定顺序一致:
40
+ * 1. [Pairing] → 子协议 `dsh-pair.<code>`,**必须**带 `X-DSH-Device-ID`;
41
+ * 2. [Token] → 子协议 `dsh-auth.<token>`(推荐,浏览器/受控环境下不必自定义头);
42
+ * 3. [Bearer] → 子协议只留 `dsh-mobile-v1`,token 走 `Authorization: Bearer`。
43
+ *
44
+ * 刻意**没有** `?token=` 变体:服务端见到 URL query 里的 token/code 会直接 `close(4001)`,
45
+ * 因为它会漏进代理日志。别在这里补一个方便的后门。
46
+ */
47
+ sealed class MobileCredentials {
48
+ /** 首配:一次性配对码 + 设备标识。配对成功后服务端会先推 `paired` 再推 `hello`。 */
49
+ data class Pairing(
50
+ val code: String,
51
+ val deviceId: String,
52
+ val deviceName: String? = null,
53
+ ) : MobileCredentials()
54
+
55
+ /** 已配对设备:把配对拿到的 token 放进子协议。 */
56
+ data class Token(val token: String) : MobileCredentials()
57
+
58
+ /** 已配对设备:token 放 Authorization 头,子协议只保留 `dsh-mobile-v1`。 */
59
+ data class Bearer(val token: String) : MobileCredentials()
60
+ }
61
+
62
+ /** hello 帧里的设备视图。回环管理员连接没有这一节(服务端会整段省略,而不是给 null)。 */
63
+ @Serializable
64
+ data class MobileDevice(
65
+ val id: String,
66
+ val name: String,
67
+ val scopes: List<String> = emptyList(),
68
+ val rootIds: List<String> = emptyList(),
69
+ )
70
+
71
+ /** 连接建立后服务端推的第一帧。`protocol` 必须等于 [MOBILE_HELLO_PROTOCOL]。 */
72
+ @Serializable
73
+ data class MobileHello(
74
+ val protocol: Int,
75
+ val capabilities: List<String> = emptyList(),
76
+ val authenticated: Boolean = false,
77
+ val device: MobileDevice? = null,
78
+ val port: Int? = null,
79
+ val clients: Int? = null,
80
+ )
81
+
82
+ /** 配对成功的凭证下发帧,只会在 `MobileCredentials.Pairing` 流程里出现,且**早于** hello。 */
83
+ @Serializable
84
+ data class MobilePaired(val token: String, val device: MobileDevice)
85
+
86
+ /**
87
+ * 服务端 `error` 帧。`code` 是稳定契约(`UNKNOWN_TYPE` / `NOT_IMPLEMENTED` / `MISSING_PARAMS` /
88
+ * `FORBIDDEN_SCOPE` / `bad-request` …),`message` 是给人看的,可能变。
89
+ */
90
+ class MobileGatewayException(
91
+ val code: String,
92
+ override val message: String,
93
+ val requestType: String? = null,
94
+ val requestId: String? = null,
95
+ val statusCode: Int? = null,
96
+ ) : RuntimeException(message)
97
+
98
+ /** 请求发出去了但等不到回帧。多半是服务端不认识这个 type,或帧被背压队列丢了。 */
99
+ class MobileRequestTimeoutException(val requestId: String, val type: String, val timeoutMs: Long) :
100
+ RuntimeException("$type (requestId=$requestId) 在 ${timeoutMs}ms 内没有回帧")
101
+
102
+ /** 连接没等到 hello 就断了,或 hello 的 protocol 不匹配。 */
103
+ class MobileHandshakeException(message: String) : RuntimeException(message)
104
+
105
+ /**
106
+ * `/ws/mobile` 的一帧响应。`kind` 是 `MobileServerFrame.kind` 的成员之一;
107
+ * `requestId` 是从 `data` 里提出来的一等字段,方便直接关联。
108
+ */
109
+ data class MobileFrame(val kind: String, val data: JsonElement) {
110
+ val requestId: String? = (data as? JsonObject)?.get("requestId")?.let {
111
+ (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content
112
+ }
113
+ }
114
+
115
+ /**
116
+ * `/ws/mobile` 的帧回调。全部方法都有默认空实现,调用方只覆盖关心的那几个。
117
+ *
118
+ * 回调都在 OkHttp 的读线程上跑,**不要**在里面做阻塞的 [MobileGatewayClient.request]:
119
+ * 那条路径要等读线程派发回帧,会自己把自己锁死。需要请求-响应就用 [MobileGatewayClient.request],
120
+ * 它内部走的是独立的等待者,但仍建议在调用方线程里用。
121
+ */
122
+ interface MobileGatewayListener {
123
+ /** 每条非 `event` 帧。hello/paired 已单独派发,这里不再重复。 */
124
+ fun onFrame(frame: MobileFrame) {}
125
+
126
+ /** `{kind:'event', data:{...}}`。事件本身带 `type`(如 `chat.message.delta`)。 */
127
+ fun onEvent(event: JsonElement) {}
128
+
129
+ /** 首帧 hello。`protocol != 3` 时先调这个,再走 [onPermanentFailure]。 */
130
+ fun onHello(hello: MobileHello) {}
131
+
132
+ /** 配对成功,拿到长期 token。只有配对流程会触发。 */
133
+ fun onPaired(paired: MobilePaired) {}
134
+
135
+ /** `ping` 的回帧。`at` 与 `time` 是同一毫秒(服务端刻意如此)。 */
136
+ fun onPong(at: Long, time: Long) {}
137
+
138
+ fun onConnectionChanged(connected: Boolean) {}
139
+
140
+ /** 重连没意义的失败(4001、hello 协议不匹配、配对码失效)。 */
141
+ fun onPermanentFailure(error: Throwable) {}
142
+ }
143
+
144
+ /**
145
+ * `/ws/mobile` 移动端网关客户端。
146
+ *
147
+ * 与 [DshWorkspaceClient] 的分工:REST 客户端覆盖「一次请求一次响应」的能力面,
148
+ * 本类覆盖长连接面 —— hello/能力协商、`event` 推送、HITL 交互、分块传输、
149
+ * 以及靠 `requestId` 关联的异步请求-响应。服务端是 WS 优先、REST 兜底,
150
+ * 所以**同一个能力两处都有**时优先走本类;本类没有的能力再回落到 REST。
151
+ *
152
+ * 用法:
153
+ * ```
154
+ * val client = MobileGatewayClient("ws://127.0.0.1:3090", MobileCredentials.Token(token)) { frame -> ... }
155
+ * val hello = client.awaitHello()
156
+ * client.subscribe(sessionId)
157
+ * client.request("session-stats", mapOf("sessionId" to sessionId))
158
+ * ```
159
+ *
160
+ * 线程模型:所有写都在 OkHttp 的 WebSocket 队列里串行;[request] 的等待者在调用方线程阻塞,
161
+ * 回帧由读线程按 `requestId` 唤醒。回调见 [MobileGatewayListener] 的线程说明。
162
+ */
163
+ class MobileGatewayClient(
164
+ wsBaseUrl: String,
165
+ private val credentials: MobileCredentials,
166
+ private val http: OkHttpClient = OkHttpClient(),
167
+ private val listener: MobileGatewayListener = object : MobileGatewayListener {},
168
+ ) : Closeable {
169
+ private val json = Json { ignoreUnknownKeys = true }
170
+ private val url: HttpUrl = buildUrl(wsBaseUrl)
171
+ private val closed = AtomicBoolean(false)
172
+ private val scheduler = Executors.newSingleThreadScheduledExecutor()
173
+ private val pending = ConcurrentHashMap<String, CompletableFuture<MobileFrame>>()
174
+ private val requestSeq = AtomicLong(0)
175
+ private val helloFuture = CompletableFuture<MobileHello>()
176
+
177
+ @Volatile private var socket: WebSocket? = null
178
+ @Volatile private var retry: ScheduledFuture<*>? = null
179
+ @Volatile private var attempts = 0
180
+
181
+ /** 已收到的 hello;握手完成前为 null。 */
182
+ @Volatile
183
+ var hello: MobileHello? = null
184
+ private set
185
+
186
+ /** 配对流程拿到的 token;非配对流程恒为 null。 */
187
+ @Volatile
188
+ var pairedToken: String? = null
189
+ private set
190
+
191
+ init { connect() }
192
+
193
+ // ── 握手 ─────────────────────────────────────────────────────────────────
194
+
195
+ /**
196
+ * 把 `ws://host:port` 或 `http(s)://host:port` 归一成 `ws(s)://host:port/ws/mobile`。
197
+ * 传完整路径也接受(幂等),省得调用方两处拼路径拼出 `/ws/mobile/ws/mobile`。
198
+ */
199
+ private fun buildUrl(base: String): HttpUrl {
200
+ val normalized = when {
201
+ base.startsWith("ws://") -> "http://" + base.removePrefix("ws://")
202
+ base.startsWith("wss://") -> "https://" + base.removePrefix("wss://")
203
+ else -> base
204
+ }
205
+ val parsed = normalized.trimEnd('/').toHttpUrl()
206
+ if (parsed.encodedPath.endsWith("/ws/mobile")) return parsed
207
+ return parsed.newBuilder().addPathSegment("ws").addPathSegment("mobile").build()
208
+ }
209
+
210
+ /** 子协议列表:`dsh-mobile-v1` 永远第一位,凭证前缀紧随其后。 */
211
+ private fun subprotocols(): String = when (credentials) {
212
+ is MobileCredentials.Pairing -> "$MOBILE_PROTOCOL, dsh-pair.${credentials.code}"
213
+ is MobileCredentials.Token -> "$MOBILE_PROTOCOL, dsh-auth.${credentials.token}"
214
+ is MobileCredentials.Bearer -> MOBILE_PROTOCOL
215
+ }
216
+
217
+ private fun requestBuilder(): Request.Builder {
218
+ val builder = Request.Builder().url(url).header("Sec-WebSocket-Protocol", subprotocols())
219
+ when (credentials) {
220
+ is MobileCredentials.Pairing -> {
221
+ builder.header("X-DSH-Device-ID", credentials.deviceId)
222
+ // 设备名走 Base64(UTF-8):HTTP 头只保证 ISO-8859-1,中文名直传会被截断。
223
+ credentials.deviceName?.takeIf { it.isNotBlank() }?.let {
224
+ builder.header("X-DSH-Device-Name", Base64.getEncoder().encodeToString(it.toByteArray(Charsets.UTF_8)))
225
+ }
226
+ }
227
+ is MobileCredentials.Bearer -> builder.header("Authorization", "Bearer ${credentials.token}")
228
+ is MobileCredentials.Token -> Unit
229
+ }
230
+ return builder
231
+ }
232
+
233
+ private fun connect() {
234
+ if (closed.get()) return
235
+ socket = http.newWebSocket(requestBuilder().build(), object : WebSocketListener() {
236
+ override fun onOpen(webSocket: WebSocket, response: Response) {
237
+ attempts = 0
238
+ listener.onConnectionChanged(true)
239
+ }
240
+
241
+ override fun onMessage(webSocket: WebSocket, text: String) {
242
+ // 单帧解析失败不该掀桌子:服务端可能先于客户端升级而多发了一种帧。
243
+ runCatching { dispatch(text) }.onFailure(listener::onPermanentFailure)
244
+ }
245
+
246
+ override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
247
+ failPending(MobileHandshakeException("连接关闭:$code $reason"))
248
+ listener.onConnectionChanged(false)
249
+ when (code) {
250
+ // 4001 覆盖「配对码无效/过期、设备 ID 缺失、token 被吊销」。
251
+ // 重连只会刷出一串同样的 4001,所以直接判永久失败。
252
+ MOBILE_CLOSE_AUTH_FAILED -> {
253
+ val error = IllegalStateException("移动网关拒绝了凭证($reason),需要重新配对或换 token。")
254
+ helloFuture.completeExceptionally(error)
255
+ listener.onPermanentFailure(error)
256
+ }
257
+ else -> reconnect()
258
+ }
259
+ }
260
+
261
+ override fun onFailure(webSocket: WebSocket, error: Throwable, response: Response?) {
262
+ failPending(error)
263
+ listener.onConnectionChanged(false)
264
+ // 429 = 连接数打满(上限 32,鉴权后才计数)。等一会儿再试是合理的,别当永久失败。
265
+ if (response?.code == 401 || response?.code == 403) {
266
+ helloFuture.completeExceptionally(error)
267
+ listener.onPermanentFailure(error)
268
+ } else {
269
+ reconnect()
270
+ }
271
+ }
272
+ })
273
+ }
274
+
275
+ /**
276
+ * 指数退避重连:1s,2s,4s,8s,16s,封顶 30s。与 [EventSubscription] 同一套参数,
277
+ * 免得同一个 App 里两条长连接节奏不一样、把服务端 32 个槽位一起打满。
278
+ */
279
+ private fun reconnect() {
280
+ if (closed.get()) return
281
+ val delaySeconds = minOf(30L, 1L shl minOf(attempts++, 5))
282
+ retry?.cancel(false)
283
+ retry = scheduler.schedule(::connect, delaySeconds, TimeUnit.SECONDS)
284
+ }
285
+
286
+ private fun dispatch(text: String) {
287
+ val envelope = json.parseToJsonElement(text).jsonObject
288
+ val kind = envelope["kind"]?.jsonPrimitive?.contentOrNullSafe()
289
+ ?: throw MobileHandshakeException("服务端帧缺少 kind:${text.take(200)}")
290
+ val data = envelope["data"] ?: JsonObject(emptyMap())
291
+ val frame = MobileFrame(kind, data)
292
+
293
+ when (kind) {
294
+ "hello" -> {
295
+ val parsed = json.decodeFromJsonElement(MobileHello.serializer(), data)
296
+ hello = parsed
297
+ listener.onHello(parsed)
298
+ if (parsed.protocol != MOBILE_HELLO_PROTOCOL) {
299
+ // 协议不匹配时**不要**继续用这条连接发帧:字段语义已经不可信了。
300
+ val error = MobileHandshakeException(
301
+ "移动网关协议是 ${parsed.protocol},本 SDK 只支持 $MOBILE_HELLO_PROTOCOL。",
302
+ )
303
+ helloFuture.completeExceptionally(error)
304
+ listener.onPermanentFailure(error)
305
+ close()
306
+ return
307
+ }
308
+ helloFuture.complete(parsed)
309
+ }
310
+ "paired" -> {
311
+ val parsed = json.decodeFromJsonElement(MobilePaired.serializer(), data)
312
+ pairedToken = parsed.token
313
+ listener.onPaired(parsed)
314
+ }
315
+ "pong" -> {
316
+ val at = (data as? JsonObject)?.get("at")?.jsonPrimitive?.contentOrNullSafe()?.toLongOrNull() ?: 0L
317
+ val time = (data as? JsonObject)?.get("time")?.jsonPrimitive?.contentOrNullSafe()?.toLongOrNull() ?: at
318
+ listener.onPong(at, time)
319
+ }
320
+ "event" -> listener.onEvent(data)
321
+ else -> listener.onFrame(frame)
322
+ }
323
+ // requestId 关联放在最后:即使上面某个分支已经派发过回调,等待者也必须被唤醒。
324
+ frame.requestId?.let { id -> pending.remove(id)?.complete(frame) }
325
+ }
326
+
327
+ /** 非字符串 primitive(数字/布尔)的 `content` 本身就是字面量文本,取它即可。 */
328
+ private fun JsonPrimitive.contentOrNullSafe(): String = content
329
+
330
+ // ── 请求-响应 ────────────────────────────────────────────────────────────
331
+
332
+ /**
333
+ * 发一帧并等它的回帧,靠 `requestId` 关联。
334
+ *
335
+ * 关联约定(与服务端一致,见 asyncapi 的 `/ws/mobile` 描述):
336
+ * - `requestId` 是不透明字符串,服务端**原样回显**,客户端只管比对;
337
+ * - 服务端在没有 requestId 时**整段省略**这个键,不会回 `null` 或空串;
338
+ * - `file-resolve` / `file-download-open` / `file-upload-open` 三帧的 requestId 是**必填**,
339
+ * 缺了服务端回 `bad-request`;
340
+ * - 关联是**每连接**作用域,不同连接可以用同一个 requestId。
341
+ *
342
+ * @throws MobileGatewayException 服务端回 `error` 帧(`UNKNOWN_TYPE` / `MISSING_PARAMS` 等)。
343
+ * @throws MobileRequestTimeoutException 超时。
344
+ */
345
+ fun request(
346
+ type: String,
347
+ payload: Map<String, JsonElement> = emptyMap(),
348
+ requestId: String = "req-${requestSeq.incrementAndGet()}",
349
+ timeoutMs: Long = 30_000,
350
+ ): MobileFrame {
351
+ val future = CompletableFuture<MobileFrame>()
352
+ pending[requestId] = future
353
+ val frame = buildJsonObject {
354
+ put("type", type)
355
+ put("requestId", requestId)
356
+ payload.forEach { (key, value) -> put(key, value) }
357
+ }
358
+ return try {
359
+ sendRaw(frame)
360
+ val reply = future.get(timeoutMs, TimeUnit.MILLISECONDS)
361
+ if (reply.kind == "error") throw toException(reply, type)
362
+ reply
363
+ } catch (timeout: java.util.concurrent.TimeoutException) {
364
+ throw MobileRequestTimeoutException(requestId, type, timeoutMs)
365
+ } catch (failure: java.util.concurrent.ExecutionException) {
366
+ // failPending 会把连接错误包成 ExecutionException;还原成真实原因才好定位。
367
+ throw (failure.cause as? RuntimeException) ?: IllegalStateException("请求失败:$type", failure.cause)
368
+ } finally {
369
+ pending.remove(requestId)
370
+ }
371
+ }
372
+
373
+ /** 发一帧不等回帧(`message`、`subscribe`、`approval-response` 这类)。 */
374
+ fun send(frame: JsonObject) {
375
+ sendRaw(frame)
376
+ }
377
+
378
+ /** 便捷封装:`{type:'ping'}`。回帧走 [MobileGatewayListener.onPong]。 */
379
+ fun ping() {
380
+ sendRaw(buildJsonObject { put("type", "ping") })
381
+ }
382
+
383
+ /** 订阅单个会话的事件流。服务端会顺带回放该会话未决的审批/提问(带 `replayed:true`)。 */
384
+ fun subscribe(sessionId: String) {
385
+ sendRaw(buildJsonObject { put("type", "subscribe"); put("sessionId", sessionId) })
386
+ }
387
+
388
+ /** 取消订阅。之后事件流不再按会话过滤,回到全量。 */
389
+ fun unsubscribe() {
390
+ sendRaw(buildJsonObject { put("type", "unsubscribe") })
391
+ }
392
+
393
+ private fun sendRaw(frame: JsonObject) {
394
+ val target = socket ?: throw IllegalStateException("移动网关连接尚未建立。")
395
+ if (!target.send(json.encodeToString(JsonObject.serializer(), frame))) {
396
+ // send 返回 false 说明写队列已满/连接已关,帧根本没出去。静默丢帧比抛错更难查。
397
+ throw IllegalStateException("帧没有进入发送队列(连接已关闭或写缓冲已满):$frame")
398
+ }
399
+ }
400
+
401
+ private fun toException(reply: MobileFrame, type: String): MobileGatewayException {
402
+ val data = reply.data as? JsonObject
403
+ val code = data?.get("code")?.jsonPrimitive?.contentOrNullSafe() ?: "UNKNOWN"
404
+ val message = data?.get("message")?.jsonPrimitive?.contentOrNullSafe() ?: code
405
+ return MobileGatewayException(
406
+ code = code,
407
+ message = message,
408
+ requestType = data?.get("requestType")?.jsonPrimitive?.contentOrNullSafe() ?: type,
409
+ requestId = reply.requestId,
410
+ statusCode = data?.get("statusCode")?.jsonPrimitive?.contentOrNullSafe()?.toIntOrNull(),
411
+ )
412
+ }
413
+
414
+ private fun failPending(error: Throwable) {
415
+ pending.values.forEach { it.completeExceptionally(error) }
416
+ pending.clear()
417
+ }
418
+
419
+ // ── 生命周期 ─────────────────────────────────────────────────────────────
420
+
421
+ /** 等 hello。握手失败(4001、协议不匹配、连接建立不起来)会抛异常而不是干等。 */
422
+ fun awaitHello(timeoutMs: Long = 15_000): MobileHello = try {
423
+ helloFuture.get(timeoutMs, TimeUnit.MILLISECONDS)
424
+ } catch (timeout: java.util.concurrent.TimeoutException) {
425
+ throw MobileHandshakeException("${timeoutMs}ms 内没有收到 hello")
426
+ } catch (failure: java.util.concurrent.ExecutionException) {
427
+ throw (failure.cause as? RuntimeException) ?: MobileHandshakeException("握手失败:${failure.cause?.message}")
428
+ }
429
+
430
+ override fun close() {
431
+ if (!closed.compareAndSet(false, true)) return
432
+ retry?.cancel(false)
433
+ failPending(IllegalStateException("客户端已关闭"))
434
+ socket?.close(1000, "Client closed")
435
+ scheduler.shutdownNow()
436
+ }
437
+ }