diff --git a/.agents/notes/implemented/feature/2026-09-27-desktop-cli-runtime.i18n.yaml b/.agents/notes/implemented/feature/2026-09-27-desktop-cli-runtime.i18n.yaml index 9814e711b4..5996a60f2a 100644 --- a/.agents/notes/implemented/feature/2026-09-27-desktop-cli-runtime.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-27-desktop-cli-runtime.i18n.yaml @@ -15,8 +15,8 @@ en: 1dfe287934c9f4bc zh: 138f226ec7409a1f /agent-note-desktop-installed-cli-runtime/command-registration: - en: 69cf21c9c8d48c25 - zh: 1f544931ccd113cb + en: ff5056a9366d31d8 + zh: 081bd3736f9c0d6e /agent-note-desktop-installed-cli-runtime/alternatives-considered: en: 91974c10416cf0fd zh: fe26c0b4f5f37e98 diff --git a/.agents/notes/implemented/feature/2026-09-27-desktop-cli-runtime.md b/.agents/notes/implemented/feature/2026-09-27-desktop-cli-runtime.md index 0db2ed0b09..0ba4184b5a 100644 --- a/.agents/notes/implemented/feature/2026-09-27-desktop-cli-runtime.md +++ b/.agents/notes/implemented/feature/2026-09-27-desktop-cli-runtime.md @@ -24,9 +24,9 @@ The command's runtime version follows Desktop. The [Electron runtime decision](. ## Command registration -The permanent **Manage dsh Command…** menu displays status and installation, repair, and removal actions. macOS uses a fixed `/usr/local/bin/dsh` link, with administrator authentication when needed, instead of editing shell startup files. Windows registers a dedicated directory containing only the public launcher in the current user's PATH and exposes the same operation through an optional installer checkbox. Private controllers are outside that PATH directory. +The permanent **Manage dsh Command…** menu displays status and installation, repair, and removal actions. macOS uses a fixed `/usr/local/bin/dsh` link, with administrator authentication when needed, instead of editing shell startup files. Windows registers a dedicated directory containing only the public launcher in the current user's PATH and exposes the same operation through an optional installer checkbox. Private installation helpers are outside that PATH directory. -Switching an existing command requires confirmation. The executor rechecks the displayed fingerprint and carries that exact command entry through withdrawal; a concurrent replacement is preserved. A receipt retains the previous macOS launcher. Windows inserts one owned PATH occurrence and records the count of identical pre-existing occurrences so removal preserves them. Other command locations are reported without being modified. Concurrent dialogs share one operation, and update preparation waits for it to finish. +Switching an existing command requires confirmation. The executor rechecks the displayed fingerprint and carries that exact command entry through withdrawal; a concurrent replacement is preserved. A receipt retains the previous macOS launcher. If another installation replaces the Desktop link before reinstallation, any older backup is preserved and reported; Remove restores only the current receipt’s backup. Windows inserts one owned PATH occurrence and records the count of identical pre-existing occurrences so removal preserves them. Other command locations are reported without being modified. Concurrent dialogs share one operation, and update preparation waits for it to finish. The macOS worker reports operation failures in a JSON response with a successful process exit so AppleScript retains its error code and message. Cancelling administrator authentication ends the operation without an error dialog. Windows treats invalid PATH entries as unavailable candidates and preserves their text; environment-change notifications do not change the result of a committed registry write. diff --git a/.agents/notes/implemented/feature/2026-09-27-desktop-cli-runtime.zh.md b/.agents/notes/implemented/feature/2026-09-27-desktop-cli-runtime.zh.md index 5f85fa81b9..316d0f14bd 100644 --- a/.agents/notes/implemented/feature/2026-09-27-desktop-cli-runtime.zh.md +++ b/.agents/notes/implemented/feature/2026-09-27-desktop-cli-runtime.zh.md @@ -24,9 +24,9 @@ Windows 通过 Koffi 注册控制台回调,将 SIGINT 和 SIGBREAK 送到 CLI ## 命令注册 -固定的**管理 dsh 命令…**菜单显示状态,并提供安装、修复和移除操作。macOS 使用固定的 `/usr/local/bin/dsh` 链接,必要时请求管理员认证,不修改 shell 启动文件。Windows 将专门存放公开启动器的目录注册到当前用户的 PATH,并通过可选的安装程序复选框提供相同操作。私有控制器不在这个 PATH 目录中。 +固定的**管理 dsh 命令…**菜单显示状态,并提供安装、修复和移除操作。macOS 使用固定的 `/usr/local/bin/dsh` 链接,必要时请求管理员认证,不修改 shell 启动文件。Windows 将专门存放公开启动器的目录注册到当前用户的 PATH,并通过可选的安装程序复选框提供相同操作。私有安装辅助程序不在这个 PATH 目录中。 -切换已有命令需要确认。执行器重新核对展示过的指纹,并在移走命令时一直使用该次观察到的条目;并发替换的命令会被保留。所有权记录保存之前的 macOS 启动器。Windows 插入一个自有 PATH 条目,并记录此前完全相同条目的数量,移除时保留它们。其他命令位置只展示,不修改。并发对话框共享一个操作,更新准备会等待该操作完成。 +切换已有命令需要确认。执行器重新核对展示过的指纹,并在移走命令时一直使用该次观察到的条目;并发替换的命令会被保留。所有权记录保存之前的 macOS 启动器。若其他安装在重新安装前替换了 Desktop 链接,旧备份会保留并报告;移除操作只恢复当前所有权记录中的备份。Windows 插入一个自有 PATH 条目,并记录此前完全相同条目的数量,移除时保留它们。其他命令位置只展示,不修改。并发对话框共享一个操作,更新准备会等待该操作完成。 macOS worker 以 JSON 响应报告操作失败,同时以成功状态退出进程,使 AppleScript 保留错误码和消息。取消管理员认证会结束操作,不显示错误对话框。Windows 将无效 PATH 条目视为不可用候选并保留其文本;环境变更通知不会改变已经提交的注册表写入结果。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index dae5658850..87e0b5d19a 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -6,8 +6,8 @@ en: eccac345d51428fa zh: 4a448b3d63cb8117 /deepseek-harness-desktop/terminal-command: - en: b3ad2dcf73235d65 - zh: bdd245117e922681 + en: e9cf74411cb3565b + zh: 4dfe1636ece66b87 /deepseek-harness-desktop/closing-the-window-and-quitting: en: c323e02e3f0305fe zh: 3c730db632136443 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index c4fecffc1f..b3fd616f23 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -28,7 +28,7 @@ The application menu's **Manage dsh Command…** entry, immediately below **Chec On macOS, installation creates `/usr/local/bin/dsh` and requests administrator authentication when the directory requires it. Shell startup files are unchanged. On Windows, the installer offers an optional command checkbox, and the management dialog uses the same current-user PATH registration. A pre-existing command requires confirmation before switching; repairing Desktop's own selected command does not repeat that confirmation. The macOS link preserves and restores the displaced launcher; Windows preserves other PATH entries, including entries that predate registration. A command with higher PATH precedence is reported with its location. After moving the application, use Repair on macOS or Install from the new location on Windows. Remove leaves unrelated installations intact. -Desktop keeps downloaded updates while a bundled CLI command is running. Finish those commands and retry installation. New commands are blocked during installer handoff; Windows applies the same protection to direct installation and uninstallation. The CLI runtime version follows the installed Desktop release. See the [runtime decision](../../.agents/notes/implemented/feature/2026-09-27-desktop-cli-runtime.md) for runtime compatibility and update ownership. +Finish CLI commands before updating or uninstalling Desktop. The CLI runtime version follows the installed Desktop release. See [bundled command runtime](#bundled-command-runtime) for Desktop plugin commands and runtime limitations. ## Closing the window and quitting diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 7e8a2dfa51..23e216082d 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -28,7 +28,7 @@ Desktop Host 的 Platform API 请求与更新策略请求用相同的 Platform macOS 安装会创建 `/usr/local/bin/dsh`;目录权限需要时,系统会请求管理员认证,不会修改 shell 启动文件。Windows 安装程序提供可选的命令复选框,管理对话框使用相同的当前用户 PATH 注册机制。切换已有命令前会要求确认;修复当前已选中的 Desktop 命令不会重复要求切换确认。macOS 链接会保留并恢复被替换的启动器;Windows 会保留其他 PATH 条目,包括注册前就已存在的条目。若其他命令的 PATH 优先级更高,对话框会显示其位置。移动应用后,macOS 使用“修复”,Windows 从新位置使用“安装”。“移除”不会改动无关安装。 -内置 CLI 命令运行期间,Desktop 会保留已下载的更新。请结束这些命令后重试安装。安装交接期间阻止新命令启动;Windows 直接安装和卸载也受到相同保护。CLI 运行时版本随已安装的 Desktop 版本变化。[运行时决策](../../.agents/notes/implemented/feature/2026-09-27-desktop-cli-runtime.zh.md)说明运行时兼容性与更新所有权。 +更新或卸载 Desktop 前请结束 CLI 命令。CLI 运行时版本随已安装的 Desktop 版本变化。Desktop 插件命令与运行时限制见[内置命令运行时](#bundled-command-runtime)。 ## 关闭窗口与退出 diff --git a/apps/desktop/cli/link-entry.c b/apps/desktop/cli/link-entry.c new file mode 100644 index 0000000000..1872592328 --- /dev/null +++ b/apps/desktop/cli/link-entry.c @@ -0,0 +1,18 @@ +// Preserve a command symlink's inode while refusing an occupied destination. +#include +#include +#include +#include +#include + +int main(int count, char **arguments) { + if (count != 3 || arguments[1][0] != '/' || arguments[2][0] != '/') { + fprintf(stderr, "Command entry paths must be absolute.\n"); + return 1; + } + if (linkat(AT_FDCWD, arguments[1], AT_FDCWD, arguments[2], 0) != 0) { + fprintf(stderr, "Cannot link command entry: %s\n", strerror(errno)); + return 1; + } + return 0; +} diff --git a/apps/desktop/installer/cli.nsh b/apps/desktop/installer/cli.nsh deleted file mode 100644 index 7e42971c3f..0000000000 --- a/apps/desktop/installer/cli.nsh +++ /dev/null @@ -1,68 +0,0 @@ -; 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 "" - !macro dshPrepareCliUpdate - Call PrepareCliUpdate - !macroend - !macro dshFinishCliUpdate - Call FinishCliUpdate - !macroend -!endif - -Function ${DSH_CLI_PREFIX}PrepareCliUpdate - ${If} $InstallerCliToken != "" - Return - ${EndIf} - 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 - Call ${DSH_CLI_PREFIX}FinishCliUpdate - SetErrorLevel 2 - Quit - ${EndIf} -FunctionEnd - -Function ${DSH_CLI_PREFIX}FinishCliUpdate - Push $0 - Push $1 - ${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 - StrCpy $InstallerCliToken "" - ${EndIf} - Pop $1 - Pop $0 -FunctionEnd - -!ifdef BUILD_UNINSTALLER -Function un.onGUIEnd - Call un.FinishCliUpdate -FunctionEnd - -Function un.onUninstSuccess - Call un.FinishCliUpdate -FunctionEnd - -Function un.onUninstFailed - Call un.FinishCliUpdate -FunctionEnd -!endif diff --git a/apps/desktop/installer/drawing.nsh b/apps/desktop/installer/drawing.nsh index 926e56bc97..173e7fc421 100644 --- a/apps/desktop/installer/drawing.nsh +++ b/apps/desktop/installer/drawing.nsh @@ -109,7 +109,8 @@ Function InstallerPaintCheckbox System::Call 'gdi32::SetBkMode(p R4, i 1)' System::Call 'gdi32::SetTextColor(p R4, i $InstallerTextColorref)' System::Call 'gdi32::SelectObject(p R4, p $InstallerSmallFont)' - System::Call 'user32::DrawTextW(p R4, w "$(INSTALLER_LAUNCH)", i -1, p R2, i 0x24)' + ${NSD_GetText} $R0 $R3 + System::Call 'user32::DrawTextW(p R4, w R3, i -1, p R2, i 0x24)' IntOp $R9 $R9 & 16 ${If} $R9 != 0 System::Call 'user32::DrawFocusRect(p R4, p R2)' diff --git a/apps/desktop/installer/strings.nsh b/apps/desktop/installer/strings.nsh index 29ef9e9993..bb4af6ef18 100644 --- a/apps/desktop/installer/strings.nsh +++ b/apps/desktop/installer/strings.nsh @@ -59,5 +59,3 @@ 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 index c6a4d11c6c..11ef10016a 100644 --- a/apps/desktop/scripts/command-installer.ps1 +++ b/apps/desktop/scripts/command-installer.ps1 @@ -17,7 +17,7 @@ try { $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) + [string]::Equals($state.activeCommand, (Join-Path $state.ownedDirectory 'dsh.cmd'), [StringComparison]::OrdinalIgnoreCase) if ($Operation -eq 'install' -and $state.activeCommand -and -not $previousDesktop) { if ($Silent) { exit 2 } Add-Type -AssemblyName System.Windows.Forms @@ -31,7 +31,7 @@ try { $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)) { + -not [string]::Equals($result.activeCommand, (Join-Path $Directory 'dsh.cmd'), [StringComparison]::OrdinalIgnoreCase)) { Add-Type -AssemblyName System.Windows.Forms [void][Windows.Forms.MessageBox]::Show($messages.cliCommandShadowed, $messages.cliCommandTitle) } diff --git a/apps/desktop/scripts/command-path.ps1 b/apps/desktop/scripts/command-path.ps1 index 55fe48f378..4e1da931a3 100644 --- a/apps/desktop/scripts/command-path.ps1 +++ b/apps/desktop/scripts/command-path.ps1 @@ -58,7 +58,7 @@ function Invoke-DshCommandPath { } } if ($Request.operation -eq 'install') { - if (-not (Test-Path -LiteralPath (Join-Path $directory 'dsh.exe') -PathType Leaf)) { Fail 'ENOENT' 'The installed launcher is unavailable.' } + if (-not (Test-Path -LiteralPath (Join-Path $directory 'dsh.cmd') -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) @@ -106,7 +106,7 @@ function Invoke-DshCommandPath { 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)) } + 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.cmd') -PathType Leaf)) } } finally { if ($locked) { $mutex.ReleaseMutex() } $mutex.Dispose() diff --git a/apps/desktop/scripts/command-update.ps1 b/apps/desktop/scripts/command-update.ps1 deleted file mode 100644 index 1dc2d93c9c..0000000000 --- a/apps/desktop/scripts/command-update.ps1 +++ /dev/null @@ -1,54 +0,0 @@ -<# 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-directories.nsh b/apps/desktop/scripts/installer-directories.nsh index 183bb7f1ad..a06636b620 100644 --- a/apps/desktop/scripts/installer-directories.nsh +++ b/apps/desktop/scripts/installer-directories.nsh @@ -28,9 +28,6 @@ Var dshNewMoved !macroend !macro dshStageApplication - !ifmacrodef dshPrepareCliUpdate - !insertmacro dshPrepareCliUpdate - !endif StrCpy $dshFinalDirectory $INSTDIR System::Call 'ole32::CoCreateGuid(g .r0) i .r1' ${If} $1 != 0 @@ -72,9 +69,6 @@ Function dshCleanupDirectories ${If} $dshFinalDirectory != "" Call dshRollbackDirectories ${EndIf} - !ifmacrodef dshFinishCliUpdate - !insertmacro dshFinishCliUpdate - !endif FunctionEnd ; Only directories created or renamed by this installer are removed during rollback. @@ -140,7 +134,4 @@ FunctionEnd RMDir /r "\\?\$dshOldDirectory" StrCpy $dshOldMoved "" ${EndIf} - !ifmacrodef dshFinishCliUpdate - !insertmacro dshFinishCliUpdate - !endif !macroend diff --git a/apps/desktop/scripts/installer.nsh b/apps/desktop/scripts/installer.nsh index b8a00e7ec3..ec50b6e468 100644 --- a/apps/desktop/scripts/installer.nsh +++ b/apps/desktop/scripts/installer.nsh @@ -11,7 +11,6 @@ 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 @@ -94,8 +93,6 @@ ManifestDPIAware true !macroend !macro customUnInstall - !insertmacro customCheckAppRunning - Call un.PrepareCliUpdate Push $0 Push $1 ${IfNot} ${isUpdated} diff --git a/apps/desktop/scripts/prepare-command-link.ts b/apps/desktop/scripts/prepare-command-link.ts new file mode 100644 index 0000000000..0ee9ed2056 --- /dev/null +++ b/apps/desktop/scripts/prepare-command-link.ts @@ -0,0 +1,21 @@ +/** Build the macOS helper for exact-path command symlink installation. */ + +import { execFileSync } from 'node:child_process' +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' + +/** + * Compile the command-link helper before signing Desktop resources. + * @param destination - Physical runtime/cli directory. + * @param arch - Desktop architecture, independent of the build host architecture. + * @param minimumVersion - Minimum macOS version declared by Electron. + */ +export function prepareCommandLink(destination: string, arch: 'arm64' | 'x64', minimumVersion: string): void { + if (process.platform !== 'darwin' || !/^\d+\.\d+(?:\.\d+)?$/u.test(minimumVersion)) { + throw new Error('desktop command link: a macOS host and Electron minimum version are required') + } + mkdirSync(destination, { recursive: true }) + execFileSync('clang', ['-std=c11', '-O2', '-Wall', '-Wextra', '-Werror', + '-arch', arch === 'arm64' ? 'arm64' : 'x86_64', '-mmacosx-version-min=' + minimumVersion, + join(import.meta.dirname, '..', 'cli', 'link-entry.c'), '-o', join(destination, 'link-entry')], { stdio: 'inherit' }) +} diff --git a/apps/desktop/scripts/prepare-runtime.ts b/apps/desktop/scripts/prepare-runtime.ts index 8c548863e0..947d577c7b 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 { prepareCommandLink } from './prepare-command-link.ts' import { en, zh } from '../src/locale.ts' const BUILD_PATHS = resolveDesktopTargetBuildPaths() @@ -43,6 +44,8 @@ async function main(): Promise { const nodeVersion = execFileSync(executable, ['-p', 'process.versions.node'], { encoding: 'utf8', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, }).trim() + const macosMinimumVersion = platform === 'darwin' ? execFileSync('/usr/libexec/PlistBuddy', + ['-c', 'Print LSMinimumSystemVersion', join(BUILD_PATHS.electron, 'Electron.app', 'Contents', 'Info.plist')], { encoding: 'utf8' }).trim() : undefined rmSync(RUNTIME_ROOT, { recursive: true, force: true }) mkdirSync(RUNTIME_ROOT, { recursive: true }) const pnpmVersion = preparePnpm() @@ -55,6 +58,7 @@ 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)) + if (macosMinimumVersion !== undefined) prepareCommandLink(join(RUNTIME_ROOT, 'cli'), arch, 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')) diff --git a/apps/desktop/src/command-installation.ts b/apps/desktop/src/command-installation.ts index 65b198d6c2..3e181ae984 100644 --- a/apps/desktop/src/command-installation.ts +++ b/apps/desktop/src/command-installation.ts @@ -12,7 +12,7 @@ import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' export interface FileCommandInstallation { readonly destination: string readonly launcher: string - readonly control: string + readonly linkHelper: string } type Entry = { readonly kind: 'symlink'; readonly fingerprint: string; readonly target: string } @@ -158,7 +158,7 @@ async function restoreEntry(options: FileCommandInstallation, source: string, de async function linkEntry(options: FileCommandInstallation, source: string, destination: string): Promise { // macOS Node link() follows symlinks; linkat preserves their identity and refuses occupied destinations. - if (process.platform === 'darwin') await promisify(execFile)(options.control, ['link-entry', source, destination]) + if (process.platform === 'darwin') await promisify(execFile)(options.linkHelper, [source, destination]) else await link(source, destination) } diff --git a/apps/desktop/src/command-management.ts b/apps/desktop/src/command-management.ts index 248dc3931b..34c7848dfb 100644 --- a/apps/desktop/src/command-management.ts +++ b/apps/desktop/src/command-management.ts @@ -55,7 +55,7 @@ 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 + ? join(value.directory, 'dsh.cmd') : 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.') diff --git a/apps/desktop/src/command-manager-entry.ts b/apps/desktop/src/command-manager-entry.ts index 2ddaa54802..edfd61c217 100644 --- a/apps/desktop/src/command-manager-entry.ts +++ b/apps/desktop/src/command-manager-entry.ts @@ -16,7 +16,7 @@ try { 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'), - control: join(resources, 'runtime', 'cli', 'cli-control') } + linkHelper: join(resources, 'runtime', 'cli', 'link-entry') } 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') diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index 15de34bb43..5d9d4607c7 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -28,8 +28,6 @@ export const en = { 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', @@ -201,8 +199,6 @@ export const zh = { cliCommandChanged: '对话框打开期间命令或 PATH 已改变。请重新打开“管理 dsh 命令”检查当前状态。', cliCommandOwnershipError: '命令注册或备份已改变,未移除无关命令。', cliCommandFailed: '无法更新命令。请检查应用是否已安装、目标位置是否可写,然后重试。', - cliCommandBusyTitle: 'dsh 命令正在运行', - cliCommandBusyDetail: '请结束运行中的 dsh 命令后重试更新。已下载的更新会保留。', application: '应用', fileMenu: '文件', closePage: '关闭页面或窗口', diff --git a/apps/desktop/tests/command-installation.spec.ts b/apps/desktop/tests/command-installation.spec.ts index b759d9348f..e81cff7027 100644 --- a/apps/desktop/tests/command-installation.spec.ts +++ b/apps/desktop/tests/command-installation.spec.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterAll, beforeAll, describe, expect, it, onTestFinished, vi } from 'vitest' import { inspectFileCommand, installFileCommand, removeFileCommand } from '../src/command-installation.ts' -import { prepareDesktopCli } from '../scripts/prepare-cli.ts' +import { prepareCommandLink } from '../scripts/prepare-command-link.ts' const barrier = vi.hoisted(() => ({ afterRead: undefined as ((path: unknown) => Promise) | undefined, @@ -43,14 +43,13 @@ async function fixture() { 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, control: join(compiled, 'cli-control') } } + return { root, options: { destination: join(root, 'dsh'), launcher, linkHelper: join(compiled, 'link-entry') } } } describe.skipIf(process.platform === 'win32')('macOS command entry ownership', () => { beforeAll(async () => { compiled = await mkdtemp(join(tmpdir(), 'dsh-command-control-')) - prepareDesktopCli(compiled, { platform: process.platform, arch: process.arch, - ...process.platform === 'darwin' ? { macosMinimumVersion: '13.0' } : {} }) + if (process.platform === 'darwin') prepareCommandLink(compiled, process.arch === 'arm64' ? 'arm64' : 'x64', '13.0') }) afterAll(async () => { if (compiled !== undefined) await rm(compiled, { recursive: true, force: true }) }) it('installs and removes only its own link', async () => { diff --git a/apps/desktop/tests/command-management.spec.ts b/apps/desktop/tests/command-management.spec.ts index fd9f888d03..bef9121d23 100644 --- a/apps/desktop/tests/command-management.spec.ts +++ b/apps/desktop/tests/command-management.spec.ts @@ -23,8 +23,8 @@ it.each([en, zh])('shows installation, repair/removal and shadowed command locat it.each([ ['darwin', 'en-US', en], ['darwin', 'zh-CN', zh], ['win32', 'en-US', en], ['win32', 'zh-CN', zh], -] as const)('records command status and update guidance on %s in %s', async (platform, language, messages) => { - const destination = platform === 'darwin' ? '/usr/local/bin/dsh' : 'C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.exe' +] as const)('records command status on %s in %s', async (platform, language, messages) => { + const destination = platform === 'darwin' ? '/usr/local/bin/dsh' : 'C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd' const other = platform === 'darwin' ? '/opt/homebrew/bin/dsh' : 'C:\\Program Files\\nodejs\\dsh.cmd' const states = [ { managed: false, available: false, destination }, @@ -35,6 +35,5 @@ it.each([ ] await expect(JSON.stringify({ dialogs: states.map(state => presentCommandManagement(state, messages, platform)), - runningCommands: { title: messages.cliCommandBusyTitle, detail: messages.cliCommandBusyDetail }, }, null, 2) + '\n').toMatchFileSnapshot(`./expected/command-management-${platform}-${language}.json`) }) diff --git a/apps/desktop/tests/command-manager-flow.spec.ts b/apps/desktop/tests/command-manager-flow.spec.ts index f8efd7dd2d..0624ccf196 100644 --- a/apps/desktop/tests/command-manager-flow.spec.ts +++ b/apps/desktop/tests/command-manager-flow.spec.ts @@ -52,9 +52,9 @@ async function fixture(options: { await writeFile(join(worker, 'package.json'), '{"type":"module"}\n') const state = { fingerprint: 'a'.repeat(64), managed: options.managed ?? false, available: true, kind: options.managed ? 'symlink' : 'file', - destination: join(root, process.platform === 'win32' ? 'dsh.exe' : 'dsh'), + destination: join(root, process.platform === 'win32' ? 'dsh.cmd' : 'dsh'), directory: root, launcher: join(root, 'desktop-dsh'), - activeCommand: options.managed && !options.shadowed ? join(root, process.platform === 'win32' ? 'dsh.exe' : 'dsh') : join(root, 'other-dsh'), + activeCommand: options.managed && !options.shadowed ? join(root, process.platform === 'win32' ? 'dsh.cmd' : 'dsh') : join(root, 'other-dsh'), } if (process.platform === 'darwin') { const shell = join(root, 'lookup-shell') diff --git a/apps/desktop/tests/expected/command-management-darwin-en-US.json b/apps/desktop/tests/expected/command-management-darwin-en-US.json index 25678d2943..02f12214f5 100644 --- a/apps/desktop/tests/expected/command-management-darwin-en-US.json +++ b/apps/desktop/tests/expected/command-management-darwin-en-US.json @@ -63,9 +63,5 @@ "defaultId": 0, "cancelId": 1 } - ], - "runningCommands": { - "title": "dsh Commands Are Running", - "detail": "Finish running dsh commands, then retry the update. The downloaded update will be kept." - } + ] } diff --git a/apps/desktop/tests/expected/command-management-darwin-zh-CN.json b/apps/desktop/tests/expected/command-management-darwin-zh-CN.json index 6c1cd5939c..9b22c7213a 100644 --- a/apps/desktop/tests/expected/command-management-darwin-zh-CN.json +++ b/apps/desktop/tests/expected/command-management-darwin-zh-CN.json @@ -63,9 +63,5 @@ "defaultId": 0, "cancelId": 1 } - ], - "runningCommands": { - "title": "dsh 命令正在运行", - "detail": "请结束运行中的 dsh 命令后重试更新。已下载的更新会保留。" - } + ] } diff --git a/apps/desktop/tests/expected/command-management-win32-en-US.json b/apps/desktop/tests/expected/command-management-win32-en-US.json index 7334ebed06..34ee59a94c 100644 --- a/apps/desktop/tests/expected/command-management-win32-en-US.json +++ b/apps/desktop/tests/expected/command-management-win32-en-US.json @@ -4,7 +4,7 @@ "type": "info", "title": "Manage dsh Command", "message": "Add the Desktop command to your terminal.", - "detail": "Desktop command: C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.exe", + "detail": "Desktop command: C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd", "buttons": [ "Install", "Cancel" @@ -16,7 +16,7 @@ "type": "info", "title": "Manage dsh Command", "message": "The Desktop command is installed.", - "detail": "Desktop command: C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.exe\n\nCurrent dsh command: C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.exe", + "detail": "Desktop command: C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd\n\nCurrent dsh command: C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd", "buttons": [ "Close", "Repair", @@ -29,7 +29,7 @@ "type": "info", "title": "Manage dsh Command", "message": "The Desktop command needs repair.", - "detail": "Desktop command: C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.exe", + "detail": "Desktop command: C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd", "buttons": [ "Close", "Repair", @@ -42,7 +42,7 @@ "type": "warning", "title": "Manage dsh Command", "message": "The Desktop command is installed.", - "detail": "Desktop command: C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.exe\n\nCurrent dsh command: C:\\Program Files\\nodejs\\dsh.cmd\n\nAnother 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.", + "detail": "Desktop command: C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd\n\nCurrent dsh command: C:\\Program Files\\nodejs\\dsh.cmd\n\nAnother 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.", "buttons": [ "Close", "Repair", @@ -55,7 +55,7 @@ "type": "info", "title": "Manage dsh Command", "message": "Add the Desktop command to your terminal.", - "detail": "Desktop command: C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.exe\n\nYour shell command could not be verified. An alias or another dsh installation may take precedence.", + "detail": "Desktop command: C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd\n\nYour shell command could not be verified. An alias or another dsh installation may take precedence.", "buttons": [ "Install", "Cancel" @@ -63,9 +63,5 @@ "defaultId": 0, "cancelId": 1 } - ], - "runningCommands": { - "title": "dsh Commands Are Running", - "detail": "Finish running dsh commands, then retry the update. The downloaded update will be kept." - } + ] } diff --git a/apps/desktop/tests/expected/command-management-win32-zh-CN.json b/apps/desktop/tests/expected/command-management-win32-zh-CN.json index e5bcb5debb..c124453ad6 100644 --- a/apps/desktop/tests/expected/command-management-win32-zh-CN.json +++ b/apps/desktop/tests/expected/command-management-win32-zh-CN.json @@ -4,7 +4,7 @@ "type": "info", "title": "管理 dsh 命令", "message": "将 Desktop 命令添加到终端。", - "detail": "Desktop 命令:C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.exe", + "detail": "Desktop 命令:C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd", "buttons": [ "安装", "取消" @@ -16,7 +16,7 @@ "type": "info", "title": "管理 dsh 命令", "message": "Desktop 命令已安装。", - "detail": "Desktop 命令:C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.exe\n\n当前 dsh 命令:C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.exe", + "detail": "Desktop 命令:C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd\n\n当前 dsh 命令:C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd", "buttons": [ "关闭", "修复", @@ -29,7 +29,7 @@ "type": "info", "title": "管理 dsh 命令", "message": "Desktop 命令需要修复。", - "detail": "Desktop 命令:C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.exe", + "detail": "Desktop 命令:C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd", "buttons": [ "关闭", "修复", @@ -42,7 +42,7 @@ "type": "warning", "title": "管理 dsh 命令", "message": "Desktop 命令已安装。", - "detail": "Desktop 命令:C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.exe\n\n当前 dsh 命令:C:\\Program Files\\nodejs\\dsh.cmd\n\n另一个 dsh 的优先级更高。请移除或调整该安装的顺序,以默认使用 Desktop 命令;也可以通过完整路径运行 Desktop 命令。", + "detail": "Desktop 命令:C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd\n\n当前 dsh 命令:C:\\Program Files\\nodejs\\dsh.cmd\n\n另一个 dsh 的优先级更高。请移除或调整该安装的顺序,以默认使用 Desktop 命令;也可以通过完整路径运行 Desktop 命令。", "buttons": [ "关闭", "修复", @@ -55,7 +55,7 @@ "type": "info", "title": "管理 dsh 命令", "message": "将 Desktop 命令添加到终端。", - "detail": "Desktop 命令:C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.exe\n\n无法确认 shell 中的命令。别名或另一个 dsh 安装可能具有更高优先级。", + "detail": "Desktop 命令:C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd\n\n无法确认 shell 中的命令。别名或另一个 dsh 安装可能具有更高优先级。", "buttons": [ "安装", "取消" @@ -63,9 +63,5 @@ "defaultId": 0, "cancelId": 1 } - ], - "runningCommands": { - "title": "dsh 命令正在运行", - "detail": "请结束运行中的 dsh 命令后重试更新。已下载的更新会保留。" - } + ] } diff --git a/apps/desktop/tests/fixtures/command-path.ps1 b/apps/desktop/tests/fixtures/command-path.ps1 index c7697a34f2..51f2d004a5 100644 --- a/apps/desktop/tests/fixtures/command-path.ps1 +++ b/apps/desktop/tests/fixtures/command-path.ps1 @@ -24,8 +24,8 @@ try { $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 $desktop 'dsh.cmd'), 'fixture') + [IO.File]::WriteAllText((Join-Path $other 'dsh.cmd'), '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) }