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() {