diff --git a/apps/desktop/installer/cli.nsh b/apps/desktop/installer/cli.nsh new file mode 100644 index 0000000000..629ff4d3c4 --- /dev/null +++ b/apps/desktop/installer/cli.nsh @@ -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 diff --git a/apps/desktop/installer/lifecycle.nsh b/apps/desktop/installer/lifecycle.nsh index 525fde4080..93280a4172 100644 --- a/apps/desktop/installer/lifecycle.nsh +++ b/apps/desktop/installer/lifecycle.nsh @@ -51,6 +51,7 @@ Function InstallerBeforeInstall Quit ${EndIf} Call InstallerCheckAppRunning + Call PrepareCliUpdate FunctionEnd Function InstallerProgressShow diff --git a/apps/desktop/installer/pages.nsh b/apps/desktop/installer/pages.nsh index 13ce171cc3..78ffe08006 100644 --- a/apps/desktop/installer/pages.nsh +++ b/apps/desktop/installer/pages.nsh @@ -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 != "" diff --git a/apps/desktop/installer/strings.nsh b/apps/desktop/installer/strings.nsh index 174ebfa28b..29ef9e9993 100644 --- a/apps/desktop/installer/strings.nsh +++ b/apps/desktop/installer/strings.nsh @@ -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 完成待安装的更新。" diff --git a/apps/desktop/scripts/command-installer.ps1 b/apps/desktop/scripts/command-installer.ps1 new file mode 100644 index 0000000000..c6a4d11c6c --- /dev/null +++ b/apps/desktop/scripts/command-installer.ps1 @@ -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 +} diff --git a/apps/desktop/scripts/command-path.ps1 b/apps/desktop/scripts/command-path.ps1 new file mode 100644 index 0000000000..d8c9141318 --- /dev/null +++ b/apps/desktop/scripts/command-path.ps1 @@ -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 + } +} diff --git a/apps/desktop/scripts/command-update.ps1 b/apps/desktop/scripts/command-update.ps1 new file mode 100644 index 0000000000..1dc2d93c9c --- /dev/null +++ b/apps/desktop/scripts/command-update.ps1 @@ -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() + } +} diff --git a/apps/desktop/scripts/installer.nsh b/apps/desktop/scripts/installer.nsh index 0f4d859e78..b8a00e7ec3 100644 --- a/apps/desktop/scripts/installer.nsh +++ b/apps/desktop/scripts/installer.nsh @@ -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 diff --git a/apps/desktop/scripts/prepare-runtime.ts b/apps/desktop/scripts/prepare-runtime.ts index c25167f1af..5129e5740a 100644 --- a/apps/desktop/scripts/prepare-runtime.ts +++ b/apps/desktop/scripts/prepare-runtime.ts @@ -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 { }, 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'] })) } diff --git a/apps/desktop/src/command-installation.ts b/apps/desktop/src/command-installation.ts new file mode 100644 index 0000000000..eebca178e8 --- /dev/null +++ b/apps/desktop/src/command-installation.ts @@ -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 { + 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 { + 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 + 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 + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }) +} diff --git a/apps/desktop/src/command-management.ts b/apps/desktop/src/command-management.ts new file mode 100644 index 0000000000..eb06d0067b --- /dev/null +++ b/apps/desktop/src/command-management.ts @@ -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 +} + +class CommandWorkerError extends Error { + constructor(readonly code: string, message: string) { super(message) } +} + +function object(value: unknown): value is Record { + 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 | undefined + + /** @param options - Installed resources and shell-owned UI callbacks. */ + constructor(private readonly options: CommandManagerOptions) {} + + /** Open or join the command-management operation. */ + show(): Promise { + return this.operation ??= this.run().finally(() => { this.operation = undefined }) + } + + /** Wait for a command operation already started by the user. */ + async idle(): Promise { await this.operation } + + private async worker(operation: string, expected?: string, elevated = false): Promise { + 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 { + 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 { + 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], + }) + } + } +} diff --git a/apps/desktop/src/command-manager-entry.ts b/apps/desktop/src/command-manager-entry.ts new file mode 100644 index 0000000000..1335e6e4e8 --- /dev/null +++ b/apps/desktop/src/command-manager-entry.ts @@ -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((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 +} diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index 066418e6bb..15de34bb43 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -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: '关闭页面或窗口', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 132e2be65e..d543045dfa 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -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 { 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 { }, 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 { : { 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 }, diff --git a/apps/desktop/tests/command-installation.spec.ts b/apps/desktop/tests/command-installation.spec.ts new file mode 100644 index 0000000000..51dbde2a75 --- /dev/null +++ b/apps/desktop/tests/command-installation.spec.ts @@ -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) + }) +}) diff --git a/apps/desktop/tests/command-management.spec.ts b/apps/desktop/tests/command-management.spec.ts new file mode 100644 index 0000000000..801686083d --- /dev/null +++ b/apps/desktop/tests/command-management.spec.ts @@ -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) +}) diff --git a/apps/desktop/tests/command-manager-flow.spec.ts b/apps/desktop/tests/command-manager-flow.spec.ts new file mode 100644 index 0000000000..7d9caa8feb --- /dev/null +++ b/apps/desktop/tests/command-manager-flow.spec.ts @@ -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() + 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>() + const manager = new DesktopCommandManager({ + resources, isPackaged: true, isInstalledLocation: () => true, isInstalling: () => false, isQuitting: () => false, + messages: () => en, show, + }) + const calls = async (): Promise> => + (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() + const choice = Promise.withResolvers() + 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' }]) +}) diff --git a/apps/desktop/tests/command-path.spec.ts b/apps/desktop/tests/command-path.spec.ts new file mode 100644 index 0000000000..0b6612fdd0 --- /dev/null +++ b/apps/desktop/tests/command-path.spec.ts @@ -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) diff --git a/apps/desktop/tests/fixtures/command-path.ps1 b/apps/desktop/tests/fixtures/command-path.ps1 new file mode 100644 index 0000000000..04d8ff169b --- /dev/null +++ b/apps/desktop/tests/fixtures/command-path.ps1 @@ -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) +} diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index b33d7917ac..a8efc33de3 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -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() }) diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts index 6856126691..d8470bac7b 100644 --- a/apps/desktop/tsdown.config.ts +++ b/apps/desktop/tsdown.config.ts @@ -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)],