feat: notify user when a kandev update is detected (#1924)
* feat: notify user when a kandev update is detected Adds a background-poller-driven update notification with a saved preference for delivery channel (desktop / in-app banner / both) and an enable/disable switch on the Updates settings page. Backend: - internal/system/updates: NotifySettings + NotifyStore persist the enable/channel preference on the existing install-wide settings table; notifyIfNewUpdate broadcasts a WS event the first time a newer release is observed, deduped durably via kandev_meta so a restart or repeat poll doesn't re-announce the same version. - New GET/PUT /api/v1/system/updates/notification-settings endpoints. - pkg/websocket: new system.update_available action. Desktop (Tauri): - native_notifications.rs: task_id is now optional (Option<String>, required only for session-scoped events) and the event allowlist accepts system.update_available: alongside the existing session events, so the update notification can use the native bridge. Frontend: - New UpdateNotificationsCard on the Updates settings page (Switch + channel Select), wired through the shared settings-save-provider floating Save/Discard action. - useUpdateAvailableToast bridges the WS event to an in-app toast and/or the native/browser desktop notification, per the saved channel, deduped per version. * docs: document the update notification channel setting * fix: hydrate update notification settings and broadcast delivery ack - Only mark a release notification as delivered when the broadcaster reports at least one connected client, closing a race where an update could be silently dropped before the first client connects. - Hydrate updateNotificationSettings through the settings boot payload (loadSettingsInitialState -> hydrateState) instead of a component- level fetch, per the boot-payload -> store -> component data flow. Widen the hydration types (HydrationState) so callers can hydrate a partial system slice without fabricating the other system fields. UpdateNotificationsCard now only reads/writes the subscribed store state. - Fix module-scope settings-fetch cache leaking across tests in use-update-available-toast.test.ts so the fetch-failure test actually exercises the catch/fallback path. - Add API-client contract tests for fetchUpdateNotificationSettings and saveUpdateNotificationSettings (GET cache policy, PUT method and body). * test: cover delayed hydration and boot-payload mapping for update notifications
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<String>,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, unknown> | undefined = initial
|
||||
? { system: { updateNotificationSettings: initial } }
|
||||
: undefined;
|
||||
return render(
|
||||
<StateProvider initialState={initialState}>
|
||||
<ToastProvider>
|
||||
<SettingsSaveProvider>
|
||||
<UpdateNotificationsCard />
|
||||
</SettingsSaveProvider>
|
||||
</ToastProvider>
|
||||
</StateProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function StoreCapture({ onStore }: { onStore: (store: StoreApi<AppState>) => void }) {
|
||||
onStore(useAppStoreApi());
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderCardCapturingStore() {
|
||||
let store: StoreApi<AppState> | undefined;
|
||||
const utils = render(
|
||||
<StateProvider>
|
||||
<ToastProvider>
|
||||
<SettingsSaveProvider>
|
||||
<StoreCapture onStore={(s) => (store = s)} />
|
||||
<UpdateNotificationsCard />
|
||||
</SettingsSaveProvider>
|
||||
</ToastProvider>
|
||||
</StateProvider>,
|
||||
);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<UpdateNotificationSettings>(stored ?? DEFAULT_SETTINGS);
|
||||
const [draft, setDraft] = useState<UpdateNotificationSettings>(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 (
|
||||
<SettingsCard isDirty={isDirty} data-testid="update-notifications-card">
|
||||
<CardHeader>
|
||||
<CardTitle>Update notifications</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-base font-medium">Notify me about new releases</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={draft.enabled}
|
||||
data-settings-dirty={draft.enabled !== saved.enabled}
|
||||
onCheckedChange={(enabled) => setDraft({ ...draft, enabled })}
|
||||
aria-label="Enable update notifications"
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
{draft.enabled && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={draft.channel}
|
||||
onValueChange={(channel) =>
|
||||
setDraft({ ...draft, channel: channel as UpdateNotificationChannel })
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-56 cursor-pointer"
|
||||
data-settings-dirty={draft.channel !== saved.channel}
|
||||
aria-label="Update notification channel"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CHANNEL_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="cursor-pointer">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">{activeChannel?.help}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>) => 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);
|
||||
});
|
||||
});
|
||||
@@ -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<UpdateNotificationSettings> | null = null;
|
||||
|
||||
function loadSettings(): Promise<UpdateNotificationSettings> {
|
||||
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<Set<string>>(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]);
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<UpdateNotificationSettings> {
|
||||
return fetchJson<UpdateNotificationSettings>(`${SYSTEM_BASE}/updates/notification-settings`, {
|
||||
...options,
|
||||
cache: "no-store",
|
||||
});
|
||||
}
|
||||
|
||||
export function saveUpdateNotificationSettings(
|
||||
settings: UpdateNotificationSettings,
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<UpdateNotificationSettings> {
|
||||
return fetchJson<UpdateNotificationSettings>(`${SYSTEM_BASE}/updates/notification-settings`, {
|
||||
...options,
|
||||
init: { ...(options?.init ?? {}), method: "PUT", body: JSON.stringify(settings) },
|
||||
});
|
||||
}
|
||||
|
||||
// --- Restart ------------------------------------------------------------
|
||||
|
||||
export function fetchRestartCapability(options?: ApiRequestOptions): Promise<RestartCapability> {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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<DefaultState>,
|
||||
s: HydrationState,
|
||||
): Pick<
|
||||
DefaultState,
|
||||
| "taskMRs"
|
||||
@@ -142,7 +143,7 @@ function mergeCodeHostFields(
|
||||
};
|
||||
}
|
||||
|
||||
function mergeQuickChatState(initialState: Partial<DefaultState>): 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>): DefaultState[
|
||||
};
|
||||
}
|
||||
|
||||
function mergeSidebarViewState(initialState: Partial<DefaultState>): 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<DefaultState>): DefaultStat
|
||||
}
|
||||
|
||||
function mergeSidebarTaskPrefsState(
|
||||
initialState: Partial<DefaultState>,
|
||||
initialState: HydrationState,
|
||||
): DefaultState["sidebarTaskPrefs"] {
|
||||
const sidebarTaskPrefs = {
|
||||
...defaultState.sidebarTaskPrefs,
|
||||
@@ -200,7 +201,7 @@ function mergeSidebarTaskPrefsState(
|
||||
}
|
||||
|
||||
function mergeReviewPRSelectionState(
|
||||
initialState: Partial<DefaultState>,
|
||||
initialState: HydrationState,
|
||||
): DefaultState["reviewPRSelection"] {
|
||||
return {
|
||||
...defaultState.reviewPRSelection,
|
||||
@@ -212,11 +213,11 @@ function mergeReviewPRSelectionState(
|
||||
};
|
||||
}
|
||||
|
||||
function mergeSessionFailureNotification(initialState: Partial<DefaultState>) {
|
||||
function mergeSessionFailureNotification(initialState: HydrationState) {
|
||||
return initialState.sessionFailureNotification ?? defaultState.sessionFailureNotification;
|
||||
}
|
||||
|
||||
export function mergeInitialState(initialState?: Partial<DefaultState>): DefaultState {
|
||||
export function mergeInitialState(initialState?: HydrationState): DefaultState {
|
||||
if (!initialState) return defaultState;
|
||||
return {
|
||||
...defaultState,
|
||||
|
||||
@@ -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<AppState>) => {
|
||||
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<AppState>) => {
|
||||
hydrateState(draft, {});
|
||||
});
|
||||
|
||||
expect(result.system).toEqual(defaultState.system);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<AppState>, state: Partial<AppState>): void {
|
||||
function hydrateKanbanAndWorkspace(draft: Draft<AppState>, 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<AppState>, state: Partial<AppSta
|
||||
}
|
||||
|
||||
/** Hydrate settings slices, preserving loading states. */
|
||||
function hydrateSettings(draft: Draft<AppState>, state: Partial<AppState>): void {
|
||||
function hydrateSettings(draft: Draft<AppState>, 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<AppState>,
|
||||
state: Partial<AppState>,
|
||||
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<AppState>,
|
||||
state: Partial<AppState>,
|
||||
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<AppState>, state: Partial<AppState>): void {
|
||||
export function hydrateUI(draft: Draft<AppState>, 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<AppState>, state: Partial<AppState>): voi
|
||||
*/
|
||||
export function hydrateState(
|
||||
draft: Draft<AppState>,
|
||||
state: Partial<AppState>,
|
||||
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<AppState>, state: Partial<AppState>): void {
|
||||
function hydrateGitHub(draft: Draft<AppState>, state: HydrationState): void {
|
||||
if (state.githubStatus) mergeWithLoading(draft.githubStatus, state.githubStatus);
|
||||
if (state.taskPRs) deepMerge(draft.taskPRs, state.taskPRs);
|
||||
if (state.azureDevOpsTaskPullRequests) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AppState>, 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<AppState>) {
|
||||
// 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<Partial<AppState>, "system"> & {
|
||||
system?: Partial<AppState["system"]>;
|
||||
};
|
||||
|
||||
export function createAppStore(initialState?: HydrationState) {
|
||||
const merged = mergeInitialState(initialState);
|
||||
|
||||
return createStore<AppState>()(
|
||||
@@ -661,5 +673,5 @@ export function createAppStore(initialState?: Partial<AppState>) {
|
||||
|
||||
export type StoreProviderProps = {
|
||||
children: React.ReactNode;
|
||||
initialState?: Partial<AppState>;
|
||||
initialState?: HydrationState;
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AppState>;
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,5 +16,10 @@ export function registerSystemEventsHandlers(store: StoreApi<AppState>): 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
<SonnerToaster richColors position="top-right" />
|
||||
<SessionFailureToastBridge />
|
||||
<TaskDeletedToastBridge />
|
||||
<UpdateAvailableToastBridge />
|
||||
<SidebarViewsSyncBridge />
|
||||
<LogBufferBridge />
|
||||
<CommandRegistryProvider>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<ReturnType<typeof listAvailableAgents>>["agents"];
|
||||
availableTools: NonNullable<Awaited<ReturnType<typeof listAvailableAgents>>["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."
|
||||
>
|
||||
<UpdatesCard />
|
||||
<UpdateNotificationsCard />
|
||||
</SystemPageShell>
|
||||
);
|
||||
}
|
||||
@@ -440,16 +444,24 @@ function SettingsRouteBootstrap({ pathname }: { pathname: string }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadSettingsInitialState(): Promise<Partial<AppState>> {
|
||||
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<HydrationState> {
|
||||
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<Partial<AppState>> {
|
||||
availableAgents: available.agents,
|
||||
availableTools: available.tools ?? [],
|
||||
userSettingsResponse,
|
||||
updateNotificationSettings,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -470,7 +483,8 @@ export function buildSettingsInitialStateForRoute({
|
||||
availableAgents,
|
||||
availableTools,
|
||||
userSettingsResponse,
|
||||
}: SettingsInitialStateData): Partial<AppState> {
|
||||
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: {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user