dsh-plugin-pack 1.0.4 → 1.0.5
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/client.js +222 -6
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -108,6 +108,9 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
108
108
|
'import.source.bundle': '整合包',
|
|
109
109
|
'import.source.plugin': '本地插件',
|
|
110
110
|
'import.notBundle': 'zip 不是整合包',
|
|
111
|
+
'import.reading': '正在读取包内容…',
|
|
112
|
+
'import.uploading': '正在上传…',
|
|
113
|
+
'import.checking': '正在检查冲突…',
|
|
111
114
|
'drop.title': '把 zip / tgz 或插件文件夹拖到这里',
|
|
112
115
|
'drop.release': '松手就导入',
|
|
113
116
|
'drop.running': '正在导入…',
|
|
@@ -266,6 +269,9 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
266
269
|
'import.source.bundle': 'Bundle',
|
|
267
270
|
'import.source.plugin': 'Local plugin',
|
|
268
271
|
'import.notBundle': 'This zip is not a bundle',
|
|
272
|
+
'import.reading': 'Reading package contents…',
|
|
273
|
+
'import.uploading': 'Uploading…',
|
|
274
|
+
'import.checking': 'Checking for conflicts…',
|
|
269
275
|
'drop.title': 'Drop a zip / tgz or plugin folder here',
|
|
270
276
|
'drop.release': 'Release to import',
|
|
271
277
|
'drop.running': 'Importing…',
|
|
@@ -488,6 +494,135 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
488
494
|
const cancelWaiting = (id, reason) =>
|
|
489
495
|
api('/plugin-pack/waiting/cancel', { method: 'POST', body: JSON.stringify({ id, reason }) });
|
|
490
496
|
|
|
497
|
+
/* ------------------------- 浏览器端读 zip 头部 ------------------------- */
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* 只读 zip 的中央目录,取出条目名(不解压内容)。
|
|
501
|
+
*
|
|
502
|
+
* 为什么在浏览器里做:拖入一个几百 MB 的包,原来的流程是
|
|
503
|
+
* 「整个文件 POST 上去 → 服务端 dryRun → 才弹冲突窗」,
|
|
504
|
+
* 用户要等上传完才看到"哪些要覆盖"。
|
|
505
|
+
*
|
|
506
|
+
* zip 的中央目录在文件尾部(最后 22 字节是 EOCD,里面记着目录偏移),
|
|
507
|
+
* 所以只读尾部 ~1 MB 就能得到全部条目名,不用传整个文件。
|
|
508
|
+
* 这样拖进来几百毫秒就能弹出冲突列表,点确认之后才真正上传。
|
|
509
|
+
*
|
|
510
|
+
* @param {File} file
|
|
511
|
+
* @returns {Promise<{ names: string[], manifest: any | null } | null>}
|
|
512
|
+
* 不是有效 zip 时返回 null(交给服务端给出正式错误)
|
|
513
|
+
*/
|
|
514
|
+
async function peekZip(file) {
|
|
515
|
+
try {
|
|
516
|
+
const size = file.size;
|
|
517
|
+
if (size < 22) return null;
|
|
518
|
+
|
|
519
|
+
// 1) 读尾部,找 EOCD
|
|
520
|
+
const tailLen = Math.min(size, 2 * 1024 * 1024);
|
|
521
|
+
const tail = new Uint8Array(await file.slice(size - tailLen).arrayBuffer());
|
|
522
|
+
let eocd = -1;
|
|
523
|
+
for (let i = tail.length - 22; i >= 0; i -= 1) {
|
|
524
|
+
if (tail[i] === 0x50 && tail[i + 1] === 0x4b && tail[i + 2] === 0x05 && tail[i + 3] === 0x06) {
|
|
525
|
+
eocd = i;
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (eocd < 0) return null;
|
|
530
|
+
|
|
531
|
+
const total = tail[eocd + 10] | (tail[eocd + 11] << 8);
|
|
532
|
+
const cdSize = tail[eocd + 12] | (tail[eocd + 13] << 8) | (tail[eocd + 14] << 16) | (tail[eocd + 15] << 24);
|
|
533
|
+
const cdOffset = tail[eocd + 16] | (tail[eocd + 17] << 8) | (tail[eocd + 18] << 16) | (tail[eocd + 19] << 24);
|
|
534
|
+
|
|
535
|
+
// 2) 按需读中央目录那一段
|
|
536
|
+
let cd = tail;
|
|
537
|
+
if (cdOffset < size - tailLen) {
|
|
538
|
+
cd = new Uint8Array(await file.slice(cdOffset, cdOffset + cdSize).arrayBuffer());
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const names = [];
|
|
542
|
+
let p = 0;
|
|
543
|
+
const base = cdOffset < size - tailLen ? 0 : cdOffset - (size - tailLen);
|
|
544
|
+
p = base;
|
|
545
|
+
while (p + 46 <= cd.length && names.length < total) {
|
|
546
|
+
if (!(cd[p] === 0x50 && cd[p + 1] === 0x4b && cd[p + 2] === 0x01 && cd[p + 3] === 0x02)) break;
|
|
547
|
+
const nameLen = cd[p + 28] | (cd[p + 29] << 8);
|
|
548
|
+
const extraLen = cd[p + 30] | (cd[p + 31] << 8);
|
|
549
|
+
const commentLen = cd[p + 32] | (cd[p + 33] << 8);
|
|
550
|
+
const raw = cd.subarray(p + 46, p + 46 + nameLen);
|
|
551
|
+
let name = '';
|
|
552
|
+
for (let k = 0; k < raw.length; k += 1) name += String.fromCharCode(raw[k]);
|
|
553
|
+
try {
|
|
554
|
+
name = decodeURIComponent(escape(name));
|
|
555
|
+
} catch {
|
|
556
|
+
/* 保持原样 */
|
|
557
|
+
}
|
|
558
|
+
names.push(name.replace(/\\/g, '/'));
|
|
559
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
560
|
+
}
|
|
561
|
+
if (!names.length) return null;
|
|
562
|
+
|
|
563
|
+
// 3) 有 manifest.json 才解压它(其余一律不解压)
|
|
564
|
+
let manifest = null;
|
|
565
|
+
const manifestEntry = names.find((n) => n === 'manifest.json');
|
|
566
|
+
if (manifestEntry) {
|
|
567
|
+
manifest = await readZipEntryAsJson(file, cd, base, manifestEntry, cdOffset < size - tailLen);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return { names, manifest };
|
|
571
|
+
} catch {
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** 从中央目录找到某条目,解压它并 JSON.parse。失败返回 null。 */
|
|
577
|
+
async function readZipEntryAsJson(file, cd, base, targetName, cdIsSeparate) {
|
|
578
|
+
try {
|
|
579
|
+
let p = base;
|
|
580
|
+
let found = null;
|
|
581
|
+
while (p + 46 <= cd.length) {
|
|
582
|
+
if (!(cd[p] === 0x50 && cd[p + 1] === 0x4b && cd[p + 2] === 0x01 && cd[p + 3] === 0x02)) break;
|
|
583
|
+
const method = cd[p + 10] | (cd[p + 11] << 8);
|
|
584
|
+
const compSize = cd[p + 20] | (cd[p + 21] << 8) | (cd[p + 22] << 16) | (cd[p + 23] << 24);
|
|
585
|
+
const nameLen = cd[p + 28] | (cd[p + 29] << 8);
|
|
586
|
+
const extraLen = cd[p + 30] | (cd[p + 31] << 8);
|
|
587
|
+
const commentLen = cd[p + 32] | (cd[p + 33] << 8);
|
|
588
|
+
const localOffset = cd[p + 42] | (cd[p + 43] << 8) | (cd[p + 44] << 16) | (cd[p + 45] << 24);
|
|
589
|
+
const raw = cd.subarray(p + 46, p + 46 + nameLen);
|
|
590
|
+
let name = '';
|
|
591
|
+
for (let k = 0; k < raw.length; k += 1) name += String.fromCharCode(raw[k]);
|
|
592
|
+
try {
|
|
593
|
+
name = decodeURIComponent(escape(name));
|
|
594
|
+
} catch {
|
|
595
|
+
/* 保持原样 */
|
|
596
|
+
}
|
|
597
|
+
if (name.replace(/\\/g, '/') === targetName) {
|
|
598
|
+
found = { method, compSize, localOffset };
|
|
599
|
+
break;
|
|
600
|
+
}
|
|
601
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
602
|
+
}
|
|
603
|
+
if (!found) return null;
|
|
604
|
+
|
|
605
|
+
// 读本地头拿到文件名/extra 的实际长度
|
|
606
|
+
const local = new Uint8Array(await file.slice(found.localOffset, found.localOffset + 30).arrayBuffer());
|
|
607
|
+
const lNameLen = local[26] | (local[27] << 8);
|
|
608
|
+
const lExtraLen = local[28] | (local[29] << 8);
|
|
609
|
+
const dataStart = found.localOffset + 30 + lNameLen + lExtraLen;
|
|
610
|
+
const raw = new Uint8Array(await file.slice(dataStart, dataStart + found.compSize).arrayBuffer());
|
|
611
|
+
|
|
612
|
+
let bytes = raw;
|
|
613
|
+
if (found.method === 8) {
|
|
614
|
+
// deflate 解压用浏览器内置的 DecompressionStream
|
|
615
|
+
const stream = new Blob([raw]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
|
|
616
|
+
const buf = await new Response(stream).arrayBuffer();
|
|
617
|
+
bytes = new Uint8Array(buf);
|
|
618
|
+
}
|
|
619
|
+
const text = new TextDecoder('utf-8').decode(bytes);
|
|
620
|
+
return JSON.parse(text);
|
|
621
|
+
} catch {
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
491
626
|
/** 上传一个 zip,返回宿主落盘后的绝对路径。 */
|
|
492
627
|
async function uploadZip(file) {
|
|
493
628
|
const form = new FormData();
|
|
@@ -1042,7 +1177,7 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1042
1177
|
* @param {boolean} [props.busy]
|
|
1043
1178
|
* @param {string} [props.hint]
|
|
1044
1179
|
*/
|
|
1045
|
-
function DropZone({ onFile, busy = false, hint, dropRef }) {
|
|
1180
|
+
function DropZone({ onFile, busy = false, hint, dropRef, phase }) {
|
|
1046
1181
|
const [over, setOver] = useState(false);
|
|
1047
1182
|
const [hover, setHover] = useState(false);
|
|
1048
1183
|
const inputRef = useRef(null);
|
|
@@ -1099,7 +1234,11 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1099
1234
|
h(
|
|
1100
1235
|
'div',
|
|
1101
1236
|
{ style: { fontSize: 13, fontWeight: 500 } },
|
|
1102
|
-
busy
|
|
1237
|
+
busy
|
|
1238
|
+
? phase || translate('drop.running')
|
|
1239
|
+
: over
|
|
1240
|
+
? translate('drop.release')
|
|
1241
|
+
: translate('drop.title'),
|
|
1103
1242
|
),
|
|
1104
1243
|
h('div', { className: 'pp-hint', style: { marginTop: 4 } }, hint ?? translate('drop.hint')),
|
|
1105
1244
|
h('input', {
|
|
@@ -1447,6 +1586,8 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1447
1586
|
const [pendingImport, setPendingImport] = useState(null);
|
|
1448
1587
|
/** 当前弹窗对应的服务端任务 id(取消时要通知服务端)。 */
|
|
1449
1588
|
const pendingJobId = useRef(null);
|
|
1589
|
+
/** 导入进度阶段文案(读取中 / 上传中 / 检查冲突…),空串表示没在跑。 */
|
|
1590
|
+
const [phase, setPhase] = useState('');
|
|
1450
1591
|
/** 后台任务列表:切走画面回来靠它恢复 */
|
|
1451
1592
|
const [jobs, setJobs] = useState([]);
|
|
1452
1593
|
/** 体积上限(MiB),0 = 不限制。默认跟着服务端配置走。 */
|
|
@@ -1620,10 +1761,66 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1620
1761
|
// 两者都走"上传 → 导入",具体分流由服务端按扩展名决定。
|
|
1621
1762
|
const isArchive = /\.(zip|tgz|tar\.gz)$/i.test(name);
|
|
1622
1763
|
if (isArchive) {
|
|
1764
|
+
/* 第一步:先在浏览器里只读 zip 尾部,几百毫秒就知道包里有哪些插件、
|
|
1765
|
+
* 哪些跟已装的重名 —— 不用等整个文件传完。
|
|
1766
|
+
* 用户把几百 MB 的包拖进来时,这个差别是"秒弹"和"等半分钟"的区别。 */
|
|
1767
|
+
const isZip = /\.zip$/i.test(name);
|
|
1768
|
+
let quick = null;
|
|
1769
|
+
if (isZip) {
|
|
1770
|
+
setPhase(t('import.reading'));
|
|
1771
|
+
quick = await peekZip(file);
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
if (quick && Array.isArray(quick.names) && quick.names.length) {
|
|
1775
|
+
const installed = new Map((status?.plugins ?? []).map((plugin) => [plugin.name, plugin]));
|
|
1776
|
+
const packPlugins = (quick.manifest?.plugins ?? []).filter(Boolean);
|
|
1777
|
+
const conflicts = packPlugins
|
|
1778
|
+
.filter((plugin) => installed.has(plugin.name))
|
|
1779
|
+
.map((plugin) => ({
|
|
1780
|
+
name: plugin.name,
|
|
1781
|
+
version: plugin.version ?? null,
|
|
1782
|
+
installedVersion: installed.get(plugin.name)?.version ?? null,
|
|
1783
|
+
dest: installed.get(plugin.name)?.dir ?? null,
|
|
1784
|
+
bytes: plugin.bytes ?? null,
|
|
1785
|
+
sameVersion: (installed.get(plugin.name)?.version ?? null) === (plugin.version ?? null),
|
|
1786
|
+
}));
|
|
1787
|
+
|
|
1788
|
+
// 不是整合包就直接报,省得白传一遍
|
|
1789
|
+
const looksLikeBundle = Boolean(quick.manifest) && String(quick.manifest?.format ?? '').startsWith('dsh-plugin-pack@');
|
|
1790
|
+
if (!looksLikeBundle) {
|
|
1791
|
+
setImportResult({
|
|
1792
|
+
error: `zip 不是整合包:这个 zip 里没有 dsh-plugin-pack 的 manifest.json(共 ${quick.names.length} 个文件)。它可能是普通的压缩包,或者是别的工具打的包。`,
|
|
1793
|
+
code: 'NOT_A_BUNDLE',
|
|
1794
|
+
});
|
|
1795
|
+
setBusy(false);
|
|
1796
|
+
setPhase('');
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
if (conflicts.length) {
|
|
1801
|
+
/* 先把冲突列表给出来,用户选完才上传 + 真导入。
|
|
1802
|
+
* 这时还没上传,所以待决策任务在服务端也不存在 —— 全靠确认时再登记。 */
|
|
1803
|
+
setPendingImport({
|
|
1804
|
+
kind: 'bundle',
|
|
1805
|
+
pendingFile: file, // 还没上传,先拿着 File 对象
|
|
1806
|
+
file: null,
|
|
1807
|
+
conflicts,
|
|
1808
|
+
plugins: packPlugins,
|
|
1809
|
+
quick: true,
|
|
1810
|
+
});
|
|
1811
|
+
setBusy(false);
|
|
1812
|
+
setPhase('');
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
// 第二步:确认无冲突(或拿不到快速清单)才上传
|
|
1818
|
+
setPhase(t('import.uploading'));
|
|
1623
1819
|
const uploaded = await uploadZip(file);
|
|
1624
1820
|
setZipPath(uploaded.file);
|
|
1625
1821
|
|
|
1626
1822
|
// 先 dryRun 探一次:有重名冲突就弹窗让用户决定
|
|
1823
|
+
setPhase(t('import.checking'));
|
|
1627
1824
|
const preview = await doImport({ file: uploaded.file, dryRun: true });
|
|
1628
1825
|
const conflicts = Array.isArray(preview?.conflicts) ? preview.conflicts : [];
|
|
1629
1826
|
if (conflicts.length) {
|
|
@@ -1637,6 +1834,7 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1637
1834
|
plugins: preview.plugins ?? [],
|
|
1638
1835
|
});
|
|
1639
1836
|
setBusy(false);
|
|
1837
|
+
setPhase('');
|
|
1640
1838
|
return;
|
|
1641
1839
|
}
|
|
1642
1840
|
|
|
@@ -1668,6 +1866,7 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1668
1866
|
if (/\.(zip|tgz|tar\.gz)$/i.test(String(file?.name ?? ''))) setImportResult({ error: message, code });
|
|
1669
1867
|
else setImportPluginResult({ error: message, code });
|
|
1670
1868
|
} finally {
|
|
1869
|
+
setPhase('');
|
|
1671
1870
|
setBusy(false);
|
|
1672
1871
|
lastDrop.dataTransfer = null;
|
|
1673
1872
|
}
|
|
@@ -1677,28 +1876,45 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1677
1876
|
const confirmImport = async (decisions) => {
|
|
1678
1877
|
const pending = pendingImport;
|
|
1679
1878
|
if (!pending) return;
|
|
1680
|
-
const jobId = pending.jobId ?? pendingJobId.current;
|
|
1879
|
+
const jobId = pending.jobId ?? pendingJobId.current ?? null;
|
|
1681
1880
|
// 明确了选择,就别让卸载钩子把它当"未决策"取消掉
|
|
1682
1881
|
pendingJobId.current = null;
|
|
1683
1882
|
setPendingImport(null);
|
|
1684
1883
|
setBusy(true);
|
|
1685
1884
|
setImportResult(null);
|
|
1686
1885
|
try {
|
|
1886
|
+
/* quick 模式:拖进来时只读了 zip 尾部就弹了冲突窗,文件还没上传。
|
|
1887
|
+
* 现在用户确认了,才真正把文件传上去。 */
|
|
1888
|
+
let file = pending.file;
|
|
1889
|
+
if (!file && pending.pendingFile) {
|
|
1890
|
+
setPhase(t('import.uploading'));
|
|
1891
|
+
const uploaded = await uploadZip(pending.pendingFile);
|
|
1892
|
+
setZipPath(uploaded.file);
|
|
1893
|
+
file = uploaded.file;
|
|
1894
|
+
setPhase(t('import.checking'));
|
|
1895
|
+
// 服务端 dryRun 一次,把待决策任务登记上(这样切走也能取消)
|
|
1896
|
+
const preview = await doImport({ file, dryRun: true });
|
|
1897
|
+
pendingJobId.current = preview?.jobId ?? null;
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
setPhase('');
|
|
1687
1901
|
const result = await doImport({
|
|
1688
|
-
file
|
|
1902
|
+
file,
|
|
1689
1903
|
dryRun: false,
|
|
1690
1904
|
restoreData: true,
|
|
1691
1905
|
linkNodeModules: true,
|
|
1692
1906
|
decisions,
|
|
1693
1907
|
// 复用同一个任务记录,界面上是同一条从"待决策"变成"完成"
|
|
1694
|
-
...(jobId ? { jobId } : {}),
|
|
1908
|
+
...(jobId || pendingJobId.current ? { jobId: jobId ?? pendingJobId.current } : {}),
|
|
1695
1909
|
});
|
|
1910
|
+
pendingJobId.current = null;
|
|
1696
1911
|
setImportResult(result);
|
|
1697
1912
|
await load();
|
|
1698
1913
|
await refreshJobs();
|
|
1699
1914
|
} catch (err) {
|
|
1700
1915
|
setImportResult({ error: String(err?.message ?? err) });
|
|
1701
1916
|
} finally {
|
|
1917
|
+
setPhase('');
|
|
1702
1918
|
setBusy(false);
|
|
1703
1919
|
}
|
|
1704
1920
|
};
|
|
@@ -2003,7 +2219,7 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
2003
2219
|
const importPage = h(
|
|
2004
2220
|
'div',
|
|
2005
2221
|
null,
|
|
2006
|
-
h(DropZone, { onFile: runDrop, busy, dropRef: lastDrop }),
|
|
2222
|
+
h(DropZone, { onFile: runDrop, busy, dropRef: lastDrop, phase }),
|
|
2007
2223
|
// 后台任务:切走画面回来能看到刚才那次导入的结果
|
|
2008
2224
|
h(JobList, { jobs, onClear: clearJobHistory, t }),
|
|
2009
2225
|
|