diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index d8a8535766..a6c13e934c 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -29,6 +29,7 @@ import ( // Event bus "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" + ws "github.com/kandev/kandev/pkg/websocket" // GitHub integration azuredevopspkg "github.com/kandev/kandev/internal/azuredevops" @@ -781,6 +782,19 @@ func startGatewayAndServe( gateway.Hub.SetSystemMetricsInterestTracker(systemSvc.Metrics) systemSvc.Metrics.SetExecutionProvider(lifecycleMetricProvider{manager: lifecycleMgr}) } + if systemSvc.Updates != nil { + systemSvc.Updates.SetBroadcaster(func(msg *ws.Message) bool { + // GetClientCount is checked first so a truly empty hub never + // enqueues onto the broadcast channel; between this check and + // the send a client could still disconnect, so this reports + // eligibility, not a delivery guarantee. + if gateway.Hub.GetClientCount() == 0 { + return false + } + gateway.Hub.Broadcast(msg) + return true + }) + } systemSvc.StartBackground(ctx) addCleanup(func() error { systemSvc.StopBackground(); return nil }) gateways.RegisterSystemNotifications(ctx, eventBus, gateway.Hub, log) diff --git a/apps/backend/internal/persistence/meta.go b/apps/backend/internal/persistence/meta.go index 5089d63611..03b11ed341 100644 --- a/apps/backend/internal/persistence/meta.go +++ b/apps/backend/internal/persistence/meta.go @@ -14,6 +14,7 @@ const ( metaKeyLatestVersion = "latest_version" metaKeyLatestVersionURL = "latest_version_url" metaKeyLatestVersionCheckedAt = "latest_version_checked_at" + metaKeyNotifiedUpdateVersion = "notified_update_version" ) const metaTableDDL = ` @@ -110,6 +111,21 @@ func ReadLatestVersion(db *sqlx.DB) (string, string, time.Time, error) { return version, url, checkedAt, nil } +// WriteNotifiedUpdateVersion records the release tag that the update +// notification was last sent for. This is independent of +// latest_version/ReadLatestVersion so a user who dismisses/misses a +// notification isn't re-notified for the same release on every subsequent +// poll tick or backend restart — only when a *newer* tag appears. +func WriteNotifiedUpdateVersion(db *sqlx.DB, version string) error { + return writeKey(db, metaKeyNotifiedUpdateVersion, version) +} + +// ReadNotifiedUpdateVersion returns the release tag last notified about, or +// "" when no update notification has been sent yet. +func ReadNotifiedUpdateVersion(db *sqlx.DB) (string, error) { + return readKey(db, metaKeyNotifiedUpdateVersion) +} + // hasUserTables returns true when the DB contains at least one table that is // not part of the SQLite internal schema and not kandev_meta itself. This // distinguishes a genuinely fresh DB from a pre-meta DB being upgraded. diff --git a/apps/backend/internal/persistence/meta_test.go b/apps/backend/internal/persistence/meta_test.go index b56f428e66..e1f5e76db5 100644 --- a/apps/backend/internal/persistence/meta_test.go +++ b/apps/backend/internal/persistence/meta_test.go @@ -144,6 +144,48 @@ func TestWriteAndReadLatestVersion_RoundtripAndUpsert(t *testing.T) { } } +// TestWriteAndReadNotifiedUpdateVersion_RoundtripAndUpsert verifies the +// separate "last version we notified the user about" key persists +// independently of the polled latest_version, and upserts cleanly across +// restarts (backed by the same DB row) so re-notification is deduped per +// version rather than per process lifetime. +func TestWriteAndReadNotifiedUpdateVersion_RoundtripAndUpsert(t *testing.T) { + db := memSQLiteDB(t) + if err := ensureMetaTable(db); err != nil { + t.Fatalf("ensureMetaTable: %v", err) + } + + v, err := ReadNotifiedUpdateVersion(db) + if err != nil { + t.Fatalf("ReadNotifiedUpdateVersion fresh: %v", err) + } + if v != "" { + t.Errorf("expected empty value on fresh DB, got %q", v) + } + + if err := WriteNotifiedUpdateVersion(db, "v1.2.3"); err != nil { + t.Fatalf("WriteNotifiedUpdateVersion: %v", err) + } + v, err = ReadNotifiedUpdateVersion(db) + if err != nil { + t.Fatalf("ReadNotifiedUpdateVersion after write: %v", err) + } + if v != "v1.2.3" { + t.Errorf("version = %q, want %q", v, "v1.2.3") + } + + if err := WriteNotifiedUpdateVersion(db, "v1.2.4"); err != nil { + t.Fatalf("WriteNotifiedUpdateVersion overwrite: %v", err) + } + v, err = ReadNotifiedUpdateVersion(db) + if err != nil { + t.Fatalf("ReadNotifiedUpdateVersion after overwrite: %v", err) + } + if v != "v1.2.4" { + t.Errorf("version after overwrite = %q, want %q", v, "v1.2.4") + } +} + // TestReadLatestVersion_PartialPersistedState verifies the read tolerates // only a subset of the three keys being present (which can happen if a // previous write was interrupted). diff --git a/apps/backend/internal/persistence/postgres_meta_test.go b/apps/backend/internal/persistence/postgres_meta_test.go index 926dfe4d17..a533fb2c8d 100644 --- a/apps/backend/internal/persistence/postgres_meta_test.go +++ b/apps/backend/internal/persistence/postgres_meta_test.go @@ -26,3 +26,22 @@ func TestPostgresLatestVersionMetaRoundTrip(t *testing.T) { t.Fatalf("meta round-trip = (%q, %q, %s)", version, url, gotCheckedAt) } } + +func TestPostgresNotifiedUpdateVersionMetaRoundTrip(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + + if err := ensureMetaTable(db); err != nil { + t.Fatalf("ensure meta table: %v", err) + } + + if err := WriteNotifiedUpdateVersion(db, "v1.2.3"); err != nil { + t.Fatalf("write notified update version: %v", err) + } + version, err := ReadNotifiedUpdateVersion(db) + if err != nil { + t.Fatalf("read notified update version: %v", err) + } + if version != "v1.2.3" { + t.Fatalf("notified update version round-trip = %q", version) + } +} diff --git a/apps/backend/internal/system/system.go b/apps/backend/internal/system/system.go index 251fabf6f2..e3031f4ea1 100644 --- a/apps/backend/internal/system/system.go +++ b/apps/backend/internal/system/system.go @@ -96,9 +96,11 @@ func Provide(cfg *config.Config, log *logger.Logger, pool *db.Pool, eventBus bus log.Error("Failed to initialize system settings store", zap.Error(err)) } var metricsSvc *metrics.Service + updatesOpts := []updates.Option{updates.WithHomeDir(homeDir), updates.WithJobs(tracker)} if settingsStore != nil { metricsStore := metrics.NewStore(settingsStore) metricsSvc = metrics.NewService(metricsStore, metrics.NewCollector()) + updatesOpts = append(updatesOpts, updates.WithNotifyStore(updates.NewNotifyStore(settingsStore))) } return &Service{ @@ -109,7 +111,7 @@ func Provide(cfg *config.Config, log *logger.Logger, pool *db.Pool, eventBus bus Backups: backupsSvc, Logs: logs.NewService(logDir, logFile, log), Metrics: metricsSvc, - Updates: updates.NewService(pool, build.Version, nil, log, updates.WithHomeDir(homeDir), updates.WithJobs(tracker)), + Updates: updates.NewService(pool, build.Version, nil, log, updatesOpts...), Restart: restart.NewManagerFromEnv(), } } @@ -147,6 +149,8 @@ func (s *Service) RegisterRoutes(router *gin.Engine, log *logger.Logger) { g.GET("/updates", updates.HandleGet(s.Updates)) g.POST("/updates/check", updates.HandleCheck(s.Updates)) g.POST("/updates/apply", updates.HandleApply(s.Updates)) + g.GET("/updates/notification-settings", updates.HandleGetNotifySettings(s.Updates)) + g.PUT("/updates/notification-settings", updates.HandleSaveNotifySettings(s.Updates)) g.GET("/restart-capability", restart.HandleCapability(s.Restart)) g.POST("/restart", restart.HandleRequest(s.Restart)) diff --git a/apps/backend/internal/system/updates/handler.go b/apps/backend/internal/system/updates/handler.go index 4d3325875f..92c9fe9ee1 100644 --- a/apps/backend/internal/system/updates/handler.go +++ b/apps/backend/internal/system/updates/handler.go @@ -80,3 +80,38 @@ func HandleApply(svc *Service) gin.HandlerFunc { c.JSON(http.StatusAccepted, ApplyResponse{JobID: jobID}) } } + +// HandleGetNotifySettings returns the persisted update-notification +// preferences (enable/disable + desktop/in_view/both channel). +func HandleGetNotifySettings(svc *Service) gin.HandlerFunc { + return func(c *gin.Context) { + settings, err := svc.GetNotifySettings(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, settings) + } +} + +// HandleSaveNotifySettings validates and persists the update-notification +// preferences. An unknown channel or malformed body is a 400. +func HandleSaveNotifySettings(svc *Service) gin.HandlerFunc { + return func(c *gin.Context) { + var body NotifySettings + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + saved, err := svc.SaveNotifySettings(c.Request.Context(), body) + if errors.Is(err, ErrNotifyStoreUnavailable) { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, saved) + } +} diff --git a/apps/backend/internal/system/updates/handler_notify_test.go b/apps/backend/internal/system/updates/handler_notify_test.go new file mode 100644 index 0000000000..9dc864c4ee --- /dev/null +++ b/apps/backend/internal/system/updates/handler_notify_test.go @@ -0,0 +1,127 @@ +package updates + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/common/logger" +) + +func newNotifyRouter(svc *Service) *gin.Engine { + r := gin.New() + api := r.Group("/api/v1/system") + api.GET("/updates/notification-settings", HandleGetNotifySettings(svc)) + api.PUT("/updates/notification-settings", HandleSaveNotifySettings(svc)) + return r +} + +func newServiceWithNotifyStore(t *testing.T) *Service { + t.Helper() + pool := newTestPool(t) + notifyStore := newTestNotifyStore(t) + return NewService(pool, "v1.0.0", nil, logger.Default(), WithNotifyStore(notifyStore)) +} + +func TestHandleGetNotifySettings_ReturnsDefaults(t *testing.T) { + svc := newServiceWithNotifyStore(t) + r := newNotifyRouter(svc) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/system/updates/notification-settings", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + var got NotifySettings + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got != DefaultNotifySettings() { + t.Errorf("got %+v, want default %+v", got, DefaultNotifySettings()) + } +} + +func TestHandleSaveNotifySettings_PersistsAndReturnsNormalized(t *testing.T) { + svc := newServiceWithNotifyStore(t) + r := newNotifyRouter(svc) + + body, _ := json.Marshal(NotifySettings{Enabled: false, Channel: NotifyChannelInView}) + req := httptest.NewRequest(http.MethodPut, "/api/v1/system/updates/notification-settings", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + var got NotifySettings + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Enabled || got.Channel != NotifyChannelInView { + t.Errorf("got %+v", got) + } + + // Persisted: a fresh GET reflects the save. + req2 := httptest.NewRequest(http.MethodGet, "/api/v1/system/updates/notification-settings", nil) + w2 := httptest.NewRecorder() + r.ServeHTTP(w2, req2) + var got2 NotifySettings + if err := json.Unmarshal(w2.Body.Bytes(), &got2); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got2 != got { + t.Errorf("GET after save = %+v, want %+v", got2, got) + } +} + +func TestHandleSaveNotifySettings_InvalidChannelReturns400(t *testing.T) { + svc := newServiceWithNotifyStore(t) + r := newNotifyRouter(svc) + + body, _ := json.Marshal(map[string]string{"channel": "smoke-signal"}) + req := httptest.NewRequest(http.MethodPut, "/api/v1/system/updates/notification-settings", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } +} + +func TestHandleSaveNotifySettings_MalformedJSONReturns400(t *testing.T) { + svc := newServiceWithNotifyStore(t) + r := newNotifyRouter(svc) + + req := httptest.NewRequest(http.MethodPut, "/api/v1/system/updates/notification-settings", bytes.NewReader([]byte("{not json"))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } +} + +func TestHandleSaveNotifySettings_NoNotifyStoreReturns500(t *testing.T) { + pool := newTestPool(t) + svc := NewService(pool, "v1.0.0", nil, logger.Default()) // no WithNotifyStore + r := newNotifyRouter(svc) + + body, _ := json.Marshal(NotifySettings{Enabled: true, Channel: NotifyChannelBoth}) + req := httptest.NewRequest(http.MethodPut, "/api/v1/system/updates/notification-settings", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } +} diff --git a/apps/backend/internal/system/updates/notify_dispatch_test.go b/apps/backend/internal/system/updates/notify_dispatch_test.go new file mode 100644 index 0000000000..1ed3c838c9 --- /dev/null +++ b/apps/backend/internal/system/updates/notify_dispatch_test.go @@ -0,0 +1,169 @@ +package updates + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/persistence" + ws "github.com/kandev/kandev/pkg/websocket" +) + +// capturingBroadcaster records every message handed to it so tests can +// assert whether (and what) the service broadcast, and can simulate "no +// client connected" by setting eligible=false. +type capturingBroadcaster struct { + messages []*ws.Message + eligible bool +} + +func (c *capturingBroadcaster) broadcast(msg *ws.Message) bool { + c.messages = append(c.messages, msg) + return c.eligible +} + +// newNotifyTestService wires a Service against a stub GitHub release server +// plus a fresh NotifyStore (defaults: enabled, both channels) and a +// capturing broadcaster, for tests that assert on the notify-on-new-update +// side effect of Check()/fetchAndPersist. +func newNotifyTestService(t *testing.T, tag, url string) (svc *Service, bc *capturingBroadcaster, notifyStore *NotifyStore) { + t.Helper() + pool := newTestPool(t) + srv, _ := newStubGitHub(t, tag, url) + notifyStore = newTestNotifyStore(t) + svc = NewService(pool, "v1.0.0", srv.Client(), logger.Default(), WithNotifyStore(notifyStore)) + svc.SetReleaseURL(srv.URL) + bc = &capturingBroadcaster{eligible: true} + svc.SetBroadcaster(bc.broadcast) + return svc, bc, notifyStore +} + +func TestService_Check_BroadcastsUpdateAvailable_WhenEnabled(t *testing.T) { + svc, bc, _ := newNotifyTestService(t, "v1.0.1", "https://example/v1.0.1") + + if _, err := svc.Check(context.Background()); err != nil { + t.Fatalf("Check: %v", err) + } + + if len(bc.messages) != 1 { + t.Fatalf("expected 1 broadcast, got %d", len(bc.messages)) + } + if bc.messages[0].Action != ws.ActionUpdateAvailable { + t.Errorf("action = %q, want %q", bc.messages[0].Action, ws.ActionUpdateAvailable) + } +} + +func TestService_Check_NoBroadcast_WhenNotifyDisabled(t *testing.T) { + svc, bc, notifyStore := newNotifyTestService(t, "v1.0.1", "https://example/v1.0.1") + if _, err := notifyStore.SaveSettings(context.Background(), NotifySettings{Enabled: false, Channel: NotifyChannelBoth}); err != nil { + t.Fatalf("SaveSettings: %v", err) + } + + if _, err := svc.Check(context.Background()); err != nil { + t.Fatalf("Check: %v", err) + } + + if len(bc.messages) != 0 { + t.Fatalf("expected no broadcast while disabled, got %d", len(bc.messages)) + } +} + +func TestService_Check_NoBroadcast_WhenNoUpdateAvailable(t *testing.T) { + // current == latest -> not an update. + svc, bc, _ := newNotifyTestService(t, "v1.0.0", "https://example/v1.0.0") + + if _, err := svc.Check(context.Background()); err != nil { + t.Fatalf("Check: %v", err) + } + if len(bc.messages) != 0 { + t.Fatalf("expected no broadcast when already up to date, got %d", len(bc.messages)) + } +} + +func TestService_Check_DedupesRepeatedBroadcastForSameVersion(t *testing.T) { + svc, bc, _ := newNotifyTestService(t, "v1.0.1", "https://example/v1.0.1") + + // Simulate two separate poll ticks of the same release (fetchAndPersist + // is what both Check() and the poller call; using it directly avoids the + // unrelated 30s manual-check rate limiter tested elsewhere). + if _, err := svc.fetchAndPersist(context.Background()); err != nil { + t.Fatalf("fetchAndPersist 1: %v", err) + } + if _, err := svc.fetchAndPersist(context.Background()); err != nil { + t.Fatalf("fetchAndPersist 2: %v", err) + } + + if len(bc.messages) != 1 { + t.Fatalf("expected exactly 1 broadcast across repeated polls of the same version, got %d", len(bc.messages)) + } +} + +func TestService_Check_DoesNotPersistNotified_WhenNoClientEligible(t *testing.T) { + // Regression test for the immediate on-Start poller tick landing before + // any WS client has connected: Hub.Broadcast has no delivery guarantee, + // so a release must not be marked notified until a broadcast reports at + // least one eligible client — otherwise the first real client to + // connect would never see the one-time notification for that release. + svc, bc, _ := newNotifyTestService(t, "v1.0.1", "https://example/v1.0.1") + bc.eligible = false + + if _, err := svc.fetchAndPersist(context.Background()); err != nil { + t.Fatalf("fetchAndPersist (no clients): %v", err) + } + if len(bc.messages) != 1 { + t.Fatalf("expected the broadcast attempt to still happen, got %d messages", len(bc.messages)) + } + if notified, err := persistence.ReadNotifiedUpdateVersion(svc.pool.Reader()); err != nil { + t.Fatalf("ReadNotifiedUpdateVersion: %v", err) + } else if notified != "" { + t.Fatalf("expected no version marked notified while ineligible, got %q", notified) + } + + // A later tick with a client now connected must still deliver it. + bc.eligible = true + if _, err := svc.fetchAndPersist(context.Background()); err != nil { + t.Fatalf("fetchAndPersist (client connected): %v", err) + } + if len(bc.messages) != 2 { + t.Fatalf("expected a retried broadcast once eligible, got %d messages", len(bc.messages)) + } + if notified, err := persistence.ReadNotifiedUpdateVersion(svc.pool.Reader()); err != nil { + t.Fatalf("ReadNotifiedUpdateVersion: %v", err) + } else if notified != "v1.0.1" { + t.Fatalf("expected v1.0.1 marked notified after eligible retry, got %q", notified) + } +} + +func TestService_Check_NilBroadcaster_DoesNotPanic(t *testing.T) { + pool := newTestPool(t) + srv, _ := newStubGitHub(t, "v1.0.1", "https://example/v1.0.1") + svc := NewService(pool, "v1.0.0", srv.Client(), logger.Default()) + svc.SetReleaseURL(srv.URL) + + if _, err := svc.Check(context.Background()); err != nil { + t.Fatalf("Check: %v", err) + } +} + +func TestService_Check_ConcurrentChecks_DoNotDoubleBroadcast(t *testing.T) { + // Regression test for a race between fetchAndPersist calls that land + // concurrently (e.g. a manual Check racing the poller's tick): only one + // of them should win the notified-version dedup and broadcast. + svc, bc, _ := newNotifyTestService(t, "v1.0.1", "https://example/v1.0.1") + + const n = 8 + done := make(chan struct{}, n) + for range n { + go func() { + defer func() { done <- struct{}{} }() + svc.notifyIfNewUpdate(context.Background(), "v1.0.1", "https://example/v1.0.1") + }() + } + for range n { + <-done + } + + if got := len(bc.messages); got != 1 { + t.Fatalf("expected exactly 1 broadcast across %d concurrent notifyIfNewUpdate calls, got %d", n, got) + } +} diff --git a/apps/backend/internal/system/updates/notify_settings.go b/apps/backend/internal/system/updates/notify_settings.go new file mode 100644 index 0000000000..7b0e376907 --- /dev/null +++ b/apps/backend/internal/system/updates/notify_settings.go @@ -0,0 +1,44 @@ +package updates + +import "fmt" + +// Update notification channels. "desktop" delivers an OS-level notification +// (native via the Tauri shell, or a browser Notification when running in a +// plain tab); "in_view" renders an in-app banner/toast that only appears +// while Kandev is open; "both" delivers both. +const ( + NotifyChannelDesktop = "desktop" + NotifyChannelInView = "in_view" + NotifyChannelBoth = "both" +) + +// NotifySettings controls whether — and how — the user is notified when the +// background updates poller (see poller.go) detects a new release. +type NotifySettings struct { + Enabled bool `json:"enabled"` + Channel string `json:"channel"` +} + +// DefaultNotifySettings returns the value used whenever nothing has been +// saved yet (fresh installs, and existing installs on their first read after +// this feature ships): notify on both channels. Once a user saves a +// preference via NotifyStore, that choice — including disabling the feature +// — persists across restarts and future releases. +func DefaultNotifySettings() NotifySettings { + return NotifySettings{Enabled: true, Channel: NotifyChannelBoth} +} + +// NormalizeNotifySettings fills in a missing channel with the default and +// rejects anything that isn't a known channel value. +func NormalizeNotifySettings(in NotifySettings) (NotifySettings, error) { + out := in + if out.Channel == "" { + out.Channel = DefaultNotifySettings().Channel + } + switch out.Channel { + case NotifyChannelDesktop, NotifyChannelInView, NotifyChannelBoth: + default: + return NotifySettings{}, fmt.Errorf("invalid notification channel %q", in.Channel) + } + return out, nil +} diff --git a/apps/backend/internal/system/updates/notify_settings_test.go b/apps/backend/internal/system/updates/notify_settings_test.go new file mode 100644 index 0000000000..9c1e17ec88 --- /dev/null +++ b/apps/backend/internal/system/updates/notify_settings_test.go @@ -0,0 +1,45 @@ +package updates + +import "testing" + +func TestDefaultNotifySettings(t *testing.T) { + got := DefaultNotifySettings() + if !got.Enabled { + t.Errorf("expected Enabled=true by default") + } + if got.Channel != NotifyChannelBoth { + t.Errorf("channel = %q, want %q", got.Channel, NotifyChannelBoth) + } +} + +func TestNormalizeNotifySettings_FillsDefaultChannel(t *testing.T) { + got, err := NormalizeNotifySettings(NotifySettings{Enabled: true, Channel: ""}) + if err != nil { + t.Fatalf("NormalizeNotifySettings: %v", err) + } + if got.Channel != NotifyChannelBoth { + t.Errorf("channel = %q, want default %q", got.Channel, NotifyChannelBoth) + } +} + +func TestNormalizeNotifySettings_AcceptsKnownChannels(t *testing.T) { + for _, channel := range []string{NotifyChannelDesktop, NotifyChannelInView, NotifyChannelBoth} { + got, err := NormalizeNotifySettings(NotifySettings{Enabled: false, Channel: channel}) + if err != nil { + t.Fatalf("NormalizeNotifySettings(%q): %v", channel, err) + } + if got.Channel != channel { + t.Errorf("channel = %q, want %q", got.Channel, channel) + } + if got.Enabled { + t.Errorf("expected Enabled=false to be preserved") + } + } +} + +func TestNormalizeNotifySettings_RejectsUnknownChannel(t *testing.T) { + _, err := NormalizeNotifySettings(NotifySettings{Enabled: true, Channel: "carrier-pigeon"}) + if err == nil { + t.Fatal("expected error for unknown channel") + } +} diff --git a/apps/backend/internal/system/updates/notify_store.go b/apps/backend/internal/system/updates/notify_store.go new file mode 100644 index 0000000000..2b14fed1a7 --- /dev/null +++ b/apps/backend/internal/system/updates/notify_store.go @@ -0,0 +1,52 @@ +package updates + +import ( + "context" + "encoding/json" + "fmt" + + systemsettings "github.com/kandev/kandev/internal/system/settings" +) + +const notifySettingsKey = "update_notifications" + +// NotifyStore persists NotifySettings on top of the generic install-wide +// settings table (see internal/system/settings), following the same +// shape as metrics.Store. +type NotifyStore struct { + settings *systemsettings.Store +} + +func NewNotifyStore(settings *systemsettings.Store) *NotifyStore { + return &NotifyStore{settings: settings} +} + +func (s *NotifyStore) GetSettings(ctx context.Context) (NotifySettings, error) { + raw, found, err := s.settings.Get(ctx, notifySettingsKey) + if err != nil { + return NotifySettings{}, err + } + if !found { + return DefaultNotifySettings(), nil + } + var settings NotifySettings + if err := json.Unmarshal(raw, &settings); err != nil { + return NotifySettings{}, err + } + return NormalizeNotifySettings(settings) +} + +func (s *NotifyStore) SaveSettings(ctx context.Context, settings NotifySettings) (NotifySettings, error) { + normalized, err := NormalizeNotifySettings(settings) + if err != nil { + return NotifySettings{}, err + } + data, err := json.Marshal(normalized) + if err != nil { + return NotifySettings{}, err + } + if err := s.settings.Save(ctx, notifySettingsKey, data); err != nil { + return NotifySettings{}, fmt.Errorf("save update notification settings: %w", err) + } + return normalized, nil +} diff --git a/apps/backend/internal/system/updates/notify_store_test.go b/apps/backend/internal/system/updates/notify_store_test.go new file mode 100644 index 0000000000..07ea9bc9db --- /dev/null +++ b/apps/backend/internal/system/updates/notify_store_test.go @@ -0,0 +1,67 @@ +package updates + +import ( + "context" + "testing" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/db" + systemsettings "github.com/kandev/kandev/internal/system/settings" +) + +func newTestNotifyStore(t *testing.T) *NotifyStore { + t.Helper() + conn, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + conn.SetMaxOpenConns(1) + t.Cleanup(func() { _ = conn.Close() }) + settingsStore, err := systemsettings.NewStore(db.NewPool(conn, conn)) + if err != nil { + t.Fatalf("new settings store: %v", err) + } + return NewNotifyStore(settingsStore) +} + +func TestNotifyStore_GetSettings_DefaultsWhenUnset(t *testing.T) { + store := newTestNotifyStore(t) + got, err := store.GetSettings(context.Background()) + if err != nil { + t.Fatalf("GetSettings: %v", err) + } + if got != DefaultNotifySettings() { + t.Errorf("got %+v, want default %+v", got, DefaultNotifySettings()) + } +} + +func TestNotifyStore_SaveAndGet_Roundtrip(t *testing.T) { + store := newTestNotifyStore(t) + ctx := context.Background() + + saved, err := store.SaveSettings(ctx, NotifySettings{Enabled: false, Channel: NotifyChannelDesktop}) + if err != nil { + t.Fatalf("SaveSettings: %v", err) + } + if saved.Enabled || saved.Channel != NotifyChannelDesktop { + t.Errorf("saved = %+v", saved) + } + + got, err := store.GetSettings(ctx) + if err != nil { + t.Fatalf("GetSettings after save: %v", err) + } + if got != saved { + t.Errorf("got %+v after save, want %+v", got, saved) + } +} + +func TestNotifyStore_SaveSettings_RejectsInvalidChannel(t *testing.T) { + store := newTestNotifyStore(t) + _, err := store.SaveSettings(context.Background(), NotifySettings{Enabled: true, Channel: "smoke-signal"}) + if err == nil { + t.Fatal("expected error for invalid channel") + } +} diff --git a/apps/backend/internal/system/updates/service.go b/apps/backend/internal/system/updates/service.go index 694051ca7c..dd0dc57c4e 100644 --- a/apps/backend/internal/system/updates/service.go +++ b/apps/backend/internal/system/updates/service.go @@ -20,6 +20,7 @@ import ( "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/persistence" "github.com/kandev/kandev/internal/system/jobs" + ws "github.com/kandev/kandev/pkg/websocket" ) // PollInterval is the cadence at which the background goroutine polls GitHub. @@ -54,6 +55,11 @@ var ErrNoUpdateAvailable = errors.New("no update available") // launched and not yet completed, so a second apply is refused. var ErrApplyInProgress = errors.New("a self-update is already in progress") +// ErrNotifyStoreUnavailable indicates that SaveNotifySettings was called on +// a Service constructed without WithNotifyStore (e.g. the system settings +// table failed to initialize at boot). +var ErrNotifyStoreUnavailable = errors.New("update notification settings are not available") + // devVersion is the sentinel used by the ldflags-injected current version // when no release tag was baked in (i.e. local dev builds). const devVersion = "dev" @@ -106,6 +112,25 @@ type Service struct { now func() time.Time applyRun applyRunner + // notifyStore persists whether/how the user wants to be told about a + // newly detected update (see notify_settings.go, notify_store.go). Nil + // when the caller didn't wire one (e.g. some tests) — treated as + // DefaultNotifySettings(). + notifyStore *NotifyStore + + // broadcast delivers a WS notification to connected clients when a new + // update is detected and notifications are enabled. Nil-safe: when unset, + // notifyIfNewUpdate is a no-op beyond the settings/dedup check. + broadcast Broadcaster + + // notifyMu serializes the read-check-broadcast-write sequence in + // notifyIfNewUpdate. fetchAndPersist can run concurrently from a manual + // Check() and the poller's tick (Check is rate-limited against itself, + // but not against a poller tick landing in the same instant), so without + // this the notified-version read and write could interleave and fire the + // broadcast twice for the same release. + notifyMu sync.Mutex + // applyStartedAt holds the unix-nano timestamp of the last self-update launch // (0 = none in flight). It guards against two concurrent /updates/apply calls // each launching a helper that would race the reinstall/restart, and @@ -136,6 +161,21 @@ type Service struct { // the poller and Check() without spinning up an httptest server. type Fetcher func(ctx context.Context) (tag, url string, err error) +// Broadcaster delivers a WS notification message to connected clients. Kept +// as a bare function type (rather than depending on gateway/websocket.Hub or +// a user-store package) so this package stays free of unrelated wiring — +// callers close over whatever targeting (broadcast-to-all, broadcast-to-user) +// makes sense for their deployment. +// +// The returned bool reports only whether at least one client was connected +// (eligible to receive) at broadcast time — Hub.Broadcast has no per-client +// ack, so this cannot guarantee actual receipt (a client can still drop the +// connection between the eligibility check and the send). It is precise +// enough to close the common gap: the poller's immediate on-Start tick can +// run before any WS client has connected, which would otherwise mark a +// release "notified" that nobody was ever eligible to see. +type Broadcaster func(msg *ws.Message) (eligible bool) + // Option customises Service construction without growing NewService's public // parameter list. type Option func(*Service) @@ -173,6 +213,15 @@ func WithApplyRunner(r applyRunner) Option { } } +// WithNotifyStore wires the persisted update-notification preferences +// (enable/disable + desktop/in_view/both channel). Without it, +// notifyIfNewUpdate falls back to DefaultNotifySettings(). +func WithNotifyStore(store *NotifyStore) Option { + return func(s *Service) { + s.notifyStore = store + } +} + // NewService constructs the updates service. When httpClient is nil, a fresh // client carrying defaultClientTimeout is allocated — http.DefaultClient has // no timeout, so handing it the poller would let a stalled socket hang a @@ -239,6 +288,34 @@ func (s *Service) SetReleaseURL(url string) { s.mu.Unlock() } +// SetBroadcaster wires the WS broadcaster used to announce a newly detected +// update. Constructed post-hoc (rather than via Option) because the WS hub +// is composed after this service in the current wiring order — see +// internal/backendapp/main.go. +func (s *Service) SetBroadcaster(b Broadcaster) { + s.mu.Lock() + s.broadcast = b + s.mu.Unlock() +} + +// GetNotifySettings returns the persisted update-notification preferences, +// or DefaultNotifySettings() when no NotifyStore was wired. +func (s *Service) GetNotifySettings(ctx context.Context) (NotifySettings, error) { + if s.notifyStore == nil { + return DefaultNotifySettings(), nil + } + return s.notifyStore.GetSettings(ctx) +} + +// SaveNotifySettings validates and persists the update-notification +// preferences. Returns an error if no NotifyStore was wired. +func (s *Service) SaveNotifySettings(ctx context.Context, settings NotifySettings) (NotifySettings, error) { + if s.notifyStore == nil { + return NotifySettings{}, ErrNotifyStoreUnavailable + } + return s.notifyStore.SaveSettings(ctx, settings) +} + // Get returns the last-known state from kandev_meta without contacting // GitHub. Safe to call on every page load. func (s *Service) Get() (UpdatesResponse, error) { @@ -358,9 +435,63 @@ func (s *Service) fetchAndPersist(ctx context.Context) (UpdatesResponse, error) s.log.Warn("updates: persist latest version failed", zap.Error(werr)) return UpdatesResponse{}, werr } + s.notifyIfNewUpdate(ctx, tag, releaseURL) return s.buildResponse(tag, releaseURL, now), nil } +// notifyIfNewUpdate broadcasts a WS notification the first time a given +// release tag is observed as newer than the running binary, provided the +// user hasn't disabled update notifications. Dedup is durable (kandev_meta), +// not in-memory, so a backend restart doesn't re-announce a release the user +// already saw. Disabling notifications does not mark the version as +// notified, so re-enabling later still surfaces the current release. +func (s *Service) notifyIfNewUpdate(ctx context.Context, tag, releaseURL string) { + if !s.updateAvailable(tag) { + return + } + s.mu.Lock() + broadcast := s.broadcast + s.mu.Unlock() + if broadcast == nil { + return + } + settings, err := s.GetNotifySettings(ctx) + if err != nil { + s.log.Warn("updates: read notify settings failed", zap.Error(err)) + return + } + if !settings.Enabled { + return + } + s.notifyMu.Lock() + defer s.notifyMu.Unlock() + notified, err := persistence.ReadNotifiedUpdateVersion(s.pool.Reader()) + if err != nil { + s.log.Warn("updates: read notified version failed", zap.Error(err)) + return + } + if notified == tag { + return + } + msg, err := ws.NewNotification(ws.ActionUpdateAvailable, map[string]interface{}{ + "version": tag, + "url": releaseURL, + }) + if err != nil { + s.log.Warn("updates: build notification message failed", zap.Error(err)) + return + } + if !broadcast(msg) { + // No client was connected to receive this — don't mark the release + // notified. The next poll tick (or a manual "Check now") will retry + // once at least one client is connected. + return + } + if werr := persistence.WriteNotifiedUpdateVersion(s.pool.Writer(), tag); werr != nil { + s.log.Warn("updates: persist notified version failed", zap.Error(werr)) + } +} + func (s *Service) buildResponse(latest, url string, checkedAt time.Time) UpdatesResponse { install, _ := s.detectInstallState() return s.buildResponseFrom(install, latest, url, checkedAt) diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 5b782d2678..cd95a90e91 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -223,6 +223,7 @@ const ( ActionExecutorPrepareProgress = "executor.prepare.progress" ActionExecutorPrepareCompleted = "executor.prepare.completed" ActionSystemMetricsUpdated = "system.metrics.updated" + ActionUpdateAvailable = "system.update_available" ActionAgentProfileDeleted = "agent.profile.deleted" ActionAgentProfileCreated = "agent.profile.created" diff --git a/apps/desktop/src-tauri/src/native_notifications.rs b/apps/desktop/src-tauri/src/native_notifications.rs index 413a4a11a9..ecf092937c 100644 --- a/apps/desktop/src-tauri/src/native_notifications.rs +++ b/apps/desktop/src-tauri/src/native_notifications.rs @@ -15,7 +15,12 @@ pub struct NativeNotificationRequest { pub event_id: String, pub title: String, pub body: String, - pub task_id: String, + /// Present for session-scoped events (waiting-for-input, failed). + /// Absent for account/app-scoped events such as an available update, + /// which has no associated task. `#[serde(default)]` accepts payloads + /// that omit the key entirely (e.g. `JSON.stringify` drops `undefined`). + #[serde(default)] + pub task_id: Option, pub session_id: Option, } @@ -83,25 +88,40 @@ impl NativeNotificationState { } } +/// Event identities this bridge will display. Session-scoped events require +/// a non-empty task_id (they're gated on task context); the update-available +/// event has no task and must omit it. +const SESSION_EVENT_PREFIXES: [&str; 4] = [ + "session.waiting_for_input:", + "session.failed:", + "session.turn_finished:", + "session.clarification_requested:", +]; +const UPDATE_AVAILABLE_PREFIX: &str = "system.update_available:"; + fn validate_request(request: &NativeNotificationRequest) -> Result<(), String> { - if !request.event_id.starts_with("session.turn_finished:") - && !request - .event_id - .starts_with("session.clarification_requested:") - && !request.event_id.starts_with("session.failed:") - { + let is_session_event = SESSION_EVENT_PREFIXES + .iter() + .any(|prefix| request.event_id.starts_with(prefix)); + let is_update_event = request.event_id.starts_with(UPDATE_AVAILABLE_PREFIX); + if !is_session_event && !is_update_event { return Err("unsupported native notification event".to_string()); } if request.event_id.len() > 256 || request.title.is_empty() || request.title.len() > 160 || request.body.len() > 1000 - || request.task_id.is_empty() - || request.task_id.len() > 256 || request.session_id.as_ref().is_some_and(|id| id.len() > 256) + || request.task_id.as_ref().is_some_and(|id| id.len() > 256) { return Err("invalid native notification payload".to_string()); } + if is_session_event { + match request.task_id.as_deref() { + Some(id) if !id.is_empty() => {} + _ => return Err("invalid native notification payload".to_string()), + } + } Ok(()) } @@ -159,11 +179,21 @@ mod tests { event_id: event_id.to_string(), title: "Agent needs your answer".to_string(), body: "The agent asked a question.".to_string(), - task_id: task_id.to_string(), + task_id: Some(task_id.to_string()), session_id: Some("session-1".to_string()), } } + fn update_available_request(event_id: &str) -> NativeNotificationRequest { + NativeNotificationRequest { + event_id: event_id.to_string(), + title: "Update available".to_string(), + body: "Kandev v1.2.3 is available.".to_string(), + task_id: None, + session_id: None, + } + } + #[test] fn event_identity_is_delivered_at_most_once() { let state = NativeNotificationState::default(); @@ -184,6 +214,16 @@ mod tests { assert!(state.claim("session.failed:0")); } + #[test] + fn bridge_rejects_events_outside_supported_notification_types() { + let request = request("office.inbox_item:item-1", "task-1"); + + assert_eq!( + validate_request(&request), + Err("unsupported native notification event".to_string()) + ); + } + #[test] fn bridge_accepts_semantic_session_events() { for event_id in [ @@ -194,6 +234,33 @@ mod tests { } } + #[test] + fn bridge_accepts_update_available_events_without_task_id() { + let request = update_available_request("system.update_available:v1.2.3"); + + assert_eq!(validate_request(&request), Ok(())); + } + + #[test] + fn bridge_rejects_session_events_missing_task_id() { + let mut request = request("session.waiting_for_input:session-1", "task-1"); + request.task_id = None; + + assert_eq!( + validate_request(&request), + Err("invalid native notification payload".to_string()) + ); + } + + #[test] + fn update_available_identity_is_delivered_at_most_once() { + let state = NativeNotificationState::default(); + let request = update_available_request("system.update_available:v1.2.3"); + + assert!(state.claim(&request.event_id)); + assert!(!state.claim(&request.event_id)); + } + #[test] fn denied_delivery_does_not_claim_the_event() { let state = NativeNotificationState::default(); diff --git a/apps/web/components/settings/system/update-notifications-card.test.tsx b/apps/web/components/settings/system/update-notifications-card.test.tsx new file mode 100644 index 0000000000..502904b600 --- /dev/null +++ b/apps/web/components/settings/system/update-notifications-card.test.tsx @@ -0,0 +1,126 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { StoreApi } from "zustand"; +import { StateProvider, useAppStoreApi } from "@/components/state-provider"; +import { ToastProvider } from "@/components/toast-provider"; +import { SettingsSaveProvider } from "@/components/settings/settings-save-provider"; +import type { AppState } from "@/lib/state/store"; +import { UpdateNotificationsCard } from "./update-notifications-card"; + +const mockSave = vi.fn(); +const TOGGLE_NAME = "Enable update notifications"; +const DIRTY_ATTRIBUTE = "data-settings-dirty"; + +vi.mock("@/lib/api/domains/system-api", () => ({ + saveUpdateNotificationSettings: (...args: unknown[]) => mockSave(...args), +})); + +function renderCard(initial?: { enabled: boolean; channel: string }) { + const initialState: Record | undefined = initial + ? { system: { updateNotificationSettings: initial } } + : undefined; + return render( + + + + + + + , + ); +} + +function StoreCapture({ onStore }: { onStore: (store: StoreApi) => void }) { + onStore(useAppStoreApi()); + return null; +} + +function renderCardCapturingStore() { + let store: StoreApi | undefined; + const utils = render( + + + + (store = s)} /> + + + + , + ); + if (!store) throw new Error("store was not captured"); + return { ...utils, store }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockSave.mockImplementation((settings) => Promise.resolve(settings)); +}); + +afterEach(() => { + cleanup(); +}); + +describe("UpdateNotificationsCard", () => { + it("renders the saved enable toggle and channel from hydrated state", async () => { + renderCard({ enabled: true, channel: "desktop" }); + + const toggle = await screen.findByRole("switch", { name: TOGGLE_NAME }); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + expect(screen.getByText("Desktop notification")).toBeTruthy(); + }); + + it("falls back to the backend default without fetching when no hydrated state is available", async () => { + renderCard(); + + const toggle = await screen.findByRole("switch", { name: TOGGLE_NAME }); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + expect(screen.getByText("Both")).toBeTruthy(); + }); + + it("hides the channel selector once disabled and persists through the floating Save action", async () => { + renderCard({ enabled: true, channel: "both" }); + + const toggle = await screen.findByRole("switch", { name: TOGGLE_NAME }); + fireEvent.click(toggle); + + expect(screen.queryByLabelText("Update notification channel")).toBeNull(); + expect(toggle.getAttribute(DIRTY_ATTRIBUTE)).toBe("true"); + + fireEvent.click(await screen.findByRole("button", { name: "Save changes" })); + + await waitFor(() => expect(mockSave).toHaveBeenCalledWith({ enabled: false, channel: "both" })); + await waitFor(() => expect(toggle.getAttribute(DIRTY_ATTRIBUTE)).toBe("false")); + }); + + it("marks the trigger dirty on channel change and persists the new channel on save", async () => { + renderCard({ enabled: true, channel: "in_view" }); + + await screen.findByText("In-app banner"); + const trigger = screen.getByRole("combobox", { name: "Update notification channel" }); + fireEvent.click(trigger); + fireEvent.click(await screen.findByRole("option", { name: "Both" })); + + expect(screen.getByText("Both")).toBeTruthy(); + expect(trigger.getAttribute(DIRTY_ATTRIBUTE)).toBe("true"); + + fireEvent.click(await screen.findByRole("button", { name: "Save changes" })); + + await waitFor(() => expect(mockSave).toHaveBeenCalledWith({ enabled: true, channel: "both" })); + await waitFor(() => expect(trigger.getAttribute(DIRTY_ATTRIBUTE)).toBe("false")); + }); + + it("adopts the boot-payload value once hydration lands after the initial render", async () => { + const { store } = renderCardCapturingStore(); + + const toggle = await screen.findByRole("switch", { name: TOGGLE_NAME }); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + expect(screen.getByText("Both")).toBeTruthy(); + + act(() => { + store.getState().setSystemUpdateNotificationSettings({ enabled: true, channel: "desktop" }); + }); + + await waitFor(() => expect(screen.getByText("Desktop notification")).toBeTruthy()); + expect(toggle.getAttribute(DIRTY_ATTRIBUTE)).toBe("false"); + }); +}); diff --git a/apps/web/components/settings/system/update-notifications-card.tsx b/apps/web/components/settings/system/update-notifications-card.tsx new file mode 100644 index 0000000000..6cd7314993 --- /dev/null +++ b/apps/web/components/settings/system/update-notifications-card.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { Switch } from "@kandev/ui/switch"; +import { useAppStore } from "@/components/state-provider"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider"; +import { useToast } from "@/components/toast-provider"; +import { saveUpdateNotificationSettings } from "@/lib/api/domains/system-api"; +import type { UpdateNotificationChannel, UpdateNotificationSettings } from "@/lib/types/system"; + +// Matches the backend default (DefaultNotifySettings). Only used while the +// initial settings fetch is pending (no SSR-hydrated value was available). +const DEFAULT_SETTINGS: UpdateNotificationSettings = { enabled: true, channel: "both" }; + +const CHANNEL_OPTIONS: Array<{ value: UpdateNotificationChannel; label: string; help: string }> = [ + { + value: "desktop", + label: "Desktop notification", + help: "Shows a native OS notification (or a browser notification when this isn't the desktop app).", + }, + { + value: "in_view", + label: "In-app banner", + help: "Shows an in-app banner only while Kandev is open in this window.", + }, + { + value: "both", + label: "Both", + help: "Shows both a desktop notification and an in-app banner.", + }, +]; + +function useUpdateNotificationSettingsDraft() { + const stored = useAppStore((s) => s.system.updateNotificationSettings); + const setStored = useAppStore((s) => s.setSystemUpdateNotificationSettings); + const { toast } = useToast(); + const [saved, setSaved] = useState(stored ?? DEFAULT_SETTINGS); + const [draft, setDraft] = useState(saved); + // Boot-payload hydration (see settings-routes.tsx: loadSettingsInitialState) + // resolves asynchronously and may land after this component's first + // render. Sync draft/saved from the store exactly once, the first time a + // real value shows up, so an in-flight boot fetch doesn't get shadowed by + // DEFAULT_SETTINGS. Never resync afterward so in-progress edits survive + // unrelated store updates (including this hook's own save round-trip). + const hydratedFromStoreRef = useRef(stored !== null); + + useEffect(() => { + if (hydratedFromStoreRef.current || stored === null) return; + hydratedFromStoreRef.current = true; + setSaved(stored); + setDraft(stored); + }, [stored]); + + const revision = JSON.stringify(draft); + const isDirty = revision !== JSON.stringify(saved); + + useSettingsSaveContributor({ + id: "system-update-notifications", + revision, + isDirty, + save: async () => { + const submitted = draft; + const persisted = await saveUpdateNotificationSettings(submitted); + setSaved(persisted); + setDraft(persisted); + setStored(persisted); + toast({ title: "Update notification settings saved", variant: "success" }); + }, + discard: () => setDraft(saved), + }); + + return { draft, saved, setDraft, isDirty }; +} + +export function UpdateNotificationsCard() { + const { draft, saved, setDraft, isDirty } = useUpdateNotificationSettingsDraft(); + const activeChannel = CHANNEL_OPTIONS.find((option) => option.value === draft.channel); + + return ( + + + Update notifications + + +
+
+
Notify me about new releases
+

+ Kandev checks GitHub for new releases every few hours in the background. When a newer + version is found, show a one-time notification for that release below. +

+
+ setDraft({ ...draft, enabled })} + aria-label="Enable update notifications" + className="cursor-pointer" + /> +
+ {draft.enabled && ( +
+ +

{activeChannel?.help}

+
+ )} +
+
+ ); +} diff --git a/apps/web/components/update-available-toast-bridge.tsx b/apps/web/components/update-available-toast-bridge.tsx new file mode 100644 index 0000000000..c038b0df38 --- /dev/null +++ b/apps/web/components/update-available-toast-bridge.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { useUpdateAvailableToast } from "@/hooks/use-update-available-toast"; + +/** Mounts the update-available toast hook inside the ToastProvider tree. */ +export function UpdateAvailableToastBridge() { + useUpdateAvailableToast(); + return null; +} diff --git a/apps/web/e2e/tests/system/update-notifications-settings.spec.ts b/apps/web/e2e/tests/system/update-notifications-settings.spec.ts new file mode 100644 index 0000000000..408ee487b8 --- /dev/null +++ b/apps/web/e2e/tests/system/update-notifications-settings.spec.ts @@ -0,0 +1,62 @@ +import { test, expect } from "../../fixtures/test-base"; + +test.describe("Update notifications settings", () => { + test("channel change persists through the floating Save action and survives reload", async ({ + testPage, + }) => { + test.setTimeout(30_000); + + await testPage.goto("/settings/system/updates"); + await expect(testPage.getByTestId("system-page-title")).toHaveText("Updates"); + + const card = testPage.getByTestId("update-notifications-card"); + await expect(card).toBeVisible(); + + // Fresh worker-scoped backend: defaults to enabled + "both" (see + // DefaultNotifySettings in internal/system/updates/notify_settings.go). + const toggle = card.getByRole("switch", { name: "Enable update notifications" }); + await expect(toggle).toHaveAttribute("aria-checked", "true"); + + const channelTrigger = card.getByRole("combobox", { name: "Update notification channel" }); + await expect(channelTrigger).toHaveText("Both"); + + await channelTrigger.click(); + await testPage.getByRole("option", { name: "Desktop notification" }).click(); + await expect(channelTrigger).toHaveText("Desktop notification"); + await expect(channelTrigger).toHaveAttribute("data-settings-dirty", "true"); + + await testPage.getByRole("button", { name: "Save changes" }).click(); + await expect(channelTrigger).toHaveAttribute("data-settings-dirty", "false", { + timeout: 10_000, + }); + + await testPage.reload(); + await expect(testPage.getByTestId("update-notifications-card")).toBeVisible(); + await expect( + testPage + .getByTestId("update-notifications-card") + .getByRole("combobox", { name: "Update notification channel" }), + ).toHaveText("Desktop notification"); + }); + + test("disabling hides the channel selector", async ({ testPage }) => { + test.setTimeout(30_000); + + await testPage.goto("/settings/system/updates"); + const card = testPage.getByTestId("update-notifications-card"); + const toggle = card.getByRole("switch", { name: "Enable update notifications" }); + await expect(toggle).toBeVisible(); + + await toggle.click(); + await expect(card.getByLabel("Update notification channel")).toHaveCount(0); + + await testPage.getByRole("button", { name: "Save changes" }).click(); + await expect(toggle).toHaveAttribute("data-settings-dirty", "false", { timeout: 10_000 }); + + await testPage.reload(); + const reloadedToggle = testPage + .getByTestId("update-notifications-card") + .getByRole("switch", { name: "Enable update notifications" }); + await expect(reloadedToggle).toHaveAttribute("aria-checked", "false"); + }); +}); diff --git a/apps/web/hooks/use-update-available-toast.test.ts b/apps/web/hooks/use-update-available-toast.test.ts new file mode 100644 index 0000000000..2c70399ecd --- /dev/null +++ b/apps/web/hooks/use-update-available-toast.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook } from "@testing-library/react"; +import type { UpdateAvailableNotification } from "@/lib/state/slices/ui/types"; +import type { UpdateNotificationSettings } from "@/lib/types/system"; +import type { useUpdateAvailableToast as UseUpdateAvailableToastHook } from "./use-update-available-toast"; + +let mockNotification: UpdateAvailableNotification | null = null; +let mockSettings: UpdateNotificationSettings | null = null; +const mockClearNotification = vi.fn(); +const mockSetSettings = vi.fn(); +const mockToast = vi.fn(); +const mockNativeIsAvailable = vi.fn(() => false); +const mockNativeShow = vi.fn().mockResolvedValue("shown"); +const mockFetchSettings = vi.fn(); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + updateAvailableNotification: mockNotification, + setUpdateAvailableNotification: mockClearNotification, + system: { updateNotificationSettings: mockSettings }, + setSystemUpdateNotificationSettings: mockSetSettings, + }), +})); + +vi.mock("@/components/toast-provider", () => ({ + useToast: () => ({ toast: mockToast }), +})); + +vi.mock("@/lib/desktop/native-notification-client", () => ({ + nativeNotifications: { + isAvailable: () => mockNativeIsAvailable(), + show: (request: unknown) => mockNativeShow(request), + }, +})); + +vi.mock("@/lib/api/domains/system-api", () => ({ + fetchUpdateNotificationSettings: (...args: unknown[]) => mockFetchSettings(...args), +})); + +let useUpdateAvailableToast: typeof UseUpdateAvailableToastHook; + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("useUpdateAvailableToast", () => { + beforeEach(async () => { + vi.clearAllMocks(); + // The hook module caches in-flight settings fetches in a module-scoped + // variable (`settingsRequest`) so concurrent hook instances share one + // request. Reset the module and re-import per test so that cache + // doesn't leak a resolved/rejected promise from a previous test into + // this one (e.g. the fetch-failure test below would otherwise silently + // reuse a prior test's already-resolved fetch and never exercise the + // catch branch). + vi.resetModules(); + mockNativeIsAvailable.mockReturnValue(false); + mockNotification = null; + mockSettings = null; + // Dynamic import is required here (not a static one): vi.resetModules() + // above clears Vitest's module registry, and only a fresh import() + // after that call picks up a new module instance with the module-scoped + // `settingsRequest` cache reset to null. + ({ useUpdateAvailableToast } = await import("./use-update-available-toast")); + }); + + it("does nothing when there is no notification", () => { + renderHook(() => useUpdateAvailableToast()); + + expect(mockToast).not.toHaveBeenCalled(); + expect(mockClearNotification).not.toHaveBeenCalled(); + }); + + it("shows an in-app toast when the channel is in_view", async () => { + mockSettings = { enabled: true, channel: "in_view" }; + mockNotification = { version: "v1.2.3" }; + renderHook(() => useUpdateAvailableToast()); + await flushMicrotasks(); + + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Update available", + description: expect.stringContaining("v1.2.3"), + }), + ); + expect(mockNativeShow).not.toHaveBeenCalled(); + expect(mockClearNotification).toHaveBeenCalledWith(null); + }); + + it("shows a native notification (not a toast) when the channel is desktop and native is available", async () => { + mockSettings = { enabled: true, channel: "desktop" }; + mockNotification = { version: "v1.2.3" }; + mockNativeIsAvailable.mockReturnValue(true); + renderHook(() => useUpdateAvailableToast()); + await flushMicrotasks(); + + expect(mockToast).not.toHaveBeenCalled(); + expect(mockNativeShow).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: "system.update_available:v1.2.3", + title: "Update available", + }), + ); + }); + + it("shows both a toast and a native notification when the channel is both", async () => { + mockSettings = { enabled: true, channel: "both" }; + mockNotification = { version: "v1.2.3" }; + mockNativeIsAvailable.mockReturnValue(true); + renderHook(() => useUpdateAvailableToast()); + await flushMicrotasks(); + + expect(mockToast).toHaveBeenCalledTimes(1); + expect(mockNativeShow).toHaveBeenCalledTimes(1); + }); + + it("does not notify on any channel when notifications are disabled", async () => { + mockSettings = { enabled: false, channel: "both" }; + mockNotification = { version: "v1.2.3" }; + mockNativeIsAvailable.mockReturnValue(true); + renderHook(() => useUpdateAvailableToast()); + await flushMicrotasks(); + + expect(mockToast).not.toHaveBeenCalled(); + expect(mockNativeShow).not.toHaveBeenCalled(); + expect(mockClearNotification).toHaveBeenCalledWith(null); + }); + + it("fetches settings lazily when not yet loaded, then caches them in the store", async () => { + mockSettings = null; + mockFetchSettings.mockResolvedValue({ enabled: true, channel: "in_view" }); + mockNotification = { version: "v1.2.3" }; + renderHook(() => useUpdateAvailableToast()); + await flushMicrotasks(); + + expect(mockFetchSettings).toHaveBeenCalledTimes(1); + expect(mockSetSettings).toHaveBeenCalledWith({ enabled: true, channel: "in_view" }); + expect(mockToast).toHaveBeenCalledTimes(1); + }); + + it("falls back to both channels when the settings fetch fails", async () => { + mockSettings = null; + mockFetchSettings.mockRejectedValue(new Error("network error")); + mockNotification = { version: "v1.2.3" }; + renderHook(() => useUpdateAvailableToast()); + await flushMicrotasks(); + await flushMicrotasks(); + + expect(mockFetchSettings).toHaveBeenCalledTimes(1); + expect(mockToast).toHaveBeenCalledTimes(1); + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ description: expect.stringContaining("v1.2.3") }), + ); + // The fallback must not be persisted to the store - a later notification + // should still see settings as unset and retry the real fetch. + expect(mockSetSettings).not.toHaveBeenCalled(); + }); + + it("deduplicates repeated notifications for the same version", async () => { + mockSettings = { enabled: true, channel: "in_view" }; + mockNotification = { version: "v1.2.3" }; + const { rerender } = renderHook(() => useUpdateAvailableToast()); + await flushMicrotasks(); + expect(mockToast).toHaveBeenCalledTimes(1); + + mockToast.mockClear(); + mockClearNotification.mockClear(); + mockNotification = { version: "v1.2.3" }; + rerender(); + await flushMicrotasks(); + + expect(mockToast).not.toHaveBeenCalled(); + expect(mockClearNotification).toHaveBeenCalledWith(null); + }); +}); diff --git a/apps/web/hooks/use-update-available-toast.ts b/apps/web/hooks/use-update-available-toast.ts new file mode 100644 index 0000000000..d8f6d6bb0a --- /dev/null +++ b/apps/web/hooks/use-update-available-toast.ts @@ -0,0 +1,89 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useAppStore } from "@/components/state-provider"; +import { useToast } from "@/components/toast-provider"; +import { nativeNotifications } from "@/lib/desktop/native-notification-client"; +import { fetchUpdateNotificationSettings } from "@/lib/api/domains/system-api"; +import type { UpdateNotificationSettings } from "@/lib/types/system"; + +// Matches the backend default (DefaultNotifySettings in +// internal/system/updates/notify_settings.go): used only when the settings +// fetch itself fails, so a transient network hiccup doesn't silently +// swallow an update notification the user would otherwise want. +const FALLBACK_SETTINGS: UpdateNotificationSettings = { enabled: true, channel: "both" }; + +let settingsRequest: Promise | null = null; + +function loadSettings(): Promise { + if (!settingsRequest) { + settingsRequest = fetchUpdateNotificationSettings({ cache: "no-store" }).catch((err) => { + // Allow the next notification to retry the fetch instead of being + // stuck on the fallback (and never re-checking the user's real saved + // preference) for the rest of the session. + settingsRequest = null; + throw err; + }); + } + return settingsRequest; +} + +/** + * Watches for a newly detected Kandev release (pushed over WS as + * `system.update_available`, see lib/ws/handlers/system-events.ts) and + * notifies the user according to their saved channel preference + * (desktop / in_view / both — see the Updates settings page). Mount once + * inside ToastProvider, alongside the other notification bridges. + */ +export function useUpdateAvailableToast() { + const notification = useAppStore((s) => s.updateAvailableNotification); + const clearNotification = useAppStore((s) => s.setUpdateAvailableNotification); + const settings = useAppStore((s) => s.system.updateNotificationSettings); + const setSettings = useAppStore((s) => s.setSystemUpdateNotificationSettings); + const { toast } = useToast(); + const shownRef = useRef>(new Set()); + + useEffect(() => { + if (!notification) return; + if (shownRef.current.has(notification.version)) { + clearNotification(null); + return; + } + shownRef.current.add(notification.version); + + void (async () => { + let current: UpdateNotificationSettings; + if (settings) { + current = settings; + } else { + try { + current = await loadSettings(); + setSettings(current); + } catch { + // Don't cache the fallback in the store — a future notification + // should still see `settings` as unset and retry the fetch. + current = FALLBACK_SETTINGS; + } + } + if (!current.enabled) return; + + const title = "Update available"; + const body = `Kandev ${notification.version} is available. See Settings > System > Updates.`; + + if (current.channel === "in_view" || current.channel === "both") { + toast({ title, description: body }); + } + if (current.channel === "desktop" || current.channel === "both") { + if (nativeNotifications.isAvailable()) { + void nativeNotifications + .show({ eventId: `system.update_available:${notification.version}`, title, body }) + .catch(() => undefined); + } else if (typeof Notification !== "undefined" && Notification.permission === "granted") { + new Notification(title, { body }); + } + } + })(); + + clearNotification(null); + }, [notification, settings, setSettings, toast, clearNotification]); +} diff --git a/apps/web/lib/api/domains/system-api.test.ts b/apps/web/lib/api/domains/system-api.test.ts index de97636eeb..c4f74e3206 100644 --- a/apps/web/lib/api/domains/system-api.test.ts +++ b/apps/web/lib/api/domains/system-api.test.ts @@ -27,6 +27,8 @@ import { fetchSystemJob, fetchRestartCapability, requestRestart, + fetchUpdateNotificationSettings, + saveUpdateNotificationSettings, adoptStorageGoCache, analyzeStorage, deleteStorageQuarantine, @@ -391,3 +393,26 @@ describe("storage maintenance", () => { expect((await restoreStorageQuarantine("entry-1")).id).toBe("restored"); }); }); + +describe("update notification settings", () => { + it("fetchUpdateNotificationSettings GETs the notification-settings endpoint and bypasses cache", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ enabled: true, channel: "desktop" })); + const settings = await fetchUpdateNotificationSettings({ cache: "force-cache" }); + expect(lastCall().url).toBe(`${BASE}/updates/notification-settings`); + expect(method()).toBe("GET"); + expect(lastCall().init?.cache).toBe("no-store"); + expect(settings).toEqual({ enabled: true, channel: "desktop" }); + }); + + it("saveUpdateNotificationSettings PUTs the settings body to the same endpoint", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ enabled: false, channel: "in_view" })); + const persisted = await saveUpdateNotificationSettings({ enabled: false, channel: "in_view" }); + expect(lastCall().url).toBe(`${BASE}/updates/notification-settings`); + expect(method()).toBe("PUT"); + expect(JSON.parse(String(lastCall().init?.body))).toEqual({ + enabled: false, + channel: "in_view", + }); + expect(persisted).toEqual({ enabled: false, channel: "in_view" }); + }); +}); diff --git a/apps/web/lib/api/domains/system-api.ts b/apps/web/lib/api/domains/system-api.ts index f5ca75d7e1..4530e860fa 100644 --- a/apps/web/lib/api/domains/system-api.ts +++ b/apps/web/lib/api/domains/system-api.ts @@ -8,6 +8,7 @@ import type { LogFileInfo, LogTailResponse, UpdatesResponse, + UpdateNotificationSettings, JobAcceptResponse, RestartCapability, RestartResponse, @@ -177,6 +178,25 @@ export function applyUpdate( }); } +export function fetchUpdateNotificationSettings( + options?: ApiRequestOptions, +): Promise { + return fetchJson(`${SYSTEM_BASE}/updates/notification-settings`, { + ...options, + cache: "no-store", + }); +} + +export function saveUpdateNotificationSettings( + settings: UpdateNotificationSettings, + options?: ApiRequestOptions, +): Promise { + return fetchJson(`${SYSTEM_BASE}/updates/notification-settings`, { + ...options, + init: { ...(options?.init ?? {}), method: "PUT", body: JSON.stringify(settings) }, + }); +} + // --- Restart ------------------------------------------------------------ export function fetchRestartCapability(options?: ApiRequestOptions): Promise { diff --git a/apps/web/lib/desktop/native-notification-client.ts b/apps/web/lib/desktop/native-notification-client.ts index e13363907b..a8d2804db3 100644 --- a/apps/web/lib/desktop/native-notification-client.ts +++ b/apps/web/lib/desktop/native-notification-client.ts @@ -4,7 +4,8 @@ export type NativeNotificationRequest = { eventId: string; title: string; body: string; - taskId: string; + /** Absent for account/app-scoped events (e.g. an available update) that have no task. */ + taskId?: string; sessionId?: string | null; }; diff --git a/apps/web/lib/state/default-state.ts b/apps/web/lib/state/default-state.ts index 4f34968370..37176093db 100644 --- a/apps/web/lib/state/default-state.ts +++ b/apps/web/lib/state/default-state.ts @@ -16,6 +16,7 @@ import { defaultSystemState, } from "./slices"; import { getStoredQuickChatNames } from "@/lib/local-storage"; +import type { HydrationState } from "./store"; import { migrateView } from "./slices/ui/ui-slice"; export const defaultState = { @@ -115,7 +116,7 @@ export type DefaultState = typeof defaultState; function mergeCodeHostFields( d: DefaultState, - s: Partial, + s: HydrationState, ): Pick< DefaultState, | "taskMRs" @@ -142,7 +143,7 @@ function mergeCodeHostFields( }; } -function mergeQuickChatState(initialState: Partial): DefaultState["quickChat"] { +function mergeQuickChatState(initialState: HydrationState): DefaultState["quickChat"] { const quickChat = { ...defaultState.quickChat, ...initialState.quickChat }; if (!initialState.quickChat?.sessions) return quickChat; @@ -160,7 +161,7 @@ function mergeQuickChatState(initialState: Partial): DefaultState[ }; } -function mergeSidebarViewState(initialState: Partial): DefaultState["sidebarViews"] { +function mergeSidebarViewState(initialState: HydrationState): DefaultState["sidebarViews"] { const sidebarViews = { ...defaultState.sidebarViews, ...initialState.sidebarViews }; const userSettings = initialState.userSettings; const serverViews = userSettings?.sidebarViews?.map(migrateView) ?? []; @@ -177,7 +178,7 @@ function mergeSidebarViewState(initialState: Partial): DefaultStat } function mergeSidebarTaskPrefsState( - initialState: Partial, + initialState: HydrationState, ): DefaultState["sidebarTaskPrefs"] { const sidebarTaskPrefs = { ...defaultState.sidebarTaskPrefs, @@ -200,7 +201,7 @@ function mergeSidebarTaskPrefsState( } function mergeReviewPRSelectionState( - initialState: Partial, + initialState: HydrationState, ): DefaultState["reviewPRSelection"] { return { ...defaultState.reviewPRSelection, @@ -212,11 +213,11 @@ function mergeReviewPRSelectionState( }; } -function mergeSessionFailureNotification(initialState: Partial) { +function mergeSessionFailureNotification(initialState: HydrationState) { return initialState.sessionFailureNotification ?? defaultState.sessionFailureNotification; } -export function mergeInitialState(initialState?: Partial): DefaultState { +export function mergeInitialState(initialState?: HydrationState): DefaultState { if (!initialState) return defaultState; return { ...defaultState, diff --git a/apps/web/lib/state/hydration/hydrator.test.ts b/apps/web/lib/state/hydration/hydrator.test.ts index 3897bbf6ca..08309dc999 100644 --- a/apps/web/lib/state/hydration/hydrator.test.ts +++ b/apps/web/lib/state/hydration/hydrator.test.ts @@ -346,3 +346,31 @@ describe("hydrateState — sidebar views from user settings", () => { }); }); }); + +describe("hydrateState — system slice", () => { + it("leaf-merges update notification settings without clobbering other system fields", () => { + const result = produce(makeAppDraft(), (draft: Draft) => { + draft.system.info = { version: "v1.0.0" } as unknown as AppState["system"]["info"]; + + hydrateState(draft, { + system: { + updateNotificationSettings: { enabled: false, channel: "desktop" }, + }, + }); + }); + + expect(result.system.updateNotificationSettings).toEqual({ + enabled: false, + channel: "desktop", + }); + expect(result.system.info).toEqual({ version: "v1.0.0" }); + }); + + it("leaves the system slice untouched when the caller supplies no system fields", () => { + const result = produce(makeAppDraft(), (draft: Draft) => { + hydrateState(draft, {}); + }); + + expect(result.system).toEqual(defaultState.system); + }); +}); diff --git a/apps/web/lib/state/hydration/hydrator.ts b/apps/web/lib/state/hydration/hydrator.ts index a207de2cfb..bc941db9cd 100644 --- a/apps/web/lib/state/hydration/hydrator.ts +++ b/apps/web/lib/state/hydration/hydrator.ts @@ -1,5 +1,5 @@ import type { Draft } from "immer"; -import type { AppState } from "../store"; +import type { AppState, HydrationState } from "../store"; import { migrateView } from "../slices/ui/ui-slice"; import { getStoredQuickChatNames } from "@/lib/local-storage"; import { deepMerge, mergeSessionMap, mergeLoadingState } from "./merge-strategies"; @@ -51,7 +51,7 @@ function mergeKanbanTasks( } /** Hydrate kanban and workspace slices. */ -function hydrateKanbanAndWorkspace(draft: Draft, state: Partial): void { +function hydrateKanbanAndWorkspace(draft: Draft, state: HydrationState): void { if (state.kanban) { // Merge tasks by ID with timestamp comparison to avoid overwriting fresher WS data const { tasks, ...kanbanRest } = state.kanban; @@ -67,7 +67,7 @@ function hydrateKanbanAndWorkspace(draft: Draft, state: Partial, state: Partial): void { +function hydrateSettings(draft: Draft, state: HydrationState): void { if (state.executors) deepMerge(draft.executors, state.executors); if (state.settingsAgents) deepMerge(draft.settingsAgents, state.settingsAgents); if (state.agentDiscovery) deepMerge(draft.agentDiscovery, state.agentDiscovery); @@ -117,7 +117,7 @@ function bridgeSidebarViewsFromUserSettings( /** Hydrate session slices, protecting active sessions. */ function hydrateSession( draft: Draft, - state: Partial, + state: HydrationState, activeSessionId: string | null, forceMergeSessionId: string | null, ): void { @@ -173,7 +173,7 @@ function hydrateSession( /** Hydrate session runtime slices (volatile state). */ function hydrateSessionRuntime( draft: Draft, - state: Partial, + state: HydrationState, activeSessionId: string | null, forceMergeSessionId: string | null, ): void { @@ -224,7 +224,7 @@ function hydrateSessionRuntime( } /** Hydrate UI slices without overwriting active connection state. */ -export function hydrateUI(draft: Draft, state: Partial): void { +export function hydrateUI(draft: Draft, state: HydrationState): void { if (state.previewPanel) deepMerge(draft.previewPanel, state.previewPanel); if (state.rightPanel) deepMerge(draft.rightPanel, state.rightPanel); if (state.diffs) deepMerge(draft.diffs, state.diffs); @@ -271,7 +271,7 @@ export function hydrateUI(draft: Draft, state: Partial): voi */ export function hydrateState( draft: Draft, - state: Partial, + state: HydrationState, options: HydrationOptions = {}, ): void { const { @@ -296,15 +296,22 @@ export function hydrateState( Object.assign(draft.office, state.office); } - // Feature flags — overwrite whole map. SSR is authoritative; the backend + // Feature flags - overwrite whole map. SSR is authoritative; the backend // is the only source of truth for what's enabled in this deployment. if (state.features) { Object.assign(draft.features, state.features); } + + // System slice - shallow-merge whichever fields the caller supplied. + // `system` aggregates many independently-fetched fields (info, diskUsage, + // updates, updateNotificationSettings, ...); callers only ever provide the + // subset they fetched, so use the same leaf-level deepMerge as the other + // multi-field slices above rather than overwriting the whole object. + if (state.system) deepMerge(draft.system, state.system); } /** Hydrate GitHub slices, preserving loading states. */ -function hydrateGitHub(draft: Draft, state: Partial): void { +function hydrateGitHub(draft: Draft, state: HydrationState): void { if (state.githubStatus) mergeWithLoading(draft.githubStatus, state.githubStatus); if (state.taskPRs) deepMerge(draft.taskPRs, state.taskPRs); if (state.azureDevOpsTaskPullRequests) { diff --git a/apps/web/lib/state/slices/system/system-slice.ts b/apps/web/lib/state/slices/system/system-slice.ts index 0474be1c43..da812e5ac5 100644 --- a/apps/web/lib/state/slices/system/system-slice.ts +++ b/apps/web/lib/state/slices/system/system-slice.ts @@ -9,6 +9,7 @@ export const defaultSystemState: SystemSliceState = { backups: { items: [], loaded: false }, logs: { files: [], tail: [], tailLoaded: false }, updates: null, + updateNotificationSettings: null, jobs: {}, metrics: null, storage: { overview: null, runs: [], quarantine: [] }, @@ -55,6 +56,10 @@ export const createSystemSlice: StateCreator< set((draft) => { draft.system.updates = updates; }), + setSystemUpdateNotificationSettings: (settings) => + set((draft) => { + draft.system.updateNotificationSettings = settings; + }), upsertSystemJob: (job) => set((draft) => { draft.system.jobs[job.id] = job; diff --git a/apps/web/lib/state/slices/system/types.ts b/apps/web/lib/state/slices/system/types.ts index a9cc2291d7..7b709d0a59 100644 --- a/apps/web/lib/state/slices/system/types.ts +++ b/apps/web/lib/state/slices/system/types.ts @@ -5,6 +5,7 @@ import type { SnapshotInfo, LogFileInfo, UpdatesResponse, + UpdateNotificationSettings, SystemJob, SystemMetricsSnapshot, StorageMaintenanceRun, @@ -33,6 +34,7 @@ export type SystemSliceState = { backups: SystemBackupsState; logs: SystemLogsState; updates: UpdatesResponse | null; + updateNotificationSettings: UpdateNotificationSettings | null; jobs: SystemJobsMap; metrics: SystemMetricsSnapshot | null; storage: { @@ -51,6 +53,7 @@ export type SystemSliceActions = { setSystemLogs: (files: LogFileInfo[]) => void; setSystemLogTail: (lines: string[]) => void; setSystemUpdates: (updates: UpdatesResponse) => void; + setSystemUpdateNotificationSettings: (settings: UpdateNotificationSettings) => void; upsertSystemJob: (job: SystemJob) => void; clearSystemJob: (jobId: string) => void; setSystemMetricsSnapshot: (snapshot: SystemMetricsSnapshot) => void; diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts index 5aac777813..fbea7422b5 100644 --- a/apps/web/lib/state/slices/ui/types.ts +++ b/apps/web/lib/state/slices/ui/types.ts @@ -104,6 +104,11 @@ export type TaskDeletedNotification = { reason?: string; }; +export type UpdateAvailableNotification = { + version: string; + url?: string; +}; + export type BottomTerminalState = { isOpen: boolean; pendingCommand: string | null; @@ -154,6 +159,8 @@ export type UISliceState = { sessionFailureNotification: SessionFailureNotification | null; /** Set when the focused task is deleted live, so a toast can explain why. */ taskDeletedNotification: TaskDeletedNotification | null; + /** Set when the background updates poller reports a newly detected release. */ + updateAvailableNotification: UpdateAvailableNotification | null; bottomTerminal: BottomTerminalState; sidebarViews: SidebarSliceState; /** Parent task IDs whose subtasks are collapsed in the sidebar. Tab-scoped (sessionStorage). */ @@ -219,6 +226,7 @@ export type UISliceActions = { setQuickChatInitialPrompt: (sessionId: string, prompt?: string) => void; setSessionFailureNotification: (n: SessionFailureNotification | null) => void; setTaskDeletedNotification: (n: TaskDeletedNotification | null) => void; + setUpdateAvailableNotification: (n: UpdateAvailableNotification | null) => void; toggleBottomTerminal: () => void; openBottomTerminalWithCommand: (command: string) => void; clearBottomTerminalCommand: () => void; diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts index d236f2b3c8..8d16c0efab 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.ts @@ -84,6 +84,7 @@ export const defaultUIState: UISliceState = { quickChat: { isOpen: false, sessions: [], activeSessionId: null }, sessionFailureNotification: null, taskDeletedNotification: null, + updateAvailableNotification: null, bottomTerminal: { isOpen: false, pendingCommand: null }, sidebarViews: createDefaultSidebarState(), collapsedSubtaskParents: [], @@ -256,6 +257,10 @@ function buildNotificationActions(set: ImmerSet) { set((draft) => { draft.taskDeletedNotification = n; }), + setUpdateAvailableNotification: (n: UISlice["updateAvailableNotification"]) => + set((draft) => { + draft.updateAvailableNotification = n; + }), }; } diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index f11e2b8d38..98a8817ce3 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -243,6 +243,7 @@ export type AppState = { quickChat: (typeof defaultUIState)["quickChat"]; sessionFailureNotification: (typeof defaultUIState)["sessionFailureNotification"]; taskDeletedNotification: (typeof defaultUIState)["taskDeletedNotification"]; + updateAvailableNotification: (typeof defaultUIState)["updateAvailableNotification"]; bottomTerminal: (typeof defaultUIState)["bottomTerminal"]; sidebarViews: (typeof defaultUIState)["sidebarViews"]; collapsedSubtaskParents: (typeof defaultUIState)["collapsedSubtaskParents"]; @@ -294,7 +295,7 @@ export type AppState = { resetLinearIssueWatches: () => void; // Actions from all slices - hydrate: (state: Partial, options?: HydrationOptions) => void; + hydrate: (state: HydrationState, options?: HydrationOptions) => void; setActiveWorkspace: (workspaceId: string | null) => void; setWorkspaces: (workspaces: WorkspaceState["items"]) => void; setActiveWorkflow: (workflowId: string | null) => void; @@ -402,6 +403,7 @@ export type AppState = { setQuickChatInitialPrompt: UIA["setQuickChatInitialPrompt"]; setSessionFailureNotification: (n: UISliceTypes.SessionFailureNotification | null) => void; setTaskDeletedNotification: (n: UISliceTypes.TaskDeletedNotification | null) => void; + setUpdateAvailableNotification: (n: UISliceTypes.UpdateAvailableNotification | null) => void; toggleBottomTerminal: () => void; openBottomTerminalWithCommand: (command: string) => void; clearBottomTerminalCommand: () => void; @@ -613,7 +615,17 @@ export type AppState = { AutomationsSliceActions & PluginsSliceActions; -export function createAppStore(initialState?: Partial) { +// Most callers hydrate a fully-shaped slice per top-level key (see +// mergeInitialState / hydrateState), but `system` is a grab-bag of many +// independently-fetched fields (info, diskUsage, updates, ...). Callers that +// only have one piece of it (e.g. update notification settings from the +// settings boot payload) must be able to pass a partial `system` object +// without fabricating placeholder values for the rest. +export type HydrationState = Omit, "system"> & { + system?: Partial; +}; + +export function createAppStore(initialState?: HydrationState) { const merged = mergeInitialState(initialState); return createStore()( @@ -661,5 +673,5 @@ export function createAppStore(initialState?: Partial) { export type StoreProviderProps = { children: React.ReactNode; - initialState?: Partial; + initialState?: HydrationState; }; diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index 09901f8905..60f43dcbe5 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -149,6 +149,11 @@ export type SystemErrorPayload = { code?: string; }; +export type UpdateAvailablePayload = { + version: string; + url?: string; +}; + export type WorkspacePayload = { id: string; name: string; @@ -484,6 +489,7 @@ export type BackendMessageMap = OfficeBackendMessageMap & "system.error": BackendMessage<"system.error", SystemErrorPayload>; "system.job.update": BackendMessage<"system.job.update", import("./system").SystemJob>; "system.metrics.updated": BackendMessage<"system.metrics.updated", SystemMetricsSnapshot>; + "system.update_available": BackendMessage<"system.update_available", UpdateAvailablePayload>; "workspace.created": BackendMessage<"workspace.created", WorkspacePayload>; "workspace.updated": BackendMessage<"workspace.updated", WorkspacePayload>; "workspace.deleted": BackendMessage<"workspace.deleted", WorkspacePayload>; diff --git a/apps/web/lib/types/system.ts b/apps/web/lib/types/system.ts index 2bc8d95918..cecde93a12 100644 --- a/apps/web/lib/types/system.ts +++ b/apps/web/lib/types/system.ts @@ -79,6 +79,13 @@ export interface UpdatesResponse { manual_commands?: string[]; } +export type UpdateNotificationChannel = "desktop" | "in_view" | "both"; + +export interface UpdateNotificationSettings { + enabled: boolean; + channel: UpdateNotificationChannel; +} + export interface InstallState { running_as_service: boolean; managed_service: boolean; diff --git a/apps/web/lib/ws/handlers/system-events.test.ts b/apps/web/lib/ws/handlers/system-events.test.ts new file mode 100644 index 0000000000..2cee5b2fd1 --- /dev/null +++ b/apps/web/lib/ws/handlers/system-events.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; +import type { StoreApi } from "zustand"; +import type { AppState } from "@/lib/state/store"; +import type { BackendMessageMap } from "@/lib/types/backend"; +import { registerSystemEventsHandlers } from "./system-events"; + +function makeStore() { + const state = { + setUpdateAvailableNotification: vi.fn(), + } as unknown as AppState; + + return { getState: () => state } as unknown as StoreApi; +} + +function makeMessage( + payload: BackendMessageMap["system.update_available"]["payload"], +): BackendMessageMap["system.update_available"] { + return { + id: "msg-1", + type: "notification", + action: "system.update_available", + payload, + timestamp: new Date().toISOString(), + } as BackendMessageMap["system.update_available"]; +} + +describe("system.update_available handler", () => { + it("stores the notification so the toast bridge hook can consume it", () => { + const store = makeStore(); + const handler = registerSystemEventsHandlers(store)["system.update_available"]!; + + handler(makeMessage({ version: "v1.2.3", url: "https://example/v1.2.3" })); + + expect(store.getState().setUpdateAvailableNotification).toHaveBeenCalledWith({ + version: "v1.2.3", + url: "https://example/v1.2.3", + }); + }); +}); diff --git a/apps/web/lib/ws/handlers/system-events.ts b/apps/web/lib/ws/handlers/system-events.ts index e49ad7f484..536baa6211 100644 --- a/apps/web/lib/ws/handlers/system-events.ts +++ b/apps/web/lib/ws/handlers/system-events.ts @@ -16,5 +16,10 @@ export function registerSystemEventsHandlers(store: StoreApi): WsHandl "system.metrics.updated": (message) => { store.getState().setSystemMetricsSnapshot(message.payload); }, + "system.update_available": (message) => { + // Persisted transiently (see slices/ui) so useUpdateAvailableToast can + // consume it and decide desktop/in-view delivery per saved settings. + store.getState().setUpdateAvailableNotification(message.payload); + }, }; } diff --git a/apps/web/src/app-shell.tsx b/apps/web/src/app-shell.tsx index fa2843c35f..4e504503ce 100644 --- a/apps/web/src/app-shell.tsx +++ b/apps/web/src/app-shell.tsx @@ -10,6 +10,7 @@ import { QuickChatProvider } from "@/components/quick-chat/quick-chat-provider"; import { RecentTaskSwitcher } from "@/components/task/recent-task-switcher"; import { SessionFailureToastBridge } from "@/components/session-failure-toast-bridge"; import { TaskDeletedToastBridge } from "@/components/task-deleted-toast-bridge"; +import { UpdateAvailableToastBridge } from "@/components/update-available-toast-bridge"; import { SidebarViewsSyncBridge } from "@/components/sidebar-views-sync-bridge"; import { ThemeProvider } from "@/components/theme-provider"; import { ToastProvider } from "@/components/toast-provider"; @@ -31,6 +32,7 @@ export function AppShell({ children }: AppShellProps) { + diff --git a/apps/web/src/settings-routes.test.ts b/apps/web/src/settings-routes.test.ts index c2df5aa2fa..624f9eebca 100644 --- a/apps/web/src/settings-routes.test.ts +++ b/apps/web/src/settings-routes.test.ts @@ -108,6 +108,16 @@ describe("buildSettingsInitialStateForRoute", () => { expect(loaded.userSettings?.loaded).toBe(true); expect(failed.userSettings).toBeUndefined(); }); + + it("maps the update notification settings from the boot payload", () => { + const state = buildState({ + updateNotificationSettings: { enabled: false, channel: "desktop" }, + }); + + expect(state.system).toEqual({ + updateNotificationSettings: { enabled: false, channel: "desktop" }, + }); + }); }); describe("renderSettingsRoute", () => { @@ -140,6 +150,7 @@ function buildState( availableAgents: [], availableTools: [], userSettingsResponse: null, + updateNotificationSettings: null, ...overrides, }); } diff --git a/apps/web/src/settings-routes.tsx b/apps/web/src/settings-routes.tsx index 2bbf922e09..046e9194f5 100644 --- a/apps/web/src/settings-routes.tsx +++ b/apps/web/src/settings-routes.tsx @@ -56,6 +56,7 @@ import { LogViewer } from "@/components/settings/system/log-viewer"; import { SystemPageShell } from "@/components/settings/system/system-page-shell"; import { UIStateCard } from "@/components/settings/system/ui-state-card"; import { UpdatesCard } from "@/components/settings/system/updates-card"; +import { UpdateNotificationsCard } from "@/components/settings/system/update-notifications-card"; import { VersionSummaryCard } from "@/components/settings/system/version-summary-card"; import { TerminalSettings } from "@/components/settings/terminal-settings"; import { VoiceModeSettings } from "@/components/settings/voice-mode-settings"; @@ -67,6 +68,7 @@ import { } from "@/components/plugins/plugin-error-boundary"; import { pluginRegistry, usePluginRegistry } from "@/lib/plugins/registry"; import { listWorkflows } from "@/lib/api/domains/kanban-api"; +import { fetchUpdateNotificationSettings } from "@/lib/api/domains/system-api"; import { fetchUserSettings, listAgentDiscovery, @@ -84,7 +86,7 @@ import { resolveSettingsActiveWorkspaceId, } from "@/lib/routing/route-bootstrap"; import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; -import type { AppState } from "@/lib/state/store"; +import type { HydrationState } from "@/lib/state/store"; import { toAgentProfileOption } from "@/lib/state/slices/settings/types"; import type { ListWorkspacesResponse, @@ -95,7 +97,7 @@ import type { WorkflowTemplate, Workspace, } from "@/lib/types/http"; -import type { LicenseEntry } from "@/lib/types/system"; +import type { LicenseEntry, UpdateNotificationSettings } from "@/lib/types/system"; type RouteRenderer = () => ReactNode; type RepositoryWithScripts = Repository & { scripts: RepositoryScript[] }; @@ -116,6 +118,7 @@ type SettingsInitialStateData = { availableAgents: Awaited>["agents"]; availableTools: NonNullable>["tools"]>; userSettingsResponse: UserSettingsResponse | null; + updateNotificationSettings: UpdateNotificationSettings | null; }; const licenseEntries = licenses as LicenseEntry[]; @@ -400,6 +403,7 @@ function renderUpdatesRoute() { description="Current vs latest release plus the full kandev changelog." > + ); } @@ -440,16 +444,24 @@ function SettingsRouteBootstrap({ pathname }: { pathname: string }) { return null; } -async function loadSettingsInitialState(): Promise> { - const [workspaces, executors, agents, discovery, available, userSettingsResponse] = - await Promise.all([ - listWorkspaces({ cache: "no-store" }).catch(() => ({ workspaces: [] })), - listExecutors({ cache: "no-store" }).catch(() => ({ executors: [] })), - listAgents({ cache: "no-store" }).catch(() => ({ agents: [] })), - listAgentDiscovery({ cache: "no-store" }).catch(() => ({ agents: [] })), - listAvailableAgents({ cache: "no-store" }).catch(() => ({ agents: [], tools: [] })), - fetchUserSettings({ cache: "no-store" }).catch(() => null), - ]); +async function loadSettingsInitialState(): Promise { + const [ + workspaces, + executors, + agents, + discovery, + available, + userSettingsResponse, + updateNotificationSettings, + ] = await Promise.all([ + listWorkspaces({ cache: "no-store" }).catch(() => ({ workspaces: [] })), + listExecutors({ cache: "no-store" }).catch(() => ({ executors: [] })), + listAgents({ cache: "no-store" }).catch(() => ({ agents: [] })), + listAgentDiscovery({ cache: "no-store" }).catch(() => ({ agents: [] })), + listAvailableAgents({ cache: "no-store" }).catch(() => ({ agents: [], tools: [] })), + fetchUserSettings({ cache: "no-store" }).catch(() => null), + fetchUpdateNotificationSettings({ cache: "no-store" }).catch(() => null), + ]); return buildSettingsInitialStateForRoute({ workspaces: workspaces.workspaces, @@ -459,6 +471,7 @@ async function loadSettingsInitialState(): Promise> { availableAgents: available.agents, availableTools: available.tools ?? [], userSettingsResponse, + updateNotificationSettings, }); } @@ -470,7 +483,8 @@ export function buildSettingsInitialStateForRoute({ availableAgents, availableTools, userSettingsResponse, -}: SettingsInitialStateData): Partial { + updateNotificationSettings, +}: SettingsInitialStateData): HydrationState { const workspaceItems = workspaces.map(mapWorkspaceItem); const activeWorkspaceId = resolveSettingsActiveWorkspaceId( workspaceItems, @@ -497,6 +511,7 @@ export function buildSettingsInitialStateForRoute({ loaded: true, }, settingsData: { executorsLoaded: true, agentsLoaded: true }, + system: { updateNotificationSettings }, ...(mappedUserSettings.loaded ? { userSettings: { diff --git a/docs/public/operations.md b/docs/public/operations.md index 18de9e54bf..3f7e1b4502 100644 --- a/docs/public/operations.md +++ b/docs/public/operations.md @@ -239,6 +239,8 @@ Never delete a managed task directory merely because its database row looks term The backend contacts the public GitHub Releases API once at startup and every six hours, with a 30-second HTTP timeout, and persists the last successful result. **Check now** performs a synchronous request and permits one manual check per process every 30 seconds. Offline or rate-limited installations continue to show cached state. +**Settings > System > Updates > Update notifications** controls whether a newly detected release triggers a notification, and on which channel: **Desktop notification** (native OS notification, or a browser notification when not running the desktop app), **In-app banner**, or **Both**. Defaults to enabled on both channels; disabling stops delivery without losing the underlying update check. Each release only notifies once per install. + Before any update, finish or stop active sessions, create and export a database backup plus its master key, preserve unpushed Git work, and read release notes. - Desktop: use **Settings > System > Updates** when signed updater assets are available; otherwise install the new desktop package.