@nszzj/dsh-webview-remote-server 0.0.0-stage → 1.0.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/index.js ADDED
@@ -0,0 +1,360 @@
1
+ // ============================================================
2
+ // dsh-webview-remote-server —— 宿主半区
3
+ //
4
+ // 把原来那个"独立跑的 node 网关脚本"收进 DSH 成为一个插件:
5
+ // · token 不再落盘 —— 直接问 connection.authenticatedUrl() 现取
6
+ // · 密码 / 端口 存 $DSH_HOME/webview-remote-server/config.json(明文,本机文件)
7
+ // · 记录连接历史(谁、什么时候、安卓还是鸿蒙)
8
+ // · 给客户端半区(设置页那一栏)开一条 RPC 通道
9
+ //
10
+ // ── 为什么不再需要 dsh-web-url.txt ──────────────────────────────
11
+ // token 是进程级随机、只在内存里。原来那个文件是"把 token 抄出来给另一个进程用",
12
+ // 现在网关和 token 在同一个进程里,直接现取即可 —— 顺带消灭了"文件过期 / 读到旧
13
+ // token"这一整类问题(DSH 每次重启 token 都会换)。
14
+ //
15
+ // ── 远程客户端看到什么 ─────────────────────────────────────────
16
+ // 客户端半区用 ctx.remote.$host.isLoopback 判断:远程时那一栏不渲染,
17
+ // 就算点进去也只有一句"仅本机可用"。跟 DSH 自己保护设置页的做法一致。
18
+ //
19
+ // ── 已实测(2026-10-15)─────────────────────────────────────────
20
+ // · 在 DSH 里真实装载:设置页那一栏出现、能存住密码、能起监听
21
+ // · 手机(鸿蒙 ArkWeb)连上:认证 → 记历史 → 页面正常加载
22
+ // —— 也就是"token 现取"这条路是通的(原来靠落盘的 token 文件)
23
+ // · 非本机打开时那一栏只显示一句"不可用",不渲染配置面板
24
+ // · 首次安装免重启;换版本重装必须重启 DSH(宿主半区改动要重启)
25
+ // ============================================================
26
+
27
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
28
+ import { homedir } from 'node:os'
29
+ import { join } from 'node:path'
30
+ import { createGateway } from './gateway.js'
31
+
32
+ /** 插件名(cordis 用);RPC 通道名与它同源,便于一眼对上。 */
33
+ const PLUGIN_NAME = 'webview-remote-server'
34
+ /** RPC 通道:客户端半区用同一个常量来 call。 */
35
+ const CHANNEL = '/webview-remote-server'
36
+ /** 数据目录名($DSH_HOME 下)。 */
37
+ const DIR_NAME = 'webview-remote-server'
38
+
39
+ /** 默认配置。password 为空 = 不启动监听(**默认不暴露任何端口**)。 */
40
+ const DEFAULT_CONFIG = {
41
+ password: '',
42
+ port: 8080,
43
+ /** 连接历史最多留多少条。 */
44
+ historyLimit: 200,
45
+ }
46
+
47
+ // ────────────────────────────────────────────────────────────
48
+ // 路径
49
+ // ────────────────────────────────────────────────────────────
50
+
51
+ /** DSH home:优先环境变量,否则 ~/.dsh(与 dsh-home-paths 的习惯一致)。 */
52
+ function homeDir() {
53
+ return process.env.DSH_HOME ?? join(homedir(), '.dsh')
54
+ }
55
+ function dataDir() {
56
+ return join(homeDir(), DIR_NAME)
57
+ }
58
+ function configPath() {
59
+ return join(dataDir(), 'config.json')
60
+ }
61
+ function historyPath() {
62
+ return join(dataDir(), 'history.json')
63
+ }
64
+
65
+ // ────────────────────────────────────────────────────────────
66
+ // 配置读写
67
+ // ────────────────────────────────────────────────────────────
68
+
69
+ function readJson(file, fallback) {
70
+ try {
71
+ if (!existsSync(file)) return fallback
72
+ const text = readFileSync(file, 'utf8')
73
+ const parsed = JSON.parse(text)
74
+ return parsed !== null && typeof parsed === 'object' ? parsed : fallback
75
+ } catch {
76
+ return fallback
77
+ }
78
+ }
79
+
80
+ /** 原子写:先写 .tmp 再 rename,避免只写了半个文件。 */
81
+ function writeJson(file, value) {
82
+ mkdirSync(dataDir(), { recursive: true })
83
+ const tmp = file + '.tmp'
84
+ writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', 'utf8')
85
+ renameSync(tmp, file)
86
+ }
87
+
88
+ function readConfig() {
89
+ const raw = readJson(configPath(), {})
90
+ const port = Number(raw.port)
91
+ return {
92
+ password: typeof raw.password === 'string' ? raw.password : DEFAULT_CONFIG.password,
93
+ port: Number.isInteger(port) && port > 0 && port <= 65535 ? port : DEFAULT_CONFIG.port,
94
+ historyLimit: Number.isInteger(raw.historyLimit) && raw.historyLimit > 0
95
+ ? raw.historyLimit
96
+ : DEFAULT_CONFIG.historyLimit,
97
+ }
98
+ }
99
+
100
+ function writeConfig(next) {
101
+ writeJson(configPath(), next)
102
+ }
103
+
104
+ // ────────────────────────────────────────────────────────────
105
+ // 连接历史
106
+ // ────────────────────────────────────────────────────────────
107
+
108
+ function readHistory() {
109
+ const list = readJson(historyPath(), [])
110
+ return Array.isArray(list) ? list : []
111
+ }
112
+
113
+ /**
114
+ * 追加一条连接记录。
115
+ * 字段:at(毫秒时间戳)、address(来源地址)、platform(android / ohos / 空)、userAgent
116
+ */
117
+ function appendHistory(entry, limit) {
118
+ try {
119
+ const list = readHistory()
120
+ list.push(entry)
121
+ const kept = list.length > limit ? list.slice(list.length - limit) : list
122
+ writeJson(historyPath(), kept)
123
+ } catch {
124
+ /* 记历史失败绝不能影响连接本身 */
125
+ }
126
+ }
127
+
128
+ // ────────────────────────────────────────────────────────────
129
+ // 工具
130
+ // ────────────────────────────────────────────────────────────
131
+
132
+ /** 取服务:优先 ctx.get(),退回属性访问,两条路都试过再放弃(同 dsh-print-web-url)。 */
133
+ function service(context, name) {
134
+ try {
135
+ const viaGet = typeof context.get === 'function' ? context.get(name) : undefined
136
+ if (viaGet !== undefined) return viaGet
137
+ } catch {
138
+ /* 回落属性访问 */
139
+ }
140
+ return context[name]
141
+ }
142
+
143
+ function ok(value) {
144
+ return { ok: true, value }
145
+ }
146
+ function fail(code, message) {
147
+ return { ok: false, error: { code, message, details: {} } }
148
+ }
149
+
150
+ // ────────────────────────────────────────────────────────────
151
+ // 主体
152
+ // ────────────────────────────────────────────────────────────
153
+
154
+ /**
155
+ * 起一次插件。
156
+ * @param {object} ctx - 插件自己的 context(用于 logger / effect)
157
+ * @param {object} webCtx - 注入进来的 web context(connection / webServer 都在这儿)
158
+ */
159
+ function boot(ctx, webCtx) {
160
+ const logger = ctx.logger ?? console
161
+
162
+ let config = readConfig()
163
+ /** 当前监听中的网关实例;未配密码时为 undefined。 */
164
+ let gate
165
+ let listeningPort
166
+
167
+ /**
168
+ * token 提供者:**每次根请求现取**(DSH 重启换 token 也不用重启本插件)。
169
+ * 返回 undefined 表示这一刻拿不到 —— 网关会当作"没有 token"处理。
170
+ */
171
+ function resolveToken() {
172
+ const connection = service(webCtx, 'connection')
173
+ const webServer = service(webCtx, 'webServer')
174
+ const dshPort = webServer?.port
175
+ if (!Number.isInteger(dshPort)) return undefined
176
+ if (typeof connection?.authenticatedUrl !== 'function') return undefined
177
+ try {
178
+ // 只要一个带 token 的 URL;authority 用 loopback 就够(token 与 authority 无关)
179
+ const url = connection.authenticatedUrl(`http://127.0.0.1:${dshPort}`)
180
+ return new URL(url).searchParams.get('token') ?? undefined
181
+ } catch {
182
+ return undefined
183
+ }
184
+ }
185
+
186
+ function upstreamPort() {
187
+ return service(webCtx, 'webServer')?.port
188
+ }
189
+
190
+ /** 起监听。没配密码就**什么都不做** —— 默认不暴露端口。 */
191
+ async function startGateway() {
192
+ if (gate !== undefined) return
193
+ if (typeof config.password !== 'string' || config.password === '') {
194
+ logger.info?.(`[${PLUGIN_NAME}] 还没配密码,未启动监听(去设置页「WebView 远程」里设置)`)
195
+ return
196
+ }
197
+ const targetPort = upstreamPort()
198
+ if (!Number.isInteger(targetPort)) {
199
+ logger.warn?.(`[${PLUGIN_NAME}] webServer.port 还不可用,这次不起监听`)
200
+ return
201
+ }
202
+ const instance = createGateway({
203
+ password: config.password,
204
+ targetPort,
205
+ resolveToken,
206
+ host: '0.0.0.0',
207
+ port: config.port,
208
+ logger,
209
+ // 客户端过了鉴权就记一条 —— 由网关在 /__gateway/session 成功时回调
210
+ onSession: (meta) => {
211
+ appendHistory(
212
+ {
213
+ at: Date.now(),
214
+ address: meta?.address ?? '',
215
+ platform: meta?.platform ?? '',
216
+ userAgent: String(meta?.userAgent ?? '').slice(0, 200),
217
+ },
218
+ config.historyLimit,
219
+ )
220
+ },
221
+ })
222
+ try {
223
+ listeningPort = await instance.listen()
224
+ gate = instance
225
+ logger.info?.(`[${PLUGIN_NAME}] 已监听 0.0.0.0:${listeningPort} → 127.0.0.1:${targetPort}`)
226
+ } catch (error) {
227
+ logger.error?.(`[${PLUGIN_NAME}] 监听失败:${(error && error.message) || error}`)
228
+ }
229
+ }
230
+
231
+ /** 停监听。配置变了就停掉重起(端口可能变了)。 */
232
+ async function stopGateway() {
233
+ const instance = gate
234
+ gate = undefined
235
+ listeningPort = undefined
236
+ if (instance === undefined) return
237
+ try {
238
+ await instance.close()
239
+ } catch (error) {
240
+ logger.warn?.(`[${PLUGIN_NAME}] 关监听时出错:${(error && error.message) || error}`)
241
+ }
242
+ }
243
+
244
+ async function restartGateway() {
245
+ await stopGateway()
246
+ await startGateway()
247
+ }
248
+
249
+ // ── RPC 端点(客户端半区调这些)──────────────────────────────
250
+ /**
251
+ * 挂 RPC 通道。
252
+ *
253
+ * ⚠️ 必须**注册在注入进来的 web 上下文上**,否则 rpc.handle() 挂不到 webServer 上。
254
+ * DSH 0.1.5 的 rpc getter 会保留 Connection 服务自己的 context,所以要显式重绑一次。
255
+ * (这条照抄 dsh-workspace-mover 的实测结论,不是我推的。)
256
+ */
257
+ function mountRpc(owner) {
258
+ const connection = owner?.connection
259
+ if (!connection?.rpc?.handle) return undefined
260
+ const rawConnection = connection[Symbol.for('cordis.original')] ?? connection
261
+ const scopedConnection = rawConnection?.[Symbol.for('cordis.extend')]?.({ ctx: owner }) ?? connection
262
+
263
+ return scopedConnection.rpc.handle(CHANNEL, async (endpoint, payload = {}) => {
264
+ try {
265
+ switch (endpoint) {
266
+ case 'remote.status':
267
+ return ok({
268
+ running: gate !== undefined,
269
+ port: listeningPort ?? null,
270
+ configuredPort: config.port,
271
+ hasPassword: typeof config.password === 'string' && config.password !== '',
272
+ configPath: configPath(),
273
+ dataDir: dataDir(),
274
+ historyCount: readHistory().length,
275
+ channel: CHANNEL,
276
+ })
277
+
278
+ case 'remote.config.get':
279
+ // 明文密码按【用户要求】存在配置文件里,这里如实回给客户端半区
280
+ return ok({ password: config.password, port: config.port })
281
+
282
+ case 'remote.config.set': {
283
+ const nextPort = Number(payload?.port)
284
+ const nextPassword = typeof payload?.password === 'string' ? payload.password : config.password
285
+ if (!Number.isInteger(nextPort) || nextPort <= 0 || nextPort > 65535) {
286
+ return fail('bad-port', '端口必须是 1~65535 的整数')
287
+ }
288
+ const changed = nextPort !== config.port || nextPassword !== config.password
289
+ writeConfig({ ...config, password: nextPassword, port: nextPort })
290
+ config = readConfig()
291
+ if (changed) await restartGateway()
292
+ return ok({ port: config.port, hasPassword: config.password !== '', restarted: changed })
293
+ }
294
+
295
+ case 'remote.history.list':
296
+ return ok(readHistory())
297
+
298
+ case 'remote.history.clear':
299
+ writeJson(historyPath(), [])
300
+ return ok({ cleared: true })
301
+
302
+ default:
303
+ return fail('unknown-endpoint', `未知端点:${String(endpoint)}`)
304
+ }
305
+ } catch (error) {
306
+ return fail('internal', String((error && error.message) || error))
307
+ }
308
+ })
309
+ }
310
+
311
+ // ── 接线 ────────────────────────────────────────────────────
312
+ try {
313
+ const disposeRpc = mountRpc(webCtx)
314
+ void startGateway()
315
+ // 插件卸载时收尾(cordis effect 的 disposer 形式)
316
+ if (typeof ctx.effect === 'function') {
317
+ ctx.effect(() => async () => {
318
+ try {
319
+ if (typeof disposeRpc === 'function') disposeRpc()
320
+ } catch {
321
+ /* 忽略 */
322
+ }
323
+ await stopGateway()
324
+ }, `${PLUGIN_NAME}.lifecycle`)
325
+ }
326
+ } catch (error) {
327
+ // apply 里绝不能抛出去 —— 抛出去插件整个不注册,症状会很难查
328
+ logger.error?.(`[${PLUGIN_NAME}] 启动失败:${(error && error.message) || error}`)
329
+ }
330
+ }
331
+
332
+ export const name = PLUGIN_NAME
333
+ export const channel = CHANNEL
334
+ export const inject = ['webServer']
335
+
336
+ export function apply(ctx) {
337
+ // connection 可能比 webServer 晚就绪;两个都注进来,等它们可用再说。
338
+ // 与 dsh-print-web-url 同一套等待姿势:等整棵树 settle,再确认两个服务都在。
339
+ ctx.inject(['connection', 'webServer'], (webCtx) => {
340
+ const loader = service(webCtx, 'loader')
341
+ const settled = typeof loader?.await === 'function' ? loader.await() : undefined
342
+
343
+ if (settled === undefined) {
344
+ boot(ctx, webCtx)
345
+ return
346
+ }
347
+ settled.then(
348
+ () => {
349
+ if (service(webCtx, 'webServer') !== undefined && service(webCtx, 'connection') !== undefined) {
350
+ boot(ctx, webCtx)
351
+ }
352
+ },
353
+ () => {
354
+ /* 树没起来就不启动,交给别处报错 */
355
+ },
356
+ )
357
+ })
358
+ }
359
+
360
+ export default { name, channel, inject, apply }
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@nszzj/dsh-webview-remote-server",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "DSH 宿主机上的 WebView 远程接入服务:带鉴权的反向代理 + 手机端接入配置(客户端 App 是 dsh-webview-remote-client)",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./client/client.js",
10
+ "./cordis.patch.yml": "./cordis.patch.yml",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "lib",
15
+ "client",
16
+ "cordis.patch.yml",
17
+ "README.md"
18
+ ],
19
+ "engines": {
20
+ "node": ">=22"
21
+ },
22
+ "license": "MIT",
23
+ "keywords": [
24
+ "deepseek-harness",
25
+ "dsh",
26
+ "dsh-plugin",
27
+ "webview",
28
+ "remote",
29
+ "proxy"
30
+ ],
31
+ "dsh": {
32
+ "bundle": {
33
+ "patch": "./cordis.patch.yml"
34
+ },
35
+ "client": {
36
+ "platform": "web",
37
+ "inject": [
38
+ "@deepseek-ai/dsh-client-connection",
39
+ "@deepseek-ai/dsh-client-ui-slots"
40
+ ]
41
+ }
42
+ }
43
+ }