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

@ -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,