refactor(installer): drop self-update, publish stable-named ClaudeDo.Installer.exe
Release workflow now names the installer asset ClaudeDo.Installer.exe (no version) for a permanent download URL; it is still uploaded and checksummed on every release. App + worker keep the git tag version. Removes the self-update preflight from App.OnStartup and deletes the now-dead SelfUpdater / SelfUpdatePromptWindow / SelfUpdateResult plus their tests. App-update detection is unaffected: the manifest records the release tag via DownloadAndExtractStep. Updates the installer CLAUDE.md.
This commit is contained in:
@@ -145,18 +145,19 @@ jobs:
|
|||||||
ZIP_NAME="ClaudeDo-${VERSION}-win-x64.zip"
|
ZIP_NAME="ClaudeDo-${VERSION}-win-x64.zip"
|
||||||
( cd bundle && zip -r -q "../assets/${ZIP_NAME}" app worker )
|
( cd bundle && zip -r -q "../assets/${ZIP_NAME}" app worker )
|
||||||
|
|
||||||
# 2) Installer single-file exe (renamed)
|
# 2) Installer single-file exe — STABLE name (no version) so the download URL
|
||||||
|
# (…/releases/latest/download/ClaudeDo.Installer.exe) stays permanent.
|
||||||
INSTALLER_EXE=$(ls out/installer/*.exe | head -n 1)
|
INSTALLER_EXE=$(ls out/installer/*.exe | head -n 1)
|
||||||
if [ -z "$INSTALLER_EXE" ]; then
|
if [ -z "$INSTALLER_EXE" ]; then
|
||||||
echo "::error::No .exe produced by installer publish" >&2
|
echo "::error::No .exe produced by installer publish" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
cp "$INSTALLER_EXE" "assets/ClaudeDo.Installer-${VERSION}.exe"
|
cp "$INSTALLER_EXE" "assets/ClaudeDo.Installer.exe"
|
||||||
|
|
||||||
# 3) Checksums (sha256, relative filenames)
|
# 3) Checksums (sha256, relative filenames)
|
||||||
( cd assets && sha256sum \
|
( cd assets && sha256sum \
|
||||||
"ClaudeDo-${VERSION}-win-x64.zip" \
|
"ClaudeDo-${VERSION}-win-x64.zip" \
|
||||||
"ClaudeDo.Installer-${VERSION}.exe" \
|
"ClaudeDo.Installer.exe" \
|
||||||
> checksums.txt )
|
> checksums.txt )
|
||||||
|
|
||||||
echo "--- assets ---"
|
echo "--- assets ---"
|
||||||
@@ -200,7 +201,7 @@ jobs:
|
|||||||
cd "$WORK/src/assets"
|
cd "$WORK/src/assets"
|
||||||
for f in \
|
for f in \
|
||||||
"ClaudeDo-${VERSION}-win-x64.zip" \
|
"ClaudeDo-${VERSION}-win-x64.zip" \
|
||||||
"ClaudeDo.Installer-${VERSION}.exe" \
|
"ClaudeDo.Installer.exe" \
|
||||||
"checksums.txt"
|
"checksums.txt"
|
||||||
do
|
do
|
||||||
echo "Uploading: $f"
|
echo "Uploading: $f"
|
||||||
|
|||||||
@@ -38,104 +38,6 @@ public partial class App : Application
|
|||||||
var localizer = new Localizer(localeStore, initialLang);
|
var localizer = new Localizer(localeStore, initialLang);
|
||||||
TrExtension.Localizer = localizer;
|
TrExtension.Localizer = localizer;
|
||||||
|
|
||||||
// --- Self-update pre-flight ---
|
|
||||||
// Resolve current exe path. Assembly.Location may point to a .dll for apphost-based
|
|
||||||
// .NET apps; swap to the .exe companion when that happens.
|
|
||||||
var currentExePath = Assembly.GetEntryAssembly()!.Location;
|
|
||||||
if (currentExePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
currentExePath = System.IO.Path.ChangeExtension(currentExePath, ".exe");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Arg form: --replace-self "<old-path>"
|
|
||||||
var replaceSelfIndex = Array.FindIndex(e.Args, a => a.Equals("--replace-self", StringComparison.OrdinalIgnoreCase));
|
|
||||||
if (replaceSelfIndex >= 0 && replaceSelfIndex + 1 < e.Args.Length)
|
|
||||||
{
|
|
||||||
var oldPath = e.Args[replaceSelfIndex + 1];
|
|
||||||
var relaunched = await SelfUpdater.HandleReplaceSelfAsync(
|
|
||||||
oldPath: oldPath,
|
|
||||||
currentExePath: currentExePath,
|
|
||||||
launchProcess: path =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch { return false; }
|
|
||||||
});
|
|
||||||
if (relaunched)
|
|
||||||
{
|
|
||||||
Shutdown(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Replacement failed — fall through to normal wizard from the temp location.
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Normal launch: check for a newer installer.
|
|
||||||
using var selfUpdateHttp = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
|
||||||
var selfUpdateReleases = new ReleaseClient(selfUpdateHttp);
|
|
||||||
var currentVersion = GetInstallerVersion();
|
|
||||||
|
|
||||||
var decision = await SelfUpdater.DecideUpdateAsync(selfUpdateReleases, currentVersion, CancellationToken.None);
|
|
||||||
if (decision.Kind == SelfUpdateDecisionKind.UpdateAvailable)
|
|
||||||
{
|
|
||||||
var prompt = new SelfUpdatePromptWindow(currentVersion, decision.LatestVersion!);
|
|
||||||
DarkTitleBar.Apply(prompt);
|
|
||||||
var ok = prompt.ShowDialog() == true;
|
|
||||||
if (!ok)
|
|
||||||
{
|
|
||||||
Shutdown(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (prompt.Choice == SelfUpdateChoice.Update)
|
|
||||||
{
|
|
||||||
prompt.ShowProgress("Downloading...");
|
|
||||||
var tempDir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "ClaudeDo.Installer.Update");
|
|
||||||
var verifiedPath = await SelfUpdater.DownloadAndVerifyAsync(
|
|
||||||
selfUpdateReleases,
|
|
||||||
decision.InstallerAsset!,
|
|
||||||
decision.ChecksumsAsset!,
|
|
||||||
tempDir,
|
|
||||||
new Progress<long>(_ => { }),
|
|
||||||
CancellationToken.None);
|
|
||||||
|
|
||||||
if (verifiedPath is null)
|
|
||||||
{
|
|
||||||
MessageBox.Show(prompt,
|
|
||||||
"Update download or verification failed. Continuing with current installer.",
|
|
||||||
"ClaudeDo Installer", MessageBoxButton.OK, MessageBoxImage.Warning);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var psi = new System.Diagnostics.ProcessStartInfo(verifiedPath)
|
|
||||||
{
|
|
||||||
UseShellExecute = true,
|
|
||||||
};
|
|
||||||
psi.ArgumentList.Add("--replace-self");
|
|
||||||
psi.ArgumentList.Add(currentExePath);
|
|
||||||
System.Diagnostics.Process.Start(psi);
|
|
||||||
Shutdown(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
MessageBox.Show(prompt,
|
|
||||||
"Failed to launch updated installer: " + ex.Message + "\nContinuing with current installer.",
|
|
||||||
"ClaudeDo Installer", MessageBoxButton.OK, MessageBoxImage.Warning);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// SelfUpdateChoice.Continue — fall through to normal wizard.
|
|
||||||
}
|
|
||||||
// No-update or check failed — fall through to normal wizard.
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Existing wizard start-up unchanged below this line ---
|
|
||||||
|
|
||||||
_services = BuildServices(localizer);
|
_services = BuildServices(localizer);
|
||||||
|
|
||||||
var context = _services.GetRequiredService<InstallContext>();
|
var context = _services.GetRequiredService<InstallContext>();
|
||||||
|
|||||||
@@ -12,14 +12,22 @@ Note: this is the one project where `System.Windows` is correct (WPF, not Avalon
|
|||||||
- Entry point: `App.xaml` / `App.xaml.cs` (no `Program.cs`)
|
- Entry point: `App.xaml` / `App.xaml.cs` (no `Program.cs`)
|
||||||
- References: `ClaudeDo.Data`, `ClaudeDo.Releases`, `ClaudeDo.Localization`
|
- References: `ClaudeDo.Data`, `ClaudeDo.Releases`, `ClaudeDo.Localization`
|
||||||
- Manifests: `app.manifest` (requireAdministrator, Release) / `app.debug.manifest` (asInvoker, Debug)
|
- Manifests: `app.manifest` (requireAdministrator, Release) / `app.debug.manifest` (asInvoker, Debug)
|
||||||
- Only CLI arg: `--replace-self <old-path>` (self-update handoff)
|
- No CLI args — mode is detected from `install.json` + the Gitea API
|
||||||
|
|
||||||
## Startup Sequence (`App.OnStartup`)
|
## Startup Sequence (`App.OnStartup`)
|
||||||
|
|
||||||
1. Load locale
|
1. Load locale
|
||||||
2. Self-update preflight — `SelfUpdater.DecideUpdateAsync` checks Gitea API; if a newer installer exists, download + checksum verify + relaunch with `--replace-self <old-path>`
|
2. Detect mode — `InstallModeDetector` reads `install.json` + Gitea API
|
||||||
3. Detect mode — `InstallModeDetector` reads `install.json` + Gitea API
|
3. Open `WizardWindow` (FreshInstall / Update) or `SettingsWindow` (Config)
|
||||||
4. Open `WizardWindow` (FreshInstall / Update) or `SettingsWindow` (Config)
|
|
||||||
|
The installer does **not** self-update. Each release ships a stable-named
|
||||||
|
`ClaudeDo.Installer.exe` asset (permanent URL
|
||||||
|
`…/releases/latest/download/ClaudeDo.Installer.exe`); the installer never checks for or
|
||||||
|
replaces itself on launch. The in-app "Update" button relaunches the on-disk installer to
|
||||||
|
run the app update — the installer binary itself only changes when the user downloads a
|
||||||
|
fresh copy. App-update detection is unaffected: `WriteInstallManifestStep` records
|
||||||
|
`ctx.InstalledVersion` (the release tag from `DownloadAndExtractStep`), which
|
||||||
|
`InstallModeDetector` compares against the latest tag.
|
||||||
|
|
||||||
## Modes (`Core/InstallerMode.cs`)
|
## Modes (`Core/InstallerMode.cs`)
|
||||||
|
|
||||||
@@ -56,8 +64,7 @@ Installer/
|
|||||||
Interfaces/ — IInstallStep + StepResult/StepStatus/StepProgress, IInstallerPage
|
Interfaces/ — IInstallStep + StepResult/StepStatus/StepProgress, IInstallerPage
|
||||||
Pages/ — WelcomePage, PathsPage, ServicePage, UiSettingsPage, InstallPage
|
Pages/ — WelcomePage, PathsPage, ServicePage, UiSettingsPage, InstallPage
|
||||||
(each: ViewModel + View.xaml)
|
(each: ViewModel + View.xaml)
|
||||||
Views/ — WizardWindow(+WizardViewModel), SettingsWindow(+SettingsViewModel),
|
Views/ — WizardWindow(+WizardViewModel), SettingsWindow(+SettingsViewModel)
|
||||||
SelfUpdatePromptWindow
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key Step Behaviors
|
## Key Step Behaviors
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ public sealed class WriteUninstallRegistryStep : IInstallStep
|
|||||||
// the single-file temp extract is gone once this process exits.
|
// the single-file temp extract is gone once this process exits.
|
||||||
var sourceExe = Environment.ProcessPath
|
var sourceExe = Environment.ProcessPath
|
||||||
?? throw new InvalidOperationException("Cannot resolve running installer path.");
|
?? throw new InvalidOperationException("Cannot resolve running installer path.");
|
||||||
// In the self-update path the installer already runs from uninstaller/ (the
|
// When relaunched from the installed copy (e.g. the Apps & Features "Rerun
|
||||||
// --replace-self handoff put it there), so source == target and the copy would
|
// Installer" entry points at uninstaller/ClaudeDo.Installer.exe), source == target
|
||||||
// throw. Skip it; the binary is already in place.
|
// and the copy would throw. Skip it; the binary is already in place.
|
||||||
var alreadyInPlace = string.Equals(
|
var alreadyInPlace = string.Equals(
|
||||||
Path.GetFullPath(sourceExe), Path.GetFullPath(targetExe), StringComparison.OrdinalIgnoreCase);
|
Path.GetFullPath(sourceExe), Path.GetFullPath(targetExe), StringComparison.OrdinalIgnoreCase);
|
||||||
if (!alreadyInPlace)
|
if (!alreadyInPlace)
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
<Window x:Class="ClaudeDo.Installer.Views.SelfUpdatePromptWindow"
|
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:loc="clr-namespace:ClaudeDo.Installer.Localization"
|
|
||||||
Title="ClaudeDo Installer Update"
|
|
||||||
Width="460" Height="200"
|
|
||||||
WindowStartupLocation="CenterScreen"
|
|
||||||
ResizeMode="NoResize"
|
|
||||||
Background="#1a1a1a" Foreground="#f0f0f0">
|
|
||||||
<Grid Margin="20">
|
|
||||||
<Grid.RowDefinitions>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
<RowDefinition Height="*"/>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
</Grid.RowDefinitions>
|
|
||||||
<TextBlock Grid.Row="0" FontSize="16" FontWeight="SemiBold" Text="{loc:Tr installer.selfUpdate.heading}"/>
|
|
||||||
<TextBlock Grid.Row="1" Margin="0,8,0,0" TextWrapping="Wrap" x:Name="DetailText"/>
|
|
||||||
<TextBlock Grid.Row="2" Margin="0,12,0,0" TextWrapping="Wrap" Foreground="#a0a0a0" x:Name="ProgressText" Visibility="Collapsed"/>
|
|
||||||
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right">
|
|
||||||
<Button x:Name="UpdateBtn" Content="{loc:Tr installer.selfUpdate.update}" MinWidth="90" Margin="4,0" Padding="10,4" Click="UpdateBtn_Click" IsDefault="True"/>
|
|
||||||
<Button x:Name="ContinueBtn" Content="{loc:Tr installer.selfUpdate.continueAnyway}" MinWidth="140" Margin="4,0" Padding="10,4" Click="ContinueBtn_Click"/>
|
|
||||||
<Button x:Name="CancelBtn" Content="{loc:Tr installer.nav.cancel}" MinWidth="90" Margin="4,0" Padding="10,4" Click="CancelBtn_Click" IsCancel="True"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
</Window>
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
using System.Windows;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Views;
|
|
||||||
|
|
||||||
public enum SelfUpdateChoice { Update, Continue, Cancel }
|
|
||||||
|
|
||||||
public partial class SelfUpdatePromptWindow : Window
|
|
||||||
{
|
|
||||||
public SelfUpdateChoice Choice { get; private set; } = SelfUpdateChoice.Cancel;
|
|
||||||
|
|
||||||
public SelfUpdatePromptWindow(string currentVersion, string latestVersion)
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
DetailText.Text = $"Installer v{latestVersion} is available (you are running v{currentVersion}). Update before continuing?";
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ShowProgress(string text)
|
|
||||||
{
|
|
||||||
ProgressText.Text = text;
|
|
||||||
ProgressText.Visibility = Visibility.Visible;
|
|
||||||
UpdateBtn.IsEnabled = false;
|
|
||||||
ContinueBtn.IsEnabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateBtn_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
Choice = SelfUpdateChoice.Update;
|
|
||||||
DialogResult = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ContinueBtn_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
Choice = SelfUpdateChoice.Continue;
|
|
||||||
DialogResult = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CancelBtn_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
Choice = SelfUpdateChoice.Cancel;
|
|
||||||
DialogResult = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
namespace ClaudeDo.Releases;
|
|
||||||
|
|
||||||
public sealed record InstallerAssetMatch(ReleaseAsset Asset, string Version);
|
|
||||||
|
|
||||||
public enum SelfUpdateDecisionKind
|
|
||||||
{
|
|
||||||
NoUpdate,
|
|
||||||
UpdateAvailable,
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record SelfUpdateDecision(
|
|
||||||
SelfUpdateDecisionKind Kind,
|
|
||||||
string? LatestVersion = null,
|
|
||||||
ReleaseAsset? InstallerAsset = null,
|
|
||||||
ReleaseAsset? ChecksumsAsset = null);
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
using System.IO;
|
|
||||||
using System.Net.Http;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Releases;
|
|
||||||
|
|
||||||
public static partial class SelfUpdater
|
|
||||||
{
|
|
||||||
[GeneratedRegex(@"^ClaudeDo\.Installer-(?<version>[\d\.]+)\.exe$", RegexOptions.IgnoreCase)]
|
|
||||||
private static partial Regex InstallerAssetRegex();
|
|
||||||
|
|
||||||
public static InstallerAssetMatch? FindInstallerAsset(IEnumerable<ReleaseAsset> assets)
|
|
||||||
{
|
|
||||||
foreach (var asset in assets)
|
|
||||||
{
|
|
||||||
var m = InstallerAssetRegex().Match(asset.Name);
|
|
||||||
if (m.Success)
|
|
||||||
{
|
|
||||||
return new InstallerAssetMatch(asset, m.Groups["version"].Value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task<SelfUpdateDecision> DecideUpdateAsync(
|
|
||||||
IReleaseClient releases,
|
|
||||||
string currentVersion,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
|
||||||
GiteaRelease? release;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
release = await releases.GetLatestReleaseAsync(ct);
|
|
||||||
}
|
|
||||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
|
||||||
{
|
|
||||||
return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (release is null)
|
|
||||||
return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
|
|
||||||
|
|
||||||
var match = FindInstallerAsset(release.Assets);
|
|
||||||
if (match is null)
|
|
||||||
return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
|
|
||||||
|
|
||||||
var cmp = VersionComparer.Compare(match.Version, currentVersion);
|
|
||||||
if (!cmp.IsNewer)
|
|
||||||
return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
|
|
||||||
|
|
||||||
var checksums = release.Assets.FirstOrDefault(
|
|
||||||
a => string.Equals(a.Name, "checksums.txt", StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
return new SelfUpdateDecision(
|
|
||||||
SelfUpdateDecisionKind.UpdateAvailable,
|
|
||||||
LatestVersion: match.Version,
|
|
||||||
InstallerAsset: match.Asset,
|
|
||||||
ChecksumsAsset: checksums);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task<bool> HandleReplaceSelfAsync(
|
|
||||||
string oldPath,
|
|
||||||
string currentExePath,
|
|
||||||
Func<string, bool> launchProcess,
|
|
||||||
int maxWaitMs = 5000)
|
|
||||||
{
|
|
||||||
var deadline = DateTime.UtcNow.AddMilliseconds(maxWaitMs);
|
|
||||||
while (DateTime.UtcNow < deadline)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (File.Exists(oldPath))
|
|
||||||
{
|
|
||||||
File.Delete(oldPath);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
catch (IOException)
|
|
||||||
{
|
|
||||||
await Task.Delay(100);
|
|
||||||
}
|
|
||||||
catch (UnauthorizedAccessException)
|
|
||||||
{
|
|
||||||
await Task.Delay(100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (File.Exists(oldPath))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
File.Copy(currentExePath, oldPath, overwrite: false);
|
|
||||||
return launchProcess(oldPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task<string?> DownloadAndVerifyAsync(
|
|
||||||
IReleaseClient releases,
|
|
||||||
ReleaseAsset installerAsset,
|
|
||||||
ReleaseAsset checksumsAsset,
|
|
||||||
string tempDir,
|
|
||||||
IProgress<long> progress,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(tempDir);
|
|
||||||
var installerPath = Path.Combine(tempDir, installerAsset.Name);
|
|
||||||
var checksumsPath = Path.Combine(tempDir, "checksums.txt");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await releases.DownloadAsync(installerAsset.BrowserDownloadUrl, installerPath, progress, ct);
|
|
||||||
await releases.DownloadAsync(checksumsAsset.BrowserDownloadUrl, checksumsPath, new Progress<long>(_ => { }), ct);
|
|
||||||
}
|
|
||||||
catch (Exception ex) when (ex is HttpRequestException or IOException or TaskCanceledException)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var checksumsText = await File.ReadAllTextAsync(checksumsPath, ct);
|
|
||||||
var map = ChecksumVerifier.ParseChecksumsFile(checksumsText);
|
|
||||||
if (!map.TryGetValue(installerAsset.Name, out var expected))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return ChecksumVerifier.Verify(installerPath, expected) ? installerPath : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
using System.Net.Http;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Releases.Tests;
|
|
||||||
|
|
||||||
public class SelfUpdaterAssetMatchingTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void FindInstallerAsset_PicksInstallerExeByPattern()
|
|
||||||
{
|
|
||||||
var assets = new[]
|
|
||||||
{
|
|
||||||
new ReleaseAsset("ClaudeDo-0.3.0-win-x64.zip", "https://x/app.zip", 10),
|
|
||||||
new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x/inst.exe", 20),
|
|
||||||
new ReleaseAsset("checksums.txt", "https://x/checks", 1),
|
|
||||||
};
|
|
||||||
|
|
||||||
var result = SelfUpdater.FindInstallerAsset(assets);
|
|
||||||
|
|
||||||
Assert.NotNull(result);
|
|
||||||
Assert.Equal("ClaudeDo.Installer-0.3.0.exe", result!.Asset.Name);
|
|
||||||
Assert.Equal("0.3.0", result.Version);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FindInstallerAsset_ReturnsNullWhenAbsent()
|
|
||||||
{
|
|
||||||
var assets = new[]
|
|
||||||
{
|
|
||||||
new ReleaseAsset("ClaudeDo-0.3.0-win-x64.zip", "https://x/app.zip", 10),
|
|
||||||
};
|
|
||||||
|
|
||||||
Assert.Null(SelfUpdater.FindInstallerAsset(assets));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FindInstallerAsset_IgnoresAppZipThatContainsInstaller()
|
|
||||||
{
|
|
||||||
var assets = new[]
|
|
||||||
{
|
|
||||||
new ReleaseAsset("ClaudeDo.Installer.Portable-0.3.0.zip", "https://x/1", 1),
|
|
||||||
new ReleaseAsset("not-the-installer.exe", "https://x/2", 1),
|
|
||||||
};
|
|
||||||
|
|
||||||
Assert.Null(SelfUpdater.FindInstallerAsset(assets));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class SelfUpdaterDecisionTests
|
|
||||||
{
|
|
||||||
private sealed class FakeReleaseClient : IReleaseClient
|
|
||||||
{
|
|
||||||
public GiteaRelease? Release { get; set; }
|
|
||||||
public bool Throw { get; set; }
|
|
||||||
|
|
||||||
public Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct)
|
|
||||||
{
|
|
||||||
if (Throw) throw new HttpRequestException("boom");
|
|
||||||
return Task.FromResult(Release);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct)
|
|
||||||
=> throw new NotSupportedException("not used in decision tests");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Decide_NoRelease_NoUpdate()
|
|
||||||
{
|
|
||||||
var client = new FakeReleaseClient { Release = null };
|
|
||||||
var d = await SelfUpdater.DecideUpdateAsync(client, currentVersion: "0.1.0", CancellationToken.None);
|
|
||||||
Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Decide_NetworkError_NoUpdate()
|
|
||||||
{
|
|
||||||
var client = new FakeReleaseClient { Throw = true };
|
|
||||||
var d = await SelfUpdater.DecideUpdateAsync(client, "0.1.0", CancellationToken.None);
|
|
||||||
Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Decide_OlderLatest_NoUpdate()
|
|
||||||
{
|
|
||||||
var client = new FakeReleaseClient
|
|
||||||
{
|
|
||||||
Release = new GiteaRelease("v0.1.0", "rel", new[]
|
|
||||||
{
|
|
||||||
new ReleaseAsset("ClaudeDo.Installer-0.1.0.exe", "u", 1),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
var d = await SelfUpdater.DecideUpdateAsync(client, "0.2.0", CancellationToken.None);
|
|
||||||
Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Decide_NewerLatestWithAsset_UpdateAvailable()
|
|
||||||
{
|
|
||||||
var client = new FakeReleaseClient
|
|
||||||
{
|
|
||||||
Release = new GiteaRelease("v0.3.0", "rel", new[]
|
|
||||||
{
|
|
||||||
new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x", 20),
|
|
||||||
new ReleaseAsset("checksums.txt", "https://checks", 1),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
var d = await SelfUpdater.DecideUpdateAsync(client, "0.2.0", CancellationToken.None);
|
|
||||||
Assert.Equal(SelfUpdateDecisionKind.UpdateAvailable, d.Kind);
|
|
||||||
Assert.Equal("0.3.0", d.LatestVersion);
|
|
||||||
Assert.NotNull(d.InstallerAsset);
|
|
||||||
Assert.NotNull(d.ChecksumsAsset);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Decide_NewerLatestButNoInstallerAsset_NoUpdate()
|
|
||||||
{
|
|
||||||
var client = new FakeReleaseClient
|
|
||||||
{
|
|
||||||
Release = new GiteaRelease("v0.3.0", "rel", new[]
|
|
||||||
{
|
|
||||||
new ReleaseAsset("ClaudeDo-0.3.0-win-x64.zip", "u", 20),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
var d = await SelfUpdater.DecideUpdateAsync(client, "0.2.0", CancellationToken.None);
|
|
||||||
Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class SelfUpdaterReplaceSelfTests : IDisposable
|
|
||||||
{
|
|
||||||
private readonly string _tempDir;
|
|
||||||
|
|
||||||
public SelfUpdaterReplaceSelfTests()
|
|
||||||
{
|
|
||||||
_tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDo.Releases.Tests-" + Guid.NewGuid().ToString("N"));
|
|
||||||
Directory.CreateDirectory(_tempDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() { try { Directory.Delete(_tempDir, true); } catch { } }
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Replace_DeletesOldAndCopiesCurrent()
|
|
||||||
{
|
|
||||||
var oldPath = Path.Combine(_tempDir, "old.exe");
|
|
||||||
var currentPath = Path.Combine(_tempDir, "current.exe");
|
|
||||||
await File.WriteAllTextAsync(oldPath, "OLD");
|
|
||||||
await File.WriteAllTextAsync(currentPath, "NEW");
|
|
||||||
|
|
||||||
var relaunchedWith = "";
|
|
||||||
var result = await SelfUpdater.HandleReplaceSelfAsync(
|
|
||||||
oldPath: oldPath,
|
|
||||||
currentExePath: currentPath,
|
|
||||||
launchProcess: path => { relaunchedWith = path; return true; },
|
|
||||||
maxWaitMs: 500);
|
|
||||||
|
|
||||||
Assert.True(result);
|
|
||||||
Assert.Equal(oldPath, relaunchedWith);
|
|
||||||
Assert.Equal("NEW", await File.ReadAllTextAsync(oldPath));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Replace_TimesOutWhenFileStaysLocked_ReturnsFalse()
|
|
||||||
{
|
|
||||||
var oldPath = Path.Combine(_tempDir, "locked.exe");
|
|
||||||
var currentPath = Path.Combine(_tempDir, "current.exe");
|
|
||||||
await File.WriteAllTextAsync(oldPath, "OLD");
|
|
||||||
await File.WriteAllTextAsync(currentPath, "NEW");
|
|
||||||
|
|
||||||
// Hold an exclusive lock across the wait window.
|
|
||||||
using var lockStream = new FileStream(oldPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
|
|
||||||
|
|
||||||
var result = await SelfUpdater.HandleReplaceSelfAsync(
|
|
||||||
oldPath: oldPath,
|
|
||||||
currentExePath: currentPath,
|
|
||||||
launchProcess: _ => true,
|
|
||||||
maxWaitMs: 200);
|
|
||||||
|
|
||||||
Assert.False(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class SelfUpdaterDownloadTests : IDisposable
|
|
||||||
{
|
|
||||||
private readonly string _tempDir;
|
|
||||||
|
|
||||||
public SelfUpdaterDownloadTests()
|
|
||||||
{
|
|
||||||
_tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDo.Releases.Tests-" + Guid.NewGuid().ToString("N"));
|
|
||||||
Directory.CreateDirectory(_tempDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() { try { Directory.Delete(_tempDir, true); } catch { } }
|
|
||||||
|
|
||||||
private sealed class StubReleaseClient : IReleaseClient
|
|
||||||
{
|
|
||||||
public string FileContent { get; set; } = "";
|
|
||||||
public string ChecksumsBody { get; set; } = "";
|
|
||||||
|
|
||||||
public Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult<GiteaRelease?>(null);
|
|
||||||
|
|
||||||
public async Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct)
|
|
||||||
{
|
|
||||||
if (url.EndsWith("checksums.txt", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
await File.WriteAllTextAsync(destPath, ChecksumsBody, ct);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await File.WriteAllTextAsync(destPath, FileContent, ct);
|
|
||||||
}
|
|
||||||
progress.Report(FileContent.Length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Download_MatchingChecksum_ReturnsPath()
|
|
||||||
{
|
|
||||||
var content = "FAKE-INSTALLER-BINARY";
|
|
||||||
var hash = Sha256Hex(content);
|
|
||||||
var client = new StubReleaseClient
|
|
||||||
{
|
|
||||||
FileContent = content,
|
|
||||||
ChecksumsBody = $"{hash} ClaudeDo.Installer-0.3.0.exe\n",
|
|
||||||
};
|
|
||||||
var installer = new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x/inst", content.Length);
|
|
||||||
var checksums = new ReleaseAsset("checksums.txt", "https://x/checksums.txt", 100);
|
|
||||||
|
|
||||||
var path = await SelfUpdater.DownloadAndVerifyAsync(
|
|
||||||
client, installer, checksums, _tempDir, new Progress<long>(_ => { }), CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.NotNull(path);
|
|
||||||
Assert.Equal(content, await File.ReadAllTextAsync(path!));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Download_ChecksumMismatch_ReturnsNull()
|
|
||||||
{
|
|
||||||
var client = new StubReleaseClient
|
|
||||||
{
|
|
||||||
FileContent = "real",
|
|
||||||
ChecksumsBody = "deadbeef" + new string('0', 56) + " ClaudeDo.Installer-0.3.0.exe\n",
|
|
||||||
};
|
|
||||||
var installer = new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x/inst", 4);
|
|
||||||
var checksums = new ReleaseAsset("checksums.txt", "https://x/checksums.txt", 100);
|
|
||||||
|
|
||||||
var path = await SelfUpdater.DownloadAndVerifyAsync(
|
|
||||||
client, installer, checksums, _tempDir, new Progress<long>(_ => { }), CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Null(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Sha256Hex(string s)
|
|
||||||
{
|
|
||||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
|
||||||
return Convert.ToHexString(sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(s))).ToLowerInvariant();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user