From 14819e2eb4abed5a6e6752931588763e426faa47 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:08:02 +0800 Subject: [PATCH 1/5] fix(desktop): retry Windows installer directory locks and report failures --- apps/desktop/installer/directory-report.h | 18 ++++ apps/desktop/installer/strings.nsh | 6 ++ apps/desktop/installer/window-frame.cpp | 24 +++++ apps/desktop/packaging/README.i18n.yaml | 4 +- apps/desktop/packaging/README.md | 2 + apps/desktop/packaging/README.zh.md | 2 + .../desktop/scripts/installer-directories.nsh | 94 +++++++++++++++++-- apps/desktop/scripts/installer.nsh | 31 ++++++ .../scripts/smoke-installer-directories.ps1 | 67 +++++++++++-- .../scripts/windows-directory-installer.mjs | 2 +- .../tests/expected/windows-installer.json | 1 + .../fixtures/installer-directory-smoke.nsi | 26 +++++ .../windows-installer-extract-report.cpp | 19 +++- .../desktop/tests/windows-installer-smoke.ps1 | 21 +++++ 14 files changed, 298 insertions(+), 19 deletions(-) create mode 100644 apps/desktop/installer/directory-report.h diff --git a/apps/desktop/installer/directory-report.h b/apps/desktop/installer/directory-report.h new file mode 100644 index 0000000000..6a405ca7dd --- /dev/null +++ b/apps/desktop/installer/directory-report.h @@ -0,0 +1,18 @@ +// Directory replacement failures retain the original Windows error and the independent restoration outcome. +#pragma once +#include "extract-report.h" + +namespace directory_report { + +inline std::wstring Compose(DWORD error, LPCWSTR source, LPCWSTR destination, DWORD restoreError, LPCWSTR backup, const std::wstring& attempts) { + const std::wstring operation = *destination ? L"MoveFileExW" : L"RemoveDirectoryW"; + std::wstring report = L"Operation: " + operation + L"\r\nWindows error: " + std::to_wstring(error) + + L" (" + extract_report::Win32Message(error) + L")\r\nSource: " + source + + L"\r\nDestination: " + destination + L"\r\nRestoration error: " + std::to_wstring(restoreError); + if (restoreError) report += L" (" + extract_report::Win32Message(restoreError) + L")\r\nPreserved backup: " + backup; + report += L"\r\nTime: " + extract_report::Timestamp() + L"\r\nWindows: " + extract_report::WindowsVersion() + + L"\r\nAttempts:\r\n" + attempts; + return report; +} + +} diff --git a/apps/desktop/installer/strings.nsh b/apps/desktop/installer/strings.nsh index 174ebfa28b..a1ac41b418 100644 --- a/apps/desktop/installer/strings.nsh +++ b/apps/desktop/installer/strings.nsh @@ -29,6 +29,12 @@ LangString INSTALLER_FAILED ${LANG_ENGLISH} "Installation did not finish. Close LangString INSTALLER_FAILED ${LANG_SIMPCHINESE} "安装未完成,请关闭安装程序后重试。如仍然失败,请重新下载安装包。" LangString INSTALLER_RUNNING ${LANG_ENGLISH} "DeepSeek Harness is running. Quit it from the system tray and try again." LangString INSTALLER_RUNNING ${LANG_SIMPCHINESE} "DeepSeek Harness 正在运行,请先在系统托盘中退出应用后重试。" +LangString INSTALLER_DIRECTORY_FAILED ${LANG_ENGLISH} "The installation directory could not be replaced." +LangString INSTALLER_DIRECTORY_FAILED ${LANG_SIMPCHINESE} "无法替换安装目录。" +LangString INSTALLER_DIRECTORY_HINT ${LANG_ENGLISH} "Check access to the installation directory and close programs using its files, then run the installer again. Copy the error details if the problem persists." +LangString INSTALLER_DIRECTORY_HINT ${LANG_SIMPCHINESE} "请检查安装目录的访问权限,关闭占用其中内容的程序后重新运行安装器。如仍失败,请复制错误详情。" +LangString INSTALLER_DIRECTORY_RESTORE_HINT ${LANG_ENGLISH} "The previous installation could not be restored. Keep the backup directory shown in the error details and contact support before installing again." +LangString INSTALLER_DIRECTORY_RESTORE_HINT ${LANG_SIMPCHINESE} "未能恢复原安装目录。请保留错误详情中列出的备份目录,联系支持人员后再安装。" LangString INSTALLER_PATH_INVALID ${LANG_ENGLISH} "Choose a full local folder path. Drive roots, system folders, links and special characters are not supported." LangString INSTALLER_PATH_INVALID ${LANG_SIMPCHINESE} "请选择本地磁盘上的完整文件夹路径,不能使用磁盘根目录、系统目录、链接目录或包含特殊字符的路径。" LangString INSTALLER_PATH_OWNERSHIP ${LANG_ENGLISH} "Choose an empty folder or the registered DeepSeek Harness installation folder." diff --git a/apps/desktop/installer/window-frame.cpp b/apps/desktop/installer/window-frame.cpp index 001fe57a29..363a1b5abf 100644 --- a/apps/desktop/installer/window-frame.cpp +++ b/apps/desktop/installer/window-frame.cpp @@ -13,10 +13,34 @@ #include "progress.h" #include "extract.h" #include "extract-report.h" +#include "directory-report.h" #include "uninstall-data.h" using namespace Gdiplus; +// Returns whether the UTF-8 report was saved; failure still presents the original error unless silent. +extern "C" __declspec(dllexport) int __cdecl InstallerReportDirectoryFailure( + HWND parent, DWORD error, LPCWSTR source, LPCWSTR destination, DWORD restoreError, LPCWSTR backup, + LPCWSTR log, LPCWSTR reportPath, int show, LPCWSTR title, LPCWSTR heading, LPCWSTR hint, LPCWSTR copy, + LPCWSTR expand, LPCWSTR collapse, LPCWSTR savedFormat, LPCWSTR unsaved, LPCWSTR copied) { + try { + const std::wstring report = directory_report::Compose(error, source, destination, restoreError, backup, extract_report::ReadOutput(log)); + const bool saved = extract_report::WriteUtf8(reportPath, report); + if (show) { + std::wstring footer = saved ? savedFormat : unsaved; + const auto placeholder = footer.find(L"%s"); + if (saved && placeholder != std::wstring::npos) footer.replace(placeholder, 2, reportPath); + const extract_report::DialogStrings strings{title, heading, copy, expand, collapse, footer.c_str(), copied}; + const std::wstring content = std::to_wstring(error) + L": " + extract_report::Win32Message(error) + L"\r\n\r\n" + hint; + extract_report::Show(parent, content, report, report, strings); + } + return saved ? 1 : 0; + } catch (const std::bad_alloc&) { + if (show) MessageBoxW(parent, hint, heading, MB_OK | MB_ICONEXCLAMATION); + return 0; + } +} + // Saves the extraction failure report and, unless silent, presents its headline with a copy action. // Returns 1 when the report file was written and 0 otherwise; the dialog outcome never fails the caller. extern "C" __declspec(dllexport) int __cdecl InstallerReportExtractFailure( diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 40103064d6..4cb5ab92d2 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -45,8 +45,8 @@ en: b42f103358d0dfd7 zh: a534164ad9eb00da /desktop-packaging/runtime-file-selection-and-installation-checks: - en: 06d5c666a3e7f2e4 - zh: c1d4a55cc29e11e3 + en: 777f4c85bc3325eb + zh: 448f2978876a091e /desktop-packaging/desktop-runtime-preparation: en: a7aa3066e649ffe8 zh: 4334637910058c09 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 9cf46b68e1..bf3ae66d25 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -244,6 +244,8 @@ pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis The Windows installer checks for a running application at startup and after destination selection, before extracting the new version beside the installation directory. It checks again before replacing directories through same-volume renames. A running application blocks installation; update launches allow up to ten seconds for it to exit. Same-path upgrades preserve the old directory until promotion succeeds; extraction failure leaves it intact, and promotion failure attempts to restore it. The installer removes the old backup before launch. Forced termination or power loss can leave `.new-*` or `.old-*` directories; installation-location and scope migrations retain electron-builder's old-uninstaller flow. +Directory renames, empty-destination removal, and restoration retry Windows errors 5, 32, and 33 up to ten attempts, with 250 ms increasing delays (11.25 seconds of total waiting per operation). Other errors stop immediately. A replacement failure reports the Windows error, affected paths, and restoration outcome in `directory-failure-.log` beside extraction reports. If restoration fails, keep the backup path named in the report and contact support before reinstalling. Silent installs save the same diagnostic without a dialog. + When extraction fails, the installer writes a report with the 7-Zip result and its complete error output to the updater cache directory as `%LOCALAPPDATA%\-updater\installer-logs\extract-failure-.log` (currently `@deepseek-aidsh-desktop-updater`) and shows the first error line with a **Copy error details** button; silent installs write only the report. Unsigned Windows builds (`DSH_DESKTOP_UNSIGNED=1`) name their installer `deepseek-harness--win-x64-unsigned.exe` so they cannot be mistaken for release artifacts. Windows packaging fixes the 7-Zip filter to `BCJ` for compatibility with the bundled NSIS decoder. This preserves ARM64 binaries carried by dependencies in x64 installers; automatic ARM64 filtering produces entries that this decoder cannot extract. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 125b480ce5..8d2f88182d 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -244,6 +244,8 @@ pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis Windows 安装器在启动时和选定目标目录后检查应用是否正在运行,通过检查后才将新版本解压到安装目录旁边。通过同卷目录改名替换前,安装器会再次检查。运行中的应用会阻止安装;更新启动允许等待应用退出,最长十秒。同路径升级在替换成功前保留旧目录;解压失败时旧版不变,替换失败时尝试恢复旧目录。安装器在启动前清理旧版备份。强制结束安装器或断电可能留下 `.new-*` 或 `.old-*` 目录;不同安装位置或安装范围迁移仍使用 electron-builder 的旧卸载器流程。 +目录改名、空目标目录删除和旧版恢复遇到 Windows 错误 5、32 或 33 时最多尝试十次,每次等待递增 250 毫秒(每个操作合计等待 11.25 秒)。其他错误立即停止。替换失败时,安装器将 Windows 错误、相关路径和恢复结果写入解压报告旁的 `directory-failure-<时间戳>.log`。如果恢复失败,请保留报告所列的备份目录,并在重新安装前联系支持人员。静默安装保存相同的诊断,不显示弹窗。 + 解压失败时,安装器会把 7-Zip 的结果和完整错误输出写入更新缓存目录 `%LOCALAPPDATA%\<按包名派生>-updater\installer-logs\extract-failure-<时间戳>.log`(当前为 `@deepseek-aidsh-desktop-updater`),并在弹窗中显示首条错误行和 **复制错误信息** 按钮;静默安装只写入报告。未签名的 Windows 构建(`DSH_DESKTOP_UNSIGNED=1`)会将安装包命名为 `deepseek-harness-<版本>-win-x64-unsigned.exe`,以免被误当作发布产物。 Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。 diff --git a/apps/desktop/scripts/installer-directories.nsh b/apps/desktop/scripts/installer-directories.nsh index 49d4a2fb82..f9c42673f2 100644 --- a/apps/desktop/scripts/installer-directories.nsh +++ b/apps/desktop/scripts/installer-directories.nsh @@ -5,6 +5,66 @@ Var dshNewDirectory Var dshOldDirectory Var dshOldMoved Var dshNewMoved +Var dshDirectoryError +Var dshDirectorySource +Var dshDirectoryDestination +Var dshRestoreError +Var dshDirectoryLogDirectory + +; MoveFileEx preserves the destination and exposes the Windows error before rollback changes it. +!macro dshDirectoryOperation SOURCE DESTINATION RESULT + Push $0 + Push $1 + Push $2 + StrCpy $0 0 + ${Do} + ${If} "${DESTINATION}" == "" + System::Call 'kernel32::RemoveDirectoryW(w "${SOURCE}") i.r1 ?e' + ${Else} + System::Call 'kernel32::MoveFileExW(w "${SOURCE}", w "${DESTINATION}", i 0) i.r1 ?e' + ${EndIf} + Pop ${RESULT} + ${If} "${DESTINATION}" == "" + ${If} ${RESULT} == 2 + ${OrIf} ${RESULT} == 3 + StrCpy $1 1 + ${EndIf} + ${EndIf} + ${If} $1 != 0 + StrCpy ${RESULT} 0 + ${ExitDo} + ${EndIf} + ClearErrors + FileOpen $2 "$PLUGINSDIR\directory-operations.log" a + ${IfNot} ${Errors} + FileSeek $2 0 END + FileWrite $2 "Windows error: ${RESULT}$\r$\n" + FileClose $2 + ${EndIf} + ${If} ${RESULT} != 5 + ${AndIf} ${RESULT} != 32 + ${AndIf} ${RESULT} != 33 + ${ExitDo} + ${EndIf} + IntOp $0 $0 + 1 + ${If} $0 >= 10 + ${ExitDo} + ${EndIf} + IntOp $1 $0 * 250 + Sleep $1 + ${Loop} + Pop $2 + Pop $1 + Pop $0 +!macroend + +!macro dshReportDirectoryFailure + !ifmacrodef customInstallerDirectoryFailed + !insertmacro customInstallerDirectoryFailed + !else + DetailPrint "Directory operation failed ($dshDirectoryError): $dshDirectorySource -> $dshDirectoryDestination; restore error: $dshRestoreError" + !endif +!macroend !macro dshExtractPayload FILE !ifmacrodef customInstallerExtract @@ -28,6 +88,9 @@ Var dshNewMoved !macroend !macro dshStageApplication + !ifdef DSH_INSTALLER_LOG_DIR + StrCpy $dshDirectoryLogDirectory "${DSH_INSTALLER_LOG_DIR}" + !endif StrCpy $dshFinalDirectory $INSTDIR System::Call 'ole32::CoCreateGuid(g .r0) i .r1' ${If} $1 != 0 @@ -38,6 +101,8 @@ Var dshNewMoved StrCpy $dshOldDirectory "$INSTDIR.old-$0" StrCpy $dshOldMoved "" StrCpy $dshNewMoved "" + StrCpy $dshDirectoryError 0 + StrCpy $dshRestoreError 0 ClearErrors CreateDirectory $dshNewDirectory ${If} ${Errors} @@ -77,9 +142,8 @@ Function dshRollbackDirectories StrCpy $dshNewMoved "" ${EndIf} ${If} $dshOldMoved == "1" - ClearErrors - Rename $dshOldDirectory $dshFinalDirectory - ${If} ${Errors} + !insertmacro dshDirectoryOperation "$dshOldDirectory" "$dshFinalDirectory" $dshRestoreError + ${If} $dshRestoreError != 0 ; Leave the complete backup in place if another process prevents restoration. DetailPrint $dshOldDirectory Return @@ -100,21 +164,35 @@ Function dshPromoteDirectories SetOutPath $PLUGINSDIR ClearErrors ${If} ${FileExists} "$dshFinalDirectory\*.*" - Rename $dshFinalDirectory $dshOldDirectory - ${If} ${Errors} + StrCpy $dshDirectorySource $dshFinalDirectory + StrCpy $dshDirectoryDestination $dshOldDirectory + !insertmacro dshDirectoryOperation "$dshDirectorySource" "$dshDirectoryDestination" $dshDirectoryError + ${If} $dshDirectoryError != 0 Call dshRollbackDirectories + !insertmacro dshReportDirectoryFailure SetErrors Return ${EndIf} StrCpy $dshOldMoved "1" ${Else} ; NSIS can create the destination before the install section starts. - RMDir $dshFinalDirectory + StrCpy $dshDirectorySource $dshFinalDirectory + StrCpy $dshDirectoryDestination "" + !insertmacro dshDirectoryOperation "$dshDirectorySource" "" $dshDirectoryError + ${If} $dshDirectoryError != 0 + Call dshRollbackDirectories + !insertmacro dshReportDirectoryFailure + SetErrors + Return + ${EndIf} ${EndIf} ClearErrors - Rename $dshNewDirectory $dshFinalDirectory - ${If} ${Errors} + StrCpy $dshDirectorySource $dshNewDirectory + StrCpy $dshDirectoryDestination $dshFinalDirectory + !insertmacro dshDirectoryOperation "$dshDirectorySource" "$dshDirectoryDestination" $dshDirectoryError + ${If} $dshDirectoryError != 0 Call dshRollbackDirectories + !insertmacro dshReportDirectoryFailure SetErrors Return ${EndIf} diff --git a/apps/desktop/scripts/installer.nsh b/apps/desktop/scripts/installer.nsh index 0f4d859e78..5961c071ba 100644 --- a/apps/desktop/scripts/installer.nsh +++ b/apps/desktop/scripts/installer.nsh @@ -195,6 +195,37 @@ ManifestDPIAware true ${EndIf} !macroend +!macro customInstallerDirectoryFailed + Push $0 + Push $1 + Push $2 + Push $3 + Push $4 + Push $5 + Push $6 + ${GetTime} "" "L" $0 $1 $2 $3 $4 $5 $6 + StrCpy $0 "$dshDirectoryLogDirectory\directory-failure-$2$1$0-$4$5$6.log" + StrCpy $1 1 + ${If} ${Silent} + StrCpy $1 0 + ${EndIf} + StrCpy $3 "$(INSTALLER_DIRECTORY_HINT)" + ${If} $dshRestoreError != 0 + StrCpy $3 "$(INSTALLER_DIRECTORY_RESTORE_HINT)" + ${EndIf} + System::Call '$PLUGINSDIR\window-frame.dll::InstallerReportDirectoryFailure(p $HWNDPARENT, i $dshDirectoryError, w "$dshDirectorySource", w "$dshDirectoryDestination", i $dshRestoreError, w "$dshOldDirectory", w "$PLUGINSDIR\directory-operations.log", w r0, i r1, w "$(^SetupCaption)", w "$(INSTALLER_DIRECTORY_FAILED)", w r3, w "$(INSTALLER_EXTRACT_COPY)", w "$(INSTALLER_EXTRACT_EXPAND)", w "$(INSTALLER_EXTRACT_COLLAPSE)", w "$(INSTALLER_EXTRACT_SAVED)", w "$(INSTALLER_EXTRACT_UNSAVED)", w "$(INSTALLER_EXTRACT_COPIED)") i.r2 ?c' + ${If} $2 == 1 + DetailPrint $0 + ${EndIf} + Pop $6 + Pop $5 + Pop $4 + Pop $3 + Pop $2 + Pop $1 + Pop $0 +!macroend + !ifndef BUILD_UNINSTALLER !include "${__FILEDIR__}\installer-directories.nsh" !endif diff --git a/apps/desktop/scripts/smoke-installer-directories.ps1 b/apps/desktop/scripts/smoke-installer-directories.ps1 index 092750b2c1..ce26ef574a 100644 --- a/apps/desktop/scripts/smoke-installer-directories.ps1 +++ b/apps/desktop/scripts/smoke-installer-directories.ps1 @@ -29,7 +29,7 @@ try { $brokenArchive = Join-Path $scratch 'broken.7z' [System.IO.File]::WriteAllText($brokenArchive, 'invalid archive') - foreach ($mode in @('first', 'upgrade', 'locked', 'missing-stage', 'broken', 'cancelled')) { + foreach ($mode in @('first', 'upgrade', 'transient-lock', 'locked', 'locked-stage', 'missing-stage', 'restore-conflict', 'broken', 'cancelled')) { $caseRoot = Join-Path $scratch $mode $target = Join-Path $caseRoot 'Application' New-Item -ItemType Directory -Path $caseRoot | Out-Null @@ -47,34 +47,75 @@ try { $compileArgs = @('/V2', "/DOUTPUT_FILE=$probe", "/DPAYLOAD_FILE=$caseArchive", "/DTARGET_DIR=$target", "/DDSH_SEVENZIP_PATH=$SevenZip") if ($FrameLibrary) { $compileArgs += @("/DSOURCE_DLL=$FrameLibrary", "/DREPORT_DIR=$reportDir") } if ($mode -eq 'missing-stage') { $compileArgs += '/DMISSING_STAGE' } + if ($mode -eq 'locked-stage') { $compileArgs += '/DLOCKED_STAGE' } + if ($mode -eq 'restore-conflict') { $compileArgs += '/DRESTORE_CONFLICT' } if ($mode -eq 'cancelled') { $compileArgs += '/DCANCELLED' } Invoke-Checked $Makensis ($compileArgs + $fixture) if ($null -ne $SignExecutable) { & $SignExecutable $probe } $handle = $null $process = $null try { - if ($mode -eq 'locked') { + if ($mode -in @('locked', 'transient-lock')) { $handle = [System.IO.File]::Open((Join-Path $target 'asset.txt'), 'Open', 'Read', 'Read') } $process = Start-Process -FilePath $probe -ArgumentList '/S' -WindowStyle Hidden -PassThru + if ($mode -eq 'transient-lock') { + $deadline = [Diagnostics.Stopwatch]::StartNew() + $observedFailure = $false + while (-not $process.HasExited -and $deadline.Elapsed.TotalSeconds -lt 30) { + $pluginPath = "$target.plugin-dir" + if (Test-Path -LiteralPath $pluginPath) { + $trace = Join-Path ([IO.File]::ReadAllText($pluginPath)) 'directory-operations.log' + if ((Test-Path -LiteralPath $trace) -and [IO.File]::ReadAllText($trace) -match 'Windows error: (5|32|33)\b') { + $observedFailure = $true + break + } + } + Start-Sleep -Milliseconds 20 + } + if (-not $observedFailure) { throw 'Transient-lock smoke did not observe the failed operation before release' } + $handle.Dispose() + $handle = $null + } if (-not $process.WaitForExit(60000)) { $process.Kill($true) $process.WaitForExit() throw "Directory smoke $mode did not exit within 60 seconds" } - $expectedExit = if ($mode -in @('first', 'upgrade')) { 0 } else { 2 } + $expectedExit = if ($mode -in @('first', 'upgrade', 'transient-lock')) { 0 } else { 2 } if ($process.ExitCode -ne $expectedExit) { throw "Directory smoke $mode exited with $($process.ExitCode), expected $expectedExit" } } finally { if ($null -ne $handle) { $handle.Dispose() } - if ($null -ne $process) { $process.Dispose() } + if ($null -ne $process) { + if (-not $process.HasExited) { $process.Kill($true); $process.WaitForExit() } + $process.Dispose() + } } - $expectedAsset = if ($mode -in @('first', 'upgrade')) { 'new asset' } else { 'old asset' } + if ($mode -eq 'restore-conflict') { + $backups = @(Get-ChildItem -LiteralPath $caseRoot -Directory -Filter 'Application.old-*') + if ($backups.Count -ne 1 -or [IO.File]::ReadAllText((Join-Path $backups[0].FullName 'asset.txt')) -ne 'old asset' -or + [IO.File]::ReadAllText((Join-Path $backups[0].FullName 'obsolete.txt')) -ne 'old only' -or + [IO.File]::ReadAllText((Join-Path $target 'unrelated.txt')) -ne 'unrelated file') { + throw 'Failed restoration did not preserve the backup and the conflicting destination' + } + if ($FrameLibrary) { + $reports = @(Get-ChildItem -LiteralPath $reportDir -File) + if ($reports.Count -ne 1) { throw 'Failed restoration did not save exactly one report' } + $report = Get-Content -LiteralPath $reports[0].FullName -Raw -Encoding UTF8 + foreach ($expected in @('Windows error: 183', 'Restoration error: 183', "Preserved backup: $($backups[0].FullName)")) { + if (-not $report.Contains($expected)) { throw "Failed restoration report lacks '$expected'" } + } + } + Write-Output "Directory replacement smoke passed: $mode" + continue + } + $expectedAsset = if ($mode -in @('first', 'upgrade', 'transient-lock')) { 'new asset' } else { 'old asset' } if ([System.IO.File]::ReadAllText((Join-Path $target 'asset.txt')) -ne $expectedAsset) { throw "Directory smoke $mode changed the wrong version" } - $expectObsolete = $mode -notin @('first', 'upgrade') + $expectObsolete = $mode -notin @('first', 'upgrade', 'transient-lock') if ((Test-Path -LiteralPath (Join-Path $target 'obsolete.txt')) -ne $expectObsolete) { throw "Directory smoke $mode did not preserve the expected old-only file state" } @@ -82,7 +123,6 @@ try { throw "Directory smoke $mode left transaction directories behind" } if ($FrameLibrary) { - # Only an extraction failure writes a report; it must quote 7-Zip's verdict so support can read the cause. $reports = @(if (Test-Path -LiteralPath $reportDir) { Get-ChildItem -LiteralPath $reportDir -File }) if ($mode -eq 'broken') { if ($reports.Count -ne 1 -or $reports[0].Name -notmatch '^extract-failure-20\d{6}-\d{6}\.log$') { @@ -92,6 +132,19 @@ try { foreach ($expected in @('Result: 7-Zip exit code 2 (fatal error)', "Archive: $caseArchive", 'Cannot open the file as [7z] archive')) { if (-not $report.Contains($expected)) { throw "Directory smoke $mode report lacks '$expected'" } } + } elseif ($mode -in @('locked', 'locked-stage', 'missing-stage')) { + if ($reports.Count -ne 1 -or $reports[0].Name -notmatch '^directory-failure-20\d{6}-\d{6}\.log$') { + throw "Directory smoke $mode did not save exactly one directory report" + } + $report = Get-Content -LiteralPath $reports[0].FullName -Raw -Encoding UTF8 + $expectedError = if ($mode -ne 'missing-stage') { '(5|32|33)' } else { '2' } + if ($report -notmatch "Windows error: $expectedError\b") { throw "Directory smoke $mode reported an unexpected Windows error" } + foreach ($expected in @('Operation: MoveFileExW', 'Restoration error: 0')) { + if (-not $report.Contains($expected)) { throw "Directory smoke $mode report lacks '$expected'" } + } + $attempts = ([regex]::Matches($report, 'Windows error: ')).Count - 1 + $expectedAttempts = if ($mode -ne 'missing-stage') { 10 } else { 1 } + if ($attempts -ne $expectedAttempts) { throw "Directory smoke $mode recorded $attempts failed attempts, expected $expectedAttempts" } } elseif ($reports.Count -ne 0) { throw "Directory smoke $mode saved an unexpected extraction report" } diff --git a/apps/desktop/scripts/windows-directory-installer.mjs b/apps/desktop/scripts/windows-directory-installer.mjs index 0f2f985142..8508730b47 100644 --- a/apps/desktop/scripts/windows-directory-installer.mjs +++ b/apps/desktop/scripts/windows-directory-installer.mjs @@ -37,7 +37,7 @@ export function directoryInstallSection(source) { !macroend`) result = replaceOnce(result, '!insertmacro setLinkVars', `!insertmacro setLinkVars !insertmacro dshStageApplication`) - result = replaceOnce(result, '!insertmacro installApplicationFiles', 'Call dshPromoteDirectories\nIfErrors 0 +4\n SetErrorLevel 2\n MessageBox MB_OK|MB_ICONEXCLAMATION "$(appCannotBeClosed)" /SD IDOK\n Quit') + result = replaceOnce(result, '!insertmacro installApplicationFiles', 'Call dshPromoteDirectories\nIfErrors 0 +3\n SetErrorLevel 2\n Quit') result = replaceOnce(result, '!ifdef UNINSTALLER_ICON\n File /oname=uninstallerIcon.ico "${UNINSTALLER_ICON}"\n!endif\n', '') // The staging macro uses the upstream installer macro, including its signed uninstaller. return result diff --git a/apps/desktop/tests/expected/windows-installer.json b/apps/desktop/tests/expected/windows-installer.json index a9d08b5a3c..0f1f23ba16 100644 --- a/apps/desktop/tests/expected/windows-installer.json +++ b/apps/desktop/tests/expected/windows-installer.json @@ -8,6 +8,7 @@ "welcome-ready-before-first-show", "welcome-presented-on-first-show", "successful-install-paints-100-before-finish", + "locked-directory-shows-copyable-error-and-preserves-installation", "registered-directory-with-trailing-separators", "registered-directory-and-checked-launch", "launch-failure-retry-and-prompt-dismissal", diff --git a/apps/desktop/tests/fixtures/installer-directory-smoke.nsi b/apps/desktop/tests/fixtures/installer-directory-smoke.nsi index 3afd73a433..2a1dfe4b0f 100644 --- a/apps/desktop/tests/fixtures/installer-directory-smoke.nsi +++ b/apps/desktop/tests/fixtures/installer-directory-smoke.nsi @@ -21,11 +21,30 @@ LangString decompressionFailed ${LANG_ENGLISH} "Payload extraction failed" Section InitPluginsDir + FileOpen $0 "${TARGET_DIR}.plugin-dir.tmp" w + FileWrite $0 "$PLUGINSDIR" + FileClose $0 + Rename "${TARGET_DIR}.plugin-dir.tmp" "${TARGET_DIR}.plugin-dir" !ifdef SOURCE_DLL File "/oname=$PLUGINSDIR\window-frame.dll" "${SOURCE_DLL}" !endif StrCpy $INSTDIR "${TARGET_DIR}" !insertmacro dshStageApplication + !ifdef RESTORE_CONFLICT + Rename $dshFinalDirectory $dshOldDirectory + StrCpy $dshOldMoved "1" + CreateDirectory $dshFinalDirectory + FileOpen $0 "$dshFinalDirectory\unrelated.txt" w + FileWrite $0 "unrelated file" + FileClose $0 + StrCpy $dshDirectorySource $dshNewDirectory + StrCpy $dshDirectoryDestination $dshFinalDirectory + !insertmacro dshDirectoryOperation "$dshDirectorySource" "$dshDirectoryDestination" $dshDirectoryError + Call dshRollbackDirectories + !insertmacro dshReportDirectoryFailure + SetErrorLevel 2 + Quit + !endif !ifdef CANCELLED SetErrorLevel 2 Call dshCleanupDirectories @@ -35,12 +54,19 @@ Section ; A missing source makes the second rename fail after the old directory moved. RMDir /r "\\?\$dshNewDirectory" !endif + !ifdef LOCKED_STAGE + System::Call 'kernel32::CreateFileW(w "$dshNewDirectory\asset.txt", i 0x80000000, i 1, p 0, i 3, i 0, p 0) p.r9' + !endif Call dshPromoteDirectories IfErrors failed !insertmacro dshFinishDirectories SetErrorLevel 0 Quit failed: + !ifdef LOCKED_STAGE + System::Call 'kernel32::CloseHandle(p r9)' + Call dshCleanupDirectories + !endif SetErrorLevel 2 Quit SectionEnd diff --git a/apps/desktop/tests/windows-installer-extract-report.cpp b/apps/desktop/tests/windows-installer-extract-report.cpp index 355d16dd94..253b2cd667 100644 --- a/apps/desktop/tests/windows-installer-extract-report.cpp +++ b/apps/desktop/tests/windows-installer-extract-report.cpp @@ -10,6 +10,11 @@ extern "C" __declspec(dllimport) int __cdecl InstallerReportExtractFailure( HWND parent, int code, LPCWSTR archive, LPCWSTR destination, LPCWSTR log, LPCWSTR reportPath, int show, LPCWSTR title, LPCWSTR heading, LPCWSTR hint, LPCWSTR copy, LPCWSTR expand, LPCWSTR collapse, LPCWSTR savedFormat, LPCWSTR unsaved, LPCWSTR copied); +extern "C" __declspec(dllimport) int __cdecl InstallerReportDirectoryFailure( + HWND parent, DWORD error, LPCWSTR source, LPCWSTR destination, DWORD restoreError, LPCWSTR backup, + LPCWSTR log, LPCWSTR reportPath, int show, + LPCWSTR title, LPCWSTR heading, LPCWSTR hint, LPCWSTR copy, LPCWSTR expand, LPCWSTR collapse, LPCWSTR savedFormat, LPCWSTR unsaved, LPCWSTR copied); + static bool Contains(const std::wstring& text, const std::wstring& needle) { return text.find(needle) != std::wstring::npos; } static std::string ReadBytes(const std::wstring& path) { @@ -127,7 +132,19 @@ int wmain() { L"title", L"heading", L"hint", L"copy", L"expand", L"collapse", L"saved %s", L"unsaved", L"copied"); assert(unwritable == 0); - for (const std::wstring& file : {reportPath, missingLogReport, log}) DeleteFileW(file.c_str()); + const std::wstring directoryReport = root + L"\\directory.log"; + assert(InstallerReportDirectoryFailure(nullptr, ERROR_SHARING_VIOLATION, L"C:\\Apps\\\x6587\x4EF6.new", L"C:\\Apps\\\x6587\x4EF6", + ERROR_ALREADY_EXISTS, L"C:\\Apps\\\x6587\x4EF6.old", (root + L"\\absent.log").c_str(), directoryReport.c_str(), 0, + L"title", L"heading", L"hint", L"copy", L"expand", L"collapse", L"saved %s", L"unsaved", L"copied") == 1); + const std::string directorySaved = ReadBytes(directoryReport); + for (const std::string& expected : {"Operation: MoveFileExW", "Windows error: 32", "Restoration error: 183", + "Preserved backup: C:\\Apps\\\xE6\x96\x87\xE4\xBB\xB6.old"}) { + assert(directorySaved.find(expected) != std::string::npos); + } + assert(InstallerReportDirectoryFailure(nullptr, ERROR_ACCESS_DENIED, L"a", L"", 0, L"", log.c_str(), (log + L"\\impossible.txt").c_str(), 0, + L"title", L"heading", L"hint", L"copy", L"expand", L"collapse", L"saved %s", L"unsaved", L"copied") == 0); + + for (const std::wstring& file : {reportPath, missingLogReport, directoryReport, log}) DeleteFileW(file.c_str()); RemoveDirectoryW((root + L"\\nested\\logs").c_str()); RemoveDirectoryW((root + L"\\nested").c_str()); const BOOL removed = RemoveDirectoryW(root.c_str()); diff --git a/apps/desktop/tests/windows-installer-smoke.ps1 b/apps/desktop/tests/windows-installer-smoke.ps1 index 53404b494d..f983b056d1 100644 --- a/apps/desktop/tests/windows-installer-smoke.ps1 +++ b/apps/desktop/tests/windows-installer-smoke.ps1 @@ -5,6 +5,7 @@ param([Parameter(Mandatory)][string]$Installer, [Parameter(Mandatory)][string]$P [Parameter(Mandatory)][string]$OutputDirectory) $ErrorActionPreference = 'Stop' . (Join-Path $PSScriptRoot 'windows-installer-ui.ps1') +Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes [InstallerCapture]::Initialize() [InstallerCapture]::ProductName = $ProductName $installPath = Join-Path $OutputDirectory 'Installed App' @@ -160,6 +161,26 @@ try { $results.Add('welcome-ready-before-first-show') $results.Add('successful-install-paints-100-before-finish') + $lockedFile = [IO.File]::Open($appPath, 'Open', 'Read', 'Read') + try { + $process = Start-Setup light + Click-Control $process $copy.INSTALLER_INSTALL + $copyButton = Wait-Control $process $copy.INSTALLER_EXTRACT_COPY + $dialog = [InstallerCapture]::TopLevel($copyButton) + $automation = [Windows.Automation.AutomationElement]::FromHandle($dialog) + $heading = $automation.FindFirst([Windows.Automation.TreeScope]::Descendants, + [Windows.Automation.PropertyCondition]::new([Windows.Automation.AutomationElement]::NameProperty, $copy.INSTALLER_DIRECTORY_FAILED)) + if ($null -eq $heading) { throw 'Directory failure dialog lacks its localized heading' } + [void][InstallerCapture]::SaveNative($dialog, (Join-Path $OutputDirectory 'directory-failure.png'), $false) + [void][InstallerCapture]::SendMessage($dialog, (0x400 + 102), [IntPtr]1, [IntPtr]::Zero) + if (-not $process.WaitForExit(10000) -or $process.ExitCode -ne 2 -or -not (Test-Path -LiteralPath $appPath)) { + throw 'Directory failure did not exit with the old installation intact' + } + } finally { + $lockedFile.Dispose() + } + $results.Add('locked-directory-shows-copyable-error-and-preserves-installation') + $process = Start-Setup dark '' Click-Control $process $copy.INSTALLER_CHOOSE_PATH $edit = Wait-Control $process $installPath From fae05edca7d72f1f80ba6353acb7d594ab8fdd86 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:13:46 +0800 Subject: [PATCH 2/5] test(desktop): share retry trace reads with the installer --- apps/desktop/scripts/smoke-installer-directories.ps1 | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/desktop/scripts/smoke-installer-directories.ps1 b/apps/desktop/scripts/smoke-installer-directories.ps1 index ce26ef574a..4804c0d919 100644 --- a/apps/desktop/scripts/smoke-installer-directories.ps1 +++ b/apps/desktop/scripts/smoke-installer-directories.ps1 @@ -66,9 +66,14 @@ try { $pluginPath = "$target.plugin-dir" if (Test-Path -LiteralPath $pluginPath) { $trace = Join-Path ([IO.File]::ReadAllText($pluginPath)) 'directory-operations.log' - if ((Test-Path -LiteralPath $trace) -and [IO.File]::ReadAllText($trace) -match 'Windows error: (5|32|33)\b') { - $observedFailure = $true - break + if (Test-Path -LiteralPath $trace) { + $stream = [IO.File]::Open($trace, 'Open', 'Read', 'ReadWrite') + $reader = [IO.StreamReader]::new($stream) + try { $attempts = $reader.ReadToEnd() } finally { $reader.Dispose() } + if ($attempts -match 'Windows error: (5|32|33)\b') { + $observedFailure = $true + break + } } } Start-Sleep -Milliseconds 20 From dcc8df83202f0bd80ddcab9c83886abdcf964cda Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:38:42 +0800 Subject: [PATCH 3/5] fix(desktop): preserve reported recovery state and label retry attempts --- apps/desktop/packaging/README.i18n.yaml | 4 ++-- apps/desktop/packaging/README.md | 2 +- apps/desktop/packaging/README.zh.md | 2 +- .../desktop/scripts/installer-directories.nsh | 23 ++++++++++++++----- .../scripts/smoke-installer-directories.ps1 | 7 ++++-- .../tests/expected/windows-installer.json | 2 +- .../fixtures/installer-directory-smoke.nsi | 6 +++-- .../windows-installer-extract-report.cpp | 5 ++++ .../desktop/tests/windows-installer-smoke.ps1 | 2 +- 9 files changed, 37 insertions(+), 16 deletions(-) diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 4cb5ab92d2..0f98b2c3e7 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -45,8 +45,8 @@ en: b42f103358d0dfd7 zh: a534164ad9eb00da /desktop-packaging/runtime-file-selection-and-installation-checks: - en: 777f4c85bc3325eb - zh: 448f2978876a091e + en: bfd89e1d139c5729 + zh: 5d6cced5462ba13e /desktop-packaging/desktop-runtime-preparation: en: a7aa3066e649ffe8 zh: 4334637910058c09 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index bf3ae66d25..84c725f4f3 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -244,7 +244,7 @@ pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis The Windows installer checks for a running application at startup and after destination selection, before extracting the new version beside the installation directory. It checks again before replacing directories through same-volume renames. A running application blocks installation; update launches allow up to ten seconds for it to exit. Same-path upgrades preserve the old directory until promotion succeeds; extraction failure leaves it intact, and promotion failure attempts to restore it. The installer removes the old backup before launch. Forced termination or power loss can leave `.new-*` or `.old-*` directories; installation-location and scope migrations retain electron-builder's old-uninstaller flow. -Directory renames, empty-destination removal, and restoration retry Windows errors 5, 32, and 33 up to ten attempts, with 250 ms increasing delays (11.25 seconds of total waiting per operation). Other errors stop immediately. A replacement failure reports the Windows error, affected paths, and restoration outcome in `directory-failure-.log` beside extraction reports. If restoration fails, keep the backup path named in the report and contact support before reinstalling. Silent installs save the same diagnostic without a dialog. +Directory renames, empty-destination removal, and restoration retry Windows errors 5, 32, and 33 up to ten attempts, with 250 ms increasing delays (11.25 seconds of total waiting per operation). Other errors stop immediately. A replacement failure reports the Windows error, affected paths, and restoration outcome in `directory-failure-.log` beside extraction reports. If restoration fails, the installer removes unlocked staging files and preserves the backup. After reporting that failure, exit cleanup does not retry restoration. Keep the backup path named in the report and contact support before reinstalling. Silent installs save the same diagnostic without a dialog. When extraction fails, the installer writes a report with the 7-Zip result and its complete error output to the updater cache directory as `%LOCALAPPDATA%\-updater\installer-logs\extract-failure-.log` (currently `@deepseek-aidsh-desktop-updater`) and shows the first error line with a **Copy error details** button; silent installs write only the report. Unsigned Windows builds (`DSH_DESKTOP_UNSIGNED=1`) name their installer `deepseek-harness--win-x64-unsigned.exe` so they cannot be mistaken for release artifacts. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 8d2f88182d..d9e617aac7 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -244,7 +244,7 @@ pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis Windows 安装器在启动时和选定目标目录后检查应用是否正在运行,通过检查后才将新版本解压到安装目录旁边。通过同卷目录改名替换前,安装器会再次检查。运行中的应用会阻止安装;更新启动允许等待应用退出,最长十秒。同路径升级在替换成功前保留旧目录;解压失败时旧版不变,替换失败时尝试恢复旧目录。安装器在启动前清理旧版备份。强制结束安装器或断电可能留下 `.new-*` 或 `.old-*` 目录;不同安装位置或安装范围迁移仍使用 electron-builder 的旧卸载器流程。 -目录改名、空目标目录删除和旧版恢复遇到 Windows 错误 5、32 或 33 时最多尝试十次,每次等待递增 250 毫秒(每个操作合计等待 11.25 秒)。其他错误立即停止。替换失败时,安装器将 Windows 错误、相关路径和恢复结果写入解压报告旁的 `directory-failure-<时间戳>.log`。如果恢复失败,请保留报告所列的备份目录,并在重新安装前联系支持人员。静默安装保存相同的诊断,不显示弹窗。 +目录改名、空目标目录删除和旧版恢复遇到 Windows 错误 5、32 或 33 时最多尝试十次,每次等待递增 250 毫秒(每个操作合计等待 11.25 秒)。其他错误立即停止。替换失败时,安装器将 Windows 错误、相关路径和恢复结果写入解压报告旁的 `directory-failure-<时间戳>.log`。如果恢复失败,安装器会清理未被占用的暂存文件并保留备份。上报此失败后,退出清理不再重试恢复。请保留报告所列的备份目录,并在重新安装前联系支持人员。静默安装保存相同的诊断,不显示弹窗。 解压失败时,安装器会把 7-Zip 的结果和完整错误输出写入更新缓存目录 `%LOCALAPPDATA%\<按包名派生>-updater\installer-logs\extract-failure-<时间戳>.log`(当前为 `@deepseek-aidsh-desktop-updater`),并在弹窗中显示首条错误行和 **复制错误信息** 按钮;静默安装只写入报告。未签名的 Windows 构建(`DSH_DESKTOP_UNSIGNED=1`)会将安装包命名为 `deepseek-harness-<版本>-win-x64-unsigned.exe`,以免被误当作发布产物。 diff --git a/apps/desktop/scripts/installer-directories.nsh b/apps/desktop/scripts/installer-directories.nsh index f9c42673f2..e189f787f8 100644 --- a/apps/desktop/scripts/installer-directories.nsh +++ b/apps/desktop/scripts/installer-directories.nsh @@ -10,9 +10,11 @@ Var dshDirectorySource Var dshDirectoryDestination Var dshRestoreError Var dshDirectoryLogDirectory +Var dshRecoveryStopped ; MoveFileEx preserves the destination and exposes the Windows error before rollback changes it. -!macro dshDirectoryOperation SOURCE DESTINATION RESULT +; Open files inside a directory can make its rename return access denied (5). +!macro dshDirectoryOperation SOURCE DESTINATION RESULT PHASE Push $0 Push $1 Push $2 @@ -38,7 +40,7 @@ Var dshDirectoryLogDirectory FileOpen $2 "$PLUGINSDIR\directory-operations.log" a ${IfNot} ${Errors} FileSeek $2 0 END - FileWrite $2 "Windows error: ${RESULT}$\r$\n" + FileWrite $2 "${PHASE}: Windows error: ${RESULT}$\r$\n" FileClose $2 ${EndIf} ${If} ${RESULT} != 5 @@ -59,6 +61,9 @@ Var dshDirectoryLogDirectory !macroend !macro dshReportDirectoryFailure + ${If} $dshRestoreError != 0 + StrCpy $dshRecoveryStopped "1" + ${EndIf} !ifmacrodef customInstallerDirectoryFailed !insertmacro customInstallerDirectoryFailed !else @@ -89,6 +94,7 @@ Var dshDirectoryLogDirectory !macro dshStageApplication !ifdef DSH_INSTALLER_LOG_DIR + ; electron-builder defines the cache name after including our function bodies, before this section expands. StrCpy $dshDirectoryLogDirectory "${DSH_INSTALLER_LOG_DIR}" !endif StrCpy $dshFinalDirectory $INSTDIR @@ -103,6 +109,7 @@ Var dshDirectoryLogDirectory StrCpy $dshNewMoved "" StrCpy $dshDirectoryError 0 StrCpy $dshRestoreError 0 + StrCpy $dshRecoveryStopped "" ClearErrors CreateDirectory $dshNewDirectory ${If} ${Errors} @@ -129,6 +136,9 @@ Function .onGUIEnd FunctionEnd Function dshCleanupDirectories + ${If} $dshRecoveryStopped == "1" + Return + ${EndIf} ${If} $dshFinalDirectory != "" Call dshRollbackDirectories ${EndIf} @@ -142,10 +152,11 @@ Function dshRollbackDirectories StrCpy $dshNewMoved "" ${EndIf} ${If} $dshOldMoved == "1" - !insertmacro dshDirectoryOperation "$dshOldDirectory" "$dshFinalDirectory" $dshRestoreError + !insertmacro dshDirectoryOperation "$dshOldDirectory" "$dshFinalDirectory" $dshRestoreError "restore" ${If} $dshRestoreError != 0 ; Leave the complete backup in place if another process prevents restoration. DetailPrint $dshOldDirectory + RMDir /r "\\?\$dshNewDirectory" Return ${EndIf} StrCpy $dshOldMoved "" @@ -166,7 +177,7 @@ Function dshPromoteDirectories ${If} ${FileExists} "$dshFinalDirectory\*.*" StrCpy $dshDirectorySource $dshFinalDirectory StrCpy $dshDirectoryDestination $dshOldDirectory - !insertmacro dshDirectoryOperation "$dshDirectorySource" "$dshDirectoryDestination" $dshDirectoryError + !insertmacro dshDirectoryOperation "$dshDirectorySource" "$dshDirectoryDestination" $dshDirectoryError "backup" ${If} $dshDirectoryError != 0 Call dshRollbackDirectories !insertmacro dshReportDirectoryFailure @@ -178,7 +189,7 @@ Function dshPromoteDirectories ; NSIS can create the destination before the install section starts. StrCpy $dshDirectorySource $dshFinalDirectory StrCpy $dshDirectoryDestination "" - !insertmacro dshDirectoryOperation "$dshDirectorySource" "" $dshDirectoryError + !insertmacro dshDirectoryOperation "$dshDirectorySource" "" $dshDirectoryError "remove-empty" ${If} $dshDirectoryError != 0 Call dshRollbackDirectories !insertmacro dshReportDirectoryFailure @@ -189,7 +200,7 @@ Function dshPromoteDirectories ClearErrors StrCpy $dshDirectorySource $dshNewDirectory StrCpy $dshDirectoryDestination $dshFinalDirectory - !insertmacro dshDirectoryOperation "$dshDirectorySource" "$dshDirectoryDestination" $dshDirectoryError + !insertmacro dshDirectoryOperation "$dshDirectorySource" "$dshDirectoryDestination" $dshDirectoryError "promote" ${If} $dshDirectoryError != 0 Call dshRollbackDirectories !insertmacro dshReportDirectoryFailure diff --git a/apps/desktop/scripts/smoke-installer-directories.ps1 b/apps/desktop/scripts/smoke-installer-directories.ps1 index 4804c0d919..35bd4ba8cc 100644 --- a/apps/desktop/scripts/smoke-installer-directories.ps1 +++ b/apps/desktop/scripts/smoke-installer-directories.ps1 @@ -102,14 +102,17 @@ try { $backups = @(Get-ChildItem -LiteralPath $caseRoot -Directory -Filter 'Application.old-*') if ($backups.Count -ne 1 -or [IO.File]::ReadAllText((Join-Path $backups[0].FullName 'asset.txt')) -ne 'old asset' -or [IO.File]::ReadAllText((Join-Path $backups[0].FullName 'obsolete.txt')) -ne 'old only' -or - [IO.File]::ReadAllText((Join-Path $target 'unrelated.txt')) -ne 'unrelated file') { + [IO.File]::ReadAllText((Join-Path "$target.conflict" 'unrelated.txt')) -ne 'unrelated file') { throw 'Failed restoration did not preserve the backup and the conflicting destination' } + if ((Test-Path -LiteralPath $target) -or @(Get-ChildItem -LiteralPath $caseRoot -Directory -Filter 'Application.new-*').Count -ne 0) { + throw 'Reported restoration failure retried recovery or retained its unlocked staging directory' + } if ($FrameLibrary) { $reports = @(Get-ChildItem -LiteralPath $reportDir -File) if ($reports.Count -ne 1) { throw 'Failed restoration did not save exactly one report' } $report = Get-Content -LiteralPath $reports[0].FullName -Raw -Encoding UTF8 - foreach ($expected in @('Windows error: 183', 'Restoration error: 183', "Preserved backup: $($backups[0].FullName)")) { + foreach ($expected in @('promote: Windows error: 183', 'restore: Windows error: 183', 'Restoration error: 183', "Preserved backup: $($backups[0].FullName)")) { if (-not $report.Contains($expected)) { throw "Failed restoration report lacks '$expected'" } } } diff --git a/apps/desktop/tests/expected/windows-installer.json b/apps/desktop/tests/expected/windows-installer.json index 0f1f23ba16..c10dcfac9a 100644 --- a/apps/desktop/tests/expected/windows-installer.json +++ b/apps/desktop/tests/expected/windows-installer.json @@ -8,7 +8,7 @@ "welcome-ready-before-first-show", "welcome-presented-on-first-show", "successful-install-paints-100-before-finish", - "locked-directory-shows-copyable-error-and-preserves-installation", + "locked-directory-shows-error-and-copy-control-and-preserves-installation", "registered-directory-with-trailing-separators", "registered-directory-and-checked-launch", "launch-failure-retry-and-prompt-dismissal", diff --git a/apps/desktop/tests/fixtures/installer-directory-smoke.nsi b/apps/desktop/tests/fixtures/installer-directory-smoke.nsi index 2a1dfe4b0f..d80deb9748 100644 --- a/apps/desktop/tests/fixtures/installer-directory-smoke.nsi +++ b/apps/desktop/tests/fixtures/installer-directory-smoke.nsi @@ -22,7 +22,7 @@ LangString decompressionFailed ${LANG_ENGLISH} "Payload extraction failed" Section InitPluginsDir FileOpen $0 "${TARGET_DIR}.plugin-dir.tmp" w - FileWrite $0 "$PLUGINSDIR" + FileWriteUTF16LE /BOM $0 "$PLUGINSDIR" FileClose $0 Rename "${TARGET_DIR}.plugin-dir.tmp" "${TARGET_DIR}.plugin-dir" !ifdef SOURCE_DLL @@ -39,9 +39,11 @@ Section FileClose $0 StrCpy $dshDirectorySource $dshNewDirectory StrCpy $dshDirectoryDestination $dshFinalDirectory - !insertmacro dshDirectoryOperation "$dshDirectorySource" "$dshDirectoryDestination" $dshDirectoryError + !insertmacro dshDirectoryOperation "$dshDirectorySource" "$dshDirectoryDestination" $dshDirectoryError "promote" Call dshRollbackDirectories !insertmacro dshReportDirectoryFailure + Rename $dshFinalDirectory "${TARGET_DIR}.conflict" + Call dshCleanupDirectories SetErrorLevel 2 Quit !endif diff --git a/apps/desktop/tests/windows-installer-extract-report.cpp b/apps/desktop/tests/windows-installer-extract-report.cpp index 253b2cd667..008526417f 100644 --- a/apps/desktop/tests/windows-installer-extract-report.cpp +++ b/apps/desktop/tests/windows-installer-extract-report.cpp @@ -141,6 +141,11 @@ int wmain() { "Preserved backup: C:\\Apps\\\xE6\x96\x87\xE4\xBB\xB6.old"}) { assert(directorySaved.find(expected) != std::string::npos); } + assert(InstallerReportDirectoryFailure(nullptr, ERROR_ACCESS_DENIED, L"a", L"", 0, L"", log.c_str(), directoryReport.c_str(), 0, + L"title", L"heading", L"hint", L"copy", L"expand", L"collapse", L"saved %s", L"unsaved", L"copied") == 1); + const std::string removalSaved = ReadBytes(directoryReport); + assert(removalSaved.find("Operation: RemoveDirectoryW") != std::string::npos); + assert(removalSaved.find("Preserved backup:") == std::string::npos); assert(InstallerReportDirectoryFailure(nullptr, ERROR_ACCESS_DENIED, L"a", L"", 0, L"", log.c_str(), (log + L"\\impossible.txt").c_str(), 0, L"title", L"heading", L"hint", L"copy", L"expand", L"collapse", L"saved %s", L"unsaved", L"copied") == 0); diff --git a/apps/desktop/tests/windows-installer-smoke.ps1 b/apps/desktop/tests/windows-installer-smoke.ps1 index f983b056d1..796f885bb4 100644 --- a/apps/desktop/tests/windows-installer-smoke.ps1 +++ b/apps/desktop/tests/windows-installer-smoke.ps1 @@ -179,7 +179,7 @@ try { } finally { $lockedFile.Dispose() } - $results.Add('locked-directory-shows-copyable-error-and-preserves-installation') + $results.Add('locked-directory-shows-error-and-copy-control-and-preserves-installation') $process = Start-Setup dark '' Click-Control $process $copy.INSTALLER_CHOOSE_PATH From 06e5024577f7c965e07107a7ac203a7711fd76dc Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:58:21 +0800 Subject: [PATCH 4/5] fix(desktop): bound expanded directory failure details --- apps/desktop/installer/extract-report.h | 24 +++++++++++-------- apps/desktop/installer/window-frame.cpp | 2 +- .../windows-installer-extract-report.cpp | 12 ++++++++++ 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/apps/desktop/installer/extract-report.h b/apps/desktop/installer/extract-report.h index c885816c75..cdfbbad22b 100644 --- a/apps/desktop/installer/extract-report.h +++ b/apps/desktop/installer/extract-report.h @@ -130,16 +130,8 @@ inline std::wstring Compose(int code, const std::wstring& archive, const std::ws return report; } -// The expanded panel adds 7-Zip's own lines beneath the result; long lines are cut and a trailing note counts what the -// saved report still holds. Header fields already appear in the report file and clipboard. -inline std::wstring Excerpt(const std::wstring& report, size_t maxLines = kExcerptLines, size_t maxChars = kExcerptChars) { - std::vector lines; - bool inOutput = false; - for (const std::wstring& line : Lines(Trim(report))) { - if (inOutput) lines.push_back(line); - else if (line.rfind(L"Result: ", 0) == 0) lines.push_back(line); - else if (line == kOutputMarker) inOutput = true; - } +// Expanded native dialogs bound both line count and line length; files and clipboard keep the complete report. +inline std::wstring BoundedExcerpt(const std::vector& lines, size_t maxLines = kExcerptLines, size_t maxChars = kExcerptChars) { std::wstring excerpt; size_t shown = 0; for (const std::wstring& line : lines) { @@ -154,6 +146,18 @@ inline std::wstring Excerpt(const std::wstring& report, size_t maxLines = kExcer return excerpt; } +// The extraction panel selects 7-Zip's own lines beneath the result; metadata remains in the saved report. +inline std::wstring Excerpt(const std::wstring& report, size_t maxLines = kExcerptLines, size_t maxChars = kExcerptChars) { + std::vector lines; + bool inOutput = false; + for (const std::wstring& line : Lines(Trim(report))) { + if (inOutput) lines.push_back(line); + else if (line.rfind(L"Result: ", 0) == 0) lines.push_back(line); + else if (line == kOutputMarker) inOutput = true; + } + return BoundedExcerpt(lines, maxLines, maxChars); +} + // A cut inside a multi-byte sequence would make the whole UTF-8 attempt fail; drop the incomplete tail first. inline void TrimPartialUtf8(std::string& bytes) { size_t trailing = 0; diff --git a/apps/desktop/installer/window-frame.cpp b/apps/desktop/installer/window-frame.cpp index 363a1b5abf..ce454f3f8a 100644 --- a/apps/desktop/installer/window-frame.cpp +++ b/apps/desktop/installer/window-frame.cpp @@ -32,7 +32,7 @@ extern "C" __declspec(dllexport) int __cdecl InstallerReportDirectoryFailure( if (saved && placeholder != std::wstring::npos) footer.replace(placeholder, 2, reportPath); const extract_report::DialogStrings strings{title, heading, copy, expand, collapse, footer.c_str(), copied}; const std::wstring content = std::to_wstring(error) + L": " + extract_report::Win32Message(error) + L"\r\n\r\n" + hint; - extract_report::Show(parent, content, report, report, strings); + extract_report::Show(parent, content, extract_report::BoundedExcerpt(extract_report::Lines(report)), report, strings); } return saved ? 1 : 0; } catch (const std::bad_alloc&) { diff --git a/apps/desktop/tests/windows-installer-extract-report.cpp b/apps/desktop/tests/windows-installer-extract-report.cpp index 008526417f..54983187da 100644 --- a/apps/desktop/tests/windows-installer-extract-report.cpp +++ b/apps/desktop/tests/windows-installer-extract-report.cpp @@ -5,6 +5,7 @@ #include #include #include "../installer/extract-report.h" +#include "../installer/directory-report.h" extern "C" __declspec(dllimport) int __cdecl InstallerReportExtractFailure( HWND parent, int code, LPCWSTR archive, LPCWSTR destination, LPCWSTR log, LPCWSTR reportPath, int show, @@ -87,6 +88,17 @@ int wmain() { assert(!Contains(excerpt, L"Archive: ")); assert(Excerpt(Compose(2, L"a", L"b", L"one\r\ntwo", L"t", L"w")) == L"Result: 7-Zip exit code 2 (fatal error)\r\none\r\ntwo"); assert(Excerpt(Compose(2, L"a", L"b", std::wstring(2000, L'x'), L"t", L"w")).size() < 64 + kExcerptLineLimit + 64); + std::wstring attempts; + for (int i = 0; i < 30; ++i) attempts += L"restore: Windows error: 5\r\n"; + const std::wstring directoryFull = directory_report::Compose(5, std::wstring(400, L'x').c_str(), L"target", 5, L"backup", attempts); + const std::wstring directoryExcerpt = BoundedExcerpt(Lines(directoryFull)); + assert(Lines(directoryExcerpt).size() <= kExcerptLines + 1); + assert(directoryExcerpt.size() <= kExcerptChars + 64); + assert(Contains(directoryExcerpt, L"Restoration error: 5")); + assert(Contains(directoryExcerpt, L"Preserved backup: backup")); + assert(Contains(directoryExcerpt, L"more lines in the saved report")); + assert(Contains(directoryFull, std::wstring(400, L'x'))); + assert(Contains(directoryFull, attempts)); // A cut inside a multi-byte sequence is trimmed back to a code point boundary. std::string partial("abc\xE6\x96\x87\xE4\xBB"); From c7ab529c342f3e99594a9d93488a44969d0b0c7d Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:11:41 +0800 Subject: [PATCH 5/5] fix(desktop): preserve recovery paths in installer dialogs --- apps/desktop/installer/directory-report.h | 7 +++++++ apps/desktop/installer/window-frame.cpp | 4 ++-- apps/desktop/tests/windows-installer-extract-report.cpp | 8 ++++++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/desktop/installer/directory-report.h b/apps/desktop/installer/directory-report.h index 6a405ca7dd..49f9cd59da 100644 --- a/apps/desktop/installer/directory-report.h +++ b/apps/desktop/installer/directory-report.h @@ -4,6 +4,13 @@ namespace directory_report { +// The recovery path stays complete in the dialog even when its report excerpt is truncated. +inline std::wstring Content(DWORD error, LPCWSTR hint, DWORD restoreError, LPCWSTR backup) { + std::wstring content = std::to_wstring(error) + L": " + extract_report::Win32Message(error) + L"\r\n\r\n" + hint; + if (restoreError) content += L"\r\n\r\n" + std::wstring(backup); + return content; +} + inline std::wstring Compose(DWORD error, LPCWSTR source, LPCWSTR destination, DWORD restoreError, LPCWSTR backup, const std::wstring& attempts) { const std::wstring operation = *destination ? L"MoveFileExW" : L"RemoveDirectoryW"; std::wstring report = L"Operation: " + operation + L"\r\nWindows error: " + std::to_wstring(error) diff --git a/apps/desktop/installer/window-frame.cpp b/apps/desktop/installer/window-frame.cpp index ce454f3f8a..04d258357d 100644 --- a/apps/desktop/installer/window-frame.cpp +++ b/apps/desktop/installer/window-frame.cpp @@ -31,8 +31,8 @@ extern "C" __declspec(dllexport) int __cdecl InstallerReportDirectoryFailure( const auto placeholder = footer.find(L"%s"); if (saved && placeholder != std::wstring::npos) footer.replace(placeholder, 2, reportPath); const extract_report::DialogStrings strings{title, heading, copy, expand, collapse, footer.c_str(), copied}; - const std::wstring content = std::to_wstring(error) + L": " + extract_report::Win32Message(error) + L"\r\n\r\n" + hint; - extract_report::Show(parent, content, extract_report::BoundedExcerpt(extract_report::Lines(report)), report, strings); + const std::wstring content = directory_report::Content(error, hint, restoreError, backup); + extract_report::Show(parent, content, extract_report::BoundedExcerpt(extract_report::Lines(extract_report::Trim(report))), report, strings); } return saved ? 1 : 0; } catch (const std::bad_alloc&) { diff --git a/apps/desktop/tests/windows-installer-extract-report.cpp b/apps/desktop/tests/windows-installer-extract-report.cpp index 54983187da..cda89345db 100644 --- a/apps/desktop/tests/windows-installer-extract-report.cpp +++ b/apps/desktop/tests/windows-installer-extract-report.cpp @@ -91,14 +91,18 @@ int wmain() { std::wstring attempts; for (int i = 0; i < 30; ++i) attempts += L"restore: Windows error: 5\r\n"; const std::wstring directoryFull = directory_report::Compose(5, std::wstring(400, L'x').c_str(), L"target", 5, L"backup", attempts); - const std::wstring directoryExcerpt = BoundedExcerpt(Lines(directoryFull)); + const std::wstring directoryExcerpt = BoundedExcerpt(Lines(Trim(directoryFull))); assert(Lines(directoryExcerpt).size() <= kExcerptLines + 1); assert(directoryExcerpt.size() <= kExcerptChars + 64); assert(Contains(directoryExcerpt, L"Restoration error: 5")); assert(Contains(directoryExcerpt, L"Preserved backup: backup")); - assert(Contains(directoryExcerpt, L"more lines in the saved report")); + assert(Contains(directoryExcerpt, L" (29 more lines in the saved report)")); assert(Contains(directoryFull, std::wstring(400, L'x'))); assert(Contains(directoryFull, attempts)); + const std::wstring longBackup = L"C:\\Users\\long-account-name\\" + std::wstring(200, L'x') + L".old-{12345678-1234-1234-1234-123456789012}"; + assert(longBackup.size() > kExcerptLineLimit); + assert(Contains(directory_report::Content(5, L"Keep this backup:", 5, longBackup.c_str()), longBackup)); + assert(!Contains(directory_report::Content(5, L"Try again.", 0, longBackup.c_str()), longBackup)); // A cut inside a multi-byte sequence is trimmed back to a code point boundary. std::string partial("abc\xE6\x96\x87\xE4\xBB");