From e7e10201e3771c43a32edf96a6109f7f0610a37c Mon Sep 17 00:00:00 2001 From: S D Date: Fri, 6 Nov 2026 23:33:02 +0000 Subject: [PATCH 1/7] Add per-user Inno installer and immutable shell extension packaging --- .github/workflows/windows-build.yml | 31 ++- installer/ClipDiff.iss | 232 ++++++++++++++++++ scripts/create-installer.ps1 | 61 +++++ scripts/create-local-release.ps1 | 21 +- scripts/test-installer.ps1 | 229 +++++++++++++++++ src/ClipDiff.ShellExtension/NativeBuild.props | 2 + src/ClipDiff.Windows/App.xaml.cs | 5 + src/ClipDiff.Windows/AppController.cs | 15 ++ src/ClipDiff.Windows/ClipDiff.Windows.csproj | 4 +- .../ExplorerContextMenuRegistration.cs | 3 +- .../Installation/InstallerShutdown.cs | 28 +++ .../Installation/ShellExtensionFiles.cs | 54 ++++ 12 files changed, 677 insertions(+), 8 deletions(-) create mode 100644 installer/ClipDiff.iss create mode 100644 scripts/create-installer.ps1 create mode 100644 scripts/test-installer.ps1 create mode 100644 src/ClipDiff.Windows/Installation/InstallerShutdown.cs create mode 100644 src/ClipDiff.Windows/Installation/ShellExtensionFiles.cs diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index b477871..b73398a 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -44,10 +44,30 @@ jobs: shell: pwsh run: ./scripts/test-release-version.ps1 + - name: Ensure Inno Setup is available + shell: pwsh + run: | + if (-not (Test-Path "${env:ProgramFiles(x86)}/Inno Setup 6/ISCC.exe")) { + choco install innosetup --yes --no-progress + if ($LASTEXITCODE -ne 0) { throw 'Inno Setup installation failed.' } + } + - name: Build, test, and package shell: pwsh run: ./scripts/create-local-release.ps1 -Version $env:RELEASE_VERSION + - name: Test installer with running app and loaded extension images + shell: pwsh + run: ./scripts/test-installer.ps1 -Version $env:RELEASE_VERSION + + - name: Upload installer test diagnostics + if: failure() + uses: actions/upload-artifact@v7 + with: + name: ClipDiff-installer-test-diagnostics + path: artifacts/installer-smoke/**/*.log + if-no-files-found: ignore + - name: Upload self-contained Windows build uses: actions/upload-artifact@v7 with: @@ -56,6 +76,14 @@ jobs: if-no-files-found: error retention-days: 14 + - name: Upload Windows installer + uses: actions/upload-artifact@v7 + with: + name: ClipDiff-win-x64-Setup + path: releases/ClipDiff-*-win-x64-Setup.exe + if-no-files-found: error + retention-days: 14 + - name: Upload .NET 10 Windows build uses: actions/upload-artifact@v7 with: @@ -74,5 +102,6 @@ jobs: . ./scripts/release-version.ps1 $selfContained = "releases/$(Get-ReleasePackageName -Version $env:RELEASE_VERSION -SelfContained $true).zip" $net10 = "releases/$(Get-ReleasePackageName -Version $env:RELEASE_VERSION -SelfContained $false).zip" + $installer = "releases/ClipDiff-$env:RELEASE_VERSION-win-x64-Setup.exe" - gh release create $env:RELEASE_TAG $selfContained $net10 --verify-tag --notes "" --title "ClipDiff $env:RELEASE_TAG" + gh release create $env:RELEASE_TAG $installer $selfContained $net10 --verify-tag --notes "" --title "ClipDiff $env:RELEASE_TAG" diff --git a/installer/ClipDiff.iss b/installer/ClipDiff.iss new file mode 100644 index 0000000..e42001f --- /dev/null +++ b/installer/ClipDiff.iss @@ -0,0 +1,232 @@ +; Build through scripts/create-installer.ps1 using the validated self-contained payload. +#ifndef AppVersion + #error AppVersion is required +#endif +#ifndef PayloadDirectory + #error PayloadDirectory is required +#endif +#ifndef ExtensionFileName + #error ExtensionFileName is required +#endif +#ifndef InstallerOutputDirectory + #error InstallerOutputDirectory is required +#endif + +[Setup] +AppId={{75F83916-586C-4D73-B744-AD39917A5039} +AppName=ClipDiff +AppVersion={#AppVersion} +AppPublisher=Stuart Dunkeld +AppPublisherURL=https://stuartd.dev +AppSupportURL=https://github.com/stuartd/ClipboardDiff +DefaultDirName={localappdata}\Programs\ClipDiff +DefaultGroupName=ClipDiff +DisableProgramGroupPage=yes +PrivilegesRequired=lowest +ArchitecturesAllowed=x64os +ArchitecturesInstallIn64BitMode=x64os +MinVersion=10.0.17763 +OutputDir={#InstallerOutputDirectory} +OutputBaseFilename=ClipDiff-{#AppVersion}-win-x64-Setup +SetupIconFile=..\src\ClipDiff.Windows\Assets\ClipDiff.ico +UninstallDisplayIcon={app}\ClipDiff.exe +Compression=lzma2 +SolidCompression=yes +WizardStyle=modern +; Never register DLLs with Restart Manager: Explorer may have one loaded. +CloseApplications=no +RestartApplications=no +SetupMutex=Local\ClipDiff.Setup + +[Files] +Source: "{#PayloadDirectory}\ClipDiff.exe"; DestDir: "{app}"; Flags: ignoreversion +; Immutable filenames: even an unchanged DLL must not be opened for overwrite. +Source: "{#PayloadDirectory}\{#ExtensionFileName}"; DestDir: "{app}"; Flags: onlyifdoesntexist uninsneveruninstall + +[Icons] +Name: "{group}\ClipDiff"; Filename: "{app}\ClipDiff.exe" + +[Run] +Filename: "{app}\ClipDiff.exe"; Description: "Launch ClipDiff"; Flags: nowait postinstall skipifsilent + +[Code] +function OpenEvent(DesiredAccess: LongWord; InheritHandle: Integer; Name: String): THandle; + external 'OpenEventW@kernel32.dll stdcall'; +function SetEvent(Event: THandle): Integer; + external 'SetEvent@kernel32.dll stdcall'; +function CloseHandle(Handle: THandle): Integer; + external 'CloseHandle@kernel32.dll stdcall'; + +function StopClipDiff(): Boolean; +var + ShutdownEvent: THandle; + Attempt: Integer; +begin + Result := True; + + if not CheckForMutexes('Local\ClipDiff') then + begin + Exit; + end; + + ShutdownEvent := OpenEvent($0002, 0, 'Local\ClipDiff.ShutdownForUpdate'); + + if ShutdownEvent <> 0 then + begin + SetEvent(ShutdownEvent); + CloseHandle(ShutdownEvent); + + for Attempt := 1 to 100 do + begin + Sleep(100); + + if not CheckForMutexes('Local\ClipDiff') then + begin + Exit; + end; + end; + end; + + // Pre-installer portable builds do not support the shutdown signal. + Result := False; +end; + +function PrepareToInstall(var NeedsRestart: Boolean): String; +begin + Result := ''; + + if not StopClipDiff() then + begin + Result := 'ClipDiff could not close automatically. Quit the running copy from its tray menu, then click Next to continue. Your captured text will be discarded when ClipDiff closes.'; + end; +end; + +procedure CurStepChanged(CurStep: TSetupStep); +var + Command: String; +begin + // Preserve an existing opt-in even if Launch ClipDiff is unchecked. + if (CurStep = ssPostInstall) and + RegQueryStringValue(HKCU, 'Software\Microsoft\Windows\CurrentVersion\Run', 'ClipDiff', Command) and + (Command <> '') then + begin + RegWriteStringValue(HKCU, 'Software\Microsoft\Windows\CurrentVersion\Run', + 'ClipDiff', '"' + ExpandConstant('{app}\ClipDiff.exe') + '"'); + end; +end; + +function InitializeUninstall(): Boolean; +begin + Result := StopClipDiff(); + + if not Result then + begin + MsgBox('ClipDiff could not close automatically. Quit ClipDiff and run the uninstaller again.', mbError, MB_OK); + end; +end; + +function IsInstalledPath(Value: String): Boolean; +begin + Result := Pos(Lowercase(ExpandConstant('{app}\')), Lowercase(Value)) = 1; +end; + +function IsExtensionFileName(Name: String): Boolean; +var + Index: Integer; + Hash, Prefix: String; +begin + Result := Name = 'ClipDiff.ShellExtension.dll'; + + if Result then + begin + Exit; + end; + + Prefix := 'ClipDiff.ShellExtension.'; + Hash := Copy(Name, Length(Prefix) + 1, 64); + + if (Copy(Name, 1, Length(Prefix)) <> Prefix) or + (Length(Name) <> Length(Prefix) + 64 + 4) or + (Copy(Name, Length(Name) - 3, 4) <> '.dll') then + begin + Exit; + end; + + for Index := 1 to Length(Hash) do + begin + if Pos(Hash[Index], '0123456789abcdef') = 0 then + begin + Exit; + end; + end; + + Result := True; +end; + +procedure RemoveOwnedKey(Key: String; PathKey: String; QuotedExecutable: Boolean); +var + Owner, Value: String; +begin + if RegQueryStringValue(HKCU, Key, 'ClipDiffOwner', Owner) and + (Owner = 'ClipDiff.ExplorerContextMenu.v1') and + RegQueryStringValue(HKCU, PathKey, '', Value) then + begin + if (QuotedExecutable and (Pos('"' + Lowercase(ExpandConstant('{app}\ClipDiff.exe')) + '"', Lowercase(Value)) = 1)) or + ((not QuotedExecutable) and IsInstalledPath(Value)) then + begin + RegDeleteKeyIncludingSubkeys(HKCU, Key); + end; + end; +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +var + Command, ExtensionPath, Owner: String; + FileInfo: TFindRec; +begin + if CurUninstallStep <> usPostUninstall then + begin + Exit; + end; + + if RegQueryStringValue(HKCU, 'Software\Microsoft\Windows\CurrentVersion\Run', 'ClipDiff', Command) and + (CompareText(Command, '"' + ExpandConstant('{app}\ClipDiff.exe') + '"') = 0) then + begin + RegDeleteValue(HKCU, 'Software\Microsoft\Windows\CurrentVersion\Run', 'ClipDiff'); + end; + + RemoveOwnedKey('Software\Classes\*\shell\ClipDiff.CompareWithCurrent', + 'Software\Classes\*\shell\ClipDiff.CompareWithCurrent\command', True); + RemoveOwnedKey('Software\Classes\CLSID\{4D22FA39-9E5D-42BD-BF0A-8AE885704EC7}', + 'Software\Classes\CLSID\{4D22FA39-9E5D-42BD-BF0A-8AE885704EC7}\LocalServer32', True); + + if RegQueryStringValue(HKCU, 'Software\Classes\CLSID\{6B46A974-40E2-4AD4-9F68-E534202B11E8}\InprocServer32', '', ExtensionPath) and + IsInstalledPath(ExtensionPath) then + begin + RemoveOwnedKey('Software\Classes\CLSID\{6B46A974-40E2-4AD4-9F68-E534202B11E8}', + 'Software\Classes\CLSID\{6B46A974-40E2-4AD4-9F68-E534202B11E8}\InprocServer32', False); + + if RegQueryStringValue(HKCU, 'Software\Classes\*\shellex\ContextMenuHandlers\ClipDiff.CompareSelected', 'ClipDiffOwner', Owner) and + (Owner = 'ClipDiff.ExplorerContextMenu.v1') then + begin + RegDeleteKeyIncludingSubkeys(HKCU, 'Software\Classes\*\shellex\ContextMenuHandlers\ClipDiff.CompareSelected'); + end; + end; + + // Best effort only. Loaded images remain until Windows releases them; no reboot is requested. + if FindFirst(ExpandConstant('{app}\ClipDiff.ShellExtension*.dll'), FileInfo) then + begin + try + repeat + if IsExtensionFileName(FileInfo.Name) then + begin + DeleteFile(ExpandConstant('{app}\') + FileInfo.Name); + end; + until not FindNext(FileInfo); + finally + FindClose(FileInfo); + end; + end; + + RemoveDir(ExpandConstant('{app}')); +end; diff --git a/scripts/create-installer.ps1 b/scripts/create-installer.ps1 new file mode 100644 index 0000000..a4311f5 --- /dev/null +++ b/scripts/create-installer.ps1 @@ -0,0 +1,61 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$Version, + [Parameter(Mandatory)][string]$PayloadDirectory, + [Parameter(Mandatory)][string]$ExtensionFileName, + [Parameter(Mandatory)][string]$OutputDirectory +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'release-version.ps1') +$Version = Get-ReleaseVersion -Version $Version +$compiler = Get-Command ISCC.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -First 1 + +if (-not $compiler) +{ + $compiler = Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6/ISCC.exe' +} + +if (-not (Test-Path $compiler -PathType Leaf)) +{ + throw 'Inno Setup 6.3 or later is required. Install Inno Setup 6, or put ISCC.exe on PATH.' +} + +if ($ExtensionFileName -cnotmatch '^ClipDiff\.ShellExtension\.[0-9a-f]{64}\.dll$') +{ + throw 'The installer requires a content-addressed shell extension filename.' +} + +$PayloadDirectory = (Resolve-Path $PayloadDirectory).Path +$expected = @('ClipDiff.exe', $ExtensionFileName) | Sort-Object +$actual = @(Get-ChildItem $PayloadDirectory | Select-Object -ExpandProperty Name | Sort-Object) + +if (@(Compare-Object $expected $actual).Count -ne 0) +{ + throw 'The installer source must be exactly the validated two-file self-contained payload.' +} + +$dllHash = (Get-FileHash (Join-Path $PayloadDirectory $ExtensionFileName) -Algorithm SHA256).Hash.ToLowerInvariant() + +if ($ExtensionFileName -cne "ClipDiff.ShellExtension.$dllHash.dll") +{ + throw 'The extension content does not match its filename.' +} + +New-Item $OutputDirectory -ItemType Directory -Force | Out-Null +$OutputDirectory = (Resolve-Path $OutputDirectory).Path +$installer = Join-Path $OutputDirectory "ClipDiff-$Version-win-x64-Setup.exe" + +if (Test-Path $installer) +{ + Remove-Item $installer -Force +} + +& $compiler "/DAppVersion=$Version" "/DPayloadDirectory=$PayloadDirectory" ` + "/DExtensionFileName=$ExtensionFileName" "/DInstallerOutputDirectory=$OutputDirectory" ` + (Join-Path $PSScriptRoot '../installer/ClipDiff.iss') + +if ($LASTEXITCODE -ne 0 -or -not (Test-Path $installer -PathType Leaf)) +{ + throw 'Inno Setup did not produce the installer.' +} diff --git a/scripts/create-local-release.ps1 b/scripts/create-local-release.ps1 index ab5190f..3106066 100644 --- a/scripts/create-local-release.ps1 +++ b/scripts/create-local-release.ps1 @@ -15,7 +15,8 @@ $applicationProject = Join-Path $repositoryRoot 'src/ClipDiff.Windows/ClipDiff.W $coreTests = Join-Path $repositoryRoot 'tests/ClipDiff.Core.Tests/ClipDiff.Core.Tests.csproj' $windowsTests = Join-Path $repositoryRoot 'tests/ClipDiff.Windows.Tests/ClipDiff.Windows.Tests.csproj' $nativeDll = Join-Path $repositoryRoot 'artifacts/native/Release/ClipDiff.ShellExtension.dll' -$requiredPayload = @('ClipDiff.exe', 'ClipDiff.ShellExtension.dll') +$extensionFileName = $null +$requiredPayload = @() function Assert-ReleasePayload([string]$Directory, [string]$Description) { @@ -87,6 +88,8 @@ try } & (Join-Path $PSScriptRoot 'build-shell-extension.ps1') -Configuration Release -Test:(-not $SkipNativeTests) + $extensionFileName = 'ClipDiff.ShellExtension.' + (Get-FileHash $nativeDll -Algorithm SHA256).Hash.ToLowerInvariant() + '.dll' + $requiredPayload = @('ClipDiff.exe', $extensionFileName) dotnet test $coreTests --configuration Release @@ -135,6 +138,7 @@ try --self-contained $selfContained ` --output $package.PublishDirectory ` -p:Version=$Version ` + -p:ShellExtensionFileName=$extensionFileName ` -p:PublishSingleFile=true ` -p:PublishTrimmed=false ` -p:IncludeNativeLibrariesForSelfExtract=true @@ -155,7 +159,7 @@ try foreach ($file in $requiredPayload) { - $source = if ($file -eq 'ClipDiff.ShellExtension.dll') { $nativeDll } else { Join-Path $package.PublishDirectory $file } + $source = if ($file -eq $extensionFileName) { $nativeDll } else { Join-Path $package.PublishDirectory $file } if (-not (Test-Path $source -PathType Leaf)) { @@ -187,9 +191,15 @@ try } } - # Replace public outputs only after both variants pass validation, so a failed - # build cannot publish mismatched or partial packages. + # Build the installer privately before exposing any release assets. + & (Join-Path $PSScriptRoot 'create-installer.ps1') -Version $Version ` + -PayloadDirectory $packages[0].StagingDirectory -ExtensionFileName $extensionFileName ` + -OutputDirectory $packageStagingRoot + $installerName = "ClipDiff-$Version-win-x64-Setup.exe" + + # Replace public outputs only after all packages pass validation. New-Item $releaseRoot -ItemType Directory -Force | Out-Null + Move-Item (Join-Path $packageStagingRoot $installerName) (Join-Path $releaseRoot $installerName) -Force foreach ($package in $packages) { @@ -214,7 +224,8 @@ try } Write-Host "Payload: $($requiredPayload -join ', ')" - Write-Host 'Install: Extract both files from one package into the same directory, then run ClipDiff.exe.' + Write-Host "Installer: $(Join-Path $releaseRoot $installerName)" + Write-Host 'Install/update: Run the Setup.exe installer. Portable ZIPs remain available.' if ($Launch) { diff --git a/scripts/test-installer.ps1 b/scripts/test-installer.ps1 new file mode 100644 index 0000000..5e5b673 --- /dev/null +++ b/scripts/test-installer.ps1 @@ -0,0 +1,229 @@ +# Windows-only integration smoke test. Requires the release assets already built. +[CmdletBinding()] +param([string]$Version) + +$ErrorActionPreference = 'Stop' +$repositoryRoot = Split-Path -Parent $PSScriptRoot +. (Join-Path $PSScriptRoot 'release-version.ps1') + +if ($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitProcess) +{ + throw 'Run the installer integration test in x64 PowerShell on Windows.' +} + +if (Get-Process ClipDiff -ErrorAction SilentlyContinue) +{ + throw 'Close ClipDiff before running this isolated installer test.' +} + +$uninstallKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{75F83916-586C-4D73-B744-AD39917A5039}_is1' + +if (Test-Path $uninstallKey) +{ + throw 'This test requires a user profile without an existing ClipDiff installer installation.' +} + +if ([string]::IsNullOrWhiteSpace($Version)) +{ + $Version = (& dotnet msbuild (Join-Path $repositoryRoot 'src/ClipDiff.Windows/ClipDiff.Windows.csproj') -nologo -getProperty:Version | Select-Object -Last 1).Trim() +} + +$Version = Get-ReleaseVersion -Version $Version +$baselineInstaller = Join-Path $repositoryRoot "releases/ClipDiff-$Version-win-x64-Setup.exe" +$baselinePayload = Join-Path $repositoryRoot "releases/$(Get-ReleasePackageName -Version $Version -SelfContained $true)" +$testRoot = Join-Path $repositoryRoot "artifacts/installer-smoke/$([Guid]::NewGuid().ToString('N'))" +$installDirectory = Join-Path $testRoot 'Installed app' +$settingsPath = Join-Path $env:LOCALAPPDATA 'ClipDiff/settings.json' +$savedSettings = if (Test-Path $settingsPath) { [IO.File]::ReadAllBytes($settingsPath) } else { $null } +$runKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' +$savedRun = Get-ItemPropertyValue $runKey -Name ClipDiff -ErrorAction SilentlyContinue +$loadedImages = [Collections.Generic.List[IntPtr]]::new() +$app = $null + +New-Item $testRoot -ItemType Directory -Force | Out-Null +Add-Type @' +using System; +using System.Runtime.InteropServices; +public static class ClipDiffInstallerTestNative { + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern IntPtr LoadLibraryW(string path); + [DllImport("kernel32.dll")] + public static extern bool FreeLibrary(IntPtr module); +} +'@ + +function Invoke-Installer([string]$Path, [string]$Step, [switch]$Uninstall) +{ + $arguments = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', "/LOG=`"$(Join-Path $testRoot "$Step.log")`"") + + if (-not $Uninstall) + { + $arguments += "/DIR=`"$installDirectory`"" + } + + $process = Start-Process $Path -ArgumentList $arguments -PassThru + + if (-not $process.WaitForExit(60000)) + { + $process.Kill() + throw "$Step timed out. See $testRoot." + } + + if ($process.ExitCode -ne 0) + { + throw "$Step failed or requested a restart (exit $($process.ExitCode)). See $testRoot." + } +} + +function Start-TestApp([string]$ExpectedExtension) +{ + $process = Start-Process (Join-Path $installDirectory 'ClipDiff.exe') -PassThru + $serverKey = 'HKCU:\Software\Classes\CLSID\{6B46A974-40E2-4AD4-9F68-E534202B11E8}\InprocServer32' + + for ($attempt = 0; $attempt -lt 100; $attempt++) + { + $path = if (Test-Path $serverKey) { (Get-Item $serverKey).GetValue('') } else { '' } + + if ($path -eq (Join-Path $installDirectory $ExpectedExtension)) + { + return $process + } + + if ($process.HasExited) + { + throw 'The installed app exited before registering its extension.' + } + + Start-Sleep -Milliseconds 100 + } + + $process.Kill() + throw 'The installed app did not register the expected extension.' +} + +function Hold-Extension([string]$Name) +{ + $path = Join-Path $installDirectory $Name + $module = [ClipDiffInstallerTestNative]::LoadLibraryW($path) + + if ($module -eq [IntPtr]::Zero) + { + throw "Could not load the installed native extension: $path" + } + + $loadedImages.Add($module) + + try + { + [IO.File]::Open($path, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None).Dispose() + throw 'The test did not establish the expected Windows image lock.' + } + catch [IO.IOException] { } + catch [UnauthorizedAccessException] { } +} + +try +{ + New-Item (Split-Path $settingsPath) -ItemType Directory -Force | Out-Null + [IO.File]::WriteAllText($settingsPath, '{"StartupPromptShown":true,"UpdateChecks":{"AutomaticChecksEnabled":false}}') + Invoke-Installer $baselineInstaller 'first-install' + $originalExtension = Get-ChildItem $baselinePayload -Filter 'ClipDiff.ShellExtension.*.dll' | Select-Object -ExpandProperty Name -First 1 + Set-ItemProperty $runKey -Name ClipDiff -Value "`"$(Join-Path $installDirectory 'ClipDiff.exe')`"" + $app = Start-TestApp $originalExtension + Hold-Extension $originalExtension + Invoke-Installer $baselineInstaller 'upgrade-unchanged-extension' + + if (-not $app.WaitForExit(10000)) + { + throw 'The installer did not shut down the running app.' + } + + # A harmless PE overlay changes the native binary's hash without changing its COM contract. + $changedPayload = Join-Path $testRoot 'changed-payload' + New-Item $changedPayload -ItemType Directory -Force | Out-Null + $changedDll = Join-Path $testRoot 'changed.dll' + Copy-Item (Join-Path $baselinePayload $originalExtension) $changedDll + $stream = [IO.File]::OpenWrite($changedDll) + + try + { + $stream.Position = $stream.Length + $stream.Write([Text.Encoding]::ASCII.GetBytes('ClipDiff installer integration fixture')) + } + finally + { + $stream.Dispose() + } + + $changedName = 'ClipDiff.ShellExtension.' + (Get-FileHash $changedDll -Algorithm SHA256).Hash.ToLowerInvariant() + '.dll' + $publishDirectory = Join-Path $testRoot 'publish' + & dotnet publish (Join-Path $repositoryRoot 'src/ClipDiff.Windows/ClipDiff.Windows.csproj') --configuration Release ` + --runtime win-x64 --self-contained true --output $publishDirectory "-p:Version=$Version" ` + "-p:ShellExtensionFileName=$changedName" -p:PublishSingleFile=true -p:PublishTrimmed=false -p:IncludeNativeLibrariesForSelfExtract=true + + if ($LASTEXITCODE -ne 0) + { + throw 'Could not build the changed-extension fixture application.' + } + + Copy-Item (Join-Path $publishDirectory 'ClipDiff.exe') $changedPayload + Copy-Item $changedDll (Join-Path $changedPayload $changedName) + & (Join-Path $PSScriptRoot 'create-installer.ps1') -Version $Version -PayloadDirectory $changedPayload ` + -ExtensionFileName $changedName -OutputDirectory $testRoot + $app = Start-TestApp $originalExtension + Invoke-Installer (Join-Path $testRoot "ClipDiff-$Version-win-x64-Setup.exe") 'upgrade-changed-extension' + + if (-not $app.WaitForExit(10000) -or -not (Test-Path (Join-Path $installDirectory $originalExtension))) + { + throw 'Changed-extension update did not close the app and preserve the loaded old DLL.' + } + + $app = Start-TestApp $changedName + Hold-Extension $changedName + Invoke-Installer (Join-Path $installDirectory 'unins000.exe') 'uninstall' -Uninstall + + if (-not $app.WaitForExit(10000) -or (Test-Path (Join-Path $installDirectory 'ClipDiff.exe')) -or (Test-Path $uninstallKey)) + { + throw 'Uninstall did not stop the app and remove the executable/installation record.' + } + + if ((Get-ItemPropertyValue $runKey -Name ClipDiff -ErrorAction SilentlyContinue) -or + (Test-Path 'HKCU:\Software\Classes\CLSID\{6B46A974-40E2-4AD4-9F68-E534202B11E8}')) + { + throw 'Uninstall left the installed app registered.' + } + + Write-Host 'Installer first install, running-app upgrades, unchanged/changed loaded DLLs, and uninstall passed without a restart.' + Write-Host 'Explorer context-menu behavior still requires the manual Windows checks in docs/windows-update-testing.md.' +} +finally +{ + if ($app -and -not $app.HasExited) + { + $app.Kill() + $app.WaitForExit() + } + + foreach ($module in $loadedImages) + { + [ClipDiffInstallerTestNative]::FreeLibrary($module) | Out-Null + } + + if ($null -ne $savedSettings) + { + [IO.File]::WriteAllBytes($settingsPath, $savedSettings) + } + else + { + Remove-Item $settingsPath -ErrorAction SilentlyContinue + } + + if ($null -ne $savedRun) + { + Set-ItemProperty $runKey -Name ClipDiff -Value $savedRun + } + else + { + Remove-ItemProperty $runKey -Name ClipDiff -ErrorAction SilentlyContinue + } +} diff --git a/src/ClipDiff.ShellExtension/NativeBuild.props b/src/ClipDiff.ShellExtension/NativeBuild.props index 464724d..3417387 100644 --- a/src/ClipDiff.ShellExtension/NativeBuild.props +++ b/src/ClipDiff.ShellExtension/NativeBuild.props @@ -17,6 +17,8 @@ shell32.lib;ole32.lib;uuid.lib;user32.lib;advapi32.lib;%(AdditionalDependencies) true + + /Brepro %(AdditionalOptions) diff --git a/src/ClipDiff.Windows/App.xaml.cs b/src/ClipDiff.Windows/App.xaml.cs index cd5cf9e..abdd29a 100644 --- a/src/ClipDiff.Windows/App.xaml.cs +++ b/src/ClipDiff.Windows/App.xaml.cs @@ -1,6 +1,7 @@ using System.Threading; using System.Windows; using ClipDiff.Windows.Explorer; +using ClipDiff.Windows.Installation; namespace ClipDiff.Windows; @@ -9,6 +10,7 @@ public partial class App : System.Windows.Application private Mutex? instanceMutex; private AppController? controller; private bool ownsMutex; + private InstallerShutdown? installerShutdown; protected override void OnStartup(StartupEventArgs args) { @@ -36,6 +38,7 @@ protected override void OnStartup(StartupEventArgs args) return; } + installerShutdown = new InstallerShutdown(Dispatcher, Shutdown); controller = new AppController(); if (isExplorerCommand) { @@ -49,6 +52,8 @@ protected override void OnStartup(StartupEventArgs args) protected override void OnExit(ExitEventArgs args) { + installerShutdown?.Dispose(); + installerShutdown = null; controller?.Dispose(); controller = null; diff --git a/src/ClipDiff.Windows/AppController.cs b/src/ClipDiff.Windows/AppController.cs index dc77982..311fce0 100644 --- a/src/ClipDiff.Windows/AppController.cs +++ b/src/ClipDiff.Windows/AppController.cs @@ -5,6 +5,7 @@ using ClipDiff.Windows.ExternalDiff; using ClipDiff.Windows.Explorer; using ClipDiff.Windows.Hotkeys; +using ClipDiff.Windows.Installation; using ClipDiff.Windows.Native; using ClipDiff.Windows.Settings; using ClipDiff.Windows.Startup; @@ -84,6 +85,20 @@ public AppController() trayIcon.ToggleStartAtLoginRequested += OnToggleStartAtLoginRequested; trayIcon.MenuOpening += OnTrayMenuOpening; UpdatePresentation(); + TryCleanupShellExtensions(); + } + + private static void TryCleanupShellExtensions() + { + try + { + var protectedPaths = new ExplorerRegistryClaimStore().ReadClaims().Select(claim => claim.ExtensionPath); + ShellExtensionFiles.TryCleanup(AppContext.BaseDirectory, ShellExtensionFiles.CurrentFileName, protectedPaths); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.Security.SecurityException) + { + // If other sessions' registrations cannot be read, leave their DLLs alone. + } } public void InitializeStartAtLogin() diff --git a/src/ClipDiff.Windows/ClipDiff.Windows.csproj b/src/ClipDiff.Windows/ClipDiff.Windows.csproj index 5f19af1..5f252a6 100644 --- a/src/ClipDiff.Windows/ClipDiff.Windows.csproj +++ b/src/ClipDiff.Windows/ClipDiff.Windows.csproj @@ -10,6 +10,7 @@ ClipDiff app.manifest Assets/ClipDiff.ico + ClipDiff.ShellExtension.dll @@ -17,6 +18,7 @@ + @@ -29,7 +31,7 @@ diff --git a/src/ClipDiff.Windows/Explorer/ExplorerContextMenuRegistration.cs b/src/ClipDiff.Windows/Explorer/ExplorerContextMenuRegistration.cs index b5ecdc0..f4b7231 100644 --- a/src/ClipDiff.Windows/Explorer/ExplorerContextMenuRegistration.cs +++ b/src/ClipDiff.Windows/Explorer/ExplorerContextMenuRegistration.cs @@ -2,6 +2,7 @@ using System.Runtime.InteropServices; using System.Security; using Microsoft.Win32; +using ClipDiff.Windows.Installation; namespace ClipDiff.Windows.Explorer; @@ -25,7 +26,7 @@ internal sealed class ExplorerContextMenuRegistration : IDisposable private readonly string singleCommandLine; private readonly string comServerCommandLine; private readonly string iconPath; - private readonly string extensionPath = Path.Combine(AppContext.BaseDirectory, "ClipDiff.ShellExtension.dll"); + private readonly string extensionPath = Path.Combine(AppContext.BaseDirectory, ShellExtensionFiles.CurrentFileName); private readonly EventWaitHandle? pairReady; private readonly ExplorerRegistrationCoordinator? registrationCoordinator; private bool pairRegistered; diff --git a/src/ClipDiff.Windows/Installation/InstallerShutdown.cs b/src/ClipDiff.Windows/Installation/InstallerShutdown.cs new file mode 100644 index 0000000..5743011 --- /dev/null +++ b/src/ClipDiff.Windows/Installation/InstallerShutdown.cs @@ -0,0 +1,28 @@ +using System.Threading; +using System.Windows.Threading; + +namespace ClipDiff.Windows.Installation; + +internal sealed class InstallerShutdown : IDisposable +{ + // The per-user installer signals this session-local event and waits for the app's mutex to disappear. + private readonly EventWaitHandle shutdownEvent = new(false, EventResetMode.AutoReset, @"Local\ClipDiff.ShutdownForUpdate"); + private readonly RegisteredWaitHandle registration; + + public InstallerShutdown(Dispatcher dispatcher, Action shutdown) + { + registration = ThreadPool.RegisterWaitForSingleObject(shutdownEvent, (_, _) => + { + if (!dispatcher.HasShutdownStarted) + { + dispatcher.BeginInvoke(shutdown); + } + }, null, Timeout.Infinite, executeOnlyOnce: true); + } + + public void Dispose() + { + registration.Unregister(null); + shutdownEvent.Dispose(); + } +} diff --git a/src/ClipDiff.Windows/Installation/ShellExtensionFiles.cs b/src/ClipDiff.Windows/Installation/ShellExtensionFiles.cs new file mode 100644 index 0000000..26ea39f --- /dev/null +++ b/src/ClipDiff.Windows/Installation/ShellExtensionFiles.cs @@ -0,0 +1,54 @@ +using System.IO; +using System.Reflection; +using System.Security; +using System.Text.RegularExpressions; + +namespace ClipDiff.Windows.Installation; + +internal static class ShellExtensionFiles +{ + private static readonly Regex FileNamePattern = new( + @"\AClipDiff\.ShellExtension(?:\.[0-9a-f]{64})?\.dll\z", RegexOptions.CultureInvariant); + + public static string CurrentFileName { get; } = ResolveFileName(typeof(ShellExtensionFiles).Assembly + .GetCustomAttributes().FirstOrDefault(attribute => attribute.Key == "ShellExtensionFileName")?.Value); + + internal static string ResolveFileName(string? metadata) => + metadata is not null && FileNamePattern.IsMatch(metadata) ? metadata : "ClipDiff.ShellExtension.dll"; + + public static void TryCleanup(string directory, string currentFileName, IEnumerable protectedPaths) + { + try + { + var keep = new HashSet(protectedPaths, StringComparer.OrdinalIgnoreCase) + { + Path.Combine(directory, currentFileName) + }; + + foreach (var path in Directory.EnumerateFiles(directory, "ClipDiff.ShellExtension*.dll")) + { + if (!FileNamePattern.IsMatch(Path.GetFileName(path)) || keep.Contains(path)) + { + continue; + } + + try + { + // Windows rejects deletion of a loaded image. Do not schedule a reboot or restart Explorer. + File.Delete(path); + } + catch (Exception exception) when (IsCleanupFailure(exception)) + { + // Retry on a later launch after Explorer has released the image. + } + } + } + catch (Exception exception) when (IsCleanupFailure(exception)) + { + // An unwritable directory or failed enumeration must never prevent startup. + } + } + + private static bool IsCleanupFailure(Exception exception) => + exception is IOException or UnauthorizedAccessException or SecurityException; +} From 24c00be333ab1d6373b2885dfefabd0c9a9f2e71 Mon Sep 17 00:00:00 2001 From: S D Date: Fri, 6 Nov 2026 23:33:19 +0000 Subject: [PATCH 2/7] Add consented GitHub release checks and update notifications --- ClipDiff.Windows.sln | 6 + README.md | 105 +++++++---- RELEASING.md | 60 ++++-- SPEC.md | 138 +++++++------- docs/windows-update-testing.md | 86 +++++++++ scripts/create-local-release.ps1 | 8 + src/ClipDiff.Windows/App.xaml.cs | 6 +- src/ClipDiff.Windows/AppController.Updates.cs | 176 ++++++++++++++++++ src/ClipDiff.Windows/AppController.cs | 4 +- .../Settings/ClipDiffSettingsStore.cs | 4 +- .../Tray/TrayIconController.cs | 36 ++++ .../Updates/GitHubReleaseClient.cs | 55 ++++++ .../Updates/ReleaseVersion.cs | 89 +++++++++ .../Updates/UpdateCheckService.cs | 119 ++++++++++++ src/ClipDiff.Windows/Views/UpdateWindow.cs | 86 +++++++++ .../ClipDiff.Update.Tests.csproj | 18 ++ .../DeploymentPolicyTests.cs | 77 ++++++++ .../GitHubReleaseClientTests.cs | 58 ++++++ .../ReleaseVersionTests.cs | 40 ++++ .../ClipDiff.Update.Tests/UpdateCheckTests.cs | 146 +++++++++++++++ 20 files changed, 1200 insertions(+), 117 deletions(-) create mode 100644 docs/windows-update-testing.md create mode 100644 src/ClipDiff.Windows/AppController.Updates.cs create mode 100644 src/ClipDiff.Windows/Updates/GitHubReleaseClient.cs create mode 100644 src/ClipDiff.Windows/Updates/ReleaseVersion.cs create mode 100644 src/ClipDiff.Windows/Updates/UpdateCheckService.cs create mode 100644 src/ClipDiff.Windows/Views/UpdateWindow.cs create mode 100644 tests/ClipDiff.Update.Tests/ClipDiff.Update.Tests.csproj create mode 100644 tests/ClipDiff.Update.Tests/DeploymentPolicyTests.cs create mode 100644 tests/ClipDiff.Update.Tests/GitHubReleaseClientTests.cs create mode 100644 tests/ClipDiff.Update.Tests/ReleaseVersionTests.cs create mode 100644 tests/ClipDiff.Update.Tests/UpdateCheckTests.cs diff --git a/ClipDiff.Windows.sln b/ClipDiff.Windows.sln index 81ff64e..365a992 100644 --- a/ClipDiff.Windows.sln +++ b/ClipDiff.Windows.sln @@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ClipDiff.Core.Tests", "test EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ClipDiff.Windows.Tests", "tests\ClipDiff.Windows.Tests\ClipDiff.Windows.Tests.csproj", "{DD95D357-3C5C-42F8-8BFD-CE69FF6DC540}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ClipDiff.Update.Tests", "tests\ClipDiff.Update.Tests\ClipDiff.Update.Tests.csproj", "{1E944553-8C01-4B53-A116-4F5D7573441B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -33,5 +35,9 @@ Global {DD95D357-3C5C-42F8-8BFD-CE69FF6DC540}.Debug|Any CPU.Build.0 = Debug|Any CPU {DD95D357-3C5C-42F8-8BFD-CE69FF6DC540}.Release|Any CPU.ActiveCfg = Release|Any CPU {DD95D357-3C5C-42F8-8BFD-CE69FF6DC540}.Release|Any CPU.Build.0 = Release|Any CPU + {1E944553-8C01-4B53-A116-4F5D7573441B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1E944553-8C01-4B53-A116-4F5D7573441B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1E944553-8C01-4B53-A116-4F5D7573441B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1E944553-8C01-4B53-A116-4F5D7573441B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/README.md b/README.md index e39b767..7455917 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ClipDiff is a Windows notification-area utility that compares the last two text values captured after it starts. A value can come from copied Unicode text or from a file copied in Explorer. -ClipDiff listens to the Windows clipboard directly. No data is ever uploaded. +ClipDiff listens to the Windows clipboard directly. Clipboard content and document data are never uploaded. Optional update checks contact GitHub only after you enable them or request a manual check. ## Use @@ -125,35 +125,64 @@ Windows Server Core is not supported. The initial release target is `win-x64`. ## Install or update -Download one of these assets from the GitHub release and extract the whole -archive into its own directory: - -- `ClipDiff--win-x64-self-contained.zip` includes its own .NET runtime - and works without a separately installed runtime. It is the larger download. -- `ClipDiff--win-x64-net10.zip` is much smaller but requires the x64 - **.NET 10 Desktop Runtime** (`Microsoft.WindowsDesktop.App`), not merely the - base `Microsoft.NETCore.App` runtime. - -Check an existing installation with `dotnet --list-runtimes`; the smaller build -requires a `Microsoft.WindowsDesktop.App 10.x` entry for x64. Both archives -contain exactly these two files: - -```text -ClipDiff.exe -ClipDiff.ShellExtension.dll -``` - -They must stay beside one another. Run `ClipDiff.exe`; the application manages -the Explorer integration per user. Do not copy the DLL to a Windows system -directory or register it manually. - -ClipDiff is portable and has no installer. The directory may be anywhere the -current user can write, for example -`%LOCALAPPDATA%\Programs\ClipDiff\1.0.0`. To update, quit -ClipDiff, extract the new ZIP to a new directory, and run the new executable. -Using a new directory avoids trying to overwrite an older shell-extension DLL -that Explorer may still have loaded. Restarting Explorer or signing out may be -needed before the old directory can be deleted. +Download `ClipDiff--win-x64-Setup.exe` from the +[latest GitHub Release](https://github.com/stuartd/ClipboardDiff/releases/latest) +and run it. The installer includes .NET, installs under +`%LOCALAPPDATA%\Programs\ClipDiff` without administrator rights, and offers to +launch ClipDiff. Run a newer installer to update: it closes the installed app, +replaces the executable, and preserves your preferences and existing start-at-login choice. +Closing ClipDiff discards the two in-memory captures. + +For your first move from an older portable build, quit that copy before running +the installer. Your preferences already live outside the portable folder and are +retained. Remove any manually created Startup-folder shortcut to the old copy. +The installer does not delete old portable folders. + +Explorer may continue using a loaded older extension temporarily. Each different +extension binary has its own `ClipDiff.ShellExtension..dll` filename; +updates leave the loaded old DLL in place and the app registers the current one. +Later launches remove obsolete, unlocked DLLs. Cleanup failures never block +startup. Normal updates do not require restarting Explorer or Windows. + +Uninstall through Windows **Installed apps**. Uninstall stops ClipDiff and +removes its installed executable, shortcuts and owned registrations. An extension +DLL held by Explorer can remain in the installation folder until released; +uninstall does not restart Explorer to remove it. Preferences are retained. + +Portable ZIPs remain available: + +- `ClipDiff--win-x64-self-contained.zip` includes .NET. +- `ClipDiff--win-x64-net10.zip` requires the x64 **.NET 10 Desktop + Runtime** (`Microsoft.WindowsDesktop.App 10.x`), not just the base runtime. + +Each ZIP contains exactly `ClipDiff.exe` and its matching +`ClipDiff.ShellExtension..dll`. Keep them beside one another. For a portable +update, quit ClipDiff, extract the new ZIP, and run the new executable. Leave any +locked older extension in place. Do not register DLLs manually or copy them to a +Windows system directory. + +All downloads remain unsigned. Windows SmartScreen may warn about the installer +or application; packaging does not remove that warning. + +## Update checks + +On the first normal launch, ClipDiff asks whether to check `api.github.com` for +new releases. **Enable** permits background checks at most once every 24 hours; +**Not now** saves that choice and makes no automatic requests. The tray menu's +**Automatically check for updates** toggle changes this preference. Manual +**Check for updates...** works regardless of the automatic setting and reports +up-to-date or failure states. Offline checks never stop clipboard monitoring. + +Checks read the latest published, non-prerelease GitHub Release. Each newer +version produces at most one automatic tray notification, even across restarts. +Click the notification or the update-available tray item for **Download** and +**Dismiss**. Download opens the specific GitHub release page in your browser; +run its Setup installer yourself. Nothing is downloaded or installed automatically. + +GitHub receives the normal request metadata, including your IP address. Requests +contain no clipboard text, document data, selected paths or settings. Stored +update preferences contain only consent, the last check attempt time, and the +last notified version. ## Build and test @@ -239,6 +268,7 @@ It stages each variant in a clean private directory, then creates these gitignor directories and ZIPs: ```text +releases\ClipDiff--win-x64-Setup.exe releases\ClipDiff--win-x64-self-contained\ releases\ClipDiff--win-x64-self-contained.zip releases\ClipDiff--win-x64-net10\ @@ -246,7 +276,7 @@ releases\ClipDiff--win-x64-net10.zip ``` Both directories and ZIPs are validated to contain exactly `ClipDiff.exe` and -`ClipDiff.ShellExtension.dll`. Those are the complete release; keep them +`ClipDiff.ShellExtension..dll`. Those are the complete portable payload; keep them together. The self-contained variant bundles .NET; the `net10` variant expects the x64 .NET 10 Desktop Runtime on the target computer. PDB, `deps.json`, `runtimeconfig.json`, `bin`, `obj`, and the rest of the raw publish directories @@ -262,11 +292,10 @@ To skip the native Explorer integration tests for a local release, pass `-SkipNa This still builds and packages the native DLL and runs the .NET tests. Native tests run by default and remain enabled in CI. You can combine this switch with `-Launch`. -Quit ClipDiff before upgrading. The versioned package directory deliberately -makes side-by-side extraction straightforward because Explorer may keep the old -DLL loaded. Restart Explorer or sign out if the old handler remains loaded. -Starting the new version removes the obsolete two-file static menu registration -automatically. +The Setup installer handles upgrades and closes ClipDiff automatically. A loaded +old extension may continue serving Explorer until released; its COM contract +remains compatible with the new app. The app registers its current extension +and removes obsolete unlocked DLLs on later launches. If PowerShell reports that script execution is disabled, allow the checked-out script for this process only, then run it again: @@ -279,7 +308,9 @@ Set-ExecutionPolicy -Scope Process Bypass Copy the chosen ZIP to the target PC and extract both files together. Choose the `net10` archive only when the x64 .NET 10 Desktop Runtime is already installed. -No installer, automatic updater, or code signing is included in the initial release. +Installers and optional update notifications are included. Automatic installation +and code signing are not included. Building release packages requires Inno Setup +6.3 or later in addition to the .NET SDK and C++ build tools. Maintainer instructions for publishing a tagged GitHub release are in [`RELEASING.md`](RELEASING.md). diff --git a/RELEASING.md b/RELEASING.md index 440e4fc..d4c6319 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,11 +1,13 @@ # Releasing ClipDiff -The release contract is the ZIPs, not a `bin` or raw `dotnet publish` -directory. Every ClipDiff Windows x64 package contains exactly: +The release contract is the Inno Setup installer and two portable ZIPs, not a +`bin` or raw `dotnet publish` directory. The recommended download is +`ClipDiff--win-x64-Setup.exe`, which bundles .NET and installs per-user +without elevation under `%LOCALAPPDATA%\Programs\ClipDiff`. Every ClipDiff Windows x64 package contains exactly: ```text ClipDiff.exe -ClipDiff.ShellExtension.dll +ClipDiff.ShellExtension..dll ``` Both files go in the same directory. The executable is the .NET application; @@ -21,19 +23,23 @@ On Windows, from a normal non-administrator PowerShell in the repository root: ``` The script requires the .NET 10 SDK and Visual Studio C++ build tools with a -Windows SDK. It builds and tests the native extension, runs the .NET tests, +Windows SDK, plus Inno Setup 6.3+ in its default `Inno Setup 6` location or +with `ISCC.exe` on PATH. It builds and tests the native extension, runs the .NET tests, publishes both application variants in separate clean staging directories, and -validates both final folders and ZIPs. Output is: +validates both final folders and ZIPs, then builds the self-contained installer +from the validated payload before exposing any release assets. Output is: ```text +releases\ClipDiff--win-x64-Setup.exe + releases\ClipDiff--win-x64-self-contained\ ClipDiff.exe - ClipDiff.ShellExtension.dll + ClipDiff.ShellExtension..dll releases\ClipDiff--win-x64-self-contained.zip releases\ClipDiff--win-x64-net10\ ClipDiff.exe - ClipDiff.ShellExtension.dll + ClipDiff.ShellExtension..dll releases\ClipDiff--win-x64-net10.zip ``` @@ -69,11 +75,40 @@ It shares tag validation and package naming with the release script, checks regu and prerelease version propagation on ordinary builds, and verifies published executable metadata. Tags such as `v1.2.3-preview.1` preserve the prerelease suffix in the product version and both ZIP names. The workflow validates the tag, runs the complete release script, -and creates a GitHub release with generated notes and both +runs the installer smoke test, and creates a GitHub release with the +`ClipDiff-1.2.3-win-x64-Setup.exe` installer and both `ClipDiff-1.2.3-win-x64-self-contained.zip` and `ClipDiff-1.2.3-win-x64-net10.zip` attached. It will not publish if any build, test, or payload validation fails. +## Installer verification and extension lifecycle + +Follow [the Windows update testing checklist](docs/windows-update-testing.md). +CI runs `scripts/test-installer.ps1`, which installs the app, starts it, holds +native DLL images open, upgrades with unchanged and changed extension binaries, +checks registration, and uninstalls without a restart. This checks Windows image +locks; real Explorer menus and COM caching still require interactive testing. +Ordinary Actions builds expose the installer as `ClipDiff-win-x64-Setup`. + +Setup signals `Local\ClipDiff.ShutdownForUpdate` and waits for the running app to +exit before replacing its EXE. It preserves preferences and an existing startup +opt-in, then offers relaunch. Pre-installer portable builds require a one-time +manual quit; their folders are not deleted. + +SHA-256 filenames are independent of app versions. Reproducible native linking +lets unchanged binaries reuse their name. Installers never overwrite an existing +hashed DLL; the app registers the current path and attempts to clean obsolete, +unlocked DLLs on launch, skipping other sessions' claims. Preserve the native +COM IDs/interfaces, readiness event and delivery behavior so an older loaded +handler remains compatible during the transition. Never restart Explorer or +request a reboot for cleanup. Uninstall leaves loaded DLLs behind, retains +preferences, and removes only owned registrations pointing into its installation. +Development builds continue to use `ClipDiff.ShellExtension.dll`. + +Automatic checks use GitHub's `releases/latest` API, which excludes drafts and +prereleases. Download opens that specific release page; no automatic downloading +or installation occurs. + ## Install or update the portable build Extract one whole ZIP into a directory owned by the current user and run @@ -81,11 +116,10 @@ Extract one whole ZIP into a directory owned by the current user and run an x64 `Microsoft.WindowsDesktop.App 10.x` entry. Do not move the DLL elsewhere or run `regsvr32`; ClipDiff owns its per-user Explorer registration. -For an update, quit ClipDiff and extract the new version into a new directory. -This avoids overwriting a DLL that Explorer may still have loaded. Update any -startup shortcut to the new executable. The old directory can be removed after -Explorer releases its DLL, which may require restarting Explorer or signing -out. +For a portable update, quit ClipDiff and extract the entire new ZIP. Different +extensions have different filenames, so leave a loaded older DLL in place. The +new app registers its matching DLL. Cleanup is best effort on later launches. +Use Setup for the recommended update path. Release binaries are currently unsigned, so Windows SmartScreen may warn on the first launch. diff --git a/SPEC.md b/SPEC.md index 0a6c4c3..7094ec9 100644 --- a/SPEC.md +++ b/SPEC.md @@ -47,7 +47,7 @@ Use: - Win32 APIs through small, explicit P/Invoke wrappers - PowerShell for local build/release scripts -The two-file Explorer menu additionally uses a small native C++17 `ClipDiff.ShellExtension.dll`, built with the Visual Studio C++ toolset and Windows SDK. This is the approved exception to the C#-only, single-executable design: only selection/menu handling runs in Explorer, with a statically linked C++ runtime and no third-party runtime dependencies. File conversion and comparison remain in the C# application. +The two-file Explorer menu additionally uses a small native C++17 `ClipDiff.ShellExtension..dll` in release packages (`ClipDiff.ShellExtension.dll` during development), built with the Visual Studio C++ toolset and Windows SDK. This is the approved exception to the C#-only, single-executable design: only selection/menu handling runs in Explorer, with a statically linked C++ runtime and no third-party runtime dependencies. File conversion and comparison remain in the C# application. The Windows application project should target: @@ -108,7 +108,7 @@ The application must not: - Add accounts or cloud services. - Add a clipboard-history browser. - Retain more than the two values needed for the comparison. -- Add settings or onboarding beyond the external-viewer executable path, warning acknowledgement, global keyboard shortcut, and start-at-login prompt, or add a complex preferences interface. +- Add settings or onboarding beyond the external-viewer executable path, warning acknowledgement, global keyboard shortcut, start-at-login prompt, and explicitly consented update checks, or add a complex preferences interface. - Add document-management features or file access beyond the copied-file conversion and explicit Explorer actions in section 6.4. - Use a terminal window or embedded web UI for the diff. - Require an installer for the initial personal release. @@ -768,6 +768,8 @@ Show Diff (Ctrl+Alt+D) Diff viewer: Built-in [submenu] Keyboard shortcut... Start at login [checked/unchecked] +Automatically check for updates [checked/unchecked] +Check for updates... ────────────────────────────────── About ClipDiff Quit ClipDiff @@ -919,7 +921,7 @@ Track the process returned by the launch. Attempt to delete the comparison direc Cleanup is best effort because crashes, power loss, open file handles, and programs that delegate to another process cannot be controlled. Normalize read-only attributes before deletion. Never log a temporary path together with clipboard text. -Persist application preferences in `%LOCALAPPDATA%\ClipDiff\settings.json`. The file may contain only the selected executable path, the one-time-warning acknowledgement, the global-shortcut modifier/virtual-key values, and whether the startup prompt has been shown. It must never contain clipboard text, previews, diffs, temporary-file contents, or selected-file paths. Returning to the built-in viewer clears the executable selection but need not reset the acknowledgement. +Persist application preferences in `%LOCALAPPDATA%\ClipDiff\settings.json`. The file may contain only the selected executable path, the one-time-warning acknowledgement, the global-shortcut modifier/virtual-key values, whether the startup prompt has been shown, and update-check consent/last attempt time/last notified release version. It must never contain clipboard text, previews, diffs, temporary-file contents, or selected-file paths. Returning to the built-in viewer clears the executable selection but need not reset the acknowledgement. ## 17. Suggested solution structure @@ -1297,7 +1299,7 @@ On Windows: 1. Confirm the per-comparison directory and its two read-only files are removed after the launched process exits and when ClipDiff exits. 1. Leave a simulated stale comparison directory and confirm the next ClipDiff start removes it. 1. Remove or rename the selected executable and confirm Show Diff falls back to the built-in viewer. -1. Inspect settings.json and confirm it contains only the selected path, acknowledgement, shortcut codes, and startup-prompt flag, never captured text or previews. +1. Inspect settings.json and confirm it contains only the selected path, acknowledgement, shortcut codes, startup-prompt flag, and update-check consent/time/version metadata, never captured text or previews. 1. Copy one file, then right-click a second file and choose **Compare with current ClipDiff capture**; confirm the second file becomes current, the copied file becomes previous, and the configured viewer opens immediately without changing the clipboard. 1. Confirm the Explorer command is single-file only, uses the same binary/encoding/fallback rules, disappears on quit or when no capture remains, and is under **Show more options** on Windows 11 when not shown in the primary menu. 1. With no captured value required, select exactly two files in Explorer and choose **Compare two selected files with ClipDiff**; confirm the first Explorer-supplied path becomes previous, the second becomes current, and the configured viewer opens immediately without changing the clipboard. @@ -1309,65 +1311,50 @@ On Windows: 1. Separately investigate Ctrl+C on file nodes in Visual Studio Solution Explorer and VS Code Explorer on Windows, recording editor versions, project/workspace types, actual clipboard formats and payload structure. Verify standard file-list handling where available; record limitations before considering custom adapters. This investigation is not yet verified. 1. Start ClipDiff after a simulated abnormal exit and confirm it removes an owned stale Explorer registration when there is no captured entry. -## 22. Release build - -Provide: - -``` -scripts/create-local-release.ps1 -``` - -It should: - -1. Build and test the native Explorer DLL with Visual Studio C++ build tools and the Windows SDK. -1. Run core tests. -1. Publish a Release build through a clean private staging directory. -1. Place versioned self-contained and .NET 10 framework-dependent directories and ZIPs in the gitignored `releases/` directory. -1. Publish both variants as single-file applications. The framework-dependent variant requires the x64 .NET 10 Desktop Runtime. -1. Place `ClipDiff.ShellExtension.dll` alongside `ClipDiff.exe` in both variants; keep the DLL outside the .NET single-file bundle. -1. Validate that every versioned directory and ZIP contains exactly `ClipDiff.exe` and `ClipDiff.ShellExtension.dll`, with no SDK by-products or stale files. -1. Optionally launch the resulting executable. - -Suggested publish properties: - -``` -win-x64 -true -true -false -true -``` - -Do not enable trimming for the initial WPF build. - -The native projects are built separately with `scripts/build-shell-extension.ps1 -Test`, so macOS can still cross-compile the .NET application and run the pure core tests. The release script runs this native build first. Distribute the executable and DLL together. Explorer can keep a loaded DLL locked after ClipDiff quits; updating into a new directory avoids overwriting a loaded DLL, and restarting Explorer or signing out may be needed to unload an older handler. Do not restart Explorer automatically. - -Continuous integration must call the same release script rather than duplicate the publish commands. A semantic-version tag in the form `v1.2.3` or `v1.2.3-preview.1` should run the complete build and test process, then create a GitHub release containing both validated versioned ZIPs. One workflow owns tag releases. Share version validation and package naming with the packaging script; test regular/prerelease version propagation in ordinary CI and validate published executable metadata before exposing packages. - -For local releases, `create-local-release.ps1 -SkipNativeTests` may explicitly skip the native Explorer integration tests. It must still build and package the DLL and run the .NET tests. Native tests remain enabled by default and in CI. - -A typical command is: - -``` -dotnet publish src/ClipDiff.Windows/ClipDiff.Windows.csproj ` - --configuration Release ` - --runtime win-x64 ` - --self-contained true ` - -p:PublishSingleFile=true ` - -p:PublishTrimmed=false -``` - -The first release can be an unsigned personal executable. Document that Windows SmartScreen may warn about an unsigned binary. - -Not required initially: - -- MSI -- MSIX -- Microsoft Store submission -- code signing -- automatic updates -- notarization equivalent -- enterprise deployment machinery +## 22. Release build and installer + +`scripts/create-local-release.ps1` is the shared local/CI release entry point. On +Windows it builds/tests the native extension, runs .NET tests, and publishes both +self-contained and .NET 10 Desktop Runtime dependent x64 single-file applications +without trimming. `-SkipNativeTests` is a local-only opt-out of native integration +tests; it does not skip the native build or .NET tests. + +Hash the native DLL with SHA-256 and ship it as +`ClipDiff.ShellExtension..dll`. Embed that exact filename in the app's +assembly metadata and use it for per-user registration. Each validated portable +directory/ZIP contains exactly that DLL and `ClipDiff.exe`. The hash is independent +of the application version. Use reproducible native linking to avoid changing the +hash for app-only releases. Keep the native DLL outside the .NET single-file bundle. + +Build an unsigned, self-contained Inno Setup installer from the same validated +payload before exposing release assets. Install per-user without elevation into +`%LOCALAPPDATA%\Programs\ClipDiff`, supply an Installed apps uninstaller and Start +menu shortcut, and offer to launch ClipDiff. Preserve preferences and an existing +start-at-login opt-in. Use a stable Inno AppId across versions. + +The app listens for the session-local `Local\ClipDiff.ShutdownForUpdate` event. +The installer/uninstaller signals it and waits for `Local\ClipDiff` to disappear +before changing the executable. Never force-kill the app or restart Explorer. +Old pre-installer portable builds require a one-time manual quit during migration. + +Never overwrite an existing extension DLL: install immutable hashed filenames +only when absent. Keep loaded old extensions available and preserve their native +COM class IDs, interfaces, readiness event and delivery behavior during updates. +On launch, register the current path; best-effort cleanup deletes only recognised +obsolete DLLs that are not referenced by another session's registration claim. +Windows rejects deletion while an image is loaded. Cleanup failure must not +prevent startup or updating. Uninstall likewise leaves locked DLLs behind without +requesting a reboot and retains user preferences. + +The tag workflow publishes `ClipDiff--win-x64-Setup.exe` alongside both +portable ZIPs only after build, packaging and installer smoke checks succeed. +Versioned artifact names preserve prerelease suffixes. `scripts/test-installer.ps1` +checks installation, running-app upgrades with unchanged and changed loaded DLLs, +and uninstall. Real Explorer interactions still require the manual Windows checks +in `docs/windows-update-testing.md`. Unsigned downloads may trigger SmartScreen. + +MSI, MSIX, Store distribution, code signing and automatic installation are outside +this change. ## 23. Development from macOS @@ -1426,6 +1413,29 @@ switching to the built-in option to avoid launching an older copy. Verify opt-in/out, prompt persistence, upgrade path quoting, failures, and actual sign-out/sign-in behaviour on Windows. +### 24.1 Opt-in update checks + +On the first normal launch, ask **Enable** or **Not now** before making any update +request to `api.github.com/repos/stuartd/ClipboardDiff/releases/latest`. Remember +either choice, including a decline. Do not prompt secondary instances or Explorer +command invocations. The tray menu provides **Automatically check for updates** +and **Check for updates...**; no separate Settings window is required. + +Automatic checks run asynchronously at most every 24 hours. Persist attempt time +before the request, including failed attempts. Check periodically while the app +remains open. Disable takes effect immediately and cancels an automatic request +already in progress. No automatic request is allowed if consent or the attempt +time cannot be saved. Manual checks bypass the automatic setting/throttle and +show current/newer/failure states. Bound request time and response size. + +Compare SemVer versions, ignoring build metadata and handling prerelease ordering. +The endpoint supplies the latest published non-prerelease release. A newer version +produces one unobtrusive tray notification per version, remembered across restarts. +Clicking it offers **Download** (the exact GitHub release page) and **Dismiss**. +Do not download or install updates automatically. Do not send clipboard/document +data, selected paths or settings. Explain the GitHub destination, purpose and +normal request metadata in the consent prompt and privacy documentation. + ## 25. README content The README should explain: @@ -1478,7 +1488,7 @@ The first release is complete when all of these are true: - Quitting releases native resources and loses all captured content. - Core unit tests pass. - A self-contained `win-x64` Release build can be produced. -- The application has no dependency on Ditto, a database, a web service, or the macOS repository. +- Clipboard functionality has no dependency on Ditto, a database, a web service, or the macOS repository. Optional GitHub update checks fail independently. ## 27. Suggested implementation order diff --git a/docs/windows-update-testing.md b/docs/windows-update-testing.md new file mode 100644 index 0000000..db2b295 --- /dev/null +++ b/docs/windows-update-testing.md @@ -0,0 +1,86 @@ +# Windows install and update verification + +These checks require an interactive x64 Windows 10/11 desktop. A Linux build is +not evidence that installation or Explorer integration works. + +## Automated installer smoke test + +On a disposable Windows user profile with no existing installer installation: + +```powershell +.\scripts\create-local-release.ps1 +.\scripts\test-installer.ps1 +``` + +Requires the .NET 10 SDK, Visual Studio C++ tools/Windows SDK and Inno Setup 6.3+ +(the `Inno Setup 6` install location, or `ISCC.exe` on PATH). Close any portable +ClipDiff first. The test temporarily backs up/restores preferences and the Run +entry. It installs into a private directory under `artifacts/installer-smoke`, +disables network checks, starts the real app, and holds its extension open with +`LoadLibrary`. It tests unchanged-DLL and changed-DLL upgrades while the app is +running, verifies registration of the new filename, and uninstalls with both DLLs +loaded. The changed DLL fixture uses a harmless PE overlay and retains the same +COM interface. All installer exits must be zero, with no restart requested. + +CI runs this test before uploading release assets. Logs live under +`artifacts/installer-smoke/` and are uploaded on failure. This simulates the +Windows image lock; it does not exercise Explorer's menus or COM cache. + +## Interactive installer and Explorer checks + +- Run the unsigned Setup asset as a normal user. Confirm no elevation is needed, + .NET is bundled, a Start menu shortcut appears, and relaunch is offered. + SmartScreen may warn; that is expected for unsigned downloads. +- On first normal launch, choose **Not now** for update checks. Use a network + inspector to verify no update request, quit and relaunch, and verify no prompt + or automatic request. The start-at-login prompt is a separate preference. +- Compare two selected text files through Explorer, including **Show more + options** on Windows 11, to make Explorer load the native extension. +- Keep ClipDiff running and install the same build again. Confirm it closes + cleanly, updates without a file-in-use prompt, and offers to relaunch. Existing + settings and login opt-in must survive. Captured text is discarded on shutdown. +- Upgrade to a build whose extension hash differs. Keep Explorer running. Confirm + the old loaded DLL remains, the new DLL has a distinct filename, and + `HKCU\Software\Classes\CLSID\{6B46A974-40E2-4AD4-9F68-E534202B11E8}\InprocServer32` + points at the new file after relaunch. Exercise both comparison menu commands + while Explorer can still use its old handler. No Explorer restart is required. +- Relaunch while the old DLL is locked; startup and comparisons must still work. + After Explorer naturally releases it (for example, after signing out/in), launch + again and confirm obsolete DLL cleanup. Do not restart Explorer as an update step. +- Quit ClipDiff and deny deletion of an obsolete DLL using a file ACL, then + relaunch. Cleanup failure must not affect startup, comparisons or the next + installer update. Restore the ACL afterwards. +- Uninstall with ClipDiff running and after opening the Explorer menu. Confirm + the tray icon disappears, installed EXE/shortcut/Run entry are removed, and no + reboot or Explorer restart is requested. A locked DLL/folder may remain; user + preferences remain. Menu invocations must not launch a removed executable. +- For the one-time migration from a pre-installer portable copy: quit it, install, + and confirm the saved viewer/shortcut preferences remain. An existing built-in + startup registration follows the installed EXE. Remove any manually created + Startup-folder shortcut to the portable copy. Its folder is left untouched. + +## Update notification checks + +- With automatic checks off, click **Check for updates...**. Confirm a request + occurs only when clicked and the result is clearly up-to-date/newer/failed. +- Enable **Automatically check for updates**. The consent prompt must identify + `api.github.com`, describe the purpose, and explain no content is sent. Confirm + one background request and that clipboard work continues during it. +- Restart repeatedly within 24 hours, including after an offline/failed attempt. + Confirm no repeat automatic requests. Leave ClipDiff running past the next daily + deadline to verify periodic checks without restarting. +- Use a build with an installed version below the latest published stable release + (build locally with `-Version` before packaging) to verify the notification. + Click it: **Download** opens that release's GitHub page; **Dismiss** closes the + update window. No file is downloaded by ClipDiff itself. Recheck on the next + day/restart and confirm the same version does not notify again automatically. +- Disable automatic checks while a slow request is in progress. Confirm it is + cancelled and produces no later notification. Restart and verify it stays off. +- Test offline and GitHub failure/rate-limit responses. Manual checks explain the + failure; automatic failures stay quiet. Clipboard/hotkey/Explorer work continues. +- Inspect `%LOCALAPPDATA%\ClipDiff\settings.json`: update data must contain only + consent, last attempt time, and last notified version. Inspect request metadata: + no clipboard text, document data, file paths or settings may be sent. + +The portable ZIPs should also launch with exactly their matching EXE and hashed +DLL. The `net10` variant additionally needs the x64 .NET 10 Desktop Runtime. diff --git a/scripts/create-local-release.ps1 b/scripts/create-local-release.ps1 index 3106066..67bc04c 100644 --- a/scripts/create-local-release.ps1 +++ b/scripts/create-local-release.ps1 @@ -14,6 +14,7 @@ $packageStagingRoot = Join-Path $repositoryRoot 'artifacts/release-package' $applicationProject = Join-Path $repositoryRoot 'src/ClipDiff.Windows/ClipDiff.Windows.csproj' $coreTests = Join-Path $repositoryRoot 'tests/ClipDiff.Core.Tests/ClipDiff.Core.Tests.csproj' $windowsTests = Join-Path $repositoryRoot 'tests/ClipDiff.Windows.Tests/ClipDiff.Windows.Tests.csproj' +$updateTests = Join-Path $repositoryRoot 'tests/ClipDiff.Update.Tests/ClipDiff.Update.Tests.csproj' $nativeDll = Join-Path $repositoryRoot 'artifacts/native/Release/ClipDiff.ShellExtension.dll' $extensionFileName = $null $requiredPayload = @() @@ -105,6 +106,13 @@ try throw 'Windows policy tests failed.' } + dotnet test $updateTests --configuration Release + + if ($LASTEXITCODE -ne 0) + { + throw 'Update policy tests failed.' + } + # dotnet publish does not clean custom output directories. Build both variants # privately and validate both before replacing any public release output. diff --git a/src/ClipDiff.Windows/App.xaml.cs b/src/ClipDiff.Windows/App.xaml.cs index abdd29a..75f74f2 100644 --- a/src/ClipDiff.Windows/App.xaml.cs +++ b/src/ClipDiff.Windows/App.xaml.cs @@ -46,7 +46,11 @@ protected override void OnStartup(StartupEventArgs args) } else { - Dispatcher.InvokeAsync(controller.InitializeStartAtLogin); + Dispatcher.InvokeAsync(() => + { + controller.InitializeStartAtLogin(); + controller?.InitializeUpdateConsent(); + }); } } diff --git a/src/ClipDiff.Windows/AppController.Updates.cs b/src/ClipDiff.Windows/AppController.Updates.cs new file mode 100644 index 0000000..f6b02e6 --- /dev/null +++ b/src/ClipDiff.Windows/AppController.Updates.cs @@ -0,0 +1,176 @@ +using System.Net.Http; +using System.Reflection; +using System.Windows.Threading; +using ClipDiff.Windows.Updates; +using ClipDiff.Windows.Views; + +namespace ClipDiff.Windows; + +internal sealed partial class AppController +{ + private readonly HttpClient updateHttpClient = new(new HttpClientHandler { AllowAutoRedirect = false }); + private readonly CancellationTokenSource updateShutdown = new(); + private UpdateCheckService updateCheckService = null!; + private DispatcherTimer? updateTimer; + private UpdateWindow? updateWindow; + private GitHubRelease? availableRelease; + private bool updateCheckRunning; + + private void InitializeUpdateChecks() + { + var versionText = typeof(AppController).Assembly.GetCustomAttribute()?.InformationalVersion; + ReleaseVersion.TryParse(versionText, out var version); + var client = new GitHubReleaseClient(updateHttpClient); + updateCheckService = new UpdateCheckService(version, client.FetchLatestAsync, + () => settings.UpdateChecks ?? new(), TrySaveUpdateSettings); + trayIcon.ToggleAutomaticUpdatesRequested += OnToggleAutomaticUpdatesRequested; + trayIcon.CheckForUpdatesRequested += OnCheckForUpdatesRequested; + trayIcon.AvailableUpdateRequested += OnAvailableUpdateRequested; + RefreshUpdateChecks(); + } + + public void InitializeUpdateConsent() + { + if (disposed) + { + return; + } + + if (settings.UpdateChecks?.AutomaticChecksEnabled is null) + { + var enabled = UpdateWindow.AskConsent(); + + if (disposed) + { + return; + } + + if (!updateCheckService.TrySetAutomaticChecks(enabled)) + { + ShowStartupError("ClipDiff could not save your update preference. Automatic checks remain off; the question may appear next time."); + } + } + + RefreshUpdateChecks(); + updateTimer = new DispatcherTimer { Interval = TimeSpan.FromHours(1) }; + updateTimer.Tick += OnUpdateTimer; + updateTimer.Start(); + _ = CheckForUpdatesAsync(manual: false); + } + + private bool TrySaveUpdateSettings(UpdateCheckSettings preferences) + { + var updated = settings with { UpdateChecks = preferences }; + + if (!settingsStore.TrySave(updated)) + { + return false; + } + + settings = updated; + return true; + } + + private void OnToggleAutomaticUpdatesRequested(object? sender, EventArgs args) + { + var enabled = settings.UpdateChecks?.AutomaticChecksEnabled == true; + + if (!enabled && !UpdateWindow.AskConsent()) + { + return; + } + + if (disposed) + { + return; + } + + if (!updateCheckService.TrySetAutomaticChecks(!enabled)) + { + ShowStartupError("ClipDiff could not save your update preference. The previous setting remains in effect."); + } + + RefreshUpdateChecks(); + _ = CheckForUpdatesAsync(manual: false); + } + + private void OnUpdateTimer(object? sender, EventArgs args) => _ = CheckForUpdatesAsync(manual: false); + private void OnCheckForUpdatesRequested(object? sender, EventArgs args) => _ = CheckForUpdatesAsync(manual: true); + + private void OnAvailableUpdateRequested(object? sender, EventArgs args) + { + if (availableRelease is { } release) + { + ShowUpdateWindow(); + updateWindow!.SetMessage($"ClipDiff {release.Version.Text} is available. Download opens its GitHub release page. Run the Setup installer to update ClipDiff.", release.DownloadPage); + } + } + + private async Task CheckForUpdatesAsync(bool manual) + { + if (disposed || updateCheckRunning) + { + return; + } + + if (manual) + { + ShowUpdateWindow(); + updateWindow!.SetMessage("Checking GitHub for a new ClipDiff release..."); + } + + updateCheckRunning = true; + RefreshUpdateChecks(); + var result = await updateCheckService.CheckAsync(manual, updateShutdown.Token); + + if (disposed) + { + return; + } + + updateCheckRunning = false; + RefreshUpdateChecks(); + + if (result.Status == UpdateCheckStatus.Available && result.Release is { } release) + { + availableRelease = release; + trayIcon.SetAvailableUpdate(release.Version.Text, notify: !manual); + + if (manual) + { + updateWindow?.SetMessage($"ClipDiff {release.Version.Text} is available. Download opens its GitHub release page. Run the Setup installer to update ClipDiff.", release.DownloadPage); + } + } + else if (manual) + { + updateWindow?.SetMessage(result.Status == UpdateCheckStatus.Current + ? "ClipDiff is up to date." + : "ClipDiff could not check for updates. Check your connection and try again later."); + } + } + + private void ShowUpdateWindow() + { + if (updateWindow is null) + { + updateWindow = new UpdateWindow(); + updateWindow.Closed += (_, _) => updateWindow = null; + } + + updateWindow.Show(); + updateWindow.Activate(); + } + + private void RefreshUpdateChecks() => trayIcon.SetUpdateChecks(settings.UpdateChecks?.AutomaticChecksEnabled == true, updateCheckRunning); + + private void DisposeUpdateChecks() + { + updateTimer?.Stop(); + updateShutdown.Cancel(); + updateWindow?.Close(); + updateHttpClient.Dispose(); + trayIcon.ToggleAutomaticUpdatesRequested -= OnToggleAutomaticUpdatesRequested; + trayIcon.CheckForUpdatesRequested -= OnCheckForUpdatesRequested; + trayIcon.AvailableUpdateRequested -= OnAvailableUpdateRequested; + } +} diff --git a/src/ClipDiff.Windows/AppController.cs b/src/ClipDiff.Windows/AppController.cs index 311fce0..095390e 100644 --- a/src/ClipDiff.Windows/AppController.cs +++ b/src/ClipDiff.Windows/AppController.cs @@ -15,7 +15,7 @@ namespace ClipDiff.Windows; -internal sealed class AppController : IDisposable +internal sealed partial class AppController : IDisposable { private readonly DiffEngine diffEngine = new(); private readonly LatestComparison comparison = new(); @@ -84,6 +84,7 @@ public AppController() trayIcon.QuitRequested += OnQuitRequested; trayIcon.ToggleStartAtLoginRequested += OnToggleStartAtLoginRequested; trayIcon.MenuOpening += OnTrayMenuOpening; + InitializeUpdateChecks(); UpdatePresentation(); TryCleanupShellExtensions(); } @@ -202,6 +203,7 @@ public void Dispose() } disposed = true; + DisposeUpdateChecks(); ClearComparison(); clipboardMonitor.ObservationReceived -= OnClipboardObservation; hotKey.Pressed -= OnHotKeyPressed; diff --git a/src/ClipDiff.Windows/Settings/ClipDiffSettingsStore.cs b/src/ClipDiff.Windows/Settings/ClipDiffSettingsStore.cs index c6a66bf..e41ba44 100644 --- a/src/ClipDiff.Windows/Settings/ClipDiffSettingsStore.cs +++ b/src/ClipDiff.Windows/Settings/ClipDiffSettingsStore.cs @@ -1,6 +1,7 @@ using System.IO; using System.Text.Json; using ClipDiff.Windows.Hotkeys; +using ClipDiff.Windows.Updates; namespace ClipDiff.Windows.Settings; @@ -8,7 +9,8 @@ internal sealed record ClipDiffSettings( string? SelectedExecutablePath = null, bool PlaintextWarningAcknowledged = false, HotKeyGesture? HotKey = null, - bool StartupPromptShown = false); + bool StartupPromptShown = false, + UpdateCheckSettings? UpdateChecks = null); internal sealed class ClipDiffSettingsStore { diff --git a/src/ClipDiff.Windows/Tray/TrayIconController.cs b/src/ClipDiff.Windows/Tray/TrayIconController.cs index f0c2d38..50ef0ca 100644 --- a/src/ClipDiff.Windows/Tray/TrayIconController.cs +++ b/src/ClipDiff.Windows/Tray/TrayIconController.cs @@ -16,6 +16,9 @@ internal sealed class TrayIconController : IDisposable private readonly Forms.ToolStripMenuItem diffViewerItem; private readonly Forms.ToolStripMenuItem shortcutItem; private readonly Forms.ToolStripMenuItem startAtLoginItem; + private readonly Forms.ToolStripMenuItem automaticUpdatesItem; + private readonly Forms.ToolStripMenuItem checkForUpdatesItem; + private readonly Forms.ToolStripMenuItem availableUpdateItem; private bool disposed; private IReadOnlyList diffTools = []; @@ -32,6 +35,12 @@ public TrayIconController( diffViewerItem = new Forms.ToolStripMenuItem("Diff viewer"); shortcutItem = new Forms.ToolStripMenuItem("Keyboard shortcut..."); startAtLoginItem = new Forms.ToolStripMenuItem("Start at login") { CheckOnClick = false }; + automaticUpdatesItem = new Forms.ToolStripMenuItem("Automatically check for updates"); + checkForUpdatesItem = new Forms.ToolStripMenuItem("Check for updates..."); + availableUpdateItem = new Forms.ToolStripMenuItem("Update available...") { Visible = false }; + automaticUpdatesItem.Click += (_, _) => ToggleAutomaticUpdatesRequested?.Invoke(this, EventArgs.Empty); + checkForUpdatesItem.Click += (_, _) => CheckForUpdatesRequested?.Invoke(this, EventArgs.Empty); + availableUpdateItem.Click += (_, _) => AvailableUpdateRequested?.Invoke(this, EventArgs.Empty); var aboutItem = new Forms.ToolStripMenuItem("About ClipDiff"); var quitItem = new Forms.ToolStripMenuItem("Quit ClipDiff"); @@ -53,6 +62,9 @@ public TrayIconController( diffViewerItem, shortcutItem, startAtLoginItem, + automaticUpdatesItem, + checkForUpdatesItem, + availableUpdateItem, new Forms.ToolStripSeparator(), aboutItem, quitItem @@ -69,8 +81,32 @@ public TrayIconController( Visible = true }; notifyIcon.DoubleClick += OnDoubleClick; + notifyIcon.BalloonTipClicked += (_, _) => AvailableUpdateRequested?.Invoke(this, EventArgs.Empty); } + public event EventHandler? ToggleAutomaticUpdatesRequested; + public event EventHandler? CheckForUpdatesRequested; + public event EventHandler? AvailableUpdateRequested; + + public void SetUpdateChecks(bool automatic, bool checking) + { + automaticUpdatesItem.Checked = automatic; + checkForUpdatesItem.Enabled = !checking; + checkForUpdatesItem.Text = checking ? "Checking for updates..." : "Check for updates..."; + } + + public void SetAvailableUpdate(string version, bool notify) + { + availableUpdateItem.Text = $"ClipDiff {version} available..."; + availableUpdateItem.Visible = true; + + if (notify) + { + notifyIcon.ShowBalloonTip(10000, "ClipDiff update available", + $"ClipDiff {version} is available. Click for Download or Dismiss.", Forms.ToolTipIcon.Info); + } + } + public event EventHandler? ToggleStartAtLoginRequested; public event EventHandler? MenuOpening; diff --git a/src/ClipDiff.Windows/Updates/GitHubReleaseClient.cs b/src/ClipDiff.Windows/Updates/GitHubReleaseClient.cs new file mode 100644 index 0000000..6a1f3d1 --- /dev/null +++ b/src/ClipDiff.Windows/Updates/GitHubReleaseClient.cs @@ -0,0 +1,55 @@ +using System.IO; +using System.Net.Http; +using System.Text.Json; + +namespace ClipDiff.Windows.Updates; + +internal sealed record GitHubRelease(ReleaseVersion Version, Uri DownloadPage); + +internal sealed class GitHubReleaseClient(HttpClient client) +{ + public const string LatestReleaseApi = "https://api.github.com/repos/stuartd/ClipboardDiff/releases/latest"; + private const string ReleasePagePrefix = "https://github.com/stuartd/ClipboardDiff/releases/tag/"; + private const int MaximumResponseBytes = 64 * 1024; + + public async Task FetchLatestAsync(CancellationToken cancellationToken) + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(TimeSpan.FromSeconds(10)); + using var request = new HttpRequestMessage(HttpMethod.Get, LatestReleaseApi); + request.Headers.UserAgent.ParseAdd("ClipDiff"); + request.Headers.Accept.ParseAdd("application/vnd.github+json"); + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + using var stream = await response.Content.ReadAsStreamAsync(deadline.Token).ConfigureAwait(false); + using var payload = new MemoryStream(); + var buffer = new byte[4096]; + int count; + + while ((count = await stream.ReadAsync(buffer, deadline.Token).ConfigureAwait(false)) != 0) + { + if (payload.Length + count > MaximumResponseBytes) + { + throw new InvalidDataException("The release response is too large."); + } + + payload.Write(buffer, 0, count); + } + + using var document = JsonDocument.Parse(payload.ToArray()); + var root = document.RootElement; + + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty("draft", out var draft) || draft.ValueKind != JsonValueKind.False || + !root.TryGetProperty("prerelease", out var prerelease) || prerelease.ValueKind != JsonValueKind.False || + !root.TryGetProperty("tag_name", out var tag) || tag.ValueKind != JsonValueKind.String || + !ReleaseVersion.TryParse(tag.GetString(), out var version)) + { + throw new InvalidDataException("GitHub did not return a published release version."); + } + + // Construct the destination from our repository and tag, never from response-supplied URLs. + return new GitHubRelease(version, new Uri(ReleasePagePrefix + Uri.EscapeDataString(tag.GetString()!))); + } +} diff --git a/src/ClipDiff.Windows/Updates/ReleaseVersion.cs b/src/ClipDiff.Windows/Updates/ReleaseVersion.cs new file mode 100644 index 0000000..d73c47b --- /dev/null +++ b/src/ClipDiff.Windows/Updates/ReleaseVersion.cs @@ -0,0 +1,89 @@ +using System.Text.RegularExpressions; + +namespace ClipDiff.Windows.Updates; + +internal sealed class ReleaseVersion : IComparable +{ + private static readonly Regex Pattern = new( + @"\A(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z", + RegexOptions.CultureInvariant | RegexOptions.NonBacktracking); + private readonly string[] numbers; + private readonly string[] prerelease; + + private ReleaseVersion(string text, Match match) + { + Text = text; + numbers = [match.Groups[1].Value, match.Groups[2].Value, match.Groups[3].Value]; + prerelease = match.Groups[4].Success ? match.Groups[4].Value.Split('.') : []; + } + + public string Text { get; } + + public static bool TryParse(string? text, out ReleaseVersion version) + { + version = null!; + + if (string.IsNullOrEmpty(text) || text.Length > 256) + { + return false; + } + + text = text.StartsWith('v') ? text[1..] : text; + var match = Pattern.Match(text); + + if (!match.Success || (match.Groups[4].Success && match.Groups[4].Value.Split('.') + .Any(part => IsNumeric(part) && part.Length > 1 && part[0] == '0'))) + { + return false; + } + + version = new ReleaseVersion(text, match); + return true; + } + + public int CompareTo(ReleaseVersion? other) + { + if (other is null) + { + return 1; + } + + for (var index = 0; index < numbers.Length; index++) + { + var comparison = CompareNumber(numbers[index], other.numbers[index]); + + if (comparison != 0) + { + return comparison; + } + } + + if (prerelease.Length == 0 || other.prerelease.Length == 0) + { + return (prerelease.Length == 0 ? 1 : 0).CompareTo(other.prerelease.Length == 0 ? 1 : 0); + } + + for (var index = 0; index < Math.Min(prerelease.Length, other.prerelease.Length); index++) + { + var left = prerelease[index]; + var right = other.prerelease[index]; + var leftNumeric = IsNumeric(left); + var rightNumeric = IsNumeric(right); + var comparison = leftNumeric && rightNumeric + ? CompareNumber(left, right) + : leftNumeric != rightNumeric ? (leftNumeric ? -1 : 1) : string.CompareOrdinal(left, right); + + if (comparison != 0) + { + return comparison; + } + } + + return prerelease.Length.CompareTo(other.prerelease.Length); + } + + private static bool IsNumeric(string text) => text.All(char.IsAsciiDigit); + + private static int CompareNumber(string left, string right) => + left.Length != right.Length ? left.Length.CompareTo(right.Length) : string.CompareOrdinal(left, right); +} diff --git a/src/ClipDiff.Windows/Updates/UpdateCheckService.cs b/src/ClipDiff.Windows/Updates/UpdateCheckService.cs new file mode 100644 index 0000000..b2c3bf8 --- /dev/null +++ b/src/ClipDiff.Windows/Updates/UpdateCheckService.cs @@ -0,0 +1,119 @@ +using System.IO; +using System.Net.Http; +using System.Text.Json; + +namespace ClipDiff.Windows.Updates; + +internal sealed record UpdateCheckSettings( + bool? AutomaticChecksEnabled = null, + DateTimeOffset? LastAttemptUtc = null, + string? NotifiedVersion = null); + +internal enum UpdateCheckStatus { Skipped, Busy, Current, Available, Failed } + +internal sealed record UpdateCheckResult(UpdateCheckStatus Status, GitHubRelease? Release = null); + +internal sealed class UpdateCheckService( + ReleaseVersion? installedVersion, + Func> fetch, + Func readSettings, + Func saveSettings, + TimeProvider? clock = null) +{ + private readonly TimeProvider timeProvider = clock ?? TimeProvider.System; + private CancellationTokenSource? pendingAutomaticCheck; + private bool checking; + + public bool TrySetAutomaticChecks(bool enabled) + { + if (!saveSettings(readSettings() with { AutomaticChecksEnabled = enabled })) + { + return false; + } + + if (!enabled) + { + pendingAutomaticCheck?.Cancel(); + } + + return true; + } + + public async Task CheckAsync(bool manual, CancellationToken cancellationToken) + { + var settings = readSettings(); + var now = timeProvider.GetUtcNow(); + + if (!manual && (settings.AutomaticChecksEnabled != true || + settings.LastAttemptUtc is { } last && now - last < TimeSpan.FromDays(1))) + { + return new(UpdateCheckStatus.Skipped); + } + + if (checking) + { + return new(UpdateCheckStatus.Busy); + } + + if (installedVersion is null) + { + return new(UpdateCheckStatus.Failed); + } + + // Persist attempts, including offline failures, so relaunching cannot cause repeated requests. + if (!saveSettings(settings with { LastAttemptUtc = now }) && !manual) + { + return new(UpdateCheckStatus.Failed); + } + + checking = true; + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + if (!manual) + { + pendingAutomaticCheck = cancellation; + } + + try + { + var release = await fetch(cancellation.Token); + settings = readSettings(); + + if (cancellation.IsCancellationRequested || (!manual && settings.AutomaticChecksEnabled != true)) + { + return new(UpdateCheckStatus.Skipped); + } + + if (release.Version.CompareTo(installedVersion) <= 0) + { + return new(UpdateCheckStatus.Current); + } + + if (!manual && ReleaseVersion.TryParse(settings.NotifiedVersion, out var notified) && + release.Version.CompareTo(notified) <= 0) + { + return new(UpdateCheckStatus.Skipped); + } + + if (!saveSettings(settings with { NotifiedVersion = release.Version.Text }) && !manual) + { + return new(UpdateCheckStatus.Failed); + } + + return new(UpdateCheckStatus.Available, release); + } + catch (OperationCanceledException) + { + return new(cancellation.IsCancellationRequested ? UpdateCheckStatus.Skipped : UpdateCheckStatus.Failed); + } + catch (Exception exception) when (exception is HttpRequestException or IOException or JsonException) + { + return new(UpdateCheckStatus.Failed); + } + finally + { + pendingAutomaticCheck = null; + checking = false; + } + } +} diff --git a/src/ClipDiff.Windows/Views/UpdateWindow.cs b/src/ClipDiff.Windows/Views/UpdateWindow.cs new file mode 100644 index 0000000..9412f29 --- /dev/null +++ b/src/ClipDiff.Windows/Views/UpdateWindow.cs @@ -0,0 +1,86 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using Button = System.Windows.Controls.Button; +using Color = System.Windows.Media.Color; +using Brushes = System.Windows.Media.Brushes; +using Orientation = System.Windows.Controls.Orientation; +using HorizontalAlignment = System.Windows.HorizontalAlignment; + +namespace ClipDiff.Windows.Views; + +internal sealed class UpdateWindow : Window +{ + private readonly TextBlock messageText; + private readonly Button downloadButton; + private Uri? downloadPage; + + public UpdateWindow(bool consent = false) + { + Title = consent ? "Check for ClipDiff updates?" : "ClipDiff updates"; + Width = 460; + SizeToContent = SizeToContent.Height; + ResizeMode = ResizeMode.NoResize; + WindowStartupLocation = WindowStartupLocation.CenterScreen; + Background = new SolidColorBrush(Color.FromRgb(32, 32, 32)); + Foreground = Brushes.WhiteSmoke; + var panel = new StackPanel { Margin = new Thickness(24) }; + messageText = new TextBlock { TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 20) }; + panel.Children.Add(messageText); + var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right }; + downloadButton = new Button { Content = consent ? "Enable" : "Download", Padding = new Thickness(16, 6, 16, 6), Visibility = consent ? Visibility.Visible : Visibility.Collapsed }; + var dismiss = new Button { Content = consent ? "Not now" : "Dismiss", Padding = new Thickness(16, 6, 16, 6), Margin = new Thickness(8, 0, 0, 0), IsCancel = true, IsDefault = true }; + dismiss.Click += (_, _) => Close(); + downloadButton.Click += (_, _) => + { + if (consent) + { + DialogResult = true; + } + else + { + OpenDownloadPage(); + } + }; + buttons.Children.Add(downloadButton); + buttons.Children.Add(dismiss); + panel.Children.Add(buttons); + Content = panel; + } + + public void SetMessage(string message, Uri? page = null) + { + messageText.Text = message; + downloadPage = page; + downloadButton.Visibility = page is null ? Visibility.Collapsed : Visibility.Visible; + } + + public static bool AskConsent() + { + var window = new UpdateWindow(consent: true); + window.messageText.Text = "Allow ClipDiff to check api.github.com for new releases automatically, at most once a day? " + + "GitHub receives a normal web request, including your IP address. No clipboard content or document data is sent.\n\n" + + "Updates are never downloaded or installed automatically. You can change this in the tray menu, or check manually at any time."; + return window.ShowDialog() == true; + } + + private void OpenDownloadPage() + { + if (downloadPage is null) + { + return; + } + + try + { + using var process = Process.Start(new ProcessStartInfo(downloadPage.AbsoluteUri) { UseShellExecute = true }); + Close(); + } + catch (Exception exception) when (exception is Win32Exception or InvalidOperationException or NotSupportedException) + { + messageText.Text = "ClipDiff could not open your browser. Visit github.com/stuartd/ClipboardDiff/releases/latest to download the update."; + } + } +} diff --git a/tests/ClipDiff.Update.Tests/ClipDiff.Update.Tests.csproj b/tests/ClipDiff.Update.Tests/ClipDiff.Update.Tests.csproj new file mode 100644 index 0000000..eccf478 --- /dev/null +++ b/tests/ClipDiff.Update.Tests/ClipDiff.Update.Tests.csproj @@ -0,0 +1,18 @@ + + + net10.0 + false + true + + + + + + + + + + + + + diff --git a/tests/ClipDiff.Update.Tests/DeploymentPolicyTests.cs b/tests/ClipDiff.Update.Tests/DeploymentPolicyTests.cs new file mode 100644 index 0000000..c702912 --- /dev/null +++ b/tests/ClipDiff.Update.Tests/DeploymentPolicyTests.cs @@ -0,0 +1,77 @@ +using ClipDiff.Windows.Installation; +using ClipDiff.Windows.Settings; +using ClipDiff.Windows.Updates; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ClipDiff.Update.Tests; + +[TestClass] +public sealed class DeploymentPolicyTests +{ + [TestMethod] + public void CleanupKeepsCurrentPeerAndUnrecognisedFiles() + { + var directory = Path.Combine(Path.GetTempPath(), "ClipDiff.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + + try + { + var current = "ClipDiff.ShellExtension." + new string('a', 64) + ".dll"; + var peer = "ClipDiff.ShellExtension." + new string('b', 64) + ".dll"; + var obsolete = "ClipDiff.ShellExtension." + new string('c', 64) + ".dll"; + var unknown = "ClipDiff.ShellExtension.my-file.dll"; + + foreach (var name in new[] { current, peer, obsolete, unknown, "ClipDiff.ShellExtension.dll" }) + { + File.WriteAllText(Path.Combine(directory, name), "placeholder"); + } + + ShellExtensionFiles.TryCleanup(directory, current, [Path.Combine(directory, peer)]); + CollectionAssert.AreEquivalent(new[] { current, peer, unknown }, Directory.GetFiles(directory).Select(Path.GetFileName).ToArray()); + } + finally + { + Directory.Delete(directory, true); + } + } + + [TestMethod] + public void FailedCleanupIsNonfatal() + { + ShellExtensionFiles.TryCleanup(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")), "ClipDiff.ShellExtension.dll", []); + } + + [TestMethod] + public void RejectsExtensionMetadataThatCouldEscapeTheInstallDirectory() + { + Assert.AreEqual("ClipDiff.ShellExtension.dll", ShellExtensionFiles.ResolveFileName("../other.dll")); + var name = "ClipDiff.ShellExtension." + new string('a', 64) + ".dll"; + Assert.AreEqual(name, ShellExtensionFiles.ResolveFileName(name)); + } + + [TestMethod] + public void UpdatePreferencesSurviveRestartWithoutDisturbingExistingPreferences() + { + var directory = Path.Combine(Path.GetTempPath(), "ClipDiff.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + + try + { + var path = Path.Combine(directory, "settings.json"); + File.WriteAllText(path, """{"SelectedExecutablePath":"Diff.exe","StartupPromptShown":true}"""); + var store = new ClipDiffSettingsStore(path); + var original = store.Load(); + Assert.IsNull(original.UpdateChecks?.AutomaticChecksEnabled); + var preferences = new UpdateCheckSettings(false, DateTimeOffset.UtcNow, "1.2.3"); + Assert.IsTrue(store.TrySave(original with { UpdateChecks = preferences })); + var reloaded = new ClipDiffSettingsStore(path).Load(); + Assert.AreEqual(preferences, reloaded.UpdateChecks); + Assert.AreEqual(original.SelectedExecutablePath, reloaded.SelectedExecutablePath); + Assert.IsTrue(reloaded.StartupPromptShown); + } + finally + { + Directory.Delete(directory, true); + } + } +} diff --git a/tests/ClipDiff.Update.Tests/GitHubReleaseClientTests.cs b/tests/ClipDiff.Update.Tests/GitHubReleaseClientTests.cs new file mode 100644 index 0000000..51dcbed --- /dev/null +++ b/tests/ClipDiff.Update.Tests/GitHubReleaseClientTests.cs @@ -0,0 +1,58 @@ +using System.Net; +using System.Text; +using ClipDiff.Windows.Updates; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ClipDiff.Update.Tests; + +[TestClass] +public sealed class GitHubReleaseClientTests +{ + [TestMethod] + public async Task RequestsOnlyTheReleaseEndpointAndBuildsATrustedDownloadDestination() + { + using var client = new HttpClient(new Handler(request => + { + Assert.AreEqual(GitHubReleaseClient.LatestReleaseApi, request.RequestUri!.AbsoluteUri); + Assert.AreEqual(HttpMethod.Get, request.Method); + Assert.IsNull(request.Content); + Assert.AreEqual("ClipDiff", request.Headers.UserAgent.ToString()); + return Response("""{"tag_name":"v1.2.3","draft":false,"prerelease":false,"html_url":"https://evil.example/"}"""); + })); + var release = await new GitHubReleaseClient(client).FetchLatestAsync(default); + Assert.AreEqual("1.2.3", release.Version.Text); + Assert.AreEqual("https://github.com/stuartd/ClipboardDiff/releases/tag/v1.2.3", release.DownloadPage.AbsoluteUri); + } + + [TestMethod] + [DataRow("not json")] + [DataRow("[]")] + [DataRow("{\"tag_name\":\"v1.2.3\",\"draft\":true,\"prerelease\":false}")] + [DataRow("{\"tag_name\":\"v1.2.3\",\"draft\":false,\"prerelease\":true}")] + [DataRow("{\"tag_name\":12,\"draft\":false,\"prerelease\":false}")] + [DataRow("{\"tag_name\":\"garbage\",\"draft\":false,\"prerelease\":false}")] + public async Task RejectsInvalidOrUnpublishedReleases(string payload) + { + using var client = new HttpClient(new Handler(_ => Response(payload))); + var service = new GitHubReleaseClient(client); + await Assert.ThrowsAsync(async () => { await service.FetchLatestAsync(default); }); + } + + [TestMethod] + public async Task RejectsOversizedResponses() + { + using var client = new HttpClient(new Handler(_ => Response(new string(' ', 65537)))); + await Assert.ThrowsAsync(async () => { await new GitHubReleaseClient(client).FetchLatestAsync(default); }); + } + + private static HttpResponseMessage Response(string payload) => new(HttpStatusCode.OK) + { + Content = new StringContent(payload, Encoding.UTF8, "application/json") + }; + + private sealed class Handler(Func respond) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(respond(request)); + } +} diff --git a/tests/ClipDiff.Update.Tests/ReleaseVersionTests.cs b/tests/ClipDiff.Update.Tests/ReleaseVersionTests.cs new file mode 100644 index 0000000..3eabca9 --- /dev/null +++ b/tests/ClipDiff.Update.Tests/ReleaseVersionTests.cs @@ -0,0 +1,40 @@ +using ClipDiff.Windows.Updates; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ClipDiff.Update.Tests; + +[TestClass] +public sealed class ReleaseVersionTests +{ + [TestMethod] + [DataRow("1.2.0", "1.10.0")] + [DataRow("1.0.0-preview.2", "1.0.0-preview.10")] + [DataRow("1.0.0-preview.10", "1.0.0")] + [DataRow("1.0.0-1", "1.0.0-alpha")] + [DataRow("1.0.0-alpha", "1.0.0-alpha.1")] + [DataRow("1.0.0", "2.0.0-preview.1")] + [DataRow("1.0.0", "999999999999999999999999.0.0")] + public void UsesSemanticVersionOrdering(string older, string newer) + { + Assert.IsLessThan(0, UpdateCheckTests.Parse(older).CompareTo(UpdateCheckTests.Parse(newer))); + Assert.IsGreaterThan(0, UpdateCheckTests.Parse(newer).CompareTo(UpdateCheckTests.Parse(older))); + } + + [TestMethod] + public void IgnoresBuildMetadataAndAcceptsReleaseTagPrefix() + { + Assert.AreEqual(0, UpdateCheckTests.Parse("v1.2.3+abc").CompareTo(UpdateCheckTests.Parse("1.2.3+def"))); + } + + [TestMethod] + [DataRow("1.2")] + [DataRow("01.2.3")] + [DataRow("1.2.3-01")] + [DataRow("1.2.3-preview..1")] + [DataRow("1.2.3\n")] + [DataRow("https://example.com")] + public void RejectsMalformedVersions(string text) + { + Assert.IsFalse(ReleaseVersion.TryParse(text, out _)); + } +} diff --git a/tests/ClipDiff.Update.Tests/UpdateCheckTests.cs b/tests/ClipDiff.Update.Tests/UpdateCheckTests.cs new file mode 100644 index 0000000..574e85f --- /dev/null +++ b/tests/ClipDiff.Update.Tests/UpdateCheckTests.cs @@ -0,0 +1,146 @@ +using ClipDiff.Windows.Updates; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ClipDiff.Update.Tests; + +[TestClass] +public sealed class UpdateCheckTests +{ + [TestMethod] + [DataRow(null)] + [DataRow(false)] + public async Task NoRequestWithoutConsentIncludingAfterRestart(bool? enabled) + { + var fixture = new Fixture(new(AutomaticChecksEnabled: enabled)); + Assert.AreEqual(UpdateCheckStatus.Skipped, (await fixture.Service().CheckAsync(false, default)).Status); + Assert.AreEqual(UpdateCheckStatus.Skipped, (await fixture.Service().CheckAsync(false, default)).Status); + Assert.AreEqual(0, fixture.Requests); + } + + [TestMethod] + public async Task FailedChecksAreThrottledAcrossRestarts() + { + var fixture = new Fixture(new(true)) { Fail = true }; + Assert.AreEqual(UpdateCheckStatus.Failed, (await fixture.Service().CheckAsync(false, default)).Status); + fixture.Clock.Now += TimeSpan.FromHours(23); + Assert.AreEqual(UpdateCheckStatus.Skipped, (await fixture.Service().CheckAsync(false, default)).Status); + fixture.Clock.Now += TimeSpan.FromHours(1); + Assert.AreEqual(UpdateCheckStatus.Failed, (await fixture.Service().CheckAsync(false, default)).Status); + Assert.AreEqual(2, fixture.Requests); + } + + [TestMethod] + public async Task NotifyOncePerNewerReleaseAcrossRestarts() + { + var fixture = new Fixture(new(true)); + Assert.AreEqual(UpdateCheckStatus.Available, (await fixture.Service().CheckAsync(false, default)).Status); + fixture.Clock.Now += TimeSpan.FromDays(1); + Assert.AreEqual(UpdateCheckStatus.Skipped, (await fixture.Service().CheckAsync(false, default)).Status); + fixture.Clock.Now += TimeSpan.FromDays(1); + fixture.PublishedVersion = "1.3.0"; + Assert.AreEqual(UpdateCheckStatus.Available, (await fixture.Service().CheckAsync(false, default)).Status); + } + + [TestMethod] + public async Task ManualChecksIgnoreConsentThrottleAndNotificationSuppression() + { + var fixture = new Fixture(new(false)); + Assert.AreEqual(UpdateCheckStatus.Available, (await fixture.Service().CheckAsync(true, default)).Status); + Assert.AreEqual(UpdateCheckStatus.Available, (await fixture.Service().CheckAsync(true, default)).Status); + Assert.AreEqual(2, fixture.Requests); + Assert.IsFalse(fixture.Settings.AutomaticChecksEnabled); + } + + [TestMethod] + [DataRow("1.0.0")] + [DataRow("0.9.0")] + [DataRow("1.0.0+different-commit")] + public async Task CurrentAndOlderReleasesDoNotNotify(string published) + { + var fixture = new Fixture(new(true)) { PublishedVersion = published }; + Assert.AreEqual(UpdateCheckStatus.Current, (await fixture.Service().CheckAsync(false, default)).Status); + Assert.IsNull(fixture.Settings.NotifiedVersion); + } + + [TestMethod] + public async Task TurningOffCancelsAPendingAutomaticRequest() + { + var settings = new UpdateCheckSettings(true); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var service = new UpdateCheckService(Parse("1.0.0"), async cancellation => + { + entered.SetResult(); + await Task.Delay(Timeout.Infinite, cancellation); + throw new InvalidOperationException("The request should have been cancelled."); + }, () => settings, value => { settings = value; return true; }); + var check = service.CheckAsync(false, default); + await entered.Task; + Assert.IsTrue(service.TrySetAutomaticChecks(false)); + Assert.AreEqual(UpdateCheckStatus.Skipped, (await check).Status); + Assert.IsFalse(settings.AutomaticChecksEnabled); + Assert.IsNull(settings.NotifiedVersion); + } + + [TestMethod] + public async Task FailedPersistenceDoesNotEnableOrStartAutomaticChecks() + { + var fixture = new Fixture(new(false)) { SaveSucceeds = false }; + Assert.IsFalse(fixture.Service().TrySetAutomaticChecks(true)); + Assert.IsFalse(fixture.Settings.AutomaticChecksEnabled); + fixture.Settings = new(true); + Assert.AreEqual(UpdateCheckStatus.Failed, (await fixture.Service().CheckAsync(false, default)).Status); + Assert.AreEqual(0, fixture.Requests); + Assert.AreEqual(UpdateCheckStatus.Available, (await fixture.Service().CheckAsync(true, default)).Status); + } + + [TestMethod] + public async Task FutureAttemptDoesNotCauseRepeatedRequestsWhenClockMovesBack() + { + var fixture = new Fixture(new(true, DateTimeOffset.UtcNow.AddDays(2))); + Assert.AreEqual(UpdateCheckStatus.Skipped, (await fixture.Service().CheckAsync(false, default)).Status); + Assert.AreEqual(0, fixture.Requests); + } + + internal static ReleaseVersion Parse(string text) + { + Assert.IsTrue(ReleaseVersion.TryParse(text, out var version)); + return version; + } + + private sealed class TestClock : TimeProvider + { + public DateTimeOffset Now { get; set; } = DateTimeOffset.UtcNow; + public override DateTimeOffset GetUtcNow() => Now; + } + + private sealed class Fixture(UpdateCheckSettings preferences) + { + public UpdateCheckSettings Settings = preferences; + public readonly TestClock Clock = new(); + public int Requests; + public bool Fail; + public bool SaveSucceeds = true; + public string PublishedVersion = "1.2.0"; + + public UpdateCheckService Service() => new(Parse("1.0.0"), _ => + { + Requests++; + + if (Fail) + { + throw new HttpRequestException("Offline"); + } + + return Task.FromResult(new GitHubRelease(Parse(PublishedVersion), new Uri("https://github.com/stuartd/ClipboardDiff/releases/latest"))); + }, () => Settings, value => + { + if (!SaveSucceeds) + { + return false; + } + + Settings = value; + return true; + }, Clock); + } +} From 100026d0631ab379fd799a37404dd4942549c89b Mon Sep 17 00:00:00 2001 From: S D Date: Fri, 6 Nov 2026 23:36:26 +0000 Subject: [PATCH 3/7] Preserve hashed extension filename in staged release payload --- scripts/create-local-release.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/create-local-release.ps1 b/scripts/create-local-release.ps1 index 67bc04c..058113d 100644 --- a/scripts/create-local-release.ps1 +++ b/scripts/create-local-release.ps1 @@ -26,7 +26,7 @@ function Assert-ReleasePayload([string]$Directory, [string]$Description) if ($unexpectedPayload.Count -ne 0) { - throw "$Description validation failed. Expected only: $($requiredPayload -join ', ')." + throw "$Description validation failed. Expected only: $($requiredPayload -join ', '). Actual: $($actualPayload -join ', ')." } } @@ -174,7 +174,7 @@ try throw "$($package.Name) is missing required file: $file" } - Copy-Item $source $package.StagingDirectory + Copy-Item $source (Join-Path $package.StagingDirectory $file) } Assert-ReleasePayload $package.StagingDirectory "$($package.Name) directory" From 8e340fa8f670968388e2b1fa6eed1614d604a686 Mon Sep 17 00:00:00 2001 From: S D Date: Fri, 6 Nov 2026 23:40:25 +0000 Subject: [PATCH 4/7] Handle absent startup value in Windows installer smoke test --- scripts/test-installer.ps1 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/test-installer.ps1 b/scripts/test-installer.ps1 index 5e5b673..64d323d 100644 --- a/scripts/test-installer.ps1 +++ b/scripts/test-installer.ps1 @@ -36,7 +36,8 @@ $installDirectory = Join-Path $testRoot 'Installed app' $settingsPath = Join-Path $env:LOCALAPPDATA 'ClipDiff/settings.json' $savedSettings = if (Test-Path $settingsPath) { [IO.File]::ReadAllBytes($settingsPath) } else { $null } $runKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -$savedRun = Get-ItemPropertyValue $runKey -Name ClipDiff -ErrorAction SilentlyContinue +$runRegistryPath = 'HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run' +$savedRun = [Microsoft.Win32.Registry]::GetValue($runRegistryPath, 'ClipDiff', $null) $loadedImages = [Collections.Generic.List[IntPtr]]::new() $app = $null @@ -187,7 +188,7 @@ try throw 'Uninstall did not stop the app and remove the executable/installation record.' } - if ((Get-ItemPropertyValue $runKey -Name ClipDiff -ErrorAction SilentlyContinue) -or + if ([Microsoft.Win32.Registry]::GetValue($runRegistryPath, 'ClipDiff', $null) -or (Test-Path 'HKCU:\Software\Classes\CLSID\{6B46A974-40E2-4AD4-9F68-E534202B11E8}')) { throw 'Uninstall left the installed app registered.' From e9fff09e320aca7651e720701481147f58e8e3ed Mon Sep 17 00:00:00 2001 From: S D Date: Fri, 6 Nov 2026 23:41:20 +0000 Subject: [PATCH 5/7] Use explicit stream and registry operations in installer test --- scripts/test-installer.ps1 | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/scripts/test-installer.ps1 b/scripts/test-installer.ps1 index 64d323d..e4bb573 100644 --- a/scripts/test-installer.ps1 +++ b/scripts/test-installer.ps1 @@ -35,8 +35,8 @@ $testRoot = Join-Path $repositoryRoot "artifacts/installer-smoke/$([Guid]::NewGu $installDirectory = Join-Path $testRoot 'Installed app' $settingsPath = Join-Path $env:LOCALAPPDATA 'ClipDiff/settings.json' $savedSettings = if (Test-Path $settingsPath) { [IO.File]::ReadAllBytes($settingsPath) } else { $null } -$runKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -$runRegistryPath = 'HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run' +$runSubKey = 'Software\Microsoft\Windows\CurrentVersion\Run' +$runRegistryPath = 'HKEY_CURRENT_USER\' + $runSubKey $savedRun = [Microsoft.Win32.Registry]::GetValue($runRegistryPath, 'ClipDiff', $null) $loadedImages = [Collections.Generic.List[IntPtr]]::new() $app = $null @@ -129,7 +129,7 @@ try [IO.File]::WriteAllText($settingsPath, '{"StartupPromptShown":true,"UpdateChecks":{"AutomaticChecksEnabled":false}}') Invoke-Installer $baselineInstaller 'first-install' $originalExtension = Get-ChildItem $baselinePayload -Filter 'ClipDiff.ShellExtension.*.dll' | Select-Object -ExpandProperty Name -First 1 - Set-ItemProperty $runKey -Name ClipDiff -Value "`"$(Join-Path $installDirectory 'ClipDiff.exe')`"" + [Microsoft.Win32.Registry]::SetValue($runRegistryPath, 'ClipDiff', "`"$(Join-Path $installDirectory 'ClipDiff.exe')`"") $app = Start-TestApp $originalExtension Hold-Extension $originalExtension Invoke-Installer $baselineInstaller 'upgrade-unchanged-extension' @@ -149,7 +149,8 @@ try try { $stream.Position = $stream.Length - $stream.Write([Text.Encoding]::ASCII.GetBytes('ClipDiff installer integration fixture')) + $fixtureBytes = [Text.Encoding]::ASCII.GetBytes('ClipDiff installer integration fixture') + $stream.Write($fixtureBytes, 0, $fixtureBytes.Length) } finally { @@ -221,10 +222,22 @@ finally if ($null -ne $savedRun) { - Set-ItemProperty $runKey -Name ClipDiff -Value $savedRun + [Microsoft.Win32.Registry]::SetValue($runRegistryPath, 'ClipDiff', $savedRun) } else { - Remove-ItemProperty $runKey -Name ClipDiff -ErrorAction SilentlyContinue + $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($runSubKey, $true) + + if ($null -ne $key) + { + try + { + $key.DeleteValue('ClipDiff', $false) + } + finally + { + $key.Dispose() + } + } } } From 48b0e1ebfcfbfb6a95deae022dd461c0d334f44b Mon Sep 17 00:00:00 2001 From: S D Date: Fri, 6 Nov 2026 23:43:10 +0000 Subject: [PATCH 6/7] Stamp numeric installer versions and cover prerelease upgrades --- README.md | 6 +++--- installer/ClipDiff.iss | 4 ++++ scripts/create-installer.ps1 | 3 ++- scripts/test-installer.ps1 | 8 +++++--- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7455917..ea715ef 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ After ClipDiff has captured at least one value, and while clipboard monitoring i Both context-menu registrations are per-user, require no administrator access, and use classic Explorer verbs, so Windows 11 may place them under **Show more options**. The two-file command appears only when exactly two files are selected and ClipDiff is running and monitoring; the individual-file command additionally requires a usable current capture. Losing the last capture removes the individual-file command; quitting removes both when no other live session needs them. Concurrent sessions for the same user coordinate the shared registrations. Their individual-file label is generic because the registry is shared, while invocation validates the capture in the local session. A later ClipDiff start cleans up registrations left by an abnormal exit. Coordination retries brief lock contention; if a peer stays stalled beyond the retry deadline, the surviving instance reconciles registration on its next capture update or tray-menu opening. -The two-file command uses a small native `ClipDiff.ShellExtension.dll` beside the executable. Its [Shell initialization interface](https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-ishellextinit-initialize) receives the complete selection, so it can show the command only for two files. It checks Shell metadata without reading file contents, then forwards the selection data object to ClipDiff's existing out-of-process COM drop target when clicked. Selected paths never appear in a process command line or the registry. The DLL holds the selection only for the menu's lifetime; file conversion and comparison run in ClipDiff. A memory-only readiness event keeps cached menus inactive after ClipDiff has exited. +The two-file command uses a small native `ClipDiff.ShellExtension..dll` beside the executable (`ClipDiff.ShellExtension.dll` during development). Its [Shell initialization interface](https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-ishellextinit-initialize) receives the complete selection, so it can show the command only for two files. It checks Shell metadata without reading file contents, then forwards the selection data object to ClipDiff's existing out-of-process COM drop target when clicked. Selected paths never appear in a process command line or the registry. The DLL holds the selection only for the menu's lifetime; file conversion and comparison run in ClipDiff. A memory-only readiness event keeps cached menus inactive after ClipDiff has exited. A newer Explorer request or accepted clipboard value cancels pending selected-file work. The single-file command checks that its original capture is still current when the read finishes. Slow reads and incomplete pipe messages do not block receipt of newer commands; failed forwarding gives concise feedback. @@ -91,7 +91,7 @@ The built-in viewer keeps captured text in memory only. An external program cann This cleanup is best effort. A crash, power loss, open file handle, or external viewer that hands work to another process can leave files behind, and the selected program may cache or retain its own copy outside ClipDiff's control. Do not select an external viewer when this disk exposure is unacceptable. Cancelling the warning opens the built-in viewer without writing the files. If automatic privacy clearing or a newer comparison invalidates the saved pair while the warning is open, neither OK nor Cancel reopens or exports it. -ClipDiff stores only the selected executable path, the one-time-warning acknowledgement, the chosen shortcut's modifier/key codes, and whether the login prompt has been shown in `%LOCALAPPDATA%\ClipDiff\settings.json`; clipboard text and previews are never stored there. Rebuilding or replacing the executable does not reset the acknowledgement. To retest the notice, close ClipDiff and set `PlaintextWarningAcknowledged` to `false` in that file; the selected program can remain unchanged. With the built-in viewer, normal exit loses all captured content. With an external viewer, normal exit also attempts to remove this instance's temporary comparison directories. Empty exclusive owner leases protect live workspaces in another session from startup cleanup; leases contain no captured text. Shared Explorer registration claims contain only executable/DLL commands and menu metadata, and stale claims are removed at startup. +ClipDiff stores only the selected executable path, the one-time-warning acknowledgement, the chosen shortcut's modifier/key codes, whether the login prompt has been shown, and update-check consent/last attempt time/last notified version in `%LOCALAPPDATA%\ClipDiff\settings.json`; clipboard text and previews are never stored there. Rebuilding or replacing the executable does not reset the acknowledgement. To retest the notice, close ClipDiff and set `PlaintextWarningAcknowledged` to `false` in that file; the selected program can remain unchanged. With the built-in viewer, normal exit loses all captured content. With an external viewer, normal exit also attempts to remove this instance's temporary comparison directories. Empty exclusive owner leases protect live workspaces in another session from startup cleanup; leases contain no captured text. Shared Explorer registration claims contain only executable/DLL commands and menu metadata, and stale claims are removed at startup. Before reading text, ClipDiff inspects and honours these advisory [clipboard formats](https://learn.microsoft.com/en-us/windows/win32/dataxchg/clipboard-formats): @@ -339,7 +339,7 @@ On a Windows desktop, verify: - each supported installed viewer receives the previous/current sides in the right order and with the documented labels and read-only switches where supported; - external comparison files appear only below `%LOCALAPPDATA%\ClipDiff\Temp`, contain the exact text, and are removed after the launched process exits, on ClipDiff exit, or on the next start; - removing or renaming the selected viewer executable causes **Show Diff** to fall back to the built-in viewer; -- `%LOCALAPPDATA%\ClipDiff\settings.json` contains only the executable preference, warning acknowledgement, shortcut codes, and the login-prompt flag, never clipboard content; +- `%LOCALAPPDATA%\ClipDiff\settings.json` contains only the executable preference, warning acknowledgement, shortcut codes, login-prompt flag, and update-check consent/time/version metadata, never clipboard content; - **Keyboard shortcut...** records a replacement such as `Ctrl+Alt+6`, keeps the old shortcut active when the replacement is already in use, updates the tray label after success, persists across restart, and resets to `Ctrl+Alt+D`; - **Copy unified diff** pastes into Notepad, has all three exclusion formats, and is not recaptured; - two identical copies produce a diff reporting **No differences**, while images leave history unchanged; diff --git a/installer/ClipDiff.iss b/installer/ClipDiff.iss index e42001f..332d00a 100644 --- a/installer/ClipDiff.iss +++ b/installer/ClipDiff.iss @@ -2,6 +2,9 @@ #ifndef AppVersion #error AppVersion is required #endif +#ifndef AppFileVersion + #error AppFileVersion is required +#endif #ifndef PayloadDirectory #error PayloadDirectory is required #endif @@ -16,6 +19,7 @@ AppId={{75F83916-586C-4D73-B744-AD39917A5039} AppName=ClipDiff AppVersion={#AppVersion} +VersionInfoVersion={#AppFileVersion} AppPublisher=Stuart Dunkeld AppPublisherURL=https://stuartd.dev AppSupportURL=https://github.com/stuartd/ClipboardDiff diff --git a/scripts/create-installer.ps1 b/scripts/create-installer.ps1 index a4311f5..fc9d6f4 100644 --- a/scripts/create-installer.ps1 +++ b/scripts/create-installer.ps1 @@ -9,6 +9,7 @@ param( $ErrorActionPreference = 'Stop' . (Join-Path $PSScriptRoot 'release-version.ps1') $Version = Get-ReleaseVersion -Version $Version +$fileVersion = $Version.Split('-')[0] + '.0' $compiler = Get-Command ISCC.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -First 1 if (-not $compiler) @@ -51,7 +52,7 @@ if (Test-Path $installer) Remove-Item $installer -Force } -& $compiler "/DAppVersion=$Version" "/DPayloadDirectory=$PayloadDirectory" ` +& $compiler "/DAppVersion=$Version" "/DAppFileVersion=$fileVersion" "/DPayloadDirectory=$PayloadDirectory" ` "/DExtensionFileName=$ExtensionFileName" "/DInstallerOutputDirectory=$OutputDirectory" ` (Join-Path $PSScriptRoot '../installer/ClipDiff.iss') diff --git a/scripts/test-installer.ps1 b/scripts/test-installer.ps1 index e4bb573..4d2fc74 100644 --- a/scripts/test-installer.ps1 +++ b/scripts/test-installer.ps1 @@ -158,9 +158,11 @@ try } $changedName = 'ClipDiff.ShellExtension.' + (Get-FileHash $changedDll -Algorithm SHA256).Hash.ToLowerInvariant() + '.dll' + $numericVersion = [Version]($Version.Split('-')[0]) + $fixtureVersion = "$($numericVersion.Major).$($numericVersion.Minor).$($numericVersion.Build + 1)-installer-test.1" $publishDirectory = Join-Path $testRoot 'publish' & dotnet publish (Join-Path $repositoryRoot 'src/ClipDiff.Windows/ClipDiff.Windows.csproj') --configuration Release ` - --runtime win-x64 --self-contained true --output $publishDirectory "-p:Version=$Version" ` + --runtime win-x64 --self-contained true --output $publishDirectory "-p:Version=$fixtureVersion" ` "-p:ShellExtensionFileName=$changedName" -p:PublishSingleFile=true -p:PublishTrimmed=false -p:IncludeNativeLibrariesForSelfExtract=true if ($LASTEXITCODE -ne 0) @@ -170,10 +172,10 @@ try Copy-Item (Join-Path $publishDirectory 'ClipDiff.exe') $changedPayload Copy-Item $changedDll (Join-Path $changedPayload $changedName) - & (Join-Path $PSScriptRoot 'create-installer.ps1') -Version $Version -PayloadDirectory $changedPayload ` + & (Join-Path $PSScriptRoot 'create-installer.ps1') -Version $fixtureVersion -PayloadDirectory $changedPayload ` -ExtensionFileName $changedName -OutputDirectory $testRoot $app = Start-TestApp $originalExtension - Invoke-Installer (Join-Path $testRoot "ClipDiff-$Version-win-x64-Setup.exe") 'upgrade-changed-extension' + Invoke-Installer (Join-Path $testRoot "ClipDiff-$fixtureVersion-win-x64-Setup.exe") 'upgrade-changed-extension' if (-not $app.WaitForExit(10000) -or -not (Test-Path (Join-Path $installDirectory $originalExtension))) { From 742a906605a7cb4ef2f65711edd9757b66ae5671 Mon Sep 17 00:00:00 2001 From: S D Date: Fri, 6 Nov 2026 23:48:45 +0000 Subject: [PATCH 7/7] Wait for second-phase uninstaller registration cleanup --- scripts/test-installer.ps1 | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/scripts/test-installer.ps1 b/scripts/test-installer.ps1 index 4d2fc74..54b6369 100644 --- a/scripts/test-installer.ps1 +++ b/scripts/test-installer.ps1 @@ -186,15 +186,29 @@ try Hold-Extension $changedName Invoke-Installer (Join-Path $installDirectory 'unins000.exe') 'uninstall' -Uninstall + # The uninstaller launcher can exit before its temporary second phase finishes. + # Wait for that phase's post-uninstall registration cleanup, not just the launcher. + for ($attempt = 0; $attempt -lt 100; $attempt++) + { + $runRemaining = $null -ne [Microsoft.Win32.Registry]::GetValue($runRegistryPath, 'ClipDiff', $null) + $extensionRemaining = Test-Path 'HKCU:\Software\Classes\CLSID\{6B46A974-40E2-4AD4-9F68-E534202B11E8}' + + if (-not $runRemaining -and -not $extensionRemaining) + { + break + } + + Start-Sleep -Milliseconds 100 + } + if (-not $app.WaitForExit(10000) -or (Test-Path (Join-Path $installDirectory 'ClipDiff.exe')) -or (Test-Path $uninstallKey)) { throw 'Uninstall did not stop the app and remove the executable/installation record.' } - if ([Microsoft.Win32.Registry]::GetValue($runRegistryPath, 'ClipDiff', $null) -or - (Test-Path 'HKCU:\Software\Classes\CLSID\{6B46A974-40E2-4AD4-9F68-E534202B11E8}')) + if ($runRemaining -or $extensionRemaining) { - throw 'Uninstall left the installed app registered.' + throw "Uninstall left the installed app registered (startup=$runRemaining, extension=$extensionRemaining)." } Write-Host 'Installer first install, running-app upgrades, unchanged/changed loaded DLLs, and uninstall passed without a restart.'