dsh-remote-plugin 0.4.8 → 0.4.9
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/README.md +2 -0
- package/apk/dsh-remote.apk +0 -0
- package/gateway.cjs +167 -0
- package/package.json +1 -1
- package/public/app.js +86 -18
- package/public/update.json +3 -3
- package/public/version.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# dsh-remote-plugin
|
|
2
2
|
|
|
3
|
+
[](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
|
|
4
|
+
|
|
3
5
|
DSH Remote 的 DSH bundle 插件:在 DSH 左侧原生边栏注册入口,点击从右侧滑出管理抽屉;插件**内置网关程序并随 DSH 自动启停**(独立 systemd 单元),抽屉直显令牌、主机 IP 与设备监控,配合 [dsh-Remote](https://github.com/Blank-not-black/dsh-Remote) 的 Android App 实现手机远程操控与文件互传(`/fs/list`、`/fs/file`、`/fs/upload`)。
|
|
4
6
|
|
|
5
7
|
## 安装
|
package/apk/dsh-remote.apk
CHANGED
|
Binary file
|
package/gateway.cjs
CHANGED
|
@@ -873,6 +873,168 @@ function fsUploadMultipart(req, res, url, dirLex, dirReal, boundary) {
|
|
|
873
873
|
})
|
|
874
874
|
}
|
|
875
875
|
|
|
876
|
+
/* ---------- /fs/upload 断点续传 ----------
|
|
877
|
+
* 分块模式: POST /fs/upload?path=..&name=..&session=<uuid>&offset=N[&finish=1][&overwrite=1]
|
|
878
|
+
* - 每一块是 raw body, 服务端写到 .<name>.dsh-remote-part-<session> 的 offset 处
|
|
879
|
+
* - offset=0 重开; offset<已有大小 = 回卷重写; offset>已有大小 = 409 offset-mismatch
|
|
880
|
+
* - finish=1 时原子 rename 到目标名; 否则返回 {partial:true,size,offset}
|
|
881
|
+
* 查询进度: GET /fs/upload-probe?path=..&name=..&session=<uuid>
|
|
882
|
+
*/
|
|
883
|
+
function fsPartPath(dirReal, name, session) {
|
|
884
|
+
const s = String(session || 'default').replace(/[^A-Za-z0-9._-]/g, '').slice(0, 64) || 'default'
|
|
885
|
+
return path.join(dirReal, `.${name}.dsh-remote-part-${s}`)
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function fsTargetState(target) {
|
|
889
|
+
try {
|
|
890
|
+
const st = fs.lstatSync(target)
|
|
891
|
+
if (st.isSymbolicLink()) return { status: 403, error: 'symlink-forbidden', detail: '拒绝覆盖符号链接' }
|
|
892
|
+
return { exists: true }
|
|
893
|
+
} catch (err) {
|
|
894
|
+
if (err.code === 'ENOENT') return { exists: false }
|
|
895
|
+
return { status: 403, error: 'permission-denied', detail: err.message }
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function fsUploadProbe(req, res, url) {
|
|
900
|
+
if (req.method !== 'GET') {
|
|
901
|
+
res.writeHead(405, { allow: 'GET' })
|
|
902
|
+
res.end()
|
|
903
|
+
return
|
|
904
|
+
}
|
|
905
|
+
if (!fsAuthorized(req, url, res)) return
|
|
906
|
+
touchDevice(req)
|
|
907
|
+
const resolved = fsResolve(url.searchParams.get('path') ?? '')
|
|
908
|
+
if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
|
|
909
|
+
const checked = fsRealChecked(resolved.abs)
|
|
910
|
+
if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
|
|
911
|
+
const name = url.searchParams.get('name') || ''
|
|
912
|
+
if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name' })
|
|
913
|
+
const part = fsPartPath(checked.abs, name, url.searchParams.get('session') || 'default')
|
|
914
|
+
let partialSize = 0, partExists = false
|
|
915
|
+
try {
|
|
916
|
+
const st = fs.statSync(part)
|
|
917
|
+
if (st.isFile()) { partialSize = st.size; partExists = true }
|
|
918
|
+
} catch {}
|
|
919
|
+
const target = fsTargetState(path.join(checked.abs, name))
|
|
920
|
+
let targetSize = 0
|
|
921
|
+
if (target.exists) {
|
|
922
|
+
try { targetSize = fs.statSync(path.join(checked.abs, name)).size } catch {}
|
|
923
|
+
}
|
|
924
|
+
fsJson(res, 200, {
|
|
925
|
+
ok: true,
|
|
926
|
+
name,
|
|
927
|
+
partialSize,
|
|
928
|
+
partExists,
|
|
929
|
+
targetExists: !!target.exists,
|
|
930
|
+
targetSize
|
|
931
|
+
})
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
935
|
+
const name = url.searchParams.get('name') || ''
|
|
936
|
+
if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name', detail: '文件名不能为空且不能包含路径分隔符' })
|
|
937
|
+
const session = url.searchParams.get('session') || ''
|
|
938
|
+
if (!session) return fsJson(res, 400, { error: 'missing-session', detail: '断点续传需要 session 参数' })
|
|
939
|
+
const offsetRaw = url.searchParams.get('offset')
|
|
940
|
+
const offset = Number(offsetRaw)
|
|
941
|
+
if (!Number.isSafeInteger(offset) || offset < 0) {
|
|
942
|
+
return fsJson(res, 400, { error: 'bad-offset', detail: 'offset 必须是非负整数' })
|
|
943
|
+
}
|
|
944
|
+
const finish = url.searchParams.get('finish') === '1' || url.searchParams.get('complete') === '1'
|
|
945
|
+
const overwrite = url.searchParams.get('overwrite') === '1' || url.searchParams.get('overwrite') === 'true'
|
|
946
|
+
const part = fsPartPath(dirReal, name, session)
|
|
947
|
+
const target = path.join(dirReal, name)
|
|
948
|
+
|
|
949
|
+
// 已有分片尺寸对齐: 回卷重写允许, 越界/缺洞拒绝
|
|
950
|
+
let existing = 0
|
|
951
|
+
try {
|
|
952
|
+
const st = fs.statSync(part)
|
|
953
|
+
if (!st.isFile()) return fsJson(res, 409, { error: 'part-conflict', detail: '分片路径被占用' })
|
|
954
|
+
existing = st.size
|
|
955
|
+
} catch (err) {
|
|
956
|
+
if (err.code !== 'ENOENT') return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
|
|
957
|
+
}
|
|
958
|
+
if (offset === 0) {
|
|
959
|
+
try { if (existing > 0) fs.truncateSync(part, 0) } catch (err) {
|
|
960
|
+
return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
|
|
961
|
+
}
|
|
962
|
+
} else {
|
|
963
|
+
if (offset > existing) return fsJson(res, 409, { error: 'offset-mismatch', partialSize: existing, detail: 'offset 超过已有分片大小, 请先 probe' })
|
|
964
|
+
if (offset < existing) {
|
|
965
|
+
try { fs.truncateSync(part, offset) } catch (err) {
|
|
966
|
+
return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
if (finish) {
|
|
972
|
+
const st = fsTargetState(target)
|
|
973
|
+
if (st.status) return fsJson(res, st.status, { error: st.error, detail: st.detail })
|
|
974
|
+
if (st.exists && !overwrite) {
|
|
975
|
+
return fsJson(res, 409, { error: 'conflict', detail: '文件已存在, overwrite=1 可覆盖' })
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
let stream
|
|
980
|
+
try {
|
|
981
|
+
stream = fs.createWriteStream(part, { flags: offset === 0 ? 'w' : 'r+', start: offset, mode: 0o600 })
|
|
982
|
+
} catch (err) {
|
|
983
|
+
return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
let bytes = 0
|
|
987
|
+
let finished = false
|
|
988
|
+
const abort = (status, msg, extra = {}) => {
|
|
989
|
+
if (finished) return
|
|
990
|
+
finished = true
|
|
991
|
+
try { stream.destroy() } catch {}
|
|
992
|
+
// 网络中断时保留分片, 客户端 probe 后续传; 只有超限/写失败才删
|
|
993
|
+
if (status === 413 || status === 500) { try { fs.unlinkSync(part) } catch {} }
|
|
994
|
+
if (!res.headersSent) fsJson(res, status, { error: msg, ...extra })
|
|
995
|
+
else try { res.destroy() } catch {}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
stream.on('error', (err) => {
|
|
999
|
+
abort(500, err.code === 'ENOENT' ? 'part-missing' : 'write-failed', { detail: err.message })
|
|
1000
|
+
})
|
|
1001
|
+
req.on('aborted', () => abort(400, 'client-aborted', { partialSize: offset + bytes }))
|
|
1002
|
+
req.on('error', () => abort(400, 'client-aborted', { partialSize: offset + bytes }))
|
|
1003
|
+
req.on('data', (chunk) => {
|
|
1004
|
+
if (finished) return
|
|
1005
|
+
bytes += chunk.length
|
|
1006
|
+
if (offset + bytes > FS_MAX_UPLOAD) {
|
|
1007
|
+
abort(413, 'too-large', { limit: FS_MAX_UPLOAD })
|
|
1008
|
+
return
|
|
1009
|
+
}
|
|
1010
|
+
stream.write(chunk)
|
|
1011
|
+
})
|
|
1012
|
+
req.on('end', () => {
|
|
1013
|
+
if (finished) return
|
|
1014
|
+
finished = true
|
|
1015
|
+
stream.end(() => {
|
|
1016
|
+
const total = offset + bytes
|
|
1017
|
+
try {
|
|
1018
|
+
const st = fs.statSync(part)
|
|
1019
|
+
if (!st.isFile() || st.size !== total) throw new Error('part-size-mismatch')
|
|
1020
|
+
if (!finish) {
|
|
1021
|
+
fsJson(res, 200, { ok: true, partial: true, name, size: total, offset: total, session })
|
|
1022
|
+
return
|
|
1023
|
+
}
|
|
1024
|
+
const ts = fsTargetState(target)
|
|
1025
|
+
if (ts.status) return fsJson(res, ts.status, { error: ts.error, detail: ts.detail })
|
|
1026
|
+
if (ts.exists && !overwrite) return fsJson(res, 409, { error: 'conflict', detail: '文件已存在, overwrite=1 可覆盖' })
|
|
1027
|
+
if (ts.exists) fs.rmSync(target, { force: true })
|
|
1028
|
+
fs.renameSync(part, target)
|
|
1029
|
+
fsJson(res, 201, { ok: true, name, path: path.join(dirLex, name), size: total, resumed: offset > 0, session })
|
|
1030
|
+
} catch (err) {
|
|
1031
|
+
if (!res.headersSent) fsJson(res, 403, { error: 'write-failed', detail: err.message })
|
|
1032
|
+
else try { res.destroy() } catch {}
|
|
1033
|
+
}
|
|
1034
|
+
})
|
|
1035
|
+
})
|
|
1036
|
+
}
|
|
1037
|
+
|
|
876
1038
|
function serveFs(req, res, url) {
|
|
877
1039
|
const sub = url.pathname.slice('/fs'.length)
|
|
878
1040
|
|
|
@@ -886,6 +1048,7 @@ function serveFs(req, res, url) {
|
|
|
886
1048
|
|
|
887
1049
|
if (sub === '/list') return fsList(req, res, url)
|
|
888
1050
|
if (sub === '/file') return fsFile(req, res, url)
|
|
1051
|
+
if (sub === '/upload-probe') return fsUploadProbe(req, res, url)
|
|
889
1052
|
|
|
890
1053
|
if (sub === '/upload') {
|
|
891
1054
|
if (req.method !== 'POST') {
|
|
@@ -909,6 +1072,10 @@ function serveFs(req, res, url) {
|
|
|
909
1072
|
if (Number.isFinite(contentLength) && contentLength > FS_MAX_UPLOAD) {
|
|
910
1073
|
return fsJson(res, 413, { error: 'too-large', limit: FS_MAX_UPLOAD })
|
|
911
1074
|
}
|
|
1075
|
+
// 带 session/offset 进入分块续传模式; 不带则保持 raw/multipart 一次性上传
|
|
1076
|
+
if (url.searchParams.has('session') || url.searchParams.has('offset')) {
|
|
1077
|
+
return fsUploadResumable(req, res, url, resolved.abs, checked.abs)
|
|
1078
|
+
}
|
|
912
1079
|
const contentType = String(req.headers['content-type'] || '')
|
|
913
1080
|
if (contentType.startsWith('multipart/form-data')) {
|
|
914
1081
|
const m = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-plugin",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
4
4
|
"description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
package/public/app.js
CHANGED
|
@@ -50,7 +50,7 @@ const state = {
|
|
|
50
50
|
history: emptyHistory(),
|
|
51
51
|
errCount: 0,
|
|
52
52
|
refreshTimer: null,
|
|
53
|
-
fs: { path: null, initial: null, loaded: false }
|
|
53
|
+
fs: { path: null, initial: null, loaded: false, upload: null }
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
const $ = (id) => document.getElementById(id)
|
|
@@ -1229,35 +1229,103 @@ function uploadFsFile(file) {
|
|
|
1229
1229
|
if (!state.token) { toast('请先到「设置」页设置令牌', 'err'); showView('view-settings'); return }
|
|
1230
1230
|
if (file.size > 2 * 1024 * 1024 * 1024) { toast('单文件超过 2GB 上限', 'err'); return }
|
|
1231
1231
|
|
|
1232
|
-
const
|
|
1233
|
-
|
|
1234
|
-
|
|
1232
|
+
const FS_CHUNK_SIZE = 4 * 1024 * 1024 // 4MB/块, 断线后重选同一文件自动续传
|
|
1233
|
+
|
|
1234
|
+
const uploadChunk = (params, blob, up) => new Promise((resolve, reject) => {
|
|
1235
1235
|
const xhr = new XMLHttpRequest()
|
|
1236
1236
|
xhr.open('POST', fsApiUrl('/upload', params))
|
|
1237
1237
|
xhr.setRequestHeader('authorization', 'Bearer ' + state.token)
|
|
1238
1238
|
xhr.setRequestHeader('x-dsh-remote-client', CAP?.isNativePlatform?.() ? 'app' : 'web')
|
|
1239
|
-
showFsProgress(0, 0, file.size)
|
|
1240
1239
|
xhr.upload.onprogress = (e) => {
|
|
1241
|
-
if (e.lengthComputable)
|
|
1240
|
+
if (e.lengthComputable) {
|
|
1241
|
+
const loaded = up.offset + Math.min(e.loaded, e.total)
|
|
1242
|
+
showFsProgress(Math.round(loaded / Math.max(1, up.size) * 100), loaded, up.size)
|
|
1243
|
+
}
|
|
1242
1244
|
}
|
|
1243
1245
|
xhr.onload = () => {
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
+
let json = {}
|
|
1247
|
+
try { json = JSON.parse(xhr.responseText || '{}') } catch {}
|
|
1248
|
+
resolve({ status: xhr.status, json })
|
|
1249
|
+
}
|
|
1250
|
+
xhr.onerror = () => reject(new Error('网络错误'))
|
|
1251
|
+
xhr.upload.onerror = () => reject(new Error('网络中断'))
|
|
1252
|
+
xhr.onabort = () => reject(new Error('已取消'))
|
|
1253
|
+
xhr.send(blob)
|
|
1254
|
+
})
|
|
1255
|
+
|
|
1256
|
+
const probe = async (up) => {
|
|
1257
|
+
const res = await fetch(fsApiUrl('/upload-probe', { path: up.path, name: up.name, session: up.session }), { headers: fsHeaders() })
|
|
1258
|
+
if (res.status === 401) { fsAuthError(401); return null }
|
|
1259
|
+
const json = await res.json().catch(() => ({}))
|
|
1260
|
+
if (json.ok) up.offset = json.partialSize || 0
|
|
1261
|
+
return json
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
;(async () => {
|
|
1265
|
+
let up = state.fs.upload
|
|
1266
|
+
if (!up || up.path !== state.fs.path || up.name !== file.name || up.size !== file.size) {
|
|
1267
|
+
up = { session: uuid(), path: state.fs.path, name: file.name, size: file.size, offset: 0 }
|
|
1268
|
+
state.fs.upload = up
|
|
1269
|
+
}
|
|
1270
|
+
let overwrite = false
|
|
1271
|
+
let wasResumed = false
|
|
1272
|
+
try {
|
|
1273
|
+
const info = await probe(up)
|
|
1274
|
+
if (info === null) return
|
|
1275
|
+
if (info.partialSize > 0) wasResumed = true
|
|
1276
|
+
if (info.targetExists && info.targetSize === up.size && !overwrite) {
|
|
1277
|
+
if (!confirm('文件已存在(大小相同),覆盖它?')) { state.fs.upload = null; return }
|
|
1278
|
+
overwrite = true
|
|
1279
|
+
}
|
|
1280
|
+
showFsProgress(Math.round(up.offset / Math.max(1, up.size) * 100), up.offset, up.size)
|
|
1281
|
+
|
|
1282
|
+
while (up.offset < up.size) {
|
|
1283
|
+
const end = Math.min(up.offset + FS_CHUNK_SIZE, up.size)
|
|
1284
|
+
const params = { path: up.path, name: up.name, session: up.session, offset: String(up.offset) }
|
|
1285
|
+
if (end >= up.size) params.finish = '1'
|
|
1286
|
+
if (overwrite) params.overwrite = '1'
|
|
1287
|
+
const r = await uploadChunk(params, file.slice(up.offset, end), up)
|
|
1288
|
+
if (r.status === 401) { fsAuthError(401); return }
|
|
1289
|
+
if (r.status === 200 && r.json.offset != null) { up.offset = r.json.offset; continue }
|
|
1290
|
+
if (r.status === 201) {
|
|
1291
|
+
hideFsProgress()
|
|
1292
|
+
state.fs.upload = null
|
|
1293
|
+
toast(`已上传 ${file.name}${wasResumed ? '(断点续传完成)' : ''}`, 'ok')
|
|
1294
|
+
loadFs()
|
|
1295
|
+
return
|
|
1296
|
+
}
|
|
1297
|
+
if (r.status === 409 && r.json.error === 'conflict') {
|
|
1298
|
+
if (!confirm('文件已存在,覆盖它?')) { state.fs.upload = null; return }
|
|
1299
|
+
overwrite = true
|
|
1300
|
+
continue // 同一 offset 带 overwrite=1 重发
|
|
1301
|
+
}
|
|
1302
|
+
if (r.status === 409 && r.json.error === 'offset-mismatch') {
|
|
1303
|
+
await probe(up)
|
|
1304
|
+
continue
|
|
1305
|
+
}
|
|
1306
|
+
throw new Error(r.json.error || ('HTTP ' + r.status))
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// 0 字节文件: 循环不执行, 发一个空 finish 块
|
|
1310
|
+
if (up.size === 0 && up.offset === 0) {
|
|
1311
|
+
const params = { path: up.path, name: up.name, session: up.session, offset: '0', finish: '1' }
|
|
1312
|
+
if (overwrite) params.overwrite = '1'
|
|
1313
|
+
const r = await uploadChunk(params, new Blob([]), up)
|
|
1314
|
+
if (r.status === 401) { fsAuthError(401); return }
|
|
1315
|
+
if (r.status !== 201) throw new Error(r.json.error || ('HTTP ' + r.status))
|
|
1316
|
+
hideFsProgress()
|
|
1317
|
+
state.fs.upload = null
|
|
1246
1318
|
toast(`已上传 ${file.name}`, 'ok')
|
|
1247
1319
|
loadFs()
|
|
1248
1320
|
return
|
|
1249
1321
|
}
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
toast(
|
|
1322
|
+
|
|
1323
|
+
hideFsProgress()
|
|
1324
|
+
} catch (e) {
|
|
1325
|
+
hideFsProgress()
|
|
1326
|
+
toast(`上传中断:${e.message}(重选同一文件可断点续传)`, 'err')
|
|
1255
1327
|
}
|
|
1256
|
-
|
|
1257
|
-
xhr.upload.onerror = () => { hideFsProgress(); toast('上传失败:网络中断', 'err') }
|
|
1258
|
-
xhr.send(file) // raw body, 网关直接流式落盘
|
|
1259
|
-
}
|
|
1260
|
-
doUpload(false)
|
|
1328
|
+
})()
|
|
1261
1329
|
}
|
|
1262
1330
|
|
|
1263
1331
|
function fsUp() {
|
package/public/update.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.4.
|
|
2
|
+
"version": "0.4.9",
|
|
3
3
|
"apkUrl": "dsh-remote.apk",
|
|
4
|
-
"releasedAt": "2026-08-
|
|
5
|
-
"notes": "
|
|
4
|
+
"releasedAt": "2026-08-16T03:05:53.903Z",
|
|
5
|
+
"notes": "上传支持分块断点续传(断网重选同一文件接着传); APK 固定签名, 各版本可原地覆盖升级; 发版全自动: tag 后 CI 构建 Release 资产 + npm + 独立仓库"
|
|
6
6
|
}
|
package/public/version.json
CHANGED