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:
Mika Kuns
2026-06-23 08:14:34 +02:00
parent 8716dd8e3a
commit 3cb4802f38
9 changed files with 21 additions and 576 deletions

View File

@@ -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>();

View File

@@ -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

View File

@@ -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)

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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;
}
}