@feiyang666/dsh-mobile-remote 1.0.1 → 1.1.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 CHANGED
@@ -1,221 +1,412 @@
1
- // dsh-mobile-remote — 手机端远程控制 DeepSeek Harness(Host 半)
2
- // Host + Client 双面插件:
3
- // - Host:检测 webServer 监听地址与端口、枚举局域网 IPv4 地址并生成手机访问
4
- // URL,通过 webServer 提供 /api/dsh-mobile-remote JSON API(状态 / 心跳),
5
- // 注册 mobile_remote_status 模型工具。
6
- // - Client(lib/client.js):设置页「远程控制」页面(展示状态 / 访问地址 /
7
- // 二维码 / 使用说明)+ 移动端 UI 适配(单列布局、侧栏抽屉化、浮动控制条)。
1
+ // dsh-mobile-remote — DSH 移动端远程控制插件(host 侧)。
8
2
  //
9
- // 原理:dsh-desktop 的「移动端远程控制」开关会让 dsh web 以 --host 0.0.0.0
10
- // 监听所有网卡;手机与电脑处于同一 Wi-Fi 时,用手机浏览器打开
11
- // http://<局域网IP>:<端口> 即可访问同一个 Web UI(同一份会话数据),因此
12
- // 对话记录、会话列表、设置天然实时双向同步;发送消息、切换会话等全部操控
13
- // 在手机上即可完成。
3
+ // 两大职责:
4
+ // 1. **移动端呈现层补丁**:向 Web UI index.html 注入移动端 CSS/JS
5
+ // (composer 输入栏窄屏适配 + iOS 触控优化),不破坏 DSH 原生交互。
6
+ // 2. **远程控制设置 API**(`/__dsh_remote/*`):
7
+ // - `GET /__dsh_remote/status` 返回当前监听 host / port / LAN IP 列表 /
8
+ // 远程控制开关状态 / 当前活跃设备数;
9
+ // - `POST /__dsh_remote/toggle` 开启或关闭远程控制(写入 / 移除
10
+ // profile 的 cordis.patch.yml,经官方 HMR
11
+ // watcher 热重载 webserver 行,无需重启);
12
+ // - `POST /__dsh_remote/heartbeat` 移动端设备心跳上报,用于统计在线设备数;
13
+ // - `GET /__dsh_remote/qr` 返回连接二维码(SVG,URL 为局域网地址)。
14
+ //
15
+ // 为什么命令行启动也能远程控制(不需要桌面端):
16
+ // 官方 CLI 出于安全拒绝 `--host 0.0.0.0`(见 dsh-web-app/startup.ts),但
17
+ // 官方 `--patch <overlay>` / profile 的 cordis.patch.yml 配置层完整支持
18
+ // `webserver.host: 0.0.0.0`,且 profile patch 由 app-boot 的 watchUserPatches
19
+ // 通过 Cordis HMR 热重载。因此本插件在设置页切换开关 = 写入 / 移除 profile
20
+ // patch 的 webserver 行,即可让 dsh 重新监听所有网卡——与桌面端 `--patch`
21
+ // overlay 机制一致,官方 CLI 用户无需安装桌面端。
22
+ //
23
+ // 零运行时依赖(除 lib/qrcode.js 内联的 MIT QR 生成器):
24
+ // 不 import 任何 dsh-client / dsh-host 内部包,仅使用 Node 内置模块与
25
+ // webServer 服务公开的 tapIndex / register 接口。
14
26
 
15
27
  import { networkInterfaces } from 'node:os'
28
+ import { homedir } from 'node:os'
29
+ import { join } from 'node:path'
30
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
31
+ import qrcode from './qrcode.js'
32
+
33
+ /** 插件名(loader 诊断用)。 */
34
+ export const name = 'dsh-mobile-remote'
35
+
36
+ /** 依赖 dsh-host-webserver 注入的 webServer 服务,保证 tapIndex / register 可用。 */
37
+ export const inject = ['webServer']
38
+
39
+ /** 移动端断点:与 DSH 原生 SIDEBAR_AUTO_COLLAPSE = 1024 对齐。 */
40
+ const MOBILE_MAX_WIDTH = 1024
41
+
42
+ /** 心跳过期时间(毫秒):超过该时长未上报的设备视为离线。 */
43
+ const HEARTBEAT_TTL = 90_000
16
44
 
17
- const name = 'dsh-mobile-remote'
45
+ /** 心跳清理定时器间隔(毫秒)。 */
46
+ const HEARTBEAT_SWEEP = 30_000
18
47
 
19
- // inject 为必需项:声明硬依赖,让 loader 等服务就绪后才调用 apply。
20
- // 否则 apply 可能早于 webServer 等服务运行,ctx.get 返回 undefined,
21
- // /api/dsh-mobile-remote 路由不会注册,设置页报 "not found"。
22
- const inject = ['webServer', 'tools', 'sessionPersistence']
48
+ /**
49
+ * 插件入口。webServer 服务就绪后被调用。
50
+ * @param {import('@deepseek-ai/cordis').Context} ctx
51
+ */
52
+ export function apply(ctx) {
53
+ // 当前监听地址在服务启动时即确定。
54
+ const remoteEnabled = ctx.webServer.host === '0.0.0.0'
55
+ const port = ctx.webServer.port
23
56
 
24
- /** 是否为回环(本机)监听地址。 */
25
- function isLoopbackHost(host) {
26
- const h = String(host || '').toLowerCase()
27
- return h === '' || h === '127.0.0.1' || h === 'localhost' || h === '::1' || h === '::'
57
+ // 活跃设备表:deviceId -> 最近心跳时间戳。
58
+ const devices = new Map()
59
+
60
+ ctx.effect(() => {
61
+ const dispose = ctx.webServer.tapIndex((html) => injectIntoIndex(html, remoteEnabled))
62
+ return () => dispose()
63
+ }, 'dsh-mobile-remote: index transform')
64
+
65
+ ctx.effect(() => {
66
+ const disposers = [
67
+ ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/status', handler: handleStatus(ctx, devices) }),
68
+ ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/toggle', handler: handleToggle(ctx) }),
69
+ ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/heartbeat', handler: handleHeartbeat(devices) }),
70
+ ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/qr', handler: handleQr() }),
71
+ ]
72
+ const timer = setInterval(() => {
73
+ const now = Date.now()
74
+ for (const [id, seen] of devices) {
75
+ if (now - seen > HEARTBEAT_TTL) devices.delete(id)
76
+ }
77
+ }, HEARTBEAT_SWEEP)
78
+ return () => {
79
+ clearInterval(timer)
80
+ for (const dispose of disposers) dispose()
81
+ }
82
+ }, 'dsh-mobile-remote: api routes')
28
83
  }
29
84
 
30
- /** 枚举本机非回环 IPv4 地址(供手机同 Wi-Fi 访问)。 */
31
- function getLanIPv4Addresses() {
85
+ /* ------------------------------------------------------------------ */
86
+ /* 远程控制 API */
87
+ /* ------------------------------------------------------------------ */
88
+
89
+ /** 读取响应体的 JSON(仅 POST)。 */
90
+ function readBody(req) {
91
+ return new Promise((resolve, reject) => {
92
+ let data = ''
93
+ req.on('data', (chunk) => { data += chunk })
94
+ req.on('end', () => {
95
+ try { resolve(data ? JSON.parse(data) : {}) }
96
+ catch { resolve({}) }
97
+ })
98
+ req.on('error', reject)
99
+ })
100
+ }
101
+
102
+ /** 发送 JSON 响应。 */
103
+ function json(res, status, body) {
104
+ const payload = JSON.stringify(body)
105
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
106
+ res.end(payload)
107
+ }
108
+
109
+ /** 本机局域网 IPv4 地址列表(不含回环)。 */
110
+ function getLanAddresses() {
32
111
  const out = []
33
- try {
34
- const ifaces = networkInterfaces()
35
- for (const list of Object.values(ifaces)) {
36
- for (const it of list || []) {
37
- if (it && it.family === 'IPv4' && !it.internal && it.address) {
38
- out.push(it.address)
39
- }
40
- }
112
+ for (const entries of Object.values(networkInterfaces() ?? {})) {
113
+ for (const iface of entries ?? []) {
114
+ if (iface.family === 'IPv4' && !iface.internal) out.push(iface.address)
41
115
  }
42
- } catch (e) {
43
- /* ignore */
44
116
  }
45
- // 去重并保持稳定顺序
46
- return Array.from(new Set(out))
117
+ return out
47
118
  }
48
119
 
49
- function apply(ctx) {
50
- const webServer = ctx.get('webServer')
51
- const tools = ctx.get('tools')
52
- const sessionPersistence = ctx.get('sessionPersistence')
120
+ /** 当前 profile 的 cordis.patch.yml 绝对路径。 */
121
+ function patchFilePath(ctx) {
122
+ // DSH_HOME 环境变量优先,否则 ~/.dsh;profile 名为 DSH_PROFILE 或默认 web。
123
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
124
+ const profile = process.env.DSH_PROFILE || 'web'
125
+ return join(home, 'profiles', profile, 'cordis.patch.yml')
126
+ }
53
127
 
54
- // ---- 读取实际监听地址 / 端口(webServer 服务提供) ----
55
- function readListen() {
56
- let host = '127.0.0.1'
57
- let port = 3080
58
- try {
59
- if (webServer) {
60
- if (typeof webServer.host === 'string' && webServer.host) host = webServer.host
61
- if (typeof webServer.port === 'number' && webServer.port > 0) port = webServer.port
62
- }
63
- } catch (e) { /* ignore */ }
64
- return { host, port }
128
+ /**
129
+ * 从 profile 的 cordis.patch.yml 中读取补丁列表(YAML 数组)。
130
+ * 不引入 yaml 依赖:远程控制仅需对 webserver 行做增删,采用精确文本匹配。
131
+ * @returns {{ file: string, content: string, lines: string[] }}
132
+ */
133
+ function readPatchFile(ctx) {
134
+ const file = patchFilePath(ctx)
135
+ const content = existsSync(file) ? readFileSync(file, 'utf8') : ''
136
+ return { file, content, lines: content.split(/\r?\n/) }
137
+ }
138
+
139
+ /** webserver 远程控制覆盖块的行区间(含分隔注释)。不存在返回 null。 */
140
+ function findRemoteBlock(lines) {
141
+ const start = lines.findIndex((l) => l.includes('# --- dsh-mobile-remote managed'))
142
+ if (start === -1) return null
143
+ const end = lines.findIndex((l, i) => i > start && l.includes('# --- end dsh-mobile-remote managed'))
144
+ if (end === -1) return { start, end: lines.length }
145
+ return { start, end: end + 1 }
146
+ }
147
+
148
+ /** 检查当前 patch 中是否已启用远程控制。 */
149
+ function isPatchEnabled(ctx) {
150
+ const { lines } = readPatchFile(ctx)
151
+ const block = findRemoteBlock(lines)
152
+ if (!block) return false
153
+ return lines.slice(block.start, block.end).some((l) => l.includes("host: '0.0.0.0'"))
154
+ }
155
+
156
+ /** 追加 / 移除 webserver host 覆盖块。 */
157
+ function setPatchEnabled(ctx, enabled) {
158
+ const { file, content, lines } = readPatchFile(ctx)
159
+ const block = findRemoteBlock(lines)
160
+ let out = lines.slice()
161
+
162
+ if (block) {
163
+ out.splice(block.start, block.end - block.start)
164
+ }
165
+ if (enabled) {
166
+ const blockLines = [
167
+ '',
168
+ '# --- dsh-mobile-remote managed (auto-generated; do not edit) ---',
169
+ '- id: webserver',
170
+ ' config:',
171
+ " host: '0.0.0.0'",
172
+ ' port: !!js ctx.webStartup.port ?? ' + (ctx.webServer.port || 3080),
173
+ '# --- end dsh-mobile-remote managed ---',
174
+ '',
175
+ ]
176
+ out = [...out, ...blockLines]
65
177
  }
66
178
 
67
- /** 手机访问 URL 列表(仅当非回环监听时才真正可访问)。 */
68
- function mobileUrls(port) {
69
- return getLanIPv4Addresses().map((ip) => `http://${ip}:${port}`)
179
+ const next = out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s*$/, '\n')
180
+ writeFileSync(file, next, 'utf8')
181
+ return { file, enabled }
182
+ }
183
+
184
+ /** GET /__dsh_remote/status */
185
+ function handleStatus(ctx, devices) {
186
+ return async (req, res) => {
187
+ const lan = getLanAddresses()
188
+ const remoteEnabled = ctx.webServer.host === '0.0.0.0'
189
+ json(res, 200, {
190
+ host: ctx.webServer.host,
191
+ port: ctx.webServer.port,
192
+ lanAddresses: lan,
193
+ remoteEnabled,
194
+ patchEnabled: isPatchEnabled(ctx),
195
+ deviceCount: devices.size,
196
+ patchFile: patchFilePath(ctx),
197
+ url: lan.length ? `http://${lan[0]}:${ctx.webServer.port}` : null,
198
+ cliHint: remoteEnabled
199
+ ? null
200
+ : 'remote control is off; toggle it in settings or start dsh with --patch',
201
+ })
70
202
  }
203
+ }
71
204
 
72
- async function dispatchAction(args) {
73
- const action = args && args.action
74
- if (action === 'status' || action === undefined) {
75
- const { host, port } = readListen()
76
- const lanAddresses = getLanIPv4Addresses()
77
- const enabled = !isLoopbackHost(host)
78
- let sessionCount = 0
79
- if (sessionPersistence) {
80
- try {
81
- const rows = await sessionPersistence.list()
82
- sessionCount = Array.isArray(rows) ? rows.length : 0
83
- } catch (e) { /* ignore */ }
84
- }
85
- const now = Date.now()
86
- return {
87
- ok: true,
88
- enabled, // 远程控制是否生效(监听 0.0.0.0 等非回环地址)
89
- host, // 实际监听地址
90
- port, // 实际监听端口
91
- lanAddresses, // 局域网 IPv4 地址列表
92
- urls: mobileUrls(port), // 手机访问 URL 列表
93
- sessionCount, // 当前会话数
94
- serverTime: now,
95
- serverTimeIso: new Date(now).toISOString(),
96
- pid: typeof process !== 'undefined' ? process.pid : null,
97
- workspace: typeof process !== 'undefined' ? process.cwd() : '',
98
- }
99
- }
100
- if (action === 'ping') {
101
- const now = Date.now()
102
- return {
205
+ /** POST /__dsh_remote/toggle { enabled: boolean } */
206
+ function handleToggle(ctx) {
207
+ return async (req, res) => {
208
+ const body = await readBody(req)
209
+ const enabled = body.enabled === true
210
+ try {
211
+ const result = setPatchEnabled(ctx, enabled)
212
+ json(res, 200, {
103
213
  ok: true,
104
- serverTime: now,
105
- serverTimeIso: new Date(now).toISOString(),
106
- }
214
+ enabled,
215
+ note: enabled
216
+ ? 'patch written; dsh HMR will re-listen on 0.0.0.0'
217
+ : 'patch removed; dsh HMR will re-listen on 127.0.0.1',
218
+ patchFile: result.file,
219
+ })
220
+ } catch (error) {
221
+ json(res, 500, { ok: false, error: String(error?.message || error) })
107
222
  }
108
- return { ok: false, error: '未知操作: ' + action }
109
223
  }
224
+ }
110
225
 
111
- // ---- webServer JSON API:设置页 / 移动端 FAB 调用 ----
112
- if (webServer) {
113
- const route = {
114
- kind: 'exact',
115
- path: '/api/dsh-mobile-remote',
116
- handler(req, res) {
117
- const chunks = []
118
- req.on('data', (c) => chunks.push(c))
119
- req.on('end', () => {
120
- let args = {}
121
- try {
122
- const body = Buffer.concat(chunks).toString('utf8')
123
- if (body) args = JSON.parse(body)
124
- } catch (e) {
125
- args = {}
126
- }
127
- dispatchAction(args).then((result) => {
128
- const payload = JSON.stringify(result || { ok: false })
129
- res.writeHead(200, {
130
- 'Content-Type': 'application/json; charset=utf-8',
131
- 'Access-Control-Allow-Origin': '*',
132
- 'Content-Length': Buffer.byteLength(payload),
133
- })
134
- res.end(payload)
135
- }).catch((err) => {
136
- const payload = JSON.stringify({ ok: false, error: String((err && err.message) || err) })
137
- res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' })
138
- res.end(payload)
139
- })
140
- })
141
- },
226
+ /** POST /__dsh_remote/heartbeat { id?: string, ua?: string } */
227
+ function handleHeartbeat(devices) {
228
+ return async (req, res) => {
229
+ const body = await readBody(req)
230
+ const id = typeof body.id === 'string' && body.id ? body.id : 'anonymous'
231
+ devices.set(id, Date.now())
232
+ json(res, 200, { ok: true, deviceCount: devices.size })
233
+ }
234
+ }
235
+
236
+ /** GET /__dsh_remote/qr?url=... */
237
+ function handleQr() {
238
+ return async (req, res) => {
239
+ const raw = new URL(req.url ?? '/', 'http://x').searchParams.get('url')
240
+ const url = raw && /^https?:\/\//i.test(raw) ? raw : null
241
+ if (!url) {
242
+ json(res, 400, { ok: false, error: 'missing or invalid url param' })
243
+ return
142
244
  }
143
245
  try {
144
- const disposeRoute = webServer.register(route)
145
- ctx.effect(() => disposeRoute, 'dsh-mobile-remote: /api/dsh-mobile-remote route')
146
- } catch (e) {
147
- console.error('[dsh-mobile-remote] webServer 路由注册失败(设置页将不可用):', (e && e.message) || e)
246
+ const qr = qrcode(0, 'M')
247
+ qr.addData(url, 'Byte')
248
+ qr.make()
249
+ const svg = qr.createSvgTag({ cellSize: 4, margin: 8, scalable: true })
250
+ res.writeHead(200, { 'Content-Type': 'image/svg+xml; charset=utf-8' })
251
+ res.end(svg)
252
+ } catch (error) {
253
+ json(res, 500, { ok: false, error: String(error?.message || error) })
148
254
  }
149
255
  }
256
+ }
150
257
 
151
- // ---- mobile_remote_status 工具:模型可调用 ----
152
- if (tools) {
153
- try {
154
- const disposeTool = tools.register({
155
- name: 'mobile_remote_status',
156
- description: '查询 DeepSeek Harness 的手机端远程控制状态:返回是否已监听局域网(远程控制开关)、实际监听地址与端口、本机局域网 IPv4 地址列表、手机可访问的 URL 列表、当前会话数。手机与电脑连接同一 Wi-Fi 时,用手机浏览器打开返回的 URL 即可实时同步并远程操控电脑端。',
157
- parameters: {
158
- type: 'object',
159
- additionalProperties: false,
160
- },
161
- output: {
162
- schema: {
163
- type: 'object',
164
- additionalProperties: false,
165
- required: ['ok'],
166
- properties: {
167
- ok: { type: 'boolean' },
168
- enabled: { type: 'boolean' },
169
- host: { type: 'string' },
170
- port: { type: 'number' },
171
- lanAddresses: { type: 'array' },
172
- urls: { type: 'array' },
173
- sessionCount: { type: 'number' },
174
- serverTimeIso: { type: 'string' },
175
- error: { type: 'string' },
176
- },
177
- },
178
- render: (args, value) => {
179
- if (!value || !value.ok) {
180
- return [{ type: 'text', text: '[dsh-mobile-remote] 状态查询失败: ' + ((value && value.error) || '未知错误') }]
181
- }
182
- const lines = []
183
- lines.push('[dsh-mobile-remote] 远程控制' + (value.enabled ? '已开启' : '未开启(需在 dsh-desktop 设置中打开「移动端远程控制」)'))
184
- lines.push('监听地址: ' + value.host + ':' + value.port)
185
- if (value.lanAddresses && value.lanAddresses.length) {
186
- lines.push('局域网地址: ' + value.lanAddresses.join(' / '))
187
- lines.push('手机访问: ' + ((value.urls || []).join(' 或 ')))
188
- } else {
189
- lines.push('局域网地址: 未检测到(请检查电脑网络连接)')
190
- }
191
- lines.push('当前会话数: ' + (value.sessionCount ?? '—'))
192
- return [{ type: 'text', text: lines.join('\n') }]
193
- },
194
- },
195
- async execute(args) {
196
- return dispatchAction({ action: 'status' })
197
- },
198
- })
199
- ctx.effect(() => disposeTool, 'dsh-mobile-remote: mobile_remote_status tool')
200
- } catch (e) {
201
- console.error('[dsh-mobile-remote] mobile_remote_status 工具注册失败:', (e && e.message) || e)
202
- }
258
+ /* ------------------------------------------------------------------ */
259
+ /* 移动端呈现层补丁 */
260
+ /* ------------------------------------------------------------------ */
261
+
262
+ /**
263
+ * 把移动端 CSS/JS 注入到 index.html。优先注入到 </head> 之前,
264
+ * 找不到则退化为追加到文档末尾。
265
+ */
266
+ function injectIntoIndex(html, remoteEnabled) {
267
+ const bootConfig =
268
+ '<script id="dsh-mobile-remote-config">window.__DSH_MOBILE_REMOTE__ = ' +
269
+ JSON.stringify({ lanEnabled: remoteEnabled, mobileMaxWidth: MOBILE_MAX_WIDTH }) +
270
+ ';</script>'
271
+ const style = `<style id="dsh-mobile-remote-css">${MOBILE_CSS}</style>`
272
+ const script = `<script id="dsh-mobile-remote-js">${MOBILE_JS}</script>`
273
+ const injection = bootConfig + style + script
274
+
275
+ if (html.includes('</head>')) {
276
+ return html.replace('</head>', `${injection}\n</head>`)
277
+ }
278
+ return html + injection
279
+ }
280
+
281
+ /* ------------------------------------------------------------------ */
282
+ /* 移动端响应式 CSS */
283
+ /* 作用:仅修补 composer 输入栏在窄屏下的挤压/粘连,并做 iOS 触屏优化。 */
284
+ /* 不动 sidebar / details / frame / 任何 React 渲染结构。 */
285
+ /* ------------------------------------------------------------------ */
286
+
287
+ const MOBILE_CSS = `
288
+ /* === dsh-mobile-remote 移动端输入栏补丁 ===
289
+ * 作用范围:仅在移动设备(<1024px)下生效。DSH 原生已经在 <1024px 时把侧边栏
290
+ * 折叠为 rail 并提供原生汉堡按钮,本补丁不碰那一套,只修 composer。
291
+ *
292
+ * 选择器结构(packages/client/ui-conversation/src/client/skeleton/InputBar.tsx):
293
+ * [data-composer-card] ← composer 卡片根
294
+ * ├ [data-input-scroll] ← 文本输入滚动区
295
+ * └ div:last-child ← 工具行 .row
296
+ * ├ div:first-child ← .tools(+ 按钮、访问模式、安全模式 select)
297
+ * └ div:last-child ← .trailing(模型 select、上下文计量、发送按钮)
298
+ */
299
+
300
+ html[data-dsh-mobile] {
301
+ -webkit-text-size-adjust: 100%;
302
+ text-size-adjust: 100%;
303
+ }
304
+
305
+ html[data-dsh-mobile] body {
306
+ -webkit-tap-highlight-color: transparent;
307
+ touch-action: manipulation;
308
+ overscroll-behavior-y: contain;
309
+ }
310
+
311
+ /* ----- 输入栏工具行:窄屏下允许换行,避免左右两侧的选择器互相挤压粘连 ----- */
312
+ html[data-dsh-mobile] [data-composer-card] > div:last-child {
313
+ flex-wrap: wrap;
314
+ row-gap: 6px;
315
+ }
316
+
317
+ /* 左半区(+ 按钮 + 访问模式 + 安全模式)可收缩 */
318
+ html[data-dsh-mobile] [data-composer-card] > div:last-child > div:first-child {
319
+ flex: 1 1 auto;
320
+ min-width: 0;
321
+ }
322
+
323
+ /* 右半区(模型选择 + 上下文 + 发送)换到第二行右对齐,与左半区彻底分开 */
324
+ html[data-dsh-mobile] [data-composer-card] > div:last-child > div:last-child {
325
+ flex: none !important;
326
+ width: 100% !important;
327
+ justify-content: flex-end !important;
328
+ }
329
+
330
+ /* 工具行内的所有 <select>(选择模型 / 选择安全模式等)限宽防溢出 + 加大触控目标 */
331
+ html[data-dsh-mobile] [data-composer-card] select {
332
+ max-width: min(46vw, 200px) !important;
333
+ min-height: 36px;
334
+ font-size: 14px;
335
+ }
336
+
337
+ /* 工具行内的按钮加大触控目标(44px 苹果 HIG 推荐) */
338
+ html[data-dsh-mobile] [data-composer-card] > div:last-child button {
339
+ min-width: 40px;
340
+ min-height: 40px;
341
+ }
342
+
343
+ /* ----- iOS 触屏优化:输入框字号 >= 16px 防止聚焦自动缩放 ----- */
344
+ html[data-dsh-mobile] textarea,
345
+ html[data-dsh-mobile] input[type='text'],
346
+ html[data-dsh-mobile] input[type='search'] {
347
+ font-size: 16px !important;
348
+ }
349
+ `
350
+
351
+ /* ------------------------------------------------------------------ */
352
+ /* 移动端设备检测 + 心跳上报:仅设置 html[data-dsh-mobile] 标记,并定时向 */
353
+ /* host 上报设备在线状态(供设置页“当前连接的设备数量”统计)。 */
354
+ /* 不注入任何 UI、不修改任何 React 状态、不动任何 DOM 节点。 */
355
+ /* ------------------------------------------------------------------ */
356
+
357
+ const MOBILE_JS = `
358
+ (function () {
359
+ 'use strict';
360
+
361
+ var cfg = window.__DSH_MOBILE_REMOTE__ || { lanEnabled: false, mobileMaxWidth: 1024 };
362
+ var MAX_WIDTH = cfg.mobileMaxWidth || 1024;
363
+
364
+ /* 设备检测:移动 UA,或触屏 + 窄视口。与 DSH 原生 SIDEBAR_AUTO_COLLAPSE 同界。 */
365
+ function isMobile() {
366
+ var ua = navigator.userAgent || '';
367
+ var mobileUA = /Android|iPhone|iPad|iPod|webOS|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(ua);
368
+ var touch = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
369
+ var narrow = Math.min(window.innerWidth, (window.screen && window.screen.width) || Infinity) <= MAX_WIDTH;
370
+ return mobileUA || (touch && narrow);
203
371
  }
204
372
 
205
- // ---- 启动日志 ----
373
+ if (!isMobile()) return;
374
+
375
+ document.documentElement.setAttribute('data-dsh-mobile', '');
376
+
377
+ // 设备心跳:稳定标识 + 30s 间隔上报,host 统计在线设备数。
206
378
  try {
207
- const { host, port } = readListen()
208
- if (isLoopbackHost(host)) {
209
- console.log(`[dsh-mobile-remote] 已就绪(仅本机监听 ${host}:${port})。在 dsh-desktop 设置中开启「移动端远程控制」后重启服务,手机即可扫码/访问局域网地址远程控制。`)
210
- } else {
211
- const urls = mobileUrls(port)
212
- if (urls.length) {
213
- console.log(`[dsh-mobile-remote] 手机端远程控制可用:手机与电脑连同一 Wi-Fi,访问 ${urls.join(' 或 ')} 即可实时同步并远程操控。`)
214
- } else {
215
- console.log(`[dsh-mobile-remote] 已监听 ${host}:${port},但未检测到局域网 IPv4 地址(请检查电脑网络连接)。`)
216
- }
379
+ var KEY = '__dsh_mobile_remote_device';
380
+ var id = localStorage.getItem(KEY);
381
+ if (!id) {
382
+ id = 'dev-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
383
+ localStorage.setItem(KEY, id);
217
384
  }
218
- } catch (e) { /* ignore */ }
219
- }
385
+ function beat() {
386
+ try {
387
+ fetch('/__dsh_remote/heartbeat', {
388
+ method: 'POST',
389
+ headers: { 'Content-Type': 'application/json' },
390
+ body: JSON.stringify({ id: id, ua: navigator.userAgent }),
391
+ keepalive: true,
392
+ }).catch(function () {});
393
+ } catch (e) {}
394
+ }
395
+ beat();
396
+ setInterval(beat, 30000);
397
+ } catch (e) {}
398
+
399
+ // 调试钩子:在控制台暴露状态,便于移动端远程排查。
400
+ window.__DSH_MOBILE_REMOTE_READY__ = {
401
+ mobile: true,
402
+ viewport: { w: window.innerWidth, h: window.innerHeight },
403
+ dpr: window.devicePixelRatio || 1,
404
+ lanEnabled: cfg.lanEnabled === true,
405
+ };
406
+ })();
407
+ `
220
408
 
221
- export { apply, inject, name }
409
+ // 注意:不要添加 `export default apply`。cordis 的 Loader.unwrapExports 会优先取
410
+ // default 导出,若 default 是 apply 函数,会丢失 `name` / `inject` 命名导出
411
+ // (详见 vendor/loader/src/index.ts 的 unwrapExports 与 vendor/cordis/src/registry.ts
412
+ // 的 plugin())。保持仅命名导出,loader 才能拿到完整的 { name, inject, apply }。