A LocalSystem Windows service can't see the logged-in user's Claude CLI authentication, so the worker now runs as the current user via a hidden per-user logon Scheduled Task with restart-on-failure. - Worker is WinExe (no console window) with a Serilog rolling file sink and a single-instance mutex so the logon task, app ensure-running, and Restart button can't fight over the SignalR port. - Installer replaces the service steps (register/start/stop) with autostart task steps, migrates the legacy ClaudeDoWorker service away on update, and removes the task on uninstall. ServicePage drops the service-account UI. - UI gains a WorkerLocator; the app ensures the worker is running at startup and the Restart button kills+relaunches this install's worker process. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
237 lines
10 KiB
C#
237 lines
10 KiB
C#
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;
|
|
using ClaudeDo.Installer.Pages.UiSettingsPage;
|
|
using ClaudeDo.Installer.Pages.WelcomePage;
|
|
using ClaudeDo.Installer.Steps;
|
|
using ClaudeDo.Installer.Views;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
namespace ClaudeDo.Installer;
|
|
|
|
public partial class App : Application
|
|
{
|
|
private ServiceProvider? _services;
|
|
|
|
protected override async void OnStartup(StartupEventArgs e)
|
|
{
|
|
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 "<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();
|
|
|
|
var context = _services.GetRequiredService<InstallContext>();
|
|
context.InstallerVersion = GetInstallerVersion();
|
|
|
|
// Default install dir for detection — on upgrade we stay where we were.
|
|
var detector = _services.GetRequiredService<InstallModeDetector>();
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
|
|
// Read manifest up front so we can fall back to Config if the API times out
|
|
// on an existing install. If the API is slow, we do NOT want to drop an
|
|
// already-installed user into FreshInstall — that would risk overwriting them.
|
|
var existingManifest = InstallManifestStore.TryRead(context.InstallDirectory);
|
|
|
|
DetectedState state;
|
|
try
|
|
{
|
|
state = await detector.DetectAsync(context.InstallDirectory, cts.Token);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
state = existingManifest is not null
|
|
? new DetectedState(InstallerMode.Config, existingManifest, null, null)
|
|
: new DetectedState(InstallerMode.FreshInstall, null, null, null);
|
|
}
|
|
|
|
context.Mode = state.Mode;
|
|
context.InstalledVersion = state.Existing?.Version;
|
|
context.LatestVersion = state.LatestVersion;
|
|
context.LatestTagUnparseable = state.LatestTagUnparseable;
|
|
if (state.Existing is not null)
|
|
context.InstallDirectory = state.Existing.InstallDir;
|
|
|
|
Window mainWindow = state.Mode switch
|
|
{
|
|
InstallerMode.FreshInstall or InstallerMode.Update => new WizardWindow
|
|
{
|
|
DataContext = _services.GetRequiredService<WizardViewModel>()
|
|
},
|
|
InstallerMode.Config => new SettingsWindow
|
|
{
|
|
DataContext = _services.GetRequiredService<SettingsViewModel>()
|
|
},
|
|
_ => throw new InvalidOperationException($"Unknown installer mode: {state.Mode}")
|
|
};
|
|
|
|
DarkTitleBar.Apply(mainWindow);
|
|
mainWindow.Show();
|
|
}
|
|
|
|
protected override void OnExit(ExitEventArgs e)
|
|
{
|
|
_services?.Dispose();
|
|
base.OnExit(e);
|
|
}
|
|
|
|
private static string GetInstallerVersion()
|
|
{
|
|
var infoAttr = Assembly.GetExecutingAssembly()
|
|
.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
|
|
return infoAttr?.InformationalVersion ?? "0.0.0";
|
|
}
|
|
|
|
private static ServiceProvider BuildServices()
|
|
{
|
|
var sc = new ServiceCollection();
|
|
|
|
// Core
|
|
sc.AddSingleton<InstallContext>();
|
|
sc.AddSingleton<PageResolver>();
|
|
// HTTP + release client
|
|
sc.AddSingleton(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(15) });
|
|
sc.AddSingleton<IReleaseClient>(sp => new ReleaseClient(sp.GetRequiredService<HttpClient>()));
|
|
sc.AddSingleton<InstallModeDetector>();
|
|
|
|
// Pages
|
|
sc.AddSingleton<IInstallerPage, WelcomePageViewModel>();
|
|
sc.AddSingleton<IInstallerPage, PathsPageViewModel>();
|
|
sc.AddSingleton<IInstallerPage, ServicePageViewModel>();
|
|
sc.AddSingleton<IInstallerPage, UiSettingsPageViewModel>();
|
|
sc.AddSingleton<IInstallerPage, InstallPageViewModel>();
|
|
|
|
// Steps — execution order matters for the FreshInstall pipeline (IEnumerable<IInstallStep>).
|
|
// Double-registered as both IInstallStep and concrete type so the Update pipeline
|
|
// can pull them out individually via GetRequiredService<T>().
|
|
sc.AddSingleton<DownloadAndExtractStep>();
|
|
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<DownloadAndExtractStep>());
|
|
sc.AddSingleton<IInstallStep, WriteConfigStep>();
|
|
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
|
|
sc.AddSingleton<RegisterAutostartStep>();
|
|
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<RegisterAutostartStep>());
|
|
sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
|
|
sc.AddSingleton<WriteUninstallRegistryStep>();
|
|
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteUninstallRegistryStep>());
|
|
sc.AddSingleton<WriteInstallManifestStep>();
|
|
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>());
|
|
// Start the worker last in the fresh pipeline (binaries + task must exist first).
|
|
sc.AddSingleton<StartWorkerStep>();
|
|
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<StartWorkerStep>());
|
|
|
|
// Stop — NOT registered as IInstallStep (not part of default FreshInstall pipeline).
|
|
// Pulled by Update flow + Repair/Uninstall.
|
|
sc.AddSingleton<StopWorkerStep>();
|
|
|
|
// Runners
|
|
sc.AddSingleton<UninstallRunner>();
|
|
|
|
// ViewModels
|
|
sc.AddSingleton<WizardViewModel>();
|
|
sc.AddSingleton<SettingsViewModel>();
|
|
|
|
return sc.BuildServiceProvider();
|
|
}
|
|
}
|