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
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

6 changes: 5 additions & 1 deletion build/plugin-recovery.html
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,11 @@ <h1 id="heading"></h1>
}

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', () => {
Expand Down
156 changes: 143 additions & 13 deletions src/main/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -53,6 +64,45 @@ let failureRecoveryVisible = false
let harnessLaunchOperation: Promise<void> | undefined
let pluginRecoveryActionResolver: ((action: PluginRecoveryAction) => void) | undefined
let mainWindowNavigationVersion = 0
let rendererPluginFailureLogs: string[] = []
let pluginRecoveryRemovedPlugins: string[] = []
let pluginRecoveryResetTimer: ReturnType<typeof setTimeout> | 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
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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)
Expand All @@ -305,6 +370,7 @@ async function openHarness(url: string): Promise<void> {
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)
Expand Down Expand Up @@ -511,21 +577,36 @@ function showUnexpectedError(error: unknown): void {
dialog.showErrorBox('DSH Desktop encountered an error', message)
}

async function showRuntimeFailure(snapshot: RuntimeSnapshot): Promise<void> {
async function showPluginRecovery(options?: {
message?: string
logs?: readonly string[]
}): Promise<void> {
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
Expand All @@ -535,7 +616,24 @@ async function showRuntimeFailure(snapshot: RuntimeSnapshot): Promise<void> {
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 {
Expand All @@ -555,18 +653,25 @@ async function showRuntimeFailure(snapshot: RuntimeSnapshot): Promise<void> {
: `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
} else {
app.quit()
return
}

if (runtime.snapshot().phase !== 'failed') return
snapshot = runtime.snapshot()
}
} catch (error) {
showUnexpectedError(error)
Expand All @@ -575,6 +680,10 @@ async function showRuntimeFailure(snapshot: RuntimeSnapshot): Promise<void> {
}
}

async function showRuntimeFailure(snapshot: RuntimeSnapshot): Promise<void> {
await showPluginRecovery({ message: snapshot.message, logs: snapshot.logs })
}

function installMenu(): void {
const isChinese = app.getLocale().toLowerCase().startsWith('zh')
const checkForUpdatesLabel = isChinese
Expand Down Expand Up @@ -773,6 +882,27 @@ async function bootstrap(): Promise<void> {
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)
Expand Down
36 changes: 34 additions & 2 deletions src/main/plugin-recovery-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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'
Expand All @@ -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'
? {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading