mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-20 13:53:32 +08:00
parent
ef841c2962
commit
c48171e2f0
79
desktop/build/check-install-processes.ps1
Normal file
79
desktop/build/check-install-processes.ps1
Normal file
@ -0,0 +1,79 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$InstallDir,
|
||||
[Parameter(Mandatory = $true)][string]$ProcessName,
|
||||
[ValidateSet('Find', 'Kill', 'KillForce')][string]$Action = 'Find',
|
||||
[int]$InstallerPid = 0,
|
||||
[int]$InstallerParentPid = 0
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Test-PathInsideInstallDirectory {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Root,
|
||||
[Parameter(Mandatory = $true)][string]$Candidate
|
||||
)
|
||||
|
||||
$resolvedRoot = [IO.Path]::GetFullPath($Root).TrimEnd('\', '/')
|
||||
$resolvedCandidate = [IO.Path]::GetFullPath($Candidate)
|
||||
$rootWithSeparator = $resolvedRoot + [IO.Path]::DirectorySeparatorChar
|
||||
return $resolvedCandidate.StartsWith(
|
||||
$rootWithSeparator,
|
||||
[StringComparison]::OrdinalIgnoreCase)
|
||||
}
|
||||
|
||||
try {
|
||||
$matches = New-Object 'System.Collections.Generic.List[object]'
|
||||
$unknownPathMatches = New-Object 'System.Collections.Generic.List[object]'
|
||||
foreach ($process in @(Get-CimInstance Win32_Process -ErrorAction Stop)) {
|
||||
if ($process.ProcessId -eq $InstallerPid) {
|
||||
continue
|
||||
}
|
||||
|
||||
$executablePath = [string]$process.ExecutablePath
|
||||
if ([string]::IsNullOrWhiteSpace($executablePath)) {
|
||||
if (([string]$process.Name).Equals($ProcessName, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
$unknownPathMatches.Add($process)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (Test-PathInsideInstallDirectory -Root $InstallDir -Candidate $executablePath) {
|
||||
$matches.Add($process)
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($process in $matches) {
|
||||
$path = if ([string]::IsNullOrWhiteSpace([string]$process.ExecutablePath)) {
|
||||
'<path unavailable>'
|
||||
} else {
|
||||
[string]$process.ExecutablePath
|
||||
}
|
||||
[Console]::Out.WriteLine(
|
||||
"Matched protected install process: PID=$($process.ProcessId); Name=$($process.Name); Path=$path")
|
||||
}
|
||||
|
||||
foreach ($process in $unknownPathMatches) {
|
||||
[Console]::Out.WriteLine(
|
||||
"Blocked unknown-path application process: PID=$($process.ProcessId); Name=$($process.Name); close it manually")
|
||||
}
|
||||
|
||||
if ($matches.Count -eq 0 -and $unknownPathMatches.Count -eq 0) {
|
||||
exit 1
|
||||
}
|
||||
if ($Action -eq 'Find') {
|
||||
exit 0
|
||||
}
|
||||
|
||||
$force = $Action -eq 'KillForce'
|
||||
foreach ($process in $matches) {
|
||||
Stop-Process -Id $process.ProcessId -Force:$force -ErrorAction Stop
|
||||
}
|
||||
exit 0
|
||||
} catch {
|
||||
$message = ([string]$_.Exception.Message) -replace '[\r\n]+', ' '
|
||||
[Console]::Out.WriteLine("Install process check failed closed: $message")
|
||||
exit 0
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
!define /ifndef INSTALL_REGISTRY_KEY "Software\${APP_GUID}"
|
||||
!define /ifndef UNINSTALL_REGISTRY_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_APP_KEY}"
|
||||
Var pid
|
||||
Var ccHahaProcessDiagnostic
|
||||
|
||||
!ifndef BUILD_UNINSTALLER
|
||||
Var ccHahaRecoveryDone
|
||||
@ -93,6 +94,71 @@ Function CcHahaFinalInstallDir
|
||||
Exch $R0
|
||||
FunctionEnd
|
||||
|
||||
Function CcHahaCanSkipLegacyRecovery
|
||||
Push $R3
|
||||
Push $R0
|
||||
Push $R1
|
||||
Push $R2
|
||||
|
||||
StrCpy $R0 "0"
|
||||
${If} $8 != "trusted-user"
|
||||
Goto cc_haha_skip_recovery_done
|
||||
${EndIf}
|
||||
${If} $ccHahaPerUserInstallLocation == ""
|
||||
Goto cc_haha_skip_recovery_done
|
||||
${EndIf}
|
||||
${If} $ccHahaPerMachineInstallLocation != ""
|
||||
Goto cc_haha_skip_recovery_done
|
||||
${EndIf}
|
||||
${If} $ccHahaPerMachineUninstallString != ""
|
||||
Goto cc_haha_skip_recovery_done
|
||||
${EndIf}
|
||||
StrCmp $ccHahaPerUserInstallLocation $INSTDIR 0 cc_haha_skip_recovery_done
|
||||
|
||||
ReadEnvStr $R1 APPDATA
|
||||
${If} $R1 == ""
|
||||
Goto cc_haha_skip_recovery_done
|
||||
${EndIf}
|
||||
ReadEnvStr $R0 CLAUDE_CONFIG_DIR
|
||||
${If} $R0 != ""
|
||||
StrCpy $R0 "0"
|
||||
Goto cc_haha_skip_recovery_done
|
||||
${EndIf}
|
||||
StrCpy $R0 "0"
|
||||
|
||||
IfFileExists "$ccHahaPerUserInstallLocation\CLAUDE_CONFIG_DIR\*.*" cc_haha_skip_recovery_done 0
|
||||
IfFileExists "$R1\Claude Code Haha\app-mode.json" cc_haha_check_default_mode 0
|
||||
StrCpy $R0 "1"
|
||||
Goto cc_haha_skip_recovery_done
|
||||
|
||||
cc_haha_check_default_mode:
|
||||
ClearErrors
|
||||
FileOpen $R2 "$R1\Claude Code Haha\app-mode.json" r
|
||||
IfErrors cc_haha_skip_recovery_done 0
|
||||
FileRead $R2 $R3
|
||||
StrCmp $R3 '{$\n' 0 cc_haha_close_mode_file
|
||||
FileRead $R2 $R3
|
||||
StrCmp $R3 ' "mode": "default",$\n' 0 cc_haha_close_mode_file
|
||||
FileRead $R2 $R3
|
||||
StrCmp $R3 ' "portable_dir": null$\n' 0 cc_haha_close_mode_file
|
||||
FileRead $R2 $R3
|
||||
StrCmp $R3 '}' 0 cc_haha_close_mode_file
|
||||
ClearErrors
|
||||
FileRead $R2 $R3
|
||||
IfErrors 0 cc_haha_close_mode_file
|
||||
StrCpy $R0 "1"
|
||||
|
||||
cc_haha_close_mode_file:
|
||||
FileClose $R2
|
||||
|
||||
cc_haha_skip_recovery_done:
|
||||
StrCpy $R3 $R0
|
||||
Pop $R2
|
||||
Pop $R1
|
||||
Pop $R0
|
||||
Exch $R3
|
||||
FunctionEnd
|
||||
|
||||
Function CcHahaRecoverLegacy
|
||||
ReadRegStr $4 HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation
|
||||
ReadRegStr $5 HKLM "${INSTALL_REGISTRY_KEY}" InstallLocation
|
||||
@ -184,34 +250,152 @@ FunctionEnd
|
||||
${AndIfNot} ${UAC_IsInnerInstance}
|
||||
StrCpy $8 "untrusted-elevated"
|
||||
${EndIf}
|
||||
|
||||
${If} ${UAC_IsInnerInstance}
|
||||
StrCpy $8 "trusted-uac-outer"
|
||||
!insertmacro UAC_AsUser_Call Function CcHahaRecoverLegacy ${UAC_SYNCREGISTERS}|${UAC_SYNCOUTDIR}|${UAC_SYNCINSTDIR}
|
||||
${Else}
|
||||
Call CcHahaRecoverLegacy
|
||||
${EndIf}
|
||||
|
||||
${If} $0 != "0"
|
||||
DetailPrint "Legacy data recovery stopped the installer (helper exit code: $0; output: $1)"
|
||||
${If} $1 == ""
|
||||
StrCpy $1 "Recovery helper failed without diagnostic output (exit code $0)"
|
||||
Call CcHahaCanSkipLegacyRecovery
|
||||
Pop $R0
|
||||
${If} $R0 == "1"
|
||||
StrCpy $ccHahaRecoveryDone "1"
|
||||
DetailPrint "No legacy data candidates found for the registered per-user installation"
|
||||
${Else}
|
||||
${If} ${UAC_IsInnerInstance}
|
||||
!insertmacro UAC_AsUser_Call Function CcHahaRecoverLegacy ${UAC_SYNCREGISTERS}|${UAC_SYNCOUTDIR}|${UAC_SYNCINSTDIR}
|
||||
${Else}
|
||||
Call CcHahaRecoverLegacy
|
||||
${EndIf}
|
||||
StrCpy $R2 "$1" 360
|
||||
MessageBox MB_ICONSTOP|MB_OK "Claude Code Haha stopped setup before removing the old version. Reason: $R2$\r$\n$\r$\nClose the app and retry. If the reason mentions an elevated installer, launch setup normally instead of using Run as administrator.$\r$\n$\r$\nClaude Code Haha 已在删除旧版本前停止安装。原因:$R2$\r$\n$\r$\n请关闭旧程序后重试;如果原因提到安装器权限过高,请直接双击运行,不要使用“以管理员身份运行”。旧版本和原数据尚未删除。" /SD IDOK
|
||||
SetErrorLevel 20
|
||||
Quit
|
||||
|
||||
${If} $0 != "0"
|
||||
DetailPrint "Legacy data recovery stopped the installer (helper exit code: $0; output: $1)"
|
||||
${If} $1 == ""
|
||||
StrCpy $1 "Recovery helper failed without diagnostic output (exit code $0)"
|
||||
${EndIf}
|
||||
StrCpy $R2 "$1" 360
|
||||
MessageBox MB_ICONSTOP|MB_OK "Claude Code Haha stopped setup before removing the old version. Reason: $R2$\r$\n$\r$\nClose the app and retry. If the reason mentions an elevated installer, launch setup normally instead of using Run as administrator.$\r$\n$\r$\nClaude Code Haha 已在删除旧版本前停止安装。原因:$R2$\r$\n$\r$\n请关闭旧程序后重试;如果原因提到安装器权限过高,请直接双击运行,不要使用“以管理员身份运行”。旧版本和原数据尚未删除。" /SD IDOK
|
||||
SetErrorLevel 20
|
||||
Quit
|
||||
${EndIf}
|
||||
StrCpy $ccHahaRecoveryDone "1"
|
||||
DetailPrint "Legacy Claude Code Haha data safety check completed"
|
||||
${EndIf}
|
||||
StrCpy $ccHahaRecoveryDone "1"
|
||||
DetailPrint "Legacy Claude Code Haha data safety check completed"
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
!macroend
|
||||
!endif
|
||||
|
||||
!macro CcHahaFindInstallProcess _FILE _RETURN
|
||||
${If} $IsPowerShellAvailable == 0
|
||||
nsExec::ExecToStack '"$PowerShellPath" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\check-install-processes.ps1" -InstallDir "$INSTDIR" -ProcessName "${_FILE}" -Action Find -InstallerPid "$pid" -InstallerParentPid "$1"'
|
||||
Pop ${_RETURN}
|
||||
Pop $ccHahaProcessDiagnostic
|
||||
${If} $ccHahaProcessDiagnostic != ""
|
||||
DetailPrint "$ccHahaProcessDiagnostic"
|
||||
${EndIf}
|
||||
${Else}
|
||||
Delete "$PLUGINSDIR\cc-haha-processes.csv"
|
||||
!ifdef INSTALL_MODE_PER_ALL_USERS
|
||||
nsExec::Exec '"$CmdPath" /D /C tasklist /FO CSV /NH > "$PLUGINSDIR\cc-haha-processes.csv"'
|
||||
!else
|
||||
nsExec::Exec '"$CmdPath" /D /C tasklist /FI "USERNAME eq %USERNAME%" /FO CSV /NH > "$PLUGINSDIR\cc-haha-processes.csv"'
|
||||
!endif
|
||||
Pop ${_RETURN}
|
||||
${If} ${_RETURN} != 0
|
||||
StrCpy $ccHahaProcessDiagnostic "PowerShell unavailable and tasklist process enumeration failed (exit code ${_RETURN}); blocking setup."
|
||||
StrCpy ${_RETURN} 0
|
||||
${Else}
|
||||
nsExec::Exec '"$SYSDIR\findstr.exe" /I /L /C:"${_FILE}" /C:"claude-sidecar-x86_64-pc-windows-msvc.exe" /C:"claude-sidecar-aarch64-pc-windows-msvc.exe" /C:"claude-sidecar.exe" /C:"OpenConsole.exe" /C:"winpty-agent.exe" /C:"rg.exe" "$PLUGINSDIR\cc-haha-processes.csv"'
|
||||
Pop ${_RETURN}
|
||||
${If} ${_RETURN} == 0
|
||||
StrCpy $ccHahaProcessDiagnostic "PowerShell unavailable; the main app, a known sidecar, or a bundled terminal/search helper is running with an unknown path. Close it manually."
|
||||
${ElseIf} ${_RETURN} == 1
|
||||
StrCpy $ccHahaProcessDiagnostic "PowerShell unavailable; exact-image fallback found no main app, known sidecar, or bundled terminal/search helper. Differently named child processes cannot be attributed without path data."
|
||||
${Else}
|
||||
StrCpy $ccHahaProcessDiagnostic "PowerShell unavailable and fallback process filtering failed (exit code ${_RETURN}); blocking setup."
|
||||
StrCpy ${_RETURN} 0
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
DetailPrint "$ccHahaProcessDiagnostic"
|
||||
${EndIf}
|
||||
!macroend
|
||||
|
||||
!macro CcHahaKillInstallProcess _FILE _FORCE
|
||||
Push $0
|
||||
${If} ${_FORCE} == 1
|
||||
StrCpy $0 "KillForce"
|
||||
${Else}
|
||||
StrCpy $0 "Kill"
|
||||
${EndIf}
|
||||
|
||||
${If} $IsPowerShellAvailable == 0
|
||||
nsExec::ExecToStack '"$PowerShellPath" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\check-install-processes.ps1" -InstallDir "$INSTDIR" -ProcessName "${_FILE}" -Action "$0" -InstallerPid "$pid" -InstallerParentPid "$1"'
|
||||
Pop $0
|
||||
Pop $ccHahaProcessDiagnostic
|
||||
${If} $ccHahaProcessDiagnostic != ""
|
||||
DetailPrint "$ccHahaProcessDiagnostic"
|
||||
${EndIf}
|
||||
${Else}
|
||||
StrCpy $ccHahaProcessDiagnostic "PowerShell unavailable; refusing to terminate by image name because the executable path is unknown. Close the app manually."
|
||||
DetailPrint "$ccHahaProcessDiagnostic"
|
||||
${EndIf}
|
||||
Pop $0
|
||||
!macroend
|
||||
|
||||
!macro customCheckAppRunning
|
||||
InitPluginsDir
|
||||
File /oname=$PLUGINSDIR\check-install-processes.ps1 "${BUILD_RESOURCES_DIR}\check-install-processes.ps1"
|
||||
!insertmacro IS_POWERSHELL_AVAILABLE
|
||||
!insertmacro _CHECK_APP_RUNNING
|
||||
StrCpy $ccHahaProcessDiagnostic ""
|
||||
${GetProcessInfo} 0 $pid $1 $2 $3 $4
|
||||
${If} $3 != "${APP_EXECUTABLE_FILENAME}"
|
||||
${If} ${isUpdated}
|
||||
Sleep 300
|
||||
${EndIf}
|
||||
|
||||
!insertmacro CcHahaFindInstallProcess "${APP_EXECUTABLE_FILENAME}" $R0
|
||||
${If} $R0 == 0
|
||||
${If} ${isUpdated}
|
||||
Sleep 1000
|
||||
Goto cc_haha_stop_process
|
||||
${EndIf}
|
||||
MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION "$(appRunning)" /SD IDOK IDOK cc_haha_stop_process
|
||||
SetErrorLevel 22
|
||||
Quit
|
||||
|
||||
cc_haha_stop_process:
|
||||
DetailPrint "$(appClosing)"
|
||||
!insertmacro CcHahaKillInstallProcess "${APP_EXECUTABLE_FILENAME}" 0
|
||||
Sleep 300
|
||||
StrCpy $R1 0
|
||||
|
||||
cc_haha_process_retry:
|
||||
IntOp $R1 $R1 + 1
|
||||
!insertmacro CcHahaFindInstallProcess "${APP_EXECUTABLE_FILENAME}" $R0
|
||||
${If} $R0 == 0
|
||||
Sleep 1000
|
||||
!insertmacro CcHahaKillInstallProcess "${APP_EXECUTABLE_FILENAME}" 1
|
||||
!insertmacro CcHahaFindInstallProcess "${APP_EXECUTABLE_FILENAME}" $R0
|
||||
${If} $R0 == 0
|
||||
DetailPrint `Waiting for "${PRODUCT_NAME}" to close.`
|
||||
Sleep 2000
|
||||
${Else}
|
||||
Goto cc_haha_process_not_running
|
||||
${EndIf}
|
||||
${Else}
|
||||
Goto cc_haha_process_not_running
|
||||
${EndIf}
|
||||
|
||||
${If} $R1 > 1
|
||||
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeClosed)$\r$\n$\r$\n$ccHahaProcessDiagnostic" /SD IDCANCEL IDRETRY cc_haha_process_retry
|
||||
SetErrorLevel 22
|
||||
Quit
|
||||
${Else}
|
||||
Goto cc_haha_process_retry
|
||||
${EndIf}
|
||||
|
||||
cc_haha_process_not_running:
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
!ifndef BUILD_UNINSTALLER
|
||||
!insertmacro CcHahaRunLegacyRecovery
|
||||
!endif
|
||||
|
||||
@ -17,83 +17,113 @@ param(
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not ('CcHahaRecoveryNativePath' -as [type])) {
|
||||
Add-Type @'
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
function Assert-NoUnsupportedPathAlias {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
|
||||
public static class CcHahaRecoveryNativePath
|
||||
{
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern SafeFileHandle CreateFile(
|
||||
string fileName,
|
||||
uint desiredAccess,
|
||||
uint shareMode,
|
||||
IntPtr securityAttributes,
|
||||
uint creationDisposition,
|
||||
uint flagsAndAttributes,
|
||||
IntPtr templateFile);
|
||||
if ($Path.StartsWith('\\?\', [StringComparison]::OrdinalIgnoreCase) -or
|
||||
$Path.StartsWith('\\.\', [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Extended device or volume aliases cannot be recovered safely without a final-path helper: $Path"
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern uint GetFinalPathNameByHandle(
|
||||
SafeFileHandle file,
|
||||
StringBuilder path,
|
||||
uint pathLength,
|
||||
uint flags);
|
||||
$fullPath = [IO.Path]::GetFullPath($Path)
|
||||
$root = [IO.Path]::GetPathRoot($fullPath)
|
||||
if ([string]::IsNullOrWhiteSpace($root) -or $root -notmatch '^[A-Za-z]:\\$') {
|
||||
throw "Only a local volume drive path can be recovered safely: $Path"
|
||||
}
|
||||
|
||||
public static string Resolve(string path)
|
||||
{
|
||||
const uint shareReadWriteDelete = 0x00000007;
|
||||
const uint openExisting = 3;
|
||||
const uint backupSemantics = 0x02000000;
|
||||
using (SafeFileHandle handle = CreateFile(
|
||||
path,
|
||||
0,
|
||||
shareReadWriteDelete,
|
||||
IntPtr.Zero,
|
||||
openExisting,
|
||||
backupSemantics,
|
||||
IntPtr.Zero))
|
||||
{
|
||||
if (handle.IsInvalid) {
|
||||
throw new Win32Exception(
|
||||
Marshal.GetLastWin32Error(),
|
||||
"Cannot resolve the final path for " + path);
|
||||
}
|
||||
StringBuilder result = new StringBuilder(32768);
|
||||
uint length = GetFinalPathNameByHandle(handle, result, (uint)result.Capacity, 0);
|
||||
if (length == 0) {
|
||||
throw new Win32Exception(
|
||||
Marshal.GetLastWin32Error(),
|
||||
"Cannot resolve the final path for " + path);
|
||||
}
|
||||
if (length >= (uint)result.Capacity) {
|
||||
throw new InvalidOperationException("Resolved path is too long: " + path);
|
||||
}
|
||||
string value = result.ToString();
|
||||
if (value.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase)) {
|
||||
return @"\\" + value.Substring(8);
|
||||
}
|
||||
return value.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase)
|
||||
? value.Substring(4)
|
||||
: value;
|
||||
}
|
||||
$relative = $fullPath.Substring($root.Length)
|
||||
$current = $root
|
||||
foreach ($segment in $relative.Split(
|
||||
[char[]]@([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar),
|
||||
[StringSplitOptions]::RemoveEmptyEntries)) {
|
||||
$next = [IO.Path]::Combine($current, $segment)
|
||||
$exists = [IO.Directory]::Exists($next) -or [IO.File]::Exists($next)
|
||||
if ($exists) {
|
||||
try {
|
||||
$exactEntry = Get-ChildItem -LiteralPath $current -Force -ErrorAction Stop |
|
||||
Where-Object { ([string]$_.Name).Equals($segment, [StringComparison]::OrdinalIgnoreCase) } |
|
||||
Select-Object -First 1
|
||||
} catch {
|
||||
throw "Path segment identity cannot be proven because its parent cannot be enumerated: $next"
|
||||
}
|
||||
if ($null -eq $exactEntry) {
|
||||
$item = Get-Item -LiteralPath $next -Force -ErrorAction SilentlyContinue
|
||||
$resolvedName = if ($null -eq $item) { '<unavailable>' } else { [string]$item.Name }
|
||||
throw "Alternate path alias cannot be recovered safely without a final-path helper: $next (resolved item name: $resolvedName)"
|
||||
}
|
||||
} elseif ($segment -match '^[^\\/:*?"<>|.~]{1,6}~[0-9]{1,6}(?:\.[^\\/:*?"<>|]{1,3})?$') {
|
||||
throw "Possible 8.3 path alias cannot be proven safe because the segment does not exist: $next"
|
||||
}
|
||||
$current = $next
|
||||
}
|
||||
|
||||
$subst = Join-Path $env:SystemRoot 'System32\subst.exe'
|
||||
$substOutput = @(& $subst 2>$null)
|
||||
$substDrive = $root.Substring(0, 2)
|
||||
$substMatch = $substOutput | Where-Object {
|
||||
([string]$_).TrimStart().StartsWith($substDrive, [StringComparison]::OrdinalIgnoreCase) -and
|
||||
([string]$_).Contains('=>')
|
||||
} | Select-Object -First 1
|
||||
if ($LASTEXITCODE -eq 0 -and $null -ne $substMatch) {
|
||||
throw "SUBST aliases cannot be recovered safely without a final-path helper: $Path"
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
function Assert-NoReparsePointInPath {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
|
||||
$fullPath = [IO.Path]::GetFullPath($Path)
|
||||
$root = [IO.Path]::GetPathRoot($fullPath)
|
||||
if ([string]::IsNullOrWhiteSpace($root)) {
|
||||
throw "Cannot determine the path root for $Path"
|
||||
}
|
||||
|
||||
$current = $root
|
||||
$rootAttributes = [IO.File]::GetAttributes($current)
|
||||
if (($rootAttributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw "Path root is a reparse point and cannot be recovered safely: $current"
|
||||
}
|
||||
$relative = $fullPath.Substring($root.Length)
|
||||
$segments = $relative.Split(
|
||||
[char[]]@([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar),
|
||||
[StringSplitOptions]::RemoveEmptyEntries)
|
||||
foreach ($segment in $segments) {
|
||||
$current = [IO.Path]::Combine($current, $segment)
|
||||
$attributes = [IO.File]::GetAttributes($current)
|
||||
if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw "Path contains a reparse point and cannot be recovered safely: $current"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Convert-ToCanonicalVolumePathIdentity {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
|
||||
$fullPath = [IO.Path]::GetFullPath($Path)
|
||||
$root = [IO.Path]::GetPathRoot($fullPath)
|
||||
$mountvol = Join-Path $env:SystemRoot 'System32\mountvol.exe'
|
||||
$output = @(& $mountvol $root /L 2>$null)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Cannot resolve the canonical volume for $Path"
|
||||
}
|
||||
$volumeRoots = @($output |
|
||||
ForEach-Object { ([string]$_).Trim() } |
|
||||
Where-Object { $_ -match '^\\\\\?\\Volume\{[0-9A-Fa-f-]+\}\\$' })
|
||||
if ($volumeRoots.Count -ne 1) {
|
||||
throw "Cannot resolve one unambiguous canonical volume for $Path"
|
||||
}
|
||||
$relative = $fullPath.Substring($root.Length)
|
||||
return [IO.Path]::Combine($volumeRoots[0], $relative)
|
||||
}
|
||||
|
||||
function Resolve-CanonicalPath {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
|
||||
Assert-NoUnsupportedPathAlias -Path $Path
|
||||
$fullPath = [IO.Path]::GetFullPath($Path)
|
||||
$existingPath = $fullPath
|
||||
$missingSegments = New-Object 'System.Collections.Generic.List[string]'
|
||||
while (-not (Test-Path -LiteralPath $existingPath)) {
|
||||
while (-not ([IO.Directory]::Exists($existingPath) -or [IO.File]::Exists($existingPath))) {
|
||||
$parent = [IO.Path]::GetDirectoryName($existingPath)
|
||||
if ([string]::IsNullOrEmpty($parent) -or $parent -eq $existingPath) {
|
||||
throw "Cannot resolve an existing ancestor for $Path"
|
||||
@ -102,21 +132,30 @@ function Resolve-CanonicalPath {
|
||||
$existingPath = $parent
|
||||
}
|
||||
|
||||
$resolved = [CcHahaRecoveryNativePath]::Resolve($existingPath)
|
||||
Assert-NoReparsePointInPath -Path $existingPath
|
||||
$resolved = [IO.Path]::GetFullPath($existingPath)
|
||||
foreach ($segment in $missingSegments) {
|
||||
$resolved = Join-Path $resolved $segment
|
||||
$resolved = [IO.Path]::Combine($resolved, $segment)
|
||||
}
|
||||
return [IO.Path]::GetFullPath($resolved)
|
||||
}
|
||||
|
||||
function Get-CanonicalPathIdentity {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
|
||||
# Keep the DOS path for filesystem operations; use the volume GUID form only as a comparison key.
|
||||
$resolved = Resolve-CanonicalPath -Path $Path
|
||||
return Convert-ToCanonicalVolumePathIdentity -Path $resolved
|
||||
}
|
||||
|
||||
function Test-PathAtOrBelow {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Parent,
|
||||
[Parameter(Mandatory = $true)][string]$Candidate
|
||||
)
|
||||
|
||||
$resolvedParent = (Resolve-CanonicalPath $Parent).TrimEnd('\', '/')
|
||||
$resolvedCandidate = (Resolve-CanonicalPath $Candidate).TrimEnd('\', '/')
|
||||
$resolvedParent = (Get-CanonicalPathIdentity $Parent).TrimEnd('\', '/')
|
||||
$resolvedCandidate = (Get-CanonicalPathIdentity $Candidate).TrimEnd('\', '/')
|
||||
if ($resolvedCandidate.Equals($resolvedParent, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
return $true
|
||||
}
|
||||
@ -131,6 +170,8 @@ function Test-LexicalPathAtOrBelow {
|
||||
[Parameter(Mandatory = $true)][string]$Candidate
|
||||
)
|
||||
|
||||
Assert-NoUnsupportedPathAlias -Path $Parent
|
||||
Assert-NoUnsupportedPathAlias -Path $Candidate
|
||||
$fullParent = [IO.Path]::GetFullPath($Parent).TrimEnd('\', '/')
|
||||
$fullCandidate = [IO.Path]::GetFullPath($Candidate).TrimEnd('\', '/')
|
||||
if ($fullCandidate.Equals($fullParent, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
@ -156,8 +197,8 @@ function Test-SamePath {
|
||||
[Parameter(Mandatory = $true)][string]$Left,
|
||||
[Parameter(Mandatory = $true)][string]$Right
|
||||
)
|
||||
return (Resolve-CanonicalPath $Left).TrimEnd('\', '/').Equals(
|
||||
(Resolve-CanonicalPath $Right).TrimEnd('\', '/'),
|
||||
return (Get-CanonicalPathIdentity $Left).TrimEnd('\', '/').Equals(
|
||||
(Get-CanonicalPathIdentity $Right).TrimEnd('\', '/'),
|
||||
[StringComparison]::OrdinalIgnoreCase)
|
||||
}
|
||||
|
||||
@ -284,9 +325,10 @@ function Get-ExistingInstallDirs {
|
||||
if ([string]::IsNullOrWhiteSpace($installDir) -or -not (Test-Path -LiteralPath $installDir -PathType Container)) {
|
||||
continue
|
||||
}
|
||||
$canonical = Resolve-CanonicalPath $installDir
|
||||
if ($seen.Add($canonical)) {
|
||||
$result.Add($canonical)
|
||||
$resolved = Resolve-CanonicalPath $installDir
|
||||
$identity = Convert-ToCanonicalVolumePathIdentity $resolved
|
||||
if ($seen.Add($identity)) {
|
||||
$result.Add($resolved)
|
||||
}
|
||||
}
|
||||
return $result.ToArray()
|
||||
@ -304,6 +346,7 @@ function Get-PotentialInstallDirs {
|
||||
if (-not [IO.Path]::IsPathRooted($installDir)) {
|
||||
throw "Application install directory is relative and cannot be checked safely: $installDir"
|
||||
}
|
||||
Assert-NoUnsupportedPathAlias -Path $installDir
|
||||
$fullPath = [IO.Path]::GetFullPath($installDir)
|
||||
if ($seen.Add($fullPath)) {
|
||||
$result.Add($fullPath)
|
||||
@ -339,7 +382,7 @@ function Get-UnsafeLegacySource {
|
||||
throw "The active data directory is the application install root itself: $active"
|
||||
}
|
||||
if (Test-Path -LiteralPath $active -PathType Container) {
|
||||
$canonicalActive = Resolve-CanonicalPath $active
|
||||
$canonicalActive = Get-CanonicalPathIdentity $active
|
||||
if (-not $sources.ContainsKey($canonicalActive)) {
|
||||
$sources.Add($canonicalActive, $active)
|
||||
}
|
||||
@ -379,7 +422,7 @@ function Get-UnsafeLegacySource {
|
||||
if (Test-SamePath -Left $possiblyDeletedRoot -Right $source) {
|
||||
throw "The active data directory is the application install root itself: $source"
|
||||
}
|
||||
$canonicalSource = Resolve-CanonicalPath $source
|
||||
$canonicalSource = Get-CanonicalPathIdentity $source
|
||||
if (-not $sources.ContainsKey($canonicalSource)) {
|
||||
$sources.Add($canonicalSource, $source)
|
||||
}
|
||||
@ -726,6 +769,60 @@ function Run-SelfTest {
|
||||
$testRoot = Join-Path ([IO.Path]::GetTempPath()) "cc-haha-storage-recovery-$([Guid]::NewGuid().ToString('N'))"
|
||||
New-Item -ItemType Directory -Path $testRoot | Out-Null
|
||||
try {
|
||||
$canonicalTestRoot = Get-CanonicalPathIdentity $testRoot
|
||||
Assert-SelfTest `
|
||||
-Condition ($canonicalTestRoot.StartsWith('\\?\Volume{', [StringComparison]::OrdinalIgnoreCase)) `
|
||||
-Message 'ordinary drive path did not receive a canonical volume identity'
|
||||
|
||||
$volumeAliasFailed = $false
|
||||
try {
|
||||
Resolve-CanonicalPath -Path $canonicalTestRoot | Out-Null
|
||||
} catch {
|
||||
$volumeAliasFailed = $_.Exception.Message.Contains('Extended device or volume aliases')
|
||||
}
|
||||
Assert-SelfTest -Condition $volumeAliasFailed -Message 'extended volume alias did not fail closed'
|
||||
|
||||
$shortAliasFailed = $false
|
||||
try {
|
||||
Resolve-CanonicalPath -Path (Join-Path ([IO.Path]::GetPathRoot($testRoot)) 'PROGRA~1') | Out-Null
|
||||
} catch {
|
||||
$shortAliasFailed = $_.Exception.Message.Contains('8.3 path alias')
|
||||
}
|
||||
Assert-SelfTest -Condition $shortAliasFailed -Message 'possible 8.3 alias did not fail closed'
|
||||
|
||||
$legalTildePath = Join-Path $testRoot 'project~notes'
|
||||
New-Item -ItemType Directory -Path $legalTildePath | Out-Null
|
||||
$resolvedLegalTildePath = Resolve-CanonicalPath -Path $legalTildePath
|
||||
Assert-SelfTest `
|
||||
-Condition ($resolvedLegalTildePath.EndsWith('project~notes', [StringComparison]::OrdinalIgnoreCase)) `
|
||||
-Message 'legal long directory name containing a tilde was blocked as an 8.3 alias'
|
||||
|
||||
$substDrive = $null
|
||||
foreach ($codePoint in (90..68)) {
|
||||
$candidateDrive = "$([char]$codePoint):"
|
||||
if (-not (Test-Path -LiteralPath "$candidateDrive\")) {
|
||||
$substDrive = $candidateDrive
|
||||
break
|
||||
}
|
||||
}
|
||||
Assert-SelfTest -Condition ($null -ne $substDrive) -Message 'no drive letter was available for the SUBST alias test'
|
||||
$subst = Join-Path $env:SystemRoot 'System32\subst.exe'
|
||||
& $subst $substDrive $testRoot
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Self-test could not create SUBST alias $substDrive for $testRoot"
|
||||
}
|
||||
try {
|
||||
$substAliasFailed = $false
|
||||
try {
|
||||
Resolve-CanonicalPath -Path "$substDrive\" | Out-Null
|
||||
} catch {
|
||||
$substAliasFailed = $_.Exception.Message.Contains('SUBST aliases')
|
||||
}
|
||||
Assert-SelfTest -Condition $substAliasFailed -Message 'SUBST alias did not fail closed'
|
||||
} finally {
|
||||
& $subst $substDrive /D | Out-Null
|
||||
}
|
||||
|
||||
$install = Join-Path $testRoot 'old install'
|
||||
$legacy = Join-Path $install 'CLAUDE_CONFIG_DIR'
|
||||
$userData = Join-Path $testRoot 'app data'
|
||||
|
||||
@ -27,12 +27,45 @@ $userProfile = Join-Path $testRoot 'UserProfile'
|
||||
$appExe = Join-Path $installDir 'Claude Code Haha.exe'
|
||||
$uninstaller = Join-Path $installDir 'Uninstall Claude Code Haha.exe'
|
||||
$recoveryHelper = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\build\recover-legacy-install-data.ps1')).Path
|
||||
$processHelper = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\build\check-install-processes.ps1')).Path
|
||||
$siblingProcess = $null
|
||||
$installProcess = $null
|
||||
$bundledHelperProcess = $null
|
||||
|
||||
$savedEnvironment = @{}
|
||||
foreach ($name in @('APPDATA', 'LOCALAPPDATA', 'USERPROFILE', 'CLAUDE_CONFIG_DIR', 'CC_HAHA_APP_PORTABLE_DIR')) {
|
||||
foreach ($name in @('APPDATA', 'LOCALAPPDATA', 'USERPROFILE', 'CLAUDE_CONFIG_DIR', 'CC_HAHA_APP_PORTABLE_DIR', 'COMPLUS_Version')) {
|
||||
$savedEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, 'Process')
|
||||
}
|
||||
|
||||
function Invoke-ProcessExpectFailure {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$FilePath,
|
||||
[Parameter(Mandatory = $true)][string]$Stage,
|
||||
[Parameter(Mandatory = $true)][string[]]$Arguments,
|
||||
[int]$ExpectedExitCode,
|
||||
[int]$TimeoutSeconds = 180
|
||||
)
|
||||
|
||||
[Console]::Out.WriteLine("$Stage starting...")
|
||||
$process = Start-Process -FilePath $FilePath -ArgumentList $Arguments -PassThru
|
||||
try {
|
||||
if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
|
||||
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
|
||||
throw "$Stage timed out after $TimeoutSeconds seconds."
|
||||
}
|
||||
if ($PSBoundParameters.ContainsKey('ExpectedExitCode')) {
|
||||
if ($process.ExitCode -ne $ExpectedExitCode) {
|
||||
throw "$Stage expected process exit code $ExpectedExitCode, received $($process.ExitCode)."
|
||||
}
|
||||
} elseif ($process.ExitCode -eq 0) {
|
||||
throw "$Stage unexpectedly succeeded with process exit code 0."
|
||||
}
|
||||
[Console]::Out.WriteLine("$Stage failed safely with process exit code $($process.ExitCode).")
|
||||
} finally {
|
||||
$process.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-CheckedProcess {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$FilePath,
|
||||
@ -57,6 +90,32 @@ function Invoke-CheckedProcess {
|
||||
}
|
||||
}
|
||||
|
||||
function Test-IsProcessElevated {
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
try {
|
||||
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
|
||||
return ($principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator))
|
||||
} finally {
|
||||
$identity.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-ProcessHelperExpectExit {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Stage,
|
||||
[Parameter(Mandatory = $true)][int]$ExpectedExitCode,
|
||||
[Parameter(Mandatory = $true)][string[]]$Arguments
|
||||
)
|
||||
|
||||
$windowsPowerShell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
||||
[Console]::Out.WriteLine("$Stage starting...")
|
||||
& $windowsPowerShell -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $processHelper @Arguments
|
||||
if ($LASTEXITCODE -ne $ExpectedExitCode) {
|
||||
throw "$Stage expected exit code $ExpectedExitCode, received $LASTEXITCODE."
|
||||
}
|
||||
[Console]::Out.WriteLine("$Stage completed with expected exit code $ExpectedExitCode.")
|
||||
}
|
||||
|
||||
function Invoke-LegacyRecoveryDiagnostic {
|
||||
$windowsPowerShell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
||||
$arguments = @(
|
||||
@ -106,14 +165,91 @@ try {
|
||||
throw "Fresh install did not create the application executable: $appExe"
|
||||
}
|
||||
|
||||
$processProbeSource = Join-Path $env:SystemRoot 'System32\ping.exe'
|
||||
$siblingDir = "$installDir Tools"
|
||||
$siblingProbe = Join-Path $siblingDir 'Claude Code Haha.exe'
|
||||
New-Item -ItemType Directory -Path $siblingDir -Force | Out-Null
|
||||
Copy-Item -LiteralPath $processProbeSource -Destination $siblingProbe
|
||||
$siblingProcess = Start-Process -FilePath $siblingProbe -ArgumentList @('-t', '127.0.0.1') -PassThru
|
||||
Start-Sleep -Milliseconds 500
|
||||
Invoke-CheckedProcess -FilePath $installer -Stage 'Sibling-prefix process reinstall' -Arguments @('--updated', '/S', '/currentuser', "/D=$installDir")
|
||||
if ($siblingProcess.HasExited) {
|
||||
throw 'Sibling-prefix process was mistaken for an install-directory process.'
|
||||
}
|
||||
[Console]::Out.WriteLine('Sibling-prefix process remains running after reinstall.')
|
||||
|
||||
$installProbe = Join-Path $installDir 'install-process-probe.exe'
|
||||
Copy-Item -LiteralPath $processProbeSource -Destination $installProbe
|
||||
$installProcess = Start-Process -FilePath $installProbe -ArgumentList @('-t', '127.0.0.1') -PassThru
|
||||
Start-Sleep -Milliseconds 500
|
||||
Invoke-ProcessHelperExpectExit `
|
||||
-Stage 'Install-directory parent process detection' `
|
||||
-ExpectedExitCode 0 `
|
||||
-Arguments @(
|
||||
'-InstallDir', $installDir,
|
||||
'-ProcessName', 'Claude Code Haha.exe',
|
||||
'-Action', 'Find',
|
||||
'-InstallerPid', [string]$PID,
|
||||
'-InstallerParentPid', [string]$installProcess.Id
|
||||
)
|
||||
Invoke-CheckedProcess -FilePath $installer -Stage 'Install-directory process reinstall' -Arguments @('--updated', '/S', '/currentuser', "/D=$installDir")
|
||||
if (-not $installProcess.HasExited) {
|
||||
throw 'Install-directory process was not terminated before reinstall.'
|
||||
}
|
||||
|
||||
Invoke-LegacyRecoveryDiagnostic
|
||||
Invoke-CheckedProcess -FilePath $installer -Stage 'Default-mode reinstall' -Arguments @('--updated', '/S', '/currentuser', "/D=$installDir")
|
||||
Stop-Process -Id $siblingProcess.Id -Force
|
||||
$siblingProcess.WaitForExit()
|
||||
$siblingProcess.Dispose()
|
||||
$siblingProcess = $null
|
||||
|
||||
$bundledHelperProbe = Join-Path $siblingDir 'OpenConsole.exe'
|
||||
Copy-Item -LiteralPath $processProbeSource -Destination $bundledHelperProbe
|
||||
$bundledHelperProcess = Start-Process -FilePath $bundledHelperProbe -ArgumentList @('-t', '127.0.0.1') -PassThru
|
||||
Start-Sleep -Milliseconds 500
|
||||
$env:COMPLUS_Version = 'v0.0.0-test-invalid-clr'
|
||||
Invoke-ProcessExpectFailure -FilePath $installer -Stage 'No-CLR external bundled-helper process reinstall' -ExpectedExitCode 22 -Arguments @('--updated', '/S', '/currentuser', "/D=$installDir")
|
||||
if ($bundledHelperProcess.HasExited) {
|
||||
throw 'No-CLR exact-image fallback terminated an external bundled-helper process.'
|
||||
}
|
||||
Remove-Item Env:COMPLUS_Version -ErrorAction SilentlyContinue
|
||||
Stop-Process -Id $bundledHelperProcess.Id -Force
|
||||
$bundledHelperProcess.WaitForExit()
|
||||
$bundledHelperProcess.Dispose()
|
||||
$bundledHelperProcess = $null
|
||||
|
||||
$env:COMPLUS_Version = 'v0.0.0-test-invalid-clr'
|
||||
if (Test-IsProcessElevated) {
|
||||
Invoke-ProcessExpectFailure -FilePath $installer -Stage 'Elevated default-mode reinstall without CLR' -ExpectedExitCode 20 -Arguments @('--updated', '/S', '/currentuser', "/D=$installDir")
|
||||
} else {
|
||||
Invoke-CheckedProcess -FilePath $installer -Stage 'Trusted-user default-mode reinstall without CLR' -Arguments @('--updated', '/S', '/currentuser', "/D=$installDir")
|
||||
}
|
||||
Remove-Item Env:COMPLUS_Version -ErrorAction SilentlyContinue
|
||||
if (-not (Test-Path -LiteralPath $appExe -PathType Leaf)) {
|
||||
throw "Reinstall removed the application executable: $appExe"
|
||||
}
|
||||
|
||||
[Console]::Out.WriteLine('Windows installer fresh-install and default-mode reinstall smoke passed.')
|
||||
$legacyDir = Join-Path $installDir 'CLAUDE_CONFIG_DIR'
|
||||
$legacySentinel = Join-Path $legacyDir 'settings.json'
|
||||
New-Item -ItemType Directory -Path $legacyDir -Force | Out-Null
|
||||
Set-Content -LiteralPath $legacySentinel -Value 'must-survive-failed-upgrade' -NoNewline
|
||||
$env:COMPLUS_Version = 'v0.0.0-test-invalid-clr'
|
||||
Invoke-ProcessExpectFailure -FilePath $installer -Stage 'Portable reinstall without CLR' -ExpectedExitCode 20 -Arguments @('--updated', '/S', '/currentuser', "/D=$installDir")
|
||||
Remove-Item Env:COMPLUS_Version -ErrorAction SilentlyContinue
|
||||
if ((Get-Content -LiteralPath $legacySentinel -Raw) -ne 'must-survive-failed-upgrade') {
|
||||
throw 'Portable reinstall without CLR modified legacy data instead of failing closed.'
|
||||
}
|
||||
|
||||
[Console]::Out.WriteLine('Windows installer fresh-install, no-CLR default reinstall, and fail-closed portable reinstall smoke passed.')
|
||||
} finally {
|
||||
foreach ($probeProcess in @($installProcess, $siblingProcess, $bundledHelperProcess)) {
|
||||
if ($null -ne $probeProcess) {
|
||||
if (-not $probeProcess.HasExited) {
|
||||
Stop-Process -Id $probeProcess.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
$probeProcess.Dispose()
|
||||
}
|
||||
}
|
||||
if (Test-Path -LiteralPath $uninstaller -PathType Leaf) {
|
||||
Invoke-CheckedProcess -FilePath $uninstaller -Stage 'Cleanup uninstall' -Arguments @('/S', '/KEEP_APP_DATA', '/currentuser') -TimeoutSeconds 120
|
||||
}
|
||||
|
||||
@ -543,7 +543,8 @@ describe('release desktop workflow', () => {
|
||||
expect(recoveryHelper).toContain('function Assert-TreeManifestsEqual')
|
||||
expect(recoveryHelper).toContain('function Write-AppModeAtomically')
|
||||
expect(recoveryHelper).toContain('[IO.File]::Replace')
|
||||
expect(recoveryHelper).toContain('GetFinalPathNameByHandle')
|
||||
expect(recoveryHelper).not.toContain('Add-Type')
|
||||
expect(recoveryHelper).toContain('function Assert-NoReparsePointInPath')
|
||||
expect(recoveryHelper).toContain('robocopy.exe')
|
||||
expect(recoveryHelper).not.toMatch(/\/XC|\/XN|\/XO/)
|
||||
expect(recoveryHelper).toContain('Multiple distinct legacy data sources')
|
||||
|
||||
75
scripts/pr/windows-installer-process-check.test.ts
Normal file
75
scripts/pr/windows-installer-process-check.test.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
describe('Windows installer process matching', () => {
|
||||
test('uses a directory-boundary-aware process helper', () => {
|
||||
const installerHook = readFileSync('desktop/build/installer.nsh', 'utf8')
|
||||
const processHelper = readFileSync(
|
||||
'desktop/build/check-install-processes.ps1',
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(installerHook).not.toContain('!insertmacro _CHECK_APP_RUNNING')
|
||||
expect(installerHook).toContain('check-install-processes.ps1')
|
||||
expect(installerHook).toContain('!macro CcHahaFindInstallProcess')
|
||||
expect(installerHook).toContain('!macro CcHahaKillInstallProcess')
|
||||
expect(installerHook).toContain('-InstallerPid "$pid"')
|
||||
expect(installerHook).toContain('-InstallerParentPid "$1"')
|
||||
expect(installerHook).toContain('tasklist /FI "USERNAME eq %USERNAME%" /FO CSV /NH >')
|
||||
expect(installerHook).toContain('tasklist process enumeration failed')
|
||||
expect(installerHook).toContain('fallback process filtering failed')
|
||||
expect(installerHook).toMatch(
|
||||
/tasklist process enumeration failed[\s\S]*StrCpy \$\{_RETURN\} 0/,
|
||||
)
|
||||
expect(installerHook).toMatch(
|
||||
/fallback process filtering failed[\s\S]*StrCpy \$\{_RETURN\} 0/,
|
||||
)
|
||||
expect(installerHook).toContain('claude-sidecar-x86_64-pc-windows-msvc.exe')
|
||||
expect(installerHook).toContain('claude-sidecar-aarch64-pc-windows-msvc.exe')
|
||||
expect(installerHook).toContain('/C:"OpenConsole.exe"')
|
||||
expect(installerHook).toContain('/C:"winpty-agent.exe"')
|
||||
expect(installerHook).toContain('/C:"rg.exe"')
|
||||
expect(installerHook).toContain('bundled terminal/search helper')
|
||||
expect(installerHook).toContain('Differently named child processes cannot be attributed')
|
||||
expect(installerHook).not.toContain('| "$FindPath"')
|
||||
|
||||
expect(processHelper).toContain('function Test-PathInsideInstallDirectory')
|
||||
expect(processHelper).toContain(
|
||||
'$rootWithSeparator = $resolvedRoot + [IO.Path]::DirectorySeparatorChar',
|
||||
)
|
||||
expect(processHelper).toContain('[StringComparison]::OrdinalIgnoreCase')
|
||||
expect(processHelper).toContain('$process.ProcessId -eq $InstallerPid')
|
||||
expect(processHelper).not.toContain('$process.ProcessId -eq $InstallerParentPid')
|
||||
expect(processHelper).toContain('Matched protected install process:')
|
||||
expect(processHelper).toContain('Blocked unknown-path application process:')
|
||||
expect(processHelper).toContain('$unknownPathMatches.Add($process)')
|
||||
expect(installerHook).not.toContain('taskkill')
|
||||
expect(installerHook).toContain('refusing to terminate by image name')
|
||||
expect(installerHook.match(/SetErrorLevel 22/g)).toHaveLength(2)
|
||||
expect(installerHook).toMatch(
|
||||
/MessageBox MB_OKCANCEL[\s\S]*SetErrorLevel 22\s+Quit/,
|
||||
)
|
||||
expect(installerHook).toMatch(
|
||||
/MessageBox MB_RETRYCANCEL[\s\S]*SetErrorLevel 22\s+Quit/,
|
||||
)
|
||||
})
|
||||
|
||||
test('keeps sibling-prefix and real install process cases in Windows smoke', () => {
|
||||
const installerSmoke = readFileSync(
|
||||
'desktop/scripts/windows-installer-smoke.ps1',
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(installerSmoke).toContain("$siblingDir = \"$installDir Tools\"")
|
||||
expect(installerSmoke).toContain("$siblingProbe = Join-Path $siblingDir 'Claude Code Haha.exe'")
|
||||
expect(installerSmoke).toContain('Sibling-prefix process remains running')
|
||||
expect(installerSmoke).toContain('Install-directory parent process detection')
|
||||
expect(installerSmoke).toContain('Install-directory process was not terminated')
|
||||
expect(installerSmoke).toContain("$bundledHelperProbe = Join-Path $siblingDir 'OpenConsole.exe'")
|
||||
expect(installerSmoke).toContain('No-CLR external bundled-helper process reinstall')
|
||||
expect(installerSmoke).toMatch(
|
||||
/No-CLR external bundled-helper process reinstall' -ExpectedExitCode 22/,
|
||||
)
|
||||
expect(installerSmoke).toContain('No-CLR exact-image fallback terminated an external bundled-helper')
|
||||
})
|
||||
})
|
||||
94
scripts/pr/windows-installer-recovery.test.ts
Normal file
94
scripts/pr/windows-installer-recovery.test.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
describe('Windows installer recovery prerequisites', () => {
|
||||
test('does not compile native path helpers at install time', () => {
|
||||
const recoveryHelper = readFileSync(
|
||||
'desktop/build/recover-legacy-install-data.ps1',
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(recoveryHelper).not.toContain('Add-Type')
|
||||
expect(recoveryHelper).toContain('function Assert-NoReparsePointInPath')
|
||||
expect(recoveryHelper).toContain('$rootAttributes = [IO.File]::GetAttributes($current)')
|
||||
expect(recoveryHelper).toContain(
|
||||
'contains a reparse point and cannot be recovered safely',
|
||||
)
|
||||
expect(recoveryHelper).toContain('function Get-CanonicalPathIdentity')
|
||||
expect(recoveryHelper).toContain('System32\\mountvol.exe')
|
||||
expect(recoveryHelper).toContain('SUBST aliases cannot be recovered safely')
|
||||
expect(recoveryHelper).toContain('Possible 8.3 path alias cannot be proven safe')
|
||||
expect(recoveryHelper).toContain('Alternate path alias cannot be recovered safely')
|
||||
expect(recoveryHelper).toContain('Get-ChildItem -LiteralPath $current -Force')
|
||||
expect(recoveryHelper).toContain("([string]$_.Name).Equals($segment")
|
||||
expect(recoveryHelper).toContain("Join-Path $testRoot 'project~notes'")
|
||||
expect(recoveryHelper).toContain('legal long directory name containing a tilde')
|
||||
expect(recoveryHelper).toContain('extended volume alias did not fail closed')
|
||||
expect(recoveryHelper).toContain('SUBST alias did not fail closed')
|
||||
})
|
||||
|
||||
test('skips PowerShell only for a proven default per-user installation', () => {
|
||||
const installerHook = readFileSync('desktop/build/installer.nsh', 'utf8')
|
||||
const fastPathStart = installerHook.indexOf(
|
||||
'Function CcHahaCanSkipLegacyRecovery',
|
||||
)
|
||||
const recoveryCall = installerHook.indexOf(
|
||||
'UAC_AsUser_Call Function CcHahaRecoverLegacy',
|
||||
)
|
||||
|
||||
expect(fastPathStart).toBeGreaterThan(-1)
|
||||
expect(fastPathStart).toBeLessThan(recoveryCall)
|
||||
expect(installerHook).toMatch(
|
||||
/Function CcHahaCanSkipLegacyRecovery[\s\S]*\$8 != "trusted-user"/,
|
||||
)
|
||||
expect(installerHook).toMatch(
|
||||
/StrCpy \$8 "trusted-user"[\s\S]*UAC_IsAdmin[\s\S]*StrCpy \$8 "untrusted-elevated"[\s\S]*UAC_IsInnerInstance[\s\S]*StrCpy \$8 "trusted-uac-outer"[\s\S]*Call CcHahaCanSkipLegacyRecovery/,
|
||||
)
|
||||
expect(installerHook).toContain(
|
||||
'$ccHahaPerUserInstallLocation == ""',
|
||||
)
|
||||
expect(installerHook).toContain(
|
||||
'$ccHahaPerMachineInstallLocation != ""',
|
||||
)
|
||||
expect(installerHook).toContain(
|
||||
'$ccHahaPerMachineUninstallString != ""',
|
||||
)
|
||||
expect(installerHook).toContain(
|
||||
'StrCmp $ccHahaPerUserInstallLocation $INSTDIR',
|
||||
)
|
||||
expect(installerHook).toContain('ReadEnvStr $R0 CLAUDE_CONFIG_DIR')
|
||||
expect(installerHook).toContain(
|
||||
'IfFileExists "$ccHahaPerUserInstallLocation\\CLAUDE_CONFIG_DIR\\*.*"',
|
||||
)
|
||||
expect(installerHook).toContain(
|
||||
'FileOpen $R2 "$R1\\Claude Code Haha\\app-mode.json" r',
|
||||
)
|
||||
expect(installerHook).toContain('StrCmp $R3 \' "mode": "default",$\\n\'')
|
||||
expect(installerHook).toContain('StrCmp $R3 \' "portable_dir": null$\\n\'')
|
||||
expect(installerHook).toContain('FileClose $R2')
|
||||
expect(installerHook).toMatch(
|
||||
/Call CcHahaCanSkipLegacyRecovery[\s\S]*No legacy data candidates found for the registered per-user installation[\s\S]*UAC_AsUser_Call Function CcHahaRecoverLegacy/,
|
||||
)
|
||||
})
|
||||
|
||||
test('keeps no-CLR default and portable upgrade cases in Windows smoke', () => {
|
||||
const installerSmoke = readFileSync(
|
||||
'desktop/scripts/windows-installer-smoke.ps1',
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(installerSmoke).toContain("$env:COMPLUS_Version = 'v0.0.0-test-invalid-clr'")
|
||||
expect(installerSmoke).toContain('Test-IsProcessElevated')
|
||||
expect(installerSmoke).toContain('Elevated default-mode reinstall without CLR')
|
||||
expect(installerSmoke).toMatch(
|
||||
/Elevated default-mode reinstall without CLR' -ExpectedExitCode 20/,
|
||||
)
|
||||
expect(installerSmoke).toContain('Trusted-user default-mode reinstall without CLR')
|
||||
expect(installerSmoke).toContain('Portable reinstall without CLR')
|
||||
expect(installerSmoke).toMatch(
|
||||
/Portable reinstall without CLR' -ExpectedExitCode 20/,
|
||||
)
|
||||
expect(installerSmoke).toContain('Invoke-ProcessExpectFailure')
|
||||
expect(installerSmoke).toContain('must-survive-failed-upgrade')
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user