dsh-remote-plugin 0.4.9 → 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
@@ -1219,20 +1219,113 @@ 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 FS_CHUNK_SIZE = 4 * 1024 * 1024 // 4MB/块, 断线后重选同一文件自动续传
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
+ }
1233
1317
 
1234
- const uploadChunk = (params, blob, up) => new Promise((resolve, reject) => {
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')
@@ -1243,17 +1336,23 @@ function uploadFsFile(file) {
1243
1336
  }
1244
1337
  }
1245
1338
  xhr.onload = () => {
1339
+ if (up.xhr === xhr) up.xhr = null
1246
1340
  let json = {}
1247
1341
  try { json = JSON.parse(xhr.responseText || '{}') } catch {}
1248
1342
  resolve({ status: xhr.status, json })
1249
1343
  }
1250
- xhr.onerror = () => reject(new Error('网络错误'))
1251
- xhr.upload.onerror = () => reject(new Error('网络中断'))
1252
- xhr.onabort = () => reject(new Error('已取消'))
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
+ }
1253
1352
  xhr.send(blob)
1254
1353
  })
1255
1354
 
1256
- const probe = async (up) => {
1355
+ const probe = async () => {
1257
1356
  const res = await fetch(fsApiUrl('/upload-probe', { path: up.path, name: up.name, session: up.session }), { headers: fsHeaders() })
1258
1357
  if (res.status === 401) { fsAuthError(401); return null }
1259
1358
  const json = await res.json().catch(() => ({}))
@@ -1261,71 +1360,130 @@ function uploadFsFile(file) {
1261
1360
  return json
1262
1361
  }
1263
1362
 
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
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
1269
1373
  }
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
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
1279
1385
  }
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
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
1305
1399
  }
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
1400
  hideFsProgress()
1401
+ setFsButtons(false)
1317
1402
  state.fs.upload = null
1318
- toast(`已上传 ${file.name}`, 'ok')
1403
+ toast(`已上传 ${up.name}(SHA-256 已校验${wasResumed ? ' · 断点续传' : ''})`, 'ok')
1319
1404
  loadFs()
1320
1405
  return
1321
1406
  }
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
+ }
1322
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
+ }
1323
1449
  hideFsProgress()
1324
- } catch (e) {
1325
- hideFsProgress()
1326
- toast(`上传中断:${e.message}(重选同一文件可断点续传)`, 'err')
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
1468
+ }
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
1327
1482
  }
1328
- })()
1483
+ hideFsProgress()
1484
+ setFsButtons(false)
1485
+ toast(`上传中断:${e.message}(重选同一文件可断点续传)`, 'err')
1486
+ }
1329
1487
  }
1330
1488
 
1331
1489
  function fsUp() {
@@ -1526,6 +1684,71 @@ function autosize(el) {
1526
1684
  }
1527
1685
 
1528
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
+
1529
1752
  function initToken() {
1530
1753
  const urlToken = new URLSearchParams(location.search).get('token')
1531
1754
  if (urlToken) {
@@ -1582,6 +1805,7 @@ function bindUi() {
1582
1805
  $('goal-close').addEventListener('click', () => $('modal-goal').classList.add('hidden'))
1583
1806
  $('goal-edit').addEventListener('click', submitGoalEdit)
1584
1807
  // 设置
1808
+ $('btn-scan-pair').addEventListener('click', scanPair)
1585
1809
  $('btn-change-token').addEventListener('click', () => {
1586
1810
  const t = prompt('输入访问令牌(网关启动时打印的 token):', state.token)
1587
1811
  if (t && t.trim()) { state.token = t.trim(); LS.set('token', t.trim()); $('token-desc').textContent = '已保存'; toast('已保存,正在重连', 'ok'); openStreams(); refreshAll() }
@@ -1629,6 +1853,8 @@ function bindUi() {
1629
1853
  if (f) uploadFsFile(f)
1630
1854
  e.target.value = '' // 允许连续选同一个文件
1631
1855
  })
1856
+ $('fs-pause-btn').addEventListener('click', pauseFsUpload)
1857
+ $('fs-cancel-btn').addEventListener('click', cancelFsUpload)
1632
1858
  bindFsPullRefresh()
1633
1859
 
1634
1860
  bindRail()
@@ -1671,6 +1897,7 @@ async function boot() {
1671
1897
  initToken()
1672
1898
  bindUi()
1673
1899
  bindNativeBack()
1900
+ bindNativeLinks()
1674
1901
  applyNativeInsets()
1675
1902
  updateConn()
1676
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>