Skip to content
118 changes: 115 additions & 3 deletions build/plugin-recovery.html
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,56 @@
.button.primary:hover { border-color: var(--button-hover); background: var(--button-hover); }
.button[disabled] { opacity: 0.64; cursor: wait; transform: none; }

[hidden] { display: none !important; }

.plugin-upgrade-btn {
margin-left: auto;
padding: 4px 10px;
border: 1px solid rgba(22, 163, 74, 0.28);
border-radius: 8px;
color: #15803d;
background: rgba(22, 163, 74, 0.08);
cursor: pointer;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 12px;
font-weight: 550;
line-height: 1.4;
white-space: nowrap;
transition: background 120ms ease, border-color 120ms ease, transform 120ms ease;
}
.plugin-upgrade-btn:hover {
background: rgba(22, 163, 74, 0.16);
border-color: rgba(22, 163, 74, 0.4);
}
.plugin-upgrade-btn:active {
transform: translateY(1px);
}
.plugin-upgrade-btn:disabled {
opacity: 0.6;
cursor: wait;
transform: none;
}
:root[data-theme="dark"] .plugin-upgrade-btn {
color: #4ade80;
background: rgba(74, 222, 128, 0.12);
border-color: rgba(74, 222, 128, 0.25);
}
:root[data-theme="dark"] .plugin-upgrade-btn:hover {
background: rgba(74, 222, 128, 0.2);
border-color: rgba(74, 222, 128, 0.45);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .plugin-upgrade-btn {
color: #4ade80;
background: rgba(74, 222, 128, 0.12);
border-color: rgba(74, 222, 128, 0.25);
}
:root:not([data-theme="light"]) .plugin-upgrade-btn:hover {
background: rgba(74, 222, 128, 0.2);
border-color: rgba(74, 222, 128, 0.45);
}
}

.advanced {
margin-top: 24px;
border-top: 1px solid var(--line);
Expand Down Expand Up @@ -365,6 +415,8 @@ <h1 id="heading"></h1>

<div class="decision-row">
<div class="actions">
<button class="button" id="safe-mode" type="button"></button>
<button class="button" id="secondary" type="button" hidden></button>
<button class="button primary" id="primary" type="button"></button>
</div>
<p class="safety">
Expand Down Expand Up @@ -412,14 +464,25 @@ <h1 id="heading"></h1>
setText('reason-title', model.reasonTitle)
setText('reason-detail', model.reasonDetail)
setText('safety-note', model.safetyNote)
setText('primary', model.primaryLabel)
setText('advanced-label', model.advancedLabel)
setText('launch-directory-label', model.launchDirectoryLabel)
setText('launch-directory', model.launchDirectory || '—')
setText('error-label', model.errorLabel)
setText('technical', model.rawError)
setText('show-log', model.logLabel)
setText('quit', model.quitLabel)
const safeModeBtn = document.getElementById('safe-mode')
if (safeModeBtn) {
safeModeBtn.textContent = model.safeModeLabel || (model.locale === 'zh' ? '进入安全模式' : 'Enter Safe Mode')
safeModeBtn.addEventListener('click', () => {
safeModeBtn.disabled = true
primary.disabled = true
if (secondary) secondary.disabled = true
document.querySelectorAll('.plugin-upgrade-btn').forEach((btn) => { btn.disabled = true })
safeModeBtn.textContent = model.locale === 'zh' ? '正在进入安全模式…' : 'Entering Safe Mode…'
navigate('safe-mode')
})
}

const progress = document.getElementById('progress')
if (model.progress) {
Expand All @@ -445,6 +508,29 @@ <h1 id="heading"></h1>
name.className = 'plugin-name'
name.textContent = String(plugin)
item.append(mark, name)

const candidate = model.upgradeCandidate
const candidateName = candidate
? (candidate.packageName.startsWith('@')
? candidate.packageName.slice(candidate.packageName.indexOf('/') + 1)
: candidate.packageName)
: undefined

if (candidate && (candidateName === plugin || model.plugins.length === 1)) {
const upgradeBtn = document.createElement('button')
upgradeBtn.type = 'button'
upgradeBtn.className = 'plugin-upgrade-btn plugin-upgrade'
const versionStr = candidate.targetVersion.startsWith('v')
? candidate.targetVersion
: `v${candidate.targetVersion}`
upgradeBtn.textContent = model.upgradeHint || (model.locale === 'zh' ? '该插件有新的兼容版本' : 'A compatible update is available')
upgradeBtn.title = model.locale === 'zh' ? `可升级至 ${versionStr}` : `Can upgrade to ${versionStr}`
upgradeBtn.addEventListener('click', () => {
primary.click()
})
item.appendChild(upgradeBtn)
}

pluginList.appendChild(item)
}
}
Expand All @@ -457,10 +543,36 @@ <h1 id="heading"></h1>
}
}
const primary = document.getElementById('primary')
const secondary = document.getElementById('secondary')

if (model.upgradeCandidate) {
setText('primary', model.upgradeLabel)
setText('secondary', model.uninstallLabel || (model.locale === 'zh' ? '仍要卸载此插件' : 'Uninstall plugin instead'))
secondary.hidden = false
secondary.addEventListener('click', () => {
secondary.disabled = true
primary.disabled = true
if (safeModeBtn) safeModeBtn.disabled = true
document.querySelectorAll('.plugin-upgrade-btn').forEach((btn) => { btn.disabled = true })
secondary.textContent = model.locale === 'zh' ? '正在卸载…' : 'Removing…'
navigate('uninstall')
})
} else {
setText('primary', model.primaryLabel)
}

primary.addEventListener('click', () => {
primary.disabled = true
primary.textContent = model.primaryBusyLabel
navigate(model.canUninstall ? 'uninstall' : 'safe-mode')
if (secondary) secondary.disabled = true
if (safeModeBtn) safeModeBtn.disabled = true
document.querySelectorAll('.plugin-upgrade-btn').forEach((btn) => { btn.disabled = true })
if (model.upgradeCandidate) {
primary.textContent = model.upgradeBusyLabel || (model.locale === 'zh' ? '正在升级…' : 'Upgrading…')
navigate('upgrade')
} else {
primary.textContent = model.primaryBusyLabel
navigate(model.canUninstall ? 'uninstall' : 'safe-mode')
}
})
document.getElementById('show-log').addEventListener('click', () => navigate('show-log'))
document.getElementById('quit').addEventListener('click', () => navigate('quit'))
Expand Down
61 changes: 58 additions & 3 deletions build/safe-mode.html
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,17 @@
.button:hover { background: var(--soft); }
.button.primary { border-color: var(--button); color: var(--button-ink); background: var(--button); }
.button.danger { color:var(--danger); border-color:rgba(195,59,56,.35); }
.button.success { color:var(--success); border-color:rgba(38,122,74,.35); }
.button.success:hover { background:rgba(38,122,74,.08); }
.button[disabled] { opacity: .55; cursor: default; }
.plugin-item { display:flex; align-items:center; justify-content:space-between; border-top:1px solid var(--line); background:var(--surface); }
.plugin-item:first-child { border-top:0; }
.plugin-item .plugin { flex:1 1 auto; min-width:0; }
.plugin-upgrade-btn { margin-right:14px; min-height:28px; padding:4px 10px; border:1px solid var(--success); border-radius:8px; color:var(--success); background:transparent; cursor:pointer; font-size:11px; font-weight:650; white-space:nowrap; flex:0 0 auto; }
.plugin-upgrade-btn:hover { background:rgba(38,122,74,.09); }
.plugin-upgrade-btn[disabled] { opacity:.55; cursor:wait; }
.plugin-status.success { color:var(--success); }
.plugin-version { margin-left:4px; color:var(--muted); font-size:11px; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
.footer { display: flex; justify-content: flex-end; padding: 0 32px 18px; }
.quit { border: 0; color: var(--muted); background: transparent; cursor: pointer; font-size: 12px; font-weight: 600; }
:root[data-theme="dark"] { --canvas:#141416; --surface:#202023; --soft:#29292d; --ink:#f2f2f4; --muted:#a5a7ad; --line:#3a3a3f; --strong:#4a4a50; --button:#f4f4f5; --button-ink:#111217; --danger:#ee7772; --success:#78d59a; }
Expand All @@ -101,6 +111,7 @@ <h1 id="heading"></h1>
<p class="safety" id="safety-note"></p>
<div class="actions">
<button class="button" id="recovery-open" type="button" hidden></button>
<button class="button success" id="upgrade-all" type="button" hidden></button>
<button class="button" id="apply" type="button" disabled></button>
<button class="button primary" id="restart" type="button"></button>
</div>
Expand Down Expand Up @@ -136,6 +147,7 @@ <h1 id="heading"></h1>
const list = document.getElementById('items')
const empty = document.getElementById('empty')
const apply = document.getElementById('apply')
const upgradeAll = document.getElementById('upgrade-all')
const recoveryOpen = document.getElementById('recovery-open')
const agent = document.getElementById('agent')
const restart = document.getElementById('restart')
Expand All @@ -145,6 +157,10 @@ <h1 id="heading"></h1>
apply.hidden = true
recoveryOpen.hidden = false
}
if (model.upgradeAllLabel && model.upgradeReadyCount > 0 && model.recoveryLocked !== true) {
upgradeAll.hidden = false
upgradeAll.textContent = model.upgradeAllLabel
}
const pluginCheckboxes = []
const issueCheckboxes = []
for (const group of Array.isArray(model.issueGroups) ? model.issueGroups : []) {
Expand Down Expand Up @@ -175,20 +191,48 @@ <h1 id="heading"></h1>
: (Array.isArray(model.plugins) ? model.plugins : []).map((name) => ({ name }))
for (const plugin of pluginItems) {
const item = document.createElement('li')
item.className = 'plugin-item'
const label = document.createElement('label'); label.className = 'plugin'
const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.value = String(plugin.name)
const copy = document.createElement('span'); copy.className = 'plugin-copy'
const title = document.createElement('span'); title.className = 'plugin-title'
const name = document.createElement('span'); name.className = 'plugin-name'; name.textContent = String(plugin.name)
title.appendChild(name)
if (plugin.installedVersion) {
const ver = document.createElement('span')
ver.className = 'plugin-version'
ver.textContent = `v${plugin.installedVersion}`
title.appendChild(ver)
}
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')
else if (plugin.statusTone === 'success') status.classList.add('success')
title.appendChild(status)
}
const action = document.createElement('span'); action.className = 'plugin-action'; action.textContent = String(plugin.actionLabel || '')
copy.append(title, action)
label.append(checkbox, copy); item.appendChild(label); list.appendChild(item); pluginCheckboxes.push(checkbox)
label.append(checkbox, copy); item.appendChild(label)

if (plugin.upgradeButtonLabel) {
const upgradeBtn = document.createElement('button')
upgradeBtn.type = 'button'
upgradeBtn.className = 'plugin-upgrade-btn'
upgradeBtn.textContent = String(plugin.upgradeButtonLabel)
upgradeBtn.addEventListener('click', (e) => {
e.stopPropagation()
upgradeBtn.disabled = true
upgradeBtn.textContent = model.locale === 'zh' ? '正在升级…' : 'Upgrading…'
apply.disabled = true
if (upgradeAll) upgradeAll.disabled = true
restart.disabled = true
agent.disabled = true
void window.dshSafeMode.action('upgrade', { plugins: [plugin.name] })
})
item.appendChild(upgradeBtn)
}

list.appendChild(item); pluginCheckboxes.push(checkbox)
}
const allCheckboxes = [...issueCheckboxes, ...pluginCheckboxes]
if (allCheckboxes.length === 0) empty.classList.add('visible')
Expand All @@ -202,13 +246,24 @@ <h1 id="heading"></h1>
})
if (plugins.length === 0 && issues.length === 0) return
apply.disabled = true; apply.textContent = model.applyBusyLabel; agent.disabled = true; restart.disabled = true
if (upgradeAll) upgradeAll.disabled = true
void window.dshSafeMode.action('apply', { plugins, issues })
})
upgradeAll.addEventListener('click', () => {
const targets = pluginItems.filter((p) => p.upgradeReady).map((p) => p.name)
if (targets.length === 0) return
upgradeAll.disabled = true
upgradeAll.textContent = model.upgradeAllBusyLabel || (model.locale === 'zh' ? '正在升级…' : 'Upgrading…')
apply.disabled = true
restart.disabled = true
agent.disabled = true
void window.dshSafeMode.action('upgrade', { plugins: targets })
})
recoveryOpen.addEventListener('click', () => { recoveryOpen.disabled = true; void window.dshSafeMode.action('recovery-open', {}) })
agent.addEventListener('click', () => { agent.disabled = true; agent.textContent = model.agentBusyLabel; apply.disabled = true; restart.disabled = true; void window.dshSafeMode.action('agent', {}) })
agent.addEventListener('click', () => { agent.disabled = true; agent.textContent = model.agentBusyLabel; apply.disabled = true; restart.disabled = true; if (upgradeAll) upgradeAll.disabled = true; void window.dshSafeMode.action('agent', {}) })
restart.addEventListener('click', () => {
if (model.restartConfirm && !window.confirm(String(model.restartConfirm))) return
restart.disabled = true; restart.textContent = model.restartBusyLabel; apply.disabled = true; agent.disabled = true; void window.dshSafeMode.action('restart', {})
restart.disabled = true; restart.textContent = model.restartBusyLabel; apply.disabled = true; agent.disabled = true; if (upgradeAll) upgradeAll.disabled = true; void window.dshSafeMode.action('restart', {})
})
document.getElementById('quit').addEventListener('click', () => void window.dshSafeMode.action('quit', {}))
</script>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface PublishedGenerationManifest {
export function projectGenerations(dshHome: string, profile?: string): Promise<ProjectionResult>
export function publishGenerationManifest(
dshHome: string,
profile?: string
profile?: string,
options?: { syncBundles?: boolean }
): Promise<PublishedGenerationManifest>
export function exposeMissingGenerationLinks(dshHome: string, profile?: string): Promise<string[]>
Original file line number Diff line number Diff line change
Expand Up @@ -245,14 +245,15 @@ export async function projectGenerations(dshHome, profile = 'web') {
* Publish the desired generation set for inventory and the next launch without
* touching the active Profile's node_modules. Market operations run inside the
* live Harness, so replacing even a junction there recreates the Windows
* rename conflict generations are meant to avoid. The cold-start projector
* materializes these links after Harness has stopped.
* rename conflict generations are meant to avoid. A removal may still update
* the manifest's bundle list: that only changes what the *next* Harness boot
* composes, while leaving the current process and its links intact.
*/
export async function publishGenerationManifest(dshHome, profile = 'web') {
export async function publishGenerationManifest(dshHome, profile = 'web', { syncBundles = false } = {}) {
const { dir, manifestState, enabled, linkSpecs } =
await prepareGenerationProjection(dshHome, profile)
const bundles = await syncProfileManifest(dir, enabled, linkSpecs, manifestState, {
syncBundles: false
syncBundles
})
return { plugins: [...enabled.keys()], bundles }
}
Expand Down
8 changes: 7 additions & 1 deletion packages/dsh-desktop-market-installer/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -541,7 +541,13 @@ export function createDesktopPnpmService(options) {
if (isCancelled()) return { exitCode: 1, message: 'The package operation was aborted.' }
write(`Disabling ${generationRemoval} generation for the next restart…`)
await disableGeneration(home, generationRemoval)
const published = await publishGenerationManifest(home)
// Do not replace the active link while Harness is running, but do
// remove this bundle from the next boot's composition now. Waiting
// for startup projection leaves an uninstalled plugin live forever
// when an unrelated migration preflight is deferred.
const published = await publishGenerationManifest(home, MARKET_PROFILE, {
syncBundles: true
})
write(`staged for next restart: ${published.plugins.join(', ')}`)
return { exitCode: 0 }
})
Expand Down
Loading
Loading