feat(worker): run worker as per-user logon task instead of Windows service

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>
This commit is contained in:
mika kuns
2026-05-30 09:39:41 +02:00
parent 1e5b3a6c3e
commit 26c4e5771b
26 changed files with 1244 additions and 265 deletions

View File

@@ -83,6 +83,7 @@ sealed class Program
sc.AddSingleton<HttpClient>(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(10) });
sc.AddSingleton<IReleaseClient>(sp => new ReleaseClient(sp.GetRequiredService<HttpClient>()));
sc.AddSingleton<InstallerLocator>();
sc.AddSingleton<WorkerLocator>();
sc.AddSingleton(sp =>
{
var releases = sp.GetRequiredService<IReleaseClient>();

View File

@@ -203,24 +203,26 @@ public partial class App : Application
sc.AddSingleton<IInstallerPage, InstallPageViewModel>();
// Steps — execution order matters for the FreshInstall pipeline (IEnumerable<IInstallStep>).
// Double-registered as both IInstallStep and concrete type so Task 15's Update pipeline
// 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<IInstallStep, RegisterServiceStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<StartServiceStep>());
sc.AddSingleton<RegisterAutostartStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<RegisterAutostartStep>());
sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
sc.AddSingleton<IInstallStep, WriteUninstallRegistryStep>();
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<StopServiceStep>();
// StartServiceStep is also registered as IInstallStep above (fresh-install pipeline).
sc.AddSingleton<StartServiceStep>();
sc.AddSingleton<StopWorkerStep>();
// Runners
sc.AddSingleton<UninstallRunner>();

View File

@@ -23,7 +23,6 @@ public sealed class InstallContext
public int SignalRPort { get; set; } = 47_821;
public int QueueBackstopIntervalMs { get; set; } = 30_000;
public string ClaudeBin { get; set; } = "claude";
public string ServiceAccount { get; set; } = "CurrentUser";
public bool AutoStart { get; set; } = true;
public int RestartDelayMs { get; set; } = 5000;

View File

@@ -0,0 +1,52 @@
using System.Security;
namespace ClaudeDo.Installer.Core;
public static class ScheduledTaskXml
{
public static string Build(string userId, string workerExePath, int restartIntervalMinutes)
{
var minutes = restartIntervalMinutes < 1 ? 1 : restartIntervalMinutes;
var user = SecurityElement.Escape(userId);
var cmd = SecurityElement.Escape(workerExePath);
return $"""
<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<Description>ClaudeDo background worker (per-user).</Description>
</RegistrationInfo>
<Triggers>
<LogonTrigger>
<Enabled>true</Enabled>
<UserId>{user}</UserId>
</LogonTrigger>
</Triggers>
<Principals>
<Principal id="Author">
<UserId>{user}</UserId>
<LogonType>InteractiveToken</LogonType>
<RunLevel>LeastPrivilege</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>true</AllowHardTerminate>
<StartWhenAvailable>true</StartWhenAvailable>
<Hidden>true</Hidden>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<RestartOnFailure>
<Interval>PT{minutes}M</Interval>
<Count>3</Count>
</RestartOnFailure>
</Settings>
<Actions Context="Author">
<Exec>
<Command>{cmd}</Command>
</Exec>
</Actions>
</Task>
""";
}
}

View File

@@ -9,9 +9,9 @@ namespace ClaudeDo.Installer.Core;
public sealed class UninstallRunner
{
private readonly InstallContext _context;
private readonly StopServiceStep _stopService;
private readonly StopWorkerStep _stopService;
public UninstallRunner(InstallContext context, StopServiceStep stopService)
public UninstallRunner(InstallContext context, StopWorkerStep stopService)
{
_context = context;
_stopService = stopService;
@@ -27,16 +27,17 @@ public sealed class UninstallRunner
// 2) Stop service. If stop fails we MUST abort — deleting a service whose
// process is still running leaves orphan locked binaries under the install dir
// which Directory.Delete will silently skip.
progress.Report("Stopping worker service...");
progress.Report("Stopping worker...");
var stopResult = await _stopService.ExecuteAsync(_context, progress, ct);
if (!stopResult.Success)
return StepResult.Fail(
$"Cannot uninstall: worker service did not stop cleanly. {stopResult.ErrorMessage} " +
$"Cannot uninstall: worker did not stop cleanly. {stopResult.ErrorMessage} " +
"Kill the worker manually and re-run uninstall.");
// 3) Unregister service.
progress.Report("Unregistering service...");
await ProcessRunner.RunAsync("sc.exe", $"delete {StopServiceStep.ServiceName}", null, progress, ct);
// 3) Unregister the autostart task, and best-effort remove any legacy service.
progress.Report("Removing autostart task...");
await ProcessRunner.RunAsync("schtasks.exe", $"/Delete /TN \"{StopWorkerStep.TaskName}\" /F", null, progress, ct);
await ProcessRunner.RunAsync("sc.exe", "delete ClaudeDoWorker", null, progress, ct);
// 3b) Remove Apps & Features registry entry (best-effort).
progress.Report("Removing Add/Remove Programs entry...");

View File

@@ -42,20 +42,23 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
Steps.Clear();
if (_context.Mode == InstallerMode.Update)
{
Steps.Add(new StepViewModel("Stop Worker Service"));
Steps.Add(new StepViewModel("Stop Worker"));
Steps.Add(new StepViewModel("Download and Extract"));
Steps.Add(new StepViewModel("Start Worker Service"));
Steps.Add(new StepViewModel("Register Autostart"));
Steps.Add(new StepViewModel("Start Worker"));
Steps.Add(new StepViewModel("Write Install Manifest"));
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
}
else
{
Steps.Add(new StepViewModel("Download and Extract"));
Steps.Add(new StepViewModel("Write Configuration"));
Steps.Add(new StepViewModel("Initialize Database"));
Steps.Add(new StepViewModel("Register Windows Service"));
Steps.Add(new StepViewModel("Register Autostart"));
Steps.Add(new StepViewModel("Create Shortcuts"));
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
Steps.Add(new StepViewModel("Write Install Manifest"));
Steps.Add(new StepViewModel("Start Worker"));
}
return Task.CompletedTask;
}
@@ -116,10 +119,15 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
{
steps = new IInstallStep[]
{
_serviceProvider.GetRequiredService<StopServiceStep>(),
_serviceProvider.GetRequiredService<StopWorkerStep>(),
_serviceProvider.GetRequiredService<DownloadAndExtractStep>(),
_serviceProvider.GetRequiredService<StartServiceStep>(),
// Migrates the legacy service away and (re)registers the logon task.
_serviceProvider.GetRequiredService<RegisterAutostartStep>(),
_serviceProvider.GetRequiredService<StartWorkerStep>(),
_serviceProvider.GetRequiredService<WriteInstallManifestStep>(),
// Refresh the bundled uninstaller exe + Add/Remove-Programs version so a
// manual update also renews the installer that bootstraps future updates.
_serviceProvider.GetRequiredService<WriteUninstallRegistryStep>(),
};
}
else

View File

@@ -9,8 +9,8 @@
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel MaxWidth="520">
<TextBlock Text="Worker Service" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
<TextBlock Text="Configure the ClaudeDo Worker background service."
<TextBlock Text="Worker" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
<TextBlock Text="Configure the ClaudeDo background worker."
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,0,0,20"
TextWrapping="Wrap"/>
@@ -33,18 +33,11 @@
<Separator Margin="0,4,0,12"/>
<Label Content="Service Account"/>
<StackPanel Margin="0,0,0,12">
<RadioButton Content="Local System (recommended)"
IsChecked="{Binding IsLocalSystem}" Margin="0,0,0,4"/>
<RadioButton Content="Current User"
IsChecked="{Binding IsCurrentUser}"/>
<TextBlock Text="Running as current user requires 'Log on as a service' privilege."
Foreground="{StaticResource TextDimBrush}" FontSize="11" Margin="20,2,0,0"
TextWrapping="Wrap"/>
</StackPanel>
<TextBlock Text="The worker runs as you (the logged-in user) via a per-user logon task, so it can use your Claude CLI authentication."
Foreground="{StaticResource TextDimBrush}" FontSize="11" Margin="0,0,0,12"
TextWrapping="Wrap"/>
<CheckBox Content="Start service automatically" IsChecked="{Binding AutoStart}" Margin="0,0,0,12"/>
<CheckBox Content="Start worker automatically at logon" IsChecked="{Binding AutoStart}" Margin="0,0,0,12"/>
<Label Content="Restart Delay (ms)"/>
<TextBox Text="{Binding RestartDelayMs, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>

View File

@@ -21,8 +21,6 @@ public partial class ServicePageViewModel : ObservableObject, IInstallerPage
[ObservableProperty] private int _signalRPort = 47_821;
[ObservableProperty] private int _queueBackstopIntervalMs = 30_000;
[ObservableProperty] private string _claudeBin = "claude";
[ObservableProperty] private bool _isLocalSystem = true;
[ObservableProperty] private bool _isCurrentUser;
[ObservableProperty] private bool _autoStart = true;
[ObservableProperty] private int _restartDelayMs = 5000;
[ObservableProperty] private string? _validationError;
@@ -43,7 +41,6 @@ public partial class ServicePageViewModel : ObservableObject, IInstallerPage
_context.SignalRPort = SignalRPort;
_context.QueueBackstopIntervalMs = QueueBackstopIntervalMs;
_context.ClaudeBin = ClaudeBin;
_context.ServiceAccount = IsCurrentUser ? "CurrentUser" : "LocalSystem";
_context.AutoStart = AutoStart;
_context.RestartDelayMs = RestartDelayMs;
_context.SignalRUrl = $"http://127.0.0.1:{SignalRPort}/hub";

View File

@@ -0,0 +1,59 @@
using System.IO;
using System.Security.Principal;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class RegisterAutostartStep : IInstallStep
{
public const string TaskName = "ClaudeDoWorker";
private const string LegacyServiceName = "ClaudeDoWorker";
public string Name => "Register Autostart";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
var workerExe = Path.Combine(ctx.InstallDirectory, "worker", "ClaudeDo.Worker.exe");
if (!File.Exists(workerExe))
return StepResult.Fail($"Worker executable not found: {workerExe}");
// 1) Migrate away the legacy Windows service if present.
progress.Report("Checking for legacy worker service...");
var (queryExit, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct);
if (queryExit == 0)
{
progress.Report("Removing legacy worker service...");
await ProcessRunner.RunAsync("sc.exe", $"stop {LegacyServiceName}", null, progress, ct);
await ProcessRunner.RunAsync("sc.exe", $"delete {LegacyServiceName}", null, progress, ct);
for (var i = 0; i < 30; i++)
{
ct.ThrowIfCancellationRequested();
var (q, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct);
if (q != 0) break;
await Task.Delay(1000, ct);
}
}
// 2) Register (or replace) the per-user logon task.
var userId = WindowsIdentity.GetCurrent().Name;
var minutes = Math.Max(1, ctx.RestartDelayMs / 60000);
var xml = ScheduledTaskXml.Build(userId, workerExe, minutes);
var xmlPath = Path.Combine(Path.GetTempPath(), $"ClaudeDoWorker-{Guid.NewGuid():N}.xml");
await File.WriteAllTextAsync(xmlPath, xml, new System.Text.UnicodeEncoding(false, true), ct);
try
{
progress.Report("Registering logon task...");
var (exit, output) = await ProcessRunner.RunAsync(
"schtasks.exe", $"/Create /TN \"{TaskName}\" /XML \"{xmlPath}\" /F", null, progress, ct);
if (exit != 0)
return StepResult.Fail($"schtasks /Create failed (exit {exit}): {output}");
}
finally
{
try { File.Delete(xmlPath); } catch { /* best effort */ }
}
return StepResult.Ok();
}
}

View File

@@ -1,88 +0,0 @@
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class RegisterServiceStep : IInstallStep
{
private const string ServiceName = "ClaudeDoWorker";
public string Name => "Register Windows Service";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
var workerExe = Path.Combine(ctx.InstallDirectory, "worker", "ClaudeDo.Worker.exe");
if (!File.Exists(workerExe))
return StepResult.Fail($"Worker executable not found: {workerExe}");
// Stop existing service (ignore errors — may not exist)
progress.Report("Stopping existing service (if any)...");
await RunSc($"stop {ServiceName}", ctx, progress, ct, ignoreErrors: true);
// Delete existing service (ignore errors)
progress.Report("Removing existing service registration (if any)...");
await RunSc($"delete {ServiceName}", ctx, progress, ct, ignoreErrors: true);
// Wait for the service to actually disappear from SCM. `sc delete` returns
// immediately but the service stays "marked for deletion" until every open
// handle (services.msc, Task Manager, a prior sc query process) is closed.
// Poll up to 30s — then fail with actionable guidance if it's still there.
progress.Report("Waiting for prior service registration to clear...");
for (var i = 0; i < 30; i++)
{
ct.ThrowIfCancellationRequested();
var (queryExit, _) = await RunSc($"query {ServiceName}", ctx, progress, ct, ignoreErrors: true);
if (queryExit != 0) break; // service no longer registered — good
if (i == 29)
return StepResult.Fail(
$"Service '{ServiceName}' is marked for deletion but hasn't cleared after 30s. " +
"Close any open Services console (services.msc), Task Manager Services tab, or " +
"Event Viewer showing the service, then retry. A reboot will also clear it.");
await Task.Delay(1000, ct);
}
// Create service
var startType = ctx.AutoStart ? "auto" : "demand";
if (ctx.ServiceAccount == "CurrentUser")
return StepResult.Fail(
"Service cannot run as Current User without a password. " +
"Select 'Local System' or extend ServicePage to capture a password.");
var objArg = ctx.ServiceAccount switch
{
"LocalSystem" => " obj= LocalSystem",
"NetworkService" => " obj= \"NT AUTHORITY\\NetworkService\"",
"LocalService" => " obj= \"NT AUTHORITY\\LocalService\"",
_ => "",
};
var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}{objArg}";
progress.Report("Creating service...");
var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct);
if (exitCode == 1072)
return StepResult.Fail(
$"Service '{ServiceName}' is still marked for deletion. " +
"Close services.msc / Task Manager / Event Viewer and retry, or reboot.");
if (exitCode != 0)
return StepResult.Fail($"sc.exe create failed (exit {exitCode}): {output}");
// Configure restart policy
var delay = ctx.RestartDelayMs;
var failureArgs = $"failure {ServiceName} reset= 86400 actions= restart/{delay}/restart/{delay}/restart/{delay}";
progress.Report("Configuring restart policy...");
var (failExit, failOutput) = await RunSc(failureArgs, ctx, progress, ct);
if (failExit != 0)
progress.Report($"Warning: failed to set restart policy (exit {failExit})");
return StepResult.Ok();
}
private static async Task<(int ExitCode, string Output)> RunSc(
string arguments, InstallContext ctx, IProgress<string> progress,
CancellationToken ct, bool ignoreErrors = false)
{
var result = await ProcessRunner.RunAsync("sc.exe", arguments, null, progress, ct);
return result;
}
}

