feat(installer): add WPF installer/configurator project
Standalone WPF app (ClaudeDo.Installer) that handles full installation and ongoing configuration of ClaudeDo. Two modes: wizard for first run, tabbed settings panel for subsequent launches. Page-based extensibility via IInstallerPage interface — adding new config sections requires only one new class. Install pipeline: dotnet publish, deploy binaries, write configs, init DB (via SchemaInitializer from ClaudeDo.Data), register Windows Service, create shortcuts. Dark theme matching the Avalonia app (forest teal accent). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
91
src/ClaudeDo.Installer/Steps/CreateShortcutsStep.cs
Normal file
91
src/ClaudeDo.Installer/Steps/CreateShortcutsStep.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.InteropServices.ComTypes;
|
||||
using System.Text;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class CreateShortcutsStep : IInstallStep
|
||||
{
|
||||
public string Name => "Create Shortcuts";
|
||||
|
||||
public Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var appExe = Path.Combine(ctx.InstallDirectory, "app", "ClaudeDo.App.exe");
|
||||
var workingDir = Path.Combine(ctx.InstallDirectory, "app");
|
||||
|
||||
// Start Menu shortcut
|
||||
var startMenuDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu),
|
||||
"Programs");
|
||||
Directory.CreateDirectory(startMenuDir);
|
||||
var startMenuPath = Path.Combine(startMenuDir, "ClaudeDo.lnk");
|
||||
CreateShortcut(startMenuPath, appExe, workingDir, "ClaudeDo Task Manager");
|
||||
progress.Report($"Created Start Menu shortcut: {startMenuPath}");
|
||||
|
||||
// Desktop shortcut (optional)
|
||||
if (ctx.CreateDesktopShortcut)
|
||||
{
|
||||
var desktopPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory),
|
||||
"ClaudeDo.lnk");
|
||||
CreateShortcut(desktopPath, appExe, workingDir, "ClaudeDo Task Manager");
|
||||
progress.Report($"Created Desktop shortcut: {desktopPath}");
|
||||
}
|
||||
|
||||
return Task.FromResult(StepResult.Ok());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(StepResult.Fail(ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private static void CreateShortcut(string shortcutPath, string targetPath, string workingDir, string description)
|
||||
{
|
||||
var link = (IShellLink)new ShellLink();
|
||||
link.SetPath(targetPath);
|
||||
link.SetWorkingDirectory(workingDir);
|
||||
link.SetDescription(description);
|
||||
link.SetIconLocation(targetPath, 0);
|
||||
|
||||
var file = (IPersistFile)link;
|
||||
file.Save(shortcutPath, false);
|
||||
}
|
||||
|
||||
#region COM Interop for IShellLink
|
||||
|
||||
[ComImport]
|
||||
[Guid("00021401-0000-0000-C000-000000000046")]
|
||||
private class ShellLink { }
|
||||
|
||||
[ComImport]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
[Guid("000214F9-0000-0000-C000-000000000046")]
|
||||
private interface IShellLink
|
||||
{
|
||||
void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cchMaxPath, IntPtr pfd, int fFlags);
|
||||
void GetIDList(out IntPtr ppidl);
|
||||
void SetIDList(IntPtr pidl);
|
||||
void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cchMaxName);
|
||||
void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName);
|
||||
void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cchMaxPath);
|
||||
void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir);
|
||||
void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cchMaxPath);
|
||||
void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs);
|
||||
void GetHotkey(out short pwHotkey);
|
||||
void SetHotkey(short wHotkey);
|
||||
void GetShowCmd(out int piShowCmd);
|
||||
void SetShowCmd(int iShowCmd);
|
||||
void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, int cchIconPath, out int piIcon);
|
||||
void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon);
|
||||
void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, int dwReserved);
|
||||
void Resolve(IntPtr hwnd, int fFlags);
|
||||
void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
62
src/ClaudeDo.Installer/Steps/DeployBinariesStep.cs
Normal file
62
src/ClaudeDo.Installer/Steps/DeployBinariesStep.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using System.IO;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class DeployBinariesStep : IInstallStep
|
||||
{
|
||||
public string Name => "Deploy Binaries";
|
||||
|
||||
public Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var appPublish = Path.Combine(ctx.SourceDirectory, "src", "ClaudeDo.App", "bin", "Release", "net8.0", "win-x64", "publish");
|
||||
var workerPublish = Path.Combine(ctx.SourceDirectory, "src", "ClaudeDo.Worker", "bin", "Release", "net8.0", "win-x64", "publish");
|
||||
|
||||
var appDest = Path.Combine(ctx.InstallDirectory, "app");
|
||||
var workerDest = Path.Combine(ctx.InstallDirectory, "worker");
|
||||
|
||||
if (!Directory.Exists(appPublish))
|
||||
return Task.FromResult(StepResult.Fail($"App publish directory not found: {appPublish}"));
|
||||
if (!Directory.Exists(workerPublish))
|
||||
return Task.FromResult(StepResult.Fail($"Worker publish directory not found: {workerPublish}"));
|
||||
|
||||
var appCount = CopyDirectory(appPublish, appDest, progress, ct);
|
||||
progress.Report($"Copied {appCount} files to {appDest}");
|
||||
|
||||
var workerCount = CopyDirectory(workerPublish, workerDest, progress, ct);
|
||||
progress.Report($"Copied {workerCount} files to {workerDest}");
|
||||
|
||||
return Task.FromResult(StepResult.Ok());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(StepResult.Fail(ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private static int CopyDirectory(string source, string dest, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
Directory.CreateDirectory(dest);
|
||||
var count = 0;
|
||||
|
||||
foreach (var dir in Directory.GetDirectories(source, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var relative = Path.GetRelativePath(source, dir);
|
||||
Directory.CreateDirectory(Path.Combine(dest, relative));
|
||||
}
|
||||
|
||||
foreach (var file in Directory.GetFiles(source, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var relative = Path.GetRelativePath(source, file);
|
||||
var destFile = Path.Combine(dest, relative);
|
||||
File.Copy(file, destFile, overwrite: true);
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
28
src/ClaudeDo.Installer/Steps/InitDatabaseStep.cs
Normal file
28
src/ClaudeDo.Installer/Steps/InitDatabaseStep.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class InitDatabaseStep : IInstallStep
|
||||
{
|
||||
public string Name => "Initialize Database";
|
||||
|
||||
public Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var expandedPath = Paths.Expand(ctx.DbPath);
|
||||
progress.Report($"Initializing database at {expandedPath}");
|
||||
|
||||
var factory = new SqliteConnectionFactory(expandedPath);
|
||||
SchemaInitializer.Apply(factory);
|
||||
|
||||
progress.Report("Schema applied successfully");
|
||||
return Task.FromResult(StepResult.Ok());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(StepResult.Fail(ex.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/ClaudeDo.Installer/Steps/PublishAppStep.cs
Normal file
20
src/ClaudeDo.Installer/Steps/PublishAppStep.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class PublishAppStep : IInstallStep
|
||||
{
|
||||
public string Name => "Publish ClaudeDo.App";
|
||||
|
||||
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
progress.Report("Publishing ClaudeDo.App...");
|
||||
|
||||
var args = "publish src/ClaudeDo.App/ClaudeDo.App.csproj -c Release -r win-x64 --self-contained false";
|
||||
var (exitCode, output) = await ProcessRunner.RunAsync("dotnet", args, ctx.SourceDirectory, progress, ct);
|
||||
|
||||
return exitCode == 0
|
||||
? StepResult.Ok()
|
||||
: StepResult.Fail($"dotnet publish failed with exit code {exitCode}");
|
||||
}
|
||||
}
|
||||
20
src/ClaudeDo.Installer/Steps/PublishWorkerStep.cs
Normal file
20
src/ClaudeDo.Installer/Steps/PublishWorkerStep.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class PublishWorkerStep : IInstallStep
|
||||
{
|
||||
public string Name => "Publish ClaudeDo.Worker";
|
||||
|
||||
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
progress.Report("Publishing ClaudeDo.Worker...");
|
||||
|
||||
var args = "publish src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release -r win-x64 --self-contained false";
|
||||
var (exitCode, output) = await ProcessRunner.RunAsync("dotnet", args, ctx.SourceDirectory, progress, ct);
|
||||
|
||||
return exitCode == 0
|
||||
? StepResult.Ok()
|
||||
: StepResult.Fail($"dotnet publish failed with exit code {exitCode}");
|
||||
}
|
||||
}
|
||||
68
src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs
Normal file
68
src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
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);
|
||||
|
||||
// Create service
|
||||
var startType = ctx.AutoStart ? "auto" : "demand";
|
||||
var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}";
|
||||
|
||||
if (ctx.ServiceAccount == "CurrentUser")
|
||||
{
|
||||
var username = Environment.UserName;
|
||||
createArgs += $" obj= \".\\{username}\"";
|
||||
}
|
||||
|
||||
progress.Report("Creating service...");
|
||||
var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct);
|
||||
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})");
|
||||
|
||||
// Start service if auto-start
|
||||
if (ctx.AutoStart)
|
||||
{
|
||||
progress.Report("Starting service...");
|
||||
var (startExit, _) = await RunSc($"start {ServiceName}", ctx, progress, ct);
|
||||
if (startExit != 0)
|
||||
progress.Report("Warning: service created but failed to start. You may need to start it manually.");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
42
src/ClaudeDo.Installer/Steps/WriteConfigStep.cs
Normal file
42
src/ClaudeDo.Installer/Steps/WriteConfigStep.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class WriteConfigStep : IInstallStep
|
||||
{
|
||||
public string Name => "Write Configuration";
|
||||
|
||||
public Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var workerCfg = new InstallerWorkerConfig
|
||||
{
|
||||
DbPath = ctx.DbPath,
|
||||
SandboxRoot = ctx.SandboxRoot,
|
||||
LogRoot = ctx.LogRoot,
|
||||
WorktreeRootStrategy = ctx.WorktreeRootStrategy,
|
||||
CentralWorktreeRoot = ctx.CentralWorktreeRoot,
|
||||
QueueBackstopIntervalMs = ctx.QueueBackstopIntervalMs,
|
||||
SignalRPort = ctx.SignalRPort,
|
||||
ClaudeBin = ctx.ClaudeBin,
|
||||
};
|
||||
workerCfg.Save();
|
||||
progress.Report("Written worker.config.json");
|
||||
|
||||
var uiCfg = new InstallerAppSettings
|
||||
{
|
||||
DbPath = ctx.UiDbPath,
|
||||
SignalRUrl = ctx.SignalRUrl,
|
||||
};
|
||||
uiCfg.Save();
|
||||
progress.Report("Written ui.config.json");
|
||||
|
||||
return Task.FromResult(StepResult.Ok());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(StepResult.Fail(ex.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user