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"
|
||||
( 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)
|
||||
if [ -z "$INSTALLER_EXE" ]; then
|
||||
echo "::error::No .exe produced by installer publish" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "$INSTALLER_EXE" "assets/ClaudeDo.Installer-${VERSION}.exe"
|
||||
cp "$INSTALLER_EXE" "assets/ClaudeDo.Installer.exe"
|
||||
|
||||
# 3) Checksums (sha256, relative filenames)
|
||||
( cd assets && sha256sum \
|
||||
"ClaudeDo-${VERSION}-win-x64.zip" \
|
||||
"ClaudeDo.Installer-${VERSION}.exe" \
|
||||
"ClaudeDo.Installer.exe" \
|
||||
> checksums.txt )
|
||||
|
||||
echo "--- assets ---"
|
||||
@@ -200,7 +201,7 @@ jobs:
|
||||
cd "$WORK/src/assets"
|
||||
for f in \
|
||||
"ClaudeDo-${VERSION}-win-x64.zip" \
|
||||
"ClaudeDo.Installer-${VERSION}.exe" \
|
||||
"ClaudeDo.Installer.exe" \
|
||||
"checksums.txt"
|
||||
do
|
||||
echo "Uploading: $f"
|
||||
|
||||
@@ -38,104 +38,6 @@ public partial class App : Application
|
||||
var localizer = new Localizer(localeStore, initialLang);
|
||||
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);
|
||||
|
||||
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`)
|
||||
- References: `ClaudeDo.Data`, `ClaudeDo.Releases`, `ClaudeDo.Localization`
|
||||
- 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`)
|
||||
|
||||
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>`
|
||||
3. Detect mode — `InstallModeDetector` reads `install.json` + Gitea API
|
||||
4. Open `WizardWindow` (FreshInstall / Update) or `SettingsWindow` (Config)
|
||||
2. Detect mode — `InstallModeDetector` reads `install.json` + Gitea API
|
||||
3. 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`)
|
||||
|
||||
@@ -56,8 +64,7 @@ Installer/
|
||||
Interfaces/ — IInstallStep + StepResult/StepStatus/StepProgress, IInstallerPage
|
||||
Pages/ — WelcomePage, PathsPage, ServicePage, UiSettingsPage, InstallPage
|
||||
(each: ViewModel + View.xaml)
|
||||
Views/ — WizardWindow(+WizardViewModel), SettingsWindow(+SettingsViewModel),
|
||||
SelfUpdatePromptWindow
|
||||
Views/ — WizardWindow(+WizardViewModel), SettingsWindow(+SettingsViewModel)
|
||||
```
|
||||
|
||||
## Key Step Behaviors
|
||||
|
||||
@@ -26,9 +26,9 @@ public sealed class WriteUninstallRegistryStep : IInstallStep
|
||||
// the single-file temp extract is gone once this process exits.
|
||||
var sourceExe = Environment.ProcessPath
|
||||
?? throw new InvalidOperationException("Cannot resolve running installer path.");
|
||||
// In the self-update path the installer already runs from uninstaller/ (the
|
||||
// --replace-self handoff put it there), so source == target and the copy would
|
||||
// throw. Skip it; the binary is already in place.
|
||||
// When relaunched from the installed copy (e.g. the Apps & Features "Rerun
|
||||
// Installer" entry points at uninstaller/ClaudeDo.Installer.exe), source == target
|
||||
// and the copy would throw. Skip it; the binary is already in place.
|
||||
var alreadyInPlace = string.Equals(
|
||||
Path.GetFullPath(sourceExe), Path.GetFullPath(targetExe), StringComparison.OrdinalIgnoreCase);
|
||||
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