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); }