feat(ui): add manual update commands and simplify update dialog

This commit is contained in:
xmanrui 2026-08-28 15:32:09 +08:00
parent 45c0a7f77f
commit 57eef234b1
13 changed files with 1050 additions and 41 deletions

View file

@ -6,6 +6,11 @@ This file records the notable changes in each dsh-im release. Its format follows
## [Unreleased]
### Added / 新增
- 更新窗口新增手工更新命令及一键复制,页面更新失败时可在相同 Harness / Desktop 环境中通过 npm 更新;复制受限时可选中文本手动复制,安装完成后仍需手动重启。
Added a manual npm update command and copy button to the update dialog for use in the same Harness / Desktop environment when the in-page update fails. The command remains selectable if clipboard access fails, and installation still requires a manual restart.
## [3.1.1] - 2026-08-28
### Fixed / 修复

View file

@ -147,6 +147,8 @@ The button reuses Desktop's package-management service or the current Harness CL
dsh plugin --profile web add -w --save-exact @xmanrui/dsh-im@3.1.0 --registry=https://registry.npmjs.org/
```
The **Manual update** section at the bottom of the dialog generates a short command for the current profile, such as `dsh plugin --profile web add -w @xmanrui/dsh-im@3.1.1`. Click the copy icon at the far right of the command, then run it in a terminal. It requests a known target version; otherwise, `@latest` resolves the version from npm when executed. The manual command uses your local npm registry configuration without fetching GitHub; the install button still forces the official registry and saves an exact version. If clipboard access fails, select and copy the command manually. For Desktop, use the current Desktop's built-in terminal. For Web, use the environment that started the current Harness and preserve the same `DSH_HOME`. If a restart is already pending, restarting is usually enough without another installation. No potentially destructive command is generated for source links or profiles that cannot be safely identified.
Source `link:`, `file:`, Git, and unrecognized installations can check versions but are never replaced automatically. Confirm the intended profile before manually migrating to npm. Conflicting scoped registries, incompatible Node versions, and unavailable Host executors disable installation with an explanation. Standard Windows CLI installations currently require a manual update; Desktop uses its existing executor.
Do not modify the same profile through a terminal or plugin market during installation. A failed command may leave partial dependency changes; it is not an automatic rollback. Inspect the installation, reinstall the previous exact version if needed, and restart manually. The updater keeps only the profile's latest job and manifest backup under the current `DSH_HOME/updates/dsh-im`, without copying bot credentials. Resolve uncertain remaining installers or locks before retrying; do not blindly delete a lock.

View file

@ -150,6 +150,8 @@ dsh web
dsh plugin --profile web add -w --save-exact @xmanrui/dsh-im@3.1.0 --registry=https://registry.npmjs.org/
```
更新窗口下方的「手工更新」会按当前 profile 生成精简命令,例如 `dsh plugin --profile web add -w @xmanrui/dsh-im@3.1.1`,点击命令最右侧的复制图标后可在终端执行。已知目标版本时指定该版本;尚未查到版本时使用 `@latest`,以执行时 npm 返回的版本为准。手工命令沿用本机 npm 源配置,不拉取 GitHub;按钮安装仍固定使用官方源并保存精确版本。浏览器无法复制时可选中文本手动复制。Desktop 请使用当前 Desktop 的内置终端;Web 请使用启动当前 Harness 的环境并保持相同 `DSH_HOME`。如果已经提示「待手动重启」,通常只需重启,无需再次安装。源码链接或无法安全确认的 profile 不生成可能覆盖安装的命令。
源码 `link:`、`file:`、Git 来源或无法确认归属的安装只提供版本检查,不会替换开发链接;如需迁移为 npm 安装,请自行确认对应 profile。作用域 registry 冲突、Node 版本不满足要求或缺少当前 Host 的执行器时,按钮会说明原因。标准 Windows CLI 暂需手动更新;Desktop 使用其原有执行器。
安装期间不要同时在终端或插件市场修改该 profile。失败可能已经改变部分依赖,不能视为自动回滚;先检查安装状态,必要时按上述命令重装原精确版本,再手动重启。更新器只在当前 `DSH_HOME/updates/dsh-im` 下保存该 profile 最近一次任务与清单备份,不复制机器人凭据;残留安装进程或锁状态不明确时,请先人工确认,不要盲目重试或删除锁。

View file

