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 CHANGED
@@ -1,5 +1,7 @@
1
1
  # dsh-remote-plugin
2
2
 
3
+ [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](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
  ## 安装
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.8",
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 doUpload = (overwrite) => {
1233
- const params = { path: state.fs.path, name: file.name }
1234
- if (overwrite) params.overwrite = '1'
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) showFsProgress(Math.round(e.loaded / Math.max(1, e.total) * 100), e.loaded, e.total)
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
- hideFsProgress()
1245
- if (xhr.status === 201 || xhr.status === 200) {
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
- if (xhr.status === 401) { fsAuthError(401); return }
1251
- let err = 'HTTP ' + xhr.status
1252
- try { err = JSON.parse(xhr.responseText || '{}').error || err } catch {}
1253
- if (xhr.status === 409 && confirm('文件已存在,覆盖它?')) { doUpload(true); return }
1254
- toast('上传失败:' + err, 'err')
1322
+
1323
+ hideFsProgress()
1324
+ } catch (e) {
1325
+ hideFsProgress()
1326
+ toast(`上传中断:${e.message}(重选同一文件可断点续传)`, 'err')
1255
1327
  }
1256
- xhr.onerror = () => { hideFsProgress(); toast('上传失败:网络错误', 'err') }
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() {
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.4.8",
2
+ "version": "0.4.9",
3
3
  "apkUrl": "dsh-remote.apk",
4
- "releasedAt": "2026-08-15T16:19:06.176Z",
5
- "notes": "新增文件传输: /fs/list /fs/file /fs/upload, 局域网/Tailscale 直传大小文件; 多服务器地址自动测速切换; 聊天记录本地缓存离线可看; 下载统一到 Downloads/dsh-remote; 修复从会话页切出后顶栏消失"
4
+ "releasedAt": "2026-08-16T03:05:53.903Z",
5
+ "notes": "上传支持分块断点续传(断网重选同一文件接着传); APK 固定签名, 各版本可原地覆盖升级; 发版全自动: tag CI 构建 Release 资产 + npm + 独立仓库"
6
6
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.4.8"
2
+ "version": "0.4.9"
3
3
  }