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
4 changes: 3 additions & 1 deletion build/safe-mode.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--canvas: #f7f7f8; --surface: #fff; --soft: #f5f6f7; --ink: #18181b;
--muted: #71717a; --line: #e4e4e7; --strong: #d4d4d8;
--button: #18181b; --button-ink: #fff; --danger: #c33b38; --success: #267a4a;
--button: #18181b; --button-ink: #fff; --danger: #c33b38; --warning: #a45b00; --success: #267a4a;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
Expand Down Expand Up @@ -48,6 +48,7 @@
.plugin-title { display:flex; flex-wrap:wrap; align-items:baseline; gap:4px; }
.plugin-name { overflow-wrap: anywhere; font-family: ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size: 13px; font-weight: 600; }
.plugin-status { color:var(--danger); font-size:11px; font-weight:650; }
.plugin-status.warning { color:var(--warning); }
.plugin-action { margin-top:4px; font-size:11px; font-weight:620; }
.issue-group { border-top:1px solid var(--line); background:var(--surface); }
.issue-group:first-child { border-top:0; }
Expand Down Expand Up @@ -201,6 +202,7 @@ <h2 class="backup-heading" id="backup-heading"></h2>
title.appendChild(name)
if (plugin.statusLabel) {
const status = document.createElement('span'); status.className = 'plugin-status'; status.textContent = String(plugin.statusLabel)
if (plugin.statusTone === 'warning') status.classList.add('warning')
title.appendChild(status)
}
const action = document.createElement('span'); action.className = 'plugin-action'; action.textContent = String(plugin.actionLabel || '')
Expand Down
131 changes: 94 additions & 37 deletions packages/dsh-desktop-market-installer/generations/projection.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,11 @@ import { resolveEnabledGenerations } from './registry.mjs'
* consistency check, recovery, and inventory — all of which read this
* contract — agree with what is actually linked.
*
* Generation plugins go in `bundles` but never in `dependencies`. `bundles`
* is what `resolveBundleDir` reads, and it resolves through the symlink this
* projector writes. `dependencies` is what `pnpm install` acts on — listing a
* generation there makes the shared-tree repair try to install it into
* `node_modules` over the symlink, which is the exact Windows rename-over-
* existing that the generation model exists to avoid.
* Generation plugins appear in `dependencies` at their installed version so
* dsh-market can present them as ordinary installed releases. pnpm is kept on
* the immutable local generation by a root override under `pnpm.overrides`;
* the internal `link:` path therefore never leaks into the market-facing
* dependency map.
*
* The projection is derived, never authored. Losing it costs a reprojection,
* not a repair.
Expand All @@ -40,6 +39,9 @@ const IN_BOX_BUNDLES = new Set(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-a
/** Substring that marks a symlink target as one this projector wrote. */
const GENERATION_LINK_MARKER = join('profiles', '.generations', 'live')

/** Versioned marker for the manifest fields owned by this derived projection. */
const PROJECTION_VERSION = 1

function profileDir(dshHome, profile = 'web') {
return join(dshHome, 'profiles', profile)
}
Expand Down Expand Up @@ -233,20 +235,20 @@ export async function projectGenerations(dshHome, profile = 'web') {

const linked = []
const linkSpecs = new Map()
const projected = new Map()
for (const [pluginName, generation] of enabled) {
const target = targets.get(pluginName)
if (target === undefined) throw new Error(`Enabled generation target was not prevalidated: ${pluginName}`)
await ensureDirLink(join(modulesDir, pluginName), target)
linked.push(pluginName)
// A `link:` spec is what makes the market's readInstalled() (which reads
// `dependencies`) see the plugin, while telling `pnpm install` the target
// is already a local directory to symlink — never something to fetch or
// rename over.
projected.set(pluginName, generation)
// This path belongs only to pnpm's root override. The market reads the
// ordinary dependency version written below and never sees it.
linkSpecs.set(pluginName, `link:${relative(dir, target).split('\\').join('/')}`)
}

const unlinked = await pruneStaleGenerationLinks(modulesDir, enabled)
const bundles = await syncProfileManifest(dir, enabled, linkSpecs, manifestState)
const unlinked = await pruneStaleGenerationLinks(modulesDir, projected)
const bundles = await syncProfileManifest(dir, projected, linkSpecs, manifestState)

return { linked, unlinked, bundles }
}
Expand Down Expand Up @@ -288,30 +290,64 @@ async function pruneStaleGenerationLinks(modulesDir, enabled) {
}

/**
* Rewrite `dsh.profile.bundles` and `dependencies` so both the app-boot
* contract and the market's `readInstalled()` agree with the projection.
* In-box bundles keep their place at the front; the enabled generations
* follow, and each is also a `link:` dependency pointing at its generation.
* Rewrite the app-boot bundle list, the market-facing dependency versions,
* and pnpm's private generation overrides. A small marker records the fields
* Desktop owns so a disabled generation can be removed after a crash without
* guessing whether an ordinary version dependency belongs to the user.
*/
async function syncProfileManifest(dir, enabled, linkSpecs, manifestState) {
const manifestPath = join(dir, 'package.json')
const { current, manifest } = manifestState

// Real pnpm dependencies the profile already had (dshmarket, anything from
// the old shared-tree path) carry through unchanged. Each enabled generation
// becomes a `link:` dependency: the market sees it as installed, and
// `pnpm install` treats a `link:` target as an existing local directory to
// symlink rather than something to fetch or rename a directory over.
const previousProjection = manifest.dsh?.desktop?.generationProjection
const previousPlugins = previousProjection?.version === PROJECTION_VERSION &&
typeof previousProjection.plugins === 'object' && previousProjection.plugins !== null
? previousProjection.plugins
: {}

// Real profile dependencies carry through unchanged. Previous generation
// entries are removed using the explicit ownership marker, then enabled
// generations are written back at their actual installed versions.
const currentDeps = manifest.dependencies ?? {}
const dependencies = {}
for (const [name, spec] of Object.entries(currentDeps)) {
// Drop a generation `link:` dep whose plugin is no longer enabled; the
// enabled ones are re-added from linkSpecs below.
if (typeof spec === 'string' && spec.includes('.generations/live/')) continue
if (!enabled.has(name)) dependencies[name] = spec
const dependencies = { ...currentDeps }
for (const name of Object.keys(previousPlugins)) {
delete dependencies[name]
}
// Migrate the pre-version-projection shape even when it predates the marker.
for (const [name, spec] of Object.entries(dependencies)) {
if (typeof spec === 'string' && spec.includes('.generations/live/')) delete dependencies[name]
}
for (const [name, spec] of linkSpecs) {
dependencies[name] = spec

const currentOverrides = manifest.pnpm?.overrides ?? {}
const overrides = { ...currentOverrides }
for (const [name, state] of Object.entries(previousPlugins)) {
if (state?.previousOverride?.present && typeof state.previousOverride.value === 'string') {
overrides[name] = state.previousOverride.value
} else {
delete overrides[name]
}
}
// Clean up an old managed override even if the marker was lost.
for (const [name, spec] of Object.entries(overrides)) {
if (typeof spec === 'string' && spec.includes('.generations/live/')) delete overrides[name]
}

const projectedPlugins = {}
for (const [name, generation] of enabled) {
const previous = previousPlugins[name]
const currentOverride = currentOverrides[name]
const previousOverride = previous?.previousOverride ?? (
typeof currentOverride === 'string' && !currentOverride.includes('.generations/live/')
? { present: true, value: currentOverride }
: { present: false }
)
dependencies[name] = generation.version
overrides[name] = linkSpecs.get(name)
projectedPlugins[name] = {
generationId: generation.id,
visibleVersion: generation.version,
previousOverride
}
}

// Bundle entries that survive: in-box bundles, plus any kept dependency that
Expand All @@ -323,23 +359,44 @@ async function syncProfileManifest(dir, enabled, linkSpecs, manifestState) {
IN_BOX_BUNDLES.has(name)
)
for (const name of Object.keys(dependencies)) {
if (declaredBundles.includes(name) || linkSpecs.has(name)) continue
if (declaredBundles.includes(name) || enabled.has(name)) continue
if (await declaresBundle(join(dir, 'node_modules', name))) declaredBundles.push(name)
}
const pluginNames = [...enabled.keys()].sort()
const bundles = [...declaredBundles, ...pluginNames]

const desktop = {
...(manifest.dsh?.desktop ?? {})
}
if (Object.keys(projectedPlugins).length > 0) {
desktop.generationProjection = {
version: PROJECTION_VERSION,
plugins: projectedPlugins
}
} else {
delete desktop.generationProjection
}
const pnpm = {
...(manifest.pnpm ?? {})
}
if (Object.keys(overrides).length > 0) pnpm.overrides = overrides
else delete pnpm.overrides
const dsh = {
...manifest.dsh,
profile: {
...(manifest.dsh?.profile ?? {}),
bundles
}
}
if (Object.keys(desktop).length > 0) dsh.desktop = desktop
else delete dsh.desktop
const next = {
...manifest,
dependencies,
dsh: {
...manifest.dsh,
profile: {
...(manifest.dsh?.profile ?? {}),
bundles
}
}
dsh
}
if (Object.keys(pnpm).length > 0) next.pnpm = pnpm
else delete next.pnpm

const body = `${JSON.stringify(next, undefined, 2)}\n`
// Only touch the file when it actually changes. The projection runs every
Expand Down
52 changes: 51 additions & 1 deletion packages/dsh-desktop-market-installer/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { existsSync, readFileSync } from 'node:fs'
import { chmod, copyFile, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { homedir } from 'node:os'
Expand All @@ -11,6 +11,7 @@ import { PassThrough } from 'node:stream'
import { installGeneration } from './generations/installer.mjs'
import { projectGenerations } from './generations/projection.mjs'
import {
disableGeneration,
listGenerations,
readDesired,
withRegistryLock,
Expand Down Expand Up @@ -376,6 +377,34 @@ function validatePluginOperation(args, invokingDir) {
}
}

function removalTarget(args) {
if (args[0] !== 'remove') return undefined
return args.slice(1).find((argument) => !argument.startsWith('-'))
}

/**
* dsh-market routes ordinary removals through `runPlugin`. Generation plugins
* must instead update desired.json, otherwise the next projection resurrects
* the dependency the CLI just removed. The marker is written by the projector
* into an otherwise market-transparent manifest field.
*/
export function projectedGenerationRemoval(args, home = dshHome()) {
const target = removalTarget(args)
if (target === undefined) return undefined
try {
const manifest = JSON.parse(readFileSync(join(profileDirectory(home), 'package.json'), 'utf8'))
const plugins = manifest.dsh?.desktop?.generationProjection?.plugins
if (typeof plugins === 'object' && plugins !== null && Object.hasOwn(plugins, target)) {
return target
}
// Compatibility with profiles projected by an earlier Desktop build.
const spec = manifest.dependencies?.[target]
return typeof spec === 'string' && spec.includes('.generations/live/') ? target : undefined
} catch {
return undefined
}
}

export function createDesktopPnpmService(options) {
const {
binDirectory,
Expand Down Expand Up @@ -483,6 +512,27 @@ export function createDesktopPnpmService(options) {
if (signal?.aborted) throw signal.reason ?? new Error('The package operation was aborted.')
if (active) throw new Error('Another desktop pnpm operation is already running.')

const generationRemoval = projectedGenerationRemoval(args, home)
if (generationRemoval !== undefined) {
const handle = asHandle(async ({ write, isCancelled }) =>
withRegistryLock(home, async () => {
if (isCancelled()) return { exitCode: 1, message: 'The package operation was aborted.' }
write(`Disabling ${generationRemoval} generation…`)
await disableGeneration(home, generationRemoval)
const projection = await projectGenerations(home)
write(`enabled: ${projection.linked.join(', ')}`)
return { exitCode: 0 }
})
)
active = handle
signal?.addEventListener('abort', handle.cancel, { once: true })
void handle.done.finally(() => {
signal?.removeEventListener('abort', handle.cancel)
if (active === handle) active = undefined
})
return handle
}

void cleanStaleTemporaryDirectories(home).catch(() => undefined)

const child = spawnProcess(
Expand Down
8 changes: 8 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ let profileBootConfirmationTimer: NodeJS.Timeout | undefined
let profileRendererHealthAt = 0
let profileBootNavigationVersion = 0
let profileBootConfirmationComplete = false
let safeModeSuspectedPlugins: string[] = []
// A renderer that crashes (render-process-gone) and reloads that fails the
// same way produces a permanent black window the user has to close by hand.
// The cooldown keeps reloads from stacking up when the underlying crash
Expand Down Expand Up @@ -1187,6 +1188,7 @@ function launchHarness(): Promise<void> {
}
}
}
if (runtime.snapshot().phase === 'ready') safeModeSuspectedPlugins = []
})().finally(() => {
harnessLaunchOperation = undefined
})
Expand Down Expand Up @@ -1623,6 +1625,7 @@ async function showPluginRecovery(options?: {
shell.showItemInFolder(join(app.getPath('logs'), 'harness.log'))
continue
} else if (action === 'safe-mode') {
safeModeSuspectedPlugins = [...new Set(detection.plugins)]
takePendingFrontendPluginRecovery()
queueMicrotask(() => void showSafeMode().catch(showUnexpectedError))
return
Expand Down Expand Up @@ -1654,6 +1657,7 @@ async function showRuntimeFailure(snapshot: RuntimeSnapshot): Promise<void> {

async function waitForSafeModeAction(options: {
plugins: readonly string[]
suspectedPlugins: readonly string[]
issues: readonly ProfileCompatibilityIssue[]
backups: Awaited<ReturnType<typeof snapshotPluginRemovalLedger>>['backups']
recoveryLocked: boolean
Expand Down Expand Up @@ -1704,6 +1708,7 @@ async function waitForSafeModeAction(options: {
const model = buildSafeModeViewModel({
locale: harnessLocale(),
plugins: options.plugins,
suspectedPlugins: options.suspectedPlugins,
issues: options.issues,
backups: options.backups,
recoveryLocked: options.recoveryLocked,
Expand Down Expand Up @@ -1943,6 +1948,7 @@ async function showSafeModeManager(initial?: {
const backupRestoreLocked = recoveryLocked || !removalLedgerReadable
const action = await waitForSafeModeAction({
plugins: installed,
suspectedPlugins: safeModeSuspectedPlugins,
issues: compatibility.issues,
backups: removalBackups.backups,
recoveryLocked,
Expand Down Expand Up @@ -2134,6 +2140,8 @@ async function showSafeModeManager(initial?: {
if (!removal.disabled) failedPlugins.push(plugin)
else if (removal.pending) pendingPlugins.push(plugin)
}
const disabledPlugins = new Set(selectedPlugins.filter((plugin) => !failedPlugins.includes(plugin)))
safeModeSuspectedPlugins = safeModeSuspectedPlugins.filter((plugin) => !disabledPlugins.has(plugin))
const failed = repairFailures + failedPlugins.length
notice = pendingPlugins.length > 0
? isChinese
Expand Down
30 changes: 28 additions & 2 deletions src/main/runtime/harness-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -653,10 +653,15 @@ function extractPluginReferences(
accepts: (value: string) => boolean
): string[] {
const plugins = new Set<string>()
const attemptLogs = latestHarnessAttemptLogs(logLines)
const hasDuplicatePrefixRoute = attemptLogs.some((line) =>
line.startsWith('[stderr] ') && /duplicate prefix route ["'][^"']+["']/i.test(line)
)

for (const line of latestHarnessAttemptLogs(logLines)) {
for (const line of attemptLogs) {
if (!line.startsWith('[stderr] ')) continue
const text = line.slice(8)
const bootFailureLines = text.split(/\r?\n/).map((value) => value.trim())

// Loader failures are nested (for example the internal `cordis:include`
// entry wrapping a third-party bundle). Collect every entry in the chain;
Expand All @@ -682,7 +687,28 @@ function extractPluginReferences(
plugins.add(m5[1].trim())
}

const bootFailureLines = text.split(/\r?\n/).map((value) => value.trim())
for (const candidate of bootFailureLines) {
const pendingEntry = candidate.match(
/^((?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*):\s*pending\s*\(waiting for service:\s*[^)]+\)\s*$/i
)
if (pendingEntry?.[1] && accepts(pendingEntry[1])) {
plugins.add(pendingEntry[1].trim())
}
}

// Some Harness errors do not include the loader wrapper that normally
// names the bundle. For duplicate routes, the first profile stack frame is
// still direct ownership evidence. Restrict stack extraction to that
// failure class so unrelated warnings cannot turn into removal suspects.
if (hasDuplicatePrefixRoute) {
for (const match of text.matchAll(
/[\\/]profiles[\\/][^\\/\s]+[\\/]node_modules[\\/]((?:@[^\\/\s]+[\\/])?[^\\/\s)]+)/gi
)) {
const candidate = match[1]?.replace(/\\/g, '/')
if (candidate && accepts(candidate)) plugins.add(candidate.trim())
}
}

const bootFailureTitle = bootFailureLines.findIndex((value) => value === 'Failed to load plugins')
if (bootFailureTitle >= 0) {
for (const candidate of bootFailureLines.slice(bootFailureTitle + 1)) {
Expand Down
Loading
Loading