@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/CHANGELOG.md +17 -16
- package/LICENSE +1 -1
- package/README.md +152 -63
- package/README.zh.md +153 -64
- package/RELEASE_NOTES.md +49 -0
- package/cordis.patch.yml +12 -16
- package/lib/client.js +256 -467
- package/lib/index.js +380 -189
- package/lib/qrcode.js +2300 -0
- package/package.json +29 -23
package/lib/index.js
CHANGED
|
@@ -1,221 +1,412 @@
|
|
|
1
|
-
// dsh-mobile-remote —
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
|
|
45
|
+
/** 心跳清理定时器间隔(毫秒)。 */
|
|
46
|
+
const HEARTBEAT_SWEEP = 30_000
|
|
18
47
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
|
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 }。
|