dsh-remote-plugin 0.7.0-rc.3 → 0.7.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/apk/dsh-remote.apk +0 -0
- package/gateway.cjs +200 -27
- package/index.mjs +29 -2
- package/package.json +1 -1
- package/public/update.json +8 -8
- package/public/version.json +1 -1
package/apk/dsh-remote.apk
CHANGED
|
Binary file
|
package/gateway.cjs
CHANGED
|
@@ -23,13 +23,15 @@
|
|
|
23
23
|
* DSH_REMOTE_FS_MAX_UPLOAD 上传字节上限, 默认 2147483648 (2GB)
|
|
24
24
|
* DSH_REMOTE_WORKBENCH 工作台绑定文件, 默认 ~/.dsh-remote/workbench.json
|
|
25
25
|
* DSH_REMOTE_ADVERTISE_HOSTS 额外写入配对二维码的宿主 IP/主机名, 逗号或空白分隔
|
|
26
|
-
* DSH_REMOTE_DSH_CONTROL_MODE DSH 生命周期后端: auto/systemd/windows/disabled
|
|
26
|
+
* DSH_REMOTE_DSH_CONTROL_MODE DSH 生命周期后端: auto/systemd/windows/process/disabled
|
|
27
|
+
* DSH_REMOTE_DSH_LAUNCH_FILE 本机 DSH 启动配置, 默认 ~/.dsh-remote/dsh-launch.json
|
|
27
28
|
*/
|
|
28
29
|
'use strict'
|
|
29
30
|
|
|
30
31
|
const http = require('node:http')
|
|
31
32
|
const https = require('node:https')
|
|
32
|
-
const { execFile } = require('node:child_process')
|
|
33
|
+
const { execFile, spawn } = require('node:child_process')
|
|
34
|
+
const net = require('node:net')
|
|
33
35
|
const fs = require('node:fs')
|
|
34
36
|
const path = require('node:path')
|
|
35
37
|
const os = require('node:os')
|
|
@@ -91,7 +93,8 @@ const DSH_SERVICE = String(process.env.DSH_REMOTE_DSH_SERVICE || 'dsh-web').trim
|
|
|
91
93
|
const SYSTEMCTL = String(process.env.DSH_REMOTE_SYSTEMCTL || 'systemctl').trim() || 'systemctl'
|
|
92
94
|
const WINDOWS_SC = String(process.env.DSH_REMOTE_WINDOWS_SC || 'sc.exe').trim() || 'sc.exe'
|
|
93
95
|
const DSH_CONTROL_MODE_RAW = String(process.env.DSH_REMOTE_DSH_CONTROL_MODE || 'auto').trim().toLowerCase()
|
|
94
|
-
const DSH_CONTROL_MODE = ['auto', 'systemd', 'windows', 'disabled'].includes(DSH_CONTROL_MODE_RAW) ? DSH_CONTROL_MODE_RAW : 'auto'
|
|
96
|
+
const DSH_CONTROL_MODE = ['auto', 'systemd', 'windows', 'process', 'disabled'].includes(DSH_CONTROL_MODE_RAW) ? DSH_CONTROL_MODE_RAW : 'auto'
|
|
97
|
+
const DSH_LAUNCH_FILE = process.env.DSH_REMOTE_DSH_LAUNCH_FILE || path.join(os.homedir(), '.dsh-remote', 'dsh-launch.json')
|
|
95
98
|
const DSH_CONTROL_TIMEOUT_MS = durationEnv('DSH_REMOTE_DSH_CONTROL_TIMEOUT_MS', 45000, 2000, 5 * 60 * 1000)
|
|
96
99
|
const DSH_CONTROL_POLL_MS = durationEnv('DSH_REMOTE_DSH_CONTROL_POLL_MS', 500, 50, 5000)
|
|
97
100
|
const HTTP_REQUEST_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_REQUEST_TIMEOUT_MS', 15 * 60 * 1000, 0, 24 * 60 * 60 * 1000)
|
|
@@ -154,6 +157,7 @@ function containerRuntimeDetected() {
|
|
|
154
157
|
}
|
|
155
158
|
|
|
156
159
|
function dshControlSupport() {
|
|
160
|
+
if (DSH_CONTROL_MODE === 'process') return { supported: true, manager: 'process' }
|
|
157
161
|
if (DSH_CONTROL_MODE === 'disabled') {
|
|
158
162
|
return { supported: false, code: 'EXTERNAL_LIFECYCLE', message: '当前 DSH 由 Docker、面板或其他外部平台管理' }
|
|
159
163
|
}
|
|
@@ -1035,6 +1039,96 @@ async function windowsServiceStatus() {
|
|
|
1035
1039
|
}
|
|
1036
1040
|
}
|
|
1037
1041
|
|
|
1042
|
+
// Only a live ChildProcess handle grants restart authority. Never trust a saved PID:
|
|
1043
|
+
// a gateway restart or PID reuse must not turn an unrelated process into our child.
|
|
1044
|
+
let dshManagedChild = null
|
|
1045
|
+
let dshManagedExit = null
|
|
1046
|
+
function readDshLaunch() {
|
|
1047
|
+
const config = JSON.parse(fs.readFileSync(DSH_LAUNCH_FILE, 'utf8'))
|
|
1048
|
+
if (config.version !== 1 || !path.isAbsolute(config.executable || '') ||
|
|
1049
|
+
!path.isAbsolute(config.cwd || '') || !Array.isArray(config.args) ||
|
|
1050
|
+
!config.args.length || !config.args.every(arg => typeof arg === 'string' && !arg.includes('\0')) ||
|
|
1051
|
+
!path.isAbsolute(config.args[0]) || !fs.statSync(config.args[0]).isFile() ||
|
|
1052
|
+
!fs.statSync(config.executable).isFile() || !fs.statSync(config.cwd).isDirectory()) {
|
|
1053
|
+
throw new Error('DSH 启动配置无效,请从 DSH Web 插件重新生成')
|
|
1054
|
+
}
|
|
1055
|
+
if (!['127.0.0.1', 'localhost', '[::1]'].includes(UPSTREAM.hostname) || config.upstream !== UPSTREAM.origin) {
|
|
1056
|
+
throw new Error('DSH 启动配置与本机上游地址不匹配')
|
|
1057
|
+
}
|
|
1058
|
+
const env = {}
|
|
1059
|
+
for (const key of ['DSH_HOME', 'HOME', 'USERPROFILE', 'PATH', 'NODE_OPTIONS']) {
|
|
1060
|
+
if (typeof config.env?.[key] === 'string') env[key] = config.env[key]
|
|
1061
|
+
}
|
|
1062
|
+
return { ...config, env }
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function dshPortOccupied() {
|
|
1066
|
+
return new Promise(resolve => {
|
|
1067
|
+
const socket = net.connect({ host: UPSTREAM.hostname.replace(/^\[|\]$/g, ''), port: UPSTREAM.port || (UPSTREAM.protocol === 'https:' ? 443 : 80) })
|
|
1068
|
+
const finish = value => { socket.destroy(); resolve(value) }
|
|
1069
|
+
socket.once('connect', () => finish(true))
|
|
1070
|
+
socket.once('error', err => finish(err.code !== 'ECONNREFUSED'))
|
|
1071
|
+
socket.setTimeout(1500, () => finish(true)) // uncertainty must not spawn a duplicate
|
|
1072
|
+
})
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
async function dshProcessStatus() {
|
|
1076
|
+
const base = { ok: true, supported: true, manager: 'process', service: 'dsh-process', running: false, mainPid: 0, canRestart: false }
|
|
1077
|
+
try { readDshLaunch() } catch (err) {
|
|
1078
|
+
return { ...base, supported: false, code: 'LAUNCH_CONFIG_INVALID', message: '缺少有效 DSH 启动配置,请先从 DSH Web 启动 Remote 插件', detail: err.message }
|
|
1079
|
+
}
|
|
1080
|
+
if (dshManagedChild) {
|
|
1081
|
+
return { ...base, running: true, mainPid: dshManagedChild.pid, owned: true, canRestart: true, activeState: 'active', subState: 'running' }
|
|
1082
|
+
}
|
|
1083
|
+
const occupied = await dshPortOccupied()
|
|
1084
|
+
const probe = occupied ? await probeDshUpstream() : null
|
|
1085
|
+
return { ...base, running: !!probe?.ok, occupied, owned: false,
|
|
1086
|
+
activeState: occupied ? (probe?.ok ? 'active' : 'inactive') : dshManagedExit ? 'failed' : 'inactive',
|
|
1087
|
+
subState: occupied ? 'external' : 'stopped',
|
|
1088
|
+
...(dshManagedExit && !occupied ? { result: 'process-exited', execMainStatus: dshManagedExit.code, logFile: dshManagedExit.logFile } : {}),
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
async function executeDshProcessAction(action) {
|
|
1093
|
+
const config = readDshLaunch()
|
|
1094
|
+
if (action === 'restart' && !dshManagedChild && await dshPortOccupied()) {
|
|
1095
|
+
return { ok: false, code: 'EXTERNAL_PROCESS', error: '当前 DSH 并非本网关启动,不能安全重启;请在原启动器中停止后重试' }
|
|
1096
|
+
}
|
|
1097
|
+
if (action === 'restart' && dshManagedChild) {
|
|
1098
|
+
const child = dshManagedChild
|
|
1099
|
+
const stopped = new Promise(resolve => {
|
|
1100
|
+
const timer = setTimeout(() => { child.removeListener('exit', onExit); resolve(false) }, 5000)
|
|
1101
|
+
const onExit = () => { clearTimeout(timer); resolve(true) }
|
|
1102
|
+
child.once('exit', onExit)
|
|
1103
|
+
})
|
|
1104
|
+
child.kill()
|
|
1105
|
+
if (!await stopped) return { ok: false, code: 'PROCESS_STOP_TIMEOUT', error: 'DSH 进程未及时退出,未启动第二个实例' }
|
|
1106
|
+
}
|
|
1107
|
+
if (dshManagedChild || await dshPortOccupied()) return { ok: false, code: 'PORT_IN_USE', error: 'DSH 端口已被占用,未启动重复实例' }
|
|
1108
|
+
const logFile = path.join(path.dirname(DSH_LAUNCH_FILE), 'dsh-process.log')
|
|
1109
|
+
fs.mkdirSync(path.dirname(logFile), { recursive: true })
|
|
1110
|
+
const fd = fs.openSync(logFile, 'a', 0o600)
|
|
1111
|
+
let child
|
|
1112
|
+
try {
|
|
1113
|
+
child = spawn(config.executable, config.args, {
|
|
1114
|
+
cwd: config.cwd, env: { ...process.env, ...config.env },
|
|
1115
|
+
detached: true, windowsHide: true, shell: false, stdio: ['ignore', fd, fd],
|
|
1116
|
+
})
|
|
1117
|
+
} finally { fs.closeSync(fd) }
|
|
1118
|
+
dshManagedChild = child
|
|
1119
|
+
dshManagedExit = null
|
|
1120
|
+
child.once('exit', code => {
|
|
1121
|
+
if (dshManagedChild === child) { dshManagedChild = null; dshManagedExit = { code, logFile } }
|
|
1122
|
+
})
|
|
1123
|
+
return new Promise(resolve => {
|
|
1124
|
+
child.once('error', err => {
|
|
1125
|
+
if (dshManagedChild === child) { dshManagedChild = null; dshManagedExit = { code: err.code, logFile } }
|
|
1126
|
+
resolve({ ok: false, code: 'PROCESS_START_FAILED', error: `${err.message};日志:${logFile}` })
|
|
1127
|
+
})
|
|
1128
|
+
child.once('spawn', () => { child.unref(); resolve({ ok: true, code: 0 }) })
|
|
1129
|
+
})
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1038
1132
|
async function dshServiceStatus() {
|
|
1039
1133
|
if (!/^[A-Za-z0-9_.@-]+$/.test(DSH_SERVICE)) {
|
|
1040
1134
|
return { ok: false, supported: false, running: false, service: DSH_SERVICE, code: 'INVALID_SERVICE', message: 'DSH_REMOTE_DSH_SERVICE 服务名配置不合法' }
|
|
@@ -1042,7 +1136,12 @@ async function dshServiceStatus() {
|
|
|
1042
1136
|
if (!DSH_CONTROL_SUPPORT.supported) {
|
|
1043
1137
|
return { ok: true, supported: false, running: false, service: DSH_SERVICE, ...DSH_CONTROL_SUPPORT }
|
|
1044
1138
|
}
|
|
1045
|
-
if (
|
|
1139
|
+
if (DSH_CONTROL_MODE === 'process') return dshProcessStatus()
|
|
1140
|
+
if (process.platform === 'win32') {
|
|
1141
|
+
const service = await windowsServiceStatus()
|
|
1142
|
+
if (DSH_CONTROL_MODE === 'auto' && service.code === 'SERVICE_NOT_FOUND') return dshProcessStatus()
|
|
1143
|
+
return { ...service, manager: 'windows' }
|
|
1144
|
+
}
|
|
1046
1145
|
const r = await execFileResult(SYSTEMCTL, [
|
|
1047
1146
|
'--user', 'show', DSH_SERVICE,
|
|
1048
1147
|
'--property=Id,LoadState,ActiveState,SubState,UnitFileState,MainPID,Result,ExecMainStatus',
|
|
@@ -1081,6 +1180,7 @@ async function dshServiceStatus() {
|
|
|
1081
1180
|
}
|
|
1082
1181
|
|
|
1083
1182
|
async function executeDshServiceAction(action, initial) {
|
|
1183
|
+
if (initial?.manager === 'process') return executeDshProcessAction(action)
|
|
1084
1184
|
if (process.platform !== 'win32') {
|
|
1085
1185
|
return execFileResult(SYSTEMCTL, ['--user', '--no-block', action, DSH_SERVICE], 5000)
|
|
1086
1186
|
}
|
|
@@ -1171,27 +1271,25 @@ function reconnectDshEventCollectors() {
|
|
|
1171
1271
|
|
|
1172
1272
|
async function runDshControlOperation(operation) {
|
|
1173
1273
|
try {
|
|
1174
|
-
|
|
1175
|
-
dshOperationStep(operation, 'checking', `正在检查 ${manager} ${DSH_SERVICE}`)
|
|
1274
|
+
dshOperationStep(operation, 'checking', '正在检查 DSH 启动配置与运行状态')
|
|
1176
1275
|
const initial = await dshServiceStatus()
|
|
1177
1276
|
operation.initialStatus = initial
|
|
1178
1277
|
operation.observed = initial
|
|
1278
|
+
const manager = initial.manager === 'process' ? '本机进程' : process.platform === 'win32' ? 'Windows 服务' : 'systemd 用户服务'
|
|
1279
|
+
operation.service = initial.service
|
|
1179
1280
|
if (!initial.supported) {
|
|
1180
1281
|
failDshOperation(operation, initial.code || 'UNSUPPORTED', initial.message || '当前 DSH 服务不可控', initial.detail, initial)
|
|
1181
1282
|
return
|
|
1182
1283
|
}
|
|
1183
|
-
if (operation.action === 'start' && initial.running) {
|
|
1184
|
-
dshOperationStep(operation, 'complete', `DSH 已在运行(${initial.service},PID ${initial.mainPid || '未知'})`, {
|
|
1185
|
-
ok: true, done: true, code: 'ALREADY_RUNNING', status: initial, upstream: await probeDshUpstream(),
|
|
1186
|
-
})
|
|
1187
|
-
return
|
|
1188
|
-
}
|
|
1189
|
-
|
|
1190
1284
|
dshOperationStep(operation, 'command', `正在向 ${manager} 提交 DSH ${operation.action === 'start' ? '启动' : '重启'}命令`)
|
|
1191
|
-
const command =
|
|
1285
|
+
const command = operation.action === 'start' && initial.running
|
|
1286
|
+
? { ok: true, code: 'ALREADY_RUNNING' }
|
|
1287
|
+
: await executeDshServiceAction(operation.action, initial)
|
|
1192
1288
|
operation.command = { ok: command.ok, code: command.code, signal: command.signal }
|
|
1193
1289
|
if (!command.ok) {
|
|
1194
|
-
const failure =
|
|
1290
|
+
const failure = initial.manager === 'process'
|
|
1291
|
+
? { code: command.code, message: command.error, detail: '' }
|
|
1292
|
+
: classifyDshServiceFailure(command)
|
|
1195
1293
|
failDshOperation(operation, failure.code, failure.message, failure.detail, await dshServiceStatus())
|
|
1196
1294
|
return
|
|
1197
1295
|
}
|
|
@@ -1216,7 +1314,7 @@ async function runDshControlOperation(operation) {
|
|
|
1216
1314
|
}
|
|
1217
1315
|
if (operation.action === 'restart' && (status.mainPid > 0 && status.mainPid !== initialPid || status.activeState !== 'active')) restartObserved = true
|
|
1218
1316
|
if (status.activeState === 'failed') {
|
|
1219
|
-
failDshOperation(operation, 'SERVICE_FAILED', `DSH
|
|
1317
|
+
failDshOperation(operation, 'SERVICE_FAILED', `DSH 启动进程已退出(Result=${status.result || 'unknown'},ExecMainStatus=${status.execMainStatus})`, status.logFile ? `启动日志:${status.logFile}` : '', status)
|
|
1220
1318
|
return
|
|
1221
1319
|
}
|
|
1222
1320
|
if (status.running && restartObserved) {
|
|
@@ -1660,7 +1758,15 @@ async function translateModernRpc(method, payload, rpcId) {
|
|
|
1660
1758
|
return legacyFallback('no generated RPC adapter')
|
|
1661
1759
|
}
|
|
1662
1760
|
|
|
1663
|
-
|
|
1761
|
+
let response = await callUpstreamRemote(endpoint, args, rpcId)
|
|
1762
|
+
if (method === 'session.prompt' && imageAdmissionRejected(response.body)) {
|
|
1763
|
+
try {
|
|
1764
|
+
const bridged = await bridgeVisionPrompt(args.request)
|
|
1765
|
+
if (bridged) response = await callUpstreamRemote(endpoint, { ...args, request: bridged }, rpcId)
|
|
1766
|
+
} catch (error) {
|
|
1767
|
+
return legacyEnvelope(rpcId, modernError(error.message, 'image-vision-failed'))
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1664
1770
|
if (modernResponseNeedsLegacyFallback(response)) return legacyFallback(`generated RPC ${endpoint} unavailable`)
|
|
1665
1771
|
if (!response.body?.result?.ok) {
|
|
1666
1772
|
recordCompatibility('generated-rpc-error', { method, status: response.status, detail: remoteFailureKind(response) })
|
|
@@ -2761,7 +2867,7 @@ function serveStatic(req, res, url) {
|
|
|
2761
2867
|
})
|
|
2762
2868
|
return
|
|
2763
2869
|
}
|
|
2764
|
-
const apkOverride = pathname === '/dsh-remote.apk'
|
|
2870
|
+
const apkOverride = pathname === '/dsh-remote.apk' || pathname === '/dsh-remote-harmonyos-unsigned.hap' || pathname === '/dsh-remote-harmonyos.hap'
|
|
2765
2871
|
const baseDir = apkOverride ? path.join(ROOT, 'apk') : PUBLIC_DIR
|
|
2766
2872
|
const filePath = path.normalize(path.join(baseDir, pathname))
|
|
2767
2873
|
if (filePath !== baseDir && !filePath.startsWith(baseDir + path.sep)) {
|
|
@@ -2787,7 +2893,7 @@ function serveStatic(req, res, url) {
|
|
|
2787
2893
|
}
|
|
2788
2894
|
res.writeHead(200, {
|
|
2789
2895
|
'content-type': MIME[ext] || 'application/octet-stream',
|
|
2790
|
-
'cache-control': ext === '.html' || ext === '.js' || ext === '.css' ? 'no-cache' : 'public, max-age=300',
|
|
2896
|
+
'cache-control': pathname === '/harmonyos-update.json' ? 'no-store' : ext === '.html' || ext === '.js' || ext === '.css' ? 'no-cache' : 'public, max-age=300',
|
|
2791
2897
|
'content-length': st.size,
|
|
2792
2898
|
'last-modified': lastModified
|
|
2793
2899
|
})
|
|
@@ -2814,7 +2920,7 @@ function upstreamReachable(cb) {
|
|
|
2814
2920
|
req.end()
|
|
2815
2921
|
}
|
|
2816
2922
|
|
|
2817
|
-
function serveAdminApi(req, res, url) {
|
|
2923
|
+
async function serveAdminApi(req, res, url) {
|
|
2818
2924
|
const sub = url.pathname.slice('/admin/api'.length) || '/'
|
|
2819
2925
|
if (sub === '/dsh') return serveDshControl(req, res, url)
|
|
2820
2926
|
if (sub === '/state' && req.method === 'GET') {
|
|
@@ -2825,6 +2931,7 @@ function serveAdminApi(req, res, url) {
|
|
|
2825
2931
|
res.end(JSON.stringify({ error: 'unauthorized' }))
|
|
2826
2932
|
return
|
|
2827
2933
|
}
|
|
2934
|
+
const control = await dshControlDescription()
|
|
2828
2935
|
upstreamReachable((reachable) => {
|
|
2829
2936
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
2830
2937
|
res.end(JSON.stringify({
|
|
@@ -2840,8 +2947,8 @@ function serveAdminApi(req, res, url) {
|
|
|
2840
2947
|
host: HOST,
|
|
2841
2948
|
port: PORT,
|
|
2842
2949
|
protocol: { version: PROTOCOL_VERSION },
|
|
2843
|
-
capabilities: CAPABILITIES,
|
|
2844
|
-
dshControl:
|
|
2950
|
+
capabilities: { ...CAPABILITIES, dshLifecycle: control.supported ? 2 : 0 },
|
|
2951
|
+
dshControl: control,
|
|
2845
2952
|
upstream: { url: UPSTREAM.origin, reachable },
|
|
2846
2953
|
latest: {
|
|
2847
2954
|
version: latestState.version,
|
|
@@ -4218,7 +4325,50 @@ function readApiRequest(req, maxBytes = 4 * 1024 * 1024) {
|
|
|
4218
4325
|
})
|
|
4219
4326
|
}
|
|
4220
4327
|
|
|
4221
|
-
|
|
4328
|
+
// Only the explicit pre-admission image rejection is safe to retry. Never retry
|
|
4329
|
+
// a timeout, ambiguous transport failure, or an already accepted prompt.
|
|
4330
|
+
function imageAdmissionRejected(body) {
|
|
4331
|
+
const result = body?.result
|
|
4332
|
+
return result?.ok === false && result.error?.code === 'session/attachment-invalid'
|
|
4333
|
+
&& result.error?.details?.reason === 'MODEL_DOES_NOT_SUPPORT_IMAGES'
|
|
4334
|
+
}
|
|
4335
|
+
|
|
4336
|
+
async function bridgeVisionPrompt(payload) {
|
|
4337
|
+
if (!Array.isArray(payload?.content) || !payload.content.some(part => part?.type === 'image')) return null
|
|
4338
|
+
const pluginFetch = (route, options = {}) => fetch(new URL('/api/dsh-image-vision/' + route, UPSTREAM), {
|
|
4339
|
+
...options,
|
|
4340
|
+
headers: dshUpstreamHeaders({ 'content-type': 'application/json' }),
|
|
4341
|
+
signal: AbortSignal.timeout(UPSTREAM_REQUEST_TIMEOUT_MS),
|
|
4342
|
+
redirect: 'error',
|
|
4343
|
+
})
|
|
4344
|
+
const configResponse = await pluginFetch('config')
|
|
4345
|
+
if (configResponse.status === 404) throw new Error('当前模型不支持图片,未找到兼容的 dsh-image-vision 插件;请安装并启用视觉辅助插件,或切换视觉模型。')
|
|
4346
|
+
if (!configResponse.ok) throw new Error('无法检查视觉辅助插件状态,请稍后重试。')
|
|
4347
|
+
const config = await configResponse.json()
|
|
4348
|
+
if (config?.config?.enabled !== true) throw new Error('当前模型不支持图片,视觉辅助插件尚未开启。请在 DSH 中启用插件后重试。')
|
|
4349
|
+
const content = []
|
|
4350
|
+
for (const part of payload.content) {
|
|
4351
|
+
if (part?.type !== 'image') { content.push(part); continue }
|
|
4352
|
+
if (typeof part.data !== 'string' || !part.data || typeof part.mediaType !== 'string') {
|
|
4353
|
+
throw new Error('视觉辅助转换失败:图片缺少内联数据,请重新选择图片。')
|
|
4354
|
+
}
|
|
4355
|
+
const response = await pluginFetch('attach', { method: 'POST', body: JSON.stringify({
|
|
4356
|
+
data: part.data, mediaType: part.mediaType,
|
|
4357
|
+
...(typeof part.name === 'string' ? { name: part.name } : {}),
|
|
4358
|
+
}) })
|
|
4359
|
+
const value = await response.json().catch(() => null)
|
|
4360
|
+
// Accept only the plugin's same-upstream attachment reference, not arbitrary
|
|
4361
|
+
// returned text/URLs. Preserve every other block and the original send mode.
|
|
4362
|
+
if (!response.ok || value?.ok !== true || typeof value.markdown !== 'string'
|
|
4363
|
+
|| !/^!\[图片\]\(\/api\/dsh-image-vision\/raw\/[A-Za-z0-9%._~-]+\?[^\s()<>]+\)$/.test(value.markdown)) {
|
|
4364
|
+
throw new Error('视觉辅助图片上传失败,消息尚未发送,请检查插件后重试。')
|
|
4365
|
+
}
|
|
4366
|
+
content.push({ type: 'text', text: value.markdown })
|
|
4367
|
+
}
|
|
4368
|
+
return { ...payload, content }
|
|
4369
|
+
}
|
|
4370
|
+
|
|
4371
|
+
async function forwardLegacyRpc(url, raw, allowVisionBridge = true) {
|
|
4222
4372
|
const target = new URL(url.pathname + url.search, UPSTREAM)
|
|
4223
4373
|
const response = await fetch(target, {
|
|
4224
4374
|
method: 'POST',
|
|
@@ -4226,7 +4376,20 @@ async function forwardLegacyRpc(url, raw) {
|
|
|
4226
4376
|
body: raw,
|
|
4227
4377
|
signal: AbortSignal.timeout(UPSTREAM_REQUEST_TIMEOUT_MS),
|
|
4228
4378
|
})
|
|
4229
|
-
|
|
4379
|
+
const result = { status: response.status, headers: response.headers, raw: await response.text() }
|
|
4380
|
+
if (allowVisionBridge) {
|
|
4381
|
+
let body, request
|
|
4382
|
+
try { body = JSON.parse(result.raw); request = JSON.parse(raw) } catch {}
|
|
4383
|
+
if (request?.method === 'session.prompt' && imageAdmissionRejected(body)) {
|
|
4384
|
+
try {
|
|
4385
|
+
const bridged = await bridgeVisionPrompt(request.payload)
|
|
4386
|
+
if (bridged) return forwardLegacyRpc(url, JSON.stringify({ ...request, payload: bridged }), false)
|
|
4387
|
+
} catch (error) {
|
|
4388
|
+
return { ...result, status: 200, raw: JSON.stringify(legacyEnvelope(request.rpcId, modernError(error.message, 'image-vision-failed'))) }
|
|
4389
|
+
}
|
|
4390
|
+
}
|
|
4391
|
+
}
|
|
4392
|
+
return result
|
|
4230
4393
|
}
|
|
4231
4394
|
|
|
4232
4395
|
function sendBufferedUpstreamResponse(res, response) {
|
|
@@ -4416,6 +4579,15 @@ function proxyApi(req, res, url) {
|
|
|
4416
4579
|
}
|
|
4417
4580
|
|
|
4418
4581
|
// ---------- 其它 ----------
|
|
4582
|
+
async function dshControlDescription() {
|
|
4583
|
+
if (!DSH_CONTROL_SUPPORT.supported) return DSH_CONTROL_SUPPORT
|
|
4584
|
+
if (process.platform !== 'win32' && DSH_CONTROL_MODE !== 'process') return DSH_CONTROL_SUPPORT
|
|
4585
|
+
const status = await dshServiceStatus()
|
|
4586
|
+
return { supported: status.supported, manager: status.manager || DSH_CONTROL_SUPPORT.manager,
|
|
4587
|
+
...(status.code ? { code: status.code, message: status.message } : {}),
|
|
4588
|
+
...(status.canRestart !== undefined ? { canRestart: status.canRestart } : {}) }
|
|
4589
|
+
}
|
|
4590
|
+
|
|
4419
4591
|
async function serveHealth(req, res, url) {
|
|
4420
4592
|
restoreMissingTokenFile()
|
|
4421
4593
|
const eventHealth = Object.fromEntries(Object.entries(eventCollectorState).map(([kind, state]) => [kind, {
|
|
@@ -4456,6 +4628,7 @@ async function serveHealth(req, res, url) {
|
|
|
4456
4628
|
const eventsOk = eventHealth.mux.connected && eventHealth.host.connected
|
|
4457
4629
|
const readiness = { ok: upstreamOk && eventsOk, upstreamOk, eventsOk }
|
|
4458
4630
|
const status = readiness.ok ? 'ready' : upstreamReachable ? 'degraded' : 'offline'
|
|
4631
|
+
const control = await dshControlDescription()
|
|
4459
4632
|
cors(res)
|
|
4460
4633
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
4461
4634
|
res.end(JSON.stringify({
|
|
@@ -4466,8 +4639,8 @@ async function serveHealth(req, res, url) {
|
|
|
4466
4639
|
readiness,
|
|
4467
4640
|
version: VERSION,
|
|
4468
4641
|
protocol: { version: PROTOCOL_VERSION },
|
|
4469
|
-
capabilities: CAPABILITIES,
|
|
4470
|
-
dshControl:
|
|
4642
|
+
capabilities: { ...CAPABILITIES, dshLifecycle: control.supported ? 2 : 0 },
|
|
4643
|
+
dshControl: control,
|
|
4471
4644
|
pid: process.pid,
|
|
4472
4645
|
upstream: UPSTREAM.origin,
|
|
4473
4646
|
upstreamProbe: DSH_HEALTH_PATH,
|
|
@@ -4500,7 +4673,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
4500
4673
|
if (url.pathname === '/diagnostics') return serveDiagnostics(req, res, url)
|
|
4501
4674
|
if (url.pathname === '/feedback') return serveFeedback(req, res, url)
|
|
4502
4675
|
if (url.pathname === '/handoff') return serveHandoff(req, res, url)
|
|
4503
|
-
if (url.pathname.startsWith('/admin/api')) return serveAdminApi(req, res, url)
|
|
4676
|
+
if (url.pathname.startsWith('/admin/api')) return await serveAdminApi(req, res, url)
|
|
4504
4677
|
if (url.pathname.startsWith('/stats')) return serveStats(req, res, url)
|
|
4505
4678
|
if (url.pathname === '/api/ws-ticket') return serveWsTicket(req, res, url)
|
|
4506
4679
|
if (url.pathname === '/api/events.poll') return serveEventPoll(req, res, url)
|
package/index.mjs
CHANGED
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
* 浏览器侧入口由 client half 注册在 DSH 原生侧边栏(见 client.js)。
|
|
7
7
|
*/
|
|
8
8
|
import { execFileSync, spawn } from 'node:child_process'
|
|
9
|
-
import { appendFileSync, chmodSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { appendFileSync, chmodSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
|
|
10
10
|
import { stat } from 'node:fs/promises'
|
|
11
11
|
import net from 'node:net'
|
|
12
12
|
import { homedir, hostname, networkInterfaces } from 'node:os'
|
|
13
13
|
import { dirname, extname, normalize, resolve } from 'node:path'
|
|
14
14
|
import { fileURLToPath } from 'node:url'
|
|
15
|
-
import { createPluginCenter } from './plugin-center.mjs'
|
|
15
|
+
import { createPluginCenter, detectProfile } from './plugin-center.mjs'
|
|
16
16
|
|
|
17
17
|
export const name = 'dsh-remote'
|
|
18
18
|
export const inject = ['webServer', 'commands', 'agents', 'connection']
|
|
@@ -976,10 +976,37 @@ async function serveStatic(req, res, ctx) {
|
|
|
976
976
|
}
|
|
977
977
|
|
|
978
978
|
const pluginCenters = new WeakMap()
|
|
979
|
+
export function saveDshLaunchConfig(ctx) {
|
|
980
|
+
// Desktop owns its Electron runtime. Never record a desktop or unknown launcher.
|
|
981
|
+
if (process.platform !== 'win32' || process.versions.electron) return false
|
|
982
|
+
const profile = detectProfile(ctx)
|
|
983
|
+
if (!profile?.cli || profile.name === 'desktop' || resolve(process.argv[1] || '') !== resolve(profile.cli)) return false
|
|
984
|
+
const env = { DSH_HOME: profile.home }
|
|
985
|
+
for (const key of ['HOME', 'USERPROFILE', 'PATH', 'NODE_OPTIONS']) {
|
|
986
|
+
if (process.env[key] !== undefined) env[key] = process.env[key]
|
|
987
|
+
}
|
|
988
|
+
const args = [profile.cli, ...process.argv.slice(2)]
|
|
989
|
+
if (!args.includes('--no-open')) args.push('--no-open')
|
|
990
|
+
const config = { version: 1, executable: process.execPath, args, cwd: process.cwd(),
|
|
991
|
+
env, profile: profile.name, upstream: upstreamUrlForListener({ host: ctx.webServer.host, port: ctx.webServer.port }) }
|
|
992
|
+
const file = process.env.DSH_REMOTE_DSH_LAUNCH_FILE || `${homedir()}/.dsh-remote/dsh-launch.json`
|
|
993
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
994
|
+
try {
|
|
995
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
996
|
+
writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 })
|
|
997
|
+
renameSync(tmp, file)
|
|
998
|
+
return true
|
|
999
|
+
} catch (err) {
|
|
1000
|
+
logGateway('无法保存 DSH 启动配置: ' + err.message)
|
|
1001
|
+
return false
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
979
1005
|
export function apply(ctx) {
|
|
980
1006
|
pluginCenters.set(ctx, createPluginCenter(ctx))
|
|
981
1007
|
dshListen = { host: ctx.webServer.host, port: ctx.webServer.port }
|
|
982
1008
|
dshConnection = ctx.connection
|
|
1009
|
+
saveDshLaunchConfig(ctx)
|
|
983
1010
|
ctx.effect(() => ctx.webServer.register({
|
|
984
1011
|
kind: 'prefix',
|
|
985
1012
|
path: MOUNT,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-plugin",
|
|
3
|
-
"version": "0.7.0
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
package/public/update.json
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.7.0
|
|
2
|
+
"version": "0.7.0",
|
|
3
3
|
"apkUrl": "dsh-remote.apk",
|
|
4
|
-
"sha256": "
|
|
5
|
-
"releasedAt": "2026-09-
|
|
6
|
-
"notes": "0.7.0-
|
|
4
|
+
"sha256": "a2c885aecf6d677b76413d0ed92a369a7045f3227a4c8b90d3365c3dfc9979f9",
|
|
5
|
+
"releasedAt": "2026-09-24T07:05:55.044Z",
|
|
6
|
+
"notes": "0.7.0:Windows 上未注册为服务的 DSH 可由 Remote 网关按已检查的 CLI 启动配置受控启动;已有 Windows 服务仍优先使用,网关只重启自己启动并持有句柄的进程。手机发送图片遇到 DSH 明确拒绝(当前模型不支持图片)时,可通过已启用的 dsh-image-vision 插件桥接附件;桥接失败不会提交不完整消息。保留长按插队发送、跨设备接续、鸿蒙客户端源码与平板分栏、网关 WebSocket Ping 分帧修复等 0.7.0 RC 功能。本次提供 Android APK 与 Windows/Linux 网关,不提供鸿蒙 HAP。",
|
|
7
7
|
"history": [
|
|
8
|
+
{
|
|
9
|
+
"version": "0.7.0",
|
|
10
|
+
"notes": "0.7.0:Windows 上未注册为服务的 DSH 可由 Remote 网关按已检查的 CLI 启动配置受控启动;已有 Windows 服务仍优先使用,网关只重启自己启动并持有句柄的进程。手机发送图片遇到 DSH 明确拒绝(当前模型不支持图片)时,可通过已启用的 dsh-image-vision 插件桥接附件;桥接失败不会提交不完整消息。保留长按插队发送、跨设备接续、鸿蒙客户端源码与平板分栏、网关 WebSocket Ping 分帧修复等 0.7.0 RC 功能。本次提供 Android APK 与 Windows/Linux 网关,不提供鸿蒙 HAP。"
|
|
11
|
+
},
|
|
8
12
|
{
|
|
9
13
|
"version": "0.6.26",
|
|
10
14
|
"notes": "0.6.26:修复 DSH 通配监听地址导致的认证与实时连接失败,兼容 IPv6-only;上传覆盖失败保留原文件,避免并发同名覆盖和续传分片竞争;网关改用认证关闭,避免误杀其他进程;完善 dsh-ui 堆叠柱状图、标签页、表格排序与特殊单元格、趋势线、环形进度、文件树和修改前后预览。已通过 Windows 自动测试与浏览器检查,Linux/Docker 专项实测仍待完成。"
|
|
@@ -40,10 +44,6 @@
|
|
|
40
44
|
{
|
|
41
45
|
"version": "0.6.18",
|
|
42
46
|
"notes": "0.6.18:新增独立 Token 量趋势统计并保留原有费用趋势;服务器连接身份改为地址加令牌,支持同地址不同令牌并兼容旧配置;投票在网关不可达时尝试直连公网收集器托底(需收集器允许 App/Web CORS);修复 Windows 下 gateway-stats 调用 zstd 时弹出终端窗口的问题,并补充回归测试;同步完善相关模块文档。"
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
"version": "0.6.17",
|
|
46
|
-
"notes": "0.6.17:修复 host.describe 初次失败后总览误报 DSH 上游离线并增加恢复重试;识别 Docker、无 systemd 和插件内嵌等外部生命周期环境,隐藏不可用的 DSH 启停并给出明确提示;兼容 Caddy Basic Auth,避免管理 API 覆盖 Authorization 导致登录循环;支持通过环境变量或管理页手动补充 Docker 宿主、局域网和 Tailscale 地址并写入配对二维码;完整适配 Windows 盘符、UNC、多文件根和跨主机文件路径清理,并新增 Windows 原生 CI 安全门禁。"
|
|
47
47
|
}
|
|
48
48
|
]
|
|
49
49
|
}
|
package/public/version.json
CHANGED