From 0ecad1ca86cb457754607bea1f1290fa9f443a91 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Sat, 14 Mar 2026 19:20:28 -0400 Subject: [PATCH 1/2] fix: improve update badge contrast and readability White text on green background instead of --bg-primary (near-black), slightly larger font and padding, nowrap to prevent text wrapping. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/index.html | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index b168710a..99c686f7 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -1002,10 +1002,11 @@ /* ── Update Badge ── */ .update-badge { - display: inline-block; margin-left: 8px; padding: 2px 8px; - font-size: 0.65rem; font-weight: 600; border-radius: 10px; - background: var(--accent-green); color: var(--bg-primary); + display: inline-block; margin-left: 8px; padding: 3px 10px; + font-size: 0.7rem; font-weight: 600; border-radius: 10px; + background: var(--accent-green); color: #fff; cursor: pointer; animation: badgePulse 2s ease-in-out infinite; + white-space: nowrap; } .update-badge:hover { opacity: 0.85; } .update-badge.updating { background: var(--text-dim); cursor: wait; animation: none; } From da0d41c29115eb3a19743e1e346bab9619d25997 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Sat, 14 Mar 2026 19:27:14 -0400 Subject: [PATCH 2/2] fix: update badge visibility and daemon restart logic - Fix badge text invisible due to nav-brand's -webkit-text-fill-color: transparent cascading to children. Reset text-fill-color and background-clip on the badge element. - Fix badge font-size overridden by .nav-brand span rule (1.3rem). Add specific .nav-brand .update-badge override. - Fix daemon self-restart: Stop()+Start() killed the process before Start() could run. Add Restart() to ServiceManager interface that uses non-blocking exec (systemctl restart / launchctl stop+start / net stop+start) so the command outlives the dying process. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/api/routes/update.go | 23 ++++++++++------------- internal/daemon/service.go | 6 ++++++ internal/daemon/service_darwin.go | 7 +++++++ internal/daemon/service_linux.go | 4 ++++ internal/daemon/service_other.go | 4 ++++ internal/daemon/service_windows.go | 5 +++++ internal/web/static/index.html | 8 +++++--- 7 files changed, 41 insertions(+), 16 deletions(-) diff --git a/internal/api/routes/update.go b/internal/api/routes/update.go index e28e869d..2c450734 100644 --- a/internal/api/routes/update.go +++ b/internal/api/routes/update.go @@ -26,13 +26,13 @@ type UpdateResponse struct { Message string `json:"message,omitempty"` } -// ServiceRestarter can stop and start the daemon service. -// If nil is passed to HandleUpdate, the handler will still perform the update -// but cannot restart the daemon automatically. +// ServiceRestarter can restart the daemon service after an update. +// Restart must be safe to call from within the running daemon — it should +// spawn the restart asynchronously (e.g. via systemctl restart) so the +// current process can finish responding before being killed. type ServiceRestarter interface { IsInstalled() bool - Stop() error - Start() error + Restart() error } // HandleUpdateCheck returns an HTTP handler that checks for available updates @@ -115,18 +115,15 @@ func HandleUpdate(version string, svc ServiceRestarter, log *slog.Logger) http.H // Send response before restarting writeJSON(w, http.StatusOK, resp) - // Restart the daemon in the background if possible + // Restart the daemon in the background if possible. + // Restart() must be non-blocking (e.g. spawns systemctl restart) + // so the HTTP response has time to flush before the process dies. if canRestart { go func() { time.Sleep(500 * time.Millisecond) log.Info("restarting daemon after update") - if err := svc.Stop(); err != nil { - log.Error("failed to stop daemon for restart", "error", err) - return - } - time.Sleep(1 * time.Second) - if err := svc.Start(); err != nil { - log.Error("failed to start daemon after update", "error", err) + if err := svc.Restart(); err != nil { + log.Error("failed to restart daemon after update", "error", err) } }() } diff --git a/internal/daemon/service.go b/internal/daemon/service.go index 99564e1d..03a895e2 100644 --- a/internal/daemon/service.go +++ b/internal/daemon/service.go @@ -20,6 +20,12 @@ type ServiceManager interface { // Stop stops the service via the platform service manager. Stop() error + // Restart restarts the service via the platform service manager. + // It must be safe to call from within the running daemon — the restart + // is handled externally by the service manager (e.g. systemctl restart) + // so the caller's process can exit cleanly. + Restart() error + // ServiceName returns a human-readable name for the service backend (e.g. "launchd", "systemd"). ServiceName() string } diff --git a/internal/daemon/service_darwin.go b/internal/daemon/service_darwin.go index 96d8bc5c..7db7e3b3 100644 --- a/internal/daemon/service_darwin.go +++ b/internal/daemon/service_darwin.go @@ -163,6 +163,13 @@ func (m *launchdManager) Stop() error { return exec.Command("launchctl", "stop", serviceLabel).Run() } +func (m *launchdManager) Restart() error { + // launchctl has no native restart; spawn a background shell to stop+start. + // Start() (not Run) so the command outlives the current process. + return exec.Command("sh", "-c", + "launchctl stop "+serviceLabel+" && sleep 1 && launchctl start "+serviceLabel).Start() +} + func (m *launchdManager) ServiceName() string { return "launchd" } diff --git a/internal/daemon/service_linux.go b/internal/daemon/service_linux.go index d31f6077..28f8e669 100644 --- a/internal/daemon/service_linux.go +++ b/internal/daemon/service_linux.go @@ -166,6 +166,10 @@ func (m *systemdManager) Stop() error { return exec.Command("systemctl", "--user", "stop", serviceName).Run() } +func (m *systemdManager) Restart() error { + return exec.Command("systemctl", "--user", "restart", serviceName).Start() +} + func (m *systemdManager) ServiceName() string { return "systemd" } diff --git a/internal/daemon/service_other.go b/internal/daemon/service_other.go index 7f095fc6..4d6f0ec4 100644 --- a/internal/daemon/service_other.go +++ b/internal/daemon/service_other.go @@ -30,3 +30,7 @@ func (m *stubManager) Start() error { func (m *stubManager) Stop() error { return fmt.Errorf("service management is not supported on this platform") } + +func (m *stubManager) Restart() error { + return fmt.Errorf("service management is not supported on this platform") +} diff --git a/internal/daemon/service_windows.go b/internal/daemon/service_windows.go index 5c54cb05..51b021dd 100644 --- a/internal/daemon/service_windows.go +++ b/internal/daemon/service_windows.go @@ -170,6 +170,11 @@ func (m *windowsServiceManager) Stop() error { return nil } +func (m *windowsServiceManager) Restart() error { + // Use sc.exe to restart — spawned as a background process so it outlives the current binary. + return exec.Command("cmd", "/C", "net stop "+winServiceName+" && net start "+winServiceName).Start() +} + func (m *windowsServiceManager) ServiceName() string { return "windows-service" } diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 99c686f7..fe22b8c3 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -164,6 +164,7 @@ letter-spacing: -0.3px; } .nav-brand span { font-size: 1.3rem; margin-right: 4px; } + .nav-brand .update-badge { font-size: 0.7rem; margin-right: 0; } .nav-tabs { display: flex; gap: 2px; } .nav-tab { padding: 8px 16px; border-radius: var(--radius-sm); @@ -1002,9 +1003,10 @@ /* ── Update Badge ── */ .update-badge { - display: inline-block; margin-left: 8px; padding: 3px 10px; - font-size: 0.7rem; font-weight: 600; border-radius: 10px; - background: var(--accent-green); color: #fff; + display: inline-block; margin-left: 8px; padding: 4px 12px; + font-size: 0.75rem; font-weight: 700; border-radius: 10px; letter-spacing: 0.02em; + background: var(--accent-green); color: #000; + -webkit-text-fill-color: #000; -webkit-background-clip: border-box; background-clip: border-box; cursor: pointer; animation: badgePulse 2s ease-in-out infinite; white-space: nowrap; }