@shellraining/dsh-plugin-updates 0.1.1 → 0.1.2
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 +5 -1
- package/lib/client.js +119 -35
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,7 +21,11 @@ dsh plugin --profile web add @shellraining/dsh-plugin-updates
|
|
|
21
21
|
|
|
22
22
|
插件不改 profile 文件,所有改动都调用宿主插件管理器的接口:先卸载旧版本,再安装 `包名@版本`,用精确版本号保存,并保留原来的启用状态。版本兼容检查、构建脚本审批和锁文件回滚都由宿主处理。
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
操作前会重新读取安装状态,已经是目标版本时跳过;精确版本声明与实际安装版本不一致时,会停止更新,提示先修复安装。卸载失败后不会继续安装,也不会断言安装状态完全没变。
|
|
25
|
+
|
|
26
|
+
卸载后安装失败时,已安装列表下方会保留完整错误、日志路径和「恢复」按钮,按原来的版本重新安装;恢复失败后可以再次重试。只有确认卸载成功后才提供恢复,不会覆盖随后通过其他入口安装的版本。错误摘要优先显示错误行,不会把 pnpm 开头的成功检查信息当成失败原因。
|
|
27
|
+
|
|
28
|
+
发布不足 24 小时的版本会标为「新发布」,pnpm 开启 `minimumReleaseAge` 时,这类版本可能装不上。如果出现 `ERR_PNPM_RESOLUTION_POLICY_VIOLATIONS_UNHANDLED`,说明 pnpm 的策略处理发生内部错误,不代表供应链校验通过就能继续安装;插件会停止操作并保留日志,不会关闭安全策略。
|
|
25
29
|
|
|
26
30
|
本插件不能在页面里更新自己,因为卸载自己时界面也会一起卸载。有新版本时,它的详情页会给出对应的 `dsh plugin` 命令,退出 DSH 后在终端运行即可。
|
|
27
31
|
|
package/lib/client.js
CHANGED
|
@@ -1289,13 +1289,19 @@ var ZH = {
|
|
|
1289
1289
|
"self.body": "\u5728\u9875\u9762\u91CC\u5378\u8F7D\u81EA\u5DF1\u4F1A\u8BA9\u8FD9\u4E2A\u754C\u9762\u4E00\u8D77\u6D88\u5931\u3002\u8BF7\u5148\u5B8C\u5168\u9000\u51FA DSH\uFF0C\u5728\u7EC8\u7AEF\u8FD0\u884C\u4E0B\u9762\u7684\u547D\u4EE4\uFF0C\u518D\u91CD\u65B0\u6253\u5F00\uFF1A",
|
|
1290
1290
|
"self.copy": "\u590D\u5236\u547D\u4EE4",
|
|
1291
1291
|
"self.copied": "\u5DF2\u590D\u5236",
|
|
1292
|
+
"error.policy": "pnpm \u5904\u7406 minimumReleaseAge \u65F6\u53D1\u751F\u5185\u90E8\u9519\u8BEF\u3002\u8BF7\u5347\u7EA7\u5BBF\u4E3B\u9644\u5E26\u7684 pnpm\uFF0C\u6216\u7B49\u53D1\u5E03\u7B49\u5F85\u671F\u7ED3\u675F\u540E\u91CD\u8BD5\uFF1B\u5B8C\u6574\u9519\u8BEF\u89C1\u4E0B\u65B9\u65E5\u5FD7",
|
|
1293
|
+
"error.inconsistent": "\u5B89\u88C5\u58F0\u660E\u4E0E\u5B9E\u9645\u7248\u672C\u4E0D\u4E00\u81F4\uFF0C\u8BF7\u5148\u7528\u5BBF\u4E3B\u63D2\u4EF6\u7BA1\u7406\u5668\u6216 CLI \u4FEE\u590D\u5B89\u88C5\uFF0C\u518D\u91CD\u8BD5\u66F4\u65B0",
|
|
1294
|
+
"error.stateChanged": "\u63D2\u4EF6\u5B89\u88C5\u72B6\u6001\u5DF2\u53D8\u5316\uFF0C\u8BF7\u5237\u65B0\u63D2\u4EF6\u5217\u8868\u540E\u91CD\u8BD5",
|
|
1295
|
+
"op.checking": "\u6B63\u5728\u6838\u5BF9 {name} \u7684\u5B89\u88C5\u72B6\u6001\u2026",
|
|
1296
|
+
"op.checkFailed": "\u65E0\u6CD5\u786E\u8BA4 {name} \u7684\u5B89\u88C5\u72B6\u6001\uFF0C\u5DF2\u505C\u6B62\u64CD\u4F5C\uFF1A{reason}",
|
|
1297
|
+
"op.current": "{name} \u5DF2\u662F {to}\uFF0C\u65E0\u9700\u91CD\u590D\u5B89\u88C5",
|
|
1292
1298
|
"op.removing": "\u6B63\u5728\u5378\u8F7D {name} {from}\u2026",
|
|
1293
1299
|
"op.installing": "\u6B63\u5728\u5B89\u88C5 {name} {to}\u2026",
|
|
1294
1300
|
"op.restoring": "\u6B63\u5728\u6062\u590D {name} {to}\u2026",
|
|
1295
1301
|
"op.done": "{name} \u5DF2\u5207\u6362\u5230 {to}",
|
|
1296
1302
|
"op.restart": "{name} \u5DF2\u5207\u6362\u5230 {to}\uFF0C\u91CD\u542F DSH \u540E\u751F\u6548",
|
|
1297
1303
|
"op.failed": "{name} \u5B89\u88C5 {to} \u5931\u8D25\uFF1A{reason}",
|
|
1298
|
-
"op.removeFailed": "{name} \u5378\u8F7D\
|
|
1304
|
+
"op.removeFailed": "{name} \u5378\u8F7D\u672A\u5B8C\u6210\uFF0C\u5DF2\u505C\u6B62\u540E\u7EED\u5B89\u88C5\uFF1A{reason}",
|
|
1299
1305
|
"op.restore": "\u6062\u590D {from}",
|
|
1300
1306
|
"op.dismiss": "\u5173\u95ED",
|
|
1301
1307
|
"op.log": "\u65E5\u5FD7\uFF1A{path}"
|
|
@@ -1325,13 +1331,19 @@ var EN = {
|
|
|
1325
1331
|
"self.body": "Uninstalling it from the page would unload this page too. Quit DSH completely, run this command in a terminal, then start DSH again:",
|
|
1326
1332
|
"self.copy": "Copy command",
|
|
1327
1333
|
"self.copied": "Copied",
|
|
1334
|
+
"error.policy": "pnpm hit an internal error while handling minimumReleaseAge. Upgrade the pnpm bundled with the host, or retry after the release-age waiting period; see the full error below",
|
|
1335
|
+
"error.inconsistent": "The declared and installed versions differ. Repair the installation with the host plugin manager or CLI before retrying",
|
|
1336
|
+
"error.stateChanged": "The installed state has changed. Refresh the plugin list and retry",
|
|
1337
|
+
"op.checking": "Checking the installed version of {name}\u2026",
|
|
1338
|
+
"op.checkFailed": "Could not confirm the installed state of {name}; stopped: {reason}",
|
|
1339
|
+
"op.current": "{name} is already at {to}; no reinstall needed",
|
|
1328
1340
|
"op.removing": "Uninstalling {name} {from}\u2026",
|
|
1329
1341
|
"op.installing": "Installing {name} {to}\u2026",
|
|
1330
1342
|
"op.restoring": "Restoring {name} {to}\u2026",
|
|
1331
1343
|
"op.done": "{name} is now at {to}",
|
|
1332
1344
|
"op.restart": "{name} is now at {to}; restart DSH to apply it",
|
|
1333
1345
|
"op.failed": "Could not install {name} {to}: {reason}",
|
|
1334
|
-
"op.removeFailed": "
|
|
1346
|
+
"op.removeFailed": "Uninstalling {name} did not finish; installation was not started: {reason}",
|
|
1335
1347
|
"op.restore": "Restore {from}",
|
|
1336
1348
|
"op.dismiss": "Dismiss",
|
|
1337
1349
|
"op.log": "Log: {path}"
|
|
@@ -1354,7 +1366,7 @@ function updateTarget(candidate, lookup) {
|
|
|
1354
1366
|
return best !== void 0 && (0, import_gt.default)(best.version, candidate.version) ? best.version : void 0;
|
|
1355
1367
|
}
|
|
1356
1368
|
function isBusy(operation) {
|
|
1357
|
-
return operation?.phase === "removing" || operation?.phase === "installing";
|
|
1369
|
+
return operation?.phase === "checking" || operation?.phase === "removing" || operation?.phase === "installing";
|
|
1358
1370
|
}
|
|
1359
1371
|
function record(value) {
|
|
1360
1372
|
return typeof value === "object" && value !== null ? value : {};
|
|
@@ -1362,6 +1374,14 @@ function record(value) {
|
|
|
1362
1374
|
function errorText(error) {
|
|
1363
1375
|
return error instanceof Error ? error.message : String(error);
|
|
1364
1376
|
}
|
|
1377
|
+
function failureReason(result) {
|
|
1378
|
+
if (!result.ok) return result.error.message;
|
|
1379
|
+
const { error, packageResult, application } = result.value;
|
|
1380
|
+
const diagnostic = error?.diagnostic?.trim();
|
|
1381
|
+
const lines = (diagnostic || packageResult?.output || "").split("\n").map((line) => line.trim()).filter(Boolean);
|
|
1382
|
+
const failure = lines.find((line) => /\bERR[_!\s]|\berror\b|\bfatal\b/i.test(line));
|
|
1383
|
+
return failure ?? error?.code ?? diagnostic ?? application;
|
|
1384
|
+
}
|
|
1365
1385
|
function npmInstalled(bundle) {
|
|
1366
1386
|
const prefix = `${bundle.name}@`;
|
|
1367
1387
|
return bundle.installed && !bundle.official && bundle.availability === "profile" && bundle.removable && bundle.readOnlyReason === void 0 && (0, import_valid.default)(bundle.version ?? "") !== null && bundle.source?.startsWith(prefix) === true && (0, import_valid2.default)(bundle.source.slice(prefix.length)) !== null;
|
|
@@ -1422,6 +1442,13 @@ var UpdatesStore = class {
|
|
|
1422
1442
|
if (this.state.status !== "idle") void this.load();
|
|
1423
1443
|
};
|
|
1424
1444
|
async load() {
|
|
1445
|
+
try {
|
|
1446
|
+
await this.loadBundles();
|
|
1447
|
+
} catch {
|
|
1448
|
+
if (!this.disposed) this.patch({ status: "error" });
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
async loadBundles() {
|
|
1425
1452
|
const generation = ++this.generation;
|
|
1426
1453
|
this.patch({ status: this.state.status === "ready" ? "ready" : "loading" });
|
|
1427
1454
|
const pm = this.ctx.remote.pluginManager;
|
|
@@ -1495,14 +1522,14 @@ var UpdatesStore = class {
|
|
|
1495
1522
|
/** Switch one plugin to `version`: uninstall it, then install that exact version with its enabled state. */
|
|
1496
1523
|
install = async (name2, version) => {
|
|
1497
1524
|
const candidate = this.state.candidates.find((item) => item.name === name2);
|
|
1498
|
-
if (candidate === void 0 || name2 === SELF || isBusy(this.state.operation)) return false;
|
|
1525
|
+
if (this.disposed || candidate === void 0 || name2 === SELF || isBusy(this.state.operation) || (0, import_valid.default)(version) === null) return false;
|
|
1499
1526
|
return this.run({ name: name2, from: candidate.version, to: version, enabled: candidate.enabled, restoring: false }, true);
|
|
1500
1527
|
};
|
|
1501
1528
|
/** Put back the version a failed switch removed; the plugin is no longer installed, so only install. */
|
|
1502
1529
|
restore = async () => {
|
|
1503
1530
|
const failed = this.state.operation;
|
|
1504
|
-
if (failed?.phase !== "failed" || failed.
|
|
1505
|
-
await this.run({ name: failed.name, from: failed.
|
|
1531
|
+
if (this.disposed || failed?.phase !== "failed" || !failed.recoverable) return;
|
|
1532
|
+
await this.run({ name: failed.name, from: failed.from, to: failed.from, enabled: failed.enabled, restoring: true }, false);
|
|
1506
1533
|
};
|
|
1507
1534
|
/** The outcome toast finished; the banner in the tab keeps the result. */
|
|
1508
1535
|
toastDone = () => {
|
|
@@ -1525,34 +1552,75 @@ var UpdatesStore = class {
|
|
|
1525
1552
|
};
|
|
1526
1553
|
async run(base, remove) {
|
|
1527
1554
|
const pm = this.ctx.remote.pluginManager;
|
|
1528
|
-
|
|
1555
|
+
let failurePhase = "check-failed";
|
|
1556
|
+
let recoverable = false;
|
|
1557
|
+
const fail = (result) => {
|
|
1529
1558
|
const value = result.ok ? result.value : void 0;
|
|
1530
|
-
const output = value?.packageResult?.output.trim();
|
|
1559
|
+
const output = value?.packageResult?.output.trim() || value?.error?.diagnostic;
|
|
1531
1560
|
const logPath = value?.packageResult?.logPath;
|
|
1532
|
-
|
|
1533
|
-
|
|
1561
|
+
this.patch({ operation: {
|
|
1562
|
+
...base,
|
|
1563
|
+
phase: failurePhase,
|
|
1564
|
+
recoverable,
|
|
1565
|
+
reason: failureReason(result),
|
|
1566
|
+
toast: Date.now(),
|
|
1567
|
+
...output ? { output } : {},
|
|
1568
|
+
...logPath ? { logPath } : {}
|
|
1569
|
+
} });
|
|
1534
1570
|
return false;
|
|
1535
1571
|
};
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
const
|
|
1539
|
-
|
|
1540
|
-
if (!
|
|
1541
|
-
|
|
1572
|
+
this.patch({ operation: { ...base, phase: "checking" } });
|
|
1573
|
+
try {
|
|
1574
|
+
const listed = await pm.listBundles();
|
|
1575
|
+
if (this.disposed) return false;
|
|
1576
|
+
if (!listed.ok) return fail(listed);
|
|
1577
|
+
const current = listed.value.find((bundle) => bundle.name === base.name && bundle.installed);
|
|
1578
|
+
if (current !== void 0 && (0, import_valid.default)(current.version ?? "") === (0, import_valid.default)(base.to)) {
|
|
1579
|
+
this.patch({ operation: { ...base, phase: "current", toast: Date.now() } });
|
|
1580
|
+
return true;
|
|
1542
1581
|
}
|
|
1582
|
+
if (remove) {
|
|
1583
|
+
if (current === void 0 || !npmInstalled(current)) {
|
|
1584
|
+
return fail({ ok: true, value: { application: "failed", error: { code: "plugin-state-changed" } } });
|
|
1585
|
+
}
|
|
1586
|
+
const declared = (0, import_valid.default)(current.source?.slice(`${base.name}@`.length) ?? "");
|
|
1587
|
+
if (declared !== null && declared !== (0, import_valid.default)(current.version ?? "")) {
|
|
1588
|
+
return fail({ ok: true, value: { application: "failed", error: { code: "installation-inconsistent" } } });
|
|
1589
|
+
}
|
|
1590
|
+
base = { ...base, from: current.version ?? base.from, enabled: current.enabled };
|
|
1591
|
+
failurePhase = "remove-failed";
|
|
1592
|
+
this.patch({ operation: { ...base, phase: "removing" } });
|
|
1593
|
+
const removed = await pm.removeBundle(base.name);
|
|
1594
|
+
const stopProfile = removed.ok && removed.value.error?.code === "stop-profile";
|
|
1595
|
+
if (!stopProfile && (!removed.ok || removed.value.application === "failed" || removed.value.application === "cancelled")) {
|
|
1596
|
+
return fail(removed);
|
|
1597
|
+
}
|
|
1598
|
+
recoverable = !stopProfile;
|
|
1599
|
+
} else {
|
|
1600
|
+
if (current !== void 0) {
|
|
1601
|
+
return fail({ ok: true, value: { application: "failed", error: { code: "plugin-state-changed" } } });
|
|
1602
|
+
}
|
|
1603
|
+
recoverable = true;
|
|
1604
|
+
}
|
|
1605
|
+
if (this.disposed) return false;
|
|
1606
|
+
failurePhase = "failed";
|
|
1607
|
+
this.patch({ operation: { ...base, phase: "installing" } });
|
|
1608
|
+
const installed = await pm.installBundle(`${base.name}@${base.to}`, {
|
|
1609
|
+
enabled: base.enabled,
|
|
1610
|
+
saveExact: true,
|
|
1611
|
+
requestId: `${SELF}:${Date.now()}`
|
|
1612
|
+
});
|
|
1613
|
+
if (!installed.ok || installed.value.application === "failed" || installed.value.application === "cancelled") {
|
|
1614
|
+
return fail(installed);
|
|
1615
|
+
}
|
|
1616
|
+
const to = installed.value.version ?? base.to;
|
|
1617
|
+
this.patch({ operation: { ...base, to, toast: Date.now(), phase: installed.value.application === "restart-required" ? "restart" : "done" } });
|
|
1618
|
+
return true;
|
|
1619
|
+
} catch (error) {
|
|
1620
|
+
return fail({ ok: false, error: { message: errorText(error) } });
|
|
1621
|
+
} finally {
|
|
1622
|
+
if (!this.disposed) await this.load();
|
|
1543
1623
|
}
|
|
1544
|
-
this.patch({ operation: { ...base, phase: "installing" } });
|
|
1545
|
-
const installed = await pm.installBundle(`${base.name}@${base.to}`, {
|
|
1546
|
-
enabled: base.enabled,
|
|
1547
|
-
saveExact: true,
|
|
1548
|
-
requestId: `${SELF}:${Date.now()}`
|
|
1549
|
-
});
|
|
1550
|
-
if (!installed.ok || installed.value.application === "failed" || installed.value.application === "cancelled") {
|
|
1551
|
-
return fail("failed", installed);
|
|
1552
|
-
}
|
|
1553
|
-
const to = installed.value.version ?? base.to;
|
|
1554
|
-
this.patch({ operation: { ...base, to, toast: Date.now(), phase: installed.value.application === "restart-required" ? "restart" : "done" } });
|
|
1555
|
-
return true;
|
|
1556
1624
|
}
|
|
1557
1625
|
};
|
|
1558
1626
|
|
|
@@ -1618,10 +1686,11 @@ function scan(document2) {
|
|
|
1618
1686
|
const end = card.querySelector('[role="switch"]')?.parentElement;
|
|
1619
1687
|
if (name2 && end) cards.set(name2, mountIn(end, "card", true));
|
|
1620
1688
|
}
|
|
1621
|
-
|
|
1689
|
+
const status = mountIn(group, "status", false);
|
|
1690
|
+
return { ...head === void 0 ? {} : { head }, status, cards };
|
|
1622
1691
|
}
|
|
1623
1692
|
function same(a, b) {
|
|
1624
|
-
if (a.head !== b.head || a.cards.size !== b.cards.size) return false;
|
|
1693
|
+
if (a.head !== b.head || a.status !== b.status || a.cards.size !== b.cards.size) return false;
|
|
1625
1694
|
for (const [name2, node] of a.cards) if (b.cards.get(name2) !== node) return false;
|
|
1626
1695
|
return true;
|
|
1627
1696
|
}
|
|
@@ -1675,8 +1744,16 @@ function SelfNotice({ t, version }) {
|
|
|
1675
1744
|
] });
|
|
1676
1745
|
}
|
|
1677
1746
|
function operationText(t, operation) {
|
|
1678
|
-
const
|
|
1747
|
+
const raw = operation.reason ?? "";
|
|
1748
|
+
const reason = raw.includes("ERR_PNPM_RESOLUTION_POLICY_VIOLATIONS_UNHANDLED") ? t("error.policy") : raw === "installation-inconsistent" ? t("error.inconsistent") : raw === "plugin-state-changed" ? t("error.stateChanged") : raw;
|
|
1749
|
+
const params = { name: operation.name, from: operation.from, to: operation.to, reason };
|
|
1679
1750
|
switch (operation.phase) {
|
|
1751
|
+
case "checking":
|
|
1752
|
+
return t("op.checking", params);
|
|
1753
|
+
case "check-failed":
|
|
1754
|
+
return t("op.checkFailed", params);
|
|
1755
|
+
case "current":
|
|
1756
|
+
return t("op.current", params);
|
|
1680
1757
|
case "removing":
|
|
1681
1758
|
return t("op.removing", params);
|
|
1682
1759
|
case "installing":
|
|
@@ -1694,8 +1771,8 @@ function operationText(t, operation) {
|
|
|
1694
1771
|
function OperationToast({ t, useUpdates, actions }) {
|
|
1695
1772
|
const operation = useUpdates((state) => state.operation);
|
|
1696
1773
|
if (operation?.toast === void 0) return null;
|
|
1697
|
-
const failed = operation.phase === "failed" || operation.phase === "remove-failed";
|
|
1698
|
-
const restorable = operation.phase === "failed" &&
|
|
1774
|
+
const failed = operation.phase === "failed" || operation.phase === "remove-failed" || operation.phase === "check-failed";
|
|
1775
|
+
const restorable = operation.phase === "failed" && operation.recoverable;
|
|
1699
1776
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1700
1777
|
import_dsh_client_ui_primitives.Toast,
|
|
1701
1778
|
{
|
|
@@ -1713,12 +1790,12 @@ function OperationToast({ t, useUpdates, actions }) {
|
|
|
1713
1790
|
function OperationBanner({ t, operation, actions }) {
|
|
1714
1791
|
const params = { from: operation.from };
|
|
1715
1792
|
const busy = isBusy(operation);
|
|
1716
|
-
const failed = operation.phase === "failed" || operation.phase === "remove-failed";
|
|
1793
|
+
const failed = operation.phase === "failed" || operation.phase === "remove-failed" || operation.phase === "check-failed";
|
|
1717
1794
|
const text = operationText(t, operation);
|
|
1718
1795
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dpu-banner", role: failed ? "alert" : "status", ...failed ? { "data-tone": "error" } : {}, children: [
|
|
1719
1796
|
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dpu-banner-row", children: [
|
|
1720
1797
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: text }),
|
|
1721
|
-
operation.phase === "failed" &&
|
|
1798
|
+
operation.phase === "failed" && operation.recoverable ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.Button, { size: "sm", variant: "outline", onClick: () => {
|
|
1722
1799
|
void actions.restore();
|
|
1723
1800
|
}, children: t("op.restore", params) }) : null,
|
|
1724
1801
|
busy ? null : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.Button, { size: "sm", variant: "ghost", onClick: actions.dismiss, children: t("op.dismiss") })
|
|
@@ -1826,6 +1903,13 @@ function ListControls({ t, useUpdates, actions }) {
|
|
|
1826
1903
|
if (target !== void 0 && candidate.name !== SELF) targets.set(candidate.name, target);
|
|
1827
1904
|
}
|
|
1828
1905
|
const portals = [];
|
|
1906
|
+
if (anchors.status !== void 0 && operation !== void 0) {
|
|
1907
|
+
portals.push((0, import_react_dom.createPortal)(
|
|
1908
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(OperationBanner, { t, operation, actions }),
|
|
1909
|
+
anchors.status,
|
|
1910
|
+
"operation"
|
|
1911
|
+
));
|
|
1912
|
+
}
|
|
1829
1913
|
if (anchors.head !== void 0 && targets.size > 0) {
|
|
1830
1914
|
portals.push((0, import_react_dom.createPortal)(
|
|
1831
1915
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.Button, { size: "sm", variant: "outline", icon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives2.IconDownloadOutlineRegular, { size: 13 }), disabled: busy, onClick: () => {
|