Merge pull request #6227 from deepseek-harness/fix/windows-installer-file-locks

fix(desktop): retry Windows installer directory locks and report failures
This commit is contained in:
winewill authored and GitHub committed 2026-10-15 14:37:39 +08:00
commit fa13ef7a66
15 files changed
+361 -29

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
// Directory replacement failures retain the original Windows error and the independent restoration outcome.
#pragma once
#include "extract-report.h"
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)
+ 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;
}
}
+14 -10
View File
@@ -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<std::wstring> 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<std::wstring>& 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<std::wstring> 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;
+6
View File
@@ -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."
+24
View File
@@ -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 = 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&) {
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(
+2 -2
View File
@@ -45,8 +45,8 @@
en: b42f103358d0dfd7
zh: a534164ad9eb00da
/desktop-packaging/runtime-file-selection-and-installation-checks:
en: 06d5c666a3e7f2e4
zh: c1d4a55cc29e11e3
en: bfd89e1d139c5729
zh: 5d6cced5462ba13e
/desktop-packaging/desktop-runtime-preparation:
en: a7aa3066e649ffe8
zh: 4334637910058c09
+2
View File
@@ -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-<timestamp>.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%\<package-derived name>-updater\installer-logs\extract-failure-<timestamp>.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-<version>-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.
+2
View File
@@ -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 过滤会生成该解码器无法解压的条目。
+97 -8
View File
@@ -5,6 +5,71 @@ Var dshNewDirectory
Var dshOldDirectory
Var dshOldMoved
Var dshNewMoved
Var dshDirectoryError
Var dshDirectorySource
Var dshDirectoryDestination
Var dshRestoreError
Var dshDirectoryLogDirectory
Var dshRecoveryStopped
; MoveFileEx preserves the destination and exposes the Windows error before rollback changes it.
; 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
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 "${PHASE}: 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
${If} $dshRestoreError != 0
StrCpy $dshRecoveryStopped "1"
${EndIf}
!ifmacrodef customInstallerDirectoryFailed
!insertmacro customInstallerDirectoryFailed
!else
DetailPrint "Directory operation failed ($dshDirectoryError): $dshDirectorySource -> $dshDirectoryDestination; restore error: $dshRestoreError"
!endif
!macroend
!macro dshExtractPayload FILE
!ifmacrodef customInstallerExtract
@@ -28,6 +93,10 @@ Var dshNewMoved
!macroend
!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
System::Call 'ole32::CoCreateGuid(g .r0) i .r1'
${If} $1 != 0
@@ -38,6 +107,9 @@ Var dshNewMoved
StrCpy $dshOldDirectory "$INSTDIR.old-$0"
StrCpy $dshOldMoved ""
StrCpy $dshNewMoved ""
StrCpy $dshDirectoryError 0
StrCpy $dshRestoreError 0
StrCpy $dshRecoveryStopped ""
ClearErrors
CreateDirectory $dshNewDirectory
${If} ${Errors}
@@ -64,6 +136,9 @@ Function .onGUIEnd
FunctionEnd
Function dshCleanupDirectories
${If} $dshRecoveryStopped == "1"
Return
${EndIf}
${If} $dshFinalDirectory != ""
Call dshRollbackDirectories
${EndIf}
@@ -77,11 +152,11 @@ Function dshRollbackDirectories
StrCpy $dshNewMoved ""
${EndIf}
${If} $dshOldMoved == "1"
ClearErrors
Rename $dshOldDirectory $dshFinalDirectory
${If} ${Errors}
!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 ""
@@ -100,21 +175,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 "backup"
${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 "remove-empty"
${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 "promote"
${If} $dshDirectoryError != 0
Call dshRollbackDirectories
!insertmacro dshReportDirectoryFailure
SetErrors
Return
${EndIf}
+31
View File
@@ -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
@@ -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,83 @@ 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) {
$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
}
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.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 @('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'" }
}
}
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 +131,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 +140,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"
}
@@ -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
@@ -8,6 +8,7 @@
"welcome-ready-before-first-show",
"welcome-presented-on-first-show",
"successful-install-paints-100-before-finish",
"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",
@@ -21,11 +21,32 @@ LangString decompressionFailed ${LANG_ENGLISH} "Payload extraction failed"
Section
InitPluginsDir
FileOpen $0 "${TARGET_DIR}.plugin-dir.tmp" w
FileWriteUTF16LE /BOM $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 "promote"
Call dshRollbackDirectories
!insertmacro dshReportDirectoryFailure
Rename $dshFinalDirectory "${TARGET_DIR}.conflict"
Call dshCleanupDirectories
SetErrorLevel 2
Quit
!endif
!ifdef CANCELLED
SetErrorLevel 2
Call dshCleanupDirectories
@@ -35,12 +56,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
@@ -5,11 +5,17 @@
#include <cassert>
#include <string>
#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,
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) {
@@ -82,6 +88,21 @@ 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(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" (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");
@@ -127,7 +148,24 @@ 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(), 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);
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());
@@ -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-error-and-copy-control-and-preserves-installation')
$process = Start-Setup dark ''
Click-Control $process $copy.INSTALLER_CHOOSE_PATH
$edit = Wait-Control $process $installPath