diff --git a/build/plugin-recovery.html b/build/plugin-recovery.html index 6f665ccf..72f761dc 100644 --- a/build/plugin-recovery.html +++ b/build/plugin-recovery.html @@ -220,6 +220,56 @@ .button.primary:hover { border-color: var(--button-hover); background: var(--button-hover); } .button[disabled] { opacity: 0.64; cursor: wait; transform: none; } + [hidden] { display: none !important; } + + .plugin-upgrade-btn { + margin-left: auto; + padding: 4px 10px; + border: 1px solid rgba(22, 163, 74, 0.28); + border-radius: 8px; + color: #15803d; + background: rgba(22, 163, 74, 0.08); + cursor: pointer; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 12px; + font-weight: 550; + line-height: 1.4; + white-space: nowrap; + transition: background 120ms ease, border-color 120ms ease, transform 120ms ease; + } + .plugin-upgrade-btn:hover { + background: rgba(22, 163, 74, 0.16); + border-color: rgba(22, 163, 74, 0.4); + } + .plugin-upgrade-btn:active { + transform: translateY(1px); + } + .plugin-upgrade-btn:disabled { + opacity: 0.6; + cursor: wait; + transform: none; + } + :root[data-theme="dark"] .plugin-upgrade-btn { + color: #4ade80; + background: rgba(74, 222, 128, 0.12); + border-color: rgba(74, 222, 128, 0.25); + } + :root[data-theme="dark"] .plugin-upgrade-btn:hover { + background: rgba(74, 222, 128, 0.2); + border-color: rgba(74, 222, 128, 0.45); + } + @media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .plugin-upgrade-btn { + color: #4ade80; + background: rgba(74, 222, 128, 0.12); + border-color: rgba(74, 222, 128, 0.25); + } + :root:not([data-theme="light"]) .plugin-upgrade-btn:hover { + background: rgba(74, 222, 128, 0.2); + border-color: rgba(74, 222, 128, 0.45); + } + } + .advanced { margin-top: 24px; border-top: 1px solid var(--line); @@ -365,6 +415,8 @@

+ +

@@ -412,7 +464,6 @@

setText('reason-title', model.reasonTitle) setText('reason-detail', model.reasonDetail) setText('safety-note', model.safetyNote) - setText('primary', model.primaryLabel) setText('advanced-label', model.advancedLabel) setText('launch-directory-label', model.launchDirectoryLabel) setText('launch-directory', model.launchDirectory || '—') @@ -420,6 +471,18 @@

setText('technical', model.rawError) setText('show-log', model.logLabel) setText('quit', model.quitLabel) + const safeModeBtn = document.getElementById('safe-mode') + if (safeModeBtn) { + safeModeBtn.textContent = model.safeModeLabel || (model.locale === 'zh' ? '进入安全模式' : 'Enter Safe Mode') + safeModeBtn.addEventListener('click', () => { + safeModeBtn.disabled = true + primary.disabled = true + if (secondary) secondary.disabled = true + document.querySelectorAll('.plugin-upgrade-btn').forEach((btn) => { btn.disabled = true }) + safeModeBtn.textContent = model.locale === 'zh' ? '正在进入安全模式…' : 'Entering Safe Mode…' + navigate('safe-mode') + }) + } const progress = document.getElementById('progress') if (model.progress) { @@ -445,6 +508,29 @@

name.className = 'plugin-name' name.textContent = String(plugin) item.append(mark, name) + + const candidate = model.upgradeCandidate + const candidateName = candidate + ? (candidate.packageName.startsWith('@') + ? candidate.packageName.slice(candidate.packageName.indexOf('/') + 1) + : candidate.packageName) + : undefined + + if (candidate && (candidateName === plugin || model.plugins.length === 1)) { + const upgradeBtn = document.createElement('button') + upgradeBtn.type = 'button' + upgradeBtn.className = 'plugin-upgrade-btn plugin-upgrade' + const versionStr = candidate.targetVersion.startsWith('v') + ? candidate.targetVersion + : `v${candidate.targetVersion}` + upgradeBtn.textContent = model.upgradeHint || (model.locale === 'zh' ? '该插件有新的兼容版本' : 'A compatible update is available') + upgradeBtn.title = model.locale === 'zh' ? `可升级至 ${versionStr}` : `Can upgrade to ${versionStr}` + upgradeBtn.addEventListener('click', () => { + primary.click() + }) + item.appendChild(upgradeBtn) + } + pluginList.appendChild(item) } } @@ -457,10 +543,36 @@

} } const primary = document.getElementById('primary') + const secondary = document.getElementById('secondary') + + if (model.upgradeCandidate) { + setText('primary', model.upgradeLabel) + setText('secondary', model.uninstallLabel || (model.locale === 'zh' ? '仍要卸载此插件' : 'Uninstall plugin instead')) + secondary.hidden = false + secondary.addEventListener('click', () => { + secondary.disabled = true + primary.disabled = true + if (safeModeBtn) safeModeBtn.disabled = true + document.querySelectorAll('.plugin-upgrade-btn').forEach((btn) => { btn.disabled = true }) + secondary.textContent = model.locale === 'zh' ? '正在卸载…' : 'Removing…' + navigate('uninstall') + }) + } else { + setText('primary', model.primaryLabel) + } + primary.addEventListener('click', () => { primary.disabled = true - primary.textContent = model.primaryBusyLabel - navigate(model.canUninstall ? 'uninstall' : 'safe-mode') + if (secondary) secondary.disabled = true + if (safeModeBtn) safeModeBtn.disabled = true + document.querySelectorAll('.plugin-upgrade-btn').forEach((btn) => { btn.disabled = true }) + if (model.upgradeCandidate) { + primary.textContent = model.upgradeBusyLabel || (model.locale === 'zh' ? '正在升级…' : 'Upgrading…') + navigate('upgrade') + } else { + primary.textContent = model.primaryBusyLabel + navigate(model.canUninstall ? 'uninstall' : 'safe-mode') + } }) document.getElementById('show-log').addEventListener('click', () => navigate('show-log')) document.getElementById('quit').addEventListener('click', () => navigate('quit')) diff --git a/build/safe-mode.html b/build/safe-mode.html index 09bceedf..649df522 100644 --- a/build/safe-mode.html +++ b/build/safe-mode.html @@ -75,7 +75,17 @@ .button:hover { background: var(--soft); } .button.primary { border-color: var(--button); color: var(--button-ink); background: var(--button); } .button.danger { color:var(--danger); border-color:rgba(195,59,56,.35); } + .button.success { color:var(--success); border-color:rgba(38,122,74,.35); } + .button.success:hover { background:rgba(38,122,74,.08); } .button[disabled] { opacity: .55; cursor: default; } + .plugin-item { display:flex; align-items:center; justify-content:space-between; border-top:1px solid var(--line); background:var(--surface); } + .plugin-item:first-child { border-top:0; } + .plugin-item .plugin { flex:1 1 auto; min-width:0; } + .plugin-upgrade-btn { margin-right:14px; min-height:28px; padding:4px 10px; border:1px solid var(--success); border-radius:8px; color:var(--success); background:transparent; cursor:pointer; font-size:11px; font-weight:650; white-space:nowrap; flex:0 0 auto; } + .plugin-upgrade-btn:hover { background:rgba(38,122,74,.09); } + .plugin-upgrade-btn[disabled] { opacity:.55; cursor:wait; } + .plugin-status.success { color:var(--success); } + .plugin-version { margin-left:4px; color:var(--muted); font-size:11px; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; } .footer { display: flex; justify-content: flex-end; padding: 0 32px 18px; } .quit { border: 0; color: var(--muted); background: transparent; cursor: pointer; font-size: 12px; font-weight: 600; } :root[data-theme="dark"] { --canvas:#141416; --surface:#202023; --soft:#29292d; --ink:#f2f2f4; --muted:#a5a7ad; --line:#3a3a3f; --strong:#4a4a50; --button:#f4f4f5; --button-ink:#111217; --danger:#ee7772; --success:#78d59a; } @@ -101,6 +111,7 @@

+
@@ -136,6 +147,7 @@

const list = document.getElementById('items') const empty = document.getElementById('empty') const apply = document.getElementById('apply') + const upgradeAll = document.getElementById('upgrade-all') const recoveryOpen = document.getElementById('recovery-open') const agent = document.getElementById('agent') const restart = document.getElementById('restart') @@ -145,6 +157,10 @@

apply.hidden = true recoveryOpen.hidden = false } + if (model.upgradeAllLabel && model.upgradeReadyCount > 0 && model.recoveryLocked !== true) { + upgradeAll.hidden = false + upgradeAll.textContent = model.upgradeAllLabel + } const pluginCheckboxes = [] const issueCheckboxes = [] for (const group of Array.isArray(model.issueGroups) ? model.issueGroups : []) { @@ -175,20 +191,48 @@

: (Array.isArray(model.plugins) ? model.plugins : []).map((name) => ({ name })) for (const plugin of pluginItems) { const item = document.createElement('li') + item.className = 'plugin-item' const label = document.createElement('label'); label.className = 'plugin' const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.value = String(plugin.name) const copy = document.createElement('span'); copy.className = 'plugin-copy' const title = document.createElement('span'); title.className = 'plugin-title' const name = document.createElement('span'); name.className = 'plugin-name'; name.textContent = String(plugin.name) title.appendChild(name) + if (plugin.installedVersion) { + const ver = document.createElement('span') + ver.className = 'plugin-version' + ver.textContent = `v${plugin.installedVersion}` + title.appendChild(ver) + } if (plugin.statusLabel) { const status = document.createElement('span'); status.className = 'plugin-status'; status.textContent = String(plugin.statusLabel) if (plugin.statusTone === 'warning') status.classList.add('warning') + else if (plugin.statusTone === 'success') status.classList.add('success') title.appendChild(status) } const action = document.createElement('span'); action.className = 'plugin-action'; action.textContent = String(plugin.actionLabel || '') copy.append(title, action) - label.append(checkbox, copy); item.appendChild(label); list.appendChild(item); pluginCheckboxes.push(checkbox) + label.append(checkbox, copy); item.appendChild(label) + + if (plugin.upgradeButtonLabel) { + const upgradeBtn = document.createElement('button') + upgradeBtn.type = 'button' + upgradeBtn.className = 'plugin-upgrade-btn' + upgradeBtn.textContent = String(plugin.upgradeButtonLabel) + upgradeBtn.addEventListener('click', (e) => { + e.stopPropagation() + upgradeBtn.disabled = true + upgradeBtn.textContent = model.locale === 'zh' ? '正在升级…' : 'Upgrading…' + apply.disabled = true + if (upgradeAll) upgradeAll.disabled = true + restart.disabled = true + agent.disabled = true + void window.dshSafeMode.action('upgrade', { plugins: [plugin.name] }) + }) + item.appendChild(upgradeBtn) + } + + list.appendChild(item); pluginCheckboxes.push(checkbox) } const allCheckboxes = [...issueCheckboxes, ...pluginCheckboxes] if (allCheckboxes.length === 0) empty.classList.add('visible') @@ -202,13 +246,24 @@

}) if (plugins.length === 0 && issues.length === 0) return apply.disabled = true; apply.textContent = model.applyBusyLabel; agent.disabled = true; restart.disabled = true + if (upgradeAll) upgradeAll.disabled = true void window.dshSafeMode.action('apply', { plugins, issues }) }) + upgradeAll.addEventListener('click', () => { + const targets = pluginItems.filter((p) => p.upgradeReady).map((p) => p.name) + if (targets.length === 0) return + upgradeAll.disabled = true + upgradeAll.textContent = model.upgradeAllBusyLabel || (model.locale === 'zh' ? '正在升级…' : 'Upgrading…') + apply.disabled = true + restart.disabled = true + agent.disabled = true + void window.dshSafeMode.action('upgrade', { plugins: targets }) + }) recoveryOpen.addEventListener('click', () => { recoveryOpen.disabled = true; void window.dshSafeMode.action('recovery-open', {}) }) - agent.addEventListener('click', () => { agent.disabled = true; agent.textContent = model.agentBusyLabel; apply.disabled = true; restart.disabled = true; void window.dshSafeMode.action('agent', {}) }) + agent.addEventListener('click', () => { agent.disabled = true; agent.textContent = model.agentBusyLabel; apply.disabled = true; restart.disabled = true; if (upgradeAll) upgradeAll.disabled = true; void window.dshSafeMode.action('agent', {}) }) restart.addEventListener('click', () => { if (model.restartConfirm && !window.confirm(String(model.restartConfirm))) return - restart.disabled = true; restart.textContent = model.restartBusyLabel; apply.disabled = true; agent.disabled = true; void window.dshSafeMode.action('restart', {}) + restart.disabled = true; restart.textContent = model.restartBusyLabel; apply.disabled = true; agent.disabled = true; if (upgradeAll) upgradeAll.disabled = true; void window.dshSafeMode.action('restart', {}) }) document.getElementById('quit').addEventListener('click', () => void window.dshSafeMode.action('quit', {})) diff --git a/packages/dsh-desktop-market-installer/generations/projection.d.ts b/packages/dsh-desktop-market-installer/generations/projection.d.ts index 05aff11a..3549e62f 100644 --- a/packages/dsh-desktop-market-installer/generations/projection.d.ts +++ b/packages/dsh-desktop-market-installer/generations/projection.d.ts @@ -12,6 +12,7 @@ export interface PublishedGenerationManifest { export function projectGenerations(dshHome: string, profile?: string): Promise export function publishGenerationManifest( dshHome: string, - profile?: string + profile?: string, + options?: { syncBundles?: boolean } ): Promise export function exposeMissingGenerationLinks(dshHome: string, profile?: string): Promise diff --git a/packages/dsh-desktop-market-installer/generations/projection.mjs b/packages/dsh-desktop-market-installer/generations/projection.mjs index 846586c5..898d5dd9 100644 --- a/packages/dsh-desktop-market-installer/generations/projection.mjs +++ b/packages/dsh-desktop-market-installer/generations/projection.mjs @@ -245,14 +245,15 @@ export async function projectGenerations(dshHome, profile = 'web') { * Publish the desired generation set for inventory and the next launch without * touching the active Profile's node_modules. Market operations run inside the * live Harness, so replacing even a junction there recreates the Windows - * rename conflict generations are meant to avoid. The cold-start projector - * materializes these links after Harness has stopped. + * rename conflict generations are meant to avoid. A removal may still update + * the manifest's bundle list: that only changes what the *next* Harness boot + * composes, while leaving the current process and its links intact. */ -export async function publishGenerationManifest(dshHome, profile = 'web') { +export async function publishGenerationManifest(dshHome, profile = 'web', { syncBundles = false } = {}) { const { dir, manifestState, enabled, linkSpecs } = await prepareGenerationProjection(dshHome, profile) const bundles = await syncProfileManifest(dir, enabled, linkSpecs, manifestState, { - syncBundles: false + syncBundles }) return { plugins: [...enabled.keys()], bundles } } diff --git a/packages/dsh-desktop-market-installer/index.js b/packages/dsh-desktop-market-installer/index.js index 9e06719b..6dd30bc0 100644 --- a/packages/dsh-desktop-market-installer/index.js +++ b/packages/dsh-desktop-market-installer/index.js @@ -541,7 +541,13 @@ export function createDesktopPnpmService(options) { if (isCancelled()) return { exitCode: 1, message: 'The package operation was aborted.' } write(`Disabling ${generationRemoval} generation for the next restart…`) await disableGeneration(home, generationRemoval) - const published = await publishGenerationManifest(home) + // Do not replace the active link while Harness is running, but do + // remove this bundle from the next boot's composition now. Waiting + // for startup projection leaves an uninstalled plugin live forever + // when an unrelated migration preflight is deferred. + const published = await publishGenerationManifest(home, MARKET_PROFILE, { + syncBundles: true + }) write(`staged for next restart: ${published.plugins.join(', ')}`) return { exitCode: 0 } }) diff --git a/src/main/index.ts b/src/main/index.ts index 729cfbbf..87b60ff2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -30,7 +30,7 @@ import { clearProfileInstallMarker, markProfileInstallComplete } from './state/profile-install-marker' -import { inspectProfileConsistency } from './state/profile-consistency' +import { healProfileBundles, inspectProfileConsistency } from './state/profile-consistency' import { disableProfilePlugins, inspectProfileCompatibility, @@ -122,6 +122,15 @@ import { } from '../shared/desktop-menu' import { buildPluginRecoveryViewModel } from './plugin-recovery-view' import { buildSafeModeViewModel, shouldStartInSafeMode } from './safe-mode' +import { + checkupAllProfilePlugins, + evaluatePluginMarketCompatibility, + readBundledDshVersion, + readInstalledPluginVersion, + type PluginHealthReport, + type PluginUpgradeCandidate +} from './state/plugin-market-check' +import { upgradePluginToGeneration } from './state/plugin-upgrade' import { aboutDetail, bundledHarnessVersion } from './version-info' import { windowsMenuViewBounds } from './windows-menu-view' import { shouldKeepRunningInBackground } from './close-to-tray' @@ -130,9 +139,10 @@ import { shouldReloadAfterMainWindowRendererLoss } from './main-window-recovery' -type PluginRecoveryAction = 'uninstall' | 'show-log' | 'quit' | 'restart' | 'refresh' | 'safe-mode' +type PluginRecoveryAction = 'uninstall' | 'upgrade' | 'show-log' | 'quit' | 'restart' | 'refresh' | 'safe-mode' type SafeModeAction = | { type: 'apply'; plugins: string[]; issues: string[] } + | { type: 'upgrade'; plugins: string[] } | { type: 'recovery-open' } | { type: 'backup-open'; removalId: string } | { type: 'backup-restore'; removalId: string } @@ -143,6 +153,7 @@ type SafeModeAction = const PLUGIN_RECOVERY_ACTIONS = new Set([ 'uninstall', + 'upgrade', 'show-log', 'quit', 'restart', @@ -1019,6 +1030,10 @@ async function showSplash(): Promise { */ async function reportProfileConsistency(dshHome: string): Promise { try { + const healed = await healProfileBundles(dshHome) + if (healed.length > 0) { + runtime.note(`[desktop] auto-composed ${healed.length} missing bundle(s): ${healed.join(', ')}`) + } const findings = await inspectProfileConsistency(dshHome) const store = await inspectStoreConsistency(dshHome) if (store) findings.push(store) @@ -1525,6 +1540,7 @@ async function waitForPluginRecoveryAction(options: { plugins: readonly string[] removedPlugins: readonly string[] notice?: string + upgradeCandidate?: PluginUpgradeCandidate }): Promise { const window = mainWindow && !mainWindow.isDestroyed() ? mainWindow : createWindow() const state = buildPluginRecoveryViewModel({ @@ -1588,6 +1604,8 @@ async function showPluginRecovery(options?: { return true } + const attemptedUpgrades = new Map() + try { while (!quitting) { const snapshot = runtime.snapshot() @@ -1603,6 +1621,37 @@ async function showPluginRecovery(options?: { appendPluginRecoveryDetectionLog(detection.plugins) waitForRendererEvidence = false if (applyPendingFrontendEvidence()) continue + + let upgradeCandidate: PluginUpgradeCandidate | undefined + if (detection.plugins.length === 1) { + const targetPlugin = detection.plugins[0]! + try { + const installedVersion = await readInstalledPluginVersion(dshHome, targetPlugin) + const runtimeVersion = + (await readBundledDshVersion(join(app.getAppPath(), 'node_modules'))) || '0.1.2-alpha.1' + const check = await evaluatePluginMarketCompatibility({ + packageName: targetPlugin, + installedVersion, + currentRuntimeVersion: runtimeVersion, + hasLocalIssue: true, + locale: harnessLocale() + }) + if ( + check.upgradeReady && + check.upgradeVersion && + attemptedUpgrades.get(targetPlugin) !== check.upgradeVersion + ) { + upgradeCandidate = { + packageName: targetPlugin, + targetVersion: check.upgradeVersion, + installedVersion + } + } + } catch (error) { + runtime.note(`[plugin-recovery] market check failed for ${targetPlugin}: ${String(error)}`) + } + } + const action = await waitForPluginRecoveryAction({ snapshot: { ...snapshot, @@ -1611,13 +1660,59 @@ async function showPluginRecovery(options?: { }, plugins: detection.plugins, removedPlugins, - notice + notice, + upgradeCandidate }) notice = undefined if (action === 'refresh') { applyPendingFrontendEvidence() continue + } else if (action === 'upgrade' && upgradeCandidate) { + await runtime.stop() + const upgradeResult = await upgradePluginToGeneration({ + dshHome, + pluginName: upgradeCandidate.packageName, + targetVersion: upgradeCandidate.targetVersion, + nodeExecutablePath: bundledNodePath(), + pnpmEntryPath: bundledPnpmEntryPath(), + note: (line) => runtime.note(line) + }) + attemptedUpgrades.set(upgradeCandidate.packageName, upgradeCandidate.targetVersion) + + if (!upgradeResult.ok) { + notice = isChinese + ? `升级 ${upgradeCandidate.packageName} 到 v${upgradeCandidate.targetVersion} 失败:${upgradeResult.detail ?? '未知错误'}。您可以重试或卸载该插件。` + : `Failed to upgrade ${upgradeCandidate.packageName} to v${upgradeCandidate.targetVersion}: ${upgradeResult.detail ?? 'unknown error'}. You may retry or uninstall.` + continue + } + + const compatibility = await inspectProfileCompatibility( + dshHome, + join(app.getAppPath(), 'node_modules') + ) + const blockingIssues = compatibility.issues.filter((issue) => issue.severity === 'blocking') + if (blockingIssues.length > 0) { + runtime.note( + `[plugin-recovery] normal mode remains blocked by ${blockingIssues.length} ` + + `profile compatibility issue${blockingIssues.length === 1 ? '' : 's'} after upgrade` + ) + notice = isChinese + ? `已升级至 v${upgradeCandidate.targetVersion},但 Profile 仍有 ${blockingIssues.length} 项兼容问题。` + + '为避免再次进入空白界面,请进入安全模式继续处理。' + : `Upgraded to v${upgradeCandidate.targetVersion}, but ${blockingIssues.length} blocking profile compatibility ` + + `issue${blockingIssues.length === 1 ? ' remains' : 's remain'}. ` + + 'Continue in Safe Mode to avoid another blank normal window.' + continue + } + + await launchHarness() + if (applyPendingFrontendEvidence()) continue + if (runtime.snapshot().phase === 'ready') { + schedulePluginRecoverySessionReset() + return + } + continue } else if (action === 'uninstall' && detection.plugins.length > 0) { // The normal web Harness may still have the failing plugin imported. // macOS permits renaming an open directory, but Windows does not; stop @@ -1726,6 +1821,7 @@ async function waitForSafeModeAction(options: { plugins: readonly string[] suspectedPlugins: readonly string[] issues: readonly ProfileCompatibilityIssue[] + healthReports?: readonly PluginHealthReport[] backups: Awaited>['backups'] recoveryLocked: boolean backupRestoreLocked: boolean @@ -1777,6 +1873,7 @@ async function waitForSafeModeAction(options: { plugins: options.plugins, suspectedPlugins: options.suspectedPlugins, issues: options.issues, + healthReports: options.healthReports, backups: options.backups, recoveryLocked: options.recoveryLocked, backupRestoreLocked: options.backupRestoreLocked, @@ -2001,6 +2098,24 @@ async function showSafeModeManager(initial?: { noticeTone ??= 'error' } const installed = [...new Set([...active, ...pendingRemovals])] + let healthReports: PluginHealthReport[] | undefined + if (installed.length > 0 && !recoveryLocked) { + try { + const incompatiblePluginNames = compatibility.issues + .filter((issue) => issue.resolution === 'disable-plugin') + .map((issue) => issue.target) + healthReports = await checkupAllProfilePlugins({ + plugins: installed, + dshHome, + bundledNodeModulesPath: join(app.getAppPath(), 'node_modules'), + incompatiblePlugins: [...new Set([...safeModeSuspectedPlugins, ...incompatiblePluginNames])], + locale: harnessLocale() + }) + } catch (error) { + runtime.note(`[safe-mode] plugin market health checkup failed: ${String(error)}`) + } + } + const allowedRestoreId = recoveryLocked && !migrationRecoveryLocked && removalLedgerReadable && @@ -2017,6 +2132,7 @@ async function showSafeModeManager(initial?: { plugins: installed, suspectedPlugins: safeModeSuspectedPlugins, issues: compatibility.issues, + healthReports, backups: removalBackups.backups, recoveryLocked, backupRestoreLocked, @@ -2167,6 +2283,57 @@ async function showSafeModeManager(initial?: { return } + if (action.type === 'upgrade') { + if (await refreshMigrationRecoveryLock(dshHome)) { + notice = isChinese ? 'Profile 恢复事务完成前禁止升级插件。' : 'Plugins cannot be upgraded until the recovery transaction completes.' + noticeTone = 'error' + continue + } + const reportsByPkg = new Map((healthReports ?? []).map((r) => [r.packageName, r])) + const targets = action.plugins.filter((pkg) => { + const report = reportsByPkg.get(pkg) + return report?.upgradeReady && report.upgradeVersion + }) + + if (targets.length === 0) { + notice = isChinese ? '所选插件没有可升级的兼容版本。' : 'No compatible upgrade candidate found for selected plugins.' + noticeTone = 'error' + continue + } + + let upgradedCount = 0 + const failedPackages: string[] = [] + for (const pkg of targets) { + const report = reportsByPkg.get(pkg)! + const res = await upgradePluginToGeneration({ + dshHome, + pluginName: pkg, + targetVersion: report.upgradeVersion!, + nodeExecutablePath: bundledNodePath(), + pnpmEntryPath: bundledPnpmEntryPath(), + note: (line) => runtime.note(line) + }) + if (res.ok) { + upgradedCount++ + } else { + failedPackages.push(pkg) + } + } + + if (failedPackages.length === 0) { + notice = isChinese + ? `成功升级 ${upgradedCount} 个插件。` + : `Successfully upgraded ${upgradedCount} plugin${upgradedCount === 1 ? '' : 's'}.` + noticeTone = 'success' + } else { + notice = isChinese + ? `成功升级 ${upgradedCount} 个插件,${failedPackages.length} 个升级失败(${failedPackages.join('、')})。` + : `Upgraded ${upgradedCount} plugin${upgradedCount === 1 ? '' : 's'}; ${failedPackages.length} failed (${failedPackages.join(', ')}).` + noticeTone = 'error' + } + continue + } + if (await refreshMigrationRecoveryLock(dshHome)) { notice = isChinese ? 'Profile 恢复事务完成前禁止修改正常 Profile。' : 'The normal Profile is locked until the recovery transaction completes.' noticeTone = 'error' @@ -2522,6 +2689,7 @@ async function bootstrap(): Promise { !safeModeManagerVisible || ( action !== 'apply' && + action !== 'upgrade' && action !== 'recovery-open' && action !== 'backup-open' && action !== 'backup-restore' && @@ -2535,7 +2703,7 @@ async function bootstrap(): Promise { } await refreshMigrationRecoveryLock(join(app.getPath('userData'), 'harness')) if ( - (action === 'apply' || action === 'backup-delete') && profileRecoveryLocked() + (action === 'apply' || action === 'upgrade' || action === 'backup-delete') && profileRecoveryLocked() ) return { ok: false } if (action === 'apply') { if (typeof selection !== 'object' || selection === null) return { ok: false } @@ -2549,6 +2717,16 @@ async function bootstrap(): Promise { return { ok: false } } resolveSafeModeAction({ type: 'apply', plugins, issues }) + } else if (action === 'upgrade') { + if (typeof selection !== 'object' || selection === null) return { ok: false } + const { plugins } = selection as { plugins?: unknown } + if ( + !Array.isArray(plugins) || + !plugins.every((plugin) => typeof plugin === 'string') + ) { + return { ok: false } + } + resolveSafeModeAction({ type: 'upgrade', plugins }) } else if ( action === 'backup-open' || action === 'backup-restore' || diff --git a/src/main/plugin-recovery-view.ts b/src/main/plugin-recovery-view.ts index 334706bf..ddb8e357 100644 --- a/src/main/plugin-recovery-view.ts +++ b/src/main/plugin-recovery-view.ts @@ -2,6 +2,12 @@ import type { RuntimeSnapshot } from '../shared/contracts' export type PluginRecoveryLocale = 'en' | 'zh' +export interface PluginRecoveryUpgradeCandidate { + packageName: string + targetVersion: string + installedVersion?: string +} + export interface PluginRecoveryViewModel { locale: PluginRecoveryLocale brand: string @@ -17,6 +23,11 @@ export interface PluginRecoveryViewModel { safetyNote: string primaryLabel: string primaryBusyLabel: string + upgradeCandidate?: PluginRecoveryUpgradeCandidate + upgradeLabel?: string + upgradeBusyLabel?: string + upgradeHint?: string + uninstallLabel?: string logLabel: string advancedLabel: string errorLabel: string @@ -24,6 +35,7 @@ export interface PluginRecoveryViewModel { launchDirectory?: string rawError: string quitLabel: string + safeModeLabel: string canUninstall: boolean } @@ -146,8 +158,9 @@ export function buildPluginRecoveryViewModel(options: { removedPlugins: readonly string[] locale: PluginRecoveryLocale notice?: string + upgradeCandidate?: PluginRecoveryUpgradeCandidate }): PluginRecoveryViewModel { - const { snapshot, locale, notice } = options + const { snapshot, locale, notice, upgradeCandidate } = options const pluginPackages = [...new Set(options.plugins)] const plugins = pluginPackages.map(displayPluginName) const removedPlugins = [...new Set(options.removedPlugins)].map(displayPluginName) @@ -179,6 +192,15 @@ export function buildPluginRecoveryViewModel(options: { ? multiple ? `卸载这 ${plugins.length} 个插件并继续检测` : '卸载此插件并继续检测' : '进入安全模式', primaryBusyLabel: canUninstall ? '正在处理并重新检测…' : '正在进入安全模式…', + upgradeCandidate, + upgradeLabel: upgradeCandidate + ? '升级插件并重启' + : undefined, + upgradeBusyLabel: upgradeCandidate ? '正在升级…' : undefined, + upgradeHint: upgradeCandidate + ? '该插件有新的兼容版本' + : undefined, + uninstallLabel: upgradeCandidate ? '仍要卸载此插件' : undefined, logLabel: '打开 Harness 日志', advancedLabel: '查看技术详情', errorLabel: '错误信息', @@ -186,6 +208,7 @@ export function buildPluginRecoveryViewModel(options: { launchDirectory: snapshot.launchDirectory, rawError: snapshot.message, quitLabel: '退出 DSH Desktop', + safeModeLabel: '进入安全模式', canUninstall } } @@ -213,6 +236,15 @@ export function buildPluginRecoveryViewModel(options: { ? multiple ? `Remove these ${plugins.length} plugins and continue` : 'Remove this plugin and continue' : 'Enter Safe Mode', primaryBusyLabel: canUninstall ? 'Removing and checking again…' : 'Entering Safe Mode…', + upgradeCandidate, + upgradeLabel: upgradeCandidate + ? 'Upgrade plugin and restart' + : undefined, + upgradeBusyLabel: upgradeCandidate ? 'Upgrading…' : undefined, + upgradeHint: upgradeCandidate + ? 'A compatible update is available' + : undefined, + uninstallLabel: upgradeCandidate ? 'Uninstall this plugin instead' : undefined, logLabel: 'Open Harness log', advancedLabel: 'View technical details', errorLabel: 'Error details', @@ -220,6 +252,7 @@ export function buildPluginRecoveryViewModel(options: { launchDirectory: snapshot.launchDirectory, rawError: snapshot.message, quitLabel: 'Quit DSH Desktop', + safeModeLabel: 'Enter Safe Mode', canUninstall } } diff --git a/src/main/safe-mode.ts b/src/main/safe-mode.ts index 354f2e3a..1fd15f8a 100644 --- a/src/main/safe-mode.ts +++ b/src/main/safe-mode.ts @@ -1,4 +1,5 @@ import type { ProfileCompatibilityIssue } from './state/profile-compatibility' +import type { PluginHealthReport, PluginHealthStatus } from './state/plugin-market-check' export type SafeModeLocale = 'en' | 'zh' @@ -24,10 +25,17 @@ export interface SafeModeIssueGroupViewModel { export interface SafeModePluginViewModel { name: string statusLabel?: string - statusTone?: 'warning' | 'danger' + statusTone?: 'warning' | 'danger' | 'success' actionLabel: string incompatible: boolean suspected: boolean + healthStatus?: PluginHealthStatus + healthLabel?: string + installedVersion?: string + latestVersion?: string + upgradeReady?: boolean + upgradeVersion?: string + upgradeButtonLabel?: string } export interface SafeModeBackupViewModel { @@ -73,6 +81,9 @@ export interface SafeModeViewModel { quitLabel: string notice?: string noticeTone?: 'success' | 'error' + upgradeAllLabel?: string + upgradeAllBusyLabel?: string + upgradeReadyCount: number } export function shouldStartInSafeMode(argv: readonly string[]): boolean { @@ -84,6 +95,7 @@ export function buildSafeModeViewModel(options: { plugins: readonly string[] suspectedPlugins?: readonly string[] issues?: readonly ProfileCompatibilityIssue[] + healthReports?: readonly PluginHealthReport[] backups?: readonly { removalId: string pluginName: string @@ -150,9 +162,13 @@ export function buildSafeModeViewModel(options: { ...incompatiblePlugins, ...suspectedPlugins ])].sort((left, right) => Number(suspectedPlugins.has(right)) - Number(suspectedPlugins.has(left))) + const healthReportByPlugin = new Map( + (options.healthReports ?? []).map((report) => [report.packageName, report]) + ) const pluginItems = plugins.map((name): SafeModePluginViewModel => { const incompatible = incompatiblePlugins.has(name) const suspected = suspectedPlugins.has(name) + const report = healthReportByPlugin.get(name) const labels = [ ...(suspected ? [options.locale === 'zh' ? '本次启动日志推断' : 'inferred from this startup log'] @@ -161,6 +177,22 @@ export function buildSafeModeViewModel(options: { ? [options.locale === 'zh' ? '版本不兼容' : 'version incompatible'] : []) ] + if (report?.healthLabel && !incompatible && !suspected) { + labels.push(report.healthLabel) + } + const statusTone = incompatible + ? 'danger' + : suspected + ? 'warning' + : report?.upgradeReady + ? 'success' + : undefined + const upgradeButtonLabel = report?.upgradeReady && report.upgradeVersion + ? options.locale === 'zh' + ? `升级至 v${report.upgradeVersion}` + : `Upgrade to v${report.upgradeVersion}` + : undefined + return { name, statusLabel: labels.length > 0 @@ -168,12 +200,20 @@ export function buildSafeModeViewModel(options: { ? `(${labels.join(',')})` : `(${labels.join(', ')})` : undefined, - statusTone: incompatible ? 'danger' : suspected ? 'warning' : undefined, + statusTone, actionLabel: options.locale === 'zh' ? '卸载插件' : 'Remove plugin', incompatible, - suspected + suspected, + ...(report?.healthStatus !== undefined ? { healthStatus: report.healthStatus } : {}), + ...(report?.healthLabel !== undefined ? { healthLabel: report.healthLabel } : {}), + ...(report?.installedVersion !== undefined ? { installedVersion: report.installedVersion } : {}), + ...(report?.latestVersion !== undefined ? { latestVersion: report.latestVersion } : {}), + ...(report?.upgradeReady !== undefined ? { upgradeReady: report.upgradeReady } : {}), + ...(report?.upgradeVersion !== undefined ? { upgradeVersion: report.upgradeVersion } : {}), + ...(upgradeButtonLabel !== undefined ? { upgradeButtonLabel } : {}) } }) + const upgradeReadyCount = pluginItems.filter((item) => item.upgradeReady).length const groups = new Map() for (const issue of issues.filter((issue) => issue.resolution !== 'disable-plugin')) { const id = issue.groupId ?? `${issue.resolution}:${issue.target}` @@ -308,7 +348,12 @@ export function buildSafeModeViewModel(options: { : undefined, quitLabel: '退出 DSH Desktop', notice: options.notice, - noticeTone: options.noticeTone + noticeTone: options.noticeTone, + upgradeAllLabel: upgradeReadyCount > 0 + ? `一键升级 ${upgradeReadyCount} 个已适配插件` + : undefined, + upgradeAllBusyLabel: '正在批量升级…', + upgradeReadyCount } } @@ -345,6 +390,11 @@ export function buildSafeModeViewModel(options: { : undefined, quitLabel: 'Quit DSH Desktop', notice: options.notice, - noticeTone: options.noticeTone + noticeTone: options.noticeTone, + upgradeAllLabel: upgradeReadyCount > 0 + ? `Upgrade ${upgradeReadyCount} compatible plugin${upgradeReadyCount === 1 ? '' : 's'}` + : undefined, + upgradeAllBusyLabel: 'Upgrading plugins…', + upgradeReadyCount } } diff --git a/src/main/state/plugin-market-check.ts b/src/main/state/plugin-market-check.ts new file mode 100644 index 00000000..557f9955 --- /dev/null +++ b/src/main/state/plugin-market-check.ts @@ -0,0 +1,471 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { profilePackageJsonPath } from './plugin-recovery' + +export interface NpmPackageManifest { + name: string + version: string + dependencies?: Record + optionalDependencies?: Record + peerDependencies?: Record + peerDependenciesMeta?: Record + engines?: Record + dsh?: { + bundle?: { + patch?: string + } + client?: { + platform?: string + inject?: string[] + } + minVersion?: string + } +} + +export type PluginHealthStatus = + | 'up-to-date' + | 'upgrade-available' + | 'incompatible-fixed-in-latest' + | 'incompatible-no-fix' + | 'checking' + | 'check-failed' + +export interface PluginHealthReport { + packageName: string + installedVersion?: string + latestVersion?: string + healthStatus: PluginHealthStatus + healthLabel: string + upgradeReady: boolean + upgradeVersion?: string + detail?: string +} + +export interface PluginUpgradeCandidate { + packageName: string + targetVersion: string + installedVersion?: string +} + +export const DEFAULT_NPM_REGISTRY = 'https://registry.npmmirror.com' +export const FALLBACK_NPM_REGISTRY = 'https://registry.npmjs.org' +export const DEFAULT_MARKET_CHECK_TIMEOUT_MS = 2_500 + +/** + * Parsed Semver version. + */ +export interface SemverVersion { + major: number + minor: number + patch: number + prerelease: Array +} + +const SEMVER_PATTERN = + /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/ + +export function parseSemver(input: string): SemverVersion | null { + if (typeof input !== 'string') return null + const trimmed = input.trim() + const match = SEMVER_PATTERN.exec(trimmed) + if (!match) return null + + const major = Number(match[1]) + const minor = Number(match[2]) + const patch = Number(match[3]) + const prerelease = match[4] + ? match[4].split('.').map((part) => (/^\d+$/.test(part) ? Number(part) : part)) + : [] + + return { major, minor, patch, prerelease } +} + +export function compareSemver(aStr: string, bStr: string): number { + const a = parseSemver(aStr) + const b = parseSemver(bStr) + if (!a && !b) return aStr.localeCompare(bStr) + if (!a) return -1 + if (!b) return 1 + + if (a.major !== b.major) return a.major > b.major ? 1 : -1 + if (a.minor !== b.minor) return a.minor > b.minor ? 1 : -1 + if (a.patch !== b.patch) return a.patch > b.patch ? 1 : -1 + + // When one has prerelease and other does not, version without prerelease is greater + if (a.prerelease.length === 0 && b.prerelease.length > 0) return 1 + if (a.prerelease.length > 0 && b.prerelease.length === 0) return -1 + if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0 + + const len = Math.max(a.prerelease.length, b.prerelease.length) + for (let i = 0; i < len; i += 1) { + const aPart = a.prerelease[i] + const bPart = b.prerelease[i] + if (aPart === undefined) return -1 + if (bPart === undefined) return 1 + if (aPart === bPart) continue + + const aNum = typeof aPart === 'number' + const bNum = typeof bPart === 'number' + if (aNum && !bNum) return -1 + if (!aNum && bNum) return 1 + return aPart > bPart ? 1 : -1 + } + + return 0 +} + +/** + * Check whether a version satisfies a comparator: e.g. "^0.1.2", ">=0.1.0", "~1.0.0", "*", "0.1.2-alpha.1". + */ +export function satisfiesComparator(versionStr: string, comparator: string): boolean { + const comp = comparator.trim() + if (!comp || comp === '*' || comp === 'x' || comp === 'X') return true + + const v = parseSemver(versionStr) + if (!v) return false + + // Handle caret ^ + if (comp.startsWith('^')) { + const target = comp.slice(1).trim() + const t = parseSemver(target) + if (!t) return false + + // Prerelease versions only satisfy ranges that have the same [major, minor, patch] tuple with a prerelease + if (v.prerelease.length > 0) { + if (t.prerelease.length === 0 || v.major !== t.major || v.minor !== t.minor || v.patch !== t.patch) { + return false + } + } + + // Must be >= target + if (compareSemver(versionStr, target) < 0) return false + + // Next breaking bump + if (t.major > 0) { + return v.major === t.major + } + if (t.minor > 0) { + return v.major === 0 && v.minor === t.minor + } + return v.major === 0 && v.minor === 0 && v.patch === t.patch + } + + // Handle tilde ~ + if (comp.startsWith('~')) { + const target = comp.slice(1).trim() + const t = parseSemver(target) + if (!t) return false + if (v.prerelease.length > 0) { + if (t.prerelease.length === 0 || v.major !== t.major || v.minor !== t.minor || v.patch !== t.patch) { + return false + } + } + if (compareSemver(versionStr, target) < 0) return false + return v.major === t.major && v.minor === t.minor + } + + if (comp.startsWith('>=')) { + const target = comp.slice(2).trim() + return compareSemver(versionStr, target) >= 0 + } + if (comp.startsWith('>')) { + const target = comp.slice(1).trim() + return compareSemver(versionStr, target) > 0 + } + if (comp.startsWith('<=')) { + const target = comp.slice(2).trim() + return compareSemver(versionStr, target) <= 0 + } + if (comp.startsWith('<')) { + const target = comp.slice(1).trim() + return compareSemver(versionStr, target) < 0 + } + if (comp.startsWith('=')) { + const target = comp.slice(1).trim() + return compareSemver(versionStr, target) === 0 + } + + // Exact version + return compareSemver(versionStr, comp) === 0 +} + +/** + * Check whether a version satisfies a semver range: e.g. ">=0.1.0 <0.2.0" or "^0.1.0 || ^0.2.0". + */ +export function satisfiesRange(versionStr: string, range: string): boolean { + if (!range || range.trim() === '*' || range.trim() === '') return true + + // Multiple alternatives separated by || + const alternatives = range.split('||').map((alt) => alt.trim()).filter(Boolean) + if (alternatives.length === 0) return true + + return alternatives.some((alt) => { + // AND conditions separated by whitespace + const parts = alt.split(/\s+/).filter(Boolean) + return parts.every((part) => satisfiesComparator(versionStr, part)) + }) +} + +// In-memory cache for package metadata to avoid repeated network hits +const manifestCache = new Map() +const CACHE_TTL_MS = 5 * 60 * 1000 + +export async function fetchPluginManifestFromRegistry( + packageName: string, + options?: { + registry?: string + timeoutMs?: number + fetchFn?: typeof fetch + } +): Promise { + const cached = manifestCache.get(packageName) + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { + return cached.manifest + } + + const registries = [ + options?.registry || DEFAULT_NPM_REGISTRY, + FALLBACK_NPM_REGISTRY + ] + const timeoutMs = options?.timeoutMs ?? DEFAULT_MARKET_CHECK_TIMEOUT_MS + const fetchImpl = options?.fetchFn ?? fetch + + for (const registry of registries) { + try { + const url = `${registry.replace(/\/$/, '')}/${encodeURIComponent(packageName)}/latest` + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + const res = await fetchImpl(url, { + signal: controller.signal, + headers: { + accept: 'application/json', + 'user-agent': 'dsh-desktop' + } + }).finally(() => clearTimeout(timer)) + + if (res.ok) { + const data = (await res.json()) as NpmPackageManifest + if (data && typeof data.version === 'string') { + manifestCache.set(packageName, { manifest: data, timestamp: Date.now() }) + return data + } + } + } catch { + // Try next registry + } + } + + manifestCache.set(packageName, { manifest: null, timestamp: Date.now() }) + return null +} + +export function clearManifestCache(): void { + manifestCache.clear() +} + +/** + * Inspect whether a remote manifest is compatible with current DSH runtime. + */ +export function inferPluginRuntimeCompatibility( + manifest: NpmPackageManifest, + currentRuntimeVersion: string +): { isCompatible: boolean; reason?: string } { + // 1. Check peerDependencies for @deepseek-ai/* packages (e.g. @deepseek-ai/dsh, @deepseek-ai/dsh-agent, etc.) + const peers = manifest.peerDependencies ?? {} + for (const [peerPkg, peerRange] of Object.entries(peers)) { + if (peerPkg.startsWith('@deepseek-ai/') && peerPkg !== '@deepseek-ai/cordis') { + if (peerRange && !satisfiesRange(currentRuntimeVersion, peerRange)) { + return { + isCompatible: false, + reason: `Declares peer ${peerPkg} (${peerRange}), incompatible with runtime ${currentRuntimeVersion}` + } + } + } + } + + // 2. Check engines.dsh + const engineDsh = manifest.engines?.dsh || manifest.dsh?.minVersion + if (engineDsh && !satisfiesRange(currentRuntimeVersion, engineDsh)) { + return { + isCompatible: false, + reason: `Requires engine DSH (${engineDsh}), incompatible with runtime ${currentRuntimeVersion}` + } + } + + // 3. Check for deprecated packages in dependencies (e.g. dsh-host-apiproxy) + const deps = { ...(manifest.dependencies ?? {}), ...(manifest.optionalDependencies ?? {}) } + if (Object.keys(deps).includes('@deepseek-ai/dsh-host-apiproxy')) { + return { + isCompatible: false, + reason: 'Requires deprecated module @deepseek-ai/dsh-host-apiproxy' + } + } + + return { isCompatible: true } +} + +export async function readBundledDshVersion(bundledNodeModulesPath: string): Promise { + try { + const raw = await readFile(join(bundledNodeModulesPath, '@deepseek-ai', 'dsh', 'package.json'), 'utf8') + const manifest = JSON.parse(raw) as { version?: string } + return manifest.version + } catch { + return undefined + } +} + +export async function readInstalledPluginVersion( + dshHome: string, + pluginName: string +): Promise { + try { + const manifestPath = profilePackageJsonPath(dshHome) + const profileDir = join(manifestPath, '..') + const pkgPath = join(profileDir, 'node_modules', ...pluginName.split('/'), 'package.json') + const raw = await readFile(pkgPath, 'utf8') + const manifest = JSON.parse(raw) as { version?: string } + return manifest.version + } catch { + return undefined + } +} + +/** + * Evaluate single plugin for upgrade readiness. + */ +export async function evaluatePluginMarketCompatibility(options: { + packageName: string + installedVersion?: string + currentRuntimeVersion: string + registry?: string + timeoutMs?: number + fetchFn?: typeof fetch + hasLocalIssue?: boolean + locale?: 'zh' | 'en' +}): Promise { + const { + packageName, + installedVersion, + currentRuntimeVersion, + hasLocalIssue = false, + locale = 'zh' + } = options + const isZh = locale === 'zh' + + const manifest = await fetchPluginManifestFromRegistry(packageName, { + registry: options.registry, + timeoutMs: options.timeoutMs, + fetchFn: options.fetchFn + }) + + if (!manifest) { + return { + packageName, + installedVersion, + healthStatus: 'check-failed', + healthLabel: isZh ? '未能连接市场检查' : 'Market check unavailable', + upgradeReady: false, + detail: isZh ? '网络超时或市场暂无此插件' : 'Network timeout or package not found in market' + } + } + + const latestVersion = manifest.version + const compatibility = inferPluginRuntimeCompatibility(manifest, currentRuntimeVersion) + const isNewer = installedVersion ? compareSemver(latestVersion, installedVersion) > 0 : false + + if (hasLocalIssue) { + if (isNewer && compatibility.isCompatible) { + return { + packageName, + installedVersion, + latestVersion, + healthStatus: 'incompatible-fixed-in-latest', + healthLabel: isZh + ? `不兼容(最新版 v${latestVersion} 已适配)` + : `Incompatible (v${latestVersion} is compatible)`, + upgradeReady: true, + upgradeVersion: latestVersion, + detail: isZh + ? `最新版 v${latestVersion} 已适配当前 DSH Runtime (${currentRuntimeVersion}),推荐升级` + : `Latest version v${latestVersion} supports current runtime (${currentRuntimeVersion}), upgrade recommended` + } + } + return { + packageName, + installedVersion, + latestVersion, + healthStatus: 'incompatible-no-fix', + healthLabel: isZh ? '当前版本与最新版均不兼容' : 'Incompatible (no compatible update in market)', + upgradeReady: false, + detail: compatibility.reason ?? (isZh ? '市场最新版本仍未声明适配当前 Runtime' : 'Latest version is still not compatible') + } + } + + if (isNewer) { + if (compatibility.isCompatible) { + return { + packageName, + installedVersion, + latestVersion, + healthStatus: 'upgrade-available', + healthLabel: isZh ? `发现新版本 v${latestVersion}` : `Update available v${latestVersion}`, + upgradeReady: true, + upgradeVersion: latestVersion, + detail: isZh ? `可升级至 v${latestVersion}` : `Can upgrade to v${latestVersion}` + } + } + return { + packageName, + installedVersion, + latestVersion, + healthStatus: 'up-to-date', + healthLabel: isZh ? '已是最新兼容版本' : 'Up to date (compatible)', + upgradeReady: false, + detail: isZh ? '市场有新版,但与当前 Runtime 暂不兼容' : 'Newer version in market is not compatible with current runtime' + } + } + + return { + packageName, + installedVersion, + latestVersion, + healthStatus: 'up-to-date', + healthLabel: isZh ? '已是最新版' : 'Up to date', + upgradeReady: false + } +} + +/** + * Run health checkup on all installed plugins in parallel. + */ +export async function checkupAllProfilePlugins(options: { + plugins: string[] + dshHome: string + bundledNodeModulesPath: string + incompatiblePlugins?: string[] + registry?: string + timeoutMs?: number + fetchFn?: typeof fetch + locale?: 'zh' | 'en' +}): Promise { + const currentRuntimeVersion = (await readBundledDshVersion(options.bundledNodeModulesPath)) || '0.1.2-alpha.1' + const incompatibleSet = new Set(options.incompatiblePlugins ?? []) + + const reports = await Promise.all( + options.plugins.map(async (plugin) => { + const installedVersion = await readInstalledPluginVersion(options.dshHome, plugin) + return evaluatePluginMarketCompatibility({ + packageName: plugin, + installedVersion, + currentRuntimeVersion, + hasLocalIssue: incompatibleSet.has(plugin), + registry: options.registry, + timeoutMs: options.timeoutMs, + fetchFn: options.fetchFn, + locale: options.locale + }) + }) + ) + + return reports +} diff --git a/src/main/state/plugin-recovery.ts b/src/main/state/plugin-recovery.ts index 3fd7ccb2..d1ff663c 100644 --- a/src/main/state/plugin-recovery.ts +++ b/src/main/state/plugin-recovery.ts @@ -1,6 +1,6 @@ import { existsSync } from 'node:fs' -import { lstat, readFile, readdir, rm, writeFile } from 'node:fs/promises' -import { dirname, join, resolve } from 'node:path' +import { lstat, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { parse } from 'yaml' import { removeTree } from './remove-tree' import { bundleEntryIds, prunePatchLayer } from './patch-layer' @@ -91,18 +91,75 @@ function configuredProfilePlugins(manifest: ProfileManifest): string[] { return plugins } +/** + * A generation uninstall can be interrupted after its dependency was removed + * but before projection removes its bundle and node_modules link. Only expose + * that residue to Safe Mode when the link resolves to the generation registry + * and its metadata names the same plugin. A bundle entry by itself is not + * enough: it may be a user-maintained profile customization. + */ +export async function isStaleGenerationBundle( + dshHome: string, + packageName: string +): Promise { + const packagePath = join(dshHome, 'profiles', 'web', 'node_modules', packageName) + const generationsDirectory = resolve(dshHome, 'profiles', '.generations', 'live') + + try { + if (!(await lstat(packagePath)).isSymbolicLink()) return false + const resolvedPackagePath = await realpath(packagePath) + const insideGenerations = relative(generationsDirectory, resolvedPackagePath) + if ( + insideGenerations === '' || + insideGenerations === '..' || + insideGenerations.startsWith(`..${sep}`) || + isAbsolute(insideGenerations) + ) return false + + const segments = insideGenerations.split(sep) + const packageSegments = packageName.split('/') + if ( + segments.length !== packageSegments.length + 2 || + segments[1] !== 'node_modules' || + !packageSegments.every((segment, index) => segments[index + 2] === segment) + ) return false + + const generation = JSON.parse( + await readFile(join(generationsDirectory, segments[0]!, 'generation.json'), 'utf8') + ) as { pluginName?: unknown } + return generation.pluginName === packageName + } catch { + return false + } +} + /** * User-installed bundle packages that Safe Mode can manage without starting - * Harness. Reading both dependencies and bundles avoids presenting transitive - * packages as plugins, or offering to remove a package that is not active in - * the profile. + * Harness. Normally a package must occur in both dependencies and bundles to + * avoid exposing transitive packages. The exception is a proven generation + * residue: its dependency is gone, while an active profile link still points + * into the generation registry. Including it lets Safe Mode finish an + * interrupted uninstall without treating arbitrary bundle entries as plugins. */ export async function listInstalledProfilePlugins(dshHome: string): Promise { try { const manifest = JSON.parse( await readFile(profilePackageJsonPath(dshHome), 'utf8') ) as ProfileManifest - const plugins = configuredProfilePlugins(manifest) + const dependencies = manifest.dependencies ?? {} + const configuredPlugins = configuredProfilePlugins(manifest) + const residualCandidates = (manifest.dsh?.profile?.bundles ?? []).filter( + (bundle) => isThirdPartyPackageName(bundle) && !(bundle in dependencies) + ) + const residualPlugins = await Promise.all( + residualCandidates.map(async (bundle) => + await isStaleGenerationBundle(dshHome, bundle) ? bundle : undefined + ) + ) + const plugins = [...new Set([ + ...configuredPlugins, + ...residualPlugins.filter((plugin): plugin is string => plugin !== undefined) + ])] const modulesDirectory = join(dshHome, 'profiles', 'web', 'node_modules') const entries = await Promise.all( plugins.map(async (name, index) => { diff --git a/src/main/state/plugin-upgrade.ts b/src/main/state/plugin-upgrade.ts new file mode 100644 index 00000000..9ffe9bfd --- /dev/null +++ b/src/main/state/plugin-upgrade.ts @@ -0,0 +1,70 @@ +import { + installGeneration, + type GenerationInstallResult +} from 'dsh-desktop-market-installer/generations/installer' +import { projectGenerations } from 'dsh-desktop-market-installer/generations/projection' +import { + listGenerations, + readDesired, + withRegistryLock, + writeDesired +} from 'dsh-desktop-market-installer/generations/registry' + +export interface PluginUpgradeOptions { + dshHome: string + pluginName: string + targetVersion: string + nodeExecutablePath: string + pnpmEntryPath: string + note?: (line: string) => void +} + +export interface PluginUpgradeResult { + ok: boolean + detail?: string +} + +/** + * Install the target version of a plugin as an immutable generation and project + * it into the web profile, replacing any older generation of that plugin. + */ +export async function upgradePluginToGeneration( + options: PluginUpgradeOptions +): Promise { + const { dshHome, pluginName, targetVersion, nodeExecutablePath, pnpmEntryPath, note } = options + const spec = `${pluginName}@${targetVersion}` + + return withRegistryLock(dshHome, async () => { + note?.(`[plugin-upgrade] installing ${spec} as a generation…`) + + const install: GenerationInstallResult = await installGeneration({ + dshHome, + pluginSpec: spec, + nodeExecutablePath, + pnpmEntryPath, + onTrace: (line) => note?.(`[plugin-upgrade] ${line}`) + }) + + if (!install.ok || !install.generation) { + const detail = install.detail ?? 'generation installation failed' + note?.(`[plugin-upgrade] failed to install ${spec}: ${detail}`) + return { ok: false, detail } + } + + const [desired, generations] = await Promise.all([ + readDesired(dshHome), + listGenerations(dshHome) + ]) + const byId = new Map(generations.map((g) => [g.id, g])) + const kept = desired.filter((id) => { + const g = byId.get(id) + return g === undefined || g.pluginName !== install.generation!.pluginName + }) + + await writeDesired(dshHome, [...kept, install.generation.id]) + await projectGenerations(dshHome) + + note?.(`[plugin-upgrade] successfully upgraded ${pluginName} to v${targetVersion} (${install.generation.id})`) + return { ok: true } + }) +} diff --git a/src/main/state/profile-consistency.ts b/src/main/state/profile-consistency.ts index 5a1b6d15..8de63779 100644 --- a/src/main/state/profile-consistency.ts +++ b/src/main/state/profile-consistency.ts @@ -156,3 +156,50 @@ export async function inspectProfileConsistency(dshHome: string): Promise { + const manifestPath = profilePackageJsonPath(dshHome) + let manifestText: string + let manifest: ProfileManifest & { dsh?: { profile?: { bundles?: string[] }; [key: string]: unknown }; [key: string]: unknown } + try { + manifestText = await readFile(manifestPath, 'utf8') + manifest = JSON.parse(manifestText) + } catch { + return [] + } + + const nodeModulesPath = join(dirname(manifestPath), 'node_modules') + const currentBundles = manifest.dsh?.profile?.bundles ?? [] + const bundleSet = new Set(currentBundles) + const dependencies = Object.keys(manifest.dependencies ?? {}) + const healed: string[] = [] + + for (const dependency of dependencies) { + if (bundleSet.has(dependency)) continue + const { installed, bundle } = await inspectPackage(nodeModulesPath, dependency) + if (installed && bundle) { + currentBundles.push(dependency) + bundleSet.add(dependency) + healed.push(dependency) + } + } + + if (healed.length > 0) { + try { + if (!manifest.dsh) manifest.dsh = {} + if (!manifest.dsh.profile) manifest.dsh.profile = {} + manifest.dsh.profile.bundles = currentBundles + const { writeFile } = await import('node:fs/promises') + await writeFile(manifestPath, `${JSON.stringify(manifest, undefined, 2)}\n`, 'utf8') + } catch { + // Best-effort auto-healing; never crash startup if write fails + } + } + + return healed +} diff --git a/test/generation-boundary.test.js b/test/generation-boundary.test.js index 3c7b36f7..847b09e0 100644 --- a/test/generation-boundary.test.js +++ b/test/generation-boundary.test.js @@ -144,7 +144,9 @@ describe('the market install boundary', () => { const manifest = JSON.parse(await readFile(join(home, 'profiles', 'web', 'package.json'), 'utf8')) expect(manifest.dependencies['demo-plugin']).toBeUndefined() expect(manifest.pnpm?.overrides?.['demo-plugin']).toBeUndefined() - expect(manifest.dsh.profile.bundles).toContain('demo-plugin') + // Uninstall immediately removes the plugin from the next Harness boot's + // composition. Its active link remains intact until the process stops. + expect(manifest.dsh.profile.bundles).not.toContain('demo-plugin') expect(await readlink(link)).toBe(activeTarget) await projectGenerations(home) diff --git a/test/plugin-market-check.test.ts b/test/plugin-market-check.test.ts new file mode 100644 index 00000000..0f86482b --- /dev/null +++ b/test/plugin-market-check.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import { + compareSemver, + inferPluginRuntimeCompatibility, + parseSemver, + satisfiesComparator, + satisfiesRange, + evaluatePluginMarketCompatibility, + type NpmPackageManifest +} from '../src/main/state/plugin-market-check' + +describe('plugin-market-check', () => { + it('parses and compares semver correctly', () => { + expect(parseSemver('1.2.3')).toEqual({ + major: 1, + minor: 2, + patch: 3, + prerelease: [] + }) + expect(parseSemver('0.1.2-alpha.4')).toEqual({ + major: 0, + minor: 1, + patch: 2, + prerelease: ['alpha', 4] + }) + expect(compareSemver('1.0.0', '1.0.1')).toBe(-1) + expect(compareSemver('1.2.0', '1.1.9')).toBe(1) + expect(compareSemver('0.1.2', '0.1.2-alpha.4')).toBe(1) + expect(compareSemver('0.1.2-alpha.1', '0.1.2-alpha.4')).toBe(-1) + }) + + it('evaluates semver comparators and ranges', () => { + expect(satisfiesComparator('0.1.2', '^0.1.0')).toBe(true) + expect(satisfiesComparator('0.2.0', '^0.1.0')).toBe(false) + expect(satisfiesComparator('1.2.3', '^1.0.0')).toBe(true) + expect(satisfiesComparator('2.0.0', '^1.0.0')).toBe(false) + expect(satisfiesComparator('0.1.5', '~0.1.2')).toBe(true) + expect(satisfiesComparator('0.2.0', '~0.1.2')).toBe(false) + expect(satisfiesRange('0.1.2-alpha.4', '^0.1.0 || ^0.1.2-0')).toBe(true) + expect(satisfiesRange('0.1.2', '>=0.1.0 <0.2.0')).toBe(true) + expect(satisfiesRange('0.2.5', '>=0.1.0 <0.2.0')).toBe(false) + }) + + it('infers runtime compatibility for manifests', () => { + const compatibleManifest: NpmPackageManifest = { + name: 'example-plugin', + version: '1.2.0', + peerDependencies: { + '@deepseek-ai/dsh': '^0.1.2-0' + } + } + expect(inferPluginRuntimeCompatibility(compatibleManifest, '0.1.2-rc.1').isCompatible).toBe(true) + + const incompatibleManifest: NpmPackageManifest = { + name: 'legacy-plugin', + version: '1.0.0', + peerDependencies: { + '@deepseek-ai/dsh': '^0.1.1' + } + } + expect(inferPluginRuntimeCompatibility(incompatibleManifest, '0.1.2-rc.1').isCompatible).toBe(false) + + const deprecatedDepManifest: NpmPackageManifest = { + name: 'deprecated-dep-plugin', + version: '1.1.0', + dependencies: { + '@deepseek-ai/dsh-host-apiproxy': '^0.1.1' + } + } + expect(inferPluginRuntimeCompatibility(deprecatedDepManifest, '0.1.2').isCompatible).toBe(false) + + const subPackagePeerManifest: NpmPackageManifest = { + name: 'dsh-better-sidebar', + version: '0.17.1', + peerDependencies: { + '@deepseek-ai/dsh-agent': '^0.1.0-rc.8', + '@deepseek-ai/cordis': '^4.0.1' + } + } + expect(inferPluginRuntimeCompatibility(subPackagePeerManifest, '0.1.2-rc.1').isCompatible).toBe(false) + expect(inferPluginRuntimeCompatibility(subPackagePeerManifest, '0.1.0-rc.9').isCompatible).toBe(true) + }) + + it('evaluates plugin market compatibility for upgrade candidates', async () => { + const mockManifest: NpmPackageManifest = { + name: 'test-plugin', + version: '2.0.0', + peerDependencies: { + '@deepseek-ai/dsh': '^0.1.2' + } + } + + const mockFetch = async () => + new Response(JSON.stringify(mockManifest), { + status: 200, + headers: { 'content-type': 'application/json' } + }) + + const report = await evaluatePluginMarketCompatibility({ + packageName: 'test-plugin', + installedVersion: '1.0.0', + currentRuntimeVersion: '0.1.2', + hasLocalIssue: true, + fetchFn: mockFetch as unknown as typeof fetch, + locale: 'zh' + }) + + expect(report.healthStatus).toBe('incompatible-fixed-in-latest') + expect(report.upgradeReady).toBe(true) + expect(report.upgradeVersion).toBe('2.0.0') + }) +}) diff --git a/test/plugin-recovery-html.test.ts b/test/plugin-recovery-html.test.ts index ceb3b6a0..972103c0 100644 --- a/test/plugin-recovery-html.test.ts +++ b/test/plugin-recovery-html.test.ts @@ -20,4 +20,10 @@ describe('plugin recovery page', () => { expect(html).toContain('id="show-log"') expect(html).toContain('id="quit"') }) + + it('renders concise upgrade indicator directly on the plugin item without emojis', () => { + expect(html).not.toContain('💡') + expect(html).not.toContain('id="upgrade-card"') + expect(html).toContain('plugin-upgrade') + }) }) diff --git a/test/plugin-recovery-view.test.ts b/test/plugin-recovery-view.test.ts index 0b5ab77c..32155e56 100644 --- a/test/plugin-recovery-view.test.ts +++ b/test/plugin-recovery-view.test.ts @@ -104,4 +104,22 @@ describe('plugin recovery view model', () => { expect(html).toContain("navigate('show-log')") expect(html).not.toContain('id="restart"') }) + + it('configures upgrade candidate when a compatible update is available', () => { + const model = buildPluginRecoveryViewModel({ + snapshot: failedSnapshot(), + plugins: ['plugin-a'], + removedPlugins: [], + locale: 'zh', + upgradeCandidate: { + packageName: 'plugin-a', + targetVersion: '2.0.0', + installedVersion: '1.0.0' + } + }) + expect(model.upgradeCandidate?.targetVersion).toBe('2.0.0') + expect(model.upgradeLabel).toBe('升级插件并重启') + expect(model.upgradeHint).toBe('该插件有新的兼容版本') + expect(model.uninstallLabel).toBe('仍要卸载此插件') + }) }) diff --git a/test/plugin-recovery.test.ts b/test/plugin-recovery.test.ts index 95c7961c..340b8761 100644 --- a/test/plugin-recovery.test.ts +++ b/test/plugin-recovery.test.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs' -import { mkdir, readFile, rm, utimes, writeFile } from 'node:fs/promises' +import { mkdir, readFile, rm, symlink, utimes, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { parse, stringify } from 'yaml' @@ -82,6 +82,60 @@ describe('plugin-recovery', () => { await expect(listInstalledProfilePlugins(join(testDir, 'missing'))).resolves.toEqual([]) }) + it('lists a generation-linked bundle left behind by an interrupted uninstall', async () => { + await writeFile( + profilePackageJsonPath(testDir), + JSON.stringify({ + dependencies: { '@deepseek-ai/dsh-base': '0.1.0' }, + dsh: { + profile: { + bundles: ['@deepseek-ai/dsh-base', '@example/stale-generation', 'plain-bundle-entry'] + } + } + }) + ) + const generationDirectory = join( + testDir, + 'profiles', + '.generations', + 'live', + 'example+stale-generation+1.0.0' + ) + const generationPackage = join( + generationDirectory, + 'node_modules', + '@example', + 'stale-generation' + ) + await mkdir(generationPackage, { recursive: true }) + await writeFile( + join(generationDirectory, 'generation.json'), + JSON.stringify({ pluginName: '@example/stale-generation' }) + ) + await writeFile( + join(generationPackage, 'package.json'), + JSON.stringify({ name: '@example/stale-generation' }) + ) + const profilePackage = join( + testDir, + 'profiles', + 'web', + 'node_modules', + '@example', + 'stale-generation' + ) + await mkdir(join(profilePackage, '..'), { recursive: true }) + await symlink( + generationPackage, + profilePackage, + process.platform === 'win32' ? 'junction' : 'dir' + ) + + await expect(listInstalledProfilePlugins(testDir)).resolves.toEqual([ + '@example/stale-generation' + ]) + }) + it('lists the most recently installed profile plugin first', async () => { await writeFile( profilePackageJsonPath(testDir), diff --git a/test/profile-consistency.test.ts b/test/profile-consistency.test.ts index a97493ea..6fc12a78 100644 --- a/test/profile-consistency.test.ts +++ b/test/profile-consistency.test.ts @@ -1,8 +1,8 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { inspectProfileConsistency } from '../src/main/state/profile-consistency' +import { healProfileBundles, inspectProfileConsistency } from '../src/main/state/profile-consistency' describe('profile consistency', () => { const homes: string[] = [] @@ -110,4 +110,33 @@ describe('profile consistency', () => { homes.push(home) await expect(inspectProfileConsistency(home)).resolves.toEqual([]) }) + + it('auto-heals uncomposed bundles into manifest dsh.profile.bundles', async () => { + const { home, modules } = await profileHome({ + dependencies: { 'dsh-better-sidebar': '^1.0.0', 'dsh-dream-skin': '^1.0.0' }, + dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } } + }) + await install(modules, 'dsh-better-sidebar', true) + await install(modules, 'dsh-dream-skin', true) + + // Before heal, consistency reports they are installed but not composed + const beforeFindings = await inspectProfileConsistency(home) + expect(beforeFindings).toContain('dsh-better-sidebar is installed and declares a bundle, but is not composed') + expect(beforeFindings).toContain('dsh-dream-skin is installed and declares a bundle, but is not composed') + + // Heal + const healed = await healProfileBundles(home) + expect(healed).toEqual(['dsh-better-sidebar', 'dsh-dream-skin']) + + // After heal, consistency reports clean + await expect(inspectProfileConsistency(home)).resolves.toEqual([]) + + // Check package.json + const manifest = JSON.parse(await readFile(join(home, 'profiles', 'web', 'package.json'), 'utf8')) + expect(manifest.dsh.profile.bundles).toEqual([ + '@deepseek-ai/dsh-base', + 'dsh-better-sidebar', + 'dsh-dream-skin' + ]) + }) }) diff --git a/test/safe-mode.test.ts b/test/safe-mode.test.ts index 4f5f8780..9fd9ab2d 100644 --- a/test/safe-mode.test.ts +++ b/test/safe-mode.test.ts @@ -349,4 +349,39 @@ describe('Safe Mode', () => { await rm(dshHome, { recursive: true, force: true }) } }) + + it('enriches plugin items with health reports and upgrade candidates', () => { + const model = buildSafeModeViewModel({ + locale: 'zh', + plugins: ['plugin-a', 'plugin-b'], + healthReports: [ + { + packageName: 'plugin-a', + installedVersion: '1.0.0', + latestVersion: '2.0.0', + healthStatus: 'incompatible-fixed-in-latest', + healthLabel: '不兼容(最新版 v2.0.0 已适配)', + upgradeReady: true, + upgradeVersion: '2.0.0' + }, + { + packageName: 'plugin-b', + installedVersion: '1.2.0', + latestVersion: '1.2.0', + healthStatus: 'up-to-date', + healthLabel: '已是最新版', + upgradeReady: false + } + ] + }) + expect(model.upgradeReadyCount).toBe(1) + expect(model.upgradeAllLabel).toBe('一键升级 1 个已适配插件') + const itemA = model.pluginItems.find((p) => p.name === 'plugin-a') + expect(itemA?.upgradeReady).toBe(true) + expect(itemA?.upgradeVersion).toBe('2.0.0') + expect(itemA?.upgradeButtonLabel).toBe('升级至 v2.0.0') + const itemB = model.pluginItems.find((p) => p.name === 'plugin-b') + expect(itemB?.upgradeReady).toBe(false) + expect(itemB?.upgradeButtonLabel).toBeUndefined() + }) })