@ -9,6 +9,9 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __commonJS = (cb, mod) => function __require() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
var __export = (target, all) => {
for (var name2 in all)
__defProp(target, name2, { get: all[name2], enumerable: true });
@ -31,6 +34,527 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// node_modules/semver/internal/debug.js
var require_debug = __commonJS({
"node_modules/semver/internal/debug.js"(exports, module2) {
"use strict";
var debug = typeof process === "object" && process.env && process.env.NODE_DEBUG && /\bsemver\b/i.test(process.env.NODE_DEBUG) ? (...args) => console.error("SEMVER", ...args) : () => {
};
module2.exports = debug;
}
});
// node_modules/semver/internal/constants.js
var require_constants = __commonJS({
"node_modules/semver/internal/constants.js"(exports, module2) {
"use strict";
var SEMVER_SPEC_VERSION = "2.0.0";
var MAX_LENGTH = 256;
var MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER || /* istanbul ignore next */
9007199254740991;
var MAX_SAFE_COMPONENT_LENGTH = 16;
var MAX_SAFE_BUILD_LENGTH = MAX_LENGTH - 6;
var RELEASE_TYPES = [
"major",
"premajor",
"minor",
"preminor",
"patch",
"prepatch",
"prerelease"
];
module2.exports = {
MAX_LENGTH,
MAX_SAFE_COMPONENT_LENGTH,
MAX_SAFE_BUILD_LENGTH,
MAX_SAFE_INTEGER,
RELEASE_TYPES,
SEMVER_SPEC_VERSION,
FLAG_INCLUDE_PRERELEASE: 1,
FLAG_LOOSE: 2
};
}
});
// node_modules/semver/internal/re.js
var require_re = __commonJS({
"node_modules/semver/internal/re.js"(exports, module2) {
"use strict";
var {
MAX_SAFE_COMPONENT_LENGTH,
MAX_SAFE_BUILD_LENGTH,
MAX_LENGTH
} = require_constants();
var debug = require_debug();
exports = module2.exports = {};
var re = exports.re = [];
var safeRe = exports.safeRe = [];
var src = exports.src = [];
var safeSrc = exports.safeSrc = [];
var t = exports.t = {};
var R = 0;
var LETTERDASHNUMBER = "[a-zA-Z0-9-]";
var safeRegexReplacements = [
["\\s", 1],
["\\d", MAX_LENGTH],
[LETTERDASHNUMBER, MAX_SAFE_BUILD_LENGTH]
];
var makeSafeRegex = (value) => {
for (const [token, max] of safeRegexReplacements) {
value = value.split(`${token}*`).join(`${token}{0,${max}}`).split(`${token}+`).join(`${token}{1,${max}}`);
}
return value;
};
var createToken = (name2, value, isGlobal) => {
const safe = makeSafeRegex(value);
const index = R++;
debug(name2, index, value);
t[name2] = index;
src[index] = value;
safeSrc[index] = safe;
re[index] = new RegExp(value, isGlobal ? "g" : void 0);
safeRe[index] = new RegExp(safe, isGlobal ? "g" : void 0);
};
createToken("NUMERICIDENTIFIER", "0|[1-9]\\d*");
createToken("NUMERICIDENTIFIERLOOSE", "\\d+");
createToken("NONNUMERICIDENTIFIER", `\\d*[a-zA-Z-]${LETTERDASHNUMBER}*`);
createToken("MAINVERSION", `(${src[t.NUMERICIDENTIFIER]})\\.(${src[t.NUMERICIDENTIFIER]})\\.(${src[t.NUMERICIDENTIFIER]})`);
createToken("MAINVERSIONLOOSE", `(${src[t.NUMERICIDENTIFIERLOOSE]})\\.(${src[t.NUMERICIDENTIFIERLOOSE]})\\.(${src[t.NUMERICIDENTIFIERLOOSE]})`);
createToken("PRERELEASEIDENTIFIER", `(?:${src[t.NONNUMERICIDENTIFIER]}|${src[t.NUMERICIDENTIFIER]})`);
createToken("PRERELEASEIDENTIFIERLOOSE", `(?:${src[t.NONNUMERICIDENTIFIER]}|${src[t.NUMERICIDENTIFIERLOOSE]})`);
createToken("PRERELEASE", `(?:-(${src[t.PRERELEASEIDENTIFIER]}(?:\\.${src[t.PRERELEASEIDENTIFIER]})*))`);
createToken("PRERELEASELOOSE", `(?:-?(${src[t.PRERELEASEIDENTIFIERLOOSE]}(?:\\.${src[t.PRERELEASEIDENTIFIERLOOSE]})*))`);
createToken("BUILDIDENTIFIER", `${LETTERDASHNUMBER}+`);
createToken("BUILD", `(?:\\+(${src[t.BUILDIDENTIFIER]}(?:\\.${src[t.BUILDIDENTIFIER]})*))`);
createToken("FULLPLAIN", `v?${src[t.MAINVERSION]}${src[t.PRERELEASE]}?${src[t.BUILD]}?`);
createToken("FULL", `^${src[t.FULLPLAIN]}$`);
createToken("LOOSEPLAIN", `[v=\\s]*${src[t.MAINVERSIONLOOSE]}${src[t.PRERELEASELOOSE]}?${src[t.BUILD]}?`);
createToken("LOOSE", `^${src[t.LOOSEPLAIN]}$`);
createToken("GTLT", "((?:<|>)?=?)");
createToken("XRANGEIDENTIFIERLOOSE", `${src[t.NUMERICIDENTIFIERLOOSE]}|x|X|\\*`);
createToken("XRANGEIDENTIFIER", `${src[t.NUMERICIDENTIFIER]}|x|X|\\*`);
createToken("XRANGEPLAIN", `[v=\\s]*(${src[t.XRANGEIDENTIFIER]})(?:\\.(${src[t.XRANGEIDENTIFIER]})(?:\\.(${src[t.XRANGEIDENTIFIER]})(?:${src[t.PRERELEASE]})?${src[t.BUILD]}?)?)?`);
createToken("XRANGEPLAINLOOSE", `[v=\\s]*(${src[t.XRANGEIDENTIFIERLOOSE]})(?:\\.(${src[t.XRANGEIDENTIFIERLOOSE]})(?:\\.(${src[t.XRANGEIDENTIFIERLOOSE]})(?:${src[t.PRERELEASELOOSE]})?${src[t.BUILD]}?)?)?`);
createToken("XRANGE", `^${src[t.GTLT]}\\s*${src[t.XRANGEPLAIN]}$`);
createToken("XRANGELOOSE", `^${src[t.GTLT]}\\s*${src[t.XRANGEPLAINLOOSE]}$`);
createToken("COERCEPLAIN", `${"(^|[^\\d])(\\d{1,"}${MAX_SAFE_COMPONENT_LENGTH}})(?:\\.(\\d{1,${MAX_SAFE_COMPONENT_LENGTH}}))?(?:\\.(\\d{1,${MAX_SAFE_COMPONENT_LENGTH}}))?`);
createToken("COERCE", `${src[t.COERCEPLAIN]}(?:$|[^\\d])`);
createToken("COERCEFULL", src[t.COERCEPLAIN] + `(?:${src[t.PRERELEASE]})?(?:${src[t.BUILD]})?(?:$|[^\\d])`);
createToken("COERCERTL", src[t.COERCE], true);
createToken("COERCERTLFULL", src[t.COERCEFULL], true);
createToken("LONETILDE", "(?:~>?)");
createToken("TILDETRIM", `(\\s*)${src[t.LONETILDE]}\\s+`, true);
exports.tildeTrimReplace = "$1~";
createToken("TILDE", `^${src[t.LONETILDE]}${src[t.XRANGEPLAIN]}$`);
createToken("TILDELOOSE", `^${src[t.LONETILDE]}${src[t.XRANGEPLAINLOOSE]}$`);
createToken("LONECARET", "(?:\\^)");
createToken("CARETTRIM", `(\\s*)${src[t.LONECARET]}\\s+`, true);
exports.caretTrimReplace = "$1^";
createToken("CARET", `^${src[t.LONECARET]}${src[t.XRANGEPLAIN]}$`);
createToken("CARETLOOSE", `^${src[t.LONECARET]}${src[t.XRANGEPLAINLOOSE]}$`);
createToken("COMPARATORLOOSE", `^${src[t.GTLT]}\\s*(${src[t.LOOSEPLAIN]})$|^$`);
createToken("COMPARATOR", `^${src[t.GTLT]}\\s*(${src[t.FULLPLAIN]})$|^$`);
createToken("COMPARATORTRIM", `(\\s*)${src[t.GTLT]}\\s*(${src[t.LOOSEPLAIN]}|${src[t.XRANGEPLAIN]})`, true);
exports.comparatorTrimReplace = "$1$2$3";
createToken("HYPHENRANGE", `^\\s*(${src[t.XRANGEPLAIN]})\\s+-\\s+(${src[t.XRANGEPLAIN]})\\s*$`);
createToken("HYPHENRANGELOOSE", `^\\s*(${src[t.XRANGEPLAINLOOSE]})\\s+-\\s+(${src[t.XRANGEPLAINLOOSE]})\\s*$`);
createToken("STAR", "(<|>)?=?\\s*\\*");
createToken("GTE0", "^\\s*>=\\s*0\\.0\\.0\\s*$");
createToken("GTE0PRE", "^\\s*>=\\s*0\\.0\\.0-0\\s*$");
}
});
// node_modules/semver/internal/parse-options.js
var require_parse_options = __commonJS({
"node_modules/semver/internal/parse-options.js"(exports, module2) {
"use strict";
var looseOption = Object.freeze({ loose: true });
var emptyOpts = Object.freeze({});
var parseOptions = (options) => {
if (!options) {
return emptyOpts;
}
if (typeof options !== "object") {
return looseOption;
}
return options;
};
module2.exports = parseOptions;
}
});
// node_modules/semver/internal/identifiers.js
var require_identifiers = __commonJS({
"node_modules/semver/internal/identifiers.js"(exports, module2) {
"use strict";
var numeric = /^[0-9]+$/;
var compareIdentifiers = (a, b) => {
if (typeof a === "number" && typeof b === "number") {
return a === b ? 0 : a < b ? -1 : 1;
}
const anum = numeric.test(a);
const bnum = numeric.test(b);
if (anum && bnum) {
a = +a;
b = +b;
}
return a === b ? 0 : anum && !bnum ? -1 : bnum && !anum ? 1 : a < b ? -1 : 1;
};
var rcompareIdentifiers = (a, b) => compareIdentifiers(b, a);
module2.exports = {
compareIdentifiers,
rcompareIdentifiers
};
}
});
// node_modules/semver/classes/semver.js
var require_semver = __commonJS({
"node_modules/semver/classes/semver.js"(exports, module2) {
"use strict";
var debug = require_debug();
var { MAX_LENGTH, MAX_SAFE_INTEGER } = require_constants();
var { safeRe: re, t } = require_re();
var parseOptions = require_parse_options();
var { compareIdentifiers } = require_identifiers();
var isPrereleaseIdentifier = (prerelease, identifier) => {
const identifiers = identifier.split(".");
if (identifiers.length > prerelease.length) {
return false;
}
for (let i = 0; i < identifiers.length; i++) {
if (compareIdentifiers(prerelease[i], identifiers[i]) !== 0) {
return false;
}
}
return true;
};
var SemVer = class _SemVer {
constructor(version, options) {
options = parseOptions(options);
if (version instanceof _SemVer) {
if (version.loose === !!options.loose && version.includePrerelease === !!options.includePrerelease) {
return version;
} else {
version = version.version;
}
} else if (typeof version !== "string") {
throw new TypeError(`Invalid version. Must be a string. Got type "${typeof version}".`);
}
if (version.length > MAX_LENGTH) {
throw new TypeError(
`version is longer than ${MAX_LENGTH} characters`
);
}
debug("SemVer", version, options);
this.options = options;
this.loose = !!options.loose;
this.includePrerelease = !!options.includePrerelease;
const m = version.trim().match(options.loose ? re[t.LOOSE] : re[t.FULL]);
if (!m) {
throw new TypeError(`Invalid Version: ${version}`);
}
this.raw = version;
this.major = +m[1];
this.minor = +m[2];
this.patch = +m[3];
if (this.major > MAX_SAFE_INTEGER || this.major < 0) {
throw new TypeError("Invalid major version");
}
if (this.minor > MAX_SAFE_INTEGER || this.minor < 0) {
throw new TypeError("Invalid minor version");
}
if (this.patch > MAX_SAFE_INTEGER || this.patch < 0) {
throw new TypeError("Invalid patch version");
}
if (!m[4]) {
this.prerelease = [];
} else {
this.prerelease = m[4].split(".").map((id5) => {
if (/^[0-9]+$/.test(id5)) {
const num = +id5;
if (num >= 0 && num < MAX_SAFE_INTEGER) {
return num;
}
}
return id5;
});
}
this.build = m[5] ? m[5].split(".") : [];
this.format();
}
format() {
this.version = `${this.major}.${this.minor}.${this.patch}`;
if (this.prerelease.length) {
this.version += `-${this.prerelease.join(".")}`;
}
return this.version;
}
toString() {
return this.version;
}
compare(other) {
debug("SemVer.compare", this.version, this.options, other);
if (!(other instanceof _SemVer)) {
if (typeof other === "string" && other === this.version) {
return 0;
}
other = new _SemVer(other, this.options);
}
if (other.version === this.version) {
return 0;
}
return this.compareMain(other) || this.comparePre(other);
}
compareMain(other) {
if (!(other instanceof _SemVer)) {
other = new _SemVer(other, this.options);
}
if (this.major < other.major) {
return -1;
}
if (this.major > other.major) {
return 1;
}
if (this.minor < other.minor) {
return -1;
}
if (this.minor > other.minor) {
return 1;
}
if (this.patch < other.patch) {
return -1;
}
if (this.patch > other.patch) {
return 1;
}
return 0;
}
comparePre(other) {
if (!(other instanceof _SemVer)) {
other = new _SemVer(other, this.options);
}
if (this.prerelease.length && !other.prerelease.length) {
return -1;
} else if (!this.prerelease.length && other.prerelease.length) {
return 1;
} else if (!this.prerelease.length && !other.prerelease.length) {
return 0;
}
let i = 0;
do {
const a = this.prerelease[i];
const b = other.prerelease[i];
debug("prerelease compare", i, a, b);
if (a === void 0 && b === void 0) {
return 0;
} else if (b === void 0) {
return 1;
} else if (a === void 0) {
return -1;
} else if (a === b) {
continue;
} else {
return compareIdentifiers(a, b);
}
} while (++i);
}
compareBuild(other) {
if (!(other instanceof _SemVer)) {
other = new _SemVer(other, this.options);
}
let i = 0;
do {
const a = this.build[i];
const b = other.build[i];
debug("build compare", i, a, b);
if (a === void 0 && b === void 0) {
return 0;
} else if (b === void 0) {
return 1;
} else if (a === void 0) {
return -1;
} else if (a === b) {
continue;
} else {
return compareIdentifiers(a, b);
}
} while (++i);
}
// preminor will bump the version up to the next minor release, and immediately
// down to pre-release. premajor and prepatch work the same way.
inc(release, identifier, identifierBase) {
if (release.startsWith("pre")) {
if (!identifier && identifierBase === false) {
throw new Error("invalid increment argument: identifier is empty");
}
if (identifier) {
const match = `-${identifier}`.match(this.options.loose ? re[t.PRERELEASELOOSE] : re[t.PRERELEASE]);
if (!match || match[1] !== identifier) {
throw new Error(`invalid identifier: ${identifier}`);
}
}
}
switch (release) {
case "premajor":
this.prerelease.length = 0;
this.patch = 0;
this.minor = 0;
this.major++;
this.inc("pre", identifier, identifierBase);
break;
case "preminor":
this.prerelease.length = 0;
this.patch = 0;
this.minor++;
this.inc("pre", identifier, identifierBase);
break;
case "prepatch":
this.prerelease.length = 0;
this.inc("patch", identifier, identifierBase);
this.inc("pre", identifier, identifierBase);
break;
// If the input is a non-prerelease version, this acts the same as
// prepatch.
case "prerelease":
if (this.prerelease.length === 0) {
this.inc("patch", identifier, identifierBase);
}
this.inc("pre", identifier, identifierBase);
break;
case "release":
if (this.prerelease.length === 0) {
throw new Error(`version ${this.raw} is not a prerelease`);
}
this.prerelease.length = 0;
break;
case "major":
if (this.minor !== 0 || this.patch !== 0 || this.prerelease.length === 0) {
this.major++;
}
this.minor = 0;
this.patch = 0;
this.prerelease = [];
break;
case "minor":
if (this.patch !== 0 || this.prerelease.length === 0) {
this.minor++;
}
this.patch = 0;
this.prerelease = [];
break;
case "patch":
if (this.prerelease.length === 0) {
this.patch++;
}
this.prerelease = [];
break;
// This probably shouldn't be used publicly.
// 1.0.0 'pre' would become 1.0.0-0 which is the wrong direction.
case "pre": {
const base = Number(identifierBase) ? 1 : 0;
if (this.prerelease.length === 0) {
this.prerelease = [base];
} else {
let i = this.prerelease.length;
while (--i >= 0) {
if (typeof this.prerelease[i] === "number") {
this.prerelease[i]++;
i = -2;
}
}
if (i === -1) {
if (identifier === this.prerelease.join(".") && identifierBase === false) {
throw new Error("invalid increment argument: identifier already exists");
}
this.prerelease.push(base);
}
}
if (identifier) {
let prerelease = [identifier, base];
if (identifierBase === false) {
prerelease = [identifier];
}
if (isPrereleaseIdentifier(this.prerelease, identifier)) {
const prereleaseBase = this.prerelease[identifier.split(".").length];
if (isNaN(prereleaseBase)) {
this.prerelease = prerelease;
}
} else {
this.prerelease = prerelease;
}
}
break;
}
default:
throw new Error(`invalid increment argument: ${release}`);
}
this.raw = this.format();
if (this.build.length) {
this.raw += `+${this.build.join(".")}`;
}
return this;
}
};
module2.exports = SemVer;
}
});
// node_modules/semver/functions/parse.js
var require_parse = __commonJS({
"node_modules/semver/functions/parse.js"(exports, module2) {
"use strict";
var SemVer = require_semver();
var parse = (version, options, throwErrors = false) => {
if (version instanceof SemVer) {
return version;
}
try {
return new SemVer(version, options);
} catch (er) {
if (!throwErrors) {
return null;
}
throw er;
}
};
module2.exports = parse;
}
});
// node_modules/semver/functions/valid.js
var require_valid = __commonJS({
"node_modules/semver/functions/valid.js"(exports, module2) {
"use strict";
var parse = require_parse();
var valid = (version, options) => {
const v = parse(version, options);
return v ? v.version : null;
};
module2.exports = valid;
}
});
// node_modules/semver/functions/compare.js
var require_compare = __commonJS({
"node_modules/semver/functions/compare.js"(exports, module2) {
"use strict";
var SemVer = require_semver();
var compare = (a, b, loose) => new SemVer(a, loose).compare(new SemVer(b, loose));
module2.exports = compare;
}
});
// node_modules/semver/functions/rcompare.js
var require_rcompare = __commonJS({
"node_modules/semver/functions/rcompare.js"(exports, module2) {
"use strict";
var compare = require_compare();
var rcompare = (a, b, loose) => compare(b, a, loose);
module2.exports = rcompare;
}
});
// plugin-src/client/index.js
var index_exports = {};
__export(index_exports, {
@ -315,17 +839,28 @@ var EN = Object.freeze({
"\u68C0\u67E5\u66F4\u65B0": "Check for updates",
"\u91CD\u65B0\u68C0\u67E5": "Check again",
"\u5237\u65B0\u72B6\u6001": "Refresh status",
"\u624B\u5DE5\u66F4\u65B0": "Manual update",
"\u624B\u5DE5\u66F4\u65B0\u547D\u4EE4": "Manual update command",
"\u590D\u5236\u547D\u4EE4": "Copy command",
"\u590D\u5236\u4E2D\u2026": "Copying\u2026",
"\u5DF2\u590D\u5236": "Copied",
"\u547D\u4EE4\u5DF2\u590D\u5236\u3002": "Command copied.",
"\u590D\u5236\u5931\u8D25\uFF0C\u8BF7\u9009\u4E2D\u547D\u4EE4\u540E\u6309 Ctrl+C \u6216 \u2318C \u590D\u5236\u3002": "Copy failed. Select the command and press Ctrl+C or \u2318C to copy it.",
"\u81EA\u52A8\u66F4\u65B0\u5931\u8D25\u53EF\u4EE5\u4F7F\u7528\u547D\u4EE4\u66F4\u65B0\uFF1A": "If the automatic update fails, update with this command:",
"\u5C1A\u672A\u786E\u8BA4\u76EE\u6807\u7248\u672C\uFF0C\u6B64\u547D\u4EE4\u5B89\u88C5\u6267\u884C\u65F6 npm \u7684 latest \u7248\u672C\u3002": "No target version has been confirmed. This command installs the npm latest version available when you run it.",
"\u8BF7\u5728\u5F53\u524D Desktop \u7684\u5185\u7F6E\u7EC8\u7AEF\u6267\u884C\uFF0C\u5B8C\u6210\u540E\u624B\u52A8\u91CD\u542F\u3002": "Run this in the current Desktop\u2019s built-in terminal, then restart manually.",
"\u8BF7\u5728\u8FD0\u884C\u5F53\u524D Harness \u7684\u540C\u4E00\u73AF\u5883\u6267\u884C\uFF0C\u4FDD\u6301 DSH_HOME \u4E00\u81F4\uFF0C\u5B8C\u6210\u540E\u624B\u52A8\u91CD\u542F\u3002": "Run this in the same environment as the current Harness, with the same DSH_HOME, then restart manually.",
"\u8BF7\u7B49\u5F85\u5F53\u524D\u64CD\u4F5C\u7ED3\u675F\uFF0C\u786E\u8BA4\u6CA1\u6709\u5B89\u88C5\u8FDB\u7A0B\u8FD0\u884C\u540E\u518D\u6267\u884C\u547D\u4EE4\u3002": "Wait for the current operation to finish and confirm no installer is running before using this command.",
"\u6E90\u7801\u6216\u94FE\u63A5\u5B89\u88C5\u8BF7\u6309\u539F\u5B89\u88C5\u65B9\u5F0F\u66F4\u65B0\uFF0C\u4E0D\u63D0\u4F9B\u8986\u76D6\u6E90\u7801\u7684 npm \u547D\u4EE4\u3002": "Update source or linked installations using their original method. No npm command is provided to overwrite the source.",
"\u65E0\u6CD5\u5B89\u5168\u751F\u6210\u5F53\u524D profile \u7684\u547D\u4EE4\uFF0C\u8BF7\u5728\u7EC8\u7AEF\u4E2D\u624B\u52A8\u786E\u8BA4 profile \u540E\u66F4\u65B0\u3002": "A safe command cannot be generated for this profile. Confirm the profile in your terminal before updating manually.",
"\u66F4\u65B0\u81F3": "Update to",
"\u5B89\u88C5\u66F4\u65B0": "Install update",
"\u6B63\u5728\u66F4\u65B0\u2026": "Updating\u2026",
"\u5F85\u624B\u52A8\u91CD\u542F": "Restart needed",
"\u8FD0\u884C\u7248\u672C": "Running version",
"\u5DF2\u5B89\u88C5\u7248\u672C": "Installed version",
"npm \u6700\u65B0\u7248\u672C": "Latest on npm",
"\u76EE\u6807 profile": "Target profile",
"\u76EE\u6807\u7248\u672C": "Target version",
"\u66F4\u65B0\u6765\u6E90": "Update source",
"\u9875\u9762\u7248\u672C": "Page version",
"\u65E0\u6CD5\u786E\u8BA4": "Unknown",
"\u53D1\u73B0\u65B0\u7248\u672C": "Update available",
"\u5DF2\u662F\u6700\u65B0\u7248\u672C": "Up to date",
@ -10467,6 +11002,17 @@ var CSS11 = String.raw`
.dim-updateHint, .dim-updateError { margin: 12px 0 0; font-size: 12px; line-height: 19px; overflow-wrap: anywhere; }
.dim-updateHint { color: var(--dsw-alias-label-secondary, #646a73); }
.dim-updateError { color: var(--dsw-alias-state-danger-primary, #d92d20); }
.dim-updateManual { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--dsw-alias-border-l1, #eef0f3); }
.dim-updateManualHeading { margin: 0; font-size: 13px; line-height: 20px; font-weight: 600; }
.dim-updateManualHint { margin: 8px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 19px; }
.dim-updateCommandRow { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding: 10px 12px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; background: var(--dsw-alias-bg-layer-1, #f7f8fa); }
.dim-updateCommand { display: block; flex: 1; width: 100%; min-width: 0; padding: 0; resize: none; border: 0; color: var(--dsw-alias-label-primary, #1f2329); background: transparent; font: 12px/19px ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
.dim-updateCommand:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: 2px; }
.dim-updateCopy { display: inline-flex; flex: 0 0 28px; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 6px; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; cursor: pointer; }
.dim-updateCopy:hover:not(:disabled) { color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-interactive-bg-hover, #eef0f3); }
.dim-updateCopy:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: 2px; }
.dim-updateCopy:disabled { opacity: .55; cursor: default; }
.dim-updateCopyCopied { color: var(--dsw-alias-state-success-primary, #20a162); }
.dim-updateFooter { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 8px; padding: 14px 24px; border-top: 1px solid var(--dsw-alias-border-l1, #eef0f3); }
.dim-updateFooter .dim-updateButton:first-child { margin-right: auto; }
.dim-updatePrimary, .dim-updatePrimary:hover:not(:disabled) { border-color: var(--dsw-alias-state-business-primary, #3370ff); color: #fff; background: var(--dsw-alias-state-business-primary, #3370ff); }
@ -10779,6 +11325,8 @@ function installImStyles() {
// plugin-src/client/update-panel.js
var React20 = __toESM(require("react"), 1);
var import_react_dom2 = require("react-dom");
var import_valid = __toESM(require_valid(), 1);
var import_rcompare = __toESM(require_rcompare(), 1);
var UPDATE_RPC_CHANNEL = "/dsh-im";
var ACTIVE_STATES4 = /* @__PURE__ */ new Set(["installing", "verifying"]);
var BLOCKED_REASONS = Object.freeze({
@ -10850,6 +11398,117 @@ function summary(snapshot, action, error) {
function retainDialogFocus(event) {
event?.currentTarget?.closest?.(".dim-updateDialog")?.focus?.({ preventScroll: true });
}
function manualUpdateCommand(snapshot) {
const profile = snapshot?.profileName;
if (snapshot?.sourceInstall || ["source-install", "unknown-profile"].includes(snapshot?.blockedReason) || typeof profile !== "string" || !profile.trim() || profile.length > 255 || profile.startsWith("-") || [".", "..", "node_modules"].includes(profile) || !/^[\p{L}\p{N}_. -]+$/u.test(profile)) return null;
const profileArgument = /^[A-Za-z0-9_.-]+$/.test(profile) ? profile : `"${profile}"`;
const validVersion = (version2) => typeof version2 === "string" && (0, import_valid.default)(version2) === version2;
const pendingRestart = snapshot.blockedReason === "pending-restart" || snapshot.job?.state === "restart-required";
const targets = [
snapshot.latestVersion,
pendingRestart ? snapshot.installedVersion : null,
snapshot.job?.state !== "completed" ? snapshot.job?.targetVersion : null
].filter(validVersion);
const version = targets.length ? [...targets, snapshot.runningVersion, snapshot.installedVersion].filter(validVersion).sort(import_rcompare.default)[0] : "latest";
return `dsh plugin --profile ${profileArgument} add -w @xmanrui/dsh-im@${version}`;
}
function ManualUpdateCommand({ command, disabled, sourceInstall, desktop }) {
const [copyState, setCopyState] = React20.useState("idle");
const commandRef = React20.useRef(null);
const mounted = React20.useRef(false);
const copying = React20.useRef(false);
React20.useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const copy = async (event) => {
if (!command || disabled || copying.current) return;
retainDialogFocus(event);
copying.current = true;
setCopyState("copying");
try {
const clipboard = globalThis.navigator?.clipboard;
if (typeof clipboard?.writeText !== "function") throw new Error("Clipboard unavailable");
await clipboard.writeText(command);
if (mounted.current) setCopyState("copied");
} catch {
if (mounted.current) {
setCopyState("failed");
commandRef.current?.focus?.({ preventScroll: true });
commandRef.current?.select?.();
}
} finally {
copying.current = false;
}
};
const copyLabel = copyState === "copying" ? "\u590D\u5236\u4E2D\u2026" : copyState === "copied" ? "\u5DF2\u590D\u5236" : "\u590D\u5236\u547D\u4EE4";
return h2(
"section",
{ className: "dim-updateManual", "aria-label": "\u624B\u5DE5\u66F4\u65B0" },
h2("h4", { className: "dim-updateManualHeading" }, "\u624B\u5DE5\u66F4\u65B0"),
command ? h2(
React20.Fragment,
null,
h2("p", { className: "dim-updateManualHint" }, "\u81EA\u52A8\u66F4\u65B0\u5931\u8D25\u53EF\u4EE5\u4F7F\u7528\u547D\u4EE4\u66F4\u65B0\uFF1A"),
h2(
"div",
{ className: "dim-updateCommandRow" },
h2("textarea", {
ref: commandRef,
className: "dim-updateCommand",
"aria-label": "\u624B\u5DE5\u66F4\u65B0\u547D\u4EE4",
value: command,
readOnly: true,
spellCheck: false,
rows: 2,
dir: "ltr"
}),
h2("button", {
type: "button",
className: `dim-updateCopy${copyState === "copied" ? " dim-updateCopyCopied" : ""}`,
"aria-label": copyLabel,
title: copyLabel,
"aria-busy": copyState === "copying",
disabled: disabled || copyState === "copying",
onClick: (event) => void copy(event)
}, h2("svg", {
width: 16,
height: 16,
viewBox: "0 0 20 20",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.6,
strokeLinecap: "round",
strokeLinejoin: "round",
"aria-hidden": "true",
focusable: "false"
}, copyState === "copied" ? h2("path", { d: "m4 10 4 4 8-8" }) : h2(
React20.Fragment,
null,
h2("rect", { x: 7, y: 7, width: 10, height: 11, rx: 1.5 }),
h2("path", { d: "M5 13H3.5A1.5 1.5 0 0 1 2 11.5v-8A1.5 1.5 0 0 1 3.5 2h8A1.5 1.5 0 0 1 13 3.5V5" })
)))
),
command.endsWith("@xmanrui/dsh-im@latest") ? h2(
"p",
{ className: "dim-updateManualHint" },
"\u5C1A\u672A\u786E\u8BA4\u76EE\u6807\u7248\u672C\uFF0C\u6B64\u547D\u4EE4\u5B89\u88C5\u6267\u884C\u65F6 npm \u7684 latest \u7248\u672C\u3002"
) : null,
h2("p", { className: "dim-updateManualHint" }, desktop ? "\u8BF7\u5728\u5F53\u524D Desktop \u7684\u5185\u7F6E\u7EC8\u7AEF\u6267\u884C\uFF0C\u5B8C\u6210\u540E\u624B\u52A8\u91CD\u542F\u3002" : "\u8BF7\u5728\u8FD0\u884C\u5F53\u524D Harness \u7684\u540C\u4E00\u73AF\u5883\u6267\u884C\uFF0C\u4FDD\u6301 DSH_HOME \u4E00\u81F4\uFF0C\u5B8C\u6210\u540E\u624B\u52A8\u91CD\u542F\u3002"),
disabled ? h2(
"p",
{ className: "dim-updateManualHint" },
"\u8BF7\u7B49\u5F85\u5F53\u524D\u64CD\u4F5C\u7ED3\u675F\uFF0C\u786E\u8BA4\u6CA1\u6709\u5B89\u88C5\u8FDB\u7A0B\u8FD0\u884C\u540E\u518D\u6267\u884C\u547D\u4EE4\u3002"
) : null,
copyState === "failed" || copyState === "copied" ? h2("p", {
className: copyState === "failed" ? "dim-updateError" : "dim-updateManualHint",
role: copyState === "failed" ? "alert" : "status"
}, copyState === "failed" ? "\u590D\u5236\u5931\u8D25\uFF0C\u8BF7\u9009\u4E2D\u547D\u4EE4\u540E\u6309 Ctrl+C \u6216 \u2318C \u590D\u5236\u3002" : "\u547D\u4EE4\u5DF2\u590D\u5236\u3002") : null
) : h2("p", { className: "dim-updateManualHint" }, sourceInstall ? "\u6E90\u7801\u6216\u94FE\u63A5\u5B89\u88C5\u8BF7\u6309\u539F\u5B89\u88C5\u65B9\u5F0F\u66F4\u65B0\uFF0C\u4E0D\u63D0\u4F9B\u8986\u76D6\u6E90\u7801\u7684 npm \u547D\u4EE4\u3002" : "\u65E0\u6CD5\u5B89\u5168\u751F\u6210\u5F53\u524D profile \u7684\u547D\u4EE4\uFF0C\u8BF7\u5728\u7EC8\u7AEF\u4E2D\u624B\u52A8\u786E\u8BA4 profile \u540E\u66F4\u65B0\u3002")
);
}
function UpdateDialog({ children, onClose }) {
const dialogRef = React20.useRef(null);
const titleId = React20.useId();
@ -10886,7 +11545,7 @@ function UpdateDialog({ children, onClose }) {
onClose();
}
if (event.key !== "Tab") return;
const buttons = dialogRef.current?.querySelectorAll?.("button:not(:disabled)");
const buttons = dialogRef.current?.querySelectorAll?.("button:not(:disabled), textarea:not(:disabled)");
if (!buttons?.length) return;
const first = buttons[0];
const last = buttons[buttons.length - 1];
@ -11059,6 +11718,7 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
const failedJob = ["failed", "interrupted"].includes(snapshot?.job?.state);
const jobMessage = snapshot?.job?.message;
const targetVersion = snapshot?.job?.targetVersion;
const manualCommand = manualUpdateCommand(snapshot);
const buttonLabel = action === "checking" ? "\u68C0\u67E5\u4E2D\u2026" : action === "starting" || activeJob ? "\u6B63\u5728\u66F4\u65B0\u2026" : restartRequired ? "\u5F85\u624B\u52A8\u91CD\u542F" : snapshot?.canInstall ? "\u66F4\u65B0\u81F3" : "\u68C0\u67E5\u66F4\u65B0";
return h2(
React20.Fragment,
@ -11091,8 +11751,6 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
h2("dt", null, "\u5DF2\u5B89\u88C5\u7248\u672C"),
h2("dd", null, `v${snapshot.installedVersion}`)
) : null,
h2("dt", null, "npm \u6700\u65B0\u7248\u672C"),
h2("dd", null, snapshot?.latestVersion ? `v${snapshot.latestVersion}` : "\u5C1A\u672A\u68C0\u67E5"),
targetVersion ? h2(
React20.Fragment,
null,
@ -11100,15 +11758,7 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
h2("dd", null, `v${targetVersion}`)
) : null,
h2("dt", null, "\u76EE\u6807 profile"),
h2("dd", null, snapshot?.profileName ?? "\u65E0\u6CD5\u786E\u8BA4"),
h2("dt", null, "\u66F4\u65B0\u6765\u6E90"),
h2("dd", null, "registry.npmjs.org"),
versionsDiffer ? h2(
React20.Fragment,
null,
h2("dt", null, "\u9875\u9762\u7248\u672C"),
h2("dd", null, `v${clientVersion}`)
) : null
h2("dd", null, snapshot?.profileName ?? "\u65E0\u6CD5\u786E\u8BA4")
),
h2(
"div",
@ -11134,7 +11784,14 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
"p",
{ className: "dim-updateHint" },
"\u8BF7\u5728\u673A\u5668\u4EBA\u7A7A\u95F2\u65F6\u5B89\u88C5\uFF1B\u5B89\u88C5\u4F1A\u4FEE\u6539\u5F53\u524D profile \u7684\u4F9D\u8D56\uFF0C\u5B8C\u6210\u540E\u9700\u624B\u52A8\u91CD\u542F\u3002"
) : null
) : null,
h2(ManualUpdateCommand, {
key: manualCommand ?? "unavailable",
command: manualCommand,
disabled: busyAction || activeJob || uncertainInstall,
sourceInstall: snapshot?.sourceInstall || snapshot?.blockedReason === "source-install",
desktop: snapshot?.environmentKind === "desktop"
})
),
h2(
"footer",

File diff suppressed because one or more lines are too long

View file

@ -13,17 +13,28 @@ const EN = Object.freeze({
'检查更新': 'Check for updates',
'重新检查': 'Check again',
'刷新状态': 'Refresh status',
'手工更新': 'Manual update',
'手工更新命令': 'Manual update command',
'复制命令': 'Copy command',
'复制中…': 'Copying…',
'已复制': 'Copied',
'命令已复制。': 'Command copied.',
'复制失败,请选中命令后按 Ctrl+C 或 ⌘C 复制。': 'Copy failed. Select the command and press Ctrl+C or ⌘C to copy it.',
'自动更新失败可以使用命令更新:': 'If the automatic update fails, update with this command:',
'尚未确认目标版本,此命令安装执行时 npm 的 latest 版本。': 'No target version has been confirmed. This command installs the npm latest version available when you run it.',
'请在当前 Desktop 的内置终端执行,完成后手动重启。': 'Run this in the current Desktop’s built-in terminal, then restart manually.',
'请在运行当前 Harness 的同一环境执行,保持 DSH_HOME 一致,完成后手动重启。': 'Run this in the same environment as the current Harness, with the same DSH_HOME, then restart manually.',
'请等待当前操作结束,确认没有安装进程运行后再执行命令。': 'Wait for the current operation to finish and confirm no installer is running before using this command.',
'源码或链接安装请按原安装方式更新,不提供覆盖源码的 npm 命令。': 'Update source or linked installations using their original method. No npm command is provided to overwrite the source.',
'无法安全生成当前 profile 的命令,请在终端中手动确认 profile 后更新。': 'A safe command cannot be generated for this profile. Confirm the profile in your terminal before updating manually.',
'更新至': 'Update to',
'安装更新': 'Install update',
'正在更新…': 'Updating…',
'待手动重启': 'Restart needed',
'运行版本': 'Running version',
'已安装版本': 'Installed version',
'npm 最新版本': 'Latest on npm',
'目标 profile': 'Target profile',
'目标版本': 'Target version',
'更新来源': 'Update source',
'页面版本': 'Page version',
'无法确认': 'Unknown',
'发现新版本': 'Update available',
'已是最新版本': 'Up to date',

View file

@ -40,6 +40,17 @@ const CSS = String.raw`
.dim-updateHint, .dim-updateError { margin: 12px 0 0; font-size: 12px; line-height: 19px; overflow-wrap: anywhere; }
.dim-updateHint { color: var(--dsw-alias-label-secondary, #646a73); }
.dim-updateError { color: var(--dsw-alias-state-danger-primary, #d92d20); }
.dim-updateManual { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--dsw-alias-border-l1, #eef0f3); }
.dim-updateManualHeading { margin: 0; font-size: 13px; line-height: 20px; font-weight: 600; }
.dim-updateManualHint { margin: 8px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 19px; }
.dim-updateCommandRow { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding: 10px 12px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; background: var(--dsw-alias-bg-layer-1, #f7f8fa); }
.dim-updateCommand { display: block; flex: 1; width: 100%; min-width: 0; padding: 0; resize: none; border: 0; color: var(--dsw-alias-label-primary, #1f2329); background: transparent; font: 12px/19px ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
.dim-updateCommand:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: 2px; }
.dim-updateCopy { display: inline-flex; flex: 0 0 28px; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 6px; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; cursor: pointer; }
.dim-updateCopy:hover:not(:disabled) { color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-interactive-bg-hover, #eef0f3); }
.dim-updateCopy:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: 2px; }
.dim-updateCopy:disabled { opacity: .55; cursor: default; }
.dim-updateCopyCopied { color: var(--dsw-alias-state-success-primary, #20a162); }
.dim-updateFooter { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 8px; padding: 14px 24px; border-top: 1px solid var(--dsw-alias-border-l1, #eef0f3); }
.dim-updateFooter .dim-updateButton:first-child { margin-right: auto; }
.dim-updatePrimary, .dim-updatePrimary:hover:not(:disabled) { border-color: var(--dsw-alias-state-business-primary, #3370ff); color: #fff; background: var(--dsw-alias-state-business-primary, #3370ff); }

View file

@ -1,5 +1,7 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
import validSemver from 'semver/functions/valid.js';
import compareVersionsDescending from 'semver/functions/rcompare.js';
import { h } from './i18n.js';
import { createPollScheduler } from './lifecycle.js';
@ -85,6 +87,102 @@ function retainDialogFocus(event) {
event?.currentTarget?.closest?.('.dim-updateDialog')?.focus?.({ preventScroll: true });
}
function manualUpdateCommand(snapshot) {
const profile = snapshot?.profileName;
if (snapshot?.sourceInstall || ['source-install', 'unknown-profile'].includes(snapshot?.blockedReason)
|| typeof profile !== 'string' || !profile.trim() || profile.length > 255
|| profile.startsWith('-') || ['.', '..', 'node_modules'].includes(profile)
|| !/^[\p{L}\p{N}_. -]+$/u.test(profile)) return null;
// Only allow shell-neutral profile characters; spaces and Unicode stay one argument.
const profileArgument = /^[A-Za-z0-9_.-]+$/.test(profile) ? profile : `"${profile}"`;
const validVersion = (version) => typeof version === 'string' && validSemver(version) === version;
const pendingRestart = snapshot.blockedReason === 'pending-restart'
|| snapshot.job?.state === 'restart-required';
const targets = [
snapshot.latestVersion,
pendingRestart ? snapshot.installedVersion : null,
snapshot.job?.state !== 'completed' ? snapshot.job?.targetVersion : null,
].filter(validVersion);
// Keep a known target pinned, and never suggest downgrading an installed/running version.
const version = targets.length
? [...targets, snapshot.runningVersion, snapshot.installedVersion]
.filter(validVersion).sort(compareVersionsDescending)[0]
: 'latest';
return `dsh plugin --profile ${profileArgument} add -w @xmanrui/dsh-im@${version}`;
}
function ManualUpdateCommand({ command, disabled, sourceInstall, desktop }) {
const [copyState, setCopyState] = React.useState('idle');
const commandRef = React.useRef(null);
const mounted = React.useRef(false);
const copying = React.useRef(false);
React.useEffect(() => {
mounted.current = true;
return () => { mounted.current = false; };
}, []);
const copy = async (event) => {
if (!command || disabled || copying.current) return;
retainDialogFocus(event);
copying.current = true;
setCopyState('copying');
try {
const clipboard = globalThis.navigator?.clipboard;
if (typeof clipboard?.writeText !== 'function') throw new Error('Clipboard unavailable');
await clipboard.writeText(command);
if (mounted.current) setCopyState('copied');
} catch {
if (mounted.current) {
setCopyState('failed');
commandRef.current?.focus?.({ preventScroll: true });
commandRef.current?.select?.();
}
} finally {
copying.current = false;
}
};
const copyLabel = copyState === 'copying' ? '复制中…' : copyState === 'copied' ? '已复制' : '复制命令';
return h('section', { className: 'dim-updateManual', 'aria-label': '手工更新' },
h('h4', { className: 'dim-updateManualHeading' }, '手工更新'),
command ? h(React.Fragment, null,
h('p', { className: 'dim-updateManualHint' }, '自动更新失败可以使用命令更新:'),
h('div', { className: 'dim-updateCommandRow' },
h('textarea', {
ref: commandRef, className: 'dim-updateCommand', 'aria-label': '手工更新命令',
value: command, readOnly: true, spellCheck: false, rows: 2, dir: 'ltr',
}),
h('button', {
type: 'button', className: `dim-updateCopy${copyState === 'copied' ? ' dim-updateCopyCopied' : ''}`,
'aria-label': copyLabel, title: copyLabel, 'aria-busy': copyState === 'copying',
disabled: disabled || copyState === 'copying', onClick: (event) => void copy(event),
}, h('svg', {
width: 16, height: 16, viewBox: '0 0 20 20', fill: 'none',
stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round',
'aria-hidden': 'true', focusable: 'false',
}, copyState === 'copied'
? h('path', { d: 'm4 10 4 4 8-8' })
: h(React.Fragment, null,
h('rect', { x: 7, y: 7, width: 10, height: 11, rx: 1.5 }),
h('path', { d: 'M5 13H3.5A1.5 1.5 0 0 1 2 11.5v-8A1.5 1.5 0 0 1 3.5 2h8A1.5 1.5 0 0 1 13 3.5V5' }))))),
command.endsWith('@xmanrui/dsh-im@latest') ? h('p', { className: 'dim-updateManualHint' },
'尚未确认目标版本,此命令安装执行时 npm 的 latest 版本。') : null,
h('p', { className: 'dim-updateManualHint' }, desktop
? '请在当前 Desktop 的内置终端执行,完成后手动重启。'
: '请在运行当前 Harness 的同一环境执行,保持 DSH_HOME 一致,完成后手动重启。'),
disabled ? h('p', { className: 'dim-updateManualHint' },
'请等待当前操作结束,确认没有安装进程运行后再执行命令。') : null,
copyState === 'failed' || copyState === 'copied' ? h('p', {
className: copyState === 'failed' ? 'dim-updateError' : 'dim-updateManualHint',
role: copyState === 'failed' ? 'alert' : 'status',
}, copyState === 'failed' ? '复制失败,请选中命令后按 Ctrl+C 或 ⌘C 复制。' : '命令已复制。') : null)
: h('p', { className: 'dim-updateManualHint' }, sourceInstall
? '源码或链接安装请按原安装方式更新,不提供覆盖源码的 npm 命令。'
: '无法安全生成当前 profile 的命令,请在终端中手动确认 profile 后更新。'));
}
function UpdateDialog({ children, onClose }) {
const dialogRef = React.useRef(null);
const titleId = React.useId();
@ -115,7 +213,7 @@ function UpdateDialog({ children, onClose }) {
onClose();
}
if (event.key !== 'Tab') return;
const buttons = dialogRef.current?.querySelectorAll?.('button:not(:disabled)');
const buttons = dialogRef.current?.querySelectorAll?.('button:not(:disabled), textarea:not(:disabled)');
if (!buttons?.length) return;
const first = buttons[0];
const last = buttons[buttons.length - 1];
@ -299,6 +397,7 @@ export function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
const failedJob = ['failed', 'interrupted'].includes(snapshot?.job?.state);
const jobMessage = snapshot?.job?.message;
const targetVersion = snapshot?.job?.targetVersion;
const manualCommand = manualUpdateCommand(snapshot);
const buttonLabel = action === 'checking' ? '检查中…'
: action === 'starting' || activeJob ? '正在更新…'
: restartRequired ? '待手动重启'
@ -324,13 +423,9 @@ export function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
snapshot?.installedVersion && snapshot.installedVersion !== snapshot.runningVersion
? h(React.Fragment, null,
h('dt', null, '已安装版本'), h('dd', null, `v${snapshot.installedVersion}`)) : null,
h('dt', null, 'npm 最新版本'), h('dd', null, snapshot?.latestVersion ? `v${snapshot.latestVersion}` : '尚未检查'),
targetVersion ? h(React.Fragment, null,
h('dt', null, '目标版本'), h('dd', null, `v${targetVersion}`)) : null,
h('dt', null, '目标 profile'), h('dd', null, snapshot?.profileName ?? '无法确认'),
h('dt', null, '更新来源'), h('dd', null, 'registry.npmjs.org'),
versionsDiffer ? h(React.Fragment, null,
h('dt', null, '页面版本'), h('dd', null, `v${clientVersion}`)) : null),
h('dt', null, '目标 profile'), h('dd', null, snapshot?.profileName ?? '无法确认')),
h('div', {
className: `dim-updateStatus${error || failedJob ? ' dim-updateStatusError' : ''}`,
role: 'status',
@ -347,7 +442,13 @@ export function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
blocked && !restartRequired ? h('p', { className: 'dim-updateHint' }, blocked) : null,
versionsDiffer && !restartRequired ? h('p', { className: 'dim-updateHint' }, '页面版本与运行版本不同,请手动刷新页面;若仍不一致,请手动重启 Harness 或 Desktop。') : null,
canConfirm ? h('p', { className: 'dim-updateHint' },
'请在机器人空闲时安装;安装会修改当前 profile 的依赖,完成后需手动重启。') : null),
'请在机器人空闲时安装;安装会修改当前 profile 的依赖,完成后需手动重启。') : null,
h(ManualUpdateCommand, {
key: manualCommand ?? 'unavailable', command: manualCommand,
disabled: busyAction || activeJob || uncertainInstall,
sourceInstall: snapshot?.sourceInstall || snapshot?.blockedReason === 'source-install',
desktop: snapshot?.environmentKind === 'desktop',
})),
h('footer', { className: 'dim-updateFooter' },
h('button', { type: 'button', className: 'dim-updateButton', onClick: () => setOpen(false) }, '关闭'),
restartRequired || !activeJob ? h('button', {

View file

@ -280,6 +280,8 @@ export function createUpdateRuntime(options = {}) {
const profile = await packageAt(runtime.profileDir);
const installed = await packageAt(join(runtime.profileDir, 'node_modules', PACKAGE_NAME));
result.sourceInstall = !registrySpec(profile.manifest.dependencies?.[PACKAGE_NAME])
|| !inside(join(runtime.profileDir, 'node_modules'), installed.directory);
result.installedVersion = typeof installed.manifest.version === 'string' ? installed.manifest.version : null;
result.packageValid = await validPackage(installed);
const loaded = await loadedPackage;
@ -298,8 +300,7 @@ export function createUpdateRuntime(options = {}) {
if (boundProfile !== undefined && boundProfile !== identity) result.blockedReason = 'installation-changed';
else if (!sameLoadedPackage && boundProfile === undefined) result.blockedReason = 'installation-changed';
else if (!result.packageValid) result.blockedReason = 'invalid-installation';
else if (!registrySpec(profile.manifest.dependencies?.[PACKAGE_NAME])
|| !inside(join(runtime.profileDir, 'node_modules'), installed.directory)) result.blockedReason = 'source-install';
else if (result.sourceInstall) result.blockedReason = 'source-install';
else if (runtime.blockedReason) result.blockedReason = runtime.blockedReason;
else if (!sameLoadedPackage) result.blockedReason = 'pending-restart';
else if (preflight) await checkRegistry(runtime);

View file

@ -201,6 +201,8 @@ export function createUpdateService({
latestVersion: checked?.release.version ?? null,
profileName: environment.profileName ?? null,
environmentKind: environment.environmentKind ?? 'cli',
// Preserve source protection even when restart/recovery takes status priority.
sourceInstall: environment.sourceInstall === true || environment.blockedReason === 'source-install',
canInstall,
blockedReason,
checkedAt: checked?.checkedAt ?? null,

View file

@ -168,7 +168,9 @@ test('local, Git, alias and external-directory installations cannot be replaced'
const f = await fixture(t);
for (const spec of ['link:/work/dsh-im', 'file:../dsh-im', 'github:xmanrui/dsh-im', 'git+ssh://git@example.com/repo.git', 'npm:another-plugin@1.0.0']) {
await json(join(f.profileDir, 'package.json'), { ...f.manifest, dependencies: { [PACKAGE_NAME]: spec } });
assert.equal((await f.runtime().inspect()).blockedReason, 'source-install', spec);
const inspected = await f.runtime().inspect();
assert.equal(inspected.blockedReason, 'source-install', spec);
assert.equal(inspected.sourceInstall, true, spec);
}
const sourceDir = join(f.root, 'source-checkout');
await plugin(sourceDir);
@ -176,7 +178,9 @@ test('local, Git, alias and external-directory installations cannot be replaced'
await symlink(sourceDir, f.installedLink, 'dir');
await json(join(f.profileDir, 'package.json'), f.manifest);
f.options.moduleUrl = pathToFileURL(join(sourceDir, 'lib/index.js')).href;
assert.equal((await f.runtime().inspect()).blockedReason, 'source-install');
const inspected = await f.runtime().inspect();
assert.equal(inspected.blockedReason, 'source-install');
assert.equal(inspected.sourceInstall, true);
assert.deepEqual(f.calls, []);
});

View file

@ -127,6 +127,7 @@ test('source installs and incompatible Host Node can check but cannot install',
const checked = await source.service.check();
assert.equal(checked.latestVersion, '3.0.8');
assert.equal(checked.blockedReason, 'source-install');
assert.equal(checked.sourceInstall, true);
assert.equal(checked.canInstall, false);
assert.equal(checked.checkId, null);
const oldNode = await fixture(t, { service: { nodeVersion: '22.18.0' } });
@ -134,6 +135,17 @@ test('source installs and incompatible Host Node can check but cannot install',
assert.deepEqual(source.state.installs, []);
});
test('source-install protection remains independent of the primary status reason', async (t) => {
const f = await fixture(t, { environment: {
eligible: false, blockedReason: 'source-install', sourceInstall: true, installedVersion: '3.0.8',
} });
const status = await f.service.status();
assert.equal(status.blockedReason, 'pending-restart');
assert.equal(status.sourceInstall, true);
f.environment.blockedReason = 'installation-changed';
assert.equal((await f.service.status()).sourceInstall, true);
});
test('a failed check invalidates previous install confirmation without claiming latest', async (t) => {
const f = await fixture(t);
const checked = await f.service.check();

View file

@ -47,7 +47,7 @@ function textOf(node) {
}
function buttonNamed(renderer, label) {
return renderer.root.findAllByType('button').find((node) => textOf(node) === label);
return renderer.root.findAllByType('button').find((node) => (node.props['aria-label'] ?? textOf(node)) === label);
}
async function flushMicrotasks() {
@ -82,6 +82,19 @@ function deferred() {
return { promise, resolve };
}
function stubClipboard(t, clipboard) {
const original = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { clipboard } });
t.after(() => {
if (original) Object.defineProperty(globalThis, 'navigator', original);
else delete globalThis.navigator;
});
}
function manualCommandOf(renderer) {
return renderer.root.findAllByType('textarea')[0]?.props.value;
}
async function tick(t, milliseconds = 1_000) {
await act(async () => {
t.mock.timers.tick(milliseconds);
@ -110,7 +123,6 @@ test('update panel reads local status first and requires an explicit npm check a
assert.equal(renderer.root.findByProps({ role: 'dialog' }).props['aria-modal'], 'true');
assert.match(textOf(renderer.toJSON()), /发现新版本/);
assert.match(textOf(renderer.toJSON()), /desktop-test/);
assert.match(textOf(renderer.toJSON()), /registry\.npmjs\.org/);
assert.match(textOf(renderer.toJSON()), /需手动重启后台.*本功能不会自动重启或主动刷新页面/);
assert.ok(buttonNamed(renderer, '安装更新'));
assert.ok(buttonNamed(renderer, '更新至 v3.0.9'));
@ -119,6 +131,19 @@ test('update panel reads local status first and requires an explicit npm check a
assert.equal(renderer.root.findAllByProps({ role: 'dialog' }).length, 0);
});
test('version details hide npm, registry and client metadata while preserving running and installed versions', async (t) => {
for (const latestVersion of [null, '3.1.2']) {
const renderer = await mount(t, async () => ok(snapshot({
runningVersion: '3.1.0', installedVersion: '3.1.1', latestVersion,
profileName: 'web', blockedReason: 'pending-restart',
})), { clientVersion: '3.1.1' });
await click(renderer, '待手动重启');
const details = renderer.root.findByProps({ className: 'dim-updateVersions' });
assert.deepEqual(details.findAllByType('dt').map(textOf), ['运行版本', '已安装版本', '目标 profile']);
assert.deepEqual(details.findAllByType('dd').map(textOf), ['v3.1.0', 'v3.1.1', 'web']);
}
});
test('confirmed installation ignores double clicks, polls the Host and stops at manual restart', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const pendingInstall = deferred();
@ -201,23 +226,24 @@ test('a failed npm check cannot reuse an earlier up-to-date result', async (t) =
});
await click(renderer, '检查更新');
assert.match(textOf(renderer.toJSON()), /无法访问 npm 或请求超时/);
assert.match(textOf(renderer.toJSON()), /尚未检查/);
assert.match(manualCommandOf(renderer), /@latest$/);
assert.doesNotMatch(textOf(renderer.toJSON()), /已是最新版本|当前版本无需更新/);
assert.equal(buttonNamed(renderer, '安装更新'), undefined);
});
test('source installations can check npm but never offer automatic replacement', async (t) => {
const calls = [];
const reported = [];
const renderer = await mount(t, async (endpoint) => {
calls.push(endpoint);
return ok(endpoint === 'update.status'
? snapshot({ blockedReason: 'source-install' })
: available({ blockedReason: 'source-install', canInstall: false, checkId: null }));
});
}, { onStatus: (value) => reported.push(value) });
await click(renderer, '检查更新');
assert.deepEqual(calls, ['update.status', 'update.check']);
assert.match(textOf(renderer.toJSON()), /当前是源码或链接安装/);
assert.match(textOf(renderer.toJSON()), /v3.0.9/);
assert.equal(reported.at(-1).latestVersion, '3.0.9');
assert.equal(buttonNamed(renderer, '安装更新'), undefined);
});
@ -299,7 +325,6 @@ test('pending restart shows manual restart guidance instead of a redundant page
await click(renderer, '待手动重启');
assert.deepEqual(calls, ['update.status', 'update.status']);
assert.match(textOf(renderer.toJSON()), /运行版本v3.0.8/);
assert.match(textOf(renderer.toJSON()), /页面版本v3.0.7/);
assert.match(textOf(renderer.toJSON()), /手动重启当前 Harness 或 Desktop/);
assert.doesNotMatch(textOf(renderer.toJSON()), /页面版本与运行版本不同|手动刷新页面/);
assert.equal(buttonNamed(renderer, '安装更新'), undefined);
@ -330,7 +355,6 @@ test('remounting the new client retains the old Host version and restores the re
await click(remounted, '待手动重启');
assert.match(textOf(remounted.toJSON()), /运行版本v3.0.8/);
assert.match(textOf(remounted.toJSON()), /已安装版本v3.0.9/);
assert.match(textOf(remounted.toJSON()), /页面版本v3.0.9/);
assert.match(textOf(remounted.toJSON()), /已安装,待手动重启/);
assert.doesNotMatch(textOf(remounted.toJSON()), /手动刷新页面/);
assert.equal(buttonNamed(remounted, '安装更新'), undefined);
@ -634,7 +658,10 @@ test('the confirmation supports keyboard focus, tab wrapping, Escape and focus r
const last = { focus() { document.activeElement = last; } };
const dialogNode = {
focus() { document.activeElement = dialogNode; },
querySelectorAll() { return [first, last]; },
querySelectorAll(selector) {
assert.match(selector, /textarea:not\(:disabled\)/);
return [first, last];
},
};
Object.defineProperty(globalThis, 'document', {
configurable: true, value: { activeElement: previous },
@ -667,3 +694,177 @@ test('the confirmation supports keyboard focus, tab wrapping, Escape and focus r
assert.equal(document.activeElement, previous);
assert.equal(prevented, 2);
});
test('manual commands use the current profile and a known target without downgrading', async (t) => {
const cases = [
{ profileName: 'web', runningVersion: '3.1.0', installedVersion: '3.1.1',
blockedReason: 'pending-restart', expected: 'web', version: '3.1.1' },
{ profileName: 'desktop-test', job: { state: 'failed', targetVersion: '3.1.2', message: 'install-failed' },
expected: 'desktop-test', version: '3.1.2' },
{ profileName: 'web', latestVersion: '3.1.3', job: { state: 'failed', targetVersion: '3.1.2' },
expected: 'web', version: '3.1.3' },
{ profileName: 'web', runningVersion: '3.1.10', installedVersion: '3.1.10', latestVersion: '3.1.9',
expected: 'web', version: '3.1.10' },
{ profileName: 'Team One', expected: '"Team One"', version: 'latest' },
{ profileName: '测试环境', expected: '"测试环境"', version: 'latest' },
{ profileName: 'web', latestVersion: '3.1.1; echo unsafe', expected: 'web', version: 'latest' },
{ profileName: 'web', job: { state: 'completed', targetVersion: '3.0.8' }, expected: 'web', version: 'latest' },
];
for (const { expected, version, ...fields } of cases) {
const renderer = await mount(t, async () => ok(snapshot(fields)));
await click(renderer, fields.blockedReason === 'pending-restart' ? '待手动重启' : '检查更新');
assert.equal(manualCommandOf(renderer),
`dsh plugin --profile ${expected} add -w @xmanrui/dsh-im@${version}`);
assert.equal(renderer.root.findByType('textarea').props.readOnly, true);
assert.match(textOf(renderer.toJSON()), /自动更新失败可以使用命令更新:/);
assert.doesNotMatch(textOf(renderer.toJSON()), /通常只需手动重启/);
}
});
test('manual copying uses an accessible icon at the right of the command row', async (t) => {
stubClipboard(t, { async writeText() {} });
const renderer = await mount(t, async () => ok(available()));
await click(renderer, '更新至 v3.0.9');
const row = renderer.root.findByProps({ className: 'dim-updateCommandRow' });
const copy = buttonNamed(renderer, '复制命令');
assert.equal(row.children[0].type, 'textarea');
assert.equal(row.children.at(-1), copy);
assert.equal(copy.props.className, 'dim-updateCopy');
assert.equal(copy.props.title, '复制命令');
assert.equal(textOf(copy), '');
assert.equal(copy.findByType('svg').props['aria-hidden'], 'true');
assert.equal(copy.findByType('svg').props.focusable, 'false');
await click(renderer, '复制命令');
const copied = buttonNamed(renderer, '已复制');
assert.match(copied.props.className, /dim-updateCopyCopied/);
assert.equal(copied.props.title, '已复制');
assert.equal(textOf(copied), '');
assert.equal(copied.findAllByType('rect').length, 0);
});
test('manual commands remain available after a failed npm check and explain the latest fallback', async (t) => {
const renderer = await mount(t, async (endpoint) => {
if (endpoint === 'update.status') return ok(snapshot({ profileName: 'web', environmentKind: 'cli' }));
throw new Error('npm unavailable');
});
await click(renderer, '检查更新');
assert.match(manualCommandOf(renderer), /@xmanrui\/dsh-im@latest$/);
assert.match(textOf(renderer.toJSON()), /尚未确认目标版本.*执行时 npm 的 latest/);
assert.match(textOf(renderer.toJSON()), /DSH_HOME 一致/);
assert.equal(buttonNamed(renderer, '复制命令').props.disabled, false);
assert.equal(buttonNamed(renderer, '安装更新'), undefined);
});
test('manual commands cannot overwrite source installs or interpolate unsafe profiles', async (t) => {
for (const profileName of [null, '', '..', 'node_modules', '-other', '../other', 'work; echo x',
'work$(echo x)', 'work`echo x`', 'work%PATH%', 'work"name', 'work\nname']) {
const renderer = await mount(t, async () => ok(snapshot({ profileName })));
await click(renderer, '检查更新');
assert.equal(manualCommandOf(renderer), undefined, String(profileName));
assert.equal(buttonNamed(renderer, '复制命令'), undefined);
}
for (const blockedReason of ['source-install', 'pending-restart', 'recovery-required']) {
const renderer = await mount(t, async () => ok(snapshot({
blockedReason, ...(blockedReason === 'source-install' ? {} : { sourceInstall: true }),
})));
await click(renderer, blockedReason === 'pending-restart' ? '待手动重启' : '检查更新');
assert.equal(manualCommandOf(renderer), undefined);
assert.equal(buttonNamed(renderer, '复制命令'), undefined);
assert.match(textOf(renderer.toJSON()), /不提供覆盖源码的 npm 命令/);
}
});
test('copying writes exactly the displayed command and never submits an installation', async (t) => {
const writes = [];
const calls = [];
const clipboard = { async writeText(value) { assert.equal(this, clipboard); writes.push(value); } };
stubClipboard(t, clipboard);
const renderer = await mount(t, async (endpoint) => {
calls.push(endpoint);
return ok(available());
});
await click(renderer, '更新至 v3.0.9');
assert.match(textOf(renderer.toJSON()), /Desktop 的内置终端/);
const before = [...calls];
await click(renderer, '复制命令');
assert.deepEqual(writes, [manualCommandOf(renderer)]);
assert.deepEqual(calls, before);
assert.ok(buttonNamed(renderer, '已复制'));
assert.match(textOf(renderer.toJSON()), /命令已复制/);
});
test('copy failure selects the command for keyboard copying and allows a retry', async (t) => {
const selection = [];
let denied = true;
stubClipboard(t, { async writeText() { if (denied) throw new Error('NotAllowedError'); } });
const renderer = await mount(t, async () => ok(available()), {}, {
createNodeMock(element) {
return element.type === 'textarea' ? {
focus() { selection.push('focus'); }, select() { selection.push('select'); },
} : null;
},
});
await click(renderer, '更新至 v3.0.9');
await click(renderer, '复制命令');
assert.deepEqual(selection, ['focus', 'select']);
assert.match(textOf(renderer.root.findByProps({ role: 'alert' })), /Ctrl\+C 或 ⌘C/);
assert.equal(buttonNamed(renderer, '复制命令').props.disabled, false);
denied = false;
await click(renderer, '复制命令');
assert.ok(buttonNamed(renderer, '已复制'));
assert.equal(renderer.root.findAllByProps({ role: 'alert' }).length, 0);
});
test('missing clipboard support exposes a selectable command without claiming success', async (t) => {
stubClipboard(t, undefined);
const renderer = await mount(t, async () => ok(available()));
await click(renderer, '更新至 v3.0.9');
await click(renderer, '复制命令');
assert.match(textOf(renderer.toJSON()), /复制失败/);
assert.ok(manualCommandOf(renderer));
assert.equal(buttonNamed(renderer, '已复制'), undefined);
});
test('an old clipboard result cannot mark a changed command or reopened dialog as copied', async (t) => {
const pending = deferred();
let writes = 0;
stubClipboard(t, { writeText() { writes += 1; return pending.promise; } });
const renderer = await mount(t, async (endpoint) => ok(endpoint === 'update.check'
? available({ latestVersion: '3.0.10' }) : available()));
await click(renderer, '更新至 v3.0.9');
const copy = buttonNamed(renderer, '复制命令');
await act(async () => { copy.props.onClick(); copy.props.onClick(); await flushMicrotasks(); });
assert.equal(writes, 1);
assert.equal(buttonNamed(renderer, '复制中…').props.disabled, true);
await click(renderer, '重新检查');
assert.match(manualCommandOf(renderer), /@3\.0\.10$/);
await act(async () => { pending.resolve(); await flushMicrotasks(); });
assert.equal(buttonNamed(renderer, '已复制'), undefined);
await click(renderer, '复制命令');
assert.ok(buttonNamed(renderer, '已复制'));
await click(renderer, '关闭');
await click(renderer, '更新至 v3.0.10');
assert.ok(buttonNamed(renderer, '复制命令'));
});
test('manual copying is disabled while the Host may still be installing', async (t) => {
const renderer = await mount(t, async () => ok(installing()));
await click(renderer, '正在更新…');
assert.equal(buttonNamed(renderer, '复制命令').props.disabled, true);
assert.match(textOf(renderer.toJSON()), /确认没有安装进程运行/);
});
test('manual update instructions and copy feedback render in English', async (t) => {
setImTranslator((key) => en[key] ?? key);
t.after(() => setImTranslator(null));
stubClipboard(t, { async writeText() {} });
const renderer = await mount(t, async () => ok(available()));
await click(renderer, 'Update to v3.0.9');
assert.match(textOf(renderer.toJSON()), /Manual update/);
assert.match(textOf(renderer.toJSON()), /If the automatic update fails, update with this command:/);
assert.equal(renderer.root.findByType('textarea').props['aria-label'], 'Manual update command');
assert.equal(buttonNamed(renderer, 'Copy command').props.title, 'Copy command');
await click(renderer, 'Copy command');
assert.match(textOf(renderer.toJSON()), /Command copied/);
assert.doesNotMatch(textOf(renderer.toJSON()), /[\p{Script=Han}]/u);
});