feat(desktop): manage the installed dsh command from native menus
This commit is contained in:
1 parent
ae4f3546ae
commit
9c8f5c2bf3
21 files changed
+1263
-6
No files matched your search
@@ -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
|
||||
@@ -51,6 +51,7 @@ Function InstallerBeforeInstall
|
||||
Quit
|
||||
${EndIf}
|
||||
Call InstallerCheckAppRunning
|
||||
Call PrepareCliUpdate
|
||||
FunctionEnd
|
||||
|
||||
Function InstallerProgressShow
|
||||
|
||||
@@ -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 != ""
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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'] }))
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: '关闭页面或窗口',
|
||||
|
||||
@@ -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' }])
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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)],
|
||||
|
||||
Reference in new issue
Block a user