Skip to content
Merged
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
19 changes: 19 additions & 0 deletions build/installer.nsh
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,24 @@
${NSD_SetText} $DshDirectoryEdit $3
StrCpy $DshDirectoryNormalizationActive "0"
FunctionEnd

; Auto-create the installation directory tree before install begins.
; This allows users to type any path (e.g. D:\dsh-desktop) directly
; without needing to pre-create parent folders first.
!define MUI_PAGE_CUSTOMFUNCTION_LEAVE DshEnsureInstDirExists

Function DshEnsureInstDirExists
CreateDirectory "$INSTDIR"
FunctionEnd

; Accept any directory path the user types, even if it does not exist yet.
; Without this override NSIS rejects non-existent paths before the user
; can click Next.
!macro preInit
!macroend
Function .onVerifyInstDir
; Always pass — we create the directory in DshEnsureInstDirExists.
FunctionEnd
!endif
!endif

10 changes: 10 additions & 0 deletions build/plugin-recovery.html
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ <h1 id="heading"></h1>

<div class="decision-row">
<div class="actions">
<button class="button" id="restart" type="button"></button>
<button class="button primary" id="primary" type="button"></button>
</div>
<p class="safety">
Expand Down Expand Up @@ -413,6 +414,7 @@ <h1 id="heading"></h1>
setText('reason-detail', model.reasonDetail)
setText('safety-note', model.safetyNote)
setText('primary', model.primaryLabel)
setText('restart', model.restartLabel)
setText('advanced-label', model.advancedLabel)
setText('launch-directory-label', model.launchDirectoryLabel)
setText('launch-directory', model.launchDirectory || '—')
Expand Down Expand Up @@ -457,11 +459,19 @@ <h1 id="heading"></h1>
}
}
const primary = document.getElementById('primary')
const restart = document.getElementById('restart')
primary.addEventListener('click', () => {
primary.disabled = true
primary.textContent = model.primaryBusyLabel
restart.disabled = true
navigate(model.canUninstall ? 'uninstall' : 'show-log')
})
restart.addEventListener('click', () => {
restart.disabled = true
restart.textContent = model.restartBusyLabel
primary.disabled = true
navigate('restart')
})
document.getElementById('show-log').addEventListener('click', () => navigate('show-log'))
document.getElementById('quit').addEventListener('click', () => navigate('quit'))
</script>
Expand Down
6 changes: 5 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import { secureWindow } from './security'
import { ensureLaunchRoot } from './state/launch-root'
import {
pruneMissingProfileBundles,
resetPluginProfile,
uninstallPluginFromProfile
} from './state/plugin-recovery'
Expand Down Expand Up @@ -53,7 +54,8 @@ type PluginRecoveryAction = 'uninstall' | 'show-log' | 'quit' | 'restart' | 'ref
const PLUGIN_RECOVERY_ACTIONS = new Set<PluginRecoveryAction>([
'uninstall',
'show-log',
'quit'
'quit',
'restart'
])