View File

@@ -1,38 +0,0 @@
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class StartServiceStep : IInstallStep
{
private const string ServiceName = StopServiceStep.ServiceName;
public string Name => "Start Worker Service";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
progress.Report($"Starting {ServiceName}...");
var (exit, _) = await ProcessRunner.RunAsync("sc.exe", $"start {ServiceName}", null, progress, ct);
// 1056 = ERROR_SERVICE_ALREADY_RUNNING — fine, fall through to the readiness poll.
if (exit != 0 && exit != 1056)
return StepResult.Fail($"sc.exe start failed with exit code {exit}");
if (exit == 1056)
progress.Report("Service was already running.");
// sc.exe start returns as soon as SCM accepts the command. Poll until the
// service actually reports RUNNING so downstream steps and SignalR clients
// don't race the worker's startup.
progress.Report("Waiting for service to reach RUNNING state...");
for (var i = 0; i < 30; i++)
{
ct.ThrowIfCancellationRequested();
var (q, output) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
if (q == 0 && output.Contains("RUNNING", StringComparison.OrdinalIgnoreCase))
return StepResult.Ok();
await Task.Delay(1000, ct);
}
return StepResult.Fail("Service did not reach RUNNING state within 30 seconds.");
}
}

View File

@@ -0,0 +1,19 @@
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class StartWorkerStep : IInstallStep
{
public const string TaskName = "ClaudeDoWorker";
public string Name => "Start Worker";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
progress.Report("Starting worker...");
var (exit, output) = await ProcessRunner.RunAsync("schtasks.exe", $"/Run /TN \"{TaskName}\"", null, progress, ct);
if (exit != 0)
return StepResult.Fail($"schtasks /Run failed (exit {exit}): {output}");
return StepResult.Ok();
}
}

View File

@@ -1,54 +0,0 @@
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class StopServiceStep : IInstallStep
{
public const string ServiceName = "ClaudeDoWorker";
public string Name => "Stop Worker Service";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
progress.Report($"Stopping {ServiceName} (if running)...");
// sc.exe query -> returns non-zero if the service does not exist; that's fine.
var (queryExit, queryOutput) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
if (queryExit != 0)
{
progress.Report("Service is not registered — nothing to stop.");
return StepResult.Ok();
}
if (queryOutput.Contains("STOPPED", StringComparison.OrdinalIgnoreCase))
{
progress.Report("Service is already stopped.");
return StepResult.Ok();
}
var (stopExit, _) = await ProcessRunner.RunAsync("sc.exe", $"stop {ServiceName}", null, progress, ct);
// 1062 = ERROR_SERVICE_NOT_ACTIVE — registered but not running, treat as already stopped.
if (stopExit == 1062)
{
progress.Report("Service was registered but not running.");
return StepResult.Ok();
}
if (stopExit != 0)
return StepResult.Fail($"sc.exe stop failed with exit code {stopExit}");
// Poll until stopped or timeout (up to 30s).
for (var i = 0; i < 30; i++)
{
ct.ThrowIfCancellationRequested();
await Task.Delay(1000, ct);
var (e, o) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
if (e != 0 || o.Contains("STOPPED", StringComparison.OrdinalIgnoreCase))
{
progress.Report("Service stopped.");
return StepResult.Ok();
}
}
return StepResult.Fail("Service did not stop within 30 seconds.");
}
}

View File

@@ -0,0 +1,48 @@
using System.Diagnostics;
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class StopWorkerStep : IInstallStep
{
public const string TaskName = "ClaudeDoWorker";
public const string ProcessName = "ClaudeDo.Worker";
public string Name => "Stop Worker";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
progress.Report("Stopping worker task (if running)...");
await ProcessRunner.RunAsync("schtasks.exe", $"/End /TN \"{TaskName}\"", null, progress, ct);
progress.Report("Stopping worker process (if running)...");
var installDir = ctx.InstallDirectory;
foreach (var p in Process.GetProcessesByName(ProcessName))
{
try
{
var path = p.MainModule?.FileName;
if (path is not null && !IsUnder(path, installDir)) continue;
p.Kill(entireProcessTree: true);
p.WaitForExit(10000);
}
catch { /* process may have exited or be inaccessible */ }
finally { p.Dispose(); }
}
await Task.CompletedTask;
return StepResult.Ok();
}
private static bool IsUnder(string filePath, string dir)
{
try
{
if (string.IsNullOrWhiteSpace(dir)) return true; // can't scope — be permissive
var full = Path.GetFullPath(filePath);
var root = Path.GetFullPath(dir).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
return full.StartsWith(root, StringComparison.OrdinalIgnoreCase);
}
catch { return false; }
}
}

View File

@@ -26,14 +26,22 @@ 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.");
try
// 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.
var alreadyInPlace = string.Equals(
Path.GetFullPath(sourceExe), Path.GetFullPath(targetExe), StringComparison.OrdinalIgnoreCase);
if (!alreadyInPlace)
{
progress.Report("Copying uninstaller binary...");
File.Copy(sourceExe, targetExe, overwrite: true);
}
catch (Exception ex)
{
return StepResult.Fail($"Failed to copy uninstaller exe: {ex.Message}");
try
{
progress.Report("Copying uninstaller binary...");
File.Copy(sourceExe, targetExe, overwrite: true);
}
catch (Exception ex)
{
return StepResult.Fail($"Failed to copy uninstaller exe: {ex.Message}");
}
}
progress.Report("Writing Add/Remove Programs entry...");

View File

@@ -11,8 +11,9 @@ public partial class SettingsViewModel : ObservableObject
{
private readonly InstallContext _context;
private readonly IReleaseClient _releases;
private readonly StopServiceStep _stopService;
private readonly StartServiceStep _startService;
private readonly StopWorkerStep _stopService;
private readonly StartWorkerStep _startService;
private readonly RegisterAutostartStep _registerAutostart;
private readonly DownloadAndExtractStep _downloadStep;
private readonly UninstallRunner _uninstallRunner;
@@ -37,8 +38,9 @@ public partial class SettingsViewModel : ObservableObject
PageResolver resolver,
InstallContext context,
IReleaseClient releases,
StopServiceStep stopService,
StartServiceStep startService,
StopWorkerStep stopService,
StartWorkerStep startService,
RegisterAutostartStep registerAutostart,
DownloadAndExtractStep downloadStep,
UninstallRunner uninstallRunner)
{
@@ -47,6 +49,7 @@ public partial class SettingsViewModel : ObservableObject
_releases = releases;
_stopService = stopService;
_startService = startService;
_registerAutostart = registerAutostart;
_downloadStep = downloadStep;
_uninstallRunner = uninstallRunner;
_selectedPage = Pages.FirstOrDefault();
@@ -154,7 +157,7 @@ public partial class SettingsViewModel : ObservableObject
IsStatusError = false;
var progress = new Progress<string>(msg => StatusMessage = msg);
var steps = new IInstallStep[] { _stopService, _downloadStep, _startService };
var steps = new IInstallStep[] { _stopService, _downloadStep, _registerAutostart, _startService };
foreach (var step in steps)
{

View File

@@ -11,7 +11,6 @@
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,43 @@
namespace ClaudeDo.Ui.Services;
public sealed class WorkerLocator
{
private const string InstallJson = "install.json";
private const string WorkerExe = "ClaudeDo.Worker.exe";
private const string WorkerSubdir = "worker";
public string? Find()
=> FindByWalkingUp(AppContext.BaseDirectory)
?? (OperatingSystem.IsWindows() ? FindByRegistry() : null);
public string? FindByWalkingUp(string startDir)
{
var dir = new DirectoryInfo(startDir);
while (dir is not null)
{
if (File.Exists(Path.Combine(dir.FullName, InstallJson)))
{
var candidate = Path.Combine(dir.FullName, WorkerSubdir, WorkerExe);
return File.Exists(candidate) ? candidate : null;
}
dir = dir.Parent;
}
return null;
}
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
public string? FindByRegistry()
{
if (!OperatingSystem.IsWindows()) return null;
try
{
using var key = Microsoft.Win32.Registry.LocalMachine
.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo");
var location = key?.GetValue("InstallLocation") as string;
if (string.IsNullOrEmpty(location)) return null;
var candidate = Path.Combine(location, WorkerSubdir, WorkerExe);
return File.Exists(candidate) ? candidate : null;
}
catch { return null; }
}
}

View File

@@ -31,6 +31,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
private readonly UpdateCheckService _updateCheck = null!;
private readonly InstallerLocator _installerLocator = null!;
private readonly WorkerLocator _workerLocator = null!;
private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory;
private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!;
private readonly Func<MergeModalViewModel> _mergeVmFactory = () => null!;
@@ -170,6 +171,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
WorkerClient worker,
UpdateCheckService updateCheck,
InstallerLocator installerLocator,
WorkerLocator workerLocator,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
Func<WorktreesOverviewModalViewModel> worktreesOverviewVmFactory,
Func<MergeModalViewModel> mergeVmFactory,
@@ -178,6 +180,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
_updateCheck = updateCheck;
_installerLocator = installerLocator;
_workerLocator = workerLocator;
_dbFactory = dbFactory;
_worktreesOverviewVmFactory = worktreesOverviewVmFactory;
_mergeVmFactory = mergeVmFactory;
@@ -218,6 +221,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
_primeStatusTimer.Elapsed += (_, _) =>
Avalonia.Threading.Dispatcher.UIThread.Post(() => PrimeStatus = null);
_ = Lists.LoadAsync();
_ = EnsureWorkerRunningAsync();
_updateCheck.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(UpdateCheckService.LastCheckStatus))
@@ -301,43 +305,58 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
[ObservableProperty] private string? _restartWorkerStatus;
private bool _ensureRunningAttempted;
private async Task EnsureWorkerRunningAsync()
{
if (_ensureRunningAttempted) return;
_ensureRunningAttempted = true;
await Task.Delay(TimeSpan.FromSeconds(4));
if (Worker?.IsConnected == true) return;
var exe = _workerLocator.Find();
if (exe is null) return;
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true }); }
catch { /* logon task is the primary mechanism; this is a convenience */ }
}
[RelayCommand]
private async Task RestartWorkerAsync()
{
if (!OperatingSystem.IsWindows())
{
await FlashRestartStatusAsync("Service control is Windows-only.");
return;
}
RestartWorkerStatus = "Restarting worker…";
try
{
await Task.Run(RestartWorkerService);
await FlashRestartStatusAsync("Worker restarted.");
}
catch (InvalidOperationException)
{
// ServiceController throws this when the service is not installed.
await FlashRestartStatusAsync("ClaudeDoWorker service is not installed.");
}
catch (Exception ex)
{
await FlashRestartStatusAsync($"Restart failed: {ex.Message}");
}
}
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
private static void RestartWorkerService()
private void RestartWorkerService()
{
using var sc = new System.ServiceProcess.ServiceController("ClaudeDoWorker");
if (sc.Status != System.ServiceProcess.ServiceControllerStatus.Stopped)
var exe = _workerLocator.Find();
if (exe is null) throw new InvalidOperationException("Worker executable not found.");
// Only kill the worker belonging to THIS installation — not any other
// ClaudeDo.Worker on the machine (e.g. a second install).
var exeFull = System.IO.Path.GetFullPath(exe);
foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker"))
{
sc.Stop();
sc.WaitForStatus(System.ServiceProcess.ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(20));
try
{
var path = p.MainModule?.FileName;
if (path is not null &&
!string.Equals(System.IO.Path.GetFullPath(path), exeFull, StringComparison.OrdinalIgnoreCase))
continue;
p.Kill(entireProcessTree: true);
p.WaitForExit(10000);
}
catch { /* may have exited or be inaccessible */ }
finally { p.Dispose(); }
}
sc.Start();
sc.WaitForStatus(System.ServiceProcess.ServiceControllerStatus.Running, TimeSpan.FromSeconds(20));
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true });
}
private async Task FlashRestartStatusAsync(string text)

View File

@@ -10,7 +10,8 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
</ItemGroup>
@@ -23,6 +24,7 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>WinExe</OutputType>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

View File

@@ -1,3 +1,4 @@
using System.Threading;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Repositories;
@@ -13,14 +14,28 @@ using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Prime;
using ClaudeDo.Worker.Worktrees;
using Microsoft.EntityFrameworkCore;
using Serilog;
// Single-instance per user session. Multiple launch paths exist (logon task,
// app ensure-running, Restart button); a second instance exits cleanly instead
// of fighting over the SignalR port.
var mutex = new Mutex(true, @"Local\ClaudeDoWorker", out var createdNew);
if (!createdNew)
return; // another instance already owns the port; exit 0
var cfg = WorkerConfig.Load();
var builder = WebApplication.CreateBuilder(args);
// When launched by the Windows SCM, speak the Service Control Protocol so SCM
// doesn't think we crashed (~30s timeout). No-op when running interactively.
builder.Host.UseWindowsService(o => o.ServiceName = "ClaudeDoWorker");
var logRoot = cfg.LogRoot;
Directory.CreateDirectory(logRoot);
builder.Host.UseSerilog((ctx, lc) => lc
.MinimumLevel.Information()
.WriteTo.File(
System.IO.Path.Combine(logRoot, "worker-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 7,
shared: true));
builder.Services.AddDbContextFactory<ClaudeDoDbContext>(opt =>
opt.UseSqlite($"Data Source={cfg.DbPath}"));
@@ -209,3 +224,5 @@ else
{
await Task.WhenAll(app.RunAsync(), externalApp.RunAsync());
}
GC.KeepAlive(mutex);