dsh-remote-plugin 0.7.2 → 0.7.3
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 +313 -18
- package/index.mjs +31 -9
- package/package.json +1 -1
- package/public/app.js +310 -303
- package/public/desktop/desktop.css +101 -0
- package/public/desktop/desktop.html +59 -21
- package/public/desktop/desktop.js +260 -101
- package/public/history.js +160 -0
- package/public/index.html +61 -22
- package/public/link-check.js +109 -0
- package/public/plugin-center.css +11 -0
- package/public/plugin-center.js +92 -39
- package/public/styles.css +107 -2
- package/public/update.json +8 -8
- package/public/version.json +1 -1
package/apk/dsh-remote.apk
CHANGED
|
Binary file
|
package/gateway.cjs
CHANGED
|
@@ -37,6 +37,96 @@ const path = require('node:path')
|
|
|
37
37
|
const os = require('node:os')
|
|
38
38
|
const crypto = require('node:crypto')
|
|
39
39
|
|
|
40
|
+
// Crash records deliberately contain facts and source positions, never arbitrary error text.
|
|
41
|
+
const CRASH_LOG_FILE=process.env.DSH_REMOTE_CRASH_LOG_FILE || path.join(os.homedir(),'.dsh-remote','crashes.jsonl')
|
|
42
|
+
const AUTO_RESTART=process.env.DSH_REMOTE_AUTO_RESTART!=='0'
|
|
43
|
+
const CRASH_KINDS=new Set(['fatal','exit','restart','restart-failed','restart-limit','manual-stop','startup','desktop-crash'])
|
|
44
|
+
const CRASH_COMPONENTS=new Set(['gateway','dsh','desktop'])
|
|
45
|
+
const CRASH_CODES=new Set(['EADDRINUSE','EACCES','EPERM','ENOENT','ENOMEM','ECONNRESET','ETIMEDOUT','ERR_OUT_OF_MEMORY','WINDOWS_APPLICATION_ERROR','PROCESS_START_FAILED','PROCESS_STOP_TIMEOUT','EXTERNAL_PROCESS','PORT_IN_USE','SERVICE_FAILED','SERVICE_TIMEOUT','EVENTS_TIMEOUT','UPSTREAM_TIMEOUT','INTERNAL_ERROR','LAUNCH_CONFIG_INVALID','STATUS_PARSE_FAILED'])
|
|
46
|
+
function safeCrashRecord(value) {
|
|
47
|
+
const row={at:Number.isFinite(value?.at)?value.at:Date.now(),component:CRASH_COMPONENTS.has(value?.component)?value.component:'gateway',kind:CRASH_KINDS.has(value?.kind)?value.kind:'fatal'}
|
|
48
|
+
if(Number.isInteger(value?.exitCode))row.exitCode=value.exitCode
|
|
49
|
+
if(/^(SIG[A-Z]+)$/.test(value?.signal||''))row.signal=value.signal
|
|
50
|
+
if(CRASH_CODES.has(value?.code))row.code=value.code
|
|
51
|
+
if(['Error','TypeError','RangeError','ReferenceError','SyntaxError','URIError','AggregateError'].includes(value?.errorName))row.errorName=value.errorName
|
|
52
|
+
if(Number.isInteger(value?.attempt)&&value.attempt>=0&&value.attempt<=10)row.attempt=value.attempt
|
|
53
|
+
if(Array.isArray(value?.frames))row.frames=value.frames.slice(0,4).filter(f=>['gateway.js','gateway.cjs','index.js'].includes(f?.file)&&Number.isInteger(f.line)&&Number.isInteger(f.column)).map(f=>({file:f.file,line:f.line,column:f.column}))
|
|
54
|
+
return row
|
|
55
|
+
}
|
|
56
|
+
function crashErrorFacts(error) {
|
|
57
|
+
const frames=[]
|
|
58
|
+
for(const line of String(error?.stack||'').split('\n').slice(1,12)){
|
|
59
|
+
const match=line.match(/(?:[\\/])(gateway\.(?:js|cjs)|index\.js):(\d+):(\d+)\)?$/)
|
|
60
|
+
if(match)frames.push({file:match[1],line:Number(match[2]),column:Number(match[3])})
|
|
61
|
+
}
|
|
62
|
+
return {code:error?.code,errorName:error?.name,frames}
|
|
63
|
+
}
|
|
64
|
+
function recordCrash(component,kind,facts={}) {
|
|
65
|
+
const row=safeCrashRecord({at:Date.now(),component,kind,...facts})
|
|
66
|
+
try {
|
|
67
|
+
fs.mkdirSync(path.dirname(CRASH_LOG_FILE),{recursive:true,mode:0o700})
|
|
68
|
+
if(fs.existsSync(CRASH_LOG_FILE)&&fs.statSync(CRASH_LOG_FILE).size>128*1024){try{fs.unlinkSync(CRASH_LOG_FILE+'.1')}catch{};fs.renameSync(CRASH_LOG_FILE,CRASH_LOG_FILE+'.1')}
|
|
69
|
+
fs.appendFileSync(CRASH_LOG_FILE,JSON.stringify(row)+'\n',{mode:0o600})
|
|
70
|
+
} catch {}
|
|
71
|
+
return row
|
|
72
|
+
}
|
|
73
|
+
function crashRecords(limit=40) {
|
|
74
|
+
const rows=[]
|
|
75
|
+
for(const file of [CRASH_LOG_FILE+'.1',CRASH_LOG_FILE])try{const stat=fs.statSync(file);if(stat.size>256*1024)continue;for(const line of fs.readFileSync(file,'utf8').split('\n'))try{rows.push(safeCrashRecord(JSON.parse(line)))}catch{}}catch{}
|
|
76
|
+
return rows.slice(-Math.min(40,limit))
|
|
77
|
+
}
|
|
78
|
+
function crashReply(res,status,value) {res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store'});res.end(JSON.stringify(value))}
|
|
79
|
+
function crashUploadSummary() {
|
|
80
|
+
// The deployed collector already persists serverInfo (500 chars), so this works before any server upgrade.
|
|
81
|
+
const recent=crashRecords().filter(r=>r.kind!=='startup'&&r.kind!=='manual-stop').slice(-3)
|
|
82
|
+
const report={schema:1,records:recent}
|
|
83
|
+
while(JSON.stringify(report).length>490){if(report.records.some(r=>r.frames?.length)){for(const r of report.records)delete r.frames}else report.records.shift()}
|
|
84
|
+
return report
|
|
85
|
+
}
|
|
86
|
+
function runGatewaySupervisor() {
|
|
87
|
+
const port=Number(process.env.PORT)||8787,lease=path.join(path.dirname(CRASH_LOG_FILE),'gateway-supervisor-'+port+'.json')
|
|
88
|
+
const nonce=crypto.randomBytes(12).toString('hex')
|
|
89
|
+
try {
|
|
90
|
+
fs.mkdirSync(path.dirname(lease),{recursive:true,mode:0o700})
|
|
91
|
+
if(fs.existsSync(lease)){
|
|
92
|
+
const old=JSON.parse(fs.readFileSync(lease,'utf8'));let alive=false
|
|
93
|
+
try{process.kill(old.pid,0);alive=true}catch(e){alive=e.code!=='ESRCH'}
|
|
94
|
+
if(alive){console.error('[recovery] supervisor already running');process.exit(78)}
|
|
95
|
+
fs.unlinkSync(lease)
|
|
96
|
+
}
|
|
97
|
+
fs.writeFileSync(lease,JSON.stringify({pid:process.pid,nonce}),{flag:'wx',mode:0o600})
|
|
98
|
+
} catch {console.error('[recovery] cannot acquire supervisor lease');process.exit(78)}
|
|
99
|
+
let worker=null,stopping=false,timer=null,attempts=[]
|
|
100
|
+
const cleanup=()=>{try{if(JSON.parse(fs.readFileSync(lease,'utf8')).nonce===nonce)fs.unlinkSync(lease)}catch{}}
|
|
101
|
+
process.once('exit',cleanup)
|
|
102
|
+
const stop=()=>{if(stopping)return;stopping=true;clearTimeout(timer);recordCrash('gateway','manual-stop');if(worker)worker.kill();else process.exit(0)}
|
|
103
|
+
process.on('SIGTERM',stop);process.on('SIGINT',stop)
|
|
104
|
+
const launch=()=>{
|
|
105
|
+
const env={...process.env,DSH_REMOTE_SUPERVISED:'1',DSH_REMOTE_SUPERVISOR_PID:String(process.pid)}
|
|
106
|
+
delete env.NODE_CHANNEL_FD;delete env.NODE_CHANNEL_SERIALIZATION_MODE
|
|
107
|
+
const args=process.pkg?process.argv.slice(2).filter(a=>a!=='--supervise'):[__filename,...process.argv.slice(2).filter(a=>a!=='--supervise')]
|
|
108
|
+
try{worker=spawn(process.execPath,args,{cwd:process.cwd(),env,windowsHide:true,stdio:['ignore','inherit','inherit']})}catch(error){recordCrash('gateway','restart-failed',crashErrorFacts(error));process.exit(1)}
|
|
109
|
+
let handled=false
|
|
110
|
+
const exited=(code,signal)=>{
|
|
111
|
+
if(handled)return;handled=true;worker=null
|
|
112
|
+
if(stopping||code===0||code===78){process.exit(code===78?78:0);return}
|
|
113
|
+
recordCrash('gateway','exit',{exitCode:code,signal})
|
|
114
|
+
if(!AUTO_RESTART){process.exit(1);return}
|
|
115
|
+
const now=Date.now();attempts=attempts.filter(t=>now-t<5*60_000)
|
|
116
|
+
if(attempts.length>=5){recordCrash('gateway','restart-limit',{attempt:5});process.exit(1);return}
|
|
117
|
+
attempts.push(now);recordCrash('gateway','restart',{attempt:attempts.length})
|
|
118
|
+
timer=setTimeout(launch,Math.min(30000,1000*2**(attempts.length-1)))
|
|
119
|
+
}
|
|
120
|
+
worker.once('error',error=>{recordCrash('gateway','restart-failed',crashErrorFacts(error));exited(1,null)})
|
|
121
|
+
worker.once('exit',exited)
|
|
122
|
+
}
|
|
123
|
+
launch()
|
|
124
|
+
}
|
|
125
|
+
if(process.argv.includes('--supervise')&&process.env.DSH_REMOTE_SUPERVISED!=='1'){
|
|
126
|
+
runGatewaySupervisor()
|
|
127
|
+
}else{
|
|
128
|
+
|
|
129
|
+
|
|
40
130
|
let statsCore = null
|
|
41
131
|
let statsStore = null
|
|
42
132
|
try {
|
|
@@ -259,6 +349,35 @@ function fsInsideReal(real) {
|
|
|
259
349
|
}
|
|
260
350
|
return false
|
|
261
351
|
}
|
|
352
|
+
// 网关自身的状态与安装目录永远不出现在 /fs 通道里: 设备密钥同样能访问 /fs,
|
|
353
|
+
// 若允许读取就等同于把主令牌(以及其它设备密钥)直接交出去, 允许写入还能替换
|
|
354
|
+
// 正在对外提供的静态资源或网关文件。显式配置到其它位置的状态文件按精确路径保护。
|
|
355
|
+
const FS_PROTECTED_DIRS = [...new Set([
|
|
356
|
+
path.resolve(path.join(FS_DEFAULT_ROOT, '.dsh-remote')),
|
|
357
|
+
path.resolve(ROOT),
|
|
358
|
+
])]
|
|
359
|
+
const FS_PROTECTED_FILES = [...new Set([TOKEN_FILE, DEVICE_KEYS_FILE, NOTES_FILE, WORKBENCH_FILE,
|
|
360
|
+
HANDOFF_FILE, DSH_UPSTREAM_COOKIE_FILE, DSH_LAUNCH_FILE].filter(Boolean).map(value => path.resolve(String(value))))]
|
|
361
|
+
|
|
362
|
+
function fsSamePath(left, right) {
|
|
363
|
+
const a = path.resolve(String(left || ''))
|
|
364
|
+
const b = path.resolve(String(right || ''))
|
|
365
|
+
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** 路径是否命中受保护目录(目录内全部)或受保护文件(精确匹配)。realpath 结果同样适用。 */
|
|
369
|
+
function fsProtectedPath(abs) {
|
|
370
|
+
if (!abs) return true
|
|
371
|
+
const candidate = path.resolve(String(abs))
|
|
372
|
+
for (const dir of FS_PROTECTED_DIRS) {
|
|
373
|
+
if (fsInsideRoot(candidate, dir)) return true
|
|
374
|
+
}
|
|
375
|
+
for (const file of FS_PROTECTED_FILES) {
|
|
376
|
+
if (fsSamePath(candidate, file)) return true
|
|
377
|
+
}
|
|
378
|
+
return false
|
|
379
|
+
}
|
|
380
|
+
|
|
262
381
|
|
|
263
382
|
const FS_MIME = {
|
|
264
383
|
...MIME,
|
|
@@ -534,6 +653,7 @@ const runtimeState = {
|
|
|
534
653
|
unhandledRejections: 0,
|
|
535
654
|
lastErrorAt: 0,
|
|
536
655
|
lastError: '',
|
|
656
|
+
recovery: {enabled:AUTO_RESTART,supervised:process.env.DSH_REMOTE_SUPERVISED==='1',supervisorPid:Number(process.env.DSH_REMOTE_SUPERVISOR_PID)||0},
|
|
537
657
|
}
|
|
538
658
|
|
|
539
659
|
function pruneDevices(now = Date.now()) {
|
|
@@ -1043,8 +1163,11 @@ async function windowsServiceStatus() {
|
|
|
1043
1163
|
// a gateway restart or PID reuse must not turn an unrelated process into our child.
|
|
1044
1164
|
let dshManagedChild = null
|
|
1045
1165
|
let dshManagedExit = null
|
|
1166
|
+
const intentionalDshStops=new WeakSet()
|
|
1167
|
+
const desktopChildren=new Map()
|
|
1046
1168
|
function readDshLaunch() {
|
|
1047
1169
|
const config = JSON.parse(fs.readFileSync(DSH_LAUNCH_FILE, 'utf8'))
|
|
1170
|
+
if (config.kind === 'desktop') return readDesktopLaunch(config)
|
|
1048
1171
|
if (config.version !== 1 || !path.isAbsolute(config.executable || '') ||
|
|
1049
1172
|
!path.isAbsolute(config.cwd || '') || !Array.isArray(config.args) ||
|
|
1050
1173
|
!config.args.length || !config.args.every(arg => typeof arg === 'string' && !arg.includes('\0')) ||
|
|
@@ -1062,6 +1185,77 @@ function readDshLaunch() {
|
|
|
1062
1185
|
return { ...config, env }
|
|
1063
1186
|
}
|
|
1064
1187
|
|
|
1188
|
+
// Desktop roots are adopted only with birth time, executable, Host ancestry and TCP ownership evidence.
|
|
1189
|
+
// Never use taskkill /T: the detached gateway must survive the desktop's exit.
|
|
1190
|
+
function readDesktopLaunch(config) {
|
|
1191
|
+
const d = config.desktop, validPid = value => Number.isSafeInteger(value) && value > 0
|
|
1192
|
+
if (process.platform !== 'win32' || config.version !== 1 || config.profile !== 'desktop' ||
|
|
1193
|
+
!path.isAbsolute(config.executable || '') || !fs.statSync(config.executable).isFile() ||
|
|
1194
|
+
!path.isAbsolute(config.cwd || '') || !fs.statSync(config.cwd).isDirectory() ||
|
|
1195
|
+
!Array.isArray(config.args) || config.args.length || !d || !validPid(d.root?.pid) || !validPid(d.host?.pid) || d.root.pid === d.host.pid ||
|
|
1196
|
+
!/^\d{4}-\d\d-\d\dT[\d:.]+Z$/.test(d.root.started || '') || !/^\d{4}-\d\d-\d\dT[\d:.]+Z$/.test(d.host.started || '') ||
|
|
1197
|
+
!path.isAbsolute(d.profilePath || '') || !path.isAbsolute(d.hostScript || '') ||
|
|
1198
|
+
!/[\\/]@deepseek-ai[\\/]dsh-desktop-host[\\/]lib[\\/]index\.js$/.test(d.hostScript) ||
|
|
1199
|
+
!['127.0.0.1','localhost','[::1]'].includes(UPSTREAM.hostname) || config.upstream !== UPSTREAM.origin) throw new Error('Desktop 启动配置无效或与当前上游不匹配')
|
|
1200
|
+
return { ...config, env:typeof config.env?.DSH_HOME === 'string' ? {DSH_HOME:config.env.DSH_HOME} : {} }
|
|
1201
|
+
}
|
|
1202
|
+
async function desktopProcessEvidence(config) {
|
|
1203
|
+
const d = config.desktop
|
|
1204
|
+
const script = '[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); $rows=@(' + [d.root.pid,d.host.pid].map(pid =>
|
|
1205
|
+
"Get-CimInstance Win32_Process -Filter 'ProcessId=" + pid + "' | ForEach-Object { @{pid=$_.ProcessId;parent=$_.ParentProcessId;executable=$_.ExecutablePath;command=$_.CommandLine;started=$_.CreationDate.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')} }").join('; ') +
|
|
1206
|
+
'); $owners=@(Get-NetTCPConnection -State Listen -LocalPort ' + UPSTREAM_PORT + ' -ErrorAction SilentlyContinue | ForEach-Object {$_.OwningProcess}); @{rows=$rows;owners=$owners} | ConvertTo-Json -Compress -Depth 4'
|
|
1207
|
+
const result = await execFileResult('powershell.exe',['-NoLogo','-NoProfile','-NonInteractive','-Command',script],10000)
|
|
1208
|
+
if (!result.ok) throw new Error('无法读取 Desktop 进程身份: ' + result.stderr)
|
|
1209
|
+
return JSON.parse(result.stdout)
|
|
1210
|
+
}
|
|
1211
|
+
function verifyDesktopEvidence(config, evidence) {
|
|
1212
|
+
const d=config.desktop, rows=evidence.rows || [], owners=evidence.owners || []
|
|
1213
|
+
const root=rows.find(row=>row.pid===d.root.pid), host=rows.find(row=>row.pid===d.host.pid)
|
|
1214
|
+
const sameExecutable=row=>row?.executable && path.resolve(row.executable).toLowerCase()===path.resolve(config.executable).toLowerCase()
|
|
1215
|
+
const rootValid=sameExecutable(root) && root.started===d.root.started && !/--(?:type|expose-internals)\b/.test(root.command || '')
|
|
1216
|
+
const hostValid=sameExecutable(host) && host.started===d.host.started && host.parent===d.root.pid &&
|
|
1217
|
+
(host.command || '').toLowerCase().includes(d.hostScript.toLowerCase()) && (host.command || '').toLowerCase().includes(d.profilePath.toLowerCase())
|
|
1218
|
+
return {rootValid:!!rootValid,hostValid:!!hostValid,occupied:owners.length>0,ownedPort:owners.length>0 && owners.every(pid=>pid===d.host.pid),rootPresent:!!root,hostPresent:!!host}
|
|
1219
|
+
}
|
|
1220
|
+
async function dshDesktopStatus(config) {
|
|
1221
|
+
try {
|
|
1222
|
+
const proof=verifyDesktopEvidence(config,await desktopProcessEvidence(config))
|
|
1223
|
+
const external=(proof.rootPresent&&!proof.rootValid)||(proof.hostPresent&&!proof.hostValid)||(proof.occupied&&(!proof.hostValid||!proof.ownedPort))
|
|
1224
|
+
return {ok:true,supported:true,manager:'desktop',service:'DSH Desktop',running:proof.rootValid,mainPid:proof.rootValid?config.desktop.root.pid:0,
|
|
1225
|
+
canRestart:proof.rootValid&&!external,canStart:!external,activeState:proof.rootValid?'active':'inactive',subState:external?'external':proof.rootValid?'running':'stopped',
|
|
1226
|
+
...(external?{code:'EXTERNAL_PROCESS',message:'Desktop 进程身份或端口归属已变化,请从当前 Desktop 重新加载 Remote 插件'}:{})}
|
|
1227
|
+
} catch(error) {return {ok:false,supported:false,manager:'desktop',code:'STATUS_PARSE_FAILED',message:'无法核验 Desktop 进程身份',detail:error.message,canRestart:false}}
|
|
1228
|
+
}
|
|
1229
|
+
async function executeDshDesktopAction(action, config) {
|
|
1230
|
+
let proof=verifyDesktopEvidence(config,await desktopProcessEvidence(config))
|
|
1231
|
+
if ((proof.rootPresent&&!proof.rootValid)||(proof.hostPresent&&!proof.hostValid)||(proof.occupied&&(!proof.hostValid||!proof.ownedPort))) return {ok:false,code:'EXTERNAL_PROCESS',error:'Desktop 进程身份或端口归属已变化,未停止任何进程'}
|
|
1232
|
+
if (action==='restart' && proof.rootValid) {
|
|
1233
|
+
const literal=value=>"'"+String(value).replace(/'/g,"''")+"'"
|
|
1234
|
+
const script="$ErrorActionPreference='Stop'; $p=Get-Process -Id "+config.desktop.root.pid+"; if($p.Path -ine "+literal(config.executable)+" -or $p.StartTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') -ne "+literal(config.desktop.root.started)+") {throw 'Desktop process identity changed'}; $p.Kill(); if(-not $p.WaitForExit(10000)){throw 'Desktop stop timed out'}"
|
|
1235
|
+
const ownedChild=desktopChildren.get(config.desktop.root.pid)
|
|
1236
|
+
if(ownedChild)intentionalDshStops.add(ownedChild)
|
|
1237
|
+
const stopped=await execFileResult('powershell.exe',['-NoLogo','-NoProfile','-NonInteractive','-Command',script],12000)
|
|
1238
|
+
if(!stopped.ok)return {ok:false,code:'PROCESS_STOP_TIMEOUT',error:'Desktop 未完成退出,未启动第二个实例'}
|
|
1239
|
+
// Host handles parent IPC disconnect through its normal shutdown service.
|
|
1240
|
+
const stopDeadline=Date.now()+15000
|
|
1241
|
+
do {
|
|
1242
|
+
proof=verifyDesktopEvidence(config,await desktopProcessEvidence(config))
|
|
1243
|
+
if(!proof.occupied&&!proof.hostPresent)break
|
|
1244
|
+
if(proof.hostPresent&&!proof.hostValid)return {ok:false,code:'EXTERNAL_PROCESS',error:'退出期间 Host 身份已变化,未启动第二个实例'}
|
|
1245
|
+
await delay(250)
|
|
1246
|
+
} while(Date.now()<stopDeadline)
|
|
1247
|
+
if(proof.occupied||proof.hostPresent)return {ok:false,code:'PROCESS_STOP_TIMEOUT',error:'Desktop Host 尚未退出,未启动第二个实例'}
|
|
1248
|
+
} else if(proof.rootValid)return {ok:true,code:'ALREADY_RUNNING'}
|
|
1249
|
+
if(await dshPortOccupied())return {ok:false,code:'PORT_IN_USE',error:'DSH 端口已被占用,未启动重复实例'}
|
|
1250
|
+
const env={...process.env,...config.env}
|
|
1251
|
+
for(const key of ['ELECTRON_RUN_AS_NODE','NODE_CHANNEL_FD','NODE_CHANNEL_SERIALIZATION_MODE','NODE_OPTIONS'])delete env[key]
|
|
1252
|
+
const child=spawn(config.executable,[],{cwd:config.cwd,env,detached:true,windowsHide:true,shell:false,stdio:'ignore'})
|
|
1253
|
+
try {await new Promise((resolve,reject)=>{child.once('spawn',resolve);child.once('error',reject)})}catch(error){return {ok:false,code:'SERVICE_FAILED',error:'Desktop 启动失败: '+error.message}}
|
|
1254
|
+
child.on('error',()=>{});child.unref()
|
|
1255
|
+
desktopChildren.set(child.pid,child);watchDshChild(child,'desktop')
|
|
1256
|
+
return {ok:true,code:'STARTED',pid:child.pid}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1065
1259
|
function dshPortOccupied() {
|
|
1066
1260
|
return new Promise(resolve => {
|
|
1067
1261
|
const socket = net.connect({ host: UPSTREAM.hostname.replace(/^\[|\]$/g, ''), port: UPSTREAM.port || (UPSTREAM.protocol === 'https:' ? 443 : 80) })
|
|
@@ -1077,6 +1271,8 @@ async function dshProcessStatus() {
|
|
|
1077
1271
|
try { readDshLaunch() } catch (err) {
|
|
1078
1272
|
return { ...base, supported: false, code: 'LAUNCH_CONFIG_INVALID', message: '缺少有效 DSH 启动配置,请先从 DSH Web 启动 Remote 插件', detail: err.message }
|
|
1079
1273
|
}
|
|
1274
|
+
const launch = readDshLaunch()
|
|
1275
|
+
if (launch.kind === 'desktop') return dshDesktopStatus(launch)
|
|
1080
1276
|
if (dshManagedChild) {
|
|
1081
1277
|
return { ...base, running: true, mainPid: dshManagedChild.pid, owned: true, canRestart: true, activeState: 'active', subState: 'running' }
|
|
1082
1278
|
}
|
|
@@ -1091,6 +1287,7 @@ async function dshProcessStatus() {
|
|
|
1091
1287
|
|
|
1092
1288
|
async function executeDshProcessAction(action) {
|
|
1093
1289
|
const config = readDshLaunch()
|
|
1290
|
+
if (config.kind === 'desktop') return executeDshDesktopAction(action, config)
|
|
1094
1291
|
if (action === 'restart' && !dshManagedChild && await dshPortOccupied()) {
|
|
1095
1292
|
return { ok: false, code: 'EXTERNAL_PROCESS', error: '当前 DSH 并非本网关启动,不能安全重启;请在原启动器中停止后重试' }
|
|
1096
1293
|
}
|
|
@@ -1101,6 +1298,7 @@ async function executeDshProcessAction(action) {
|
|
|
1101
1298
|
const onExit = () => { clearTimeout(timer); resolve(true) }
|
|
1102
1299
|
child.once('exit', onExit)
|
|
1103
1300
|
})
|
|
1301
|
+
intentionalDshStops.add(child)
|
|
1104
1302
|
child.kill()
|
|
1105
1303
|
if (!await stopped) return { ok: false, code: 'PROCESS_STOP_TIMEOUT', error: 'DSH 进程未及时退出,未启动第二个实例' }
|
|
1106
1304
|
}
|
|
@@ -1117,6 +1315,7 @@ async function executeDshProcessAction(action) {
|
|
|
1117
1315
|
} finally { fs.closeSync(fd) }
|
|
1118
1316
|
dshManagedChild = child
|
|
1119
1317
|
dshManagedExit = null
|
|
1318
|
+
watchDshChild(child,'dsh')
|
|
1120
1319
|
child.once('exit', code => {
|
|
1121
1320
|
if (dshManagedChild === child) { dshManagedChild = null; dshManagedExit = { code, logFile } }
|
|
1122
1321
|
})
|
|
@@ -1180,7 +1379,7 @@ async function dshServiceStatus() {
|
|
|
1180
1379
|
}
|
|
1181
1380
|
|
|
1182
1381
|
async function executeDshServiceAction(action, initial) {
|
|
1183
|
-
if (initial?.manager
|
|
1382
|
+
if (['process','desktop'].includes(initial?.manager)) return executeDshProcessAction(action)
|
|
1184
1383
|
if (process.platform !== 'win32') {
|
|
1185
1384
|
return execFileResult(SYSTEMCTL, ['--user', '--no-block', action, DSH_SERVICE], 5000)
|
|
1186
1385
|
}
|
|
@@ -1231,6 +1430,75 @@ async function probeDshUpstream() {
|
|
|
1231
1430
|
|
|
1232
1431
|
let dshControlOperation = null
|
|
1233
1432
|
|
|
1433
|
+
// Owned children expose abnormal exit codes. Adopted Desktop instances require an exact Windows crash event.
|
|
1434
|
+
const recoveryAttempts=new Map(),recoveryTimers=new Map()
|
|
1435
|
+
function watchDshChild(child,component) {
|
|
1436
|
+
child.once('exit',(exitCode,signal)=>{
|
|
1437
|
+
desktopChildren.delete(child.pid)
|
|
1438
|
+
if(intentionalDshStops.has(child)||exitCode===0)return
|
|
1439
|
+
recordCrash(component,'exit',{exitCode,signal})
|
|
1440
|
+
let config;try{config=readDshLaunch()}catch{return}
|
|
1441
|
+
if(component==='desktop'&&config.desktop.root.pid!==child.pid)return
|
|
1442
|
+
scheduleDshRecovery(component,component==='desktop'?config.desktop.root.started:'owned',child.pid,'start')
|
|
1443
|
+
})
|
|
1444
|
+
}
|
|
1445
|
+
function scheduleDshRecovery(component,identity,pid,action) {
|
|
1446
|
+
if(!AUTO_RESTART||recoveryTimers.has(component))return
|
|
1447
|
+
const now=Date.now(),attempts=(recoveryAttempts.get(component)||[]).filter(t=>now-t<5*60_000)
|
|
1448
|
+
if(attempts.length>=5){recordCrash(component,'restart-limit',{attempt:5});return}
|
|
1449
|
+
attempts.push(now);recoveryAttempts.set(component,attempts)
|
|
1450
|
+
recordCrash(component,'restart',{attempt:attempts.length})
|
|
1451
|
+
const run=async()=>{
|
|
1452
|
+
if(dshControlOperation&&!dshControlOperation.done){recoveryTimers.set(component,setTimeout(run,1000));return}
|
|
1453
|
+
recoveryTimers.delete(component)
|
|
1454
|
+
try{
|
|
1455
|
+
const config=readDshLaunch()
|
|
1456
|
+
if(component==='desktop'&&(config.kind!=='desktop'||config.desktop.root.pid!==pid||config.desktop.root.started!==identity))return
|
|
1457
|
+
if(component==='dsh'&&dshManagedChild)return
|
|
1458
|
+
const operation={operationId:crypto.randomUUID(),action,startedAt:Date.now(),updatedAt:Date.now(),done:false,ok:false,stage:'queued',steps:[],automatic:true}
|
|
1459
|
+
dshControlOperation=operation
|
|
1460
|
+
await runDshControlOperation(operation)
|
|
1461
|
+
if(!operation.ok)recordCrash(component,'restart-failed',{code:operation.code})
|
|
1462
|
+
}catch(error){recordCrash(component,'restart-failed',crashErrorFacts(error))}
|
|
1463
|
+
}
|
|
1464
|
+
recoveryTimers.set(component,setTimeout(()=>{void run()},Math.min(30000,1000*2**(attempts.length-1))))
|
|
1465
|
+
}
|
|
1466
|
+
function matchingDesktopCrash(config,records) {
|
|
1467
|
+
const d=config.desktop
|
|
1468
|
+
return records.some(row=>{
|
|
1469
|
+
const pid=Number(row.pid),expected=pid===d.root.pid?d.root:pid===d.host.pid?d.host:null
|
|
1470
|
+
return expected&&path.resolve(String(row.executable||'')).toLowerCase()===path.resolve(config.executable).toLowerCase()&&
|
|
1471
|
+
Number.isFinite(Date.parse(row.at))&&Date.parse(row.at)>=Date.parse(expected.started)&&Date.now()-Date.parse(row.at)<120000
|
|
1472
|
+
})
|
|
1473
|
+
}
|
|
1474
|
+
async function desktopCrashEvidence(config) {
|
|
1475
|
+
// Application Error event 1000 contains executable, PID and timestamp; no message text is retained.
|
|
1476
|
+
const script=String.raw`[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); $rows=@(Get-WinEvent -FilterHashtable @{LogName='Application';Id=1000;StartTime=(Get-Date).AddMinutes(-2)} -MaxEvents 20 -ErrorAction SilentlyContinue | ForEach-Object { [xml]$xml=$_.ToXml(); $data=@{}; foreach($item in $xml.Event.EventData.Data){$data[$item.Name]=$item.'#text'}; @{pid=$data.ProcessId;executable=$data.AppPath;at=$_.TimeCreated.ToUniversalTime().ToString('o')} }); ConvertTo-Json -InputObject $rows -Compress`
|
|
1477
|
+
const result=await execFileResult('powershell.exe',['-NoLogo','-NoProfile','-NonInteractive','-Command',script],10000)
|
|
1478
|
+
if(!result.ok)return false
|
|
1479
|
+
try{return matchingDesktopCrash(config,JSON.parse(result.stdout||'[]'))}catch{return false}
|
|
1480
|
+
}
|
|
1481
|
+
let desktopWatchBusy=false
|
|
1482
|
+
const desktopCrashSeen=new Set()
|
|
1483
|
+
async function checkDesktopCrash() {
|
|
1484
|
+
if(!AUTO_RESTART||desktopWatchBusy||(dshControlOperation&&!dshControlOperation.done))return
|
|
1485
|
+
let config;try{config=readDshLaunch()}catch{return}
|
|
1486
|
+
if(config.kind!=='desktop')return
|
|
1487
|
+
const alive=pid=>{try{process.kill(pid,0);return true}catch(e){return e.code!=='ESRCH'}}
|
|
1488
|
+
if(alive(config.desktop.root.pid)&&alive(config.desktop.host.pid))return
|
|
1489
|
+
const identity=config.desktop.root.started+':'+config.desktop.root.pid+':'+config.desktop.host.pid
|
|
1490
|
+
if(desktopCrashSeen.has(identity)||recoveryTimers.has('desktop'))return
|
|
1491
|
+
desktopWatchBusy=true
|
|
1492
|
+
try{
|
|
1493
|
+
if(await desktopCrashEvidence(config)){
|
|
1494
|
+
desktopCrashSeen.add(identity);if(desktopCrashSeen.size>20)desktopCrashSeen.delete(desktopCrashSeen.values().next().value)
|
|
1495
|
+
recordCrash('desktop','desktop-crash',{code:'WINDOWS_APPLICATION_ERROR'})
|
|
1496
|
+
scheduleDshRecovery('desktop',config.desktop.root.started,config.desktop.root.pid,'restart')
|
|
1497
|
+
}
|
|
1498
|
+
}catch{}finally{desktopWatchBusy=false}
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
|
|
1234
1502
|
function dshOperationStep(operation, stage, message, extra = {}) {
|
|
1235
1503
|
const now = Date.now()
|
|
1236
1504
|
operation.stage = stage
|
|
@@ -1275,7 +1543,7 @@ async function runDshControlOperation(operation) {
|
|
|
1275
1543
|
const initial = await dshServiceStatus()
|
|
1276
1544
|
operation.initialStatus = initial
|
|
1277
1545
|
operation.observed = initial
|
|
1278
|
-
const manager = initial.manager === 'process' ? '本机进程' : process.platform === 'win32' ? 'Windows 服务' : 'systemd 用户服务'
|
|
1546
|
+
const manager = initial.manager === 'desktop' ? 'DSH Desktop' : initial.manager === 'process' ? '本机进程' : process.platform === 'win32' ? 'Windows 服务' : 'systemd 用户服务'
|
|
1279
1547
|
operation.service = initial.service
|
|
1280
1548
|
if (!initial.supported) {
|
|
1281
1549
|
failDshOperation(operation, initial.code || 'UNSUPPORTED', initial.message || '当前 DSH 服务不可控', initial.detail, initial)
|
|
@@ -1287,7 +1555,7 @@ async function runDshControlOperation(operation) {
|
|
|
1287
1555
|
: await executeDshServiceAction(operation.action, initial)
|
|
1288
1556
|
operation.command = { ok: command.ok, code: command.code, signal: command.signal }
|
|
1289
1557
|
if (!command.ok) {
|
|
1290
|
-
const failure = initial.manager
|
|
1558
|
+
const failure = ['process','desktop'].includes(initial.manager)
|
|
1291
1559
|
? { code: command.code, message: command.error, detail: '' }
|
|
1292
1560
|
: classifyDshServiceFailure(command)
|
|
1293
1561
|
failDshOperation(operation, failure.code, failure.message, failure.detail, await dshServiceStatus())
|
|
@@ -2812,6 +3080,7 @@ function serveFeedback(req, res, url) {
|
|
|
2812
3080
|
const contact = String(payload.contact || '').trim()
|
|
2813
3081
|
const appVersion = String(payload.appVersion || '').trim()
|
|
2814
3082
|
const includeDiagnostics = payload.includeDiagnostics === true
|
|
3083
|
+
const includeCrashLogs = payload.includeCrashLogs === true
|
|
2815
3084
|
if (!['bug', 'suggestion', 'other', 'poll'].includes(type)) {
|
|
2816
3085
|
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
2817
3086
|
res.end(JSON.stringify({ error: 'invalid type', expect: 'bug|suggestion|other|poll' }))
|
|
@@ -2864,6 +3133,7 @@ function serveFeedback(req, res, url) {
|
|
|
2864
3133
|
gatewayVersion: VERSION,
|
|
2865
3134
|
clientIp: maskIp(ip),
|
|
2866
3135
|
...(includeDiagnostics ? { diagnostics: compatibilityDiagnostics() } : {}),
|
|
3136
|
+
...(includeCrashLogs ? { serverInfo: JSON.stringify(crashUploadSummary()) } : {}),
|
|
2867
3137
|
...(pollVote || {})
|
|
2868
3138
|
}),
|
|
2869
3139
|
signal: AbortSignal.timeout(8000)
|
|
@@ -3154,6 +3424,7 @@ async function serveAdminApi(req, res, url) {
|
|
|
3154
3424
|
res.end(JSON.stringify({ ok: true, bye: true }))
|
|
3155
3425
|
// 给响应留出发送时间, 然后退出; 由插件/系统按需再拉起
|
|
3156
3426
|
setTimeout(() => {
|
|
3427
|
+
recordCrash('gateway','manual-stop')
|
|
3157
3428
|
console.log('[shutdown] 收到管理端停止指令, 网关退出')
|
|
3158
3429
|
process.exit(0)
|
|
3159
3430
|
}, 150)
|
|
@@ -3305,6 +3576,7 @@ async function fsResolve(input) {
|
|
|
3305
3576
|
else if (/^~[\\/]/.test(raw)) abs = path.resolve(FS_DEFAULT_ROOT, raw.slice(2))
|
|
3306
3577
|
else if (path.isAbsolute(raw)) abs = path.resolve(raw)
|
|
3307
3578
|
else abs = path.resolve(FS_ROOTS[0], raw) // 相对路径按默认根解析
|
|
3579
|
+
if (fsProtectedPath(abs)) return { error: 'forbidden' }
|
|
3308
3580
|
if (FS_ROOTS.some(root => fsInsideRoot(abs, root))) return { abs }
|
|
3309
3581
|
if (FS_WINDOWS_DEFAULT) return { error: 'forbidden' }
|
|
3310
3582
|
let workspaces = await loadFsWorkspaceRoots(false)
|
|
@@ -3324,6 +3596,7 @@ function fsRealChecked(abs) {
|
|
|
3324
3596
|
return { error: err.code === 'ENOENT' ? 'not-found' : 'permission-denied' }
|
|
3325
3597
|
}
|
|
3326
3598
|
if (!fsInsideReal(real)) return { error: 'forbidden' }
|
|
3599
|
+
if (fsProtectedPath(real)) return { error: 'forbidden' }
|
|
3327
3600
|
return { abs: real }
|
|
3328
3601
|
}
|
|
3329
3602
|
|
|
@@ -3390,11 +3663,12 @@ async function fsList(req, res, url) {
|
|
|
3390
3663
|
const entries = []
|
|
3391
3664
|
for (const d of dirents) {
|
|
3392
3665
|
const full = path.join(checked.abs, d.name)
|
|
3666
|
+
if (fsProtectedPath(full)) continue
|
|
3393
3667
|
try {
|
|
3394
3668
|
// 符号链接指向允许根之外时直接不展示, 点进去/下载也必然被 realpath 复核拒绝
|
|
3395
3669
|
if (d.isSymbolicLink()) {
|
|
3396
3670
|
const real = fs.realpathSync(full)
|
|
3397
|
-
if (!fsInsideReal(real)) continue
|
|
3671
|
+
if (!fsInsideReal(real) || fsProtectedPath(real)) continue
|
|
3398
3672
|
}
|
|
3399
3673
|
const info = fs.statSync(full)
|
|
3400
3674
|
if (!info.isFile() && !info.isDirectory()) continue
|
|
@@ -3598,6 +3872,10 @@ function fsOpenUploadTarget(res, url, dirLex, dirReal, name) {
|
|
|
3598
3872
|
}
|
|
3599
3873
|
const target = path.join(dirReal, name)
|
|
3600
3874
|
const overwrite = url.searchParams.get('overwrite') === '1' || url.searchParams.get('overwrite') === 'true'
|
|
3875
|
+
if (fsProtectedPath(target)) {
|
|
3876
|
+
fsJson(res, 403, { error: 'forbidden' })
|
|
3877
|
+
return null
|
|
3878
|
+
}
|
|
3601
3879
|
let exists = false
|
|
3602
3880
|
try {
|
|
3603
3881
|
const st = fs.lstatSync(target)
|
|
@@ -3821,7 +4099,9 @@ async function fsUploadProbe(req, res, url) {
|
|
|
3821
4099
|
if (uploadLength !== null && (!Number.isSafeInteger(uploadLength) || uploadLength < 0)) {
|
|
3822
4100
|
return fsJson(res, 400, { error: 'bad-length', detail: 'size 必须是非负整数' })
|
|
3823
4101
|
}
|
|
3824
|
-
const
|
|
4102
|
+
const probeTarget = path.join(checked.abs, name)
|
|
4103
|
+
if (fsProtectedPath(probeTarget)) return fsJson(res, 403, { error: 'forbidden' })
|
|
4104
|
+
const target = fsTargetState(probeTarget)
|
|
3825
4105
|
let targetSize = 0
|
|
3826
4106
|
if (target.exists) {
|
|
3827
4107
|
try { targetSize = fs.statSync(path.join(checked.abs, name)).size } catch {}
|
|
@@ -3860,6 +4140,7 @@ async function fsMkdir(req, res, url) {
|
|
|
3860
4140
|
const name = url.searchParams.get('name') || ''
|
|
3861
4141
|
if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name', detail: '目录名不能为空且不能包含路径分隔符' })
|
|
3862
4142
|
const target = path.join(checked.abs, name)
|
|
4143
|
+
if (fsProtectedPath(target)) return fsJson(res, 403, { error: 'forbidden' })
|
|
3863
4144
|
try {
|
|
3864
4145
|
fs.mkdirSync(target)
|
|
3865
4146
|
} catch (err) {
|
|
@@ -3903,6 +4184,7 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
|
3903
4184
|
const target = path.join(dirReal, name)
|
|
3904
4185
|
|
|
3905
4186
|
// 已有分片尺寸对齐: 回卷重写允许, 越界/缺洞拒绝
|
|
4187
|
+
if (fsProtectedPath(target)) return fsJson(res, 403, { error: 'forbidden' })
|
|
3906
4188
|
let existing = 0
|
|
3907
4189
|
try {
|
|
3908
4190
|
const st = fs.statSync(part)
|
|
@@ -4743,6 +5025,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
4743
5025
|
if (url.pathname === '/fs' || url.pathname.startsWith('/fs/')) return await serveFs(req, res, url)
|
|
4744
5026
|
if (url.pathname === '/workbench' || url.pathname.startsWith('/workbench/')) return serveWorkbench(req, res, url)
|
|
4745
5027
|
if (url.pathname === '/diagnostics') return serveDiagnostics(req, res, url)
|
|
5028
|
+
if (url.pathname === '/crash-logs') {
|
|
5029
|
+
cors(res)
|
|
5030
|
+
if(req.method==='OPTIONS'){res.writeHead(204);res.end();return}
|
|
5031
|
+
if(req.method!=='GET'){res.setHeader('allow','GET, OPTIONS');return crashReply(res,405,{error:'read-only'})}
|
|
5032
|
+
if(!authorized(req,url))return crashReply(res,401,{error:'unauthorized'})
|
|
5033
|
+
touchDevice(req)
|
|
5034
|
+
return crashReply(res,200,{schema:1,recovery:runtimeState.recovery,records:crashRecords(),upload:crashUploadSummary()})
|
|
5035
|
+
}
|
|
4746
5036
|
if (url.pathname === '/feedback') return serveFeedback(req, res, url)
|
|
4747
5037
|
if (url.pathname === '/handoff') return serveHandoff(req, res, url)
|
|
4748
5038
|
if (url.pathname.startsWith('/admin/api')) return await serveAdminApi(req, res, url)
|
|
@@ -4773,19 +5063,20 @@ server.headersTimeout = HTTP_HEADERS_TIMEOUT_MS
|
|
|
4773
5063
|
server.keepAliveTimeout = HTTP_KEEPALIVE_TIMEOUT_MS
|
|
4774
5064
|
server.timeout = 0
|
|
4775
5065
|
|
|
4776
|
-
//
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
5066
|
+
// Fatal state is recorded synchronously, then a supervised worker exits for a clean restart.
|
|
5067
|
+
let fatalExiting=false
|
|
5068
|
+
function fatalGateway(error,kind) {
|
|
5069
|
+
if(fatalExiting)return
|
|
5070
|
+
fatalExiting=true
|
|
5071
|
+
runtimeState[kind==='uncaughtException'?'uncaughtExceptions':'unhandledRejections']++
|
|
5072
|
+
runtimeState.lastErrorAt=Date.now();runtimeState.lastError='fatal '+kind
|
|
5073
|
+
recordCrash('gateway','fatal',crashErrorFacts(error))
|
|
5074
|
+
try{console.error('[recovery] '+kind+'; see local crash records')}catch{}
|
|
5075
|
+
// Exit on the next turn; never continue accepting work in an undefined state.
|
|
5076
|
+
setImmediate(()=>process.exit(error?.code==='EADDRINUSE'?78:1))
|
|
5077
|
+
}
|
|
5078
|
+
process.on('uncaughtException',error=>fatalGateway(error,'uncaughtException'))
|
|
5079
|
+
process.on('unhandledRejection',error=>fatalGateway(error,'unhandledRejection'))
|
|
4789
5080
|
|
|
4790
5081
|
function wsPingFrame(masked) {
|
|
4791
5082
|
if (!masked) return Buffer.from([0x89, 0x00])
|
|
@@ -5133,6 +5424,8 @@ server.on('clientError', (err, socket) => {
|
|
|
5133
5424
|
})
|
|
5134
5425
|
|
|
5135
5426
|
server.listen(PORT, HOST, () => {
|
|
5427
|
+
recordCrash('gateway','startup')
|
|
5428
|
+
const crashWatch=setInterval(()=>{void checkDesktopCrash()},5000);crashWatch.unref?.()
|
|
5136
5429
|
const clientToken = deviceKeyState.enabled ? deviceKeyState.keys[0]?.token : TOKEN
|
|
5137
5430
|
console.log('DSH Remote 网关 v' + VERSION + ' 已启动')
|
|
5138
5431
|
console.log(' 本机: http://127.0.0.1:' + PORT + '/?token=' + (clientToken || '请在管理页创建设备密钥'))
|
|
@@ -5153,3 +5446,5 @@ server.listen(PORT, HOST, () => {
|
|
|
5153
5446
|
scanStatsOnce(2000)
|
|
5154
5447
|
setInterval(() => scanStatsOnce(0), 5 * 60 * 1000)
|
|
5155
5448
|
})
|
|
5449
|
+
|
|
5450
|
+
} // gateway worker
|
package/index.mjs
CHANGED
|
@@ -234,6 +234,7 @@ const GATEWAY_ENV_KEYS = [
|
|
|
234
234
|
'TOKEN', 'TOKEN_FILE', 'DSH_REMOTE_TOKEN', 'DSH_REMOTE_DEVICE_KEYS', 'DSH_REMOTE_FS_ROOT', 'DSH_REMOTE_FS_WORKSPACE_CACHE_MS', 'DSH_REMOTE_FS_MAX_UPLOAD',
|
|
235
235
|
'DSH_REMOTE_DSH_COOKIE_FILE',
|
|
236
236
|
'DSH_REMOTE_NOTES', 'DSH_REMOTE_WORKBENCH', 'DSH_REMOTE_ADVERTISE_HOSTS', 'DSH_REMOTE_DSH_SERVICE', 'DSH_REMOTE_SYSTEMCTL', 'DSH_REMOTE_DSH_CONTROL_MODE',
|
|
237
|
+
'DSH_REMOTE_AUTO_RESTART', 'DSH_REMOTE_CRASH_LOG_FILE',
|
|
237
238
|
'DSH_REMOTE_DSH_CONTROL_TIMEOUT_MS', 'DSH_REMOTE_DSH_CONTROL_POLL_MS', 'DSH_REMOTE_FEEDBACK_URL',
|
|
238
239
|
'UPDATE_CHECK_URL', 'UPDATE_INTERVAL_MS', 'UPDATE_PROXY', 'DSH_HEALTH_PATH',
|
|
239
240
|
'GATEWAY_WS_IDLE_MS', 'GATEWAY_WS_PING_MS', 'GATEWAY_WS_PONG_TIMEOUT_MS',
|
|
@@ -389,7 +390,7 @@ async function startGateway() {
|
|
|
389
390
|
'--user', '--unit=dsh-remote-gateway', '--service-type=exec',
|
|
390
391
|
...gatewaySystemdEnvArgs(),
|
|
391
392
|
'--setenv=PORT=' + port, '--setenv=HOST=' + host, '--setenv=DSH_UPSTREAM=' + upstream,
|
|
392
|
-
'--', process.execPath, script,
|
|
393
|
+
'--', process.execPath, script, '--supervise',
|
|
393
394
|
])) === 0
|
|
394
395
|
} catch {}
|
|
395
396
|
if (sysd) {
|
|
@@ -405,11 +406,14 @@ async function startGateway() {
|
|
|
405
406
|
try {
|
|
406
407
|
logFd = openSync(`${homedir()}/.dsh-remote/plugin-gateway.log`, 'a')
|
|
407
408
|
} catch {}
|
|
408
|
-
const
|
|
409
|
+
const gatewayEnv = { ...process.env, PORT: port, HOST: host, DSH_UPSTREAM: upstream }
|
|
410
|
+
delete gatewayEnv.NODE_CHANNEL_FD
|
|
411
|
+
delete gatewayEnv.NODE_CHANNEL_SERIALIZATION_MODE
|
|
412
|
+
const child = spawn(process.execPath, [script, '--supervise'], {
|
|
409
413
|
cwd: dirname(script),
|
|
410
414
|
detached: true,
|
|
411
415
|
stdio: ['ignore', logFd ?? 'ignore', logFd ?? 'ignore'],
|
|
412
|
-
env:
|
|
416
|
+
env: gatewayEnv,
|
|
413
417
|
})
|
|
414
418
|
child.unref()
|
|
415
419
|
writeGatewayPid(child.pid)
|
|
@@ -986,18 +990,36 @@ async function serveStatic(req, res, ctx) {
|
|
|
986
990
|
}
|
|
987
991
|
|
|
988
992
|
const pluginCenters = new WeakMap()
|
|
993
|
+
export function desktopLaunchConfig(ctx) {
|
|
994
|
+
if (process.platform !== 'win32' || !process.versions.electron) return null
|
|
995
|
+
const profile = detectProfile(ctx)
|
|
996
|
+
const hostScript = process.argv[1] || ''
|
|
997
|
+
if (profile?.name !== 'desktop' || !/[\\/]@deepseek-ai[\\/]dsh-desktop-host[\\/]lib[\\/]index\.js$/.test(hostScript)) return null
|
|
998
|
+
const pid = process.pid, parent = process.ppid
|
|
999
|
+
try {
|
|
1000
|
+
const script = '[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); @(' + [pid,parent].map(id =>
|
|
1001
|
+
"Get-CimInstance Win32_Process -Filter 'ProcessId=" + id + "' | ForEach-Object { @{pid=$_.ProcessId;parent=$_.ParentProcessId;executable=$_.ExecutablePath;command=$_.CommandLine;started=$_.CreationDate.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')} }").join('; ') + ') | ConvertTo-Json -Compress'
|
|
1002
|
+
const rows = JSON.parse(execFileSync('powershell.exe', ['-NoLogo','-NoProfile','-NonInteractive','-Command',script], { encoding:'utf8', timeout:8000, windowsHide:true }))
|
|
1003
|
+
const host = rows.find(row => row.pid === pid), root = rows.find(row => row.pid === parent)
|
|
1004
|
+
if (!host || !root || host.parent !== root.pid || resolve(root.executable) !== resolve(process.execPath) || resolve(host.executable) !== resolve(process.execPath) || /--(?:type|expose-internals)\b/.test(root.command)) return null
|
|
1005
|
+
return { version:1, kind:'desktop', executable:process.execPath, args:[], cwd:dirname(process.execPath), profile:'desktop', env:{DSH_HOME:profile.home},
|
|
1006
|
+
upstream:upstreamUrlForListener(ctx.webServer), desktop:{root:{pid:root.pid,started:root.started},host:{pid:host.pid,started:host.started},hostScript,profilePath:profile.dir} }
|
|
1007
|
+
} catch (error) { logGateway('Desktop 启动信息核验失败: ' + error.message); return null }
|
|
1008
|
+
}
|
|
1009
|
+
|
|
989
1010
|
export function saveDshLaunchConfig(ctx) {
|
|
990
|
-
|
|
991
|
-
|
|
1011
|
+
if (process.platform !== 'win32') return false
|
|
1012
|
+
const desktop = desktopLaunchConfig(ctx)
|
|
1013
|
+
if (process.versions.electron && !desktop) return false
|
|
992
1014
|
const profile = detectProfile(ctx)
|
|
993
|
-
if (!profile?.cli || profile.name === 'desktop' || resolve(process.argv[1] || '') !== resolve(profile.cli)) return false
|
|
1015
|
+
if (!desktop && (!profile?.cli || profile.name === 'desktop' || resolve(process.argv[1] || '') !== resolve(profile.cli))) return false
|
|
994
1016
|
const env = { DSH_HOME: profile.home }
|
|
995
1017
|
for (const key of ['HOME', 'USERPROFILE', 'PATH', 'NODE_OPTIONS']) {
|
|
996
1018
|
if (process.env[key] !== undefined) env[key] = process.env[key]
|
|
997
1019
|
}
|
|
998
|
-
const args = [profile.cli, ...process.argv.slice(2)]
|
|
999
|
-
if (!args.includes('--no-open')) args.push('--no-open')
|
|
1000
|
-
const config = { version: 1, executable: process.execPath, args, cwd: process.cwd(),
|
|
1020
|
+
const args = desktop ? [] : [profile.cli, ...process.argv.slice(2)]
|
|
1021
|
+
if (!desktop && !args.includes('--no-open')) args.push('--no-open')
|
|
1022
|
+
const config = desktop || { version: 1, executable: process.execPath, args, cwd: process.cwd(),
|
|
1001
1023
|
env, profile: profile.name, upstream: upstreamUrlForListener({ host: ctx.webServer.host, port: ctx.webServer.port }) }
|
|
1002
1024
|
const file = process.env.DSH_REMOTE_DSH_LAUNCH_FILE || `${homedir()}/.dsh-remote/dsh-launch.json`
|
|
1003
1025
|
const tmp = `${file}.${process.pid}.tmp`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-plugin",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|