diff --git a/.gitignore b/.gitignore
index 149ad8a2..9180d8c2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,6 @@ build/icon-1024.png
.env
.env.*
!.env.example
+.coaligne/
+.coaligneignore
+
diff --git a/build/plugin-recovery.html b/build/plugin-recovery.html
index dc9af93a..a6421dd2 100644
--- a/build/plugin-recovery.html
+++ b/build/plugin-recovery.html
@@ -450,7 +450,11 @@
}
const navigate = (action) => {
- location.href = `dsh-recovery://${action}`
+ if (window.dshRecovery && typeof window.dshRecovery.action === 'function') {
+ void window.dshRecovery.action(action)
+ } else {
+ location.href = `dsh-recovery://${action}`
+ }
}
const primary = document.getElementById('primary')
primary.addEventListener('click', () => {
diff --git a/src/main/index.ts b/src/main/index.ts
index ccbdaf95..2e241123 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -1,6 +1,6 @@
import { spawn } from 'node:child_process'
import { join } from 'node:path'
-import { existsSync, readFileSync } from "node:fs"
+import { appendFileSync, existsSync, readFileSync } from 'node:fs'
import { parse } from 'yaml'
import {
app,
@@ -13,11 +13,22 @@ import {
type IpcMainInvokeEvent,
type MessageBoxOptions
} from 'electron'
-import { extractFailureCause, extractOffendingPlugins, HarnessRuntime } from './runtime/harness-runtime'
+import {
+ extractDuplicateLoaderEntryId,
+ extractFailureCause,
+ extractPluginFailureReferences,
+ extractSlotConflictName,
+ HarnessRuntime
+} from './runtime/harness-runtime'
+import { removeProfilePluginWithDsh } from './runtime/profile-plugin-command'
import { LanMobileBridge } from './mobile/lan-mobile-bridge'
import { secureWindow } from './security'
import { ensureLaunchRoot } from './state/launch-root'
-import { resetPluginProfile, uninstallPluginFromProfile } from './state/plugin-recovery'
+import {
+ resetPluginProfile,
+ resolveProfileRecoveryPlugins,
+ uninstallPluginFromProfile
+} from './state/plugin-recovery'
import { isAbortedNavigationError, shouldLoadHarnessUrl } from './window-navigation'
import {
checkForUpdates,
@@ -53,6 +64,45 @@ let failureRecoveryVisible = false
let harnessLaunchOperation: Promise | undefined
let pluginRecoveryActionResolver: ((action: PluginRecoveryAction) => void) | undefined
let mainWindowNavigationVersion = 0
+let rendererPluginFailureLogs: string[] = []
+let pluginRecoveryRemovedPlugins: string[] = []
+let pluginRecoveryResetTimer: ReturnType | undefined
+
+function cancelPluginRecoverySessionReset(): void {
+ if (pluginRecoveryResetTimer) clearTimeout(pluginRecoveryResetTimer)
+ pluginRecoveryResetTimer = undefined
+}
+
+function schedulePluginRecoverySessionReset(): void {
+ cancelPluginRecoverySessionReset()
+ pluginRecoveryResetTimer = setTimeout(() => {
+ pluginRecoveryResetTimer = undefined
+ pluginRecoveryRemovedPlugins = []
+ // Keep the chain alive long enough for slower Windows machines to finish
+ // rendering a frontend plugin failure after the backend reports ready.
+ }, 60_000)
+}
+
+function appendRendererPluginRecoveryLog(logs: readonly string[]): void {
+ if (logs.length === 0) return
+
+ try {
+ const evidence = logs
+ .slice(-50)
+ .join('\n')
+ .slice(-20_000)
+ .split(/\r?\n/)
+ .map((line) => `[renderer] ${line}`)
+ .join('\n')
+ appendFileSync(
+ join(app.getPath('logs'), 'harness.log'),
+ `\n[desktop] frontend plugin recovery ${new Date().toISOString()}\n${evidence}\n`,
+ 'utf8'
+ )
+ } catch (error) {
+ console.warn('[desktop] failed to persist frontend plugin recovery evidence', error)
+ }
+}
function isDevelopmentBuild(): boolean {
if (!app.isPackaged) return true
@@ -164,6 +214,12 @@ function bundledNodePath(): string {
return join(app.getAppPath(), 'node_modules', 'node', 'bin', executable)
}
+function bundledPnpmEntryPath(): string {
+ const root = join(app.getAppPath(), 'node_modules', 'pnpm', 'bin')
+ const candidates = [join(root, 'pnpm.cjs'), join(root, 'pnpm.mjs')]
+ return candidates.find((candidate) => existsSync(candidate)) ?? join(root, 'pnpm.cjs')
+}
+
function harnessNodeEntryPath(): string {
return app.isPackaged
? join(process.resourcesPath, 'harness-node-entry.mjs')
@@ -290,6 +346,15 @@ function createWindow(): BrowserWindow {
event.preventDefault()
window.setTitle('')
})
+ window.webContents.on('console-message', (details) => {
+ if (details.level !== 'error') return
+ const sourceUrl = details.sourceId || window.webContents.getURL()
+ if (!sourceUrl.startsWith('http://127.0.0.1:')) return
+ const message = details.message.trim()
+ if (!message) return
+ rendererPluginFailureLogs.push(`[stderr] ${message}`)
+ rendererPluginFailureLogs = rendererPluginFailureLogs.slice(-50)
+ })
installPluginRecoveryNavigation(window)
secureWindow(window)
installContextMenu(window, harnessLocale)
@@ -305,6 +370,7 @@ async function openHarness(url: string): Promise {
const window = mainWindow && !mainWindow.isDestroyed() ? mainWindow : createWindow()
if (shouldLoadHarnessUrl(window.webContents.getURL(), url)) {
const navigationVersion = ++mainWindowNavigationVersion
+ rendererPluginFailureLogs = []
window.webContents.stop()
try {
await window.loadURL(url)
@@ -511,21 +577,36 @@ function showUnexpectedError(error: unknown): void {
dialog.showErrorBox('DSH Desktop encountered an error', message)
}
-async function showRuntimeFailure(snapshot: RuntimeSnapshot): Promise {
+async function showPluginRecovery(options?: {
+ message?: string
+ logs?: readonly string[]
+}): Promise {
if (failureRecoveryVisible || quitting) return
failureRecoveryVisible = true
const dshHome = join(app.getPath('userData'), 'harness')
const isChinese = harnessLocale() === 'zh'
- const removedPlugins: string[] = []
+ cancelPluginRecoverySessionReset()
+ const removedPlugins = pluginRecoveryRemovedPlugins
let notice: string | undefined
try {
- while (!quitting && runtime.snapshot().phase === 'failed') {
- snapshot = runtime.snapshot()
- const offendingPlugins = extractOffendingPlugins(snapshot.logs)
+ while (!quitting) {
+ let snapshot = runtime.snapshot()
+ const logs = options?.logs ?? snapshot.logs
+ const message = options?.message ?? snapshot.message
+ const offendingPlugins = await resolveProfileRecoveryPlugins(
+ dshHome,
+ extractPluginFailureReferences(logs),
+ extractDuplicateLoaderEntryId(logs),
+ extractSlotConflictName(logs),
+ removedPlugins
+ )
const action = await waitForPluginRecoveryAction({
- snapshot,
+ snapshot: {
+ ...snapshot,
+ message: message || snapshot.message
+ },
plugins: offendingPlugins,
removedPlugins,
notice
@@ -535,7 +616,24 @@ async function showRuntimeFailure(snapshot: RuntimeSnapshot): Promise {
if (action === 'uninstall' && offendingPlugins.length > 0) {
const failedPlugins: string[] = []
for (const plugin of offendingPlugins) {
- const removed = await uninstallPluginFromProfile(dshHome, plugin)
+ const removed = await uninstallPluginFromProfile(dshHome, plugin, async (pluginName) => {
+ const result = await removeProfilePluginWithDsh(
+ {
+ dshHome,
+ dshEntryPath: dshEntryPath(),
+ nodeExecutablePath: bundledNodePath(),
+ pnpmEntryPath: bundledPnpmEntryPath(),
+ environment: process.env
+ },
+ pluginName
+ )
+ if (!result.ok) {
+ console.warn(
+ `[plugin-recovery] Failed to remove ${pluginName}: ${result.detail ?? 'unknown error'}`
+ )
+ }
+ return result.ok
+ })
if (removed) {
if (!removedPlugins.includes(plugin)) removedPlugins.push(plugin)
} else {
@@ -555,8 +653,18 @@ async function showRuntimeFailure(snapshot: RuntimeSnapshot): Promise {
: `These plugins could not be removed: ${failedPlugins.join(', ')}`
}
await launchHarness()
+ if (runtime.snapshot().phase === 'ready') {
+ schedulePluginRecoverySessionReset()
+ return
+ }
+ continue
} else if (action === 'restart') {
await launchHarness()
+ if (runtime.snapshot().phase === 'ready') {
+ schedulePluginRecoverySessionReset()
+ return
+ }
+ continue
} else if (action === 'show-log') {
shell.showItemInFolder(join(app.getPath('logs'), 'harness.log'))
continue
@@ -564,9 +672,6 @@ async function showRuntimeFailure(snapshot: RuntimeSnapshot): Promise {
app.quit()
return
}
-
- if (runtime.snapshot().phase !== 'failed') return
- snapshot = runtime.snapshot()
}
} catch (error) {
showUnexpectedError(error)
@@ -575,6 +680,10 @@ async function showRuntimeFailure(snapshot: RuntimeSnapshot): Promise {
}
}
+async function showRuntimeFailure(snapshot: RuntimeSnapshot): Promise {
+ await showPluginRecovery({ message: snapshot.message, logs: snapshot.logs })
+}
+
function installMenu(): void {
const isChinese = app.getLocale().toLowerCase().startsWith('zh')
const checkForUpdatesLabel = isChinese
@@ -773,6 +882,27 @@ async function bootstrap(): Promise {
ipcMain.handle('harness:show-log', () => {
shell.showItemInFolder(join(app.getPath('logs'), 'harness.log'))
})
+ ipcMain.removeHandler('harness:open-recovery')
+ ipcMain.handle('harness:open-recovery', async (event, frontendErrorMessage?: unknown) => {
+ assertTrustedMainWindowEvent(event)
+ const message = typeof frontendErrorMessage === 'string' ? frontendErrorMessage : undefined
+ const logs = [
+ ...rendererPluginFailureLogs,
+ ...(message ? [`[stderr] ${message}`] : [])
+ ]
+ appendRendererPluginRecoveryLog(logs)
+ void showPluginRecovery({ message, logs })
+ return { ok: true }
+ })
+ ipcMain.removeHandler('recovery:action')
+ ipcMain.handle('recovery:action', (event, action: unknown) => {
+ assertTrustedMainWindowEvent(event)
+ if (typeof action === 'string' && PLUGIN_RECOVERY_ACTIONS.has(action as PluginRecoveryAction)) {
+ resolvePluginRecoveryAction(action as PluginRecoveryAction)
+ return { ok: true }
+ }
+ return { ok: false }
+ })
ipcMain.removeHandler('harness:reset-plugins')
ipcMain.handle('harness:reset-plugins', async (event, pluginName?: unknown) => {
assertTrustedMainWindowEvent(event)
diff --git a/src/main/plugin-recovery-view.ts b/src/main/plugin-recovery-view.ts
index e4924131..08deca0d 100644
--- a/src/main/plugin-recovery-view.ts
+++ b/src/main/plugin-recovery-view.ts
@@ -32,6 +32,11 @@ interface FailureDescription {
detail: string
}
+function displayPluginName(packageName: string): string {
+ if (!packageName.startsWith('@')) return packageName
+ return packageName.slice(packageName.indexOf('/') + 1)
+}
+
function latestAttemptText(logs: readonly string[]): string {
let startIndex = -1
for (let index = logs.length - 1; index >= 0; index -= 1) {
@@ -61,6 +66,19 @@ export function describePluginFailure(
detail: `The startup log shows that ${duplicateRoute} was registered more than once, so Harness could not continue.`
}
}
+
+ if (/duplicate loader entry id/i.test(text)) {
+ const entryId = text.match(/duplicate loader entry id:\s*([^\s]+)/i)?.[1]
+ return locale === 'zh'
+ ? {
+ title: '插件注册了重复的服务组件',
+ detail: `启动日志显示组件 ${entryId ? `"${entryId}"` : ''} 被重复定义,插件之间存在加载冲突,因此 Harness 无法继续启动。`
+ }
+ : {
+ title: 'A plugin registered a duplicate service component',
+ detail: `The startup log shows that component ${entryId ? `"${entryId}"` : ''} was registered more than once due to a plugin conflict.`
+ }
+ }
if (/cannot resolve profile bundle/i.test(text)) {
return locale === 'zh'
@@ -86,6 +104,19 @@ export function describePluginFailure(
}
}
+ if (/single slot\s+["'][^"']+["']\s+already has a registration/i.test(text)) {
+ const slotName = text.match(/single slot\s+["']([^"']+)["']/i)?.[1]
+ return locale === 'zh'
+ ? {
+ title: '插件存在界面插槽冲突',
+ detail: `检测到界面插槽 ${slotName ? `"${slotName}"` : ''} 存在重复注册,多个第三方插件试图占用相同的界面组件,导致前端无法正常渲染。`
+ }
+ : {
+ title: 'A plugin has a UI slot conflict',
+ detail: `UI slot ${slotName ? `"${slotName}"` : ''} has duplicate registrations from conflicting plugins.`
+ }
+ }
+
if (/failed to import loader entry/i.test(text)) {
return locale === 'zh'
? {
@@ -117,8 +148,9 @@ export function buildPluginRecoveryViewModel(options: {
notice?: string
}): PluginRecoveryViewModel {
const { snapshot, locale, notice } = options
- const plugins = [...new Set(options.plugins)]
- const removedPlugins = [...new Set(options.removedPlugins)]
+ const pluginPackages = [...new Set(options.plugins)]
+ const plugins = pluginPackages.map(displayPluginName)
+ const removedPlugins = [...new Set(options.removedPlugins)].map(displayPluginName)
const canUninstall = plugins.length > 0
const description = describePluginFailure(snapshot.logs, locale)
const multiple = plugins.length > 1
diff --git a/src/main/runtime/harness-runtime.ts b/src/main/runtime/harness-runtime.ts
index d0c3567e..65fcba14 100644
--- a/src/main/runtime/harness-runtime.ts
+++ b/src/main/runtime/harness-runtime.ts
@@ -316,9 +316,28 @@ export function extractFailureCause(logLines: readonly string[]): string | undef
return undefined
}
-const CORE_BUNDLES = new Set(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
+const CORE_BUNDLES = new Set(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'dshmarket'])
+const PACKAGE_REFERENCE_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i
-export function extractOffendingPlugins(logLines: readonly string[]): string[] {
+function isPackageReference(value: string): boolean {
+ const candidate = value.trim()
+ if (!candidate || candidate.includes(':')) return false
+ return PACKAGE_REFERENCE_PATTERN.test(candidate)
+}
+
+function isActionablePluginReference(value: string): boolean {
+ const candidate = value.trim()
+ return (
+ isPackageReference(candidate) &&
+ !CORE_BUNDLES.has(candidate) &&
+ !candidate.startsWith('@deepseek-ai/')
+ )
+}
+
+function extractPluginReferences(
+ logLines: readonly string[],
+ accepts: (value: string) => boolean
+): string[] {
const plugins = new Set()
for (const line of latestHarnessAttemptLogs(logLines)) {
@@ -326,34 +345,72 @@ export function extractOffendingPlugins(logLines: readonly string[]): string[] {
const text = line.slice(8)
const m1 = text.match(/failed to apply loader entry [^\s]+ \((@[^)]+|[^)]+)\)/i)
- if (m1 && m1[1] && !CORE_BUNDLES.has(m1[1].trim())) {
+ if (m1 && m1[1] && accepts(m1[1])) {
plugins.add(m1[1].trim())
}
const m2 = text.match(/cannot resolve profile bundle ["']([^"']+)["']/i)
- if (m2 && m2[1] && !CORE_BUNDLES.has(m2[1].trim())) {
+ if (m2 && m2[1] && accepts(m2[1])) {
plugins.add(m2[1].trim())
}
const m3 = text.match(/profile bundle ["']([^"']+)["'] declares no dsh\.bundle/i)
- if (m3 && m3[1] && !CORE_BUNDLES.has(m3[1].trim())) {
+ if (m3 && m3[1] && accepts(m3[1])) {
plugins.add(m3[1].trim())
}
const m4 = text.match(/failed to import loader entry [^\s]+ \((@[^)]+|[^)]+)\)/i)
- if (m4 && m4[1] && !CORE_BUNDLES.has(m4[1].trim())) {
+ if (m4 && m4[1] && accepts(m4[1])) {
plugins.add(m4[1].trim())
}
const m5 = text.match(/plugin\(s\) failed to load:\s*([a-zA-Z0-9@/_-]+)/i)
- if (m5 && m5[1] && !CORE_BUNDLES.has(m5[1].trim())) {
+ if (m5 && m5[1] && accepts(m5[1])) {
plugins.add(m5[1].trim())
}
+
+ const bootFailureLines = text.split(/\r?\n/).map((value) => value.trim())
+ const bootFailureTitle = bootFailureLines.findIndex((value) => value === 'Failed to load plugins')
+ if (bootFailureTitle >= 0) {
+ for (const candidate of bootFailureLines.slice(bootFailureTitle + 1)) {
+ if (accepts(candidate)) plugins.add(candidate)
+ }
+ }
}
return [...plugins]
}
+export function extractPluginFailureReferences(logLines: readonly string[]): string[] {
+ return extractPluginReferences(logLines, isPackageReference)
+}
+
+export function extractOffendingPlugins(logLines: readonly string[]): string[] {
+ return extractPluginReferences(logLines, isActionablePluginReference)
+}
+
+export function extractDuplicateLoaderEntryId(
+ logLines: readonly string[]
+): string | undefined {
+ for (const line of latestHarnessAttemptLogs(logLines)) {
+ if (!line.startsWith('[stderr] ')) continue
+ const match = line.slice(8).match(/duplicate loader entry id:\s*["']?([^\s"']+)["']?/i)
+ if (match?.[1]) return match[1].trim()
+ }
+ return undefined
+}
+
+export function extractSlotConflictName(
+ logLines: readonly string[]
+): string | undefined {
+ for (const line of latestHarnessAttemptLogs(logLines)) {
+ if (!line.startsWith('[stderr] ')) continue
+ const match = line.slice(8).match(/single slot\s+["']([^"']+)["']\s+already has a registration/i)
+ if (match?.[1]) return match[1].trim()
+ }
+ return undefined
+}
+
export function extractOffendingPlugin(logLines: readonly string[]): string | undefined {
return extractOffendingPlugins(logLines)[0]
}
diff --git a/src/main/runtime/profile-plugin-command.ts b/src/main/runtime/profile-plugin-command.ts
new file mode 100644
index 00000000..3bbbc1c7
--- /dev/null
+++ b/src/main/runtime/profile-plugin-command.ts
@@ -0,0 +1,192 @@
+import { spawn } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import { chmod, mkdir, writeFile } from 'node:fs/promises'
+import { delimiter, dirname, join } from 'node:path'
+
+const PROFILE = 'web'
+const OPERATION_TIMEOUT_MS = 15 * 60 * 1000
+const MAX_OUTPUT_BYTES = 32 * 1024
+
+export interface ProfilePluginCommandOptions {
+ dshHome: string
+ dshEntryPath: string
+ nodeExecutablePath: string
+ pnpmEntryPath: string
+ environment?: NodeJS.ProcessEnv
+}
+
+export interface ProfilePluginCommandResult {
+ ok: boolean
+ detail?: string
+}
+
+function shellQuote(value: string): string {
+ return `'${value.replaceAll("'", `'\\''`)}'`
+}
+
+export function buildProfilePluginRemoveArguments(
+ dshEntryPath: string,
+ pluginName: string
+): string[] {
+ return [dshEntryPath, 'plugin', '--profile', PROFILE, 'remove', pluginName]
+}
+
+export async function ensureProfilePnpmShim(options: ProfilePluginCommandOptions): Promise {
+ const directory = join(options.dshHome, '.desktop-bin')
+ await mkdir(directory, { recursive: true })
+
+ if (process.platform === 'win32') {
+ await writeFile(
+ join(directory, 'pnpm.cmd'),
+ `@chcp 65001 >nul\r\n@echo off\r\n"${options.nodeExecutablePath}" "${options.pnpmEntryPath}" %*\r\n`,
+ 'utf8'
+ )
+ await writeFile(
+ join(directory, 'node.cmd'),
+ `@chcp 65001 >nul\r\n@echo off\r\n"${options.nodeExecutablePath}" %*\r\n`,
+ 'utf8'
+ )
+ } else {
+ const pnpmPath = join(directory, 'pnpm')
+ await writeFile(
+ pnpmPath,
+ `#!/bin/sh\nexec ${shellQuote(options.nodeExecutablePath)} ${shellQuote(options.pnpmEntryPath)} "$@"\n`,
+ { encoding: 'utf8', mode: 0o755 }
+ )
+ await chmod(pnpmPath, 0o755)
+ const nodePath = join(directory, 'node')
+ await writeFile(
+ nodePath,
+ `#!/bin/sh\nexec ${shellQuote(options.nodeExecutablePath)} "$@"\n`,
+ { encoding: 'utf8', mode: 0o755 }
+ )
+ await chmod(nodePath, 0o755)
+ }
+
+ return directory
+}
+
+export function buildProfilePluginCommandEnvironment(
+ environment: NodeJS.ProcessEnv,
+ shimDirectory: string,
+ nodeExecutablePath: string
+): NodeJS.ProcessEnv {
+ const result = { ...environment }
+ delete result.ELECTRON_RUN_AS_NODE
+
+ const currentPath =
+ (process.platform === 'win32' ? result.Path : result.PATH) ??
+ result.PATH ??
+ result.Path ??
+ ''
+ const parts = currentPath.split(delimiter).filter(Boolean)
+ const additions = [shimDirectory, dirname(nodeExecutablePath)].filter(
+ (directory) => !parts.includes(directory)
+ )
+ const nextPath = [...additions, currentPath].filter(Boolean).join(delimiter)
+ result.PATH = nextPath
+ if (process.platform === 'win32') result.Path = nextPath
+ result.DSH_HOME = result.DSH_HOME ?? ''
+ result.CI = 'true'
+ result.NO_COLOR = '1'
+ return result
+}
+
+function killProcessTree(child: ReturnType): void {
+ if (child.exitCode !== null || !child.pid) return
+ if (process.platform === 'win32') {
+ spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
+ windowsHide: true,
+ stdio: 'ignore'
+ }).unref()
+ return
+ }
+ try {
+ process.kill(-child.pid, 'SIGTERM')
+ } catch {
+ child.kill('SIGTERM')
+ }
+}
+
+export async function removeProfilePluginWithDsh(
+ options: ProfilePluginCommandOptions,
+ pluginName: string
+): Promise {
+ const requiredPaths = [
+ options.dshEntryPath,
+ options.nodeExecutablePath,
+ options.pnpmEntryPath
+ ]
+ if (requiredPaths.some((path) => !existsSync(path))) {
+ return { ok: false, detail: 'The bundled DSH, Node.js, or pnpm runtime was not found.' }
+ }
+
+ const profileDirectory = join(options.dshHome, 'profiles', PROFILE)
+ if (!existsSync(profileDirectory)) {
+ return { ok: false, detail: 'The web profile directory was not found.' }
+ }
+
+ try {
+ const shimDirectory = await ensureProfilePnpmShim(options)
+ const environment = buildProfilePluginCommandEnvironment(
+ options.environment ?? process.env,
+ shimDirectory,
+ options.nodeExecutablePath
+ )
+ environment.DSH_HOME = options.dshHome
+
+ const child = spawn(
+ options.nodeExecutablePath,
+ buildProfilePluginRemoveArguments(options.dshEntryPath, pluginName),
+ {
+ cwd: profileDirectory,
+ env: environment,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ windowsHide: true,
+ detached: process.platform !== 'win32'
+ }
+ )
+
+ let output = ''
+ const append = (chunk: Buffer | string): void => {
+ output = `${output}${chunk.toString()}`.slice(-MAX_OUTPUT_BYTES)
+ }
+ child.stdout?.on('data', append)
+ child.stderr?.on('data', append)
+
+ let timedOut = false
+ const timer = setTimeout(() => {
+ timedOut = true
+ killProcessTree(child)
+ }, OPERATION_TIMEOUT_MS)
+
+ try {
+ const exit = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
+ (resolve, reject) => {
+ child.once('error', reject)
+ child.once('exit', (code, signal) => resolve({ code, signal }))
+ }
+ )
+ if (timedOut) {
+ return { ok: false, detail: 'Plugin removal timed out after 15 minutes.' }
+ }
+ if (exit.code !== 0) {
+ const detail = output.trim().split(/\r?\n/u).at(-1)?.slice(0, 800)
+ return {
+ ok: false,
+ detail:
+ detail ||
+ `Plugin removal exited with ${exit.signal ? `signal ${exit.signal}` : `code ${exit.code}`}.`
+ }
+ }
+ return { ok: true }
+ } finally {
+ clearTimeout(timer)
+ }
+ } catch (error) {
+ return {
+ ok: false,
+ detail: error instanceof Error ? error.message : String(error)
+ }
+ }
+}
diff --git a/src/main/security-policy.ts b/src/main/security-policy.ts
index 062a7efe..96186834 100644
--- a/src/main/security-policy.ts
+++ b/src/main/security-policy.ts
@@ -12,7 +12,8 @@ function isHarnessUrl(rawUrl: string): boolean {
export function isTrustedAppUrl(rawUrl: string): boolean {
try {
- if (new URL(rawUrl).protocol === 'file:') return true
+ const parsed = new URL(rawUrl)
+ if (parsed.protocol === 'file:' || parsed.protocol === 'dsh-recovery:') return true
} catch {
return false
}
diff --git a/src/main/state/plugin-recovery.ts b/src/main/state/plugin-recovery.ts
index 17b54d1d..f9e13247 100644
--- a/src/main/state/plugin-recovery.ts
+++ b/src/main/state/plugin-recovery.ts
@@ -1,6 +1,7 @@
import { existsSync } from 'node:fs'
-import { readFile, writeFile } from 'node:fs/promises'
-import { join } from 'node:path'
+import { readFile, readdir, rm, writeFile } from 'node:fs/promises'
+import { dirname, join, resolve } from 'node:path'
+import { parse } from 'yaml'
export function profilePackageJsonPath(dshHome: string): string {
return join(dshHome, 'profiles', 'web', 'package.json')
@@ -21,42 +22,255 @@ interface ProfileManifest {
}
}
-export async function uninstallPluginFromProfile(
- dshHome: string,
- pluginName: string
+interface BundleManifest {
+ dependencies?: Record
+ optionalDependencies?: Record
+ dsh?: {
+ bundle?: {
+ patch?: string
+ }
+ }
+}
+
+interface ProfileLockfile {
+ importers?: Record<
+ string,
+ {
+ dependencies?: Record
+ devDependencies?: Record
+ optionalDependencies?: Record
+ }
+ >
+}
+
+export type ProfilePluginRemovalRunner = (pluginName: string) => Promise
+
+const CORE_BUNDLES = new Set(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'dshmarket'])
+const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i
+
+function yamlPackageNamePattern(packageName: string): RegExp {
+ const escaped = packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+ return new RegExp(
+ `^\\s*name:\\s*(?:["']${escaped}["']|${escaped})(?:\\s*(?:#.*)?)?$`,
+ 'm'
+ )
+}
+
+export function isThirdPartyPackageName(packageName: string): boolean {
+ return (
+ PACKAGE_NAME_PATTERN.test(packageName) &&
+ !packageName.startsWith('@deepseek-ai/') &&
+ !CORE_BUNDLES.has(packageName)
+ )
+}
+
+function configuredProfilePlugins(manifest: ProfileManifest): string[] {
+ const dependencies = manifest.dependencies ?? {}
+ const bundles = new Set(manifest.dsh?.profile?.bundles ?? [])
+ const plugins: string[] = []
+
+ for (const dep of Object.keys(dependencies)) {
+ if (bundles.has(dep) && isThirdPartyPackageName(dep)) {
+ plugins.push(dep)
+ }
+ }
+
+ return plugins
+}
+
+async function bundleOwnsPackage(
+ profileDirectory: string,
+ bundle: string,
+ packageName: string
): Promise {
+ const packageDirectory = join(profileDirectory, 'node_modules', bundle)
+
+ try {
+ const rawManifest = await readFile(join(packageDirectory, 'package.json'), 'utf8')
+ const manifest = JSON.parse(rawManifest) as BundleManifest
+ if (
+ packageName in (manifest.dependencies ?? {}) ||
+ packageName in (manifest.optionalDependencies ?? {})
+ ) {
+ return true
+ }
+
+ const patch = manifest.dsh?.bundle?.patch
+ if (!patch) return false
+ const rawPatch = await readFile(resolve(packageDirectory, patch), 'utf8')
+ return yamlPackageNamePattern(packageName).test(rawPatch)
+ } catch {
+ return false
+ }
+}
+
+function loaderEntryPattern(entryId: string): RegExp {
+ const escaped = entryId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+ return new RegExp(
+ `^\\s*-\\s+id:\\s*(?:["']${escaped}["']|${escaped})(?:\\s*(?:#.*)?)?$`,
+ 'm'
+ )
+}
+
+async function bundleDeclaresLoaderEntry(
+ profileDirectory: string,
+ bundle: string,
+ entryId: string
+): Promise {
+ const packageDirectory = join(profileDirectory, 'node_modules', bundle)
+ const packageJsonPath = join(packageDirectory, 'package.json')
+
+ try {
+ const rawManifest = await readFile(packageJsonPath, 'utf8')
+ const bundleManifest = JSON.parse(rawManifest) as BundleManifest
+ const patch = bundleManifest.dsh?.bundle?.patch
+ if (!patch) return false
+
+ const patchPath = resolve(packageDirectory, patch)
+ const rawPatch = await readFile(patchPath, 'utf8')
+ return loaderEntryPattern(entryId).test(rawPatch)
+ } catch {
+ return false
+ }
+}
+
+async function pluginMatchesSlot(
+ profileDirectory: string,
+ plugin: string,
+ slotName: string
+): Promise {
+ const packageDir = join(profileDirectory, 'node_modules', plugin)
+ const filesToCheck = [
+ 'cordis.patch.yml',
+ 'client.js',
+ 'lib/client.js',
+ 'dist/client.js',
+ 'package.json',
+ 'index.js',
+ 'lib/index.js',
+ 'dist/index.js'
+ ]
+ for (const file of filesToCheck) {
+ try {
+ const content = await readFile(join(packageDir, file), 'utf8')
+ if (content.includes(slotName)) return true
+ } catch {}
+ }
+ return false
+}
+
+export async function resolveProfileRecoveryPlugins(
+ dshHome: string,
+ detectedPlugins: readonly string[],
+ duplicateLoaderEntryId?: string,
+ slotConflictName?: string,
+ excludedPlugins: readonly string[] = []
+): Promise {
const manifestPath = profilePackageJsonPath(dshHome)
- if (!existsSync(manifestPath)) return false
try {
const raw = await readFile(manifestPath, 'utf8')
const manifest = JSON.parse(raw) as ProfileManifest
- let modified = false
+ const excludedSet = new Set(excludedPlugins)
+ const configuredPlugins = configuredProfilePlugins(manifest).filter(
+ (plugin) => !excludedSet.has(plugin)
+ )
+ const configuredSet = new Set(configuredPlugins)
+ const profileDirectory = dirname(manifestPath)
- if (manifest.dependencies && pluginName in manifest.dependencies) {
- delete manifest.dependencies[pluginName]
- modified = true
+ // 1. Match an installed third-party root package directly, or prove that
+ // a reported sub-package is owned by one configured third-party bundle.
+ const matchedPlugins = new Set()
+ for (const detected of detectedPlugins) {
+ if (!PACKAGE_NAME_PATTERN.test(detected)) continue
+ if (configuredSet.has(detected)) {
+ matchedPlugins.add(detected)
+ continue
+ }
+ for (const configured of configuredPlugins) {
+ if (await bundleOwnsPackage(profileDirectory, configured, detected)) {
+ matchedPlugins.add(configured)
+ }
+ }
}
+ if (matchedPlugins.size === 1) return [...matchedPlugins]
- if (manifest.dsh?.profile?.bundles) {
- const originalLength = manifest.dsh.profile.bundles.length
- manifest.dsh.profile.bundles = manifest.dsh.profile.bundles.filter(
- (bundle) => bundle !== pluginName
- )
- if (manifest.dsh.profile.bundles.length !== originalLength) {
- modified = true
+ // 2. Duplicate loader entry matching
+ if (duplicateLoaderEntryId) {
+ let offendingPlugin: string | undefined
+ for (const plugin of configuredPlugins) {
+ if (await bundleDeclaresLoaderEntry(profileDirectory, plugin, duplicateLoaderEntryId)) {
+ offendingPlugin = plugin
+ }
}
+ if (offendingPlugin) return [offendingPlugin]
}
- if (modified) {
- await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8')
- return true
+ // 3. Slot conflict matching
+ if (slotConflictName) {
+ const slotMatched = new Set()
+ for (const plugin of configuredPlugins) {
+ if (await pluginMatchesSlot(profileDirectory, plugin, slotConflictName)) {
+ slotMatched.add(plugin)
+ }
+ }
+ if (slotMatched.size === 1) return [...slotMatched]
}
+
+ // Never guess. A recovery action is only safe when one or more packages
+ // have direct evidence tying them to the reported failure.
+ return []
} catch {
- return false
+ return []
}
+}
- return false
+export async function uninstallPluginFromProfile(
+ dshHome: string,
+ pluginName: string,
+ removePlugin?: ProfilePluginRemovalRunner
+): Promise {
+ if (!isThirdPartyPackageName(pluginName) || !removePlugin) return false
+
+ const manifestPath = profilePackageJsonPath(dshHome)
+ const lockfilePath = join(dirname(manifestPath), 'pnpm-lock.yaml')
+ const pluginDirectory = join(dirname(manifestPath), 'node_modules', pluginName)
+
+ try {
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as ProfileManifest
+ const configured =
+ Object.hasOwn(manifest.dependencies ?? {}, pluginName) &&
+ (manifest.dsh?.profile?.bundles ?? []).includes(pluginName)
+ if (!configured) return false
+
+ const lockfileExisted = existsSync(lockfilePath)
+ if (!(await removePlugin(pluginName))) return false
+
+ const updatedManifest = JSON.parse(await readFile(manifestPath, 'utf8')) as ProfileManifest
+ if (
+ Object.hasOwn(updatedManifest.dependencies ?? {}, pluginName) ||
+ (updatedManifest.dsh?.profile?.bundles ?? []).includes(pluginName) ||
+ existsSync(pluginDirectory)
+ ) {
+ return false
+ }
+
+ if (lockfileExisted) {
+ const lockfile = parse(await readFile(lockfilePath, 'utf8')) as ProfileLockfile
+ const importer = lockfile.importers?.['.']
+ if (
+ Object.hasOwn(importer?.dependencies ?? {}, pluginName) ||
+ Object.hasOwn(importer?.devDependencies ?? {}, pluginName) ||
+ Object.hasOwn(importer?.optionalDependencies ?? {}, pluginName)
+ ) {
+ return false
+ }
+ }
+
+ return true
+ } catch {
+ return false
+ }
}
export async function resetPluginProfile(
@@ -65,15 +279,20 @@ export async function resetPluginProfile(
): Promise {
const manifestPath = profilePackageJsonPath(dshHome)
if (!existsSync(manifestPath)) return false
+ if (failingPlugin && !isThirdPartyPackageName(failingPlugin)) return false
try {
const raw = await readFile(manifestPath, 'utf8')
const manifest = JSON.parse(raw) as ProfileManifest
+ let modified = false
if (failingPlugin) {
const scope = failingPlugin.startsWith('@') ? failingPlugin.split('/')[0] : undefined
if (manifest.dependencies) {
- delete manifest.dependencies[failingPlugin]
+ if (failingPlugin in manifest.dependencies) {
+ delete manifest.dependencies[failingPlugin]
+ modified = true
+ }
for (const dep of Object.keys(manifest.dependencies)) {
if (
failingPlugin.includes(dep) ||
@@ -81,10 +300,12 @@ export async function resetPluginProfile(
(scope && dep.startsWith(scope))
) {
delete manifest.dependencies[dep]
+ modified = true
}
}
}
if (manifest.dsh?.profile?.bundles) {
+ const origLen = manifest.dsh.profile.bundles.length
manifest.dsh.profile.bundles = manifest.dsh.profile.bundles.filter(
(b) =>
b !== failingPlugin &&
@@ -92,25 +313,64 @@ export async function resetPluginProfile(
!b.includes(failingPlugin) &&
(!scope || !b.startsWith(scope))
)
+ if (manifest.dsh.profile.bundles.length !== origLen) {
+ modified = true
+ }
}
} else {
- // If no specific plugin given, reset bundles to safe core bundles
+ // If no specific plugin given, reset to safe core bundles and clean all third-party dependencies
const safeBundles = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']
if (manifest.dependencies?.dshmarket) safeBundles.push('dshmarket')
- if (manifest.dsh?.profile?.bundles) {
- manifest.dsh.profile.bundles = safeBundles
+ manifest.dsh ??= {}
+ manifest.dsh.profile ??= {}
+ manifest.dsh.profile.bundles = safeBundles
+ modified = true
+ if (manifest.dependencies) {
+ for (const dep of Object.keys(manifest.dependencies)) {
+ if (!CORE_BUNDLES.has(dep)) {
+ delete manifest.dependencies[dep]
+ modified = true
+ }
+ }
}
}
- await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8')
+ if (modified) {
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8')
+ }
// Reset cordis.patch.yml to clean state
const patchPath = profileCordisPatchPath(dshHome)
if (existsSync(patchPath)) {
- await writeFile(patchPath, '[]\n', 'utf8')
+ const patchContent = await readFile(patchPath, 'utf8')
+ if (patchContent.trim() !== '[]') {
+ await writeFile(patchPath, '[]\n', 'utf8')
+ modified = true
+ }
}
- return true
+ // Physically clean plugin files from node_modules to guarantee thorough uninstallation
+ const nodeModulesPath = join(dshHome, 'profiles', 'web', 'node_modules')
+ if (existsSync(nodeModulesPath)) {
+ if (failingPlugin) {
+ const pluginDir = join(nodeModulesPath, failingPlugin)
+ await rm(pluginDir, { recursive: true, force: true }).catch(() => undefined)
+ if (failingPlugin.startsWith('@')) {
+ const scope = failingPlugin.split('/')[0]
+ if (scope) {
+ const scopeDir = join(nodeModulesPath, scope)
+ try {
+ const files = await readdir(scopeDir)
+ if (files.length === 0) {
+ await rm(scopeDir, { recursive: true, force: true }).catch(() => undefined)
+ }
+ } catch {}
+ }
+ }
+ }
+ }
+
+ return modified
} catch {
return false
}
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 3b695098..bb26bb3c 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -6,26 +6,15 @@ import {
updateMessage,
type UpdateLocale
} from './update-view'
-import {
- isPluginLoadError,
- extractPluginName,
- pluginErrorMessage
-} from './plugin-error-view'
+import { isPluginLoadError } from './plugin-error-view'
import { mountWindowsTitlebar } from './windows-titlebar'
const ROOT_ID = 'dsh-desktop-update-root'
-const PLUGIN_ERROR_ROOT_ID = 'dsh-desktop-plugin-error-root'
const MOBILE_BUTTON_ID = 'dsh-desktop-mobile-button'
const locale: UpdateLocale = navigator.language.toLowerCase().startsWith('zh') ? 'zh' : 'en'
let host: HTMLDivElement | undefined
let content: HTMLDivElement | undefined
-let pluginErrorHost: HTMLDivElement | undefined
-let pluginErrorContent: HTMLDivElement | undefined
-let activePluginErrorName: string | undefined
-let pluginErrorVisible = false
-let restartingHarness = false
-let resettingHarness = false
let currentStatus: UpdateStatus | undefined
let dismissedVersion: string | null = null
let dismissedTransientPhase: UpdateStatus['phase'] | null = null
@@ -34,52 +23,63 @@ let receivedStatusEvent = false
let phoneConnected = false
let mobileStatusTimer: number | undefined
-function checkBootFailureInDom(): void {
+let bootFailureTriggered = false
+let bootFailureTimer: number | undefined
+const pendingBootFailureMessages: string[] = []
+
+const BOOT_FAILURE_SETTLE_MS = 400
+
+function currentBootFailureText(): string | undefined {
const root = document.body || document.documentElement
- if (!root) return
- const divs = Array.from(root.querySelectorAll('div'))
- const failedTitle = divs.find((el) => el.textContent?.trim() === 'Failed to load plugins')
- if (!failedTitle || !failedTitle.parentElement) return
-
- const failedContainer = failedTitle.parentElement
- const errorText = failedContainer.textContent ?? ''
- const pluginName = extractPluginName(errorText)
-
- const INJECTED_ID = 'dsh-desktop-boot-recovery-actions'
- if (document.getElementById(INJECTED_ID)) return
-
- const actionsDiv = document.createElement('div')
- actionsDiv.id = INJECTED_ID
- actionsDiv.style.cssText = [
- 'display:flex',
- 'margin-top:20px',
- 'justify-content:center',
- 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif'
- ].join(';')
+ if (!root) return undefined
+
+ // The package list and loader detail are rendered in separate sibling
+ // containers on Harness's boot-failure page. Reading only the title's
+ // parent drops exactly the evidence Desktop needs to identify the second
+ // conflicting plugin, so capture the full failure page instead.
+ const text = document.body?.innerText || root.textContent
+ if (!text?.includes('Failed to load plugins')) return undefined
+ return text
+ ?.split(/\r?\n/)
+ .map((value) => value.trim())
+ .filter(Boolean)
+ .join('\n')
+}
- const restartBtn = document.createElement('button')
- restartBtn.type = 'button'
- restartBtn.textContent = locale === 'zh' ? '重启 Harness' : 'Restart Harness'
- restartBtn.style.cssText = [
- 'appearance:none',
- 'border:none',
- 'background:#4d6bfe',
- 'color:#ffffff',
- 'padding:9px 22px',
- 'border-radius:8px',
- 'font-size:13px',
- 'font-weight:600',
- 'cursor:pointer',
- 'box-shadow:0 2px 8px rgba(77,107,254,0.35)'
- ].join(';')
- restartBtn.addEventListener('click', () => {
- restartBtn.disabled = true
- restartBtn.textContent = locale === 'zh' ? '正在重启…' : 'Restarting…'
- void ipcRenderer.invoke('harness:reset-plugins', pluginName)
- })
+function addBootFailureMessage(message: string | undefined): void {
+ const normalized = message?.trim()
+ if (!normalized || pendingBootFailureMessages.includes(normalized)) return
+ pendingBootFailureMessages.push(normalized)
+}
+
+function queueBootFailure(message?: string): void {
+ if (bootFailureTriggered) return
+
+ addBootFailureMessage(message)
+ addBootFailureMessage(currentBootFailureText())
+ if (pendingBootFailureMessages.length === 0) return
+
+ if (bootFailureTimer !== undefined) window.clearTimeout(bootFailureTimer)
+ bootFailureTimer = window.setTimeout(() => {
+ bootFailureTimer = undefined
+ if (bootFailureTriggered) return
+
+ // The web boot page renders the plugin name and detailed loader error after
+ // window.error/unhandledrejection fires. Read it one last time before leaving
+ // the page so recovery receives the richest available diagnostic evidence.
+ addBootFailureMessage(currentBootFailureText())
+ const errorText = pendingBootFailureMessages.join('\n')
+ if (!errorText) return
+
+ bootFailureTriggered = true
+ void ipcRenderer.invoke('harness:open-recovery', errorText)
+ }, BOOT_FAILURE_SETTLE_MS)
+}
- actionsDiv.appendChild(restartBtn)
- failedContainer.appendChild(actionsDiv)
+function checkBootFailureInDom(): void {
+ const errorText = currentBootFailureText()
+ if (!errorText) return
+ queueBootFailure(errorText)
}
const domObserver = new MutationObserver(() => {
@@ -146,7 +146,6 @@ function initializeUi(): void {
mountWindowsTitlebar({ document, ipcRenderer, locale })
}
mount()
- mountPluginErrorCard()
mountMobileButton()
checkBootFailureInDom()
domObserver.observe(document.documentElement, {
@@ -160,14 +159,16 @@ function initializeUi(): void {
window.addEventListener('error', (event) => {
const err = event.error ?? event.message
if (isPluginLoadError(err)) {
- showPluginErrorNotification(extractPluginName(err))
+ const errorText = typeof err === 'string' ? err : err instanceof Error ? err.message : String(err)
+ queueBootFailure(errorText)
}
})
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason
if (isPluginLoadError(reason)) {
- showPluginErrorNotification(extractPluginName(reason))
+ const errorText = typeof reason === 'string' ? reason : reason instanceof Error ? reason.message : String(reason)
+ queueBootFailure(errorText)
}
})
@@ -178,114 +179,12 @@ contextBridge.exposeInMainWorld(
})
)
-function mountPluginErrorCard(): void {
- if (document.getElementById(PLUGIN_ERROR_ROOT_ID)) return
-
- pluginErrorHost = document.createElement('div')
- pluginErrorHost.id = PLUGIN_ERROR_ROOT_ID
- pluginErrorHost.style.cssText = [
- 'position:fixed',
- 'right:20px',
- 'bottom:20px',
- 'z-index:2147483647',
- 'display:none',
- 'width:min(384px,calc(100vw - 40px))',
- 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif'
- ].join(';')
-
- const shadow = pluginErrorHost.attachShadow({ mode: 'closed' })
- const style = document.createElement('style')
- style.textContent = styles
- pluginErrorContent = document.createElement('div')
- shadow.append(style, pluginErrorContent)
- document.documentElement.appendChild(pluginErrorHost)
- renderPluginError()
-}
-
-function showPluginErrorNotification(pluginName?: string): void {
- activePluginErrorName = pluginName || activePluginErrorName
- pluginErrorVisible = true
- mountPluginErrorCard()
- renderPluginError()
-}
-
-function dismissPluginError(): void {
- pluginErrorVisible = false
- if (pluginErrorHost) {
- pluginErrorHost.style.display = 'none'
- }
-}
-
-function renderPluginError(): void {
- if (!pluginErrorHost || !pluginErrorContent) return
-
- if (!pluginErrorVisible) {
- pluginErrorHost.style.display = 'none'
- pluginErrorContent.replaceChildren()
- return
- }
-
- pluginErrorHost.style.display = 'block'
- const info = pluginErrorMessage(locale, activePluginErrorName)
-
- const card = element('aside', 'card')
- card.setAttribute('aria-live', 'polite')
- card.setAttribute('aria-label', info.title)
-
- const row = element('div', 'row')
- const indicator = element('span', restartingHarness || resettingHarness ? 'spinner' : 'dot warning')
- indicator.setAttribute('aria-hidden', 'true')
- row.appendChild(indicator)
-
- const body = element('div', 'body')
- const title = element('p', 'message')
- title.textContent = info.title
- body.appendChild(title)
-
- const detail = element('p', 'detail')
- detail.textContent = info.message
- body.appendChild(detail)
-
- const actions = element('div', 'actions')
- const restartBtn = button(
- restartingHarness
- ? locale === 'zh'
- ? '正在重启…'
- : 'Restarting…'
- : locale === 'zh'
- ? '重启 Harness'
- : 'Restart Harness',
- 'primary'
- )
- restartBtn.disabled = restartingHarness
- restartBtn.addEventListener('click', () => {
- restartingHarness = true
- renderPluginError()
- void ipcRenderer
- .invoke('harness:reset-plugins', activePluginErrorName)
- .finally(() => {
- restartingHarness = false
- dismissPluginError()
- })
+contextBridge.exposeInMainWorld(
+ 'dshRecovery',
+ Object.freeze({
+ action: (action: string): Promise<{ ok: boolean }> => ipcRenderer.invoke('recovery:action', action)
})
-
- const ignoreBtn = button(locale === 'zh' ? '忽略' : 'Dismiss', 'secondary')
- ignoreBtn.disabled = restartingHarness
- ignoreBtn.addEventListener('click', dismissPluginError)
-
- actions.append(restartBtn, ignoreBtn)
- body.appendChild(actions)
-
- row.appendChild(body)
-
- const close = button('×', 'close')
- close.setAttribute('aria-label', locale === 'zh' ? '关闭' : 'Close')
- close.addEventListener('click', dismissPluginError)
- row.appendChild(close)
-
- card.appendChild(row)
- pluginErrorContent.replaceChildren(card)
-}
+)
function mount(): void {
if (document.getElementById(ROOT_ID)) return
diff --git a/test/plugin-error-view.test.ts b/test/plugin-error-view.test.ts
index 35340301..09be218e 100644
--- a/test/plugin-error-view.test.ts
+++ b/test/plugin-error-view.test.ts
@@ -48,16 +48,18 @@ describe('plugin load error detection and extraction', () => {
})
})
-describe('preload wiring for plugin error toast', () => {
- it('installs error listeners and exposes restart button', async () => {
+describe('preload wiring for plugin error handling', () => {
+ it('installs error listeners and connects to unified recovery', async () => {
const preload = await readFile('src/preload/index.ts', 'utf8')
- expect(preload).toContain('dsh-desktop-plugin-error-root')
expect(preload).toContain("window.addEventListener('error'")
expect(preload).toContain("window.addEventListener('unhandledrejection'")
expect(preload).toContain('isPluginLoadError')
- expect(preload).toContain("harness:restart")
- expect(preload).toContain("harness:reset-plugins")
- expect(preload).toContain('mountPluginErrorCard')
+ expect(preload).toContain('harness:open-recovery')
+ expect(preload).toContain('checkBootFailureInDom')
+ expect(preload).toContain('queueBootFailure(errorText)')
+ expect(preload).toContain('pendingBootFailureMessages.join')
+ expect(preload).toContain("document.body?.innerText")
+ expect(preload).toContain("text?.includes('Failed to load plugins')")
})
})
diff --git a/test/plugin-recovery-view.test.ts b/test/plugin-recovery-view.test.ts
index d6bb406a..7ecdb314 100644
--- a/test/plugin-recovery-view.test.ts
+++ b/test/plugin-recovery-view.test.ts
@@ -38,7 +38,9 @@ describe('plugin recovery view model', () => {
it.each([
['cannot resolve profile bundle example', '插件没有完整安装'],
['package declares no dsh.bundle', '安装的包不是兼容的 DSH 插件'],
- ['failed to import loader entry example', '插件代码加载失败']
+ ['failed to import loader entry example', '插件代码加载失败'],
+ ['duplicate loader entry id: storage', '插件注册了重复的服务组件'],
+ ['single slot "conversation.hero.workspace.directoryFlow" already has a registration at priority 0', '插件存在界面插槽冲突']
])('describes known startup failures: %s', (log, expectedTitle) => {
expect(describePluginFailure([`[stderr] ${log}`], 'zh').title).toBe(expectedTitle)
})
@@ -70,6 +72,17 @@ describe('plugin recovery view model', () => {
expect(model.plugins).toEqual(['plugin-b'])
})
+ it('shows a readable name for a scoped package while recovery keeps its package id', () => {
+ const model = buildPluginRecoveryViewModel({
+ snapshot: failedSnapshot(),
+ plugins: ['@deepseek-harness-tui/dsh-tui'],
+ removedPlugins: [],
+ locale: 'zh'
+ })
+ expect(model.plugins).toEqual(['dsh-tui'])
+ expect(model.canUninstall).toBe(true)
+ })
+
it('falls back to the log when no plugin can be identified', () => {
const model = buildPluginRecoveryViewModel({
snapshot: failedSnapshot(),
diff --git a/test/plugin-recovery.test.ts b/test/plugin-recovery.test.ts
index aa69e619..f153e6ee 100644
--- a/test/plugin-recovery.test.ts
+++ b/test/plugin-recovery.test.ts
@@ -1,15 +1,41 @@
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { parse, stringify } from 'yaml'
import {
+ isThirdPartyPackageName,
profilePackageJsonPath,
resetPluginProfile,
+ resolveProfileRecoveryPlugins,
uninstallPluginFromProfile
} from '../src/main/state/plugin-recovery'
describe('plugin-recovery', () => {
const testDir = join(__dirname, '.temp-plugin-recovery-test')
+ async function simulateDshPluginRemove(pluginName: string): Promise {
+ const profileDirectory = join(testDir, 'profiles', 'web')
+ const manifestPath = join(profileDirectory, 'package.json')
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
+ delete manifest.dependencies?.[pluginName]
+ if (manifest.dsh?.profile?.bundles) {
+ manifest.dsh.profile.bundles = manifest.dsh.profile.bundles.filter(
+ (bundle: string) => bundle !== pluginName
+ )
+ }
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
+
+ const lockfilePath = join(profileDirectory, 'pnpm-lock.yaml')
+ const lockfile = parse(await readFile(lockfilePath, 'utf8'))
+ delete lockfile.importers?.['.']?.dependencies?.[pluginName]
+ await writeFile(lockfilePath, stringify(lockfile))
+ await rm(join(profileDirectory, 'node_modules', pluginName), {
+ recursive: true,
+ force: true
+ })
+ return true
+ }
+
beforeEach(async () => {
await mkdir(join(testDir, 'profiles', 'web'), { recursive: true })
})
@@ -40,8 +66,30 @@ describe('plugin-recovery', () => {
}
}
await writeFile(pkgPath, JSON.stringify(originalPkg, null, 2))
+ await mkdir(join(testDir, 'profiles', 'web', 'node_modules', 'dsh-better-sidebar'), {
+ recursive: true
+ })
+ await writeFile(
+ join(testDir, 'profiles', 'web', 'pnpm-lock.yaml'),
+ stringify({
+ lockfileVersion: '9.0',
+ importers: {
+ '.': {
+ dependencies: {
+ 'dsh-better-sidebar': { specifier: '^0.13.1', version: '0.13.1' },
+ '@linxin666/dsh-web-ui-all': { specifier: '^0.2.2', version: '0.2.2' },
+ dshmarket: { specifier: '1.9.0', version: '1.9.0' }
+ }
+ }
+ }
+ })
+ )
- const success = await uninstallPluginFromProfile(testDir, 'dsh-better-sidebar')
+ const success = await uninstallPluginFromProfile(
+ testDir,
+ 'dsh-better-sidebar',
+ simulateDshPluginRemove
+ )
expect(success).toBe(true)
const updatedPkg = JSON.parse(await readFile(pkgPath, 'utf8'))
@@ -55,6 +103,13 @@ describe('plugin-recovery', () => {
'dshmarket',
'@linxin666/dsh-web-ui-all'
])
+ const updatedLockfile = parse(
+ await readFile(join(testDir, 'profiles', 'web', 'pnpm-lock.yaml'), 'utf8')
+ )
+ expect(updatedLockfile.importers['.'].dependencies).toEqual({
+ '@linxin666/dsh-web-ui-all': { specifier: '^0.2.2', version: '0.2.2' },
+ dshmarket: { specifier: '1.9.0', version: '1.9.0' }
+ })
})
it('returns false when package.json does not exist', async () => {
@@ -82,6 +137,145 @@ describe('plugin-recovery', () => {
expect(success).toBe(false)
})
+ it('does not report success when the lockfile still imports the removed plugin', async () => {
+ const profileDirectory = join(testDir, 'profiles', 'web')
+ const pkgPath = profilePackageJsonPath(testDir)
+ await writeFile(
+ pkgPath,
+ JSON.stringify({
+ dependencies: { 'stale-lock-plugin': '^1.0.0' },
+ dsh: { profile: { bundles: ['stale-lock-plugin'] } }
+ })
+ )
+ await writeFile(
+ join(profileDirectory, 'pnpm-lock.yaml'),
+ stringify({
+ lockfileVersion: '9.0',
+ importers: {
+ '.': {
+ dependencies: {
+ 'stale-lock-plugin': { specifier: '^1.0.0', version: '1.0.0' }
+ }
+ }
+ }
+ })
+ )
+
+ const success = await uninstallPluginFromProfile(
+ testDir,
+ 'stale-lock-plugin',
+ async (pluginName) => {
+ const manifest = JSON.parse(await readFile(pkgPath, 'utf8'))
+ delete manifest.dependencies[pluginName]
+ manifest.dsh.profile.bundles = []
+ await writeFile(pkgPath, JSON.stringify(manifest))
+ return true
+ }
+ )
+
+ expect(success).toBe(false)
+ })
+
+ it('never treats Harness core packages as uninstallable third-party packages', async () => {
+ const pkgPath = profilePackageJsonPath(testDir)
+ const manifest = {
+ dependencies: {
+ '@deepseek-ai/dsh-client-ui-directory-picker-native': '^0.1.0-rc.7',
+ dshmarket: '1.15.0'
+ },
+ dsh: {
+ profile: {
+ bundles: [
+ '@deepseek-ai/dsh-base',
+ '@deepseek-ai/dsh-web-app',
+ '@deepseek-ai/dsh-client-ui-directory-picker-native',
+ 'dshmarket'
+ ]
+ }
+ }
+ }
+ await writeFile(pkgPath, JSON.stringify(manifest))
+
+ expect(isThirdPartyPackageName('@deepseek-ai/dsh-client-ui-directory-picker-native')).toBe(false)
+ expect(isThirdPartyPackageName('dshmarket')).toBe(false)
+ expect(isThirdPartyPackageName('@linxin666/dsh-web-ui-all')).toBe(true)
+ await expect(
+ uninstallPluginFromProfile(testDir, '@deepseek-ai/dsh-client-ui-directory-picker-native')
+ ).resolves.toBe(false)
+ expect(JSON.parse(await readFile(pkgPath, 'utf8'))).toEqual(manifest)
+ })
+
+ it('maps an internal duplicate loader error to the profile bundle that declared it', async () => {
+ const pkgPath = profilePackageJsonPath(testDir)
+ const pluginDirectory = join(
+ testDir,
+ 'profiles',
+ 'web',
+ 'node_modules',
+ '@deepseek-harness-tui',
+ 'dsh-tui'
+ )
+ await mkdir(pluginDirectory, { recursive: true })
+ await writeFile(
+ pkgPath,
+ JSON.stringify({
+ dependencies: {
+ '@deepseek-harness-tui/dsh-tui': '^0.8.4',
+ dshmarket: '1.15.0'
+ },
+ dsh: {
+ profile: {
+ bundles: [
+ '@deepseek-ai/dsh-base',
+ '@deepseek-ai/dsh-web-app',
+ 'dshmarket',
+ '@deepseek-harness-tui/dsh-tui'
+ ]
+ }
+ }
+ })
+ )
+ await writeFile(
+ join(pluginDirectory, 'package.json'),
+ JSON.stringify({
+ name: '@deepseek-harness-tui/dsh-tui',
+ dsh: { bundle: { patch: './cordis.patch.yml' } }
+ })
+ )
+ await writeFile(
+ join(pluginDirectory, 'cordis.patch.yml'),
+ '- id: storage\n name: "@deepseek-ai/dsh-storage"\n'
+ )
+
+ await expect(
+ resolveProfileRecoveryPlugins(testDir, [], 'storage')
+ ).resolves.toEqual(['@deepseek-harness-tui/dsh-tui'])
+ })
+
+ it('does not offer or remove a package that is not an active profile bundle', async () => {
+ const pkgPath = profilePackageJsonPath(testDir)
+ await writeFile(
+ pkgPath,
+ JSON.stringify({
+ dependencies: {
+ 'partial-plugin': '^1.0.0'
+ },
+ dsh: {
+ profile: {
+ bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']
+ }
+ }
+ })
+ )
+
+ await expect(
+ resolveProfileRecoveryPlugins(testDir, ['partial-plugin'])
+ ).resolves.toEqual([])
+ await expect(
+ uninstallPluginFromProfile(testDir, 'partial-plugin', async () => true)
+ ).resolves.toBe(false)
+ })
+
it('resets plugin profile by cleaning up specific failing plugin and related packages', async () => {
const pkgPath = profilePackageJsonPath(testDir)
const originalPkg = {
@@ -116,4 +310,321 @@ describe('plugin-recovery', () => {
'dshmarket'
])
})
+
+ it('resolves root package when a scoped sub-module fails', async () => {
+ const pkgPath = profilePackageJsonPath(testDir)
+ const rootPackageDir = join(
+ testDir,
+ 'profiles',
+ 'web',
+ 'node_modules',
+ '@linxin666',
+ 'dsh-web-ui-all'
+ )
+ await mkdir(rootPackageDir, { recursive: true })
+ await writeFile(
+ join(rootPackageDir, 'package.json'),
+ JSON.stringify({
+ name: '@linxin666/dsh-web-ui-all',
+ dependencies: {
+ '@linxin666/dsh-client-ui-web-ui-settings': '0.2.2'
+ }
+ })
+ )
+ await writeFile(
+ pkgPath,
+ JSON.stringify({
+ dependencies: {
+ '@linxin666/dsh-web-ui-all': '^0.2.2',
+ '@openviking/dsh-memory-plugin': '^0.1.0',
+ dshmarket: '1.9.0'
+ },
+ dsh: {
+ profile: {
+ bundles: [
+ '@deepseek-ai/dsh-base',
+ '@deepseek-ai/dsh-web-app',
+ 'dshmarket',
+ '@linxin666/dsh-web-ui-all',
+ '@openviking/dsh-memory-plugin'
+ ]
+ }
+ }
+ })
+ )
+
+ const resolved = await resolveProfileRecoveryPlugins(testDir, [
+ '@linxin666/dsh-client-ui-web-ui-settings'
+ ])
+ expect(resolved).toEqual(['@linxin666/dsh-web-ui-all'])
+ })
+
+ it('resolves the specific plugin that declared a conflicting UI slot', async () => {
+ const pkgPath = profilePackageJsonPath(testDir)
+ const remoteDir = join(testDir, 'profiles', 'web', 'node_modules', 'dsh-full-remote')
+ const memoryDir = join(testDir, 'profiles', 'web', 'node_modules', '@openviking', 'dsh-memory-plugin')
+ await mkdir(remoteDir, { recursive: true })
+ await mkdir(memoryDir, { recursive: true })
+
+ await writeFile(
+ pkgPath,
+ JSON.stringify({
+ dependencies: {
+ 'dsh-full-remote': '^0.3.4',
+ '@openviking/dsh-memory-plugin': '^0.1.0',
+ dshmarket: '1.9.0'
+ },
+ dsh: {
+ profile: {
+ bundles: [
+ '@deepseek-ai/dsh-base',
+ '@deepseek-ai/dsh-web-app',
+ 'dshmarket',
+ 'dsh-full-remote',
+ '@openviking/dsh-memory-plugin'
+ ]
+ }
+ }
+ })
+ )
+
+ await writeFile(
+ join(remoteDir, 'client.js'),
+ 'ctx.slot("conversation.hero.workspace.directoryFlow", component);'
+ )
+ await writeFile(
+ join(memoryDir, 'client.js'),
+ 'ctx.slot("sidebar.panel", memoryComponent);'
+ )
+
+ const resolved = await resolveProfileRecoveryPlugins(
+ testDir,
+ ['@deepseek-ai/dsh-client-ui-directory-picker-browse'],
+ undefined,
+ 'conversation.hero.workspace.directoryFlow'
+ )
+ expect(resolved).toEqual(['dsh-full-remote'])
+ })
+
+ it('maps a failed core entry to the third-party bundle that inserted it', async () => {
+ const pkgPath = profilePackageJsonPath(testDir)
+ const remoteDir = join(testDir, 'profiles', 'web', 'node_modules', 'dsh-full-remote')
+ await mkdir(remoteDir, { recursive: true })
+ await writeFile(
+ join(remoteDir, 'package.json'),
+ JSON.stringify({
+ name: 'dsh-full-remote',
+ dsh: { bundle: { patch: './cordis.patch.yml' } }
+ })
+ )
+ await writeFile(
+ join(remoteDir, 'cordis.patch.yml'),
+ "- id: ui-directory-picker-browse\n name: '@deepseek-ai/dsh-client-ui-directory-picker-browse'\n"
+ )
+ await writeFile(
+ pkgPath,
+ JSON.stringify({
+ dependencies: {
+ 'dsh-full-remote': '^0.3.4',
+ 'unrelated-plugin': '^1.0.0'
+ },
+ dsh: {
+ profile: {
+ bundles: [
+ '@deepseek-ai/dsh-base',
+ '@deepseek-ai/dsh-web-app',
+ 'dsh-full-remote',
+ 'unrelated-plugin'
+ ]
+ }
+ }
+ })
+ )
+
+ await expect(
+ resolveProfileRecoveryPlugins(testDir, [
+ '@deepseek-ai/dsh-client-ui-directory-picker-browse'
+ ])
+ ).resolves.toEqual(['dsh-full-remote'])
+ })
+
+ it('maps the directory-picker frontend failure to the remaining remote bundle', async () => {
+ const pkgPath = profilePackageJsonPath(testDir)
+ const remoteDir = join(testDir, 'profiles', 'web', 'node_modules', '@xgone', 'dsh-remote')
+ await mkdir(remoteDir, { recursive: true })
+ await writeFile(
+ join(remoteDir, 'package.json'),
+ JSON.stringify({
+ name: '@xgone/dsh-remote',
+ dsh: { bundle: { patch: './cordis.patch.yml' } }
+ })
+ )
+ await writeFile(
+ join(remoteDir, 'cordis.patch.yml'),
+ [
+ '- insert:',
+ ' - id: directory-picker-browse-ui',
+ " name: '@deepseek-ai/dsh-client-ui-directory-picker-browse'",
+ ''
+ ].join('\n')
+ )
+ await writeFile(
+ pkgPath,
+ JSON.stringify({
+ dependencies: {
+ '@xgone/dsh-remote': '^0.2.0',
+ dshmarket: '1.9.0'
+ },
+ dsh: {
+ profile: {
+ bundles: [
+ '@deepseek-ai/dsh-base',
+ '@deepseek-ai/dsh-web-app',
+ 'dshmarket',
+ '@xgone/dsh-remote'
+ ]
+ }
+ }
+ })
+ )
+
+ await expect(
+ resolveProfileRecoveryPlugins(testDir, [
+ '@deepseek-ai/dsh-client-ui-directory-picker-browse'
+ ])
+ ).resolves.toEqual(['@xgone/dsh-remote'])
+ })
+
+ it('continues a recovery session by excluding the plugin removed in the previous round', async () => {
+ const pkgPath = profilePackageJsonPath(testDir)
+ const fullRemoteDir = join(testDir, 'profiles', 'web', 'node_modules', 'dsh-full-remote')
+ const xgoneRemoteDir = join(
+ testDir,
+ 'profiles',
+ 'web',
+ 'node_modules',
+ '@xgone',
+ 'dsh-remote'
+ )
+ for (const [directory, name] of [
+ [fullRemoteDir, 'dsh-full-remote'],
+ [xgoneRemoteDir, '@xgone/dsh-remote']
+ ] as const) {
+ await mkdir(directory, { recursive: true })
+ await writeFile(
+ join(directory, 'package.json'),
+ JSON.stringify({ name, dsh: { bundle: { patch: './cordis.patch.yml' } } })
+ )
+ await writeFile(
+ join(directory, 'cordis.patch.yml'),
+ "- id: directory-picker-browse-ui\n name: '@deepseek-ai/dsh-client-ui-directory-picker-browse'\n"
+ )
+ }
+ await writeFile(
+ pkgPath,
+ JSON.stringify({
+ dependencies: {
+ 'dsh-full-remote': '^0.3.4',
+ '@xgone/dsh-remote': '^0.2.0'
+ },
+ dsh: {
+ profile: {
+ bundles: [
+ '@deepseek-ai/dsh-base',
+ '@deepseek-ai/dsh-web-app',
+ '@xgone/dsh-remote',
+ 'dsh-full-remote'
+ ]
+ }
+ }
+ })
+ )
+
+ await expect(
+ resolveProfileRecoveryPlugins(
+ testDir,
+ ['@deepseek-ai/dsh-client-ui-directory-picker-browse'],
+ undefined,
+ 'conversation.hero.workspace.directoryFlow'
+ )
+ ).resolves.toEqual([])
+ await expect(
+ resolveProfileRecoveryPlugins(
+ testDir,
+ ['@deepseek-ai/dsh-client-ui-directory-picker-browse'],
+ undefined,
+ 'conversation.hero.workspace.directoryFlow',
+ ['dsh-full-remote']
+ )
+ ).resolves.toEqual(['@xgone/dsh-remote'])
+ })
+
+ it('does not offer every third-party package when the failure has no direct match', async () => {
+ const pkgPath = profilePackageJsonPath(testDir)
+ await writeFile(
+ pkgPath,
+ JSON.stringify({
+ dependencies: {
+ 'plugin-a': '^1.0.0',
+ 'plugin-b': '^1.0.0',
+ dshmarket: '1.15.0'
+ },
+ dsh: {
+ profile: {
+ bundles: [
+ '@deepseek-ai/dsh-base',
+ '@deepseek-ai/dsh-web-app',
+ 'dshmarket',
+ 'plugin-a',
+ 'plugin-b'
+ ]
+ }
+ }
+ })
+ )
+
+ await expect(
+ resolveProfileRecoveryPlugins(testDir, [
+ '@deepseek-ai/dsh-client-ui-directory-picker-native'
+ ])
+ ).resolves.toEqual([])
+ })
+
+ it('does not guess when more than one third-party package directly references a conflicting slot', async () => {
+ const pkgPath = profilePackageJsonPath(testDir)
+ const firstDir = join(testDir, 'profiles', 'web', 'node_modules', 'plugin-a')
+ const secondDir = join(testDir, 'profiles', 'web', 'node_modules', 'plugin-b')
+ await mkdir(firstDir, { recursive: true })
+ await mkdir(secondDir, { recursive: true })
+ await writeFile(join(firstDir, 'client.js'), 'slots.register({ name: "sidebar.panel" })')
+ await writeFile(join(secondDir, 'client.js'), 'slots.register({ name: "sidebar.panel" })')
+ await writeFile(
+ pkgPath,
+ JSON.stringify({
+ dependencies: {
+ 'plugin-a': '^1.0.0',
+ 'plugin-b': '^1.0.0'
+ },
+ dsh: {
+ profile: {
+ bundles: [
+ '@deepseek-ai/dsh-base',
+ '@deepseek-ai/dsh-web-app',
+ 'plugin-a',
+ 'plugin-b'
+ ]
+ }
+ }
+ })
+ )
+
+ await expect(
+ resolveProfileRecoveryPlugins(
+ testDir,
+ ['@deepseek-ai/dsh-client-ui-sidebar'],
+ undefined,
+ 'sidebar.panel'
+ )
+ ).resolves.toEqual([])
+ })
})
diff --git a/test/profile-plugin-command.test.ts b/test/profile-plugin-command.test.ts
new file mode 100644
index 00000000..9a5f3138
--- /dev/null
+++ b/test/profile-plugin-command.test.ts
@@ -0,0 +1,58 @@
+import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { removeProfilePluginWithDsh } from '../src/main/runtime/profile-plugin-command'
+
+describe('profile-plugin-command', () => {
+ const testDir = join(__dirname, '.temp-profile-plugin-command-test')
+
+ afterEach(async () => {
+ await rm(testDir, { recursive: true, force: true })
+ })
+
+ it('runs the DSH remove command with the bundled pnpm shim on PATH', async () => {
+ const profileDirectory = join(testDir, 'profiles', 'web')
+ const reportPath = join(testDir, 'report.json')
+ const dshEntryPath = join(testDir, 'fake-dsh.mjs')
+ await mkdir(profileDirectory, { recursive: true })
+ await writeFile(
+ dshEntryPath,
+ `
+ import { spawnSync } from 'node:child_process'
+ import { writeFileSync } from 'node:fs'
+ const pnpm = spawnSync('pnpm', ['--version'], {
+ encoding: 'utf8',
+ shell: process.platform === 'win32'
+ })
+ writeFileSync(${JSON.stringify(reportPath)}, JSON.stringify({
+ argv: process.argv.slice(2),
+ dshHome: process.env.DSH_HOME,
+ pnpmVersion: pnpm.stdout?.trim(),
+ pnpmStatus: pnpm.status,
+ pnpmError: pnpm.error?.message
+ }))
+ process.exit(pnpm.status ?? 1)
+ `,
+ 'utf8'
+ )
+
+ const result = await removeProfilePluginWithDsh(
+ {
+ dshHome: testDir,
+ dshEntryPath,
+ nodeExecutablePath: process.execPath,
+ pnpmEntryPath: join(process.cwd(), 'node_modules', 'pnpm', 'bin', 'pnpm.cjs'),
+ environment: process.env
+ },
+ '@example/plugin'
+ )
+
+ expect(result).toEqual({ ok: true })
+ expect(JSON.parse(await readFile(reportPath, 'utf8'))).toEqual({
+ argv: ['plugin', '--profile', 'web', 'remove', '@example/plugin'],
+ dshHome: testDir,
+ pnpmVersion: '10.34.5',
+ pnpmStatus: 0
+ })
+ })
+})
diff --git a/test/runtime.test.ts b/test/runtime.test.ts
index c82161be..5ec3a0b1 100644
--- a/test/runtime.test.ts
+++ b/test/runtime.test.ts
@@ -3,9 +3,11 @@ import {
buildHarnessArguments,
buildHarnessSpawnOptions,
buildNodeArguments,
+ extractDuplicateLoaderEntryId,
extractFailureCause,
extractOffendingPlugin,
extractOffendingPlugins,
+ extractPluginFailureReferences,
formatExitCode,
updateReadyStability
} from '../src/main/runtime/harness-runtime'
@@ -219,11 +221,32 @@ describe('offending plugin extraction', () => {
it('ignores core deepseek packages as offending plugins', () => {
const logs = [
- '[stderr] [harness-node] DSH entry failed: Error: dsh: plugin tree failed to load: failed to apply loader entry base (@deepseek-ai/dsh-base): some error'
+ '[stderr] [harness-node] DSH entry failed: Error: dsh: plugin tree failed to load: failed to apply loader entry picker (@deepseek-ai/dsh-client-ui-directory-picker-native): some error'
]
expect(extractOffendingPlugin(logs)).toBeUndefined()
})
+ it('extracts only third-party packages from the frontend boot failure list', () => {
+ const logs = [
+ '[stderr] Failed to load plugins\n@deepseek-ai/dsh-client-ui-directory-picker-native\ndsh-remote\nweb boot: 2 entries did not activate'
+ ]
+ expect(extractOffendingPlugins(logs)).toEqual(['dsh-remote'])
+ expect(extractPluginFailureReferences(logs)).toEqual([
+ '@deepseek-ai/dsh-client-ui-directory-picker-native',
+ 'dsh-remote'
+ ])
+ })
+
+ it('keeps a failed core entry as ownership evidence without making it uninstallable', () => {
+ const logs = [
+ '[stderr] failed to apply loader entry 43d01328 (@deepseek-ai/dsh-client-ui-directory-picker-browse): single slot "conversation.hero.workspace.directoryFlow" already has a registration at priority 0'
+ ]
+ expect(extractPluginFailureReferences(logs)).toEqual([
+ '@deepseek-ai/dsh-client-ui-directory-picker-browse'
+ ])
+ expect(extractOffendingPlugins(logs)).toEqual([])
+ })
+
it('returns undefined when no plugin error is matched', () => {
const logs = [
'[stderr] [harness-node] uncaught exception: ReferenceError: x is not defined'
@@ -231,6 +254,14 @@ describe('offending plugin extraction', () => {
expect(extractOffendingPlugin(logs)).toBeUndefined()
})
+ it('never treats an internal Cordis loader as an uninstallable plugin', () => {
+ const logs = [
+ '[stderr] [harness-node] DSH entry failed: Error: dsh: plugin tree failed to load: failed to apply loader entry include (cordis:include): duplicate loader entry id: storage'
+ ]
+ expect(extractOffendingPlugins(logs)).toEqual([])
+ expect(extractDuplicateLoaderEntryId(logs)).toBe('storage')
+ })
+
it('collects multiple unique plugins reported by the same launch', () => {
const logs = [
'[desktop] starting 2026-08-19T08:00:00.000Z',