Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 10 additions & 13 deletions internal/api/routes/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}()
}
Expand Down
6 changes: 6 additions & 0 deletions internal/daemon/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
7 changes: 7 additions & 0 deletions internal/daemon/service_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
4 changes: 4 additions & 0 deletions internal/daemon/service_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
4 changes: 4 additions & 0 deletions internal/daemon/service_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
5 changes: 5 additions & 0 deletions internal/daemon/service_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
9 changes: 6 additions & 3 deletions internal/web/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -1002,10 +1003,12 @@

/* ── 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: 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;
}
.update-badge:hover { opacity: 0.85; }
.update-badge.updating { background: var(--text-dim); cursor: wait; animation: none; }
Expand Down
Loading