From 445242cd7d9a548bca6efc3f01ffd1fa81a0d8fb Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 6 Aug 2026 11:08:15 +0200 Subject: [PATCH] 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). --- src/ClaudeDo.Installer/CLAUDE.md | 2 +- .../Core/AutostartShortcut.cs | 17 ++++++- .../Core/ShortcutFactory.cs | 20 ++++++++ .../Steps/RegisterAutostartStep.cs | 10 +++- .../AutostartShortcutTests.cs | 47 ++++++++++++++++++- 5 files changed, 90 insertions(+), 6 deletions(-) diff --git a/src/ClaudeDo.Installer/CLAUDE.md b/src/ClaudeDo.Installer/CLAUDE.md index 9a02c039..3e0d96e6 100644 --- a/src/ClaudeDo.Installer/CLAUDE.md +++ b/src/ClaudeDo.Installer/CLAUDE.md @@ -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`. -**`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 scheduled task: `schtasks /Delete /TN ClaudeDoWorker` diff --git a/src/ClaudeDo.Installer/Core/AutostartShortcut.cs b/src/ClaudeDo.Installer/Core/AutostartShortcut.cs index f417b16e..2833df2d 100644 --- a/src/ClaudeDo.Installer/Core/AutostartShortcut.cs +++ b/src/ClaudeDo.Installer/Core/AutostartShortcut.cs @@ -11,13 +11,26 @@ public static class AutostartShortcut public static string PathIn(string startupDir) => Path.Combine(startupDir, FileName); - public static void Install(string startupDir, string workerExe) + /// Creates or updates the Startup shortcut. Returns false if it already pointed at and was left untouched. + public static bool Install(string startupDir, string workerExe) { 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; - 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) { var path = PathIn(startupDir); diff --git a/src/ClaudeDo.Installer/Core/ShortcutFactory.cs b/src/ClaudeDo.Installer/Core/ShortcutFactory.cs index dedfde39..5d53eb06 100644 --- a/src/ClaudeDo.Installer/Core/ShortcutFactory.cs +++ b/src/ClaudeDo.Installer/Core/ShortcutFactory.cs @@ -6,6 +6,8 @@ namespace ClaudeDo.Installer.Core; public static class ShortcutFactory { + private const int SlgpRawPath = 0x4; + public static void CreateShortcut(string shortcutPath, string targetPath, string workingDir, string description) { var link = (IShellLink)new ShellLink(); @@ -18,6 +20,24 @@ public static class ShortcutFactory file.Save(shortcutPath, false); } + /// Reads the target path of an existing .lnk, or null if it can't be read (missing/corrupt). + 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] [Guid("00021401-0000-0000-C000-000000000046")] private class ShellLink { } diff --git a/src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs b/src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs index 359d9412..fe64bef5 100644 --- a/src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs +++ b/src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs @@ -16,6 +16,11 @@ public sealed class RegisterAutostartStep : IInstallStep if (!File.Exists(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. progress.Report("Checking for legacy worker service..."); 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); // 3) Register per-user autostart via a Startup-folder shortcut. - progress.Report("Creating Startup shortcut..."); + progress.Report("Checking Startup shortcut..."); 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) { diff --git a/tests/ClaudeDo.Installer.Tests/AutostartShortcutTests.cs b/tests/ClaudeDo.Installer.Tests/AutostartShortcutTests.cs index bde032a7..4e6dfa43 100644 --- a/tests/ClaudeDo.Installer.Tests/AutostartShortcutTests.cs +++ b/tests/ClaudeDo.Installer.Tests/AutostartShortcutTests.cs @@ -22,13 +22,58 @@ public class AutostartShortcutTests var workerExe = Path.Combine(workerDir, "ClaudeDo.Worker.exe"); 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))); } 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] public void Remove_deletes_existing_lnk() {