Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ build/icon-1024.png
.env
.env.*
!.env.example
.coaligne/
.coaligneignore

10 changes: 8 additions & 2 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { extractFailureCause, extractOffendingPlugins, HarnessRuntime } from './
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 { getInstalledThirdPartyPlugins, resetPluginProfile, uninstallPluginFromProfile } from './state/plugin-recovery'
import { isAbortedNavigationError, shouldLoadHarnessUrl } from './window-navigation'
import {
checkForUpdates,
Expand Down Expand Up @@ -523,7 +523,13 @@ async function showRuntimeFailure(snapshot: RuntimeSnapshot): Promise<void> {
try {
while (!quitting && runtime.snapshot().phase === 'failed') {
snapshot = runtime.snapshot()
const offendingPlugins = extractOffendingPlugins(snapshot.logs)
let offendingPlugins = extractOffendingPlugins(snapshot.logs)
if (offendingPlugins.length === 0) {
const thirdParty = await getInstalledThirdPartyPlugins(dshHome)
if (thirdParty.length > 0) {
offendingPlugins = thirdParty
}
}
const action = await waitForPluginRecoveryAction({
snapshot,
plugins: offendingPlugins,
Expand Down
13 changes: 13 additions & 0 deletions src/main/plugin-recovery-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,19 @@ export function describePluginFailure(
}
}

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'
? {
Expand Down
80 changes: 51 additions & 29 deletions src/main/state/plugin-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,42 +21,40 @@ interface ProfileManifest {
}
}

export async function uninstallPluginFromProfile(
dshHome: string,
pluginName: string
): Promise<boolean> {
const CORE_BUNDLES = new Set(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'dshmarket'])

export async function getInstalledThirdPartyPlugins(dshHome: string): Promise<string[]> {
const manifestPath = profilePackageJsonPath(dshHome)
if (!existsSync(manifestPath)) return false
if (!existsSync(manifestPath)) return []

try {
const raw = await readFile(manifestPath, 'utf8')
const manifest = JSON.parse(raw) as ProfileManifest
let modified = false

if (manifest.dependencies && pluginName in manifest.dependencies) {
delete manifest.dependencies[pluginName]
modified = true
}
const thirdParty = new Set<string>()

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
for (const bundle of manifest.dsh.profile.bundles) {
if (!CORE_BUNDLES.has(bundle)) thirdParty.add(bundle)
}
}

if (modified) {
await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8')
return true
if (manifest.dependencies) {
for (const dep of Object.keys(manifest.dependencies)) {
if (!CORE_BUNDLES.has(dep)) thirdParty.add(dep)
}
}

return Array.from(thirdParty)
} catch {
return false
return []
}
}

return false
export async function uninstallPluginFromProfile(
dshHome: string,
pluginName: string
): Promise<boolean> {
return resetPluginProfile(dshHome, pluginName)
}

export async function resetPluginProfile(
Expand All @@ -69,48 +67,72 @@ export async function resetPluginProfile(
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) ||
dep.includes(failingPlugin) ||
(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 &&
!failingPlugin.includes(b) &&
!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
return modified
} catch {
return false
}
Expand Down
3 changes: 2 additions & 1 deletion test/plugin-recovery-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ 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', '插件注册了重复的服务组件']
])('describes known startup failures: %s', (log, expectedTitle) => {
expect(describePluginFailure([`[stderr] ${log}`], 'zh').title).toBe(expectedTitle)
})
Expand Down
Loading