let mainWindow: BrowserWindow | undefined
Expand Down Expand Up @@ -445,6 +447,8 @@ function launchHarness(): Promise<void> {
if (harnessLaunchOperation) return harnessLaunchOperation

harnessLaunchOperation = (async () => {
const dshHome = join(app.getPath('userData'), 'harness')
await pruneMissingProfileBundles(dshHome).catch(() => false)
await showSplash()
await runtime.start(launchDirectory)
})().finally(() => {
Expand Down
6 changes: 6 additions & 0 deletions src/main/plugin-recovery-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface PluginRecoveryViewModel {
safetyNote: string
primaryLabel: string
primaryBusyLabel: string
restartLabel: string
restartBusyLabel: string
logLabel: string
advancedLabel: string
errorLabel: string
Expand Down Expand Up @@ -179,6 +181,8 @@ export function buildPluginRecoveryViewModel(options: {
? multiple ? `卸载这 ${plugins.length} 个插件并继续检测` : '卸载此插件并继续检测'
: '打开 Harness 日志',
primaryBusyLabel: canUninstall ? '正在处理并重新检测…' : '正在打开日志…',
restartLabel: '重启 Harness',
restartBusyLabel: '正在重启…',
logLabel: '打开 Harness 日志',
advancedLabel: '查看技术详情',
errorLabel: '错误信息',
Expand Down Expand Up @@ -213,6 +217,8 @@ export function buildPluginRecoveryViewModel(options: {
? multiple ? `Remove these ${plugins.length} plugins and continue` : 'Remove this plugin and continue'
: 'Open Harness log',
primaryBusyLabel: canUninstall ? 'Removing and checking again…' : 'Opening log…',
restartLabel: 'Restart Harness',
restartBusyLabel: 'Restarting…',
logLabel: 'Open Harness log',
advancedLabel: 'View technical details',
errorLabel: 'Error details',
Expand Down
67 changes: 67 additions & 0 deletions src/main/state/plugin-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,8 +466,75 @@ export async function resetPluginProfile(
}
}

// Remove stale pnpm-lock.yaml so pnpm regenerates a clean hoisted dependency graph
const lockfilePath = join(dshHome, 'profiles', 'web', 'pnpm-lock.yaml')
if (existsSync(lockfilePath)) {
await rm(lockfilePath, { force: true }).catch(() => undefined)
}

return modified
} catch {
return false
}
}

/**
* Automatically inspects the web profile manifest before launch.
* If any third-party bundle listed in `dsh.profile.bundles` is missing from `node_modules`,
* prunes it from `bundles` and `dependencies` to prevent Harness from failing with
* "cannot resolve profile bundle".
*/
export async function pruneMissingProfileBundles(dshHome: string): Promise<boolean> {
const manifestPath = profilePackageJsonPath(dshHome)
if (!existsSync(manifestPath)) return false

const profileDirectory = dirname(manifestPath)
const nodeModulesPath = join(profileDirectory, 'node_modules')

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

if (manifest.dsh?.profile?.bundles) {
const origBundles = manifest.dsh.profile.bundles
const prunedBundles = origBundles.filter((bundle) => {
if (!isThirdPartyPackageName(bundle)) return true
const bundleDir = join(nodeModulesPath, bundle)
const exists = existsSync(bundleDir)
if (!exists) {
modified = true
}
return exists
})

if (modified) {
manifest.dsh.profile.bundles = prunedBundles
}
}

if (manifest.dependencies) {
for (const dep of Object.keys(manifest.dependencies)) {
if (!isThirdPartyPackageName(dep)) continue
const depDir = join(nodeModulesPath, dep)
if (!existsSync(depDir)) {
delete manifest.dependencies[dep]
modified = true
}
}
}

if (modified) {
await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8')
const lockfilePath = join(profileDirectory, 'pnpm-lock.yaml')
if (existsSync(lockfilePath)) {
await rm(lockfilePath, { force: true }).catch(() => undefined)
}
}

return modified
} catch {
return false
}
}

1 change: 1 addition & 0 deletions test/plugin-recovery-html.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ describe('plugin recovery page', () => {

it('retains access to diagnostics and exit actions', () => {
expect(html).toContain('class="decision-row"')
expect(html).toContain('id="restart"')
expect(html.indexOf('id="primary"')).toBeLessThan(html.indexOf('id="safety-note"'))
expect(html).toContain('id="advanced-label"')
expect(html).toContain('id="show-log"')
Expand Down
4 changes: 4 additions & 0 deletions test/plugin-recovery-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ describe('plugin recovery view model', () => {
expect(model.plugins).toEqual(['plugin-a', 'plugin-b'])
expect(model.primaryLabel).toBe('卸载这 2 个插件并继续检测')
expect(model.canUninstall).toBe(true)
expect(model.restartLabel).toBe('重启 Harness')
expect(model.restartBusyLabel).toBe('正在重启…')
expect(model).not.toHaveProperty('status')
expect(model.advancedLabel).toBe('查看技术详情')
})
Expand Down Expand Up @@ -92,5 +94,7 @@ describe('plugin recovery view model', () => {
})
expect(model.canUninstall).toBe(false)
expect(model.primaryLabel).toBe('Open Harness log')
expect(model.restartLabel).toBe('Restart Harness')
expect(model.restartBusyLabel).toBe('Restarting…')
})
})
71 changes: 71 additions & 0 deletions test/plugin-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { parse, stringify } from 'yaml'
import {
isThirdPartyPackageName,
profilePackageJsonPath,
pruneMissingProfileBundles,
resetPluginProfile,
resolveProfileRecoveryPlugins,
uninstallPluginFromProfile
Expand Down Expand Up @@ -627,4 +628,74 @@ describe('plugin-recovery', () => {
)
).resolves.toEqual([])
})

it('prunes missing third-party bundles and broken dependencies while preserving core and installed packages', async () => {
const pkgPath = profilePackageJsonPath(testDir)
const existingPluginDir = join(testDir, 'profiles', 'web', 'node_modules', 'dsh-existing-plugin')
await mkdir(existingPluginDir, { recursive: true })
await writeFile(join(existingPluginDir, 'package.json'), JSON.stringify({ name: 'dsh-existing-plugin' }))

const lockfilePath = join(testDir, 'profiles', 'web', 'pnpm-lock.yaml')
await writeFile(lockfilePath, 'lockfile-content')

await writeFile(
pkgPath,
JSON.stringify({
dependencies: {
'dsh-existing-plugin': '^1.0.0',
'dsh-full-remote': '^1.0.0'
},
dsh: {
profile: {
bundles: [
'@deepseek-ai/dsh-base',
'@deepseek-ai/dsh-web-app',
'dshmarket',
'dsh-existing-plugin',
'dsh-full-remote'
]
}
}
})
)

const modified = await pruneMissingProfileBundles(testDir)
expect(modified).toBe(true)

const updated = JSON.parse(await readFile(pkgPath, 'utf8'))
expect(updated.dependencies).toEqual({
'dsh-existing-plugin': '^1.0.0'
})
expect(updated.dsh.profile.bundles).toEqual([
'@deepseek-ai/dsh-base',
'@deepseek-ai/dsh-web-app',
'dshmarket',
'dsh-existing-plugin'
])
})

it('leaves clean profile manifests unmodified when pruning missing bundles', async () => {
const pkgPath = profilePackageJsonPath(testDir)
await writeFile(
pkgPath,
JSON.stringify({
dependencies: {
dshmarket: '1.16.0'
},
dsh: {
profile: {
bundles: [
'@deepseek-ai/dsh-base',
'@deepseek-ai/dsh-web-app',
'dshmarket'
]
}
}
})
)

const modified = await pruneMissingProfileBundles(testDir)
expect(modified).toBe(false)
})
})

Loading