dsh-remote-plugin 0.4.8 → 0.5.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/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)
@@ -1219,45 +1219,271 @@ function showFsProgress(pct, loaded, total) {
1219
1219
  $('fs-progress-text').textContent = `${pct}% · ${fmtSize(loaded)} / ${fmtSize(total)}`
1220
1220
  }
1221
1221
 
1222
+ function setFsProgressText(text) {
1223
+ $('fs-progress-text').textContent = text
1224
+ }
1225
+
1222
1226
  function hideFsProgress() {
1223
1227
  $('fs-progress').classList.add('hidden')
1224
1228
  $('fs-progress-bar').style.width = '0%'
1225
1229
  }
1226
1230
 
1231
+ function setFsButtons(show, paused = false) {
1232
+ const pauseBtn = $('fs-pause-btn')
1233
+ const cancelBtn = $('fs-cancel-btn')
1234
+ if (!pauseBtn || !cancelBtn) return
1235
+ pauseBtn.classList.toggle('hidden', !show)
1236
+ cancelBtn.classList.toggle('hidden', !show)
1237
+ if (show) pauseBtn.textContent = paused ? '继续' : '暂停'
1238
+ }
1239
+
1240
+ function pauseFsUpload() {
1241
+ const up = state.fs.upload
1242
+ if (!up) return
1243
+ if (!up.active) {
1244
+ if (up.paused) resumeFsUpload()
1245
+ return
1246
+ }
1247
+ up.pauseRequested = true
1248
+ try { up.xhr?.abort() } catch {}
1249
+ }
1250
+
1251
+ function resumeFsUpload() {
1252
+ const up = state.fs.upload
1253
+ if (!up || !up.file) return
1254
+ up.paused = false
1255
+ runFsUpload(up)
1256
+ }
1257
+
1258
+ async function cancelFsUpload() {
1259
+ const up = state.fs.upload
1260
+ if (!up) return
1261
+ up.cancelled = true
1262
+ try { up.xhr?.abort() } catch {}
1263
+ try {
1264
+ await fetch(fsApiUrl('/upload-control', { path: up.path, name: up.name, session: up.session, action: 'cancel' }), {
1265
+ method: 'POST', headers: fsHeaders()
1266
+ })
1267
+ } catch {}
1268
+ state.fs.upload = null
1269
+ hideFsProgress()
1270
+ setFsButtons(false)
1271
+ toast('已取消上传')
1272
+ }
1273
+
1274
+ const FS_CHUNK_SIZE = 4 * 1024 * 1024 // 4MB/块, 断线后重选同一文件自动续传
1275
+
1276
+ /** 把文件 [start,end) 逐块喂给 hasher(续传时补齐已传前缀); 期间可被暂停打断 */
1277
+ async function hashFsRange(file, hasher, start, end, up) {
1278
+ const step = FS_CHUNK_SIZE
1279
+ for (let off = start; off < end; off += step) {
1280
+ const buf = await file.slice(off, Math.min(off + step, end)).arrayBuffer()
1281
+ if (up?.pauseRequested) {
1282
+ const err = new Error('已暂停'); err.code = 'PAUSED'; throw err
1283
+ }
1284
+ hasher.update(new Uint8Array(buf))
1285
+ }
1286
+ }
1287
+
1227
1288
  function uploadFsFile(file) {
1228
1289
  if (!file) return
1229
1290
  if (!state.token) { toast('请先到「设置」页设置令牌', 'err'); showView('view-settings'); return }
1230
1291
  if (file.size > 2 * 1024 * 1024 * 1024) { toast('单文件超过 2GB 上限', 'err'); return }
1292
+ if (state.fs.upload?.active) { toast('已有上传任务,先暂停或取消', 'err'); return }
1231
1293
 
1232
- const doUpload = (overwrite) => {
1233
- const params = { path: state.fs.path, name: file.name }
1234
- if (overwrite) params.overwrite = '1'
1294
+ const prev = state.fs.upload
1295
+ if (prev && prev.path === state.fs.path && prev.name === file.name && prev.size === file.size) {
1296
+ prev.file = file
1297
+ runFsUpload(prev)
1298
+ return
1299
+ }
1300
+ // 换传别的文件: 顺手清掉旧任务的服务端分片, 不残留隐藏 .part
1301
+ if (prev) {
1302
+ prev.cancelled = true
1303
+ try { prev.xhr?.abort() } catch {}
1304
+ try {
1305
+ fetch(fsApiUrl('/upload-control', { path: prev.path, name: prev.name, session: prev.session, action: 'cancel' }), {
1306
+ method: 'POST', headers: fsHeaders()
1307
+ })
1308
+ } catch {}
1309
+ }
1310
+ const up = {
1311
+ session: uuid(), path: state.fs.path, name: file.name, size: file.size,
1312
+ offset: 0, file, xhr: null, active: false, paused: false, cancelled: false
1313
+ }
1314
+ state.fs.upload = up
1315
+ runFsUpload(up)
1316
+ }
1317
+
1318
+ async function runFsUpload(up) {
1319
+ if (!up || !up.file) return
1320
+ up.active = true
1321
+ up.cancelled = false
1322
+ up.paused = false
1323
+ up.pauseRequested = false
1324
+ setFsButtons(true, false)
1325
+
1326
+ const uploadChunk = (params, blob) => new Promise((resolve, reject) => {
1235
1327
  const xhr = new XMLHttpRequest()
1328
+ up.xhr = xhr
1236
1329
  xhr.open('POST', fsApiUrl('/upload', params))
1237
1330
  xhr.setRequestHeader('authorization', 'Bearer ' + state.token)
1238
1331
  xhr.setRequestHeader('x-dsh-remote-client', CAP?.isNativePlatform?.() ? 'app' : 'web')
1239
- showFsProgress(0, 0, file.size)
1240
1332
  xhr.upload.onprogress = (e) => {
1241
- if (e.lengthComputable) showFsProgress(Math.round(e.loaded / Math.max(1, e.total) * 100), e.loaded, e.total)
1333
+ if (e.lengthComputable) {
1334
+ const loaded = up.offset + Math.min(e.loaded, e.total)
1335
+ showFsProgress(Math.round(loaded / Math.max(1, up.size) * 100), loaded, up.size)
1336
+ }
1242
1337
  }
1243
1338
  xhr.onload = () => {
1244
- hideFsProgress()
1245
- if (xhr.status === 201 || xhr.status === 200) {
1246
- toast(`已上传 ${file.name}`, 'ok')
1339
+ if (up.xhr === xhr) up.xhr = null
1340
+ let json = {}
1341
+ try { json = JSON.parse(xhr.responseText || '{}') } catch {}
1342
+ resolve({ status: xhr.status, json })
1343
+ }
1344
+ xhr.onerror = () => { if (up.xhr === xhr) up.xhr = null; reject(new Error('网络错误')) }
1345
+ xhr.upload.onerror = () => { if (up.xhr === xhr) up.xhr = null; reject(new Error('网络中断')) }
1346
+ xhr.onabort = () => {
1347
+ if (up.xhr === xhr) up.xhr = null
1348
+ const err = new Error(up.cancelled ? '已取消' : '已暂停')
1349
+ err.code = up.cancelled ? 'CANCELLED' : 'PAUSED'
1350
+ reject(err)
1351
+ }
1352
+ xhr.send(blob)
1353
+ })
1354
+
1355
+ const probe = async () => {
1356
+ const res = await fetch(fsApiUrl('/upload-probe', { path: up.path, name: up.name, session: up.session }), { headers: fsHeaders() })
1357
+ if (res.status === 401) { fsAuthError(401); return null }
1358
+ const json = await res.json().catch(() => ({}))
1359
+ if (json.ok) up.offset = json.partialSize || 0
1360
+ return json
1361
+ }
1362
+
1363
+ let overwrite = false
1364
+ let wasResumed = false
1365
+ let hasher = new SHA256()
1366
+ try {
1367
+ const info = await probe()
1368
+ if (info === null || up.cancelled) return
1369
+ if (info.partialSize > 0) wasResumed = true
1370
+ if (info.targetExists && info.targetSize === up.size && !overwrite) {
1371
+ if (!confirm('文件已存在(大小相同),覆盖它?')) { state.fs.upload = null; hideFsProgress(); setFsButtons(false); return }
1372
+ overwrite = true
1373
+ }
1374
+ if (up.offset > 0) await hashFsRange(up.file, hasher, 0, up.offset, up)
1375
+ showFsProgress(Math.round(up.offset / Math.max(1, up.size) * 100), up.offset, up.size)
1376
+
1377
+ while (up.offset < up.size) {
1378
+ if (up.cancelled) return
1379
+ const end = Math.min(up.offset + FS_CHUNK_SIZE, up.size)
1380
+ const blob = up.file.slice(up.offset, end)
1381
+ const before = hasher.clone()
1382
+ const chunkBytes = new Uint8Array(await blob.arrayBuffer())
1383
+ if (up.pauseRequested) {
1384
+ const err = new Error('已暂停'); err.code = 'PAUSED'; throw err
1385
+ }
1386
+ hasher.update(chunkBytes)
1387
+ const isLast = end >= up.size
1388
+ const params = { path: up.path, name: up.name, session: up.session, offset: String(up.offset) }
1389
+ if (isLast) { params.finish = '1'; params.sha256 = hasher.hex() }
1390
+ if (overwrite) params.overwrite = '1'
1391
+ const r = await uploadChunk(params, blob)
1392
+ if (r.status === 401) { fsAuthError(401); return }
1393
+ if (r.status === 200 && r.json.offset != null) { up.offset = r.json.offset; continue }
1394
+ if (r.status === 201) {
1395
+ const expected = params.sha256 || hasher.hex()
1396
+ if (r.json.sha256 && r.json.sha256 !== expected) {
1397
+ const err = new Error('文件校验不一致(SHA-256)'); err.checksum = true
1398
+ throw err
1399
+ }
1400
+ hideFsProgress()
1401
+ setFsButtons(false)
1402
+ state.fs.upload = null
1403
+ toast(`已上传 ${up.name}(SHA-256 已校验${wasResumed ? ' · 断点续传' : ''})`, 'ok')
1247
1404
  loadFs()
1248
1405
  return
1249
1406
  }
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')
1407
+ if (r.status === 409 && r.json.error === 'conflict') {
1408
+ hasher = before // 这段数据没被写入, 回退哈希状态后带 overwrite=1 重发
1409
+ if (!confirm('文件已存在,覆盖它?')) { state.fs.upload = null; hideFsProgress(); setFsButtons(false); return }
1410
+ overwrite = true
1411
+ continue
1412
+ }
1413
+ if (r.status === 409 && r.json.error === 'offset-mismatch') {
1414
+ hasher = before
1415
+ await probe()
1416
+ continue
1417
+ }
1418
+ if (r.status === 422 && r.json.error === 'checksum-mismatch') {
1419
+ const err = new Error('文件校验不一致(SHA-256),已清除坏分片')
1420
+ err.checksum = true
1421
+ throw err
1422
+ }
1423
+ throw new Error(r.json.error || ('HTTP ' + r.status))
1424
+ }
1425
+
1426
+ // offset 已到文件末尾但还没落位(0 字节文件 / 续传时最后一块已完成而 rename 被中断):
1427
+ // 发一个空 finish 块完成收尾, 同时带上全量 SHA-256 校验
1428
+ if (up.offset >= up.size) {
1429
+ const expected = hasher.hex()
1430
+ const params = { path: up.path, name: up.name, session: up.session, offset: String(up.offset), finish: '1', sha256: expected }
1431
+ if (overwrite) params.overwrite = '1'
1432
+ const r = await uploadChunk(params, new Blob([]))
1433
+ if (r.status === 401) { fsAuthError(401); return }
1434
+ if (r.status === 409 && r.json.error === 'conflict') {
1435
+ if (!confirm('文件已存在,覆盖它?')) { state.fs.upload = null; hideFsProgress(); setFsButtons(false); return }
1436
+ params.overwrite = '1'
1437
+ return runFsUpload(up) // 目标冲突未写入, 重新走 probe + 空 finish
1438
+ }
1439
+ if (r.status === 422 && r.json.error === 'checksum-mismatch') {
1440
+ const err = new Error('文件校验不一致(SHA-256),已清除坏分片')
1441
+ err.checksum = true
1442
+ throw err
1443
+ }
1444
+ if (r.status !== 201) throw new Error(r.json.error || ('HTTP ' + r.status))
1445
+ if (r.json.sha256 && r.json.sha256 !== expected) {
1446
+ const err = new Error('文件校验不一致(SHA-256)'); err.checksum = true
1447
+ throw err
1448
+ }
1449
+ hideFsProgress()
1450
+ setFsButtons(false)
1451
+ state.fs.upload = null
1452
+ toast(`已上传 ${up.name}(SHA-256 已校验${wasResumed ? ' · 断点续传' : ''})`, 'ok')
1453
+ loadFs()
1454
+ return
1455
+ }
1456
+
1457
+ hideFsProgress()
1458
+ setFsButtons(false)
1459
+ } catch (e) {
1460
+ up.active = false
1461
+ if (up.cancelled || e?.code === 'CANCELLED') return
1462
+ if (e?.code === 'PAUSED') {
1463
+ up.paused = true
1464
+ setFsButtons(true, true)
1465
+ setFsProgressText(`已暂停 · ${Math.round(up.offset / Math.max(1, up.size) * 100)}%`)
1466
+ toast('已暂停,点「继续」接着传', 'ok')
1467
+ return
1255
1468
  }
1256
- xhr.onerror = () => { hideFsProgress(); toast('上传失败:网络错误', 'err') }
1257
- xhr.upload.onerror = () => { hideFsProgress(); toast('上传失败:网络中断', 'err') }
1258
- xhr.send(file) // raw body, 网关直接流式落盘
1469
+ if (e?.checksum) {
1470
+ // 坏分片保留只会反复校验失败: 服务端删掉, 下一次「继续」从 0 完整重传
1471
+ up.paused = true
1472
+ up.offset = 0
1473
+ try {
1474
+ await fetch(fsApiUrl('/upload-control', { path: up.path, name: up.name, session: up.session, action: 'cancel' }), {
1475
+ method: 'POST', headers: fsHeaders()
1476
+ })
1477
+ } catch {}
1478
+ setFsButtons(true, true)
1479
+ setFsProgressText('校验失败 · 点「继续」重新上传')
1480
+ toast(e.message, 'err')
1481
+ return
1482
+ }
1483
+ hideFsProgress()
1484
+ setFsButtons(false)
1485
+ toast(`上传中断:${e.message}(重选同一文件可断点续传)`, 'err')
1259
1486
  }
1260
- doUpload(false)
1261
1487
  }
1262
1488
 
1263
1489
  function fsUp() {
@@ -1458,6 +1684,71 @@ function autosize(el) {
1458
1684
  }
1459
1685
 
1460
1686
  /* ---------------- 初始化 ---------------- */
1687
+ /** 解析 dshremote://pair?token=..&server=.. 配对二维码 */
1688
+ function applyPairUrl(url) {
1689
+ try {
1690
+ const u = new URL(String(url).trim())
1691
+ if (u.protocol !== 'dshremote:' || u.hostname !== 'pair') return false
1692
+ const t = (u.searchParams.get('token') || '').trim()
1693
+ const server = (u.searchParams.get('server') || '').trim().replace(/\/+$/, '')
1694
+ if (!t || !/^https?:\/\//i.test(server)) return false
1695
+ state.token = t
1696
+ LS.set('token', t)
1697
+ state.server = server
1698
+ if (!state.servers.includes(server)) state.servers.unshift(server)
1699
+ saveServers()
1700
+ renderServers()
1701
+ $('token-desc').textContent = '已保存(扫码)'
1702
+ return true
1703
+ } catch {
1704
+ return false
1705
+ }
1706
+ }
1707
+
1708
+ /** App 内扫码: 调用原生 ML Kit, 扫到 dshremote://pair 后自动配对 */
1709
+ async function scanPair() {
1710
+ if (!CAP?.isNativePlatform?.()) {
1711
+ toast('浏览器请打开主机管理页,用手机相机扫码', 'err')
1712
+ return
1713
+ }
1714
+ const scanner = CAP.Plugins?.BarcodeScanner
1715
+ if (!scanner?.scan) { toast('当前 App 版本不支持扫码,请先更新 App', 'err'); return }
1716
+ try {
1717
+ const supported = await scanner.isSupported?.()
1718
+ if (supported && supported.supported === false) { toast('设备不支持扫码', 'err'); return }
1719
+ const result = await scanner.scan({ formats: ['QR_CODE'], autoZoom: true })
1720
+ const raw = result?.barcodes?.[0]?.rawValue || result?.barcodes?.[0]?.displayValue || ''
1721
+ if (!raw) { toast('没有扫到内容', 'err'); return }
1722
+ if (applyPairUrl(raw)) {
1723
+ toast('配对成功,正在连接', 'ok')
1724
+ openStreams()
1725
+ refreshAll()
1726
+ } else {
1727
+ toast('这不是 DSH Remote 的配对二维码', 'err')
1728
+ }
1729
+ } catch (e) {
1730
+ const msg = String(e?.message || e || '')
1731
+ toast(msg.includes('cancel') || msg.includes('Cancel') ? '已取消扫码' : '扫码失败:' + msg, 'err')
1732
+ }
1733
+ }
1734
+
1735
+ /** 系统相机/浏览器扫码后通过 dshremote:// 链接唤起 App: 这里接住并配对 */
1736
+ function bindNativeLinks() {
1737
+ if (!CAP?.isNativePlatform?.()) return
1738
+ try {
1739
+ CAP.Plugins?.App?.addListener?.('appUrlOpen', (data) => {
1740
+ if (data?.url && applyPairUrl(data.url)) {
1741
+ toast('已通过二维码配对', 'ok')
1742
+ openStreams()
1743
+ refreshAll()
1744
+ }
1745
+ })
1746
+ CAP.Plugins?.App?.getLaunchUrl?.().then((data) => {
1747
+ if (data?.url) applyPairUrl(data.url)
1748
+ }).catch(() => {})
1749
+ } catch {}
1750
+ }
1751
+
1461
1752
  function initToken() {
1462
1753
  const urlToken = new URLSearchParams(location.search).get('token')
1463
1754
  if (urlToken) {
@@ -1514,6 +1805,7 @@ function bindUi() {
1514
1805
  $('goal-close').addEventListener('click', () => $('modal-goal').classList.add('hidden'))
1515
1806
  $('goal-edit').addEventListener('click', submitGoalEdit)
1516
1807
  // 设置
1808
+ $('btn-scan-pair').addEventListener('click', scanPair)
1517
1809
  $('btn-change-token').addEventListener('click', () => {
1518
1810
  const t = prompt('输入访问令牌(网关启动时打印的 token):', state.token)
1519
1811
  if (t && t.trim()) { state.token = t.trim(); LS.set('token', t.trim()); $('token-desc').textContent = '已保存'; toast('已保存,正在重连', 'ok'); openStreams(); refreshAll() }
@@ -1561,6 +1853,8 @@ function bindUi() {
1561
1853
  if (f) uploadFsFile(f)
1562
1854
  e.target.value = '' // 允许连续选同一个文件
1563
1855
  })
1856
+ $('fs-pause-btn').addEventListener('click', pauseFsUpload)
1857
+ $('fs-cancel-btn').addEventListener('click', cancelFsUpload)
1564
1858
  bindFsPullRefresh()
1565
1859
 
1566
1860
  bindRail()
@@ -1603,6 +1897,7 @@ async function boot() {
1603
1897
  initToken()
1604
1898
  bindUi()
1605
1899
  bindNativeBack()
1900
+ bindNativeLinks()
1606
1901
  applyNativeInsets()
1607
1902
  updateConn()
1608
1903
  loadLocalVersion()
package/public/index.html CHANGED
@@ -47,6 +47,10 @@
47
47
  <div id="fs-progress" class="fs-progress hidden">
48
48
  <div class="fs-progress-track"><div id="fs-progress-bar" class="fs-progress-bar"></div></div>
49
49
  <div id="fs-progress-text" class="fs-progress-text muted">0%</div>
50
+ <div class="fs-progress-actions">
51
+ <button id="fs-pause-btn" class="mini-btn hidden">暂停</button>
52
+ <button id="fs-cancel-btn" class="mini-btn hidden">取消</button>
53
+ </div>
50
54
  </div>
51
55
  <div id="fs-pull" class="fs-pull">松开刷新</div>
52
56
  <div id="fs-list" class="fs-list"></div>
@@ -106,6 +110,10 @@
106
110
  <input id="server-input" type="url" placeholder="http://IP:8787">
107
111
  <button id="btn-server-add" class="mini-btn">添加</button>
108
112
  </div>
113
+ <div class="setting-row">
114
+ <div><div class="setting-name">扫码连接</div><div class="setting-desc">扫主机管理页的令牌二维码,自动填入地址与令牌</div></div>
115
+ <button id="btn-scan-pair" class="mini-btn">扫码</button>
116
+ </div>
109
117
  <div class="setting-row">
110
118
  <div><div class="setting-name">通知</div><div class="setting-desc">收到审批/提问时推送</div></div>
111
119
  <label class="switch"><input type="checkbox" id="opt-notify"><span class="slider"></span></label>
@@ -194,6 +202,7 @@
194
202
 
195
203
  <div id="toast" class="toast hidden"></div>
196
204
 
205
+ <script src="sha256.js"></script>
197
206
  <script src="app.js"></script>
198
207
  </body>
199
208
  </html>