dsh-im-ops/plugin-src/client/update-panel.js
oliver d53daf7d26 Rebrand as dsh-im-ops fork of xmanrui/dsh-im@4.9.1.
Keep all IM channels; rename package/cordis id for local profile install without colliding with the community plugin. Document fork intent in FORK.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 15:02:54 +08:00

468 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
export const UPDATE_RPC_CHANNEL = '/dsh-im';
const ACTIVE_STATES = new Set(['installing', 'verifying']);
const BLOCKED_REASONS = Object.freeze({
'source-install': '当前是源码或链接安装,只能检查版本;请手动更新源码,或迁移到 npm 安装。',
'unknown-profile': '无法确认当前 profile,请在对应的 Harness 环境中手动更新。',
'unsupported-runtime': '当前运行环境不支持按钮安装,请手动更新插件。',
'registry-conflict': '当前 npm 源配置与官方源不一致,请先检查 registry 配置。',
'incompatible-node': '当前 Host 的 Node.js 版本不满足新版要求,请先更新运行环境。',
'pending-restart': '新版本已安装,请在方便时手动重启当前 Harness 或 Desktop。',
'recovery-required': '上次安装结果无法确认,请先检查此 profile 的插件安装状态。',
});
const ERROR_MESSAGES = Object.freeze({
'check-failed': '无法访问 npm 或请求超时,请稍后重新检查。',
'update-failed': '更新请求失败,请重试。',
'invalid-release': 'npm 返回的版本信息无效,暂时无法更新。',
'check-expired': '版本确认已过期,请重新检查后再安装。',
'installation-changed': '插件安装状态已发生变化,请重新检查。',
'update-busy': '此 profile 正在更新,请稍后查看状态。',
'install-failed': '安装失败,请检查当前安装状态后重试。',
'verify-failed': '安装结果校验失败,请手动检查插件版本。',
'state-unavailable': '无法安全保存更新状态,请先检查当前安装结果。',
interrupted: '上次更新已中断,请检查安装状态后重试。',
disposed: '更新服务已关闭,请手动重新打开设置页。',
'bad-request': '更新请求无效,请重新检查版本。',
'invalid-installation': '当前插件安装不完整或与 profile 不符,请手动检查安装配置。',
'executor-unavailable': BLOCKED_REASONS['unsupported-runtime'],
'registry-check-failed': '无法确认当前 npm 源配置,暂时不能安装更新。',
'install-interrupted': '上次更新已中断,请检查安装状态后重试。',
'install-timeout': '安装超时,请先确认当前安装状态,再决定是否重试。',
'invalid-version': 'npm 返回的版本信息无效,暂时无法更新。',
});
function unwrapSnapshot(result) {
if (result?.ok === false) {
const error = new Error(result.error?.message || '更新请求失败,请重试。');
error.code = result.error?.code;
throw error;
}
const value = result?.value;
if (result?.ok !== true || typeof value?.runningVersion !== 'string'
|| typeof value?.canInstall !== 'boolean') {
throw new Error('更新服务返回了无法识别的响应。');
}
return value;
}
function presentError(error) {
const code = error?.rpcError?.code ?? error?.code ?? '';
const message = error?.rpcError?.message ?? error?.message ?? '';
if (code === 'update-unavailable'
|| /(?:not[-_ ]found|unimplemented|unknown[-_ ](?:endpoint|channel)|no[-_ ]handler)/i.test(code)
|| /(?:not found|unknown (?:endpoint|channel)|not registered|no .*handler|HTTP 404)/i.test(message)) {
return '当前 Host 不支持更新接口,请先手动更新插件并重启。';
}
return BLOCKED_REASONS[code] ?? ERROR_MESSAGES[code]
?? (message.slice(0, 400) || '更新请求失败,请重试。');
}
function summary(snapshot, action, error) {
if (action === 'checking') return '正在从 npm 检查最新版本…';
if (action === 'starting' || snapshot?.job?.state === 'installing') return '正在安装,请稍候…';
if (snapshot?.job?.state === 'verifying') return '正在校验安装结果…';
if (snapshot?.job?.state === 'restart-required' || snapshot?.blockedReason === 'pending-restart') {
return '已安装,待手动重启';
}
if (snapshot?.job?.state === 'completed') return '更新已生效';
if (snapshot?.job?.state === 'failed') return '更新失败';
if (snapshot?.job?.state === 'interrupted') return '上次更新已中断,请检查安装状态后重试。';
if (error) return '更新请求失败';
if (snapshot?.canInstall) return '发现新版本';
if (snapshot?.checkedAt && snapshot.latestVersion === snapshot.runningVersion) return '已是最新版本';
if (snapshot?.blockedReason === 'no-update') return '当前版本无需更新';
if (snapshot?.checkedAt) return '已获取 npm 最新版本';
return '检查 npm 最新版本,不会自动安装。';
}
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 dsh-im-ops@${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('dsh-im-ops@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();
const descriptionId = React.useId();
React.useEffect(() => {
const previous = globalThis.document?.activeElement;
dialogRef.current?.focus?.();
return () => { if (previous?.isConnected) previous.focus?.(); };
}, []);
const content = h('div', {
className: 'dim-updateBackdrop',
onMouseDown: (event) => { if (event.target === event.currentTarget) onClose(); },
},
h('section', {
ref: dialogRef,
className: 'dim-updateDialog',
role: 'dialog',
'aria-modal': 'true',
'aria-labelledby': titleId,
'aria-describedby': descriptionId,
tabIndex: -1,
onKeyDown: (event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
onClose();
}
if (event.key !== 'Tab') return;
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];
const active = globalThis.document?.activeElement;
if (event.shiftKey && (active === first || active === dialogRef.current)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (active === last || active === dialogRef.current)) {
event.preventDefault();
first.focus();
}
},
},
h('h3', { id: titleId }, 'DSH-IM 更新'),
h('p', { id: descriptionId, className: 'dim-updateDescription' },
'仅更新 DSH-IM。安装完成后需手动重启后台;本功能不会自动重启或主动刷新页面。'),
children));
return typeof document !== 'undefined' && document.body
? createPortal(content, document.body)
: content;
}
export function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
const [snapshot, setSnapshot] = React.useState(null);
const [action, setAction] = React.useState('status');
const [error, setError] = React.useState(null);
const [open, setOpen] = React.useState(false);
const [uncertainInstall, setUncertainInstall] = React.useState(false);
const mounted = React.useRef(false);
const busy = React.useRef(false);
const readController = React.useRef(null);
const pollReadController = React.useRef(null);
const installRequest = React.useRef(null);
const onStatusRef = React.useRef(onStatus);
onStatusRef.current = onStatus;
const accept = React.useCallback((next) => {
setSnapshot(next);
onStatusRef.current?.(next);
}, []);
const acceptAuthoritative = (next) => {
// Abort synchronously: effect cleanup alone can lose to an already queued reply.
pollReadController.current?.abort();
setUncertainInstall(false);
accept(next);
};
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
if (typeof rpcCall !== 'function') {
const unavailable = new Error('当前 Host 不支持更新接口,请先手动更新插件并重启。');
unavailable.code = 'update-unavailable';
throw unavailable;
}
return unwrapSnapshot(await rpcCall(endpoint, payload, signal));
}, [rpcCall]);
React.useEffect(() => {
mounted.current = true;
busy.current = true;
const controller = new AbortController();
void invoke('update.status', {}, controller.signal).then((next) => {
if (!controller.signal.aborted) accept(next);
}).catch((cause) => {
if (!controller.signal.aborted) setError(presentError(cause));
}).finally(() => {
if (!controller.signal.aborted) {
busy.current = false;
setAction(null);
}
});
return () => {
mounted.current = false;
controller.abort();
readController.current?.abort();
};
}, [accept, invoke]);
const activeJob = ACTIVE_STATES.has(snapshot?.job?.state);
const restartRequired = snapshot?.job?.state === 'restart-required'
|| snapshot?.blockedReason === 'pending-restart';
const shouldPoll = activeJob || uncertainInstall;
React.useEffect(() => {
if (!shouldPoll) return undefined;
let controller;
const scheduler = createPollScheduler({
setTimeoutFn: (callback, delay) => globalThis.setTimeout(callback, delay),
clearTimeoutFn: (timer) => globalThis.clearTimeout(timer),
});
const poll = async () => {
const pendingController = new AbortController();
controller = pendingController;
pollReadController.current = pendingController;
let keepPolling = true;
try {
const next = await invoke('update.status', {}, pendingController.signal);
if (!pendingController.signal.aborted) {
accept(next);
setError(null);
setUncertainInstall(false);
keepPolling = ACTIVE_STATES.has(next.job?.state);
}
} catch (cause) {
if (!pendingController.signal.aborted) setError(presentError(cause));
} finally {
if (pollReadController.current === pendingController) pollReadController.current = null;
}
if (keepPolling) scheduler.schedule(poll, 1_000);
};
scheduler.schedule(poll, 1_000);
return () => {
controller?.abort();
scheduler.dispose();
};
}, [accept, invoke, shouldPoll]);
const readSnapshot = async (endpoint) => {
const checkLatest = endpoint === 'update.check';
if (!mounted.current || busy.current || (checkLatest && (activeJob || restartRequired))) return;
busy.current = true;
setAction(checkLatest ? 'checking' : 'status');
setError(null);
if (checkLatest) {
installRequest.current = null;
setSnapshot((current) => current
? { ...current, canInstall: false, checkId: null, latestVersion: null, checkedAt: null }
: current);
}
const controller = new AbortController();
readController.current = controller;
try {
const next = await invoke(endpoint, {}, controller.signal);
if (!controller.signal.aborted && mounted.current) {
if (checkLatest) accept(next);
else acceptAuthoritative(next);
}
} catch (cause) {
if (!controller.signal.aborted && mounted.current) setError(presentError(cause));
} finally {
if (!controller.signal.aborted && mounted.current) {
busy.current = false;
setAction(null);
}
}
};
const check = () => readSnapshot('update.check');
const refreshStatus = () => readSnapshot('update.status');
const install = async () => {
if (busy.current || !snapshot?.canInstall || !snapshot.checkId) return;
busy.current = true;
setAction('starting');
setError(null);
if (installRequest.current?.checkId !== snapshot.checkId) {
installRequest.current = {
checkId: snapshot.checkId,
requestId: globalThis.crypto?.randomUUID?.()
?? `update-${Date.now()}-${Math.random().toString(36).slice(2)}`,
};
}
try {
// Closing this page only aborts reads, never an accepted Host installation.
const next = await invoke('update.install', installRequest.current);
if (mounted.current) acceptAuthoritative(next);
} catch (cause) {
if (mounted.current) {
setError(presentError(cause));
setUncertainInstall(true);
}
} finally {
if (mounted.current) {
busy.current = false;
setAction(null);
}
}
};
const busyAction = action !== null;
const blocked = BLOCKED_REASONS[snapshot?.blockedReason] ?? ERROR_MESSAGES[snapshot?.blockedReason];
const canConfirm = snapshot?.canInstall && snapshot.checkId && !activeJob && !restartRequired;
const versionsDiffer = snapshot && clientVersion && snapshot.runningVersion !== clientVersion;
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 ? '待手动重启'
: snapshot?.canInstall ? '更新至'
: '检查更新';
return h(React.Fragment, null,
h('button', {
type: 'button',
className: 'dim-updateButton dim-updateTrigger',
disabled: busyAction,
'aria-haspopup': 'dialog',
onClick: () => {
setOpen(true);
if (restartRequired) void refreshStatus();
else if (!snapshot?.canInstall && !snapshot?.job) void check();
},
}, buttonLabel, buttonLabel === '更新至' ? ` v${snapshot.latestVersion}` : null),
open ? h(UpdateDialog, { onClose: () => setOpen(false) },
h('div', { className: 'dim-updateBody' },
h('dl', { className: 'dim-updateVersions' },
h('dt', null, '运行版本'), h('dd', null, `v${snapshot?.runningVersion ?? clientVersion}`),
snapshot?.installedVersion && snapshot.installedVersion !== snapshot.runningVersion
? h(React.Fragment, null,
h('dt', null, '已安装版本'), h('dd', null, `v${snapshot.installedVersion}`)) : null,
targetVersion ? h(React.Fragment, null,
h('dt', null, '目标版本'), h('dd', null, `v${targetVersion}`)) : null,
h('dt', null, '目标 profile'), h('dd', null, snapshot?.profileName ?? '无法确认')),
h('div', {
className: `dim-updateStatus${error || failedJob ? ' dim-updateStatusError' : ''}`,
role: 'status',
'aria-live': 'polite',
'aria-atomic': 'true',
},
h('strong', null, summary(snapshot, action, error)),
activeJob || action === 'starting'
? h('p', null, '关闭窗口不会取消安装。请勿同时在其他窗口管理此 profile 的插件。') : null,
restartRequired ? h('p', null, BLOCKED_REASONS['pending-restart']) : null,
failedJob && jobMessage ? h('p', null,
BLOCKED_REASONS[jobMessage] ?? ERROR_MESSAGES[jobMessage] ?? jobMessage) : null),
error ? h('p', { className: 'dim-updateError', role: 'alert' }, error) : null,
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,
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', {
type: 'button', className: 'dim-updateButton', disabled: busyAction,
onClick: (event) => {
retainDialogFocus(event);
void (restartRequired ? refreshStatus() : check());
},
}, restartRequired ? '刷新状态' : '重新检查') : null,
canConfirm ? h('button', {
type: 'button', className: 'dim-updateButton dim-updatePrimary', disabled: busyAction,
onClick: (event) => {
retainDialogFocus(event);
void install();
},
}, '安装更新') : null)) : null);
}