diff --git a/.gitignore b/.gitignore index 13cbffe..9fadc0a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Local dev worktrees (created by using-git-worktrees skill) +.worktrees/ + # .NET build output bin/ obj/ diff --git a/ClaudeDo.slnx b/ClaudeDo.slnx index 178690d..eb6dbf9 100644 --- a/ClaudeDo.slnx +++ b/ClaudeDo.slnx @@ -5,10 +5,12 @@ + + diff --git a/docs/open.md b/docs/open.md index 06cb836..a12020d 100644 --- a/docs/open.md +++ b/docs/open.md @@ -191,3 +191,19 @@ Im aktuellen Stand kompiliert die UI, aber mehrere Stellen sind als `// TODO` ma 9. **Tag-Negation (3.4)** — wenn der Bedarf konkret wird. Punkte 1–3 sind ein realistischer Block für eine Session. + +--- + +## Self-Update — Manual Verification + +Preconditions: a working Gitea release at `git.kuns.dev/releases/ClaudeDo` with three assets — `ClaudeDo--win-x64.zip`, `ClaudeDo.Installer-.exe`, and `checksums.txt` listing both. + +1. Install a baseline version (e.g. `0.2.x`) normally. +2. Publish a new release tagged `v0.3.0` with fresh installer + app zip + checksums. +3. Launch the app — confirm the banner appears: `Update available: v0.2.x → v0.3.0`. +4. Click **Update now** — app closes, installer opens in Update mode, runs, restarts the worker. +5. Re-launch the app — banner is gone; `Help → Check for updates` briefly shows "You're up to date (v0.3.0)". +6. Run the `v0.2.x` installer manually — confirm it prompts to self-update to v0.3.0. Click **Update** → running exe is replaced and the wizard opens on the new version. +7. Repeat step 6 with **Continue anyway** → wizard opens without self-update. +8. Repeat step 6 with **Cancel** → installer exits without any action. +9. Kill network during startup in both app and installer → confirm silent fallback (no errors, no banner, wizard opens normally). diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index 26c136a..2c07966 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -1,6 +1,7 @@ using Avalonia; using ClaudeDo.Data; using ClaudeDo.Data.Git; +using ClaudeDo.Releases; using ClaudeDo.Ui; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels; @@ -9,6 +10,8 @@ using ClaudeDo.Ui.ViewModels.Modals; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using System; +using System.Net.Http; +using System.Reflection; using System.Runtime.InteropServices; namespace ClaudeDo.App; @@ -75,6 +78,17 @@ sealed class Program sc.AddSingleton(); sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService().SignalRUrl)); + // Release check + installer update + sc.AddSingleton(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(10) }); + sc.AddSingleton(sp => new ReleaseClient(sp.GetRequiredService())); + sc.AddSingleton(); + sc.AddSingleton(sp => + { + var releases = sp.GetRequiredService(); + var version = Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "0.0.0.0"; + return new UpdateCheckService(releases, version); + }); + // ViewModels sc.AddTransient(); sc.AddTransient(); diff --git a/src/ClaudeDo.Installer/App.xaml.cs b/src/ClaudeDo.Installer/App.xaml.cs index fd282fb..33e0645 100644 --- a/src/ClaudeDo.Installer/App.xaml.cs +++ b/src/ClaudeDo.Installer/App.xaml.cs @@ -2,6 +2,7 @@ using System.Net.Http; using System.Reflection; using System.Windows; using ClaudeDo.Installer.Core; +using ClaudeDo.Releases; using ClaudeDo.Installer.Pages.InstallPage; using ClaudeDo.Installer.Pages.PathsPage; using ClaudeDo.Installer.Pages.ServicePage; @@ -21,6 +22,104 @@ public partial class App : Application { base.OnStartup(e); + // --- 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 "" + 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(_ => { }), + 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(); var context = _services.GetRequiredService(); diff --git a/src/ClaudeDo.Installer/ClaudeDo.Installer.csproj b/src/ClaudeDo.Installer/ClaudeDo.Installer.csproj index 139ba01..eb58cf3 100644 --- a/src/ClaudeDo.Installer/ClaudeDo.Installer.csproj +++ b/src/ClaudeDo.Installer/ClaudeDo.Installer.csproj @@ -45,6 +45,7 @@ + diff --git a/src/ClaudeDo.Installer/Core/InstallModeDetector.cs b/src/ClaudeDo.Installer/Core/InstallModeDetector.cs index 574d300..81fa819 100644 --- a/src/ClaudeDo.Installer/Core/InstallModeDetector.cs +++ b/src/ClaudeDo.Installer/Core/InstallModeDetector.cs @@ -1,3 +1,5 @@ +using ClaudeDo.Releases; + namespace ClaudeDo.Installer.Core; public sealed record DetectedState( @@ -31,7 +33,9 @@ public sealed class InstallModeDetector return new DetectedState(InstallerMode.Config, manifest, null, null); var latestVersion = release.TagName.TrimStart('v', 'V'); - var newer = IsNewer(latestVersion, manifest.Version, out var unparseable); + var cmp = VersionComparer.Compare(latestVersion, manifest.Version); + var newer = cmp.IsNewer; + var unparseable = cmp.Unparseable; if (newer) return new DetectedState(InstallerMode.Update, manifest, release, latestVersion); @@ -41,16 +45,4 @@ public sealed class InstallModeDetector }; } - /// - /// Returns true only when both versions parse as System.Version (major.minor[.build[.revision]]) - /// AND latest > current. Semver pre-release tags like "0.2.0-beta" fail to parse and are - /// treated as "not newer" — the user drops into Config mode with no update offered, but - /// is set so the UI can surface a hint. - /// - private static bool IsNewer(string latest, string current, out bool unparseable) - { - unparseable = !Version.TryParse(latest, out var lv) | !Version.TryParse(current, out var cv); - if (unparseable) return false; - return lv > cv; - } } diff --git a/src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs b/src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs index aa97f78..515c54d 100644 --- a/src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs +++ b/src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs @@ -1,6 +1,7 @@ using System.IO; using System.IO.Compression; using ClaudeDo.Installer.Core; +using ClaudeDo.Releases; namespace ClaudeDo.Installer.Steps; diff --git a/src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml b/src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml new file mode 100644 index 0000000..e9f9aa4 --- /dev/null +++ b/src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml @@ -0,0 +1,25 @@ + + + + + + + + + + + + +