feat(desktop): manage the installed dsh command from native menus

This commit is contained in:
Tianyi Cui committed 2026-09-29 12:54:53 +08:00
1 parent ae4f3546ae
commit 9c8f5c2bf3
21 files changed
+1263 -6

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
; Native command admission also covers direct installer and uninstaller launches.
Var InstallerCliToken
Var InstallerCliApplication
!ifdef BUILD_UNINSTALLER
!define DSH_CLI_PREFIX "un."
!else
!define DSH_CLI_PREFIX ""
!endif
Function ${DSH_CLI_PREFIX}PrepareCliUpdate
StrCpy $InstallerCliApplication $INSTDIR
${IfNot} ${FileExists} "$INSTDIR\resources\runtime\cli\bin\dsh.exe"
Return
${EndIf}
InitPluginsDir
File "/oname=$PLUGINSDIR\dsh-command-update.ps1" "${INSTALLER_SOURCE_DIR}\..\scripts\command-update.ps1"
System::Call 'ole32::CoCreateGuid(g .r0) i .r1'
${If} $1 != 0
SetErrorLevel 2
Quit
${EndIf}
StrCpy $InstallerCliToken $0 36 1
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\dsh-command-update.ps1" -Operation prepare -Application "$INSTDIR" -Token "$InstallerCliToken" -Version "${VERSION}"'
Pop $0
Pop $1
${If} $0 != 0
MessageBox MB_OK|MB_ICONEXCLAMATION "$(INSTALLER_CLI_BUSY)" /SD IDOK
SetErrorLevel 2
Quit
${EndIf}
FunctionEnd
!ifdef BUILD_UNINSTALLER
Function un.onGUIEnd
!else
Function .onGUIEnd
!endif
${If} $InstallerCliToken != ""
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\dsh-command-update.ps1" -Operation finish -Application "$InstallerCliApplication" -Token "$InstallerCliToken"'
Pop $0
Pop $1
${EndIf}
FunctionEnd
+1
View File
@@ -51,6 +51,7 @@ Function InstallerBeforeInstall
Quit
${EndIf}
Call InstallerCheckAppRunning
Call PrepareCliUpdate
FunctionEnd
Function InstallerProgressShow
+19 -4
View File
@@ -17,6 +17,8 @@ Var InstallerEdit
Var InstallerEditFrame
Var InstallerBrowse
Var InstallerLaunch
Var InstallerCommand
Var InstallerCommandSelected
Var InstallerExpanded
!include "${__FILEDIR__}\path.nsh"
!include "${__FILEDIR__}\drawing.nsh"
@@ -90,12 +92,12 @@ Function InstallerCreate
${NSD_CreateButton} 0 0 0 0 "$(INSTALLER_CHOOSE_PATH)"
Pop $InstallerChoose
!insertmacro InstallerPlace $InstallerChoose 232 438 136 28
!insertmacro InstallerPlace $InstallerChoose 232 420 136 28
${NSD_OnClick} $InstallerChoose InstallerExpandPath
${NSD_OnNotify} $InstallerChoose InstallerPaintButton
${NSD_CreateBitmap} 0 0 0 0 ""
Pop $InstallerEditFrame
!insertmacro InstallerPlace $InstallerEditFrame 64 434 384 34
!insertmacro InstallerPlace $InstallerEditFrame 64 416 384 34
Call InstallerDrawEditFrame
${NSD_CreateText} 0 0 0 0 "$InstallerPath"
Pop $InstallerEdit
@@ -117,7 +119,7 @@ Function InstallerCreate
System::Call 'gdi32::SelectObject(p r4, p r5)'
System::Call 'user32::ReleaseDC(p $InstallerEdit, p r4)'
System::Call 'kernel32::MulDiv(i 76, i $InstallerDpi, i 96) i.r0'
System::Call 'kernel32::MulDiv(i 434, i $InstallerDpi, i 96) i.r1'
System::Call 'kernel32::MulDiv(i 416, i $InstallerDpi, i 96) i.r1'
System::Call 'kernel32::MulDiv(i 360, i $InstallerDpi, i 96) i.r2'
System::Call 'kernel32::MulDiv(i 34, i $InstallerDpi, i 96) i.r3'
IntOp $3 $3 - $7
@@ -130,7 +132,7 @@ Function InstallerCreate
${NSD_OnChange} $InstallerEdit InstallerPathChanged
${NSD_CreateButton} 0 0 0 0 "$(INSTALLER_BROWSE)"
Pop $InstallerBrowse
!insertmacro InstallerPlace $InstallerBrowse 456 434 80 34
!insertmacro InstallerPlace $InstallerBrowse 456 416 80 34
${NSD_OnClick} $InstallerBrowse InstallerBrowsePath
${NSD_OnNotify} $InstallerBrowse InstallerPaintButton
${NSD_CreateCheckbox} 0 0 0 0 "$(INSTALLER_LAUNCH)"
@@ -157,6 +159,16 @@ Function InstallerCreate
${NSD_OnNotify} $InstallerLaunch InstallerPaintCheckbox
${NSD_Check} $InstallerLaunch
${NSD_CreateCheckbox} 0 0 0 0 "$(INSTALLER_DSH_COMMAND)"
Pop $InstallerCommand
!insertmacro InstallerPlace $InstallerCommand 185 454 250 32
SendMessage $InstallerCommand ${WM_SETFONT} $InstallerSmallFont 1
!insertmacro InstallerControlColors $InstallerCommand
${NSD_OnNotify} $InstallerCommand InstallerPaintCheckbox
${If} $InstallerCommandSelected == 1
${NSD_Check} $InstallerCommand
${EndIf}
${NSD_CreateButton} 0 0 0 0 "$(INSTALLER_INSTALL)"
Pop $InstallerButton
!insertmacro InstallerPlace $InstallerButton ${INSTALLER_BUTTON_X} ${INSTALLER_BUTTON_Y} ${INSTALLER_BUTTON_WIDTH} ${INSTALLER_BUTTON_HEIGHT}
@@ -185,12 +197,14 @@ Function InstallerRender
ShowWindow $InstallerEditFrame 0
ShowWindow $InstallerBrowse 0
ShowWindow $InstallerLaunch 0
ShowWindow $InstallerCommand 0
ShowWindow $InstallerStatus 0
${If} $InstallerPhase == "success"
${NSD_SetText} $InstallerButton "$(INSTALLER_FINISH)"
ShowWindow $InstallerLaunch 5
${Else}
${NSD_SetText} $InstallerButton "$(INSTALLER_INSTALL)"
ShowWindow $InstallerCommand 5
${If} $InstallerExpanded == 1
ShowWindow $InstallerEditFrame 5
ShowWindow $InstallerEdit 5
@@ -208,6 +222,7 @@ FunctionEnd
; Page leave callbacks also run when Enter activates NSIS's hidden default button.
Function InstallerWelcomeLeave
${NSD_GetState} $InstallerCommand $InstallerCommandSelected
${NSD_GetText} $InstallerEdit $InstallerPath
Call InstallerPreflight
${If} $InstallerError != ""
+6
View File
@@ -55,3 +55,9 @@ LangString INSTALLER_EXTRACT_UNSAVED ${LANG_ENGLISH} "The full report could not
LangString INSTALLER_EXTRACT_UNSAVED ${LANG_SIMPCHINESE} "无法保存完整错误报告,请使用“复制错误信息”保留。"
LangString INSTALLER_EXTRACT_COPIED ${LANG_ENGLISH} "Error details were copied to the clipboard."
LangString INSTALLER_EXTRACT_COPIED ${LANG_SIMPCHINESE} "错误信息已复制到剪贴板。"
LangString INSTALLER_DSH_COMMAND ${LANG_ENGLISH} "Add dsh command to PATH"
LangString INSTALLER_DSH_COMMAND ${LANG_SIMPCHINESE} "将 dsh 命令添加到 PATH"
LangString INSTALLER_DSH_COMMAND_FAILED ${LANG_ENGLISH} "Desktop was installed, but the dsh command could not be added. Open Manage dsh Command in the application to retry."
LangString INSTALLER_DSH_COMMAND_FAILED ${LANG_SIMPCHINESE} "Desktop 已安装,但无法添加 dsh 命令。请在应用中打开“管理 dsh 命令”重试。"
LangString INSTALLER_CLI_BUSY ${LANG_ENGLISH} "Finish running dsh commands, then retry installation. If this message remains, reopen Desktop and complete its pending update."
LangString INSTALLER_CLI_BUSY ${LANG_SIMPCHINESE} "请结束运行中的 dsh 命令后重试安装。如果仍出现此提示,请重新打开 Desktop 完成待安装的更新。"
@@ -0,0 +1,40 @@
<# Installer/uninstaller adapter for the same per-user PATH transaction used by Desktop. #>
param(
[ValidateSet('install','remove')][string]$Operation,
[Parameter(Mandatory)][string]$Directory,
[string]$Language = '1033',
[switch]$Silent
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'command-path.ps1')
try {
$locales = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'command-messages.json') -Raw -Encoding UTF8 | ConvertFrom-Json
$messages = if ($Language -eq '2052') { $locales.zh } else { $locales.en }
$machine = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SYSTEM\CurrentControlSet\Control\Session Manager\Environment')
try { $machinePath = [string]$machine.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) }
finally { $machine.Dispose() }
$sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
$options = @{ EnvironmentKey='Environment'; OwnerKey='Software\DeepSeekHarness\Command'; MachinePath=$machinePath; MutexName=('Global\DeepSeekHarness.Command.' + $sid) }
$state = Invoke-DshCommandPath @options -Request @{ operation='inspect'; directory=$Directory }
$previousDesktop = $state.ownedDirectory -and $state.activeCommand -and
[string]::Equals($state.activeCommand, (Join-Path $state.ownedDirectory 'dsh.exe'), [StringComparison]::OrdinalIgnoreCase)
if ($Operation -eq 'install' -and $state.activeCommand -and -not $previousDesktop) {
if ($Silent) { exit 2 }
Add-Type -AssemblyName System.Windows.Forms
$text = $messages.cliCommandSwitch + [Environment]::NewLine + [Environment]::NewLine +
$messages.cliCommandSelected.Replace('{path}', $state.activeCommand) + [Environment]::NewLine + [Environment]::NewLine +
$messages.cliCommandPreserve
$choice = [Windows.Forms.MessageBox]::Show($text, $messages.cliCommandTitle, [Windows.Forms.MessageBoxButtons]::YesNo,
[Windows.Forms.MessageBoxIcon]::Question, [Windows.Forms.MessageBoxDefaultButton]::Button2)
if ($choice -ne [Windows.Forms.DialogResult]::Yes) { exit 0 }
}
$result = Invoke-DshCommandPath @options -Request @{ operation=$Operation; directory=$Directory; expected=$state.fingerprint }
Send-DshCommandEnvironmentChange
if (-not $Silent -and $Operation -eq 'install' -and $result.activeCommand -and
-not [string]::Equals($result.activeCommand, (Join-Path $Directory 'dsh.exe'), [StringComparison]::OrdinalIgnoreCase)) {
Add-Type -AssemblyName System.Windows.Forms
[void][Windows.Forms.MessageBox]::Show($messages.cliCommandShadowed, $messages.cliCommandTitle)
}
} catch {
exit 1
}
+124
View File
@@ -0,0 +1,124 @@
<# Per-user PATH ownership. The worker owns its mutex through registry publication. #>
function Invoke-DshCommandPath {
param([hashtable]$Request, [string]$EnvironmentKey, [string]$OwnerKey, [string]$MachinePath, [string]$MutexName)
$ErrorActionPreference = 'Stop'
function Fail([string]$Code, [string]$Message) {
$error = [InvalidOperationException]::new($Message)
$error.Data['code'] = $Code
throw $error
}
function Comparable([string]$Path) {
$expanded = [Environment]::ExpandEnvironmentVariables($Path.Trim().Trim('"'))
if (-not [IO.Path]::IsPathRooted($expanded)) { return $expanded }
return [IO.Path]::GetFullPath($expanded).TrimEnd('\','/').ToUpperInvariant()
}
$directory = [string]$Request.directory
if (-not [IO.Path]::IsPathRooted($directory) -or $directory.Contains(';') -or $directory.Contains([char]0)) {
Fail 'EUNSUPPORTED' 'The command directory cannot be represented in PATH.'
}
$directory = [IO.Path]::GetFullPath($directory)
if ($Request.operation -notin @('inspect','install','remove')) { Fail 'EINVAL' 'Unknown command operation.' }
$mutex = [Threading.Mutex]::new($false, $MutexName)
$locked = $false
try {
try { $locked = $mutex.WaitOne(5000) } catch [Threading.AbandonedMutexException] { $locked = $true }
if (-not $locked) { Fail 'EBUSY' 'Another command-management operation is running.' }
$environment = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($EnvironmentKey)
$owner = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($OwnerKey)
try {
$raw = if ($environment) { $environment.GetValue('Path', $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) } else { $null }
if ($null -ne $raw -and $raw -isnot [string]) { Fail 'EUNSUPPORTED' 'The user PATH value is not a string.' }
$kind = if ($null -ne $raw) { $environment.GetValueKind('Path') } else { [Microsoft.Win32.RegistryValueKind]::ExpandString }
$owned = if ($owner) { $owner.GetValue('Directory', $null) } else { $null }
$wasAbsent = if ($owner) { $owner.GetValue('PathWasAbsent', 0) -eq 1 } else { $false }
} finally {
if ($environment) { $environment.Dispose() }
if ($owner) { $owner.Dispose() }
}
$snapshot = [ordered]@{ path=$raw; kind=[string]$kind; owned=$owned; absent=$wasAbsent; directory=$directory; machine=$MachinePath }
$sha = [Security.Cryptography.SHA256]::Create()
try { $fingerprint = ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes(($snapshot | ConvertTo-Json -Compress))))).Replace('-','').ToLowerInvariant() }
finally { $sha.Dispose() }
if ($Request.operation -ne 'inspect' -and $Request.expected -ne $fingerprint) { Fail 'ESTALE' 'PATH changed after confirmation.' }
$parts = if ($null -eq $raw) { @() } else { @($raw.Split(';')) }
$current = Comparable $directory
$old = if ($owned) { Comparable ([string]$owned) } else { $null }
$kept = @($parts | Where-Object { (Comparable $_) -ne $current -and ($Request.operation -ne 'install' -or -not $old -or (Comparable $_) -ne $old) })
if ($Request.operation -eq 'install') {
if (-not (Test-Path -LiteralPath (Join-Path $directory 'dsh.exe') -PathType Leaf)) { Fail 'ENOENT' 'The installed launcher is unavailable.' }
$next = (@($directory) + $kept) -join ';'
$environment = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey($EnvironmentKey)
$owner = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey($OwnerKey)
try {
$environment.SetValue('Path', $next, $kind)
$owner.SetValue('Directory', $directory, [Microsoft.Win32.RegistryValueKind]::String)
$absent = ($null -eq $raw) -or ($owned -and $wasAbsent)
$owner.SetValue('PathWasAbsent', [int]$absent, [Microsoft.Win32.RegistryValueKind]::DWord)
} finally { $environment.Dispose(); $owner.Dispose() }
$raw = $next
$owned = $directory
} elseif ($Request.operation -eq 'remove') {
if ($kept.Count -ne $parts.Count) {
$environment = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey($EnvironmentKey)
try {
if ($kept.Count -eq 0 -and $wasAbsent) { $environment.DeleteValue('Path', $false); $raw = $null }
else { $raw = $kept -join ';'; $environment.SetValue('Path', $raw, $kind) }
} finally { $environment.Dispose() }
}
$owner = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($OwnerKey, $true)
if ($owner -and $old -eq $current) {
try { $owner.DeleteValue('Directory', $false); $owner.DeleteValue('PathWasAbsent', $false) }
finally { $owner.Dispose() }
$owned = $null
} elseif ($owner) {
$owner.Dispose()
}
}
$active = $null
foreach ($part in (($MachinePath + ';' + [string]$raw).Split(';'))) {
$path = [Environment]::ExpandEnvironmentVariables($part.Trim().Trim('"'))
if (-not [IO.Path]::IsPathRooted($path)) { continue }
foreach ($extension in @('.com','.exe','.bat','.cmd')) {
$candidate = Join-Path $path ('dsh' + $extension)
if (Test-Path -LiteralPath $candidate -PathType Leaf) { $active = $candidate; break }
}
if ($active) { break }
}
$onPath = @(([string]$raw).Split(';') | Where-Object { (Comparable $_) -eq $current }).Count -gt 0
return [ordered]@{ fingerprint=$fingerprint; directory=$directory; ownedDirectory=$owned; managed=([bool]$owned -and (Comparable ([string]$owned)) -eq $current); activeCommand=$active; available=($onPath -and (Test-Path -LiteralPath (Join-Path $directory 'dsh.exe') -PathType Leaf)) }
} finally {
if ($locked) { $mutex.ReleaseMutex() }
$mutex.Dispose()
}
}
function Send-DshCommandEnvironmentChange {
if (-not ('DshCommandEnvironment' -as [type])) {
Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class DshCommandEnvironment { [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr SendMessageTimeout(IntPtr h, uint m, IntPtr w, string l, uint f, uint t, out IntPtr r); }'
}
$ignored = [IntPtr]::Zero
[void][DshCommandEnvironment]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [IntPtr]::Zero, 'Environment', 2, 2000, [ref]$ignored)
}
if ($MyInvocation.InvocationName -ne '.') {
$ErrorActionPreference = 'Stop'
[Console]::InputEncoding = [Text.UTF8Encoding]::new($false)
[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
try {
$inputValue = [Console]::In.ReadToEnd() | ConvertFrom-Json
$request = @{ operation=$inputValue.operation; directory=$inputValue.directory; expected=$inputValue.expected }
$machine = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SYSTEM\CurrentControlSet\Control\Session Manager\Environment')
try { $machinePath = [string]$machine.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) }
finally { $machine.Dispose() }
$sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
$state = Invoke-DshCommandPath -Request $request -EnvironmentKey 'Environment' -OwnerKey 'Software\DeepSeekHarness\Command' -MachinePath $machinePath -MutexName ('Global\DeepSeekHarness.Command.' + $sid)
if ($request.operation -ne 'inspect') {
Send-DshCommandEnvironmentChange
}
@{ ok=$true; state=$state } | ConvertTo-Json -Compress -Depth 5
} catch {
$code = if ($_.Exception.Data['code']) { $_.Exception.Data['code'] } else { 'EIO' }
@{ ok=$false; code=$code; message=$_.Exception.Message } | ConvertTo-Json -Compress
exit 1
}
}
+54
View File
@@ -0,0 +1,54 @@
<# Native installer handoff; only the matching transaction can be cancelled or removed. #>
param(
[ValidateSet('prepare','finish')][string]$Operation,
[Parameter(Mandatory)][string]$Application,
[Parameter(Mandatory)][string]$Token,
[string]$Version = 'uninstall'
)
$ErrorActionPreference = 'Stop'
if ($Token -notmatch '^[A-Za-z0-9.+-]{1,128}$' -or $Version -notmatch '^[A-Za-z0-9.+-]{1,128}$') { exit 1 }
$control = Join-Path $Application 'resources\runtime\cli\cli-control.exe'
$executable = Join-Path $Application 'DeepSeek Harness.exe'
$process = $null
try {
if ($Operation -eq 'finish' -and -not (Test-Path -LiteralPath $executable)) {
$directory = [IO.DirectoryInfo]::new($Application)
$marker = Join-Path $directory.Parent.FullName ('.' + $directory.Name + '.dsh-cli-update')
if (Test-Path -LiteralPath $marker) {
$item = Get-Item -LiteralPath $marker -Force
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or $item.Length -gt 4096) { exit 1 }
$lines = [IO.File]::ReadAllLines($marker)
if ($lines.Length -eq 4 -and $lines[0] -eq 'DSH_CLI_UPDATE_1' -and $lines[2] -eq $Token) { [IO.File]::Delete($marker) }
}
exit 0
}
$start = [Diagnostics.ProcessStartInfo]::new()
$start.FileName = $control
$start.Arguments = if ($Operation -eq 'prepare') { 'hold ' + $Token + ' ' + $Version } else { 'cancel ' + $Token }
$start.UseShellExecute = $false
$start.CreateNoWindow = $true
$start.RedirectStandardInput = $true
$start.RedirectStandardOutput = $true
$start.RedirectStandardError = $true
$process = [Diagnostics.Process]::Start($start)
if ($Operation -eq 'prepare') {
$ready = $process.StandardOutput.ReadLineAsync()
if (-not $ready.Wait(15000) -or $ready.Result -ne 'READY') {
if ($process.HasExited -and $process.ExitCode -eq 75) { exit 75 }
throw 'CLI controller did not become ready.'
}
$process.StandardInput.WriteLine('HANDOFF')
$committed = $process.StandardOutput.ReadLineAsync()
if (-not $committed.Wait(15000) -or $committed.Result -ne 'COMMITTED') { throw 'CLI handoff was not committed.' }
}
$process.StandardInput.Close()
if (-not $process.WaitForExit(15000)) { throw 'CLI controller did not exit.' }
exit $process.ExitCode
} catch {
exit 1
} finally {
if ($process) {
if (-not $process.HasExited) { $process.Kill(); $process.WaitForExit() }
$process.Dispose()
}
}
+36
View File
@@ -11,6 +11,7 @@ ManifestDPIAware true
!macro customHeader
!define /ifndef INSTALLER_STRINGS_FILE "${INSTALLER_SOURCE_DIR}\strings.nsh"
!include "${INSTALLER_STRINGS_FILE}"
!include "${INSTALLER_SOURCE_DIR}\cli.nsh"
!ifdef BUILD_UNINSTALLER
BrandingText " "
SetFont "Segoe UI" 9
@@ -45,6 +46,11 @@ ManifestDPIAware true
StrCpy $hasPerMachineInstallation 0
StrCpy $hasPerUserInstallation 1
StrCpy $InstallerPath $INSTDIR
StrCpy $InstallerCommandSelected 0
ReadRegStr $0 HKCU "Software\DeepSeekHarness\Command" "Directory"
${If} $0 != ""
StrCpy $InstallerCommandSelected 1
${EndIf}
StrCpy $InstallerTheme "auto"
${GetParameters} $0
${GetOptions} $0 "/THEME=" $1
@@ -88,6 +94,19 @@ ManifestDPIAware true
!macroend
!macro customUnInstall
!insertmacro customCheckAppRunning
Call un.PrepareCliUpdate
Push $0
Push $1
${IfNot} ${isUpdated}
${If} ${FileExists} "$INSTDIR\resources\runtime\cli\command-installer.ps1"
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$INSTDIR\resources\runtime\cli\command-installer.ps1" -Operation remove -Directory "$INSTDIR\resources\runtime\cli\bin" -Language "$LANGUAGE" -Silent'
Pop $0
Pop $1
${EndIf}
${EndIf}
Pop $1
Pop $0
Call un.CleanData
!macroend
@@ -201,6 +220,8 @@ ManifestDPIAware true
!macro customInstall
Push $0
Push $1
Push $2
StrCpy $0 0
${If} ${Errors}
StrCpy $0 1
@@ -209,10 +230,25 @@ ManifestDPIAware true
!insertmacro dshFinishDirectories
; Standard uninstall-entry metadata read by inventory tools; the upstream template records it only under its private key.
WriteRegStr SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}" InstallLocation "$INSTDIR"
${If} $InstallerCommandSelected == 1
${If} ${Silent}
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$INSTDIR\resources\runtime\cli\command-installer.ps1" -Operation install -Directory "$INSTDIR\resources\runtime\cli\bin" -Language "$LANGUAGE" -Silent'
${Else}
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$INSTDIR\resources\runtime\cli\command-installer.ps1" -Operation install -Directory "$INSTDIR\resources\runtime\cli\bin" -Language "$LANGUAGE"'
${EndIf}
Pop $1
Pop $2
${If} $1 != 0
${AndIfNot} ${Silent}
MessageBox MB_OK|MB_ICONEXCLAMATION "$(INSTALLER_DSH_COMMAND_FAILED)" /SD IDOK
${EndIf}
${EndIf}
${If} $0 == 1
SetErrors
${Else}
ClearErrors
${EndIf}
Pop $2
Pop $1
Pop $0
!macroend
+5
View File
@@ -11,6 +11,7 @@ import extractZip from 'extract-zip'
import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
import { preparePrimaryRuntime } from './prepare-primary-runtime.ts'
import { prepareDesktopCli } from './prepare-cli.ts'
import { en, zh } from '../src/locale.ts'
const BUILD_PATHS = resolveDesktopTargetBuildPaths()
const RUNTIME_ROOT = BUILD_PATHS.runtime
@@ -56,6 +57,10 @@ async function main(): Promise<void> {
}, undefined, 2)}\n`)
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:cli',
async () => prepareDesktopCli(join(RUNTIME_ROOT, 'cli'), { platform, arch, ...macosMinimumVersion === undefined ? {} : { macosMinimumVersion } }))
cpSync(join(import.meta.dirname, '..', 'lib', 'command-manager-entry.js'), join(RUNTIME_ROOT, 'cli', 'command-manager.js'))
cpSync(join(import.meta.dirname, 'command-path.ps1'), join(RUNTIME_ROOT, 'cli', 'command-path.ps1'))
cpSync(join(import.meta.dirname, 'command-installer.ps1'), join(RUNTIME_ROOT, 'cli', 'command-installer.ps1'))
writeFileSync(join(RUNTIME_ROOT, 'cli', 'command-messages.json'), JSON.stringify({ en, zh }) + '\n')
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:primary-runtime',
() => preparePrimaryRuntime({ deferSmoke: values['defer-primary-runtime-smoke'] }))
}
+229
View File
@@ -0,0 +1,229 @@
/** Reversible command-link ownership; operations never follow or overwrite a changed command entry. */
import { createHash, randomUUID } from 'node:crypto'
import { constants } from 'node:fs'
import { access, link, lstat, mkdir, readFile, readlink, rename, stat, symlink, unlink } from 'node:fs/promises'
import { basename, dirname, isAbsolute, join } from 'node:path'
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
/** Fixed installation locations supplied by the Desktop shell. */
export interface FileCommandInstallation {
readonly destination: string
readonly launcher: string
}
type Entry = { readonly kind: 'symlink'; readonly fingerprint: string; readonly target: string }
| { readonly kind: 'missing' | 'file' | 'unsupported'; readonly fingerprint: string }
interface Backup {
readonly name: string
readonly fingerprint: string
}
interface Receipt {
readonly schemaVersion: 1
readonly launcher: string
readonly installedFingerprint: string
readonly backup?: Backup
}
/** State displayed before a command-management operation. */
export interface CommandInspection {
readonly fingerprint: string
readonly destination: string
readonly launcher: string
readonly kind: Entry['kind']
readonly managed: boolean
readonly available: boolean
readonly target?: string
readonly backup?: string
readonly preservedBackup?: string
}
/** A declined stale operation or a command entry outside this installer's ownership. */
export class CommandInstallationError extends Error {
constructor(readonly code: string, message: string) { super(message) }
}
function digest(value: unknown): string {
return createHash('sha256').update(JSON.stringify(value)).digest('hex')
}
function receiptPath(options: FileCommandInstallation): string {
return join(dirname(options.destination), '.dsh-desktop-command.json')
}
async function readEntry(path: string): Promise<Entry> {
let info
try { info = await lstat(path, { bigint: true }) } catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { kind: 'missing', fingerprint: digest('missing') }
throw error
}
const fields = [info.dev, info.ino, info.mode, info.uid, info.gid, info.size, info.mtimeNs].map(String)
if (info.isSymbolicLink()) {
const target = await readlink(path)
return { kind: 'symlink', fingerprint: digest({ kind: 'symlink', fields, target }), target }
}
const kind = info.isFile() ? 'file' : 'unsupported'
return { kind, fingerprint: digest({ kind, fields }) }
}
async function readReceipt(options: FileCommandInstallation): Promise<Receipt | undefined> {
const path = receiptPath(options)
let info
try { info = await lstat(path) } catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
throw error
}
if (!info.isFile() || info.size > 8192) throw new CommandInstallationError('EOWNERSHIP', 'Invalid command ownership record.')
let value: unknown
try { value = JSON.parse(await readFile(path, 'utf8')) } catch (error) {
if (!(error instanceof SyntaxError)) throw error
throw new CommandInstallationError('EOWNERSHIP', 'Invalid command ownership record.')
}
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new CommandInstallationError('EOWNERSHIP', 'Invalid command ownership record.')
}
const record = value as Record<string, unknown>
if (record.schemaVersion !== 1 || typeof record.launcher !== 'string' || !isAbsolute(record.launcher)
|| typeof record.installedFingerprint !== 'string' || !/^[a-f0-9]{64}$/u.test(record.installedFingerprint)) {
throw new CommandInstallationError('EOWNERSHIP', 'Invalid command ownership record.')
}
let backup: Backup | undefined
if (record.backup !== undefined) {
if (typeof record.backup !== 'object' || record.backup === null) throw new CommandInstallationError('EOWNERSHIP', 'Invalid command backup record.')
const candidate = record.backup as Record<string, unknown>
if (typeof candidate.name !== 'string' || !/^\.dsh-command-backup-[a-f0-9-]{36}$/u.test(candidate.name)
|| typeof candidate.fingerprint !== 'string' || !/^[a-f0-9]{64}$/u.test(candidate.fingerprint)) {
throw new CommandInstallationError('EOWNERSHIP', 'Invalid command backup record.')
}
backup = { name: candidate.name, fingerprint: candidate.fingerprint }
}
return { schemaVersion: 1, launcher: record.launcher, installedFingerprint: record.installedFingerprint,
...backup === undefined ? {} : { backup } }
}
async function available(path: string): Promise<boolean> {
try { await access(path, constants.X_OK); return (await stat(path)).isFile() } catch (error) {
if (['ENOENT', 'EACCES'].includes((error as NodeJS.ErrnoException).code ?? '')) return false
throw error
}
}
/**
* Read the command and receipt without changing either.
* @param options - Fixed command destination and current installed launcher.
* @returns A fingerprint to bind a later user decision to the observed entries.
*/
export async function inspectFileCommand(options: FileCommandInstallation): Promise<CommandInspection> {
const entry = await readEntry(options.destination)
const receipt = await readReceipt(options)
return {
fingerprint: digest({ entry: entry.fingerprint, receipt }),
destination: options.destination, launcher: options.launcher, kind: entry.kind,
managed: receipt !== undefined && entry.kind === 'symlink' && entry.target === receipt.launcher && entry.fingerprint === receipt.installedFingerprint,
available: await available(options.destination),
...entry.kind === 'symlink' ? { target: entry.target } : {},
...receipt?.backup === undefined ? {} : { backup: join(dirname(options.destination), receipt.backup.name) },
}
}
async function restoreEntry(source: string, destination: string): Promise<void> {
const entry = await readEntry(source)
if (entry.kind === 'symlink') await symlink(entry.target, destination)
else if (entry.kind === 'file') await link(source, destination)
else throw new CommandInstallationError('EOWNERSHIP', 'Command backup is unavailable.')
await unlink(source)
}
async function withdraw(destination: string, expected: Entry): Promise<string | undefined> {
if (expected.kind === 'missing') return undefined
if (expected.kind === 'unsupported') throw new CommandInstallationError('EUNSUPPORTED', 'The command path is not a file or symbolic link.')
const path = join(dirname(destination), '.dsh-command-backup-' + randomUUID())
await rename(destination, path)
if ((await readEntry(path)).fingerprint !== expected.fingerprint) {
try { await restoreEntry(path, destination) } catch (error) {
throw new AggregateError([error], 'The command changed; its entry is preserved at ' + path)
}
throw new CommandInstallationError('ESTALE', 'The command changed after confirmation.')
}
return path
}
/**
* Install or repair a link, keeping the previous launcher available for removal.
* @param options - Fixed command destination and current installed launcher.
* @param expected - Fingerprint displayed in the user's confirmation.
* @returns Updated state and any older backup preserved after an external command replacement.
*/
export async function installFileCommand(options: FileCommandInstallation, expected: string): Promise<CommandInspection> {
await mkdir(dirname(options.destination), { recursive: true })
return withFileLock(receiptPath(options), async () => {
const state = await inspectFileCommand(options)
if (state.fingerprint !== expected) throw new CommandInstallationError('ESTALE', 'The command changed after confirmation.')
if (!await available(options.launcher)) throw new CommandInstallationError('ENOENT', 'The installed launcher is unavailable.')
const receipt = await readReceipt(options)
const entry = await readEntry(options.destination)
const moved = await withdraw(options.destination, entry)
let created: Entry | undefined
try {
await symlink(options.launcher, options.destination)
created = await readEntry(options.destination)
const backup = state.managed || moved === undefined ? receipt?.backup
: { name: basename(moved), fingerprint: entry.fingerprint }
const next: Receipt = { schemaVersion: 1, launcher: options.launcher, installedFingerprint: created.fingerprint,
...backup === undefined ? {} : { backup } }
await writeFileAtomic(receiptPath(options), JSON.stringify(next) + '\n', { mode: 0o644 })
} catch (error) {
if (created !== undefined && (await readEntry(options.destination)).fingerprint === created.fingerprint) {
await unlink(options.destination)
}
if (moved !== undefined) {
try { await restoreEntry(moved, options.destination) } catch (restoreError) {
throw new AggregateError([error, restoreError], 'The previous command is preserved at ' + moved)
}
}
throw error
}
if (state.managed && moved !== undefined) await unlink(moved)
const result = await inspectFileCommand(options)
return receipt?.backup !== undefined && !state.managed && moved !== undefined
? { ...result, preservedBackup: join(dirname(options.destination), receipt.backup.name) } : result
}, { waitMs: 5000 })
}
/**
* Remove only a recorded Desktop link and restore its unchanged previous entry.
* @param options - Fixed command destination and current installed launcher.
* @param expected - Fingerprint displayed when removal was requested.
* @returns State after removal; an externally replaced command remains untouched.
*/
export async function removeFileCommand(options: FileCommandInstallation, expected: string): Promise<CommandInspection> {
return withFileLock(receiptPath(options), async () => {
const state = await inspectFileCommand(options)
if (state.fingerprint !== expected) throw new CommandInstallationError('ESTALE', 'The command changed after confirmation.')
const receipt = await readReceipt(options)
if (receipt === undefined) return state
const backup = receipt.backup === undefined ? undefined : join(dirname(options.destination), receipt.backup.name)
if (state.managed && backup !== undefined && receipt.backup !== undefined
&& (await readEntry(backup)).fingerprint !== receipt.backup.fingerprint) {
throw new CommandInstallationError('EOWNERSHIP', 'The previous command backup changed or is missing.')
}
if (state.managed) {
const removed = await withdraw(options.destination, await readEntry(options.destination))
try {
if (backup !== undefined) await restoreEntry(backup, options.destination)
await unlink(receiptPath(options))
} catch (error) {
if (removed !== undefined && (await readEntry(options.destination)).kind === 'missing') await restoreEntry(removed, options.destination)
throw error
}
if (removed !== undefined) await unlink(removed)
} else {
// Another installer owns the current entry. Keep it and the old backup.
await unlink(receiptPath(options))
}
const result = await inspectFileCommand(options)
return !state.managed && backup !== undefined ? { ...result, preservedBackup: backup } : result
}, { waitMs: 5000 })
}
+233
View File
@@ -0,0 +1,233 @@
/** Native command-management dialogs; only fixed installation locations reach the worker. */
import { execFile } from 'node:child_process'
import { isAbsolute, join, normalize, win32 } from 'node:path'
import { userInfo } from 'node:os'
import { promisify } from 'node:util'
import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'
import type { DesktopMessages } from './locale.ts'
import type { UpdateDialogOptions } from './update-dialog.ts'
interface CommandState {
fingerprint: string
managed: boolean
available: boolean
destination: string
launcher: string
target?: string
activeCommand?: string
backup?: string
preservedBackup?: string
occupied: boolean
selectionUnknown?: boolean
}
/** Data required to render one command-management decision. */
export interface CommandPresentation {
readonly managed: boolean
readonly available: boolean
readonly destination: string
readonly activeCommand?: string
readonly target?: string
readonly selectionUnknown?: boolean
}
/** Shell-owned access to installed resources, locale and native dialogs. */
export interface CommandManagerOptions {
readonly resources: string
readonly isPackaged: boolean
readonly isInstalledLocation: () => boolean
readonly isInstalling: () => boolean
readonly isQuitting: () => boolean
readonly messages: () => DesktopMessages
readonly show: (options: UpdateDialogOptions) => Promise<MessageBoxReturnValue>
}
class CommandWorkerError extends Error {
constructor(readonly code: string, message: string) { super(message) }
}
function object(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function parseState(value: unknown, platform: NodeJS.Platform): CommandState {
if (!object(value) || typeof value.fingerprint !== 'string' || !/^[a-f0-9]{64}$/u.test(value.fingerprint)
|| typeof value.managed !== 'boolean' || typeof value.available !== 'boolean') throw new Error('Invalid command-manager response.')
const destination = platform === 'win32' && typeof value.directory === 'string'
? join(value.directory, 'dsh.exe') : value.destination
const launcher = platform === 'win32' ? destination : value.launcher
if (typeof destination !== 'string' || !isAbsolute(destination) || typeof launcher !== 'string' || !isAbsolute(launcher)) {
throw new Error('Invalid command-manager locations.')
}
for (const name of ['target', 'activeCommand', 'backup', 'preservedBackup']) {
if (value[name] !== undefined && value[name] !== null && typeof value[name] !== 'string') throw new Error('Invalid command-manager detail.')
}
return {
fingerprint: value.fingerprint, managed: value.managed, available: value.available,
destination, launcher,
occupied: platform === 'win32' ? typeof value.activeCommand === 'string' : value.kind !== 'missing',
...typeof value.target === 'string' ? { target: value.target } : {},
...typeof value.activeCommand === 'string' ? { activeCommand: value.activeCommand } : {},
...typeof value.backup === 'string' ? { backup: value.backup } : {},
...typeof value.preservedBackup === 'string' ? { preservedBackup: value.preservedBackup } : {},
}
}
function format(text: string, path: string): string { return text.replace('{path}', path) }
function sameCommand(left: string, right: string, platform: NodeJS.Platform): boolean {
return platform === 'win32' ? win32.normalize(left).toLowerCase() === win32.normalize(right).toLowerCase() : normalize(left) === normalize(right)
}
/**
* Describe installation state using Desktop-owned copy.
* @param state - Observed link or PATH selection.
* @param messages - Current Desktop locale.
* @param platform - Command lookup semantics of the host operating system.
* @returns Native dialog with stable Close/Repair/Remove or Install/Cancel indices.
*/
export function presentCommandManagement(
state: CommandPresentation, messages: DesktopMessages, platform: NodeJS.Platform,
): MessageBoxOptions {
const other = state.activeCommand !== undefined && !sameCommand(state.activeCommand, state.destination, platform)
const detail = [
format(messages.cliCommandLocation, state.destination),
...state.activeCommand === undefined ? [] : [format(messages.cliCommandSelected, state.activeCommand)],
...state.target === undefined ? [] : [format(messages.cliCommandTarget, state.target)],
...other ? [messages.cliCommandShadowed] : [],
...state.selectionUnknown ? [messages.cliCommandSelectionUnknown] : [],
].join('\n\n')
return {
type: other ? 'warning' : 'info', title: messages.cliCommandTitle,
message: state.managed ? (state.available ? messages.cliCommandInstalled : messages.cliCommandBroken) : messages.cliCommandNotInstalled,
detail,
buttons: state.managed
? [messages.cliCommandClose, messages.cliCommandRepair, messages.cliCommandRemove]
: [messages.cliCommandInstall, messages.cancel],
defaultId: 0, cancelId: state.managed ? 0 : 1,
}
}
async function shellCommand(): Promise<{ activeCommand?: string; selectionUnknown?: boolean }> {
const shell = userInfo().shell
if (shell === null) return { selectionUnknown: true }
const script = "printf '\\0DSH_COMMAND\\0'; command -v dsh; printf '\\0'"
const { stdout } = await promisify(execFile)(shell, ['-ilc', script], { timeout: 5000, maxBuffer: 65536 })
const value = stdout.split('\0DSH_COMMAND\0')[1]?.split('\0')[0]?.trim()
if (value === undefined) return { selectionUnknown: true }
if (value === '') return {}
return isAbsolute(value) && !/[\r\n]/u.test(value) ? { activeCommand: value } : { selectionUnknown: true }
}
/** One visible operation; updater preparation waits for its started worker to finish. */
export class DesktopCommandManager {
private operation: Promise<void> | undefined
/** @param options - Installed resources and shell-owned UI callbacks. */
constructor(private readonly options: CommandManagerOptions) {}
/** Open or join the command-management operation. */
show(): Promise<void> {
return this.operation ??= this.run().finally(() => { this.operation = undefined })
}
/** Wait for a command operation already started by the user. */
async idle(): Promise<void> { await this.operation }
private async worker(operation: string, expected?: string, elevated = false): Promise<CommandState> {
const node = join(this.options.resources, 'runtime', 'primary-runtime', 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node')
const entry = join(this.options.resources, 'runtime', 'cli', 'command-manager.js')
let stdout: string
try {
if (elevated) {
const appleScript = 'on run argv\nset cmd to "/usr/bin/env -i " & quoted form of (item 1 of argv) & " " & quoted form of (item 2 of argv) & " " & quoted form of (item 3 of argv) & " " & quoted form of (item 4 of argv)\ndo shell script cmd with administrator privileges\nend run'
stdout = (await promisify(execFile)('/usr/bin/osascript', ['-e', appleScript, node, entry, operation, expected ?? ''],
{ maxBuffer: 65536 })).stdout
} else {
const env = Object.fromEntries(Object.entries(process.env)
.filter(([name]) => !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^NODE_PATH$/iu.test(name)))
stdout = (await promisify(execFile)(node, [entry, operation, ...expected === undefined ? [] : [expected]],
{ timeout: 30000, maxBuffer: 65536, windowsHide: true, env })).stdout
}
} catch (error) {
if (object(error) && typeof error.stdout === 'string' && error.stdout.trim().startsWith('{')) stdout = error.stdout
else throw new CommandWorkerError('EIO', 'Command-manager process failed.')
}
const response: unknown = JSON.parse(stdout.trim())
if (!object(response) || typeof response.ok !== 'boolean') throw new Error('Invalid command-manager response.')
if (!response.ok) {
const code = typeof response.code === 'string' ? response.code : 'EIO'
if (!elevated && process.platform === 'darwin' && operation !== 'inspect' && ['EACCES', 'EPERM'].includes(code)) {
return this.worker(operation, expected, true)
}
throw new CommandWorkerError(code, typeof response.message === 'string' ? response.message : 'Command management failed.')
}
return parseState(response.state, process.platform)
}
private async inspect(): Promise<CommandState> {
const state = await this.worker('inspect')
if (process.platform !== 'darwin') return state
try {
return { ...state, ...await shellCommand() }
} catch {
// A custom login shell can fail or wait for interaction; its output is not a diagnostic.
return { ...state, selectionUnknown: true }
}
}
private async run(): Promise<void> {
const messages = this.options.messages()
if (!this.options.isPackaged || !this.options.isInstalledLocation()) {
await this.options.show({ type: 'info', title: messages.cliCommandTitle, message: messages.cliCommandInstallApp, buttons: [messages.cliCommandClose] })
return
}
if (this.options.isInstalling()) {
await this.options.show({ type: 'info', title: messages.cliCommandTitle, message: messages.cliCommandUpdating, buttons: [messages.cliCommandClose] })
return
}
try {
const state = await this.inspect()
const choice = await this.options.show(presentCommandManagement(state, messages, process.platform))
if (this.options.isQuitting()) return
const operation = state.managed ? (choice.response === 1 ? 'install' : choice.response === 2 ? 'remove' : undefined)
: choice.response === 0 ? 'install' : undefined
if (operation === undefined) return
if (operation === 'install' && (state.occupied || state.activeCommand !== undefined || state.selectionUnknown)) {
const confirmation = await this.options.show({
type: 'warning', title: messages.cliCommandTitle, message: messages.cliCommandSwitch,
detail: (state.selectionUnknown ? messages.cliCommandSelectionUnknown
: format(messages.cliCommandSelected, state.activeCommand ?? state.destination))
+ '\n\n' + messages.cliCommandPreserve,
buttons: [messages.cliCommandContinue, messages.cancel], defaultId: 1, cancelId: 1,
})
if (confirmation.response !== 0 || this.options.isQuitting()) return
}
const result = await this.worker(operation, state.fingerprint)
if (this.options.isQuitting()) return
const current = await this.inspect()
const shadowed = current.activeCommand !== undefined && !sameCommand(current.activeCommand, current.destination, process.platform)
await this.options.show({
type: shadowed && operation === 'install' ? 'warning' : 'info', title: messages.cliCommandTitle,
message: operation === 'remove' ? messages.cliCommandRemoved : messages.cliCommandInstalled,
detail: [operation === 'install' ? messages.cliCommandNewTerminal : state.backup !== undefined && state.managed ? messages.cliCommandPreviousRestored : messages.cliCommandOtherKept,
...shadowed && operation === 'install' && current.activeCommand !== undefined
? [format(messages.cliCommandSelected, current.activeCommand), messages.cliCommandShadowed] : [],
...current.selectionUnknown && operation === 'install' ? [messages.cliCommandSelectionUnknown] : [],
...result.preservedBackup === undefined ? [] : [format(messages.cliCommandBackupKept, result.preservedBackup)],
].join('\n\n'),
buttons: [messages.cliCommandClose],
})
} catch (error) {
if (this.options.isQuitting()) return
const code = error instanceof CommandWorkerError ? error.code : 'EIO'
await this.options.show({
type: 'error', title: messages.cliCommandTitle,
message: code === 'ESTALE' ? messages.cliCommandChanged : code === 'EOWNERSHIP' ? messages.cliCommandOwnershipError : messages.cliCommandFailed,
technicalDetails: error instanceof Error ? error.message : '',
buttons: [messages.cliCommandClose],
})
}
}
}
+45
View File
@@ -0,0 +1,45 @@
/** Private command-management worker; macOS mutation targets are fixed before elevation. */
import { spawn } from 'node:child_process'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { CommandInstallationError, inspectFileCommand, installFileCommand, removeFileCommand } from './command-installation.ts'
const [operation, expected] = process.argv.slice(2)
const fingerprint = expected ?? ''
const resources = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
// This dedicated worker writes public command ownership metadata, not user secrets.
if (process.platform === 'darwin') process.umask(0o022)
try {
if (!['inspect', 'install', 'remove'].includes(operation ?? '')) throw new CommandInstallationError('EINVAL', 'Invalid command-management operation.')
if (operation !== 'inspect' && !/^[a-f0-9]{64}$/u.test(fingerprint)) throw new CommandInstallationError('EINVAL', 'Missing command confirmation.')
if (process.platform === 'darwin') {
const options = { destination: '/usr/local/bin/dsh', launcher: join(resources, 'runtime', 'cli', 'bin', 'dsh') }
const state = operation === 'inspect' ? await inspectFileCommand(options)
: operation === 'install' ? await installFileCommand(options, fingerprint) : await removeFileCommand(options, fingerprint)
process.stdout.write(JSON.stringify({ ok: true, state }) + '\n')
} else if (process.platform === 'win32') {
const systemRoot = process.env.SystemRoot ?? process.env.WINDIR
if (systemRoot === undefined) throw new CommandInstallationError('ENOENT', 'Windows system directory is unavailable.')
const child = spawn(join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', join(resources, 'runtime', 'cli', 'command-path.ps1')],
{ stdio: ['pipe', 'pipe', 'inherit'], windowsHide: true })
child.stdout.pipe(process.stdout)
const exited = new Promise<number | null>((accept, reject) => {
child.once('error', reject)
child.once('close', accept)
})
child.stdin.end(JSON.stringify({ operation, expected, directory: join(resources, 'runtime', 'cli', 'bin') }))
process.exitCode = await exited ?? 1
} else {
throw new CommandInstallationError('EUNSUPPORTED', 'Command installation is supported on macOS and Windows.')
}
} catch (error) {
process.stdout.write(JSON.stringify({
ok: false,
code: error instanceof CommandInstallationError ? error.code : (error as NodeJS.ErrnoException).code ?? 'EIO',
message: error instanceof Error ? error.message : 'Command management failed.',
}) + '\n')
process.exitCode = 1
}
+58
View File
@@ -1,6 +1,35 @@
/** Typed English and Chinese copy owned by the Electron shell. */
export const en = {
cliCommandMenu: 'Manage dsh Command…',
cliCommandTitle: 'Manage dsh Command',
cliCommandLocation: 'Desktop command: {path}',
cliCommandSelected: 'Current dsh command: {path}',
cliCommandTarget: 'Current launcher target: {path}',
cliCommandShadowed: 'Another dsh takes precedence. Remove or reorder that installation to use the Desktop command by default. You can also run the Desktop command by its full path.',
cliCommandSelectionUnknown: 'Your shell command could not be verified. An alias or another dsh installation may take precedence.',
cliCommandInstalled: 'The Desktop command is installed.',
cliCommandNotInstalled: 'Add the Desktop command to your terminal.',
cliCommandBroken: 'The Desktop command needs repair.',
cliCommandInstall: 'Install',
cliCommandRepair: 'Repair',
cliCommandRemove: 'Remove',
cliCommandClose: 'Close',
cliCommandSwitch: 'Continue with the Desktop command?',
cliCommandPreserve: 'An existing command will be preserved. Other installations and shell startup files will not be changed.',
cliCommandContinue: 'Continue',
cliCommandInstallApp: 'Install Desktop in your Applications folder before managing the dsh command.',
cliCommandUpdating: 'An update is being installed. Manage the command after installation finishes.',
cliCommandNewTerminal: 'Open a new terminal and run dsh --version.',
cliCommandRemoved: 'Desktop command registration removed.',
cliCommandPreviousRestored: 'The previous launcher has been restored.',
cliCommandOtherKept: 'Other command installations have been left in place.',
cliCommandBackupKept: 'A previous launcher is preserved at: {path}',
cliCommandChanged: 'The command or PATH changed while this dialog was open. Open Manage dsh Command again to review the current state.',
cliCommandOwnershipError: 'The command registration or backup changed. No unrelated command was removed.',
cliCommandFailed: 'The command could not be updated. Check that the application is installed and the destination is writable, then retry.',
cliCommandBusyTitle: 'dsh Commands Are Running',
cliCommandBusyDetail: 'Finish running dsh commands, then retry the update. The downloaded update will be kept.',
application: 'Application',
fileMenu: 'File',
closePage: 'Close Page or Window',
@@ -145,6 +174,35 @@ export const en = {
export type DesktopMessages = { readonly [Key in keyof typeof en]: string }
export const zh = {
cliCommandMenu: '管理 dsh 命令…',
cliCommandTitle: '管理 dsh 命令',
cliCommandLocation: 'Desktop 命令:{path}',
cliCommandSelected: '当前 dsh 命令:{path}',
cliCommandTarget: '当前启动器目标:{path}',
cliCommandShadowed: '另一个 dsh 的优先级更高。请移除或调整该安装的顺序,以默认使用 Desktop 命令;也可以通过完整路径运行 Desktop 命令。',
cliCommandSelectionUnknown: '无法确认 shell 中的命令。别名或另一个 dsh 安装可能具有更高优先级。',
cliCommandInstalled: 'Desktop 命令已安装。',
cliCommandNotInstalled: '将 Desktop 命令添加到终端。',
cliCommandBroken: 'Desktop 命令需要修复。',
cliCommandInstall: '安装',
cliCommandRepair: '修复',
cliCommandRemove: '移除',
cliCommandClose: '关闭',
cliCommandSwitch: '继续使用 Desktop 命令?',
cliCommandPreserve: '现有命令会被保留,不会修改其他安装或 shell 启动文件。',
cliCommandContinue: '继续',
cliCommandInstallApp: '请先将 Desktop 安装到“应用程序”文件夹,再管理 dsh 命令。',
cliCommandUpdating: '正在安装更新。请在安装完成后管理命令。',
cliCommandNewTerminal: '打开新终端并运行 dsh --version。',
cliCommandRemoved: '已移除 Desktop 命令注册。',
cliCommandPreviousRestored: '已恢复之前的启动器。',
cliCommandOtherKept: '其他命令安装保持不变。',
cliCommandBackupKept: '之前的启动器保留在:{path}',
cliCommandChanged: '对话框打开期间命令或 PATH 已改变。请重新打开“管理 dsh 命令”检查当前状态。',
cliCommandOwnershipError: '命令注册或备份已改变,未移除无关命令。',
cliCommandFailed: '无法更新命令。请检查应用是否已安装、目标位置是否可写,然后重试。',
cliCommandBusyTitle: 'dsh 命令正在运行',
cliCommandBusyDetail: '请结束运行中的 dsh 命令后重试更新。已下载的更新会保留。',
application: '应用',
fileMenu: '文件',
closePage: '关闭页面或窗口',
+24
View File
@@ -34,6 +34,8 @@ import { readDeviceInfo } from './device-info.ts'
import { desktopUpdateReadyConfirmation, formatDesktopMessage, resolveDesktopLocale, resolveDesktopStartupLocale } from './locale.ts'
import { claimDesktopSingleInstance } from './single-instance.ts'
import { DesktopUpdateCoordinator } from './update-coordinator.ts'
import { DesktopCommandManager } from './command-management.ts'
import { DesktopCliBusyError, DesktopCliUpdateGuard } from './cli-update-guard.ts'
import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts'
import { DesktopFatalRecovery } from './fatal-recovery.ts'
import { pruneCrashReports, RendererConsoleTail, writeCrashReport, type CrashReportSource } from './crash-report.ts'
@@ -372,6 +374,15 @@ async function main(): Promise<void> {
detail: formatDesktopMessage(locale.messages.aboutVersion, { version: app.getVersion() }),
buttons: [locale.messages.updateAcknowledge], cancelId: 0 })
}
const commandManager = new DesktopCommandManager({
resources: process.resourcesPath,
isPackaged: app.isPackaged,
isInstalledLocation: () => process.platform !== 'darwin' || app.isInApplicationsFolder(),
isInstalling: () => updateState.phase === 'installing',
isQuitting,
messages: () => currentDesktopLocale().messages,
show: ordinaryMessageBox,
})
const appPreload = fileURLToPath(new URL('./preload-app.cjs', import.meta.url))
const applicationUrl = `${SCHEME}://app/`
let hostUrl: string | undefined
@@ -610,6 +621,17 @@ async function main(): Promise<void> {
},
undefined, undefined, undefined,
(success, reason) => { void track('desktop_upgrade_download_result', { is_success: success, ...reason === undefined ? {} : { error_reason: reason } }) },
development ? undefined : async (version) => {
await commandManager.idle()
try {
return await DesktopCliUpdateGuard.acquire(join(process.resourcesPath, 'runtime', 'cli', process.platform === 'win32' ? 'cli-control.exe' : 'cli-control'), version)
} catch (error) {
if (!(error instanceof DesktopCliBusyError)) throw error
await ordinaryMessageBox({ type: 'info', title: locale.messages.cliCommandBusyTitle,
message: locale.messages.cliCommandBusyDetail, buttons: [locale.messages.cliCommandClose], cancelId: 0 })
return undefined
}
},
)
const updateSchedule = new DesktopUpdateSchedule(updates, resolveDesktopUpdateScheduleConfig(process.env))
@@ -928,6 +950,8 @@ async function main(): Promise<void> {
: { label: currentDesktopLocale().messages.aboutMenu, role: 'about' },
{ type: 'separator' },
{ label: currentDesktopLocale().messages.checkUpdatesMenu, click: () => { void openUpdatePrompt(true) } },
...process.platform === 'darwin' || process.platform === 'win32'
? [{ label: currentDesktopLocale().messages.cliCommandMenu, click: () => { void commandManager.show() } }] : [],
...development ? [
{ type: 'separator' as const },
{ label: currentDesktopLocale().messages.reloadPageMenu, role: 'reload' as const },
@@ -0,0 +1,133 @@
/** Real command entries and receipts; every test owns its destination directory. */
import { chmod, lstat, mkdir, mkdtemp, readFile, readlink, rm, symlink, unlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { describe, expect, it, onTestFinished } from 'vitest'
import { inspectFileCommand, installFileCommand, removeFileCommand } from '../src/command-installation.ts'
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'dsh-command-link-'))
onTestFinished(() => rm(root, { recursive: true, force: true }))
const launcher = join(root, 'desktop-launcher')
await writeFile(launcher, 'desktop\n', { mode: 0o755 })
return { root, options: { destination: join(root, 'dsh'), launcher } }
}
describe.skipIf(process.platform === 'win32')('macOS command entry ownership', () => {
it('installs and removes only its own link', async () => {
const f = await fixture()
const before = await inspectFileCommand(f.options)
const installed = await installFileCommand(f.options, before.fingerprint)
expect(installed.managed).toBe(true)
expect(await readlink(f.options.destination)).toBe(f.options.launcher)
await removeFileCommand(f.options, installed.fingerprint)
await expect(lstat(f.options.destination)).rejects.toMatchObject({ code: 'ENOENT' })
expect(await readFile(f.options.launcher, 'utf8')).toBe('desktop\n')
})
it('preserves the raw target of an existing relative npm link and restores it', async () => {
const f = await fixture()
await writeFile(join(f.root, 'npm-cli'), 'npm\n', { mode: 0o755 })
await symlink('npm-cli', f.options.destination)
const installed = await installFileCommand(f.options, (await inspectFileCommand(f.options)).fingerprint)
expect(await readlink(installed.backup!)).toBe('npm-cli')
await removeFileCommand(f.options, installed.fingerprint)
expect(await readlink(f.options.destination)).toBe('npm-cli')
expect(await readFile(f.options.destination, 'utf8')).toBe('npm\n')
})
it('restores a regular executable with its bytes and permission bits', async () => {
const f = await fixture()
const bytes = Buffer.from([0, 1, 2, 255])
await writeFile(f.options.destination, bytes, { mode: 0o751 })
const installed = await installFileCommand(f.options, (await inspectFileCommand(f.options)).fingerprint)
await removeFileCommand(f.options, installed.fingerprint)
expect(await readFile(f.options.destination)).toEqual(bytes)
expect((await lstat(f.options.destination)).mode & 0o777).toBe(0o751)
})
it('rejects a changed confirmation without moving the replacement', async () => {
const f = await fixture()
const before = await inspectFileCommand(f.options)
await symlink('another-command', f.options.destination)
await expect(installFileCommand(f.options, before.fingerprint)).rejects.toMatchObject({ code: 'ESTALE' })
expect(await readlink(f.options.destination)).toBe('another-command')
})
it('repairs a moved application without replacing the original backup', async () => {
const f = await fixture()
await symlink('npm-cli', f.options.destination)
const first = await installFileCommand(f.options, (await inspectFileCommand(f.options)).fingerprint)
const moved = join(f.root, 'moved-desktop')
await writeFile(moved, 'moved\n', { mode: 0o755 })
await unlink(f.options.launcher)
const options = { ...f.options, launcher: moved }
const broken = await inspectFileCommand(options)
expect(broken.managed).toBe(true)
expect(broken.available).toBe(false)
const repaired = await installFileCommand(options, broken.fingerprint)
expect(repaired.backup).toBe(first.backup)
expect(await readlink(options.destination)).toBe(moved)
await removeFileCommand(options, repaired.fingerprint)
expect(await readlink(options.destination)).toBe('npm-cli')
})
it('leaves a command installed by someone else after Desktop registration intact', async () => {
const f = await fixture()
await symlink('old-npm', f.options.destination)
const installed = await installFileCommand(f.options, (await inspectFileCommand(f.options)).fingerprint)
await unlink(f.options.destination)
await symlink('new-npm', f.options.destination)
const state = await inspectFileCommand(f.options)
const removed = await removeFileCommand(f.options, state.fingerprint)
expect(await readlink(f.options.destination)).toBe('new-npm')
expect(removed.preservedBackup).toBe(installed.backup)
expect(await readlink(installed.backup!)).toBe('old-npm')
})
it('refuses to restore a modified backup', async () => {
const f = await fixture()
await writeFile(f.options.destination, 'original\n', { mode: 0o755 })
const installed = await installFileCommand(f.options, (await inspectFileCommand(f.options)).fingerprint)
await writeFile(installed.backup!, 'changed backup with different bytes\n')
await expect(removeFileCommand(f.options, installed.fingerprint)).rejects.toMatchObject({ code: 'EOWNERSHIP' })
expect(await readlink(f.options.destination)).toBe(f.options.launcher)
})
it('rejects directories and non-executable launcher resources', async () => {
const f = await fixture()
await mkdir(f.options.destination)
await expect(installFileCommand(f.options, (await inspectFileCommand(f.options)).fingerprint)).rejects.toMatchObject({ code: 'EUNSUPPORTED' })
expect((await lstat(f.options.destination)).isDirectory()).toBe(true)
await rm(f.options.destination, { recursive: true })
await chmod(f.options.launcher, 0o644)
await expect(installFileCommand(f.options, (await inspectFileCommand(f.options)).fingerprint)).rejects.toMatchObject({ code: 'ENOENT' })
})
it('rejects linked receipts and backup paths outside the command directory', async () => {
const f = await fixture()
const receipt = join(f.root, '.dsh-desktop-command.json')
await symlink(f.options.launcher, receipt)
await expect(inspectFileCommand(f.options)).rejects.toMatchObject({ code: 'EOWNERSHIP' })
await unlink(receipt)
await writeFile(receipt, JSON.stringify({
schemaVersion: 1, launcher: f.options.launcher, installedFingerprint: '0'.repeat(64),
backup: { name: '../../other-file', fingerprint: '0'.repeat(64) },
}))
await expect(inspectFileCommand(f.options)).rejects.toMatchObject({ code: 'EOWNERSHIP' })
expect(await readFile(f.options.launcher, 'utf8')).toBe('desktop\n')
})
it('serializes simultaneous choices so only one stale snapshot can install', async () => {
const f = await fixture()
const state = await inspectFileCommand(f.options)
const results = await Promise.allSettled([
installFileCommand(f.options, state.fingerprint),
installFileCommand(f.options, state.fingerprint),
])
expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1)
expect(results.filter(result => result.status === 'rejected')).toHaveLength(1)
expect((await inspectFileCommand(f.options)).managed).toBe(true)
})
})
@@ -0,0 +1,21 @@
/** Locale-owned native command-management states. */
import { expect, it } from 'vitest'
import { en, zh } from '../src/locale.ts'
import { presentCommandManagement } from '../src/command-management.ts'
it.each([en, zh])('shows installation, repair/removal and shadowed command locations', (messages) => {
const destination = '/usr/local/bin/dsh'
const missing = presentCommandManagement({ managed: false, available: false, destination }, messages, 'darwin')
expect(missing.buttons).toEqual([messages.cliCommandInstall, messages.cancel])
expect(missing.cancelId).toBe(1)
const installed = presentCommandManagement({ managed: true, available: true, destination, activeCommand: destination }, messages, 'darwin')
expect(installed.buttons).toEqual([messages.cliCommandClose, messages.cliCommandRepair, messages.cliCommandRemove])
expect(installed.cancelId).toBe(0)
const shadowed = presentCommandManagement({ managed: true, available: true, destination, activeCommand: '/opt/homebrew/bin/dsh' }, messages, 'darwin')
expect(shadowed.type).toBe('warning')
expect(shadowed.detail).toContain('/opt/homebrew/bin/dsh')
expect(shadowed.detail).toContain(messages.cliCommandShadowed)
const unknown = presentCommandManagement({ managed: false, available: false, destination, selectionUnknown: true }, messages, 'darwin')
expect(unknown.detail).toContain(messages.cliCommandSelectionUnknown)
})
@@ -0,0 +1,90 @@
/** User decisions cross a real worker process; the worker replaces only the OS mutation boundary. */
import { copyFile, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it, onTestFinished, vi } from 'vitest'
import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'
import { DesktopCommandManager } from '../src/command-management.ts'
import { en } from '../src/locale.ts'
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>()
return { ...actual, userInfo: () => ({ ...actual.userInfo(), shell: null }) }
})
async function fixture(failure?: string) {
const root = await mkdtemp(join(tmpdir(), 'dsh-command-dialog-'))
onTestFinished(() => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }))
const resources = join(root, 'resources')
const bin = join(resources, 'runtime', 'primary-runtime', 'dependencies', 'node', 'bin')
const worker = join(resources, 'runtime', 'cli')
await mkdir(bin, { recursive: true })
await mkdir(worker, { recursive: true })
const node = join(bin, process.platform === 'win32' ? 'node.exe' : 'node')
if (process.platform === 'win32') await copyFile(process.execPath, node)
else await symlink(process.execPath, node)
await writeFile(join(worker, 'package.json'), '{"type":"module"}\n')
const state = {
fingerprint: 'a'.repeat(64), managed: false, available: true, kind: 'file',
destination: join(root, process.platform === 'win32' ? 'dsh.exe' : 'dsh'),
directory: root, launcher: join(root, 'desktop-dsh'), activeCommand: join(root, 'other-dsh'),
}
await writeFile(join(worker, 'state.json'), JSON.stringify({ state, failure }))
await writeFile(join(worker, 'command-manager.js'), [
"import { readFileSync, appendFileSync } from 'node:fs'",
"import { join } from 'node:path'",
"const { state, failure } = JSON.parse(readFileSync(join(import.meta.dirname, 'state.json'), 'utf8'))",
'const operation = process.argv[2]',
"appendFileSync(join(import.meta.dirname, 'calls.jsonl'), JSON.stringify({operation, expected:process.argv[3]})+'\\n')",
"if (operation === 'install' && failure) {",
" process.stdout.write(JSON.stringify({ok:false,code:failure,message:'worker declined'})); process.exitCode=1",
'} else {',
' process.stdout.write(JSON.stringify({ok:true,state}))',
'}',
'',
].join('\n'))
const show = vi.fn<(options: MessageBoxOptions) => Promise<MessageBoxReturnValue>>()
const manager = new DesktopCommandManager({
resources, isPackaged: true, isInstalledLocation: () => true, isInstalling: () => false, isQuitting: () => false,
messages: () => en, show,
})
const calls = async (): Promise<Array<{ operation: string; expected?: string }>> =>
(await readFile(join(worker, 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as { operation: string; expected?: string })
return { manager, show, calls }
}
it('requires confirmation before replacing an existing command', async () => {
const f = await fixture()
f.show.mockResolvedValueOnce({ response: 0, checkboxChecked: false })
.mockResolvedValueOnce({ response: 1, checkboxChecked: false })
await f.manager.show()
expect(f.show.mock.calls[1]![0]).toMatchObject({ message: en.cliCommandSwitch, defaultId: 1, cancelId: 1 })
expect(await f.calls()).toEqual([{ operation: 'inspect' }])
})
it('passes the displayed fingerprint and reports a stale worker refusal', async () => {
const f = await fixture('ESTALE')
f.show.mockResolvedValue({ response: 0, checkboxChecked: false })
await f.manager.show()
expect(await f.calls()).toEqual([{ operation: 'inspect' }, { operation: 'install', expected: 'a'.repeat(64) }])
expect(f.show.mock.lastCall![0].message).toBe(en.cliCommandChanged)
})
it('coalesces repeated menu clicks and keeps update preparation waiting for the active decision', async () => {
const f = await fixture()
const shown = Promise.withResolvers<undefined>()
const choice = Promise.withResolvers<MessageBoxReturnValue>()
f.show.mockImplementation(() => { shown.resolve(undefined); return choice.promise })
const first = f.manager.show()
expect(f.manager.show()).toBe(first)
await shown.promise
let idle = false
const waiting = f.manager.idle().then(() => { idle = true })
await Promise.resolve()
expect(idle).toBe(false)
choice.resolve({ response: 1, checkboxChecked: false })
await Promise.all([first, waiting])
expect(idle).toBe(true)
expect(await f.calls()).toEqual([{ operation: 'inspect' }])
})
+24
View File
@@ -0,0 +1,24 @@
/** Real Windows registry operations use a unique key tree rather than the user's environment. */
import { execFile } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { expect, it } from 'vitest'
it.skipIf(process.platform !== 'win32')('preserves unrelated PATH entries and rejects stale or foreign removal', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-command-path-'))
try {
const systemRoot = process.env.SystemRoot ?? process.env.WINDIR
if (systemRoot === undefined) throw new Error('SystemRoot is required for the Windows registry test')
const result = await promisify(execFile)(join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), [
'-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File',
join(import.meta.dirname, 'fixtures', 'command-path.ps1'),
'-Worker', join(import.meta.dirname, '..', 'scripts', 'command-path.ps1'), '-Root', root,
], { timeout: 60000, windowsHide: true })
expect(result.stdout).toContain('WINDOWS_COMMAND_PATH_OK')
} finally {
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
}
}, 75000)
+63
View File
@@ -0,0 +1,63 @@
param([string]$Worker, [string]$Root)
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
. $Worker
$identity = [Guid]::NewGuid().ToString('N')
$keyRoot = 'Software\DeepSeekHarness\CommandTests\' + $identity
$environmentKey = $keyRoot + '\Environment'
$ownerKey = $keyRoot + '\Owner'
$options = @{ EnvironmentKey=$environmentKey; OwnerKey=$ownerKey; MachinePath=''; MutexName=('Local\DshCommandTest-' + $identity) }
function Require([bool]$Condition, [string]$Message) { if (-not $Condition) { throw $Message } }
function UserPath {
$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($environmentKey)
try { return $key.GetValue('Path', $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) }
finally { $key.Dispose() }
}
function State([string]$Directory) { return Invoke-DshCommandPath @options -Request @{ operation='inspect'; directory=$Directory } }
function Apply([string]$Operation, [string]$Directory) {
$state = State $Directory
return Invoke-DshCommandPath @options -Request @{ operation=$Operation; directory=$Directory; expected=$state.fingerprint }
}
try {
$desktop = Join-Path $Root ('Desktop space ' + [char]0x4e2d + [char]0x6587)
$other = Join-Path $Root 'Other Desktop'
$npm = Join-Path $Root 'npm'
$system = Join-Path $Root 'system'
New-Item -ItemType Directory -Force $desktop, $other, $npm, $system | Out-Null
[IO.File]::WriteAllText((Join-Path $desktop 'dsh.exe'), 'fixture')
[IO.File]::WriteAllText((Join-Path $other 'dsh.exe'), 'fixture')
[IO.File]::WriteAllText((Join-Path $npm 'dsh.cmd'), 'fixture')
$key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey($environmentKey)
try { $key.SetValue('Path', $npm, [Microsoft.Win32.RegistryValueKind]::ExpandString) }
finally { $key.Dispose() }
$before = State $desktop
Require ($before.activeCommand -eq (Join-Path $npm 'dsh.cmd')) 'existing npm command was not detected'
[void](Apply 'install' $desktop)
Require ((UserPath) -eq ($desktop + ';' + $npm)) 'installation changed unrelated PATH entries'
$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($environmentKey, $true)
try { $key.SetValue('Path', (UserPath) + ';extra-user-entry', [Microsoft.Win32.RegistryValueKind]::ExpandString) }
finally { $key.Dispose() }
[void](Apply 'remove' $desktop)
Require ((UserPath) -eq ($npm + ';extra-user-entry')) 'removal lost concurrent user entries'
$stale = State $desktop
$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($environmentKey, $true)
try { $key.SetValue('Path', (UserPath) + ';newer-entry', [Microsoft.Win32.RegistryValueKind]::ExpandString) }
finally { $key.Dispose() }
$rejected = $false
try { [void](Invoke-DshCommandPath @options -Request @{ operation='install'; directory=$desktop; expected=$stale.fingerprint }) }
catch { $rejected = $_.Exception.Data['code'] -eq 'ESTALE' }
Require $rejected 'stale confirmation was accepted'
[void](Apply 'install' $desktop)
[void](Apply 'install' $other)
$newer = UserPath
[void](Apply 'remove' $desktop)
Require ((UserPath) -eq $newer) 'old uninstaller removed the newer command'
Require ((State $other).managed) 'old uninstaller deleted newer ownership'
[IO.File]::WriteAllText((Join-Path $system 'dsh.exe'), 'fixture')
$options.MachinePath = $system
Require ((State $other).activeCommand -eq (Join-Path $system 'dsh.exe')) 'machine PATH precedence was hidden'
[void](Apply 'remove' $other)
'WINDOWS_COMMAND_PATH_OK'
} finally {
[Microsoft.Win32.Registry]::CurrentUser.DeleteSubKeyTree($keyRoot, $false)
}
+2 -2
View File
@@ -835,7 +835,7 @@ describe('desktop main startup', () => {
expect(() => handler(event, 'application', NaN, 34)).toThrow('invalid popup request')
const application = handler(event, 'application', 48, 34)
expect(harness.menu.buildFromTemplate.mock.lastCall![0].map(item => item.label ?? item.type)).toEqual([
'关于 DeepSeek Harness', 'separator', '检查更新…', 'separator', '退出',
'关于 DeepSeek Harness', 'separator', '检查更新…', '管理 dsh 命令…', 'separator', '退出',
])
expect(harness.popup.mock.lastCall![0]).toMatchObject({ window, x: 48, y: 34 })
expect(harness.popup.mock.lastCall![0].callback).toBeTypeOf('function')
@@ -872,7 +872,7 @@ describe('desktop main startup', () => {
: ['Application', 'editMenu'])
const application = template[0]!.submenu as MenuItemConstructorOptions[]
expect(application.filter(item => item.visible !== false).map(describeItem)).toEqual(platform === 'darwin'
? ['about', 'separator', en.checkUpdatesMenu, 'separator', 'hide', 'hideOthers', 'unhide', 'separator', 'quit']
? ['about', 'separator', en.checkUpdatesMenu, en.cliCommandMenu, 'separator', 'hide', 'hideOthers', 'unhide', 'separator', 'quit']
: ['about', 'separator', en.checkUpdatesMenu, 'separator', 'quit'])
expect(harness.menu.setApplicationMenu).toHaveBeenCalledOnce()
})
+13
View File
@@ -33,6 +33,19 @@ if (clientVersion === undefined) throw new Error('desktop build: the client envi
const clientVersionDefine = { 'process.env.DSH_CLIENT_VERSION': JSON.stringify(clientVersion) }
export default defineConfig([
{
entry: ['lib/types/command-manager-entry.js'],
plugins: [packagedImportsPlugin({ packages: new Set(), nodeBuiltins: true })],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
codeSplitting: false,
deps: { alwaysBundle: ['@deepseek-ai/dsh-atomic-write'] },
},
{
entry: ['lib/types/main.js'],
plugins: [packagedImportsPlugin(mainProcessImports)],