fix(installer): skip rewriting the autostart shortcut when already current

RegisterAutostartStep rewrote the Startup .lnk on every install/update/repair
even when it already pointed at the right worker exe. AutostartShortcut.Install
now reads the existing shortcut's target via ShortcutFactory.TryGetTarget and
skips the rewrite when it matches, reporting the skip in progress output.
Legacy service/scheduled-task cleanup stays unconditional (migration safety net).
This commit is contained in:
mika kuns
2026-08-06 11:08:15 +02:00
parent cfd2936c25
commit 445242cd7d
5 changed files with 90 additions and 6 deletions
+1 -1
View File
@@ -75,7 +75,7 @@ claude mcp add --transport http --scope user claudedo http://127.0.0.1:{External
``` ```
Non-fatal if `claude` CLI is missing or too old (prints the manual command). Server name: `claudedo`. Non-fatal if `claude` CLI is missing or too old (prints the manual command). Server name: `claudedo`.
**`RegisterAutostartStep`** — creates a per-user Startup-folder shortcut `ClaudeDo Worker.lnk` (`Environment.SpecialFolder.Startup`). Also migrates away from legacy mechanisms: **`RegisterAutostartStep`** — creates a per-user Startup-folder shortcut `ClaudeDo Worker.lnk` (`Environment.SpecialFolder.Startup`); `AutostartShortcut.Install` skips the rewrite (and reports it) when the shortcut already points at the current worker exe, so update/repair runs don't touch it needlessly. Also migrates away from legacy mechanisms, unconditionally on every run (no cached "already migrated" flag — see the comment in the step):
- Deletes legacy Windows service: `sc.exe stop/delete ClaudeDoWorker` - Deletes legacy Windows service: `sc.exe stop/delete ClaudeDoWorker`
- Deletes legacy scheduled task: `schtasks /Delete /TN ClaudeDoWorker` - Deletes legacy scheduled task: `schtasks /Delete /TN ClaudeDoWorker`
@@ -11,13 +11,26 @@ public static class AutostartShortcut
public static string PathIn(string startupDir) => Path.Combine(startupDir, FileName); public static string PathIn(string startupDir) => Path.Combine(startupDir, FileName);
public static void Install(string startupDir, string workerExe) /// <summary>Creates or updates the Startup shortcut. Returns false if it already pointed at <paramref name="workerExe"/> and was left untouched.</summary>
public static bool Install(string startupDir, string workerExe)
{ {
Directory.CreateDirectory(startupDir); Directory.CreateDirectory(startupDir);
var shortcutPath = PathIn(startupDir);
if (File.Exists(shortcutPath))
{
var existingTarget = ShortcutFactory.TryGetTarget(shortcutPath);
if (existingTarget is not null && PathsEqual(existingTarget, workerExe))
return false;
}
var workingDir = Path.GetDirectoryName(workerExe) ?? startupDir; var workingDir = Path.GetDirectoryName(workerExe) ?? startupDir;
ShortcutFactory.CreateShortcut(PathIn(startupDir), workerExe, workingDir, "ClaudeDo background worker"); ShortcutFactory.CreateShortcut(shortcutPath, workerExe, workingDir, "ClaudeDo background worker");
return true;
} }
private static bool PathsEqual(string a, string b) =>
string.Equals(Path.GetFullPath(a), Path.GetFullPath(b), StringComparison.OrdinalIgnoreCase);
public static void Remove(string startupDir) public static void Remove(string startupDir)
{ {
var path = PathIn(startupDir); var path = PathIn(startupDir);
@@ -6,6 +6,8 @@ namespace ClaudeDo.Installer.Core;
public static class ShortcutFactory public static class ShortcutFactory
{ {
private const int SlgpRawPath = 0x4;
public static void CreateShortcut(string shortcutPath, string targetPath, string workingDir, string description) public static void CreateShortcut(string shortcutPath, string targetPath, string workingDir, string description)
{ {
var link = (IShellLink)new ShellLink(); var link = (IShellLink)new ShellLink();
@@ -18,6 +20,24 @@ public static class ShortcutFactory
file.Save(shortcutPath, false); file.Save(shortcutPath, false);
} }
/// <summary>Reads the target path of an existing .lnk, or null if it can't be read (missing/corrupt).</summary>
public static string? TryGetTarget(string shortcutPath)
{
try
{
var link = (IShellLink)new ShellLink();
((IPersistFile)link).Load(shortcutPath, 0);
var sb = new StringBuilder(260);
link.GetPath(sb, sb.Capacity, IntPtr.Zero, SlgpRawPath);
var path = sb.ToString();
return path.Length == 0 ? null : path;
}
catch (Exception)
{
return null;
}
}
[ComImport] [ComImport]
[Guid("00021401-0000-0000-C000-000000000046")] [Guid("00021401-0000-0000-C000-000000000046")]
private class ShellLink { } private class ShellLink { }
@@ -16,6 +16,11 @@ public sealed class RegisterAutostartStep : IInstallStep
if (!File.Exists(workerExe)) if (!File.Exists(workerExe))
return StepResult.Fail($"Worker executable not found: {workerExe}"); return StepResult.Fail($"Worker executable not found: {workerExe}");
// Legacy service/task cleanup below runs unconditionally on every install/update/repair,
// even though it's a no-op once migrated. A cached "already migrated" flag could go stale
// (e.g. a user re-adds the legacy service) and strand them with it still running; two
// extra process starts per run is the price for that migration safety net.
// 1) Migrate away the legacy Windows service if present. // 1) Migrate away the legacy Windows service if present.
progress.Report("Checking for legacy worker service..."); progress.Report("Checking for legacy worker service...");
var (queryExit, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct); var (queryExit, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct);
@@ -38,10 +43,11 @@ public sealed class RegisterAutostartStep : IInstallStep
await ProcessRunner.RunAsync("schtasks.exe", $"/Delete /TN \"{LegacyTaskName}\" /F", null, progress, ct); await ProcessRunner.RunAsync("schtasks.exe", $"/Delete /TN \"{LegacyTaskName}\" /F", null, progress, ct);
// 3) Register per-user autostart via a Startup-folder shortcut. // 3) Register per-user autostart via a Startup-folder shortcut.
progress.Report("Creating Startup shortcut..."); progress.Report("Checking Startup shortcut...");
try try
{ {
AutostartShortcut.Install(AutostartShortcut.DefaultStartupDir, workerExe); var created = AutostartShortcut.Install(AutostartShortcut.DefaultStartupDir, workerExe);
progress.Report(created ? "Startup shortcut created." : "Startup shortcut already up to date.");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -22,13 +22,58 @@ public class AutostartShortcutTests
var workerExe = Path.Combine(workerDir, "ClaudeDo.Worker.exe"); var workerExe = Path.Combine(workerDir, "ClaudeDo.Worker.exe");
File.WriteAllText(workerExe, ""); File.WriteAllText(workerExe, "");
AutostartShortcut.Install(startup, workerExe); var created = AutostartShortcut.Install(startup, workerExe);
Assert.True(created);
Assert.True(File.Exists(Path.Combine(startup, AutostartShortcut.FileName))); Assert.True(File.Exists(Path.Combine(startup, AutostartShortcut.FileName)));
} }
finally { Directory.Delete(startup, true); Directory.Delete(workerDir, true); } finally { Directory.Delete(startup, true); Directory.Delete(workerDir, true); }
} }
[Fact]
public void Install_is_noop_when_shortcut_already_targets_same_exe()
{
var startup = TempDir();
var workerDir = TempDir();
try
{
var workerExe = Path.Combine(workerDir, "ClaudeDo.Worker.exe");
File.WriteAllText(workerExe, "");
AutostartShortcut.Install(startup, workerExe);
var writtenAt = File.GetLastWriteTimeUtc(Path.Combine(startup, AutostartShortcut.FileName));
var createdAgain = AutostartShortcut.Install(startup, workerExe);
Assert.False(createdAgain);
Assert.Equal(writtenAt, File.GetLastWriteTimeUtc(Path.Combine(startup, AutostartShortcut.FileName)));
}
finally { Directory.Delete(startup, true); Directory.Delete(workerDir, true); }
}
[Fact]
public void Install_rewrites_shortcut_when_target_changed()
{
var startup = TempDir();
var workerDir = TempDir();
try
{
var oldExe = Path.Combine(workerDir, "old", "ClaudeDo.Worker.exe");
Directory.CreateDirectory(Path.GetDirectoryName(oldExe)!);
File.WriteAllText(oldExe, "");
AutostartShortcut.Install(startup, oldExe);
var newExe = Path.Combine(workerDir, "new", "ClaudeDo.Worker.exe");
Directory.CreateDirectory(Path.GetDirectoryName(newExe)!);
File.WriteAllText(newExe, "");
var created = AutostartShortcut.Install(startup, newExe);
Assert.True(created);
Assert.Equal(newExe, ShortcutFactory.TryGetTarget(Path.Combine(startup, AutostartShortcut.FileName)));
}
finally { Directory.Delete(startup, true); Directory.Delete(workerDir, true); }
}
[Fact] [Fact]
public void Remove_deletes_existing_lnk() public void Remove_deletes_existing_lnk()
{ {