From 715a2033b2381b5fe5ae9d395158926c93128a0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sat, 25 Jul 2026 11:07:06 +0100 Subject: [PATCH] fix: route update alerts through notification providers (#1938) --- apps/backend/internal/backendapp/main.go | 22 +- .../internal/backendapp/postgres_boot_test.go | 2 +- apps/backend/internal/backendapp/storage.go | 21 +- .../backend/internal/gateway/websocket/hub.go | 33 +- .../gateway/websocket/hub_broadcast_test.go | 52 +++ .../websocket/hub_user_subscription_test.go | 33 ++ .../internal/notifications/providers/local.go | 6 +- .../notifications/providers/local_test.go | 123 ++++++ .../notifications/providers/provider.go | 4 + .../internal/notifications/service/service.go | 50 ++- .../notifications/service/service_test.go | 172 +++++++- .../internal/notifications/store/provider.go | 10 +- .../internal/notifications/store/sqlite.go | 75 +++- .../notifications/store/sqlite_test.go | 100 ++++- apps/backend/internal/persistence/meta.go | 16 - .../backend/internal/persistence/meta_test.go | 42 -- .../persistence/postgres_meta_test.go | 19 - apps/backend/internal/system/system.go | 6 - .../internal/system/updates/handler.go | 35 -- .../system/updates/handler_notify_test.go | 127 ------ .../system/updates/notify_dispatch_test.go | 277 ++++++------- .../system/updates/notify_settings.go | 44 --- .../system/updates/notify_settings_test.go | 45 --- .../internal/system/updates/notify_store.go | 52 --- .../system/updates/notify_store_test.go | 67 ---- .../internal/system/updates/service.go | 145 ++----- apps/desktop/src-tauri/build.rs | 2 + .../src-tauri/capabilities/default.json | 2 + .../src-tauri/gen/schemas/acl-manifests.json | 2 +- .../src-tauri/gen/schemas/capabilities.json | 2 +- .../src-tauri/gen/schemas/desktop-schema.json | 108 ++++- .../src-tauri/gen/schemas/linux-schema.json | 108 ++++- .../get_native_notification_permission.toml | 11 + ...equest_native_notification_permission.toml | 11 + apps/desktop/src-tauri/src/backend.rs | 3 +- apps/desktop/src-tauri/src/main.rs | 2 + .../src-tauri/src/native_notifications.rs | 104 ++--- .../notification-permission-section.test.tsx | 90 +++++ .../notification-permission-section.tsx | 146 +++++++ .../notifications-settings-actions.test.tsx | 157 +++++++- .../notifications-settings-actions.ts | 38 +- .../settings/notifications-settings.tsx | 112 +----- .../system/update-notifications-card.test.tsx | 126 ------ .../system/update-notifications-card.tsx | 133 ------- .../mobile-notification-events.spec.ts | 38 +- .../update-notifications-settings.spec.ts | 62 --- .../hooks/use-update-available-toast.test.ts | 122 ++---- apps/web/hooks/use-update-available-toast.ts | 77 +--- apps/web/lib/api/domains/system-api.test.ts | 25 -- apps/web/lib/api/domains/system-api.ts | 20 - .../native-notification-client.test.ts | 36 ++ .../lib/desktop/native-notification-client.ts | 24 +- apps/web/lib/notifications/events.ts | 5 + apps/web/lib/state/hydration/hydrator.test.ts | 18 - apps/web/lib/state/hydration/hydrator.ts | 2 +- .../lib/state/slices/system/system-slice.ts | 5 - apps/web/lib/state/slices/system/types.ts | 3 - apps/web/lib/state/slices/ui/types.ts | 3 + apps/web/lib/types/backend.ts | 3 + apps/web/lib/types/system.ts | 7 - .../web/lib/ws/handlers/notifications.test.ts | 43 +- apps/web/lib/ws/handlers/notifications.ts | 4 + .../web/lib/ws/handlers/system-events.test.ts | 39 -- apps/web/lib/ws/handlers/system-events.ts | 5 - apps/web/src/settings-routes.test.ts | 25 +- apps/web/src/settings-routes.tsx | 43 +- ...039-native-desktop-integration-boundary.md | 9 +- ...2026-07-24-semantic-notification-events.md | 6 + .../update-notification-reliability/plan.md | 372 ++++++++++++++++++ .../task-01-provider-event-and-delivery.md | 56 +++ .../task-02-update-replay-and-wiring.md | 65 +++ .../task-03-native-permission-bridge.md | 56 +++ .../task-04-frontend-provider-delivery.md | 83 ++++ .../task-05-mobile-e2e-and-docs.md | 53 +++ docs/public/operations.md | 2 +- docs/public/websocket-api.md | 1 + docs/specs/desktop-tauri-app/spec.md | 13 +- docs/specs/platform/notifications.md | 79 +++- docs/specs/system-page/spec.md | 7 + 79 files changed, 2506 insertions(+), 1640 deletions(-) create mode 100644 apps/backend/internal/gateway/websocket/hub_user_subscription_test.go create mode 100644 apps/backend/internal/notifications/providers/local_test.go delete mode 100644 apps/backend/internal/system/updates/handler_notify_test.go delete mode 100644 apps/backend/internal/system/updates/notify_settings.go delete mode 100644 apps/backend/internal/system/updates/notify_settings_test.go delete mode 100644 apps/backend/internal/system/updates/notify_store.go delete mode 100644 apps/backend/internal/system/updates/notify_store_test.go create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/get_native_notification_permission.toml create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/request_native_notification_permission.toml create mode 100644 apps/web/components/settings/notification-permission-section.test.tsx create mode 100644 apps/web/components/settings/notification-permission-section.tsx delete mode 100644 apps/web/components/settings/system/update-notifications-card.test.tsx delete mode 100644 apps/web/components/settings/system/update-notifications-card.tsx delete mode 100644 apps/web/e2e/tests/system/update-notifications-settings.spec.ts create mode 100644 apps/web/lib/desktop/native-notification-client.test.ts delete mode 100644 apps/web/lib/ws/handlers/system-events.test.ts create mode 100644 docs/plans/update-notification-reliability/plan.md create mode 100644 docs/plans/update-notification-reliability/task-01-provider-event-and-delivery.md create mode 100644 docs/plans/update-notification-reliability/task-02-update-replay-and-wiring.md create mode 100644 docs/plans/update-notification-reliability/task-03-native-permission-bridge.md create mode 100644 docs/plans/update-notification-reliability/task-04-frontend-provider-delivery.md create mode 100644 docs/plans/update-notification-reliability/task-05-mobile-e2e-and-docs.md diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index fecf709bea..21805cc260 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -30,7 +30,6 @@ 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" @@ -61,6 +60,7 @@ import ( notificationcontroller "github.com/kandev/kandev/internal/notifications/controller" promptcontroller "github.com/kandev/kandev/internal/prompts/controller" usercontroller "github.com/kandev/kandev/internal/user/controller" + userstore "github.com/kandev/kandev/internal/user/store" utilitycontroller "github.com/kandev/kandev/internal/utility/controller" // Orchestrator @@ -311,7 +311,7 @@ func startServices( //nolint:cyclop // ============================================ log.Info("Initializing Task Service...") - dbPool, repos, repoCleanups, err := provideRepositories(cfg, log, Version) + dbPool, repos, repoCleanups, err := provideRepositories(ctx, cfg, log, Version) if err != nil { log.Error("Failed to initialize repositories", zap.Error(err)) return false @@ -666,7 +666,7 @@ func startGatewayAndServe( if services.Mentions != nil { referenceValidator = services.Mentions.Submission } - gateway, _, notificationCtrl, terminalSvc, err := provideGateway( + gateway, notificationSvc, notificationCtrl, terminalSvc, err := provideGateway( ctx, log, eventBus, services.Task, services.User, orchestratorSvc, lifecycleMgr, agentRegistry, repos.Notification, repos.Task, repos.Terminal, services.GitHub, services.GitLab, @@ -804,16 +804,14 @@ func startGatewayAndServe( 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 + systemSvc.Updates.SetNotifier(notificationSvc) + gateway.Hub.AddUserSubscriptionListener(func(userID string) { + if userID != userstore.DefaultUserID { + return + } + if err := systemSvc.Updates.ReplayCachedUpdate(ctx); err != nil { + log.Warn("failed to replay cached update notification", zap.Error(err)) } - gateway.Hub.Broadcast(msg) - return true }) } systemSvc.StartBackground(ctx) diff --git a/apps/backend/internal/backendapp/postgres_boot_test.go b/apps/backend/internal/backendapp/postgres_boot_test.go index b315dc3303..b077925fed 100644 --- a/apps/backend/internal/backendapp/postgres_boot_test.go +++ b/apps/backend/internal/backendapp/postgres_boot_test.go @@ -51,7 +51,7 @@ func TestPostgresBootInitializesRepositories(t *testing.T) { } log := newTestLogger() - pool, repos, cleanups, err := provideRepositories(cfg, log, "test-postgres-boot") + pool, repos, cleanups, err := provideRepositories(context.Background(), cfg, log, "test-postgres-boot") if err != nil { t.Fatalf("provide repositories with postgres: %v", err) } diff --git a/apps/backend/internal/backendapp/storage.go b/apps/backend/internal/backendapp/storage.go index 34bf06cbaf..09b93753a8 100644 --- a/apps/backend/internal/backendapp/storage.go +++ b/apps/backend/internal/backendapp/storage.go @@ -1,6 +1,7 @@ package backendapp import ( + "context" "fmt" "github.com/jmoiron/sqlx" @@ -27,59 +28,50 @@ import ( userstore "github.com/kandev/kandev/internal/user/store" ) -func provideRepositories(cfg *config.Config, log *logger.Logger, version string) (*db.Pool, *Repositories, []func() error, error) { +func provideRepositories(ctx context.Context, cfg *config.Config, log *logger.Logger, version string) (*db.Pool, *Repositories, []func() error, error) { cleanups := make([]func() error, 0, 12) pool, cleanup, err := persistence.Provide(cfg, log, version) if err != nil { return nil, nil, nil, err } cleanups = append(cleanups, cleanup) - - writer := pool.Writer() - reader := pool.Reader() + writer, reader := pool.Writer(), pool.Reader() taskRepoImpl, cleanup, err := repository.Provide(writer, reader, log) if err != nil { return nil, nil, nil, err } cleanups = append(cleanups, cleanup) - // Workflow repo must be initialized before analytics repo because // analytics creates indexes on the workflow_steps table. workflowRepo, err := workflowrepository.NewWithDB(writer, reader, log) if err != nil { return nil, nil, nil, err } - analyticsRepo, cleanup, err := analyticsrepository.Provide(writer, reader) if err != nil { return nil, nil, nil, err } cleanups = append(cleanups, cleanup) - agentSettingsRepo, cleanup, err := settingsstore.Provide(writer, reader, log) if err != nil { return nil, nil, nil, err } cleanups = append(cleanups, cleanup) - - supportRepos, supportCleanups, err := provideSupportRepos(writer, reader) + supportRepos, supportCleanups, err := provideSupportRepos(ctx, writer, reader) if err != nil { return nil, nil, nil, err } cleanups = append(cleanups, supportCleanups...) - officeRepo, officeCleanup, err := office.Provide(writer, reader, log) if err != nil { return nil, nil, nil, fmt.Errorf("office repo: %w", err) } cleanups = append(cleanups, officeCleanup) - terminalRepoImpl, err := terminalrepo.NewWithDB(writer, reader, log) if err != nil { return nil, nil, nil, fmt.Errorf("terminal repo: %w", err) } - runtimeFlagsStore, err := runtimeflags.NewSQLiteStore(writer, reader) if err != nil { return nil, nil, nil, fmt.Errorf("runtime flags store: %w", err) @@ -89,7 +81,6 @@ func provideRepositories(cfg *config.Config, log *logger.Logger, version string) if err != nil { return nil, nil, nil, fmt.Errorf("auth store: %w", err) } - masterKeyProvider, err := secrets.NewMasterKeyProvider(cfg.ResolvedDataDir()) if err != nil { return nil, nil, nil, fmt.Errorf("master key: %w", err) @@ -138,7 +129,7 @@ type supportRepositorySet struct { // provideSupportRepos wires up user, notification, editor, prompt, and utility // repositories. Extracted from provideRepositories to keep its statement count // within the funlen limit. -func provideSupportRepos(writer, reader *sqlx.DB) (supportRepositorySet, []func() error, error) { +func provideSupportRepos(ctx context.Context, writer, reader *sqlx.DB) (supportRepositorySet, []func() error, error) { var cleanups []func() error var repos supportRepositorySet @@ -151,7 +142,7 @@ func provideSupportRepos(writer, reader *sqlx.DB) (supportRepositorySet, []func( // Same concrete store, account-management view (used by internal/auth). repos.userAccounts = userRepo - notificationRepo, cleanup, err := notificationstore.Provide(writer, reader) + notificationRepo, cleanup, err := notificationstore.Provide(ctx, writer, reader) if err != nil { return repos, nil, err } diff --git a/apps/backend/internal/gateway/websocket/hub.go b/apps/backend/internal/gateway/websocket/hub.go index b5d7dad009..5b0ffc91a1 100644 --- a/apps/backend/internal/gateway/websocket/hub.go +++ b/apps/backend/internal/gateway/websocket/hub.go @@ -45,7 +45,8 @@ type Hub struct { dispatcher *ws.Dispatcher // Optional provider for session data on subscription (e.g., git status) - sessionDataProvider SessionDataProvider + sessionDataProvider SessionDataProvider + userSubscriptionListeners []func(userID string) // sessionMode tracks per-session focus state and fires listeners when // effective mode (paused/slow/fast) transitions. See hub_session_mode.go. @@ -357,9 +358,11 @@ func (h *Hub) getSubscribersLocked(m map[string]map[*Client]bool, id string) []* } // sendToClients delivers a pre-marshalled message to a list of clients. -func (h *Hub) sendToClients(data []byte, clients []*Client, action string) { +func (h *Hub) sendToClients(data []byte, clients []*Client, action string) int { + queued := 0 for _, client := range clients { if client.sendBytes(data) { + queued++ h.logger.Debug("Sent message to client", zap.String("client_id", client.ID), zap.String("action", action)) @@ -369,6 +372,7 @@ func (h *Hub) sendToClients(data []byte, clients []*Client, action string) { zap.String("action", action)) } } + return queued } // BroadcastToTask sends a notification to clients subscribed to a specific task @@ -435,18 +439,18 @@ func (h *Hub) BroadcastToSession(sessionID string, msg *ws.Message) { } // BroadcastToUser sends a notification to clients subscribed to a specific user -func (h *Hub) BroadcastToUser(userID string, msg *ws.Message) { +func (h *Hub) BroadcastToUser(userID string, msg *ws.Message) bool { data, err := json.Marshal(msg) if err != nil { h.logger.Error("Failed to marshal message", zap.Error(err)) - return + return false } clients := h.getSubscribersLocked(h.userSubscribers, userID) h.logger.Debug("BroadcastToUser", zap.String("user_id", userID), zap.String("action", msg.Action), zap.Int("subscriber_count", len(clients))) - h.sendToClients(data, clients, msg.Action) + return h.sendToClients(data, clients, msg.Action) > 0 } // SubscribeToTask subscribes a client to task notifications @@ -500,17 +504,32 @@ func (h *Hub) UnsubscribeFromSession(client *Client, sessionID string) { // SubscribeToUser subscribes a client to user notifications func (h *Hub) SubscribeToUser(client *Client, userID string) { h.mu.Lock() - defer h.mu.Unlock() - if _, ok := h.userSubscribers[userID]; !ok { h.userSubscribers[userID] = make(map[*Client]bool) } h.userSubscribers[userID][client] = true client.userSubscriptions[userID] = true + listeners := append([]func(string){}, h.userSubscriptionListeners...) + h.mu.Unlock() h.logger.Debug("Client subscribed to user", zap.String("client_id", client.ID), zap.String("user_id", userID)) + for _, listener := range listeners { + listener(userID) + } +} + +// AddUserSubscriptionListener registers a callback invoked after a user has +// been registered as a subscriber. Callbacks run without the hub lock held so +// they can safely send messages through the hub. +func (h *Hub) AddUserSubscriptionListener(listener func(userID string)) { + if listener == nil { + return + } + h.mu.Lock() + h.userSubscriptionListeners = append(h.userSubscriptionListeners, listener) + h.mu.Unlock() } // UnsubscribeFromUser unsubscribes a client from user notifications diff --git a/apps/backend/internal/gateway/websocket/hub_broadcast_test.go b/apps/backend/internal/gateway/websocket/hub_broadcast_test.go index 50da21d6f3..35f53e7dc2 100644 --- a/apps/backend/internal/gateway/websocket/hub_broadcast_test.go +++ b/apps/backend/internal/gateway/websocket/hub_broadcast_test.go @@ -117,3 +117,55 @@ func TestBroadcastToSession_DeliversOnceToSubscribedAndFocusedClient(t *testing. t.Fatal("subscribed+focused client received a duplicate broadcast") } } + +func TestBroadcastToUserReportsWhetherAFrameWasQueued(t *testing.T) { + h := newTestHub(t) + msg, err := ws.NewNotification("system.update_available", map[string]any{"occurrence_id": "v1.2.3"}) + if err != nil { + t.Fatalf("notification: %v", err) + } + if h.BroadcastToUser("user-1", msg) { + t.Fatal("broadcast without a user subscriber must report no queued recipient") + } + + c := newTestClient("c1") + registerTestClient(h, c) + h.SubscribeToUser(c, "user-1") + if !h.BroadcastToUser("user-1", msg) { + t.Fatal("broadcast with a user subscriber must report a queued recipient") + } +} + +func TestBroadcastToUserReportsFalseWhenSubscriberBufferIsFull(t *testing.T) { + h := newTestHub(t) + c := newTestClient("c1") + registerTestClient(h, c) + h.SubscribeToUser(c, "user-1") + for index := 0; index < cap(c.send); index++ { + c.send <- []byte("queued") + } + + msg, err := ws.NewNotification("system.update_available", map[string]any{"occurrence_id": "v1.2.3"}) + if err != nil { + t.Fatalf("notification: %v", err) + } + if h.BroadcastToUser("user-1", msg) { + t.Fatal("broadcast with a full subscriber buffer must report no queued recipient") + } +} + +func TestBroadcastToUserReportsFalseWhenSubscriberIsClosed(t *testing.T) { + h := newTestHub(t) + c := newTestClient("c1") + registerTestClient(h, c) + h.SubscribeToUser(c, "user-1") + c.closeSend() + + msg, err := ws.NewNotification("system.update_available", map[string]any{"occurrence_id": "v1.2.3"}) + if err != nil { + t.Fatalf("notification: %v", err) + } + if h.BroadcastToUser("user-1", msg) { + t.Fatal("broadcast to a closed subscriber must report no queued recipient") + } +} diff --git a/apps/backend/internal/gateway/websocket/hub_user_subscription_test.go b/apps/backend/internal/gateway/websocket/hub_user_subscription_test.go new file mode 100644 index 0000000000..500ef65d88 --- /dev/null +++ b/apps/backend/internal/gateway/websocket/hub_user_subscription_test.go @@ -0,0 +1,33 @@ +package websocket + +import ( + "testing" + + ws "github.com/kandev/kandev/pkg/websocket" +) + +func TestHub_UserSubscriptionListenerRunsAfterRegistrationOutsideHubLock(t *testing.T) { + h := newTestHub(t) + client := newTestClient("c1") + + called := false + h.AddUserSubscriptionListener(func(userID string) { + called = true + msg, err := ws.NewNotification("system.update_available", map[string]any{"occurrence_id": "v1.0.1"}) + if err != nil { + t.Fatalf("new notification: %v", err) + } + if !h.BroadcastToUser(userID, msg) { + t.Fatal("subscription listener ran before user registration") + } + }) + + h.SubscribeToUser(client, "default") + + if !called { + t.Fatal("user subscription listener was not called") + } + if !clientReceived(client) { + t.Fatal("listener broadcast did not reach newly subscribed client") + } +} diff --git a/apps/backend/internal/notifications/providers/local.go b/apps/backend/internal/notifications/providers/local.go index 3ce148d7e2..436c59595b 100644 --- a/apps/backend/internal/notifications/providers/local.go +++ b/apps/backend/internal/notifications/providers/local.go @@ -34,11 +34,15 @@ func (p *LocalProvider) Send(_ context.Context, message Message) error { "occurrence_id": message.OccurrenceID, "title": message.Title, "body": message.Body, + "version": message.Payload["version"], + "url": message.Payload["url"], }) if err != nil { return err } msg.ID = message.OccurrenceID - p.hub.BroadcastToUser(message.UserID, msg) + if !p.hub.BroadcastToUser(message.UserID, msg) { + return ErrNoEligibleSubscriber + } return nil } diff --git a/apps/backend/internal/notifications/providers/local_test.go b/apps/backend/internal/notifications/providers/local_test.go new file mode 100644 index 0000000000..f5fcbc1ba3 --- /dev/null +++ b/apps/backend/internal/notifications/providers/local_test.go @@ -0,0 +1,123 @@ +package providers + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/common/logger" + gatewayws "github.com/kandev/kandev/internal/gateway/websocket" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +func TestLocalProviderReturnsNoEligibleSubscriberWhenUserHasNoWebSocketClient(t *testing.T) { + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("create logger: %v", err) + } + provider := NewLocalProvider(gatewayws.NewHub(nil, log)) + err = provider.Send(context.Background(), Message{ + EventType: "system.update_available", + OccurrenceID: "v1.2.3", + UserID: "user-1", + Title: "Kandev update available", + Body: "Kandev v1.2.3 is available.", + Payload: map[string]string{"version": "v1.2.3", "url": "https://example.test/releases/v1.2.3"}, + }) + if !errors.Is(err, ErrNoEligibleSubscriber) { + t.Fatalf("send error = %v, want no eligible subscriber", err) + } +} + +func TestLocalProviderForwardsUpdatePayloadToSubscribedClient(t *testing.T) { + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("create logger: %v", err) + } + hub := gatewayws.NewHub(nil, log) + hubCtx, cancelHub := context.WithCancel(context.Background()) + hubDone := make(chan struct{}) + go func() { + hub.Run(hubCtx) + close(hubDone) + }() + cleanupHub := func() { + cancelHub() + <-hubDone + } + t.Cleanup(cleanupHub) + clientReady := make(chan *gatewayws.Client, 1) + serverDone := make(chan struct{}) + upgrader := websocket.Upgrader{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer close(serverDone) + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("upgrade connection: %v", err) + return + } + client := gatewayws.NewClient("client-1", authn.Identity{}, conn, hub, log) + hub.Register(client) + hub.SubscribeToUser(client, "user-1") + clientReady <- client + client.WritePump() + })) + t.Cleanup(func() { + cleanupHub() + select { + case <-serverDone: + case <-time.After(time.Second): + t.Error("websocket writer did not stop") + } + server.Close() + }) + + conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil) + if err != nil { + t.Fatalf("dial websocket: %v", err) + } + t.Cleanup(func() { + _ = conn.Close() + }) + <-clientReady + + provider := NewLocalProvider(hub) + if err := provider.Send(context.Background(), Message{ + EventType: "system.update_available", + OccurrenceID: "v1.2.3", + UserID: "user-1", + Payload: map[string]string{"version": "v1.2.3", "url": "https://example.test/releases/v1.2.3"}, + }); err != nil { + t.Fatalf("send notification: %v", err) + } + + if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + _, data, err := conn.ReadMessage() + if err != nil { + t.Fatalf("read notification: %v", err) + } + var message ws.Message + if err := json.Unmarshal(data, &message); err != nil { + t.Fatalf("decode notification: %v", err) + } + var payload struct { + Version string `json:"version"` + URL string `json:"url"` + } + if err := message.ParsePayload(&payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + if message.Action != "system.update_available" || payload.Version != "v1.2.3" || payload.URL != "https://example.test/releases/v1.2.3" { + t.Fatalf("forwarded notification = %#v with payload %#v", message, payload) + } +} diff --git a/apps/backend/internal/notifications/providers/provider.go b/apps/backend/internal/notifications/providers/provider.go index 5e8deb4c03..6b53ebbb7e 100644 --- a/apps/backend/internal/notifications/providers/provider.go +++ b/apps/backend/internal/notifications/providers/provider.go @@ -2,8 +2,11 @@ package providers import ( "context" + "errors" ) +var ErrNoEligibleSubscriber = errors.New("no eligible notification subscriber") + type Message struct { EventType string Title string @@ -13,6 +16,7 @@ type Message struct { OccurrenceID string UserID string Config map[string]interface{} + Payload map[string]string } type Provider interface { diff --git a/apps/backend/internal/notifications/service/service.go b/apps/backend/internal/notifications/service/service.go index 0694fc8891..18ecf4cc7b 100644 --- a/apps/backend/internal/notifications/service/service.go +++ b/apps/backend/internal/notifications/service/service.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "strings" + "sync" "github.com/google/uuid" @@ -23,6 +24,7 @@ const ( EventTaskSessionTurnFinished = "session.turn_finished" EventTaskSessionClarificationAsked = "session.clarification_requested" EventOfficeInboxItem = "office.inbox_item" + EventSystemUpdateAvailable = "system.update_available" desktopNativeNotificationsEnv = "KANDEV_DESKTOP_NATIVE_NOTIFICATIONS" ) @@ -34,11 +36,12 @@ type taskGetter interface { } type Service struct { - repo notificationstore.Repository - taskRepo taskGetter - hub *gatewayws.Hub - logger *logger.Logger - providers map[models.ProviderType]providers.Provider + defaultProvidersMu sync.Mutex + repo notificationstore.Repository + taskRepo taskGetter + hub *gatewayws.Hub + logger *logger.Logger + providers map[models.ProviderType]providers.Provider } func NewService(repo notificationstore.Repository, taskRepo taskGetter, hub *gatewayws.Hub, log *logger.Logger) *Service { @@ -67,7 +70,7 @@ func (s *Service) AppriseAvailable() bool { } func (s *Service) AvailableEvents() []string { - return []string{EventTaskSessionTurnFinished, EventTaskSessionClarificationAsked, EventOfficeInboxItem} + return []string{EventTaskSessionTurnFinished, EventTaskSessionClarificationAsked, EventOfficeInboxItem, EventSystemUpdateAvailable} } func (s *Service) ListProviders(ctx context.Context, userID string) ([]*models.Provider, map[string][]string, error) { @@ -168,14 +171,24 @@ type ProviderUpdate struct { } func (s *Service) HandleTaskTurnFinished(ctx context.Context, taskID, sessionID, turnID string) { - s.handleSemanticOccurrence(ctx, taskID, sessionID, turnID, EventTaskSessionTurnFinished) + s.handleSemanticOccurrence(ctx, taskID, sessionID, turnID, EventTaskSessionTurnFinished, nil) } func (s *Service) HandleClarificationRequested(ctx context.Context, taskID, sessionID, pendingID string) { - s.handleSemanticOccurrence(ctx, taskID, sessionID, pendingID, EventTaskSessionClarificationAsked) + s.handleSemanticOccurrence(ctx, taskID, sessionID, pendingID, EventTaskSessionClarificationAsked, nil) } -func (s *Service) handleSemanticOccurrence(ctx context.Context, taskID, sessionID, occurrenceID, eventType string) { +func (s *Service) HandleUpdateAvailable(ctx context.Context, version, releaseURL string) { + if version == "" { + return + } + s.handleSemanticOccurrence(ctx, "", "", version, EventSystemUpdateAvailable, map[string]string{ + "version": version, + "url": releaseURL, + }) +} + +func (s *Service) handleSemanticOccurrence(ctx context.Context, taskID, sessionID, occurrenceID, eventType string, payload map[string]string) { if occurrenceID == "" { return } @@ -185,7 +198,7 @@ func (s *Service) handleSemanticOccurrence(ctx context.Context, taskID, sessionI s.logger.Error("failed to load notification providers", zap.Error(err)) return } - title, body := s.buildSemanticMessage(ctx, taskID, eventType) + title, body := s.buildSemanticMessage(ctx, taskID, eventType, payload) for _, provider := range providers { if !provider.Enabled { continue @@ -216,6 +229,7 @@ func (s *Service) handleSemanticOccurrence(ctx context.Context, taskID, sessionI EventType: eventType, Title: title, Body: body, + Payload: payload, }); err != nil { s.logger.Warn("notification delivery failed", zap.String("provider_id", provider.ID), zap.Error(err)) _ = s.repo.DeleteDelivery(ctx, provider.ID, eventType, occurrenceID) @@ -272,6 +286,7 @@ type notificationPayload struct { EventType string Title string Body string + Payload map[string]string } func (s *Service) dispatchProvider(ctx context.Context, provider *models.Provider, payload notificationPayload) error { @@ -283,6 +298,7 @@ func (s *Service) dispatchProvider(ctx context.Context, provider *models.Provide EventType: payload.EventType, Title: payload.Title, Body: payload.Body, + Payload: payload.Payload, TaskID: payload.TaskID, TaskSessionID: payload.TaskSessionID, OccurrenceID: payload.OccurrenceID, @@ -291,7 +307,10 @@ func (s *Service) dispatchProvider(ctx context.Context, provider *models.Provide }) } -func (s *Service) buildSemanticMessage(ctx context.Context, taskID, eventType string) (string, string) { +func (s *Service) buildSemanticMessage(ctx context.Context, taskID, eventType string, payload map[string]string) (string, string) { + if eventType == EventSystemUpdateAvailable { + return semanticMessageCopy(eventType, payload["version"]) + } title, body := semanticMessageCopy(eventType, "") if taskID == "" || s.taskRepo == nil { return title, body @@ -307,6 +326,9 @@ func (s *Service) buildSemanticMessage(ctx context.Context, taskID, eventType st } func semanticMessageCopy(eventType, taskTitle string) (string, string) { + if eventType == EventSystemUpdateAvailable { + return "Kandev update available", fmt.Sprintf("Kandev %s is available. Open Settings > System > Updates to review it.", taskTitle) + } if eventType == EventTaskSessionClarificationAsked { if taskTitle == "" { return "Agent needs your answer", "The agent asked a question." @@ -320,6 +342,9 @@ func semanticMessageCopy(eventType, taskTitle string) (string, string) { } func (s *Service) ensureDefaultProviders(ctx context.Context, userID string) error { + s.defaultProvidersMu.Lock() + defer s.defaultProvidersMu.Unlock() + providers, err := s.repo.ListProvidersByUser(ctx, userID) if err != nil { return err @@ -349,6 +374,7 @@ func (s *Service) ensureDefaultProviders(ctx context.Context, userID string) err if err := s.repo.ReplaceSubscriptions(ctx, provider.ID, userID, []string{ EventTaskSessionClarificationAsked, EventOfficeInboxItem, + EventSystemUpdateAvailable, }); err != nil { return err } @@ -383,6 +409,7 @@ func (s *Service) ensureSystemProvider(ctx context.Context, userID string) error return s.repo.ReplaceSubscriptions(ctx, provider.ID, userID, []string{ EventTaskSessionClarificationAsked, EventOfficeInboxItem, + EventSystemUpdateAvailable, }) } @@ -416,6 +443,7 @@ func (s *Service) validateEvents(events []string) error { EventTaskSessionTurnFinished: {}, EventTaskSessionClarificationAsked: {}, EventOfficeInboxItem: {}, + EventSystemUpdateAvailable: {}, } for _, event := range events { if _, ok := allowed[event]; !ok { diff --git a/apps/backend/internal/notifications/service/service_test.go b/apps/backend/internal/notifications/service/service_test.go index ea757850b1..81dcfef68b 100644 --- a/apps/backend/internal/notifications/service/service_test.go +++ b/apps/backend/internal/notifications/service/service_test.go @@ -2,9 +2,11 @@ package service import ( "context" + "sync" "testing" "github.com/kandev/kandev/internal/common/logger" + gatewayws "github.com/kandev/kandev/internal/gateway/websocket" "github.com/kandev/kandev/internal/notifications/models" "github.com/kandev/kandev/internal/notifications/providers" taskmodels "github.com/kandev/kandev/internal/task/models" @@ -12,12 +14,15 @@ import ( ) type notificationTestRepository struct { + mu sync.Mutex providers []*models.Provider subscriptions map[string][]*models.Subscription deliveries []*models.Delivery } func (r *notificationTestRepository) CreateProvider(_ context.Context, provider *models.Provider) error { + r.mu.Lock() + defer r.mu.Unlock() r.providers = append(r.providers, provider) return nil } @@ -25,6 +30,8 @@ func (r *notificationTestRepository) UpdateProvider(context.Context, *models.Pro return nil } func (r *notificationTestRepository) GetProvider(_ context.Context, id string) (*models.Provider, error) { + r.mu.Lock() + defer r.mu.Unlock() for _, provider := range r.providers { if provider.ID == id { return provider, nil @@ -33,16 +40,28 @@ func (r *notificationTestRepository) GetProvider(_ context.Context, id string) ( return nil, nil } func (r *notificationTestRepository) ListProvidersByUser(context.Context, string) ([]*models.Provider, error) { - return r.providers, nil + r.mu.Lock() + defer r.mu.Unlock() + return append([]*models.Provider(nil), r.providers...), nil } func (r *notificationTestRepository) DeleteProvider(context.Context, string) error { return nil } func (r *notificationTestRepository) ListSubscriptionsByProvider(_ context.Context, providerID string) ([]*models.Subscription, error) { - return r.subscriptions[providerID], nil + r.mu.Lock() + defer r.mu.Unlock() + return append([]*models.Subscription(nil), r.subscriptions[providerID]...), nil } -func (r *notificationTestRepository) ReplaceSubscriptions(context.Context, string, string, []string) error { +func (r *notificationTestRepository) ReplaceSubscriptions(_ context.Context, providerID, userID string, events []string) error { + r.mu.Lock() + defer r.mu.Unlock() + r.subscriptions[providerID] = make([]*models.Subscription, 0, len(events)) + for _, eventType := range events { + r.subscriptions[providerID] = append(r.subscriptions[providerID], &models.Subscription{ProviderID: providerID, UserID: userID, EventType: eventType, Enabled: true}) + } return nil } func (r *notificationTestRepository) InsertDelivery(_ context.Context, delivery *models.Delivery) (bool, error) { + r.mu.Lock() + defer r.mu.Unlock() for _, existing := range r.deliveries { if existing.ProviderID == delivery.ProviderID && existing.EventType == delivery.EventType && existing.OccurrenceID == delivery.OccurrenceID { return false, nil @@ -52,6 +71,8 @@ func (r *notificationTestRepository) InsertDelivery(_ context.Context, delivery return true, nil } func (r *notificationTestRepository) DeleteDelivery(_ context.Context, providerID, eventType, occurrenceID string) error { + r.mu.Lock() + defer r.mu.Unlock() for index, delivery := range r.deliveries { if delivery.ProviderID == providerID && delivery.EventType == eventType && delivery.OccurrenceID == occurrenceID { r.deliveries = append(r.deliveries[:index], r.deliveries[index+1:]...) @@ -161,6 +182,151 @@ func TestSemanticOccurrencesUseEventSpecificCopyAndOccurrenceIdempotency(t *test } } +func TestUpdateAvailabilityIsAvailableEvent(t *testing.T) { + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("create logger: %v", err) + } + service := NewService(¬ificationTestRepository{}, nil, nil, log) + if !containsEvent(service.AvailableEvents(), "system.update_available") { + t.Fatalf("available events = %#v, want system.update_available", service.AvailableEvents()) + } +} + +func TestFreshLocalProviderSubscribesToUpdateAvailability(t *testing.T) { + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("create logger: %v", err) + } + repo := ¬ificationTestRepository{subscriptions: make(map[string][]*models.Subscription)} + service := NewService(repo, nil, nil, log) + if _, _, err := service.ListProviders(context.Background(), "user-1"); err != nil { + t.Fatalf("list providers: %v", err) + } + for _, provider := range repo.providers { + if provider.Type == models.ProviderTypeLocal && !containsEvent(subscriptionEventTypes(repo.subscriptions[provider.ID]), EventSystemUpdateAvailable) { + t.Fatalf("local defaults = %#v, want update availability", repo.subscriptions[provider.ID]) + } + } +} + +func TestConcurrentProviderInitializationCreatesSingletonDefaultsAndClaimsOneUpdatePerProvider(t *testing.T) { + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("create logger: %v", err) + } + repo := ¬ificationTestRepository{subscriptions: make(map[string][]*models.Subscription)} + service := NewService(repo, nil, nil, log) + local, system := &captureProvider{}, &captureProvider{} + service.providers[models.ProviderTypeLocal] = local + service.providers[models.ProviderTypeSystem] = system + + var calls sync.WaitGroup + for range 32 { + calls.Add(1) + go func() { + defer calls.Done() + if _, _, err := service.ListProviders(context.Background(), "user-1"); err != nil { + t.Errorf("list providers: %v", err) + } + }() + } + calls.Wait() + + localCount, systemCount := 0, 0 + for _, provider := range repo.providers { + switch provider.Type { + case models.ProviderTypeLocal: + localCount++ + case models.ProviderTypeSystem: + systemCount++ + } + } + if localCount != 1 || systemCount != 1 { + t.Fatalf("default providers: local=%d system=%d, want one of each", localCount, systemCount) + } + + service.HandleUpdateAvailable(context.Background(), "v1.2.3", "https://example.test/releases/v1.2.3") + service.HandleUpdateAvailable(context.Background(), "v1.2.3", "https://example.test/releases/v1.2.3") + if len(local.messages) != 1 || len(system.messages) != 1 || len(repo.deliveries) != 2 { + t.Fatalf("update delivery: local=%d system=%d claims=%d, want one per default provider", len(local.messages), len(system.messages), len(repo.deliveries)) + } +} + +func subscriptionEventTypes(subscriptions []*models.Subscription) []string { + events := make([]string, 0, len(subscriptions)) + for _, subscription := range subscriptions { + events = append(events, subscription.EventType) + } + return events +} + +func TestUpdateAvailabilityRoutesProviderScopedOccurrenceWithReleasePayload(t *testing.T) { + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("create logger: %v", err) + } + repo := ¬ificationTestRepository{ + providers: []*models.Provider{ + {ID: "local", Type: models.ProviderTypeLocal, Enabled: true}, + {ID: "apprise", Type: models.ProviderTypeApprise, Enabled: true}, + {ID: "system", Type: models.ProviderTypeSystem, Enabled: true}, + }, + subscriptions: map[string][]*models.Subscription{ + "local": {{ProviderID: "local", EventType: EventSystemUpdateAvailable, Enabled: true}}, + "apprise": {{ProviderID: "apprise", EventType: EventSystemUpdateAvailable, Enabled: true}}, + "system": {{ProviderID: "system", EventType: EventSystemUpdateAvailable, Enabled: true}}, + }, + } + service := NewService(repo, nil, nil, log) + local, apprise, system := &captureProvider{}, &captureProvider{}, &captureProvider{} + service.providers[models.ProviderTypeLocal] = local + service.providers[models.ProviderTypeApprise] = apprise + service.providers[models.ProviderTypeSystem] = system + + service.HandleUpdateAvailable(context.Background(), "v1.2.3", "https://example.test/releases/v1.2.3") + service.HandleUpdateAvailable(context.Background(), "v1.2.3", "https://example.test/releases/v1.2.3") + + for providerType, captured := range map[string]*captureProvider{"local": local, "apprise": apprise, "system": system} { + if len(captured.messages) != 1 { + t.Fatalf("%s messages = %#v, want exactly one", providerType, captured.messages) + } + message := captured.messages[0] + if message.EventType != EventSystemUpdateAvailable || message.OccurrenceID != "v1.2.3" || message.TaskSessionID != "" || message.Title != "Kandev update available" || message.Body != "Kandev v1.2.3 is available. Open Settings > System > Updates to review it." { + t.Fatalf("%s update message = %#v", providerType, message) + } + if message.Payload["version"] != "v1.2.3" || message.Payload["url"] != "https://example.test/releases/v1.2.3" { + t.Fatalf("%s payload = %#v", providerType, message.Payload) + } + } +} + +func TestNoEligibleLocalUpdateSubscriberReleasesOccurrenceClaimForReplay(t *testing.T) { + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("create logger: %v", err) + } + repo := ¬ificationTestRepository{ + providers: []*models.Provider{{ID: "local", Type: models.ProviderTypeLocal, Enabled: true}}, + subscriptions: map[string][]*models.Subscription{ + "local": {{ProviderID: "local", EventType: EventSystemUpdateAvailable, Enabled: true}}, + }, + } + service := NewService(repo, nil, gatewayws.NewHub(nil, log), log) + + service.HandleUpdateAvailable(context.Background(), "v1.2.3", "https://example.test/releases/v1.2.3") + if len(repo.deliveries) != 0 { + t.Fatalf("failed local delivery claims = %#v, want none", repo.deliveries) + } + + capture := &captureProvider{} + service.providers[models.ProviderTypeLocal] = capture + service.HandleUpdateAvailable(context.Background(), "v1.2.3", "https://example.test/releases/v1.2.3") + if len(capture.messages) != 1 || len(repo.deliveries) != 1 { + t.Fatalf("replayed local delivery: messages=%#v claims=%#v, want one", capture.messages, repo.deliveries) + } +} + func TestFailedSemanticDeliveryReleasesOnlyItsOccurrenceClaim(t *testing.T) { log, err := logger.NewFromZap(zap.NewNop()) if err != nil { diff --git a/apps/backend/internal/notifications/store/provider.go b/apps/backend/internal/notifications/store/provider.go index e8fd3b4e6a..080284a415 100644 --- a/apps/backend/internal/notifications/store/provider.go +++ b/apps/backend/internal/notifications/store/provider.go @@ -1,10 +1,14 @@ package store -import "github.com/jmoiron/sqlx" +import ( + "context" + + "github.com/jmoiron/sqlx" +) // Provide creates the SQLite notification store using separate writer and reader pools. -func Provide(writer, reader *sqlx.DB) (*sqliteRepository, func() error, error) { - repo, err := newSQLiteRepositoryWithDB(writer, reader) +func Provide(ctx context.Context, writer, reader *sqlx.DB) (*sqliteRepository, func() error, error) { + repo, err := newSQLiteRepositoryWithDB(ctx, writer, reader) if err != nil { return nil, nil, err } diff --git a/apps/backend/internal/notifications/store/sqlite.go b/apps/backend/internal/notifications/store/sqlite.go index d63f664181..dc576b7fa6 100644 --- a/apps/backend/internal/notifications/store/sqlite.go +++ b/apps/backend/internal/notifications/store/sqlite.go @@ -24,13 +24,13 @@ type sqliteRepository struct { var _ Repository = (*sqliteRepository)(nil) -func newSQLiteRepositoryWithDB(writer, reader *sqlx.DB) (*sqliteRepository, error) { - return newSQLiteRepository(writer, reader, false) +func newSQLiteRepositoryWithDB(ctx context.Context, writer, reader *sqlx.DB) (*sqliteRepository, error) { + return newSQLiteRepository(ctx, writer, reader, false) } -func newSQLiteRepository(writer, reader *sqlx.DB, ownsDB bool) (*sqliteRepository, error) { +func newSQLiteRepository(ctx context.Context, writer, reader *sqlx.DB, ownsDB bool) (*sqliteRepository, error) { repo := &sqliteRepository{db: writer, ro: reader, ownsDB: ownsDB} - if err := repo.initSchema(); err != nil { + if err := repo.initSchema(ctx); err != nil { if ownsDB { if closeErr := writer.Close(); closeErr != nil { return nil, fmt.Errorf("failed to close database after schema error: %w", closeErr) @@ -48,7 +48,7 @@ func (r *sqliteRepository) Close() error { return r.db.Close() } -func (r *sqliteRepository) initSchema() error { +func (r *sqliteRepository) initSchema(ctx context.Context) error { schema := ` CREATE TABLE IF NOT EXISTS notification_providers ( id TEXT PRIMARY KEY, @@ -84,6 +84,11 @@ func (r *sqliteRepository) initSchema() error { UNIQUE(provider_id, event_type, occurrence_id) ); + CREATE TABLE IF NOT EXISTS notification_migrations ( + name TEXT PRIMARY KEY, + completed_at TIMESTAMP NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_notification_providers_user_id ON notification_providers(user_id); CREATE INDEX IF NOT EXISTS idx_notification_subscriptions_provider_id ON notification_subscriptions(provider_id); CREATE INDEX IF NOT EXISTS idx_notification_subscriptions_user_id ON notification_subscriptions(user_id); @@ -96,7 +101,65 @@ func (r *sqliteRepository) initSchema() error { if err := r.migrateDeliveries(); err != nil { return err } - return r.migrateLegacySubscriptions() + if err := r.migrateLegacySubscriptions(); err != nil { + return err + } + return r.migrateUpdateAvailableSubscriptions(ctx) +} + +const updateAvailableSubscriptionMigration = "add_system_update_available_to_local_and_system_v1" + +func (r *sqliteRepository) migrateUpdateAvailableSubscriptions(ctx context.Context) error { + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + var completed bool + err = tx.GetContext(ctx, &completed, r.db.Rebind(`SELECT TRUE FROM notification_migrations WHERE name = ?`), updateAvailableSubscriptionMigration) + if err == nil { + return tx.Commit() + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + rows, err := tx.QueryxContext(ctx, r.db.Rebind(` + SELECT p.id, p.user_id FROM notification_providers p + WHERE p.type IN (?, ?) + AND NOT EXISTS ( + SELECT 1 FROM notification_subscriptions s + WHERE s.provider_id = p.id AND s.event_type = ? + ) + `), "local", "system", "system.update_available") + if err != nil { + return err + } + defer func() { _ = rows.Close() }() + type providerRow struct{ id, userID string } + var providers []providerRow + for rows.Next() { + var provider providerRow + if err := rows.Scan(&provider.id, &provider.userID); err != nil { + return err + } + providers = append(providers, provider) + } + if err := rows.Err(); err != nil { + return err + } + now := time.Now().UTC() + for _, provider := range providers { + if _, err := tx.ExecContext(ctx, r.db.Rebind(` + INSERT INTO notification_subscriptions (id, user_id, provider_id, event_type, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `), uuid.New().String(), provider.userID, provider.id, "system.update_available", dialect.BoolToInt(true), now, now); err != nil { + return err + } + } + if _, err := tx.ExecContext(ctx, r.db.Rebind(`INSERT INTO notification_migrations (name, completed_at) VALUES (?, ?)`), updateAvailableSubscriptionMigration, now); err != nil { + return err + } + return tx.Commit() } func (r *sqliteRepository) migrateDeliveries() error { diff --git a/apps/backend/internal/notifications/store/sqlite_test.go b/apps/backend/internal/notifications/store/sqlite_test.go index c407189127..9196c84f8f 100644 --- a/apps/backend/internal/notifications/store/sqlite_test.go +++ b/apps/backend/internal/notifications/store/sqlite_test.go @@ -2,6 +2,7 @@ package store import ( "context" + "errors" "fmt" "path/filepath" "testing" @@ -25,7 +26,7 @@ func openNotificationTestDB(t *testing.T) *sqlx.DB { func TestSQLiteRepositoryDeliverySchemaUsesOccurrenceID(t *testing.T) { database := openNotificationTestDB(t) - if _, err := newSQLiteRepositoryWithDB(database, database); err != nil { + if _, err := newSQLiteRepositoryWithDB(context.Background(), database, database); err != nil { t.Fatalf("create repository: %v", err) } @@ -50,6 +51,17 @@ func TestSQLiteRepositoryDeliverySchemaUsesOccurrenceID(t *testing.T) { t.Fatal("notification_deliveries must retain a semantic occurrence_id") } +func TestSQLiteRepositoryInitializationHonorsCanceledContext(t *testing.T) { + database := openNotificationTestDB(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := newSQLiteRepositoryWithDB(ctx, database, database) + if !errors.Is(err, context.Canceled) { + t.Fatalf("initialize repository error = %v, want context canceled", err) + } +} + func TestSQLiteRepositoryMigratesLegacyWaitingSubscriptionToClarificationOnly(t *testing.T) { database := openNotificationTestDB(t) ctx := context.Background() @@ -78,7 +90,7 @@ func TestSQLiteRepositoryMigratesLegacyWaitingSubscriptionToClarificationOnly(t t.Fatalf("seed legacy subscription: %v", err) } - repo, err := newSQLiteRepositoryWithDB(database, database) + repo, err := newSQLiteRepositoryWithDB(ctx, database, database) if err != nil { t.Fatalf("migrate legacy schema: %v", err) } @@ -86,8 +98,55 @@ func TestSQLiteRepositoryMigratesLegacyWaitingSubscriptionToClarificationOnly(t if err != nil { t.Fatalf("list migrated subscriptions: %v", err) } - if len(subscriptions) != 1 || subscriptions[0].EventType != "session.clarification_requested" { - t.Fatalf("legacy subscription = %#v, want clarification subscription only", subscriptions) + if !hasEnabledSubscription(subscriptions, "session.clarification_requested") || !hasEnabledSubscription(subscriptions, "system.update_available") { + t.Fatalf("legacy subscriptions = %#v, want enabled clarification and update subscriptions", subscriptions) + } +} + +func TestSQLiteRepositoryMigratesExistingLocalAndSystemUpdateSubscriptionsOnlyOnce(t *testing.T) { + database := openNotificationTestDB(t) + ctx := context.Background() + repo, err := newSQLiteRepositoryWithDB(ctx, database, database) + if err != nil { + t.Fatalf("create repository: %v", err) + } + providerIDs := make(map[string]string) + for _, provider := range []*models.Provider{ + {ID: "local", UserID: "user-1", Name: "Local", Type: models.ProviderTypeLocal, Enabled: true}, + {ID: "system", UserID: "user-1", Name: "System", Type: models.ProviderTypeSystem, Enabled: true}, + {ID: "apprise", UserID: "user-1", Name: "Apprise", Type: models.ProviderTypeApprise, Enabled: true}, + } { + if err := repo.CreateProvider(ctx, provider); err != nil { + t.Fatalf("create %s provider: %v", provider.Type, err) + } + providerIDs[string(provider.Type)] = provider.ID + } + if _, err := database.Exec(`DELETE FROM notification_migrations`); err != nil { + t.Fatalf("clear fresh migration marker: %v", err) + } + + if err := repo.migrateUpdateAvailableSubscriptions(ctx); err != nil { + t.Fatalf("migrate update subscriptions: %v", err) + } + if err := repo.ReplaceSubscriptions(ctx, providerIDs[string(models.ProviderTypeLocal)], "user-1", []string{}); err != nil { + t.Fatalf("user opt-out: %v", err) + } + if err := repo.migrateUpdateAvailableSubscriptions(ctx); err != nil { + t.Fatalf("replay update migration: %v", err) + } + for providerType, want := range map[string]bool{"local": false, "system": true, "apprise": false} { + providerID := providerIDs[providerType] + subscriptions, err := repo.ListSubscriptionsByProvider(ctx, providerID) + if err != nil { + t.Fatalf("list %s subscriptions: %v", providerType, err) + } + got := false + for _, subscription := range subscriptions { + got = got || (subscription.EventType == "system.update_available" && subscription.Enabled) + } + if got != want { + t.Fatalf("%s update subscription = %v, want %v", providerType, got, want) + } } } @@ -114,10 +173,10 @@ func TestSQLiteRepositoryMergesLegacySubscriptionIdempotently(t *testing.T) { `); err != nil { t.Fatalf("seed legacy and semantic subscriptions: %v", err) } - if _, err := newSQLiteRepositoryWithDB(database, database); err != nil { + if _, err := newSQLiteRepositoryWithDB(ctx, database, database); err != nil { t.Fatalf("first migration: %v", err) } - repo, err := newSQLiteRepositoryWithDB(database, database) + repo, err := newSQLiteRepositoryWithDB(ctx, database, database) if err != nil { t.Fatalf("replay migration: %v", err) } @@ -125,14 +184,23 @@ func TestSQLiteRepositoryMergesLegacySubscriptionIdempotently(t *testing.T) { if err != nil { t.Fatalf("list migrated subscriptions: %v", err) } - if len(subscriptions) != 1 || subscriptions[0].EventType != "session.clarification_requested" || !subscriptions[0].Enabled { - t.Fatalf("merged subscriptions = %#v, want one enabled clarification subscription", subscriptions) + if !hasEnabledSubscription(subscriptions, "session.clarification_requested") || !hasEnabledSubscription(subscriptions, "system.update_available") { + t.Fatalf("merged subscriptions = %#v, want enabled clarification and update subscriptions", subscriptions) } } +func hasEnabledSubscription(subscriptions []*models.Subscription, eventType string) bool { + for _, subscription := range subscriptions { + if subscription.EventType == eventType && subscription.Enabled { + return true + } + } + return false +} + func TestSQLiteRepositoryDeliveryIdempotencyIsScopedToOccurrence(t *testing.T) { database := openNotificationTestDB(t) - repo, err := newSQLiteRepositoryWithDB(database, database) + repo, err := newSQLiteRepositoryWithDB(context.Background(), database, database) if err != nil { t.Fatalf("create repository: %v", err) } @@ -158,11 +226,11 @@ func TestSQLiteRepositoryMigratesLegacyDeliveriesPreservingHistoryAndReplayabili database := openNotificationTestDB(t) seedLegacyDeliverySchema(t, database) - repo, err := newSQLiteRepositoryWithDB(database, database) + repo, err := newSQLiteRepositoryWithDB(context.Background(), database, database) if err != nil { t.Fatalf("first migration: %v", err) } - if _, err := newSQLiteRepositoryWithDB(database, database); err != nil { + if _, err := newSQLiteRepositoryWithDB(context.Background(), database, database); err != nil { t.Fatalf("replay migration: %v", err) } @@ -185,7 +253,7 @@ func TestSQLiteRepositoryDeliveryMigrationRollsBackOnMalformedLegacyTable(t *tes t.Fatalf("seed malformed legacy deliveries: %v", err) } - if _, err := newSQLiteRepositoryWithDB(database, database); err == nil { + if _, err := newSQLiteRepositoryWithDB(context.Background(), database, database); err == nil { t.Fatal("migration must fail after beginning when legacy history is malformed") } var count int @@ -202,11 +270,11 @@ func TestPostgresRepositoryMigratesLegacyDeliveriesReplayably(t *testing.T) { database := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) seedLegacyDeliverySchema(t, database) - repo, err := newSQLiteRepositoryWithDB(database, database) + repo, err := newSQLiteRepositoryWithDB(context.Background(), database, database) if err != nil { t.Fatalf("first migration: %v", err) } - if _, err := newSQLiteRepositoryWithDB(database, database); err != nil { + if _, err := newSQLiteRepositoryWithDB(context.Background(), database, database); err != nil { t.Fatalf("replay migration: %v", err) } @@ -217,10 +285,10 @@ func TestPostgresRepositoryMigratesLegacyDeliveriesReplayably(t *testing.T) { func TestPostgresRepositoryFreshSchemaReinitializes(t *testing.T) { database := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) - if _, err := newSQLiteRepositoryWithDB(database, database); err != nil { + if _, err := newSQLiteRepositoryWithDB(context.Background(), database, database); err != nil { t.Fatalf("first schema initialization: %v", err) } - if _, err := newSQLiteRepositoryWithDB(database, database); err != nil { + if _, err := newSQLiteRepositoryWithDB(context.Background(), database, database); err != nil { t.Fatalf("replayed schema initialization: %v", err) } } diff --git a/apps/backend/internal/persistence/meta.go b/apps/backend/internal/persistence/meta.go index 03b11ed341..5089d63611 100644 --- a/apps/backend/internal/persistence/meta.go +++ b/apps/backend/internal/persistence/meta.go @@ -14,7 +14,6 @@ const ( metaKeyLatestVersion = "latest_version" metaKeyLatestVersionURL = "latest_version_url" metaKeyLatestVersionCheckedAt = "latest_version_checked_at" - metaKeyNotifiedUpdateVersion = "notified_update_version" ) const metaTableDDL = ` @@ -111,21 +110,6 @@ func ReadLatestVersion(db *sqlx.DB) (string, string, time.Time, error) { return version, url, checkedAt, nil } -// WriteNotifiedUpdateVersion records the release tag that the update -// notification was last sent for. This is independent of -// latest_version/ReadLatestVersion so a user who dismisses/misses a -// notification isn't re-notified for the same release on every subsequent -// poll tick or backend restart — only when a *newer* tag appears. -func WriteNotifiedUpdateVersion(db *sqlx.DB, version string) error { - return writeKey(db, metaKeyNotifiedUpdateVersion, version) -} - -// ReadNotifiedUpdateVersion returns the release tag last notified about, or -// "" when no update notification has been sent yet. -func ReadNotifiedUpdateVersion(db *sqlx.DB) (string, error) { - return readKey(db, metaKeyNotifiedUpdateVersion) -} - // hasUserTables returns true when the DB contains at least one table that is // not part of the SQLite internal schema and not kandev_meta itself. This // distinguishes a genuinely fresh DB from a pre-meta DB being upgraded. diff --git a/apps/backend/internal/persistence/meta_test.go b/apps/backend/internal/persistence/meta_test.go index e1f5e76db5..b56f428e66 100644 --- a/apps/backend/internal/persistence/meta_test.go +++ b/apps/backend/internal/persistence/meta_test.go @@ -144,48 +144,6 @@ func TestWriteAndReadLatestVersion_RoundtripAndUpsert(t *testing.T) { } } -// TestWriteAndReadNotifiedUpdateVersion_RoundtripAndUpsert verifies the -// separate "last version we notified the user about" key persists -// independently of the polled latest_version, and upserts cleanly across -// restarts (backed by the same DB row) so re-notification is deduped per -// version rather than per process lifetime. -func TestWriteAndReadNotifiedUpdateVersion_RoundtripAndUpsert(t *testing.T) { - db := memSQLiteDB(t) - if err := ensureMetaTable(db); err != nil { - t.Fatalf("ensureMetaTable: %v", err) - } - - v, err := ReadNotifiedUpdateVersion(db) - if err != nil { - t.Fatalf("ReadNotifiedUpdateVersion fresh: %v", err) - } - if v != "" { - t.Errorf("expected empty value on fresh DB, got %q", v) - } - - if err := WriteNotifiedUpdateVersion(db, "v1.2.3"); err != nil { - t.Fatalf("WriteNotifiedUpdateVersion: %v", err) - } - v, err = ReadNotifiedUpdateVersion(db) - if err != nil { - t.Fatalf("ReadNotifiedUpdateVersion after write: %v", err) - } - if v != "v1.2.3" { - t.Errorf("version = %q, want %q", v, "v1.2.3") - } - - if err := WriteNotifiedUpdateVersion(db, "v1.2.4"); err != nil { - t.Fatalf("WriteNotifiedUpdateVersion overwrite: %v", err) - } - v, err = ReadNotifiedUpdateVersion(db) - if err != nil { - t.Fatalf("ReadNotifiedUpdateVersion after overwrite: %v", err) - } - if v != "v1.2.4" { - t.Errorf("version after overwrite = %q, want %q", v, "v1.2.4") - } -} - // TestReadLatestVersion_PartialPersistedState verifies the read tolerates // only a subset of the three keys being present (which can happen if a // previous write was interrupted). diff --git a/apps/backend/internal/persistence/postgres_meta_test.go b/apps/backend/internal/persistence/postgres_meta_test.go index a533fb2c8d..926dfe4d17 100644 --- a/apps/backend/internal/persistence/postgres_meta_test.go +++ b/apps/backend/internal/persistence/postgres_meta_test.go @@ -26,22 +26,3 @@ func TestPostgresLatestVersionMetaRoundTrip(t *testing.T) { t.Fatalf("meta round-trip = (%q, %q, %s)", version, url, gotCheckedAt) } } - -func TestPostgresNotifiedUpdateVersionMetaRoundTrip(t *testing.T) { - db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) - - if err := ensureMetaTable(db); err != nil { - t.Fatalf("ensure meta table: %v", err) - } - - if err := WriteNotifiedUpdateVersion(db, "v1.2.3"); err != nil { - t.Fatalf("write notified update version: %v", err) - } - version, err := ReadNotifiedUpdateVersion(db) - if err != nil { - t.Fatalf("read notified update version: %v", err) - } - if version != "v1.2.3" { - t.Fatalf("notified update version round-trip = %q", version) - } -} diff --git a/apps/backend/internal/system/system.go b/apps/backend/internal/system/system.go index c4d867c038..f84e787534 100644 --- a/apps/backend/internal/system/system.go +++ b/apps/backend/internal/system/system.go @@ -101,7 +101,6 @@ func Provide(cfg *config.Config, log *logger.Logger, pool *db.Pool, eventBus bus if settingsStore != nil { metricsStore := metrics.NewStore(settingsStore) metricsSvc = metrics.NewService(metricsStore, metrics.NewCollector()) - updatesOpts = append(updatesOpts, updates.WithNotifyStore(updates.NewNotifyStore(settingsStore))) } return &Service{ @@ -154,11 +153,6 @@ func (s *Service) RegisterRoutes(router *gin.Engine, log *logger.Logger) { g.GET("/updates", updates.HandleGet(s.Updates)) admin.POST("/updates/check", updates.HandleCheck(s.Updates)) admin.POST("/updates/apply", updates.HandleApply(s.Updates)) - // Notification settings are a single install-wide preference: reading is - // open to any authenticated user, but writing is admin-only like the other - // system-config mutations. - g.GET("/updates/notification-settings", updates.HandleGetNotifySettings(s.Updates)) - admin.PUT("/updates/notification-settings", updates.HandleSaveNotifySettings(s.Updates)) g.GET("/restart-capability", restart.HandleCapability(s.Restart)) admin.POST("/restart", restart.HandleRequest(s.Restart)) diff --git a/apps/backend/internal/system/updates/handler.go b/apps/backend/internal/system/updates/handler.go index 92c9fe9ee1..4d3325875f 100644 --- a/apps/backend/internal/system/updates/handler.go +++ b/apps/backend/internal/system/updates/handler.go @@ -80,38 +80,3 @@ func HandleApply(svc *Service) gin.HandlerFunc { c.JSON(http.StatusAccepted, ApplyResponse{JobID: jobID}) } } - -// HandleGetNotifySettings returns the persisted update-notification -// preferences (enable/disable + desktop/in_view/both channel). -func HandleGetNotifySettings(svc *Service) gin.HandlerFunc { - return func(c *gin.Context) { - settings, err := svc.GetNotifySettings(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, settings) - } -} - -// HandleSaveNotifySettings validates and persists the update-notification -// preferences. An unknown channel or malformed body is a 400. -func HandleSaveNotifySettings(svc *Service) gin.HandlerFunc { - return func(c *gin.Context) { - var body NotifySettings - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) - return - } - saved, err := svc.SaveNotifySettings(c.Request.Context(), body) - if errors.Is(err, ErrNotifyStoreUnavailable) { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, saved) - } -} diff --git a/apps/backend/internal/system/updates/handler_notify_test.go b/apps/backend/internal/system/updates/handler_notify_test.go deleted file mode 100644 index 9dc864c4ee..0000000000 --- a/apps/backend/internal/system/updates/handler_notify_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package updates - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - - "github.com/kandev/kandev/internal/common/logger" -) - -func newNotifyRouter(svc *Service) *gin.Engine { - r := gin.New() - api := r.Group("/api/v1/system") - api.GET("/updates/notification-settings", HandleGetNotifySettings(svc)) - api.PUT("/updates/notification-settings", HandleSaveNotifySettings(svc)) - return r -} - -func newServiceWithNotifyStore(t *testing.T) *Service { - t.Helper() - pool := newTestPool(t) - notifyStore := newTestNotifyStore(t) - return NewService(pool, "v1.0.0", nil, logger.Default(), WithNotifyStore(notifyStore)) -} - -func TestHandleGetNotifySettings_ReturnsDefaults(t *testing.T) { - svc := newServiceWithNotifyStore(t) - r := newNotifyRouter(svc) - - req := httptest.NewRequest(http.MethodGet, "/api/v1/system/updates/notification-settings", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) - } - var got NotifySettings - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if got != DefaultNotifySettings() { - t.Errorf("got %+v, want default %+v", got, DefaultNotifySettings()) - } -} - -func TestHandleSaveNotifySettings_PersistsAndReturnsNormalized(t *testing.T) { - svc := newServiceWithNotifyStore(t) - r := newNotifyRouter(svc) - - body, _ := json.Marshal(NotifySettings{Enabled: false, Channel: NotifyChannelInView}) - req := httptest.NewRequest(http.MethodPut, "/api/v1/system/updates/notification-settings", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) - } - var got NotifySettings - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if got.Enabled || got.Channel != NotifyChannelInView { - t.Errorf("got %+v", got) - } - - // Persisted: a fresh GET reflects the save. - req2 := httptest.NewRequest(http.MethodGet, "/api/v1/system/updates/notification-settings", nil) - w2 := httptest.NewRecorder() - r.ServeHTTP(w2, req2) - var got2 NotifySettings - if err := json.Unmarshal(w2.Body.Bytes(), &got2); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if got2 != got { - t.Errorf("GET after save = %+v, want %+v", got2, got) - } -} - -func TestHandleSaveNotifySettings_InvalidChannelReturns400(t *testing.T) { - svc := newServiceWithNotifyStore(t) - r := newNotifyRouter(svc) - - body, _ := json.Marshal(map[string]string{"channel": "smoke-signal"}) - req := httptest.NewRequest(http.MethodPut, "/api/v1/system/updates/notification-settings", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) - } -} - -func TestHandleSaveNotifySettings_MalformedJSONReturns400(t *testing.T) { - svc := newServiceWithNotifyStore(t) - r := newNotifyRouter(svc) - - req := httptest.NewRequest(http.MethodPut, "/api/v1/system/updates/notification-settings", bytes.NewReader([]byte("{not json"))) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) - } -} - -func TestHandleSaveNotifySettings_NoNotifyStoreReturns500(t *testing.T) { - pool := newTestPool(t) - svc := NewService(pool, "v1.0.0", nil, logger.Default()) // no WithNotifyStore - r := newNotifyRouter(svc) - - body, _ := json.Marshal(NotifySettings{Enabled: true, Channel: NotifyChannelBoth}) - req := httptest.NewRequest(http.MethodPut, "/api/v1/system/updates/notification-settings", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != http.StatusInternalServerError { - t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) - } -} diff --git a/apps/backend/internal/system/updates/notify_dispatch_test.go b/apps/backend/internal/system/updates/notify_dispatch_test.go index 1ed3c838c9..2ae6b370f7 100644 --- a/apps/backend/internal/system/updates/notify_dispatch_test.go +++ b/apps/backend/internal/system/updates/notify_dispatch_test.go @@ -3,167 +3,138 @@ package updates import ( "context" "testing" + "time" + "github.com/kandev/kandev/internal/auth/authn" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/db" + gateways "github.com/kandev/kandev/internal/gateway/websocket" + notificationservice "github.com/kandev/kandev/internal/notifications/service" + notificationstore "github.com/kandev/kandev/internal/notifications/store" "github.com/kandev/kandev/internal/persistence" - ws "github.com/kandev/kandev/pkg/websocket" + userstore "github.com/kandev/kandev/internal/user/store" ) -// 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 +type capturingNotifier struct { + calls []updateNotification } -func (c *capturingBroadcaster) broadcast(msg *ws.Message) bool { - c.messages = append(c.messages, msg) - return c.eligible +type updateNotification struct { + version string + url string } -// 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) { +func (n *capturingNotifier) HandleUpdateAvailable(_ context.Context, version, releaseURL string) { + n.calls = append(n.calls, updateNotification{version: version, url: releaseURL}) +} + +func TestService_ReplayCachedUpdate_NotifiesCachedNewerReleaseWithoutFetching(t *testing.T) { + svc := NewService(newTestPool(t), "v1.0.0", nil, logger.Default()) + notifier := &capturingNotifier{} + svc.SetNotifier(notifier) + svc.SetFetcher(func(context.Context) (string, string, error) { + t.Fatal("ReplayCachedUpdate must not fetch GitHub") + return "", "", nil + }) + + if err := persistence.WriteLatestVersion(svc.pool.Writer(), "v1.0.1", "https://example.test/v1.0.1", time.Now()); err != nil { + t.Fatalf("write cached release: %v", err) + } + if err := svc.ReplayCachedUpdate(context.Background()); err != nil { + t.Fatalf("ReplayCachedUpdate: %v", err) + } + + if len(notifier.calls) != 1 { + t.Fatalf("notifier calls = %d, want 1", len(notifier.calls)) + } + if got := notifier.calls[0]; got.version != "v1.0.1" || got.url != "https://example.test/v1.0.1" { + t.Errorf("notifier call = %+v, want cached release", got) + } +} + +func TestService_FetchAndPersist_NotifiesCanonicalServiceForNewerRelease(t *testing.T) { + svc := NewService(newTestPool(t), "v1.0.0", nil, logger.Default()) + notifier := &capturingNotifier{} + svc.SetNotifier(notifier) + svc.SetFetcher(func(context.Context) (string, string, error) { + return "v1.0.1", "https://example.test/v1.0.1", nil + }) + + if _, err := svc.fetchAndPersist(context.Background()); err != nil { + t.Fatalf("fetchAndPersist: %v", err) + } + + if len(notifier.calls) != 1 { + t.Fatalf("notifier calls = %d, want 1", len(notifier.calls)) + } +} + +func TestService_PollBeforeLocalSubscription_ReplaysCachedUpdateExactlyOnce(t *testing.T) { + ctx := context.Background() + t.Setenv("KANDEV_DESKTOP_NATIVE_NOTIFICATIONS", "true") + pool := newTestPool(t) + repo, closeRepo, err := notificationstore.Provide(ctx, pool.Writer(), pool.Reader()) + if err != nil { + t.Fatalf("provide notification repository: %v", err) + } + t.Cleanup(func() { _ = closeRepo() }) + + hub := gateways.NewHub(nil, logger.Default()) + notifier := notificationservice.NewService(repo, nil, hub, logger.Default()) + svc := NewService(pool, "v1.0.0", nil, logger.Default()) + svc.SetNotifier(notifier) + fetches := 0 + svc.SetFetcher(func(context.Context) (string, string, error) { + fetches++ + return "v1.0.1", "https://example.test/v1.0.1", nil + }) + hub.AddUserSubscriptionListener(func(userID string) { + if userID != userstore.DefaultUserID { + return + } + if err := svc.ReplayCachedUpdate(ctx); err != nil { + t.Errorf("replay cached update: %v", err) + } + }) + + if _, err := svc.fetchAndPersist(ctx); err != nil { + t.Fatalf("initial poll: %v", err) + } + if got := localUpdateDeliveryCount(t, pool); got != 0 { + t.Fatalf("Local delivery claims after poll without subscriber = %d, want 0", got) + } + + first := gateways.NewClient("first", authn.Identity{}, nil, hub, logger.Default()) + hub.SubscribeToUser(first, userstore.DefaultUserID) + if got := localUpdateDeliveryCount(t, pool); got != 1 { + t.Fatalf("Local delivery claims after first subscription = %d, want 1", got) + } + if fetches != 1 { + t.Fatalf("GitHub fetches after cached replay = %d, want 1", fetches) + } + + second := gateways.NewClient("second", authn.Identity{}, nil, hub, logger.Default()) + hub.SubscribeToUser(second, userstore.DefaultUserID) + if got := localUpdateDeliveryCount(t, pool); got != 1 { + t.Fatalf("Local delivery claims after second subscription = %d, want 1", got) + } + if fetches != 1 { + t.Fatalf("GitHub fetches after second replay = %d, want 1", fetches) + } +} + +func localUpdateDeliveryCount(t *testing.T, pool *db.Pool) int { 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) - } + var count int + if err := pool.Reader().Get(&count, ` + SELECT COUNT(*) + FROM notification_deliveries deliveries + JOIN notification_providers providers ON providers.id = deliveries.provider_id + WHERE providers.type = 'local' + AND deliveries.event_type = 'system.update_available' + AND deliveries.occurrence_id = 'v1.0.1' + `); err != nil { + t.Fatalf("count Local update deliveries: %v", err) + } + return count } diff --git a/apps/backend/internal/system/updates/notify_settings.go b/apps/backend/internal/system/updates/notify_settings.go deleted file mode 100644 index 7b0e376907..0000000000 --- a/apps/backend/internal/system/updates/notify_settings.go +++ /dev/null @@ -1,44 +0,0 @@ -package updates - -import "fmt" - -// Update notification channels. "desktop" delivers an OS-level notification -// (native via the Tauri shell, or a browser Notification when running in a -// plain tab); "in_view" renders an in-app banner/toast that only appears -// while Kandev is open; "both" delivers both. -const ( - NotifyChannelDesktop = "desktop" - NotifyChannelInView = "in_view" - NotifyChannelBoth = "both" -) - -// NotifySettings controls whether — and how — the user is notified when the -// background updates poller (see poller.go) detects a new release. -type NotifySettings struct { - Enabled bool `json:"enabled"` - Channel string `json:"channel"` -} - -// DefaultNotifySettings returns the value used whenever nothing has been -// saved yet (fresh installs, and existing installs on their first read after -// this feature ships): notify on both channels. Once a user saves a -// preference via NotifyStore, that choice — including disabling the feature -// — persists across restarts and future releases. -func DefaultNotifySettings() NotifySettings { - return NotifySettings{Enabled: true, Channel: NotifyChannelBoth} -} - -// NormalizeNotifySettings fills in a missing channel with the default and -// rejects anything that isn't a known channel value. -func NormalizeNotifySettings(in NotifySettings) (NotifySettings, error) { - out := in - if out.Channel == "" { - out.Channel = DefaultNotifySettings().Channel - } - switch out.Channel { - case NotifyChannelDesktop, NotifyChannelInView, NotifyChannelBoth: - default: - return NotifySettings{}, fmt.Errorf("invalid notification channel %q", in.Channel) - } - return out, nil -} diff --git a/apps/backend/internal/system/updates/notify_settings_test.go b/apps/backend/internal/system/updates/notify_settings_test.go deleted file mode 100644 index 9c1e17ec88..0000000000 --- a/apps/backend/internal/system/updates/notify_settings_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package updates - -import "testing" - -func TestDefaultNotifySettings(t *testing.T) { - got := DefaultNotifySettings() - if !got.Enabled { - t.Errorf("expected Enabled=true by default") - } - if got.Channel != NotifyChannelBoth { - t.Errorf("channel = %q, want %q", got.Channel, NotifyChannelBoth) - } -} - -func TestNormalizeNotifySettings_FillsDefaultChannel(t *testing.T) { - got, err := NormalizeNotifySettings(NotifySettings{Enabled: true, Channel: ""}) - if err != nil { - t.Fatalf("NormalizeNotifySettings: %v", err) - } - if got.Channel != NotifyChannelBoth { - t.Errorf("channel = %q, want default %q", got.Channel, NotifyChannelBoth) - } -} - -func TestNormalizeNotifySettings_AcceptsKnownChannels(t *testing.T) { - for _, channel := range []string{NotifyChannelDesktop, NotifyChannelInView, NotifyChannelBoth} { - got, err := NormalizeNotifySettings(NotifySettings{Enabled: false, Channel: channel}) - if err != nil { - t.Fatalf("NormalizeNotifySettings(%q): %v", channel, err) - } - if got.Channel != channel { - t.Errorf("channel = %q, want %q", got.Channel, channel) - } - if got.Enabled { - t.Errorf("expected Enabled=false to be preserved") - } - } -} - -func TestNormalizeNotifySettings_RejectsUnknownChannel(t *testing.T) { - _, err := NormalizeNotifySettings(NotifySettings{Enabled: true, Channel: "carrier-pigeon"}) - if err == nil { - t.Fatal("expected error for unknown channel") - } -} diff --git a/apps/backend/internal/system/updates/notify_store.go b/apps/backend/internal/system/updates/notify_store.go deleted file mode 100644 index 2b14fed1a7..0000000000 --- a/apps/backend/internal/system/updates/notify_store.go +++ /dev/null @@ -1,52 +0,0 @@ -package updates - -import ( - "context" - "encoding/json" - "fmt" - - systemsettings "github.com/kandev/kandev/internal/system/settings" -) - -const notifySettingsKey = "update_notifications" - -// NotifyStore persists NotifySettings on top of the generic install-wide -// settings table (see internal/system/settings), following the same -// shape as metrics.Store. -type NotifyStore struct { - settings *systemsettings.Store -} - -func NewNotifyStore(settings *systemsettings.Store) *NotifyStore { - return &NotifyStore{settings: settings} -} - -func (s *NotifyStore) GetSettings(ctx context.Context) (NotifySettings, error) { - raw, found, err := s.settings.Get(ctx, notifySettingsKey) - if err != nil { - return NotifySettings{}, err - } - if !found { - return DefaultNotifySettings(), nil - } - var settings NotifySettings - if err := json.Unmarshal(raw, &settings); err != nil { - return NotifySettings{}, err - } - return NormalizeNotifySettings(settings) -} - -func (s *NotifyStore) SaveSettings(ctx context.Context, settings NotifySettings) (NotifySettings, error) { - normalized, err := NormalizeNotifySettings(settings) - if err != nil { - return NotifySettings{}, err - } - data, err := json.Marshal(normalized) - if err != nil { - return NotifySettings{}, err - } - if err := s.settings.Save(ctx, notifySettingsKey, data); err != nil { - return NotifySettings{}, fmt.Errorf("save update notification settings: %w", err) - } - return normalized, nil -} diff --git a/apps/backend/internal/system/updates/notify_store_test.go b/apps/backend/internal/system/updates/notify_store_test.go deleted file mode 100644 index 07ea9bc9db..0000000000 --- a/apps/backend/internal/system/updates/notify_store_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package updates - -import ( - "context" - "testing" - - "github.com/jmoiron/sqlx" - _ "github.com/mattn/go-sqlite3" - - "github.com/kandev/kandev/internal/db" - systemsettings "github.com/kandev/kandev/internal/system/settings" -) - -func newTestNotifyStore(t *testing.T) *NotifyStore { - t.Helper() - conn, err := sqlx.Open("sqlite3", ":memory:") - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - conn.SetMaxOpenConns(1) - t.Cleanup(func() { _ = conn.Close() }) - settingsStore, err := systemsettings.NewStore(db.NewPool(conn, conn)) - if err != nil { - t.Fatalf("new settings store: %v", err) - } - return NewNotifyStore(settingsStore) -} - -func TestNotifyStore_GetSettings_DefaultsWhenUnset(t *testing.T) { - store := newTestNotifyStore(t) - got, err := store.GetSettings(context.Background()) - if err != nil { - t.Fatalf("GetSettings: %v", err) - } - if got != DefaultNotifySettings() { - t.Errorf("got %+v, want default %+v", got, DefaultNotifySettings()) - } -} - -func TestNotifyStore_SaveAndGet_Roundtrip(t *testing.T) { - store := newTestNotifyStore(t) - ctx := context.Background() - - saved, err := store.SaveSettings(ctx, NotifySettings{Enabled: false, Channel: NotifyChannelDesktop}) - if err != nil { - t.Fatalf("SaveSettings: %v", err) - } - if saved.Enabled || saved.Channel != NotifyChannelDesktop { - t.Errorf("saved = %+v", saved) - } - - got, err := store.GetSettings(ctx) - if err != nil { - t.Fatalf("GetSettings after save: %v", err) - } - if got != saved { - t.Errorf("got %+v after save, want %+v", got, saved) - } -} - -func TestNotifyStore_SaveSettings_RejectsInvalidChannel(t *testing.T) { - store := newTestNotifyStore(t) - _, err := store.SaveSettings(context.Background(), NotifySettings{Enabled: true, Channel: "smoke-signal"}) - if err == nil { - t.Fatal("expected error for invalid channel") - } -} diff --git a/apps/backend/internal/system/updates/service.go b/apps/backend/internal/system/updates/service.go index dd0dc57c4e..89c548c577 100644 --- a/apps/backend/internal/system/updates/service.go +++ b/apps/backend/internal/system/updates/service.go @@ -20,7 +20,6 @@ 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. @@ -55,11 +54,6 @@ 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" @@ -112,24 +106,9 @@ 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 + // notifier routes update availability through the canonical notification + // service. It is nil-safe for isolated updates tests and minimal startup. + notifier UpdateNotifier // applyStartedAt holds the unix-nano timestamp of the last self-update launch // (0 = none in flight). It guards against two concurrent /updates/apply calls @@ -161,20 +140,12 @@ 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) +// UpdateNotifier is the narrow canonical notification-service boundary used +// by release detection. Provider policy and occurrence de-duplication remain +// owned by that service. +type UpdateNotifier interface { + HandleUpdateAvailable(ctx context.Context, version, releaseURL string) +} // Option customises Service construction without growing NewService's public // parameter list. @@ -213,15 +184,6 @@ 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 @@ -288,34 +250,13 @@ 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) { +// SetNotifier wires the canonical notifier after gateway composition. +func (s *Service) SetNotifier(notifier UpdateNotifier) { s.mu.Lock() - s.broadcast = b + s.notifier = notifier 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) { @@ -435,61 +376,33 @@ 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) + s.notifyUpdateAvailable(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) { +// ReplayCachedUpdate delivers a previously persisted newer release without +// contacting GitHub. It is called when the default user becomes eligible for +// Local delivery after an early startup poll. +func (s *Service) ReplayCachedUpdate(ctx context.Context) error { + tag, releaseURL, _, err := persistence.ReadLatestVersion(s.pool.Reader()) + if err != nil { + return err + } + s.notifyUpdateAvailable(ctx, tag, releaseURL) + return nil +} + +func (s *Service) notifyUpdateAvailable(ctx context.Context, tag, releaseURL string) { if !s.updateAvailable(tag) { return } s.mu.Lock() - broadcast := s.broadcast + notifier := s.notifier s.mu.Unlock() - if broadcast == nil { + if notifier == 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)) - } + notifier.HandleUpdateAvailable(ctx, tag, releaseURL) } func (s *Service) buildResponse(latest, url string, checkedAt time.Time) UpdatesResponse { diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index 8da570a386..85fe915526 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -5,6 +5,8 @@ fn main() { "check_for_updates", "install_update", "show_native_notification", + "get_native_notification_permission", + "request_native_notification_permission", "open_external_url", ]); tauri_build::try_build(tauri_build::Attributes::new().app_manifest(manifest)) diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 0959200d58..ff2126d6d0 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -14,6 +14,8 @@ "allow-check-for-updates", "allow-install-update", "allow-show-native-notification", + "allow-get-native-notification-permission", + "allow-request-native-notification-permission", "allow-open-external-url" ] } diff --git a/apps/desktop/src-tauri/gen/schemas/acl-manifests.json b/apps/desktop/src-tauri/gen/schemas/acl-manifests.json index d6917fb0a0..4c6f8de462 100644 --- a/apps/desktop/src-tauri/gen/schemas/acl-manifests.json +++ b/apps/desktop/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"__app-acl__":{"default_permission":null,"permissions":{"allow-check-for-updates":{"identifier":"allow-check-for-updates","description":"Enables the check_for_updates command without any pre-configured scope.","commands":{"allow":["check_for_updates"],"deny":[]}},"allow-get-update-state":{"identifier":"allow-get-update-state","description":"Enables the get_update_state command without any pre-configured scope.","commands":{"allow":["get_update_state"],"deny":[]}},"allow-install-update":{"identifier":"allow-install-update","description":"Enables the install_update command without any pre-configured scope.","commands":{"allow":["install_update"],"deny":[]}},"allow-open-external-url":{"identifier":"allow-open-external-url","description":"Enables the open_external_url command without any pre-configured scope.","commands":{"allow":["open_external_url"],"deny":[]}},"allow-show-native-notification":{"identifier":"allow-show-native-notification","description":"Enables the show_native_notification command without any pre-configured scope.","commands":{"allow":["show_native_notification"],"deny":[]}},"deny-check-for-updates":{"identifier":"deny-check-for-updates","description":"Denies the check_for_updates command without any pre-configured scope.","commands":{"allow":[],"deny":["check_for_updates"]}},"deny-get-update-state":{"identifier":"deny-get-update-state","description":"Denies the get_update_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_update_state"]}},"deny-install-update":{"identifier":"deny-install-update","description":"Denies the install_update command without any pre-configured scope.","commands":{"allow":[],"deny":["install_update"]}},"deny-open-external-url":{"identifier":"deny-open-external-url","description":"Denies the open_external_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_external_url"]}},"deny-show-native-notification":{"identifier":"deny-show-native-notification","description":"Denies the show_native_notification command without any pre-configured scope.","commands":{"allow":[],"deny":["show_native_notification"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"notification":{"default_permission":{"identifier":"default","description":"This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n","permissions":["allow-is-permission-granted","allow-request-permission","allow-notify","allow-register-action-types","allow-register-listener","allow-cancel","allow-get-pending","allow-remove-active","allow-get-active","allow-check-permissions","allow-show","allow-batch","allow-list-channels","allow-delete-channel","allow-create-channel","allow-permission-state"]},"permissions":{"allow-batch":{"identifier":"allow-batch","description":"Enables the batch command without any pre-configured scope.","commands":{"allow":["batch"],"deny":[]}},"allow-cancel":{"identifier":"allow-cancel","description":"Enables the cancel command without any pre-configured scope.","commands":{"allow":["cancel"],"deny":[]}},"allow-check-permissions":{"identifier":"allow-check-permissions","description":"Enables the check_permissions command without any pre-configured scope.","commands":{"allow":["check_permissions"],"deny":[]}},"allow-create-channel":{"identifier":"allow-create-channel","description":"Enables the create_channel command without any pre-configured scope.","commands":{"allow":["create_channel"],"deny":[]}},"allow-delete-channel":{"identifier":"allow-delete-channel","description":"Enables the delete_channel command without any pre-configured scope.","commands":{"allow":["delete_channel"],"deny":[]}},"allow-get-active":{"identifier":"allow-get-active","description":"Enables the get_active command without any pre-configured scope.","commands":{"allow":["get_active"],"deny":[]}},"allow-get-pending":{"identifier":"allow-get-pending","description":"Enables the get_pending command without any pre-configured scope.","commands":{"allow":["get_pending"],"deny":[]}},"allow-is-permission-granted":{"identifier":"allow-is-permission-granted","description":"Enables the is_permission_granted command without any pre-configured scope.","commands":{"allow":["is_permission_granted"],"deny":[]}},"allow-list-channels":{"identifier":"allow-list-channels","description":"Enables the list_channels command without any pre-configured scope.","commands":{"allow":["list_channels"],"deny":[]}},"allow-notify":{"identifier":"allow-notify","description":"Enables the notify command without any pre-configured scope.","commands":{"allow":["notify"],"deny":[]}},"allow-permission-state":{"identifier":"allow-permission-state","description":"Enables the permission_state command without any pre-configured scope.","commands":{"allow":["permission_state"],"deny":[]}},"allow-register-action-types":{"identifier":"allow-register-action-types","description":"Enables the register_action_types command without any pre-configured scope.","commands":{"allow":["register_action_types"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-active":{"identifier":"allow-remove-active","description":"Enables the remove_active command without any pre-configured scope.","commands":{"allow":["remove_active"],"deny":[]}},"allow-request-permission":{"identifier":"allow-request-permission","description":"Enables the request_permission command without any pre-configured scope.","commands":{"allow":["request_permission"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"deny-batch":{"identifier":"deny-batch","description":"Denies the batch command without any pre-configured scope.","commands":{"allow":[],"deny":["batch"]}},"deny-cancel":{"identifier":"deny-cancel","description":"Denies the cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel"]}},"deny-check-permissions":{"identifier":"deny-check-permissions","description":"Denies the check_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_permissions"]}},"deny-create-channel":{"identifier":"deny-create-channel","description":"Denies the create_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["create_channel"]}},"deny-delete-channel":{"identifier":"deny-delete-channel","description":"Denies the delete_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_channel"]}},"deny-get-active":{"identifier":"deny-get-active","description":"Denies the get_active command without any pre-configured scope.","commands":{"allow":[],"deny":["get_active"]}},"deny-get-pending":{"identifier":"deny-get-pending","description":"Denies the get_pending command without any pre-configured scope.","commands":{"allow":[],"deny":["get_pending"]}},"deny-is-permission-granted":{"identifier":"deny-is-permission-granted","description":"Denies the is_permission_granted command without any pre-configured scope.","commands":{"allow":[],"deny":["is_permission_granted"]}},"deny-list-channels":{"identifier":"deny-list-channels","description":"Denies the list_channels command without any pre-configured scope.","commands":{"allow":[],"deny":["list_channels"]}},"deny-notify":{"identifier":"deny-notify","description":"Denies the notify command without any pre-configured scope.","commands":{"allow":[],"deny":["notify"]}},"deny-permission-state":{"identifier":"deny-permission-state","description":"Denies the permission_state command without any pre-configured scope.","commands":{"allow":[],"deny":["permission_state"]}},"deny-register-action-types":{"identifier":"deny-register-action-types","description":"Denies the register_action_types command without any pre-configured scope.","commands":{"allow":[],"deny":["register_action_types"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-active":{"identifier":"deny-remove-active","description":"Denies the remove_active command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_active"]}},"deny-request-permission":{"identifier":"deny-request-permission","description":"Denies the request_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_permission"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"__app-acl__":{"default_permission":null,"permissions":{"allow-check-for-updates":{"identifier":"allow-check-for-updates","description":"Enables the check_for_updates command without any pre-configured scope.","commands":{"allow":["check_for_updates"],"deny":[]}},"allow-get-native-notification-permission":{"identifier":"allow-get-native-notification-permission","description":"Enables the get_native_notification_permission command without any pre-configured scope.","commands":{"allow":["get_native_notification_permission"],"deny":[]}},"allow-get-update-state":{"identifier":"allow-get-update-state","description":"Enables the get_update_state command without any pre-configured scope.","commands":{"allow":["get_update_state"],"deny":[]}},"allow-install-update":{"identifier":"allow-install-update","description":"Enables the install_update command without any pre-configured scope.","commands":{"allow":["install_update"],"deny":[]}},"allow-open-external-url":{"identifier":"allow-open-external-url","description":"Enables the open_external_url command without any pre-configured scope.","commands":{"allow":["open_external_url"],"deny":[]}},"allow-request-native-notification-permission":{"identifier":"allow-request-native-notification-permission","description":"Enables the request_native_notification_permission command without any pre-configured scope.","commands":{"allow":["request_native_notification_permission"],"deny":[]}},"allow-show-native-notification":{"identifier":"allow-show-native-notification","description":"Enables the show_native_notification command without any pre-configured scope.","commands":{"allow":["show_native_notification"],"deny":[]}},"deny-check-for-updates":{"identifier":"deny-check-for-updates","description":"Denies the check_for_updates command without any pre-configured scope.","commands":{"allow":[],"deny":["check_for_updates"]}},"deny-get-native-notification-permission":{"identifier":"deny-get-native-notification-permission","description":"Denies the get_native_notification_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["get_native_notification_permission"]}},"deny-get-update-state":{"identifier":"deny-get-update-state","description":"Denies the get_update_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_update_state"]}},"deny-install-update":{"identifier":"deny-install-update","description":"Denies the install_update command without any pre-configured scope.","commands":{"allow":[],"deny":["install_update"]}},"deny-open-external-url":{"identifier":"deny-open-external-url","description":"Denies the open_external_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_external_url"]}},"deny-request-native-notification-permission":{"identifier":"deny-request-native-notification-permission","description":"Denies the request_native_notification_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_native_notification_permission"]}},"deny-show-native-notification":{"identifier":"deny-show-native-notification","description":"Denies the show_native_notification command without any pre-configured scope.","commands":{"allow":[],"deny":["show_native_notification"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"notification":{"default_permission":{"identifier":"default","description":"This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n","permissions":["allow-is-permission-granted","allow-request-permission","allow-notify","allow-register-action-types","allow-register-listener","allow-cancel","allow-get-pending","allow-remove-active","allow-get-active","allow-check-permissions","allow-show","allow-batch","allow-list-channels","allow-delete-channel","allow-create-channel","allow-permission-state"]},"permissions":{"allow-batch":{"identifier":"allow-batch","description":"Enables the batch command without any pre-configured scope.","commands":{"allow":["batch"],"deny":[]}},"allow-cancel":{"identifier":"allow-cancel","description":"Enables the cancel command without any pre-configured scope.","commands":{"allow":["cancel"],"deny":[]}},"allow-check-permissions":{"identifier":"allow-check-permissions","description":"Enables the check_permissions command without any pre-configured scope.","commands":{"allow":["check_permissions"],"deny":[]}},"allow-create-channel":{"identifier":"allow-create-channel","description":"Enables the create_channel command without any pre-configured scope.","commands":{"allow":["create_channel"],"deny":[]}},"allow-delete-channel":{"identifier":"allow-delete-channel","description":"Enables the delete_channel command without any pre-configured scope.","commands":{"allow":["delete_channel"],"deny":[]}},"allow-get-active":{"identifier":"allow-get-active","description":"Enables the get_active command without any pre-configured scope.","commands":{"allow":["get_active"],"deny":[]}},"allow-get-pending":{"identifier":"allow-get-pending","description":"Enables the get_pending command without any pre-configured scope.","commands":{"allow":["get_pending"],"deny":[]}},"allow-is-permission-granted":{"identifier":"allow-is-permission-granted","description":"Enables the is_permission_granted command without any pre-configured scope.","commands":{"allow":["is_permission_granted"],"deny":[]}},"allow-list-channels":{"identifier":"allow-list-channels","description":"Enables the list_channels command without any pre-configured scope.","commands":{"allow":["list_channels"],"deny":[]}},"allow-notify":{"identifier":"allow-notify","description":"Enables the notify command without any pre-configured scope.","commands":{"allow":["notify"],"deny":[]}},"allow-permission-state":{"identifier":"allow-permission-state","description":"Enables the permission_state command without any pre-configured scope.","commands":{"allow":["permission_state"],"deny":[]}},"allow-register-action-types":{"identifier":"allow-register-action-types","description":"Enables the register_action_types command without any pre-configured scope.","commands":{"allow":["register_action_types"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-active":{"identifier":"allow-remove-active","description":"Enables the remove_active command without any pre-configured scope.","commands":{"allow":["remove_active"],"deny":[]}},"allow-request-permission":{"identifier":"allow-request-permission","description":"Enables the request_permission command without any pre-configured scope.","commands":{"allow":["request_permission"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"deny-batch":{"identifier":"deny-batch","description":"Denies the batch command without any pre-configured scope.","commands":{"allow":[],"deny":["batch"]}},"deny-cancel":{"identifier":"deny-cancel","description":"Denies the cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel"]}},"deny-check-permissions":{"identifier":"deny-check-permissions","description":"Denies the check_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_permissions"]}},"deny-create-channel":{"identifier":"deny-create-channel","description":"Denies the create_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["create_channel"]}},"deny-delete-channel":{"identifier":"deny-delete-channel","description":"Denies the delete_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_channel"]}},"deny-get-active":{"identifier":"deny-get-active","description":"Denies the get_active command without any pre-configured scope.","commands":{"allow":[],"deny":["get_active"]}},"deny-get-pending":{"identifier":"deny-get-pending","description":"Denies the get_pending command without any pre-configured scope.","commands":{"allow":[],"deny":["get_pending"]}},"deny-is-permission-granted":{"identifier":"deny-is-permission-granted","description":"Denies the is_permission_granted command without any pre-configured scope.","commands":{"allow":[],"deny":["is_permission_granted"]}},"deny-list-channels":{"identifier":"deny-list-channels","description":"Denies the list_channels command without any pre-configured scope.","commands":{"allow":[],"deny":["list_channels"]}},"deny-notify":{"identifier":"deny-notify","description":"Denies the notify command without any pre-configured scope.","commands":{"allow":[],"deny":["notify"]}},"deny-permission-state":{"identifier":"deny-permission-state","description":"Denies the permission_state command without any pre-configured scope.","commands":{"allow":[],"deny":["permission_state"]}},"deny-register-action-types":{"identifier":"deny-register-action-types","description":"Denies the register_action_types command without any pre-configured scope.","commands":{"allow":[],"deny":["register_action_types"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-active":{"identifier":"deny-remove-active","description":"Denies the remove_active command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_active"]}},"deny-request-permission":{"identifier":"deny-request-permission","description":"Denies the request_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_permission"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/apps/desktop/src-tauri/gen/schemas/capabilities.json b/apps/desktop/src-tauri/gen/schemas/capabilities.json index 3367a8ce86..d459b84acc 100644 --- a/apps/desktop/src-tauri/gen/schemas/capabilities.json +++ b/apps/desktop/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Desktop bridge; privileged commands verify the exact owned backend origin","remote":{"urls":["http://127.0.0.1:*"]},"local":false,"windows":["main"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","allow-get-update-state","allow-check-for-updates","allow-install-update","allow-show-native-notification","allow-open-external-url"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Desktop bridge; privileged commands verify the exact owned backend origin","remote":{"urls":["http://127.0.0.1:*"]},"local":false,"windows":["main"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","allow-get-update-state","allow-check-for-updates","allow-install-update","allow-show-native-notification","allow-get-native-notification-permission","allow-request-native-notification-permission","allow-open-external-url"]}} \ No newline at end of file diff --git a/apps/desktop/src-tauri/gen/schemas/desktop-schema.json b/apps/desktop/src-tauri/gen/schemas/desktop-schema.json index 45ff29b8f1..f1605a9e8b 100644 --- a/apps/desktop/src-tauri/gen/schemas/desktop-schema.json +++ b/apps/desktop/src-tauri/gen/schemas/desktop-schema.json @@ -350,6 +350,12 @@ "const": "allow-check-for-updates", "markdownDescription": "Enables the check_for_updates command without any pre-configured scope." }, + { + "description": "Enables the get_native_notification_permission command without any pre-configured scope.", + "type": "string", + "const": "allow-get-native-notification-permission", + "markdownDescription": "Enables the get_native_notification_permission command without any pre-configured scope." + }, { "description": "Enables the get_update_state command without any pre-configured scope.", "type": "string", @@ -368,6 +374,12 @@ "const": "allow-open-external-url", "markdownDescription": "Enables the open_external_url command without any pre-configured scope." }, + { + "description": "Enables the request_native_notification_permission command without any pre-configured scope.", + "type": "string", + "const": "allow-request-native-notification-permission", + "markdownDescription": "Enables the request_native_notification_permission command without any pre-configured scope." + }, { "description": "Enables the show_native_notification command without any pre-configured scope.", "type": "string", @@ -380,6 +392,12 @@ "const": "deny-check-for-updates", "markdownDescription": "Denies the check_for_updates command without any pre-configured scope." }, + { + "description": "Denies the get_native_notification_permission command without any pre-configured scope.", + "type": "string", + "const": "deny-get-native-notification-permission", + "markdownDescription": "Denies the get_native_notification_permission command without any pre-configured scope." + }, { "description": "Denies the get_update_state command without any pre-configured scope.", "type": "string", @@ -398,6 +416,12 @@ "const": "deny-open-external-url", "markdownDescription": "Denies the open_external_url command without any pre-configured scope." }, + { + "description": "Denies the request_native_notification_permission command without any pre-configured scope.", + "type": "string", + "const": "deny-request-native-notification-permission", + "markdownDescription": "Denies the request_native_notification_permission command without any pre-configured scope." + }, { "description": "Denies the show_native_notification command without any pre-configured scope.", "type": "string", @@ -411,10 +435,10 @@ "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" }, { - "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`", + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`", "type": "string", "const": "core:app:default", - "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`" + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`" }, { "description": "Enables the app_hide command without any pre-configured scope.", @@ -458,12 +482,24 @@ "const": "core:app:allow-name", "markdownDescription": "Enables the name command without any pre-configured scope." }, + { + "description": "Enables the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-register-listener", + "markdownDescription": "Enables the register_listener command without any pre-configured scope." + }, { "description": "Enables the remove_data_store command without any pre-configured scope.", "type": "string", "const": "core:app:allow-remove-data-store", "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, { "description": "Enables the set_app_theme command without any pre-configured scope.", "type": "string", @@ -476,6 +512,12 @@ "const": "core:app:allow-set-dock-visibility", "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." }, + { + "description": "Enables the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-supports-multiple-windows", + "markdownDescription": "Enables the supports_multiple_windows command without any pre-configured scope." + }, { "description": "Enables the tauri_version command without any pre-configured scope.", "type": "string", @@ -530,12 +572,24 @@ "const": "core:app:deny-name", "markdownDescription": "Denies the name command without any pre-configured scope." }, + { + "description": "Denies the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-register-listener", + "markdownDescription": "Denies the register_listener command without any pre-configured scope." + }, { "description": "Denies the remove_data_store command without any pre-configured scope.", "type": "string", "const": "core:app:deny-remove-data-store", "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, { "description": "Denies the set_app_theme command without any pre-configured scope.", "type": "string", @@ -548,6 +602,12 @@ "const": "core:app:deny-set-dock-visibility", "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." }, + { + "description": "Denies the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-supports-multiple-windows", + "markdownDescription": "Denies the supports_multiple_windows command without any pre-configured scope." + }, { "description": "Denies the tauri_version command without any pre-configured scope.", "type": "string", @@ -1071,10 +1131,10 @@ "markdownDescription": "Denies the close command without any pre-configured scope." }, { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`", + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`", "type": "string", "const": "core:tray:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`" + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`" }, { "description": "Enables the get_by_id command without any pre-configured scope.", @@ -1106,6 +1166,12 @@ "const": "core:tray:allow-set-icon-as-template", "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." }, + { + "description": "Enables the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-with-as-template", + "markdownDescription": "Enables the set_icon_with_as_template command without any pre-configured scope." + }, { "description": "Enables the set_menu command without any pre-configured scope.", "type": "string", @@ -1172,6 +1238,12 @@ "const": "core:tray:deny-set-icon-as-template", "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." }, + { + "description": "Denies the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-with-as-template", + "markdownDescription": "Denies the set_icon_with_as_template command without any pre-configured scope." + }, { "description": "Denies the set_menu command without any pre-configured scope.", "type": "string", @@ -1431,10 +1503,16 @@ "markdownDescription": "Denies the webview_size command without any pre-configured scope." }, { - "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`", + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`", "type": "string", "const": "core:window:default", - "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`" + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-activity-name", + "markdownDescription": "Enables the activity_name command without any pre-configured scope." }, { "description": "Enables the available_monitors command without any pre-configured scope.", @@ -1628,6 +1706,12 @@ "const": "core:window:allow-scale-factor", "markdownDescription": "Enables the scale_factor command without any pre-configured scope." }, + { + "description": "Enables the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scene-identifier", + "markdownDescription": "Enables the scene_identifier command without any pre-configured scope." + }, { "description": "Enables the set_always_on_bottom command without any pre-configured scope.", "type": "string", @@ -1892,6 +1976,12 @@ "const": "core:window:allow-unminimize", "markdownDescription": "Enables the unminimize command without any pre-configured scope." }, + { + "description": "Denies the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-activity-name", + "markdownDescription": "Denies the activity_name command without any pre-configured scope." + }, { "description": "Denies the available_monitors command without any pre-configured scope.", "type": "string", @@ -2084,6 +2174,12 @@ "const": "core:window:deny-scale-factor", "markdownDescription": "Denies the scale_factor command without any pre-configured scope." }, + { + "description": "Denies the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scene-identifier", + "markdownDescription": "Denies the scene_identifier command without any pre-configured scope." + }, { "description": "Denies the set_always_on_bottom command without any pre-configured scope.", "type": "string", diff --git a/apps/desktop/src-tauri/gen/schemas/linux-schema.json b/apps/desktop/src-tauri/gen/schemas/linux-schema.json index 45ff29b8f1..f1605a9e8b 100644 --- a/apps/desktop/src-tauri/gen/schemas/linux-schema.json +++ b/apps/desktop/src-tauri/gen/schemas/linux-schema.json @@ -350,6 +350,12 @@ "const": "allow-check-for-updates", "markdownDescription": "Enables the check_for_updates command without any pre-configured scope." }, + { + "description": "Enables the get_native_notification_permission command without any pre-configured scope.", + "type": "string", + "const": "allow-get-native-notification-permission", + "markdownDescription": "Enables the get_native_notification_permission command without any pre-configured scope." + }, { "description": "Enables the get_update_state command without any pre-configured scope.", "type": "string", @@ -368,6 +374,12 @@ "const": "allow-open-external-url", "markdownDescription": "Enables the open_external_url command without any pre-configured scope." }, + { + "description": "Enables the request_native_notification_permission command without any pre-configured scope.", + "type": "string", + "const": "allow-request-native-notification-permission", + "markdownDescription": "Enables the request_native_notification_permission command without any pre-configured scope." + }, { "description": "Enables the show_native_notification command without any pre-configured scope.", "type": "string", @@ -380,6 +392,12 @@ "const": "deny-check-for-updates", "markdownDescription": "Denies the check_for_updates command without any pre-configured scope." }, + { + "description": "Denies the get_native_notification_permission command without any pre-configured scope.", + "type": "string", + "const": "deny-get-native-notification-permission", + "markdownDescription": "Denies the get_native_notification_permission command without any pre-configured scope." + }, { "description": "Denies the get_update_state command without any pre-configured scope.", "type": "string", @@ -398,6 +416,12 @@ "const": "deny-open-external-url", "markdownDescription": "Denies the open_external_url command without any pre-configured scope." }, + { + "description": "Denies the request_native_notification_permission command without any pre-configured scope.", + "type": "string", + "const": "deny-request-native-notification-permission", + "markdownDescription": "Denies the request_native_notification_permission command without any pre-configured scope." + }, { "description": "Denies the show_native_notification command without any pre-configured scope.", "type": "string", @@ -411,10 +435,10 @@ "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" }, { - "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`", + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`", "type": "string", "const": "core:app:default", - "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`" + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`" }, { "description": "Enables the app_hide command without any pre-configured scope.", @@ -458,12 +482,24 @@ "const": "core:app:allow-name", "markdownDescription": "Enables the name command without any pre-configured scope." }, + { + "description": "Enables the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-register-listener", + "markdownDescription": "Enables the register_listener command without any pre-configured scope." + }, { "description": "Enables the remove_data_store command without any pre-configured scope.", "type": "string", "const": "core:app:allow-remove-data-store", "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, { "description": "Enables the set_app_theme command without any pre-configured scope.", "type": "string", @@ -476,6 +512,12 @@ "const": "core:app:allow-set-dock-visibility", "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." }, + { + "description": "Enables the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-supports-multiple-windows", + "markdownDescription": "Enables the supports_multiple_windows command without any pre-configured scope." + }, { "description": "Enables the tauri_version command without any pre-configured scope.", "type": "string", @@ -530,12 +572,24 @@ "const": "core:app:deny-name", "markdownDescription": "Denies the name command without any pre-configured scope." }, + { + "description": "Denies the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-register-listener", + "markdownDescription": "Denies the register_listener command without any pre-configured scope." + }, { "description": "Denies the remove_data_store command without any pre-configured scope.", "type": "string", "const": "core:app:deny-remove-data-store", "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, { "description": "Denies the set_app_theme command without any pre-configured scope.", "type": "string", @@ -548,6 +602,12 @@ "const": "core:app:deny-set-dock-visibility", "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." }, + { + "description": "Denies the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-supports-multiple-windows", + "markdownDescription": "Denies the supports_multiple_windows command without any pre-configured scope." + }, { "description": "Denies the tauri_version command without any pre-configured scope.", "type": "string", @@ -1071,10 +1131,10 @@ "markdownDescription": "Denies the close command without any pre-configured scope." }, { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`", + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`", "type": "string", "const": "core:tray:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`" + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`" }, { "description": "Enables the get_by_id command without any pre-configured scope.", @@ -1106,6 +1166,12 @@ "const": "core:tray:allow-set-icon-as-template", "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." }, + { + "description": "Enables the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-with-as-template", + "markdownDescription": "Enables the set_icon_with_as_template command without any pre-configured scope." + }, { "description": "Enables the set_menu command without any pre-configured scope.", "type": "string", @@ -1172,6 +1238,12 @@ "const": "core:tray:deny-set-icon-as-template", "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." }, + { + "description": "Denies the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-with-as-template", + "markdownDescription": "Denies the set_icon_with_as_template command without any pre-configured scope." + }, { "description": "Denies the set_menu command without any pre-configured scope.", "type": "string", @@ -1431,10 +1503,16 @@ "markdownDescription": "Denies the webview_size command without any pre-configured scope." }, { - "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`", + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`", "type": "string", "const": "core:window:default", - "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`" + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-activity-name", + "markdownDescription": "Enables the activity_name command without any pre-configured scope." }, { "description": "Enables the available_monitors command without any pre-configured scope.", @@ -1628,6 +1706,12 @@ "const": "core:window:allow-scale-factor", "markdownDescription": "Enables the scale_factor command without any pre-configured scope." }, + { + "description": "Enables the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scene-identifier", + "markdownDescription": "Enables the scene_identifier command without any pre-configured scope." + }, { "description": "Enables the set_always_on_bottom command without any pre-configured scope.", "type": "string", @@ -1892,6 +1976,12 @@ "const": "core:window:allow-unminimize", "markdownDescription": "Enables the unminimize command without any pre-configured scope." }, + { + "description": "Denies the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-activity-name", + "markdownDescription": "Denies the activity_name command without any pre-configured scope." + }, { "description": "Denies the available_monitors command without any pre-configured scope.", "type": "string", @@ -2084,6 +2174,12 @@ "const": "core:window:deny-scale-factor", "markdownDescription": "Denies the scale_factor command without any pre-configured scope." }, + { + "description": "Denies the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scene-identifier", + "markdownDescription": "Denies the scene_identifier command without any pre-configured scope." + }, { "description": "Denies the set_always_on_bottom command without any pre-configured scope.", "type": "string", diff --git a/apps/desktop/src-tauri/permissions/autogenerated/get_native_notification_permission.toml b/apps/desktop/src-tauri/permissions/autogenerated/get_native_notification_permission.toml new file mode 100644 index 0000000000..dc3ae491ad --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/get_native_notification_permission.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-get-native-notification-permission" +description = "Enables the get_native_notification_permission command without any pre-configured scope." +commands.allow = ["get_native_notification_permission"] + +[[permission]] +identifier = "deny-get-native-notification-permission" +description = "Denies the get_native_notification_permission command without any pre-configured scope." +commands.deny = ["get_native_notification_permission"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/request_native_notification_permission.toml b/apps/desktop/src-tauri/permissions/autogenerated/request_native_notification_permission.toml new file mode 100644 index 0000000000..d2695bdad4 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/request_native_notification_permission.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-request-native-notification-permission" +description = "Enables the request_native_notification_permission command without any pre-configured scope." +commands.allow = ["request_native_notification_permission"] + +[[permission]] +identifier = "deny-request-native-notification-permission" +description = "Denies the request_native_notification_permission command without any pre-configured scope." +commands.deny = ["request_native_notification_permission"] diff --git a/apps/desktop/src-tauri/src/backend.rs b/apps/desktop/src-tauri/src/backend.rs index 15a19a26ea..b93076a21a 100644 --- a/apps/desktop/src-tauri/src/backend.rs +++ b/apps/desktop/src-tauri/src/backend.rs @@ -359,8 +359,7 @@ pub fn desktop_environment( ) -> BTreeMap { let path = normalized_path(env.get(OsStr::new("PATH")), home_dir); env.retain(|key, _| { - !key - .to_string_lossy() + !key.to_string_lossy() .eq_ignore_ascii_case(DESKTOP_NATIVE_NOTIFICATIONS_ENV) }); env.insert( diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 587e162319..7266301cbf 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -39,6 +39,8 @@ fn main() { updater::check_for_updates, updater::install_update, native_notifications::show_native_notification, + native_notifications::get_native_notification_permission, + native_notifications::request_native_notification_permission, external_links::open_external_url, ]) .menu(build_menu) diff --git a/apps/desktop/src-tauri/src/native_notifications.rs b/apps/desktop/src-tauri/src/native_notifications.rs index ecf092937c..0423525904 100644 --- a/apps/desktop/src-tauri/src/native_notifications.rs +++ b/apps/desktop/src-tauri/src/native_notifications.rs @@ -1,11 +1,5 @@ use serde::{Deserialize, Serialize}; -use std::{ - collections::VecDeque, - sync::{ - atomic::{AtomicBool, Ordering}, - Mutex, - }, -}; +use std::{collections::VecDeque, sync::Mutex}; const MAX_SEEN_EVENT_IDS: usize = 1_024; @@ -15,7 +9,7 @@ pub struct NativeNotificationRequest { pub event_id: String, pub title: String, pub body: String, - /// Present for session-scoped events (waiting-for-input, failed). + /// Present for session-scoped events (turn-finished, clarification-requested, 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`). @@ -23,19 +17,18 @@ pub struct NativeNotificationRequest { pub task_id: Option, pub session_id: Option, } - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] pub enum NativeNotificationResult { Shown, Duplicate, PermissionDenied, + DisplayFailed, } #[derive(Debug, Default)] pub struct NativeNotificationState { seen_event_ids: Mutex>, - permission_prompt_attempted: AtomicBool, } impl NativeNotificationState { @@ -75,24 +68,12 @@ impl NativeNotificationState { NativeNotificationResult::Duplicate } } - - fn begin_permission_prompt(&self) -> bool { - !self - .permission_prompt_attempted - .swap(true, Ordering::SeqCst) - } - - fn allow_permission_prompt_retry(&self) { - self.permission_prompt_attempted - .store(false, Ordering::SeqCst); - } } /// 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:", +const SESSION_EVENT_PREFIXES: [&str; 3] = [ "session.failed:", "session.turn_finished:", "session.clarification_requested:", @@ -139,19 +120,10 @@ pub fn show_native_notification( backend.require_owned_origin(&webview)?; validate_request(&request)?; - let mut permission = app + let permission = app .notification() .permission_state() .map_err(|err| err.to_string())?; - if permission != PermissionState::Granted - && permission != PermissionState::Denied - && state.begin_permission_prompt() - { - permission = app.notification().request_permission().map_err(|err| { - state.allow_permission_prompt_retry(); - err.to_string() - })?; - } let claim_result = state.claim_after_permission(&request, permission == PermissionState::Granted); if claim_result != NativeNotificationResult::Shown { @@ -165,11 +137,42 @@ pub fn show_native_notification( .show(); if let Err(err) = show_result { state.release(&request.event_id); - return Err(err.to_string()); + eprintln!("Could not show native notification: {err}"); + return Ok(NativeNotificationResult::DisplayFailed); } Ok(NativeNotificationResult::Shown) } +#[cfg(feature = "desktop-runtime")] +#[tauri::command] +pub fn get_native_notification_permission( + app: tauri::AppHandle, + backend: tauri::State<'_, crate::backend::BackendState>, + webview: tauri::WebviewWindow, +) -> Result { + use tauri_plugin_notification::NotificationExt; + + backend.require_owned_origin(&webview)?; + app.notification() + .permission_state() + .map_err(|err| err.to_string()) +} + +#[cfg(feature = "desktop-runtime")] +#[tauri::command] +pub fn request_native_notification_permission( + app: tauri::AppHandle, + backend: tauri::State<'_, crate::backend::BackendState>, + webview: tauri::WebviewWindow, +) -> Result { + use tauri_plugin_notification::NotificationExt; + + backend.require_owned_origin(&webview)?; + app.notification() + .request_permission() + .map_err(|err| err.to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -229,11 +232,22 @@ mod tests { for event_id in [ "session.turn_finished:turn-1", "session.clarification_requested:request-1", + "session.failed:session-1", ] { assert_eq!(validate_request(&request(event_id, "task-1")), Ok(())); } } + #[test] + fn bridge_rejects_retired_waiting_for_input_events() { + let request = request("session.waiting_for_input:session-1", "task-1"); + + assert_eq!( + validate_request(&request), + Err("unsupported native notification event".to_string()) + ); + } + #[test] fn bridge_accepts_update_available_events_without_task_id() { let request = update_available_request("system.update_available:v1.2.3"); @@ -243,7 +257,7 @@ mod tests { #[test] fn bridge_rejects_session_events_missing_task_id() { - let mut request = request("session.waiting_for_input:session-1", "task-1"); + let mut request = request("session.clarification_requested:session-1", "task-1"); request.task_id = None; assert_eq!( @@ -277,19 +291,15 @@ mod tests { } #[test] - fn permission_prompt_is_attempted_only_once_per_process() { + fn display_failure_releases_the_event_identity_for_retry() { let state = NativeNotificationState::default(); + let request = request("session.failed:session-1", "task-1"); - assert!(state.begin_permission_prompt()); - assert!(!state.begin_permission_prompt()); - } - - #[test] - fn permission_prompt_can_retry_after_a_transient_error() { - let state = NativeNotificationState::default(); - - assert!(state.begin_permission_prompt()); - state.allow_permission_prompt_retry(); - assert!(state.begin_permission_prompt()); + assert!(state.claim(&request.event_id)); + state.release(&request.event_id); + assert_eq!( + state.claim_after_permission(&request, true), + NativeNotificationResult::Shown + ); } } diff --git a/apps/web/components/settings/notification-permission-section.test.tsx b/apps/web/components/settings/notification-permission-section.test.tsx new file mode 100644 index 0000000000..3c0ba55129 --- /dev/null +++ b/apps/web/components/settings/notification-permission-section.test.tsx @@ -0,0 +1,90 @@ +import { render, renderHook, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + DesktopNotificationsSection, + useNotificationPermission, +} from "./notification-permission-section"; + +const mocks = vi.hoisted(() => ({ + nativeAvailable: vi.fn(), + nativePermissionGet: vi.fn(), +})); +const originalNotification = globalThis.Notification; + +vi.mock("@/lib/desktop/native-notification-client", () => ({ + nativeNotifications: { + isAvailable: mocks.nativeAvailable, + permission: { get: mocks.nativePermissionGet }, + }, +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + Object.defineProperty(globalThis, "Notification", { + configurable: true, + value: originalNotification, + }); +}); + +describe("useNotificationPermission", () => { + it("reads native notification permission when Tauri is available", async () => { + mocks.nativeAvailable.mockReturnValue(true); + mocks.nativePermissionGet.mockResolvedValue("granted"); + + const { result } = renderHook(() => useNotificationPermission()); + + await waitFor(() => expect(result.current.notificationPermission).toBe("granted")); + expect(mocks.nativePermissionGet).toHaveBeenCalledOnce(); + }); + + it("reads browser notification permission when Tauri is unavailable", async () => { + mocks.nativeAvailable.mockReturnValue(false); + Object.defineProperty(globalThis, "Notification", { + configurable: true, + value: { permission: "denied" }, + }); + + const { result } = renderHook(() => useNotificationPermission()); + + await waitFor(() => expect(result.current.notificationPermission).toBe("denied")); + }); + + it("reports unsupported when browser notifications are unavailable", async () => { + mocks.nativeAvailable.mockReturnValue(false); + Object.defineProperty(globalThis, "Notification", { + configurable: true, + value: undefined, + }); + + const { result } = renderHook(() => useNotificationPermission()); + + await waitFor(() => expect(result.current.notificationPermission).toBe("unsupported")); + }); + + it("reports an error when the native permission query rejects", async () => { + mocks.nativeAvailable.mockReturnValue(true); + mocks.nativePermissionGet.mockRejectedValueOnce(new Error("native permission unavailable")); + + const { result } = renderHook(() => useNotificationPermission()); + + await waitFor(() => expect(result.current.notificationPermission).toBe("error")); + }); +}); + +describe("DesktopNotificationsSection", () => { + it("names the permission refresh button", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: "Refresh notification permission" })).toBeTruthy(); + }); +}); diff --git a/apps/web/components/settings/notification-permission-section.tsx b/apps/web/components/settings/notification-permission-section.tsx new file mode 100644 index 0000000000..ea013931e2 --- /dev/null +++ b/apps/web/components/settings/notification-permission-section.tsx @@ -0,0 +1,146 @@ +import { useCallback, useEffect, useState } from "react"; +import { IconBell, IconRefresh } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; +import { + nativeNotifications, + type NativeNotificationPermission, +} from "@/lib/desktop/native-notification-client"; +import type { PermissionRefresh } from "./notifications-settings-actions"; + +type NotificationPermissionState = + | NativeNotificationPermission + | NotificationPermission + | "unsupported" + | "error"; + +type DesktopNotificationsSectionProps = { + notificationPermission: NotificationPermissionState; + onRequestPermission: () => Promise; + onRefreshPermission: () => Promise; + onTestNotification: () => void; +}; + +function permissionActionLabel(permission: NotificationPermissionState) { + if (permission === "granted") return "Enabled"; + if (permission === "error") return "Retry"; + return "Enable"; +} + +export function DesktopNotificationsSection({ + notificationPermission, + onRequestPermission, + onRefreshPermission, + onTestNotification, +}: DesktopNotificationsSectionProps) { + return ( +
+
+
+
Desktop Notifications
+

+ Notify this device when a selected agent turn, question, or Office event occurs. +

+
+
+ + + + + + + Refresh permission status + + + + + + + + If you do not see notifications, check your OS settings and allow this browser. + + +
+
+ + {notificationPermission === "denied" && ( +

+ {nativeNotifications.isAvailable() + ? "Notifications are blocked in your OS app notification settings. Enable them there, then click Refresh." + : "Notifications are blocked in your browser. Enable them in site settings, then click Refresh."} +

+ )} + {notificationPermission === "unsupported" && ( +

+ This browser does not support desktop notifications. +

+ )} + {notificationPermission === "error" && ( +

+ Kandev could not check notification permission. Try again, then check your browser or OS + notification settings. +

+ )} +
+ ); +} + +export function useNotificationPermission() { + const [notificationPermission, setNotificationPermission] = + useState("default"); + const refreshPermission = useCallback(async (error) => { + if (error) { + setNotificationPermission("error"); + return; + } + try { + if (nativeNotifications.isAvailable()) { + setNotificationPermission(await nativeNotifications.permission.get()); + return; + } + setNotificationPermission( + typeof Notification === "undefined" ? "unsupported" : Notification.permission, + ); + } catch { + setNotificationPermission("error"); + } + }, []); + useEffect(() => { + void refreshPermission(); + }, [refreshPermission]); + return { notificationPermission, refreshPermission }; +} diff --git a/apps/web/components/settings/notifications-settings-actions.test.tsx b/apps/web/components/settings/notifications-settings-actions.test.tsx index 36449f2869..4dc897dcee 100644 --- a/apps/web/components/settings/notifications-settings-actions.test.tsx +++ b/apps/web/components/settings/notifications-settings-actions.test.tsx @@ -1,5 +1,5 @@ -import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, render, renderHook, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { NotificationProvider } from "@/lib/types/http"; import { useIsDirty, @@ -7,6 +7,10 @@ import { useNotificationsState, useSaveRequest, } from "./notifications-settings-actions"; +import { + DesktopNotificationsSection, + useNotificationPermission, +} from "./notification-permission-section"; const mocks = vi.hoisted(() => ({ createNotificationProvider: vi.fn(), @@ -14,6 +18,9 @@ const mocks = vi.hoisted(() => ({ testNotificationProvider: vi.fn(), updateNotificationProvider: vi.fn(), setNotificationProviders: vi.fn(), + nativeAvailable: vi.fn(), + nativePermissionGet: vi.fn(), + nativePermissionRequest: vi.fn(), })); const PAGER_URL = "json://pager"; const CLARIFICATION_EVENT = "session.clarification_requested"; @@ -40,6 +47,7 @@ const savedProvider: NotificationProvider = { let notificationProviders = [savedProvider]; let notificationEvents = ["task.completed"]; let notificationProvidersLoaded = true; +const originalNotification = globalThis.Notification; vi.mock("@/hooks/domains/settings/use-notification-providers", () => ({ useNotificationProviders: () => ({ @@ -55,6 +63,16 @@ vi.mock("@/components/state-provider", () => ({ selector({ setNotificationProviders: mocks.setNotificationProviders }), })); +vi.mock("@/lib/desktop/native-notification-client", () => ({ + nativeNotifications: { + isAvailable: mocks.nativeAvailable, + permission: { + get: mocks.nativePermissionGet, + request: mocks.nativePermissionRequest, + }, + }, +})); + function useHarness() { const state = useNotificationsState(); const saveRequest = useSaveRequest(state); @@ -74,9 +92,17 @@ beforeEach(() => { notificationProviders = [savedProvider]; notificationEvents = ["task.completed"]; notificationProvidersLoaded = true; + mocks.nativeAvailable.mockReturnValue(true); mocks.createNotificationProvider.mockResolvedValue(createdProvider); }); +afterEach(() => { + Object.defineProperty(globalThis, "Notification", { + configurable: true, + value: originalNotification, + }); +}); + describe("notification provider draft hydration", () => { it("hydrates a cold provider load into the draft", () => { notificationProviders = []; @@ -221,3 +247,130 @@ describe("notification provider draft saving", () => { expect(result.current.isDirty).toBe(false); }); }); + +describe("notification permission actions", () => { + it("surfaces a rejected native permission query as an actionable error state", async () => { + mocks.nativePermissionGet.mockRejectedValueOnce(new Error("native permission unavailable")); + + const { result } = renderHook(() => useNotificationPermission()); + + await waitFor(() => expect(result.current.notificationPermission).toBe("error")); + }); + + it("renders actionable feedback when the permission state cannot be queried", () => { + render( + , + ); + + expect(screen.getByText(/could not check notification permission/i)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy(); + }); + + it("requests native permission from the user action when Tauri is available", async () => { + mocks.nativePermissionRequest.mockResolvedValue("granted"); + const refreshPermission = vi.fn(); + const { result } = renderHook(() => { + const state = useNotificationsState(); + return useNotificationsActions(state, refreshPermission); + }); + + await act(() => result.current.handleRequestPermission()); + + expect(mocks.nativePermissionRequest).toHaveBeenCalledOnce(); + expect(refreshPermission).toHaveBeenCalledOnce(); + }); + + it("handles a rejected native permission request without leaking the rejection", async () => { + mocks.nativePermissionRequest.mockRejectedValueOnce(new Error("native permission unavailable")); + const refreshPermission = vi.fn(); + const { result } = renderHook(() => { + const state = useNotificationsState(); + return useNotificationsActions(state, refreshPermission); + }); + + await expect(result.current.handleRequestPermission()).resolves.toBeUndefined(); + + expect(refreshPermission).toHaveBeenCalledWith(expect.any(Error)); + }); + + it("reports a rejected browser permission request without leaking the rejection", async () => { + mocks.nativePermissionRequest.mockReset(); + const originalNotification = globalThis.Notification; + Object.defineProperty(globalThis, "Notification", { + configurable: true, + value: { + permission: "default", + requestPermission: vi.fn().mockRejectedValue(new Error("blocked")), + }, + }); + const refreshPermission = vi.fn(); + mocks.nativeAvailable.mockReturnValueOnce(false); + const { result } = renderHook(() => { + const state = useNotificationsState(); + return useNotificationsActions(state, refreshPermission); + }); + + await expect(result.current.handleRequestPermission()).resolves.toBeUndefined(); + + expect(refreshPermission).toHaveBeenCalledWith(expect.any(Error)); + Object.defineProperty(globalThis, "Notification", { + configurable: true, + value: originalNotification, + }); + }); +}); + +describe("notification permission transport behavior", () => { + it("requests browser permission alongside native permission for Office notification fallback", async () => { + mocks.nativePermissionRequest.mockResolvedValue("granted"); + const requestPermission = vi.fn().mockResolvedValue("granted"); + Object.defineProperty(globalThis, "Notification", { + configurable: true, + value: { permission: "default", requestPermission }, + }); + const refreshPermission = vi.fn(); + const { result } = renderHook(() => { + const state = useNotificationsState(); + return useNotificationsActions(state, refreshPermission); + }); + + await act(() => result.current.handleRequestPermission()); + + expect(mocks.nativePermissionRequest).toHaveBeenCalledOnce(); + expect(requestPermission).toHaveBeenCalledOnce(); + expect(refreshPermission).toHaveBeenCalledOnce(); + }); + + it("directs denied native notifications to OS app settings", () => { + mocks.nativeAvailable.mockReturnValue(true); + const { container } = render( + , + ); + + expect(within(container).getByText(/OS app notification settings/i)).toBeTruthy(); + }); + + it("directs denied browser notifications to site settings", () => { + mocks.nativeAvailable.mockReturnValue(false); + const { container } = render( + , + ); + + expect(within(container).getByText(/site settings/i)).toBeTruthy(); + }); +}); diff --git a/apps/web/components/settings/notifications-settings-actions.ts b/apps/web/components/settings/notifications-settings-actions.ts index acd6bf6e69..cb85e23f91 100644 --- a/apps/web/components/settings/notifications-settings-actions.ts +++ b/apps/web/components/settings/notifications-settings-actions.ts @@ -9,6 +9,7 @@ import { } from "@/lib/api"; import { useRequest } from "@/lib/http/use-request"; import { DEFAULT_NOTIFICATION_EVENTS } from "@/lib/notifications/events"; +import { nativeNotifications } from "@/lib/desktop/native-notification-client"; import { useNotificationProviders } from "@/hooks/domains/settings/use-notification-providers"; import { useAppStore } from "@/components/state-provider"; import type { NotificationProvider } from "@/lib/types/http"; @@ -244,6 +245,7 @@ export function useNotificationsState() { export type NotificationsState = ReturnType; export type { AppriseFormMode }; +export type PermissionRefresh = (error?: unknown) => void | Promise; export function useSaveRequest(state: NotificationsState) { const { @@ -452,7 +454,10 @@ function useAppriseProviderActions(state: NotificationsState) { }; } -export function useNotificationsActions(state: NotificationsState, bumpPermission: () => void) { +export function useNotificationsActions( + state: NotificationsState, + bumpPermission: PermissionRefresh, +) { const { setProviders } = state; const appriseActions = useAppriseProviderActions(state); @@ -468,13 +473,34 @@ export function useNotificationsActions(state: NotificationsState, bumpPermissio }; const handleRequestPermission = async () => { - if (typeof Notification === "undefined") return; - await Notification.requestPermission(); - bumpPermission(); + try { + if (nativeNotifications.isAvailable()) { + const requests: Array> = [nativeNotifications.permission.request()]; + if (typeof Notification !== "undefined") { + requests.push(Notification.requestPermission()); + } + await Promise.all(requests); + await bumpPermission(); + return; + } + if (typeof Notification === "undefined") return; + await Notification.requestPermission(); + await bumpPermission(); + } catch (error) { + try { + await bumpPermission(error); + } catch { + // A permission failure must never escape a user gesture handler. + } + } }; - const handleRefreshPermission = () => { - if (typeof Notification !== "undefined") bumpPermission(); + const handleRefreshPermission = async () => { + try { + await bumpPermission(); + } catch { + // A permission query failure is rendered by the permission state hook. + } }; const handleTestNotification = async () => { diff --git a/apps/web/components/settings/notifications-settings.tsx b/apps/web/components/settings/notifications-settings.tsx index f049a887fb..6596c8db44 100644 --- a/apps/web/components/settings/notifications-settings.tsx +++ b/apps/web/components/settings/notifications-settings.tsx @@ -1,9 +1,8 @@ "use client"; -import { useMemo, useReducer, useState, useSyncExternalStore, type FormEvent } from "react"; -import { IconBell, IconRefresh } from "@tabler/icons-react"; +import { useMemo, useState, type FormEvent } from "react"; +import { IconBell } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; -import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card"; import { Input } from "@kandev/ui/input"; import { Separator } from "@kandev/ui/separator"; import { Textarea } from "@kandev/ui/textarea"; @@ -13,6 +12,10 @@ import { SettingsPageTemplate } from "@/components/settings/settings-page-templa import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; import { DEFAULT_NOTIFICATION_EVENTS } from "@/lib/notifications/events"; import type { NotificationProvider } from "@/lib/types/http"; +import { + DesktopNotificationsSection, + useNotificationPermission, +} from "@/components/settings/notification-permission-section"; import { useNotificationsState, useSaveRequest, @@ -22,91 +25,6 @@ import { type AppriseFormMode, } from "@/components/settings/notifications-settings-actions"; -type DesktopNotificationsSectionProps = { - notificationPermission: NotificationPermission | "unsupported"; - onRequestPermission: () => void; - onRefreshPermission: () => void; - onTestNotification: () => void; -}; - -function DesktopNotificationsSection({ - notificationPermission, - onRequestPermission, - onRefreshPermission, - onTestNotification, -}: DesktopNotificationsSectionProps) { - return ( -
-
-
-
Desktop Notifications
-

- Notify this device when a selected agent turn, question, or Office event occurs. -

-
-
- - - - - - - Refresh permission status - - - - - - - - If you do not see notifications, check your OS settings and allow this browser. - - -
-
- - {notificationPermission === "denied" && ( -

- Notifications are blocked in your browser. Enable them in site settings, then click - Refresh. -

- )} - {notificationPermission === "unsupported" && ( -

- This browser does not support desktop notifications. -

- )} -
- ); -} - function AppriseProviderCardActions({ provider, onOpenForm, @@ -247,20 +165,6 @@ function AppriseProviderList({ ); } -function useNotificationPermission() { - const mounted = useSyncExternalStore( - () => () => undefined, - () => true, - () => false, - ); - const [, bumpPermission] = useReducer((value) => value + 1, 0); - let notificationPermission: NotificationPermission | "unsupported"; - if (!mounted) notificationPermission = "default"; - else if (typeof Notification === "undefined") notificationPermission = "unsupported"; - else notificationPermission = Notification.permission; - return { notificationPermission, bumpPermission }; -} - type ExternalProvidersSectionProps = { appriseAvailable: boolean; appriseProviders: NotificationProvider[]; @@ -434,9 +338,9 @@ function useNotificationPageSaveState(state: NotificationsState, soundIsDirty: b export function NotificationsSettings() { const state = useNotificationsState(); - const { notificationPermission, bumpPermission } = useNotificationPermission(); + const { notificationPermission, refreshPermission } = useNotificationPermission(); const saveRequest = useSaveRequest(state); - const actions = useNotificationsActions(state, bumpPermission); + const actions = useNotificationsActions(state, refreshPermission); const [soundIsDirty, setSoundIsDirty] = useState(false); const saveState = useNotificationPageSaveState(state, soundIsDirty); const { tableProviders, tableEvents } = useTableData(state); diff --git a/apps/web/components/settings/system/update-notifications-card.test.tsx b/apps/web/components/settings/system/update-notifications-card.test.tsx deleted file mode 100644 index 502904b600..0000000000 --- a/apps/web/components/settings/system/update-notifications-card.test.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { StoreApi } from "zustand"; -import { StateProvider, useAppStoreApi } from "@/components/state-provider"; -import { ToastProvider } from "@/components/toast-provider"; -import { SettingsSaveProvider } from "@/components/settings/settings-save-provider"; -import type { AppState } from "@/lib/state/store"; -import { UpdateNotificationsCard } from "./update-notifications-card"; - -const mockSave = vi.fn(); -const TOGGLE_NAME = "Enable update notifications"; -const DIRTY_ATTRIBUTE = "data-settings-dirty"; - -vi.mock("@/lib/api/domains/system-api", () => ({ - saveUpdateNotificationSettings: (...args: unknown[]) => mockSave(...args), -})); - -function renderCard(initial?: { enabled: boolean; channel: string }) { - const initialState: Record | undefined = initial - ? { system: { updateNotificationSettings: initial } } - : undefined; - return render( - - - - - - - , - ); -} - -function StoreCapture({ onStore }: { onStore: (store: StoreApi) => void }) { - onStore(useAppStoreApi()); - return null; -} - -function renderCardCapturingStore() { - let store: StoreApi | undefined; - const utils = render( - - - - (store = s)} /> - - - - , - ); - if (!store) throw new Error("store was not captured"); - return { ...utils, store }; -} - -beforeEach(() => { - vi.clearAllMocks(); - mockSave.mockImplementation((settings) => Promise.resolve(settings)); -}); - -afterEach(() => { - cleanup(); -}); - -describe("UpdateNotificationsCard", () => { - it("renders the saved enable toggle and channel from hydrated state", async () => { - renderCard({ enabled: true, channel: "desktop" }); - - const toggle = await screen.findByRole("switch", { name: TOGGLE_NAME }); - expect(toggle.getAttribute("aria-checked")).toBe("true"); - expect(screen.getByText("Desktop notification")).toBeTruthy(); - }); - - it("falls back to the backend default without fetching when no hydrated state is available", async () => { - renderCard(); - - const toggle = await screen.findByRole("switch", { name: TOGGLE_NAME }); - expect(toggle.getAttribute("aria-checked")).toBe("true"); - expect(screen.getByText("Both")).toBeTruthy(); - }); - - it("hides the channel selector once disabled and persists through the floating Save action", async () => { - renderCard({ enabled: true, channel: "both" }); - - const toggle = await screen.findByRole("switch", { name: TOGGLE_NAME }); - fireEvent.click(toggle); - - expect(screen.queryByLabelText("Update notification channel")).toBeNull(); - expect(toggle.getAttribute(DIRTY_ATTRIBUTE)).toBe("true"); - - fireEvent.click(await screen.findByRole("button", { name: "Save changes" })); - - await waitFor(() => expect(mockSave).toHaveBeenCalledWith({ enabled: false, channel: "both" })); - await waitFor(() => expect(toggle.getAttribute(DIRTY_ATTRIBUTE)).toBe("false")); - }); - - it("marks the trigger dirty on channel change and persists the new channel on save", async () => { - renderCard({ enabled: true, channel: "in_view" }); - - await screen.findByText("In-app banner"); - const trigger = screen.getByRole("combobox", { name: "Update notification channel" }); - fireEvent.click(trigger); - fireEvent.click(await screen.findByRole("option", { name: "Both" })); - - expect(screen.getByText("Both")).toBeTruthy(); - expect(trigger.getAttribute(DIRTY_ATTRIBUTE)).toBe("true"); - - fireEvent.click(await screen.findByRole("button", { name: "Save changes" })); - - await waitFor(() => expect(mockSave).toHaveBeenCalledWith({ enabled: true, channel: "both" })); - await waitFor(() => expect(trigger.getAttribute(DIRTY_ATTRIBUTE)).toBe("false")); - }); - - it("adopts the boot-payload value once hydration lands after the initial render", async () => { - const { store } = renderCardCapturingStore(); - - const toggle = await screen.findByRole("switch", { name: TOGGLE_NAME }); - expect(toggle.getAttribute("aria-checked")).toBe("true"); - expect(screen.getByText("Both")).toBeTruthy(); - - act(() => { - store.getState().setSystemUpdateNotificationSettings({ enabled: true, channel: "desktop" }); - }); - - await waitFor(() => expect(screen.getByText("Desktop notification")).toBeTruthy()); - expect(toggle.getAttribute(DIRTY_ATTRIBUTE)).toBe("false"); - }); -}); diff --git a/apps/web/components/settings/system/update-notifications-card.tsx b/apps/web/components/settings/system/update-notifications-card.tsx deleted file mode 100644 index 6cd7314993..0000000000 --- a/apps/web/components/settings/system/update-notifications-card.tsx +++ /dev/null @@ -1,133 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; -import { Switch } from "@kandev/ui/switch"; -import { useAppStore } from "@/components/state-provider"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider"; -import { useToast } from "@/components/toast-provider"; -import { saveUpdateNotificationSettings } from "@/lib/api/domains/system-api"; -import type { UpdateNotificationChannel, UpdateNotificationSettings } from "@/lib/types/system"; - -// Matches the backend default (DefaultNotifySettings). Only used while the -// initial settings fetch is pending (no SSR-hydrated value was available). -const DEFAULT_SETTINGS: UpdateNotificationSettings = { enabled: true, channel: "both" }; - -const CHANNEL_OPTIONS: Array<{ value: UpdateNotificationChannel; label: string; help: string }> = [ - { - value: "desktop", - label: "Desktop notification", - help: "Shows a native OS notification (or a browser notification when this isn't the desktop app).", - }, - { - value: "in_view", - label: "In-app banner", - help: "Shows an in-app banner only while Kandev is open in this window.", - }, - { - value: "both", - label: "Both", - help: "Shows both a desktop notification and an in-app banner.", - }, -]; - -function useUpdateNotificationSettingsDraft() { - const stored = useAppStore((s) => s.system.updateNotificationSettings); - const setStored = useAppStore((s) => s.setSystemUpdateNotificationSettings); - const { toast } = useToast(); - const [saved, setSaved] = useState(stored ?? DEFAULT_SETTINGS); - const [draft, setDraft] = useState(saved); - // Boot-payload hydration (see settings-routes.tsx: loadSettingsInitialState) - // resolves asynchronously and may land after this component's first - // render. Sync draft/saved from the store exactly once, the first time a - // real value shows up, so an in-flight boot fetch doesn't get shadowed by - // DEFAULT_SETTINGS. Never resync afterward so in-progress edits survive - // unrelated store updates (including this hook's own save round-trip). - const hydratedFromStoreRef = useRef(stored !== null); - - useEffect(() => { - if (hydratedFromStoreRef.current || stored === null) return; - hydratedFromStoreRef.current = true; - setSaved(stored); - setDraft(stored); - }, [stored]); - - const revision = JSON.stringify(draft); - const isDirty = revision !== JSON.stringify(saved); - - useSettingsSaveContributor({ - id: "system-update-notifications", - revision, - isDirty, - save: async () => { - const submitted = draft; - const persisted = await saveUpdateNotificationSettings(submitted); - setSaved(persisted); - setDraft(persisted); - setStored(persisted); - toast({ title: "Update notification settings saved", variant: "success" }); - }, - discard: () => setDraft(saved), - }); - - return { draft, saved, setDraft, isDirty }; -} - -export function UpdateNotificationsCard() { - const { draft, saved, setDraft, isDirty } = useUpdateNotificationSettingsDraft(); - const activeChannel = CHANNEL_OPTIONS.find((option) => option.value === draft.channel); - - return ( - - - Update notifications - - -
-
-
Notify me about new releases
-

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

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

{activeChannel?.help}

-
- )} -
-
- ); -} diff --git a/apps/web/e2e/tests/settings/mobile-notification-events.spec.ts b/apps/web/e2e/tests/settings/mobile-notification-events.spec.ts index d6bca50963..0d02cedde6 100644 --- a/apps/web/e2e/tests/settings/mobile-notification-events.spec.ts +++ b/apps/web/e2e/tests/settings/mobile-notification-events.spec.ts @@ -4,6 +4,7 @@ import type { ApiClient } from "../../helpers/api-client"; const TURN_FINISHED = "session.turn_finished"; const CLARIFICATION_REQUESTED = "session.clarification_requested"; +const UPDATE_AVAILABLE = "system.update_available"; const PROVIDER_NAMES = [ "E2E mobile semantic notifications alpha", "E2E mobile semantic notifications beta", @@ -36,7 +37,7 @@ async function expectViewportContained(locator: Locator) { } test.describe("Mobile notification event settings", () => { - test("shows both semantic event rows as touch-operable without horizontal overflow", async ({ + test("shows semantic event rows, including updates, as touch-operable without horizontal overflow", async ({ testPage, apiClient, }) => { @@ -54,12 +55,18 @@ test.describe("Mobile notification event settings", () => { const needsAnswer = testPage.getByRole("checkbox", { name: `Agent needs an answer for ${PROVIDER_NAMES[0]}`, }); + const updateAvailable = testPage.getByRole("checkbox", { + name: `Kandev update available for ${PROVIDER_NAMES[0]}`, + }); const turnFinishedTarget = testPage.getByTestId( `notification-event-toggle-${TURN_FINISHED}-${providers[0].id}`, ); const needsAnswerTarget = testPage.getByTestId( `notification-event-toggle-${CLARIFICATION_REQUESTED}-${providers[0].id}`, ); + const updateAvailableTarget = testPage.getByTestId( + `notification-event-toggle-${UPDATE_AVAILABLE}-${providers[0].id}`, + ); await expect(eventContainer).toBeVisible(); await expect( eventContainer.getByText(PROVIDER_NAMES[2], { exact: true }).first(), @@ -76,6 +83,13 @@ test.describe("Mobile notification event settings", () => { ).toBeVisible(); await expect(turnFinished).toBeVisible(); await expect(needsAnswer).toBeVisible(); + await expect(updateAvailable).not.toBeChecked(); + await expect( + eventContainer.getByText("Kandev update available", { exact: true }), + ).toBeVisible(); + await expect( + eventContainer.getByText("Notify when a newer Kandev release is available."), + ).toBeVisible(); await expectViewportContained( eventContainer.getByText("Agent turn finished", { exact: true }), @@ -89,9 +103,16 @@ test.describe("Mobile notification event settings", () => { await expectViewportContained( eventContainer.getByText("Notify when the agent explicitly asks you a question."), ); + await expectViewportContained( + eventContainer.getByText("Kandev update available", { exact: true }), + ); + await expectViewportContained( + eventContainer.getByText("Notify when a newer Kandev release is available."), + ); await expectViewportContained(turnFinished); await expectViewportContained(needsAnswer); - for (const target of [turnFinishedTarget, needsAnswerTarget]) { + await expectViewportContained(updateAvailable); + for (const target of [turnFinishedTarget, needsAnswerTarget, updateAvailableTarget]) { const box = await target.boundingBox(); expect(box).not.toBeNull(); expect(box!.width).toBeGreaterThanOrEqual(44); @@ -101,12 +122,13 @@ test.describe("Mobile notification event settings", () => { await eventContainer.evaluate((element) => element.scrollWidth <= element.clientWidth), ).toBe(true); - for (const target of [turnFinishedTarget, needsAnswerTarget]) { - await target.scrollIntoViewIfNeeded(); - await target.tap(); - } - await expect(turnFinished).not.toBeChecked(); - await expect(needsAnswer).not.toBeChecked(); + await updateAvailableTarget.scrollIntoViewIfNeeded(); + await updateAvailableTarget.tap(); + await expect(updateAvailable).toBeChecked(); + await testPage.getByRole("button", { name: "Save changes" }).click(); + await expect(testPage.getByRole("button", { name: "Save changes" })).not.toBeVisible(); + await testPage.reload(); + await expect(updateAvailable).toBeChecked(); expect( await testPage.evaluate(() => document.documentElement.scrollWidth > window.innerWidth), ).toBe(false); diff --git a/apps/web/e2e/tests/system/update-notifications-settings.spec.ts b/apps/web/e2e/tests/system/update-notifications-settings.spec.ts deleted file mode 100644 index 408ee487b8..0000000000 --- a/apps/web/e2e/tests/system/update-notifications-settings.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { test, expect } from "../../fixtures/test-base"; - -test.describe("Update notifications settings", () => { - test("channel change persists through the floating Save action and survives reload", async ({ - testPage, - }) => { - test.setTimeout(30_000); - - await testPage.goto("/settings/system/updates"); - await expect(testPage.getByTestId("system-page-title")).toHaveText("Updates"); - - const card = testPage.getByTestId("update-notifications-card"); - await expect(card).toBeVisible(); - - // Fresh worker-scoped backend: defaults to enabled + "both" (see - // DefaultNotifySettings in internal/system/updates/notify_settings.go). - const toggle = card.getByRole("switch", { name: "Enable update notifications" }); - await expect(toggle).toHaveAttribute("aria-checked", "true"); - - const channelTrigger = card.getByRole("combobox", { name: "Update notification channel" }); - await expect(channelTrigger).toHaveText("Both"); - - await channelTrigger.click(); - await testPage.getByRole("option", { name: "Desktop notification" }).click(); - await expect(channelTrigger).toHaveText("Desktop notification"); - await expect(channelTrigger).toHaveAttribute("data-settings-dirty", "true"); - - await testPage.getByRole("button", { name: "Save changes" }).click(); - await expect(channelTrigger).toHaveAttribute("data-settings-dirty", "false", { - timeout: 10_000, - }); - - await testPage.reload(); - await expect(testPage.getByTestId("update-notifications-card")).toBeVisible(); - await expect( - testPage - .getByTestId("update-notifications-card") - .getByRole("combobox", { name: "Update notification channel" }), - ).toHaveText("Desktop notification"); - }); - - test("disabling hides the channel selector", async ({ testPage }) => { - test.setTimeout(30_000); - - await testPage.goto("/settings/system/updates"); - const card = testPage.getByTestId("update-notifications-card"); - const toggle = card.getByRole("switch", { name: "Enable update notifications" }); - await expect(toggle).toBeVisible(); - - await toggle.click(); - await expect(card.getByLabel("Update notification channel")).toHaveCount(0); - - await testPage.getByRole("button", { name: "Save changes" }).click(); - await expect(toggle).toHaveAttribute("data-settings-dirty", "false", { timeout: 10_000 }); - - await testPage.reload(); - const reloadedToggle = testPage - .getByTestId("update-notifications-card") - .getByRole("switch", { name: "Enable update notifications" }); - await expect(reloadedToggle).toHaveAttribute("aria-checked", "false"); - }); -}); diff --git a/apps/web/hooks/use-update-available-toast.test.ts b/apps/web/hooks/use-update-available-toast.test.ts index 2c70399ecd..9871f4e6f8 100644 --- a/apps/web/hooks/use-update-available-toast.test.ts +++ b/apps/web/hooks/use-update-available-toast.test.ts @@ -1,25 +1,30 @@ 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(); +const UPDATE_VERSION = "v1.2.3"; +const UPDATE_TITLE = "Kandev update available"; + +function updateNotification(): UpdateAvailableNotification { + return { + version: UPDATE_VERSION, + title: UPDATE_TITLE, + body: `Kandev ${UPDATE_VERSION} is available.`, + occurrence_id: UPDATE_VERSION, + }; +} vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: Record) => unknown) => selector({ updateAvailableNotification: mockNotification, setUpdateAvailableNotification: mockClearNotification, - system: { updateNotificationSettings: mockSettings }, - setSystemUpdateNotificationSettings: mockSetSettings, }), })); @@ -34,35 +39,14 @@ vi.mock("@/lib/desktop/native-notification-client", () => ({ }, })); -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")); }); @@ -73,104 +57,50 @@ describe("useUpdateAvailableToast", () => { 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" }; + it("always shows an in-app toast for a Local update occurrence", () => { + mockNotification = updateNotification(); renderHook(() => useUpdateAvailableToast()); - await flushMicrotasks(); - expect(mockToast).toHaveBeenCalledWith( expect.objectContaining({ - title: "Update available", - description: expect.stringContaining("v1.2.3"), + title: UPDATE_TITLE, + description: expect.stringContaining(UPDATE_VERSION), }), ); 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" }; + it("also attempts native delivery without replacing the toast", () => { + mockNotification = updateNotification(); mockNativeIsAvailable.mockReturnValue(true); renderHook(() => useUpdateAvailableToast()); - await flushMicrotasks(); - - expect(mockToast).not.toHaveBeenCalled(); + expect(mockToast).toHaveBeenCalledTimes(1); expect(mockNativeShow).toHaveBeenCalledWith( expect.objectContaining({ - eventId: "system.update_available:v1.2.3", - title: "Update available", + eventId: `system.update_available:${UPDATE_VERSION}`, + title: UPDATE_TITLE, }), ); }); - 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" }; + it("retains the toast when native delivery is denied", () => { + mockNotification = updateNotification(); mockNativeIsAvailable.mockReturnValue(true); + mockNativeShow.mockResolvedValueOnce("permission-denied"); 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" }; + it("deduplicates repeated notifications for the same version", () => { + mockNotification = updateNotification(); const { rerender } = renderHook(() => useUpdateAvailableToast()); - await flushMicrotasks(); expect(mockToast).toHaveBeenCalledTimes(1); mockToast.mockClear(); mockClearNotification.mockClear(); - mockNotification = { version: "v1.2.3" }; + mockNotification = updateNotification(); rerender(); - await flushMicrotasks(); - expect(mockToast).not.toHaveBeenCalled(); expect(mockClearNotification).toHaveBeenCalledWith(null); }); diff --git a/apps/web/hooks/use-update-available-toast.ts b/apps/web/hooks/use-update-available-toast.ts index d8f6d6bb0a..3db6691e86 100644 --- a/apps/web/hooks/use-update-available-toast.ts +++ b/apps/web/hooks/use-update-available-toast.ts @@ -4,42 +4,10 @@ import { useEffect, useRef } from "react"; import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; import { nativeNotifications } from "@/lib/desktop/native-notification-client"; -import { fetchUpdateNotificationSettings } from "@/lib/api/domains/system-api"; -import type { UpdateNotificationSettings } from "@/lib/types/system"; -// Matches the backend default (DefaultNotifySettings in -// internal/system/updates/notify_settings.go): used only when the settings -// fetch itself fails, so a transient network hiccup doesn't silently -// swallow an update notification the user would otherwise want. -const FALLBACK_SETTINGS: UpdateNotificationSettings = { enabled: true, channel: "both" }; - -let settingsRequest: Promise | null = null; - -function loadSettings(): Promise { - if (!settingsRequest) { - settingsRequest = fetchUpdateNotificationSettings({ cache: "no-store" }).catch((err) => { - // Allow the next notification to retry the fetch instead of being - // stuck on the fallback (and never re-checking the user's real saved - // preference) for the rest of the session. - settingsRequest = null; - throw err; - }); - } - return settingsRequest; -} - -/** - * Watches for a newly detected Kandev release (pushed over WS as - * `system.update_available`, see lib/ws/handlers/system-events.ts) and - * notifies the user according to their saved channel preference - * (desktop / in_view / both — see the Updates settings page). Mount once - * inside ToastProvider, alongside the other notification bridges. - */ export function useUpdateAvailableToast() { const notification = useAppStore((s) => s.updateAvailableNotification); const clearNotification = useAppStore((s) => s.setUpdateAvailableNotification); - const settings = useAppStore((s) => s.system.updateNotificationSettings); - const setSettings = useAppStore((s) => s.setSystemUpdateNotificationSettings); const { toast } = useToast(); const shownRef = useRef>(new Set()); @@ -51,39 +19,20 @@ export function useUpdateAvailableToast() { } 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 = notification.title || "Kandev update available"; + const body = + notification.body || + `Kandev ${notification.version} is available. Open Settings > System > Updates to review it.`; + toast({ title, description: body }); - 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 }); - } - } - })(); + if (nativeNotifications.isAvailable()) { + void nativeNotifications + .show({ eventId: `system.update_available:${notification.occurrence_id}`, title, body }) + .catch(() => undefined); + } else if (typeof Notification !== "undefined" && Notification.permission === "granted") { + new Notification(title, { body }); + } clearNotification(null); - }, [notification, settings, setSettings, toast, clearNotification]); + }, [notification, toast, clearNotification]); } diff --git a/apps/web/lib/api/domains/system-api.test.ts b/apps/web/lib/api/domains/system-api.test.ts index c4f74e3206..de97636eeb 100644 --- a/apps/web/lib/api/domains/system-api.test.ts +++ b/apps/web/lib/api/domains/system-api.test.ts @@ -27,8 +27,6 @@ import { fetchSystemJob, fetchRestartCapability, requestRestart, - fetchUpdateNotificationSettings, - saveUpdateNotificationSettings, adoptStorageGoCache, analyzeStorage, deleteStorageQuarantine, @@ -393,26 +391,3 @@ describe("storage maintenance", () => { expect((await restoreStorageQuarantine("entry-1")).id).toBe("restored"); }); }); - -describe("update notification settings", () => { - it("fetchUpdateNotificationSettings GETs the notification-settings endpoint and bypasses cache", async () => { - fetchSpy.mockResolvedValueOnce(jsonResponse({ enabled: true, channel: "desktop" })); - const settings = await fetchUpdateNotificationSettings({ cache: "force-cache" }); - expect(lastCall().url).toBe(`${BASE}/updates/notification-settings`); - expect(method()).toBe("GET"); - expect(lastCall().init?.cache).toBe("no-store"); - expect(settings).toEqual({ enabled: true, channel: "desktop" }); - }); - - it("saveUpdateNotificationSettings PUTs the settings body to the same endpoint", async () => { - fetchSpy.mockResolvedValueOnce(jsonResponse({ enabled: false, channel: "in_view" })); - const persisted = await saveUpdateNotificationSettings({ enabled: false, channel: "in_view" }); - expect(lastCall().url).toBe(`${BASE}/updates/notification-settings`); - expect(method()).toBe("PUT"); - expect(JSON.parse(String(lastCall().init?.body))).toEqual({ - enabled: false, - channel: "in_view", - }); - expect(persisted).toEqual({ enabled: false, channel: "in_view" }); - }); -}); diff --git a/apps/web/lib/api/domains/system-api.ts b/apps/web/lib/api/domains/system-api.ts index 4530e860fa..f5ca75d7e1 100644 --- a/apps/web/lib/api/domains/system-api.ts +++ b/apps/web/lib/api/domains/system-api.ts @@ -8,7 +8,6 @@ import type { LogFileInfo, LogTailResponse, UpdatesResponse, - UpdateNotificationSettings, JobAcceptResponse, RestartCapability, RestartResponse, @@ -178,25 +177,6 @@ export function applyUpdate( }); } -export function fetchUpdateNotificationSettings( - options?: ApiRequestOptions, -): Promise { - return fetchJson(`${SYSTEM_BASE}/updates/notification-settings`, { - ...options, - cache: "no-store", - }); -} - -export function saveUpdateNotificationSettings( - settings: UpdateNotificationSettings, - options?: ApiRequestOptions, -): Promise { - return fetchJson(`${SYSTEM_BASE}/updates/notification-settings`, { - ...options, - init: { ...(options?.init ?? {}), method: "PUT", body: JSON.stringify(settings) }, - }); -} - // --- Restart ------------------------------------------------------------ export function fetchRestartCapability(options?: ApiRequestOptions): Promise { diff --git a/apps/web/lib/desktop/native-notification-client.test.ts b/apps/web/lib/desktop/native-notification-client.test.ts new file mode 100644 index 0000000000..2a0beef98b --- /dev/null +++ b/apps/web/lib/desktop/native-notification-client.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { nativeNotifications } from "./native-notification-client"; + +type TauriWindow = Window & { + __TAURI_INTERNALS__?: { + invoke: (command: string, args?: Record) => Promise; + }; +}; + +afterEach(() => { + delete (window as TauriWindow).__TAURI_INTERNALS__; +}); + +describe("native notification client", () => { + it("exposes distinct permission query and request commands", () => { + const invoke = vi.fn(async (command: string) => + command === "get_native_notification_permission" ? "prompt" : "granted", + ); + (window as TauriWindow).__TAURI_INTERNALS__ = { invoke }; + + expect("permission" in nativeNotifications).toBe(true); + }); + + it("queries permission separately from requesting it", async () => { + const invoke = vi.fn(async (command: string) => + command === "get_native_notification_permission" ? "denied" : "granted", + ); + (window as TauriWindow).__TAURI_INTERNALS__ = { invoke }; + + await expect(nativeNotifications.permission.get()).resolves.toBe("denied"); + await expect(nativeNotifications.permission.request()).resolves.toBe("granted"); + + expect(invoke).toHaveBeenNthCalledWith(1, "get_native_notification_permission", undefined); + expect(invoke).toHaveBeenNthCalledWith(2, "request_native_notification_permission", undefined); + }); +}); diff --git a/apps/web/lib/desktop/native-notification-client.ts b/apps/web/lib/desktop/native-notification-client.ts index a8d2804db3..0c752313af 100644 --- a/apps/web/lib/desktop/native-notification-client.ts +++ b/apps/web/lib/desktop/native-notification-client.ts @@ -9,12 +9,34 @@ export type NativeNotificationRequest = { sessionId?: string | null; }; -type NativeNotificationResult = "shown" | "duplicate" | "permission-denied"; +export type NativeNotificationResult = + | "shown" + | "duplicate" + | "permission-denied" + | "display-failed"; + +export type NativeNotificationPermission = + | "granted" + | "denied" + | "prompt" + | "prompt-with-rationale"; const transport = createTauriInvokeTransport(); export const nativeNotifications = { isAvailable: transport.isAvailable, + permission: { + get(): Promise { + return transport.invoke( + "get_native_notification_permission", + ) as Promise; + }, + request(): Promise { + return transport.invoke( + "request_native_notification_permission", + ) as Promise; + }, + }, show(request: NativeNotificationRequest): Promise { if (!transport.isAvailable()) return Promise.resolve("duplicate"); return transport.invoke("show_native_notification", { diff --git a/apps/web/lib/notifications/events.ts b/apps/web/lib/notifications/events.ts index c3c2348a4a..26d3cb241b 100644 --- a/apps/web/lib/notifications/events.ts +++ b/apps/web/lib/notifications/events.ts @@ -1,5 +1,6 @@ export const NOTIFICATION_EVENT_SESSION_TURN_FINISHED = "session.turn_finished"; export const NOTIFICATION_EVENT_SESSION_CLARIFICATION_REQUESTED = "session.clarification_requested"; +export const NOTIFICATION_EVENT_SYSTEM_UPDATE_AVAILABLE = "system.update_available"; export const NOTIFICATION_EVENT_OFFICE_INBOX_ITEM = "office.inbox_item"; export const DEFAULT_NOTIFICATION_EVENTS = [NOTIFICATION_EVENT_SESSION_CLARIFICATION_REQUESTED]; @@ -13,6 +14,10 @@ export const EVENT_LABELS: Record { }); describe("hydrateState — system slice", () => { - it("leaf-merges update notification settings without clobbering other system fields", () => { - const result = produce(makeAppDraft(), (draft: Draft) => { - draft.system.info = { version: "v1.0.0" } as unknown as AppState["system"]["info"]; - - hydrateState(draft, { - system: { - updateNotificationSettings: { enabled: false, channel: "desktop" }, - }, - }); - }); - - expect(result.system.updateNotificationSettings).toEqual({ - enabled: false, - channel: "desktop", - }); - expect(result.system.info).toEqual({ version: "v1.0.0" }); - }); - it("leaves the system slice untouched when the caller supplies no system fields", () => { const result = produce(makeAppDraft(), (draft: Draft) => { hydrateState(draft, {}); diff --git a/apps/web/lib/state/hydration/hydrator.ts b/apps/web/lib/state/hydration/hydrator.ts index bc941db9cd..2dd03cd8c3 100644 --- a/apps/web/lib/state/hydration/hydrator.ts +++ b/apps/web/lib/state/hydration/hydrator.ts @@ -304,7 +304,7 @@ export function hydrateState( // System slice - shallow-merge whichever fields the caller supplied. // `system` aggregates many independently-fetched fields (info, diskUsage, - // updates, updateNotificationSettings, ...); callers only ever provide the + // updates, jobs, metrics, ...); 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); diff --git a/apps/web/lib/state/slices/system/system-slice.ts b/apps/web/lib/state/slices/system/system-slice.ts index da812e5ac5..0474be1c43 100644 --- a/apps/web/lib/state/slices/system/system-slice.ts +++ b/apps/web/lib/state/slices/system/system-slice.ts @@ -9,7 +9,6 @@ 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: [] }, @@ -56,10 +55,6 @@ export const createSystemSlice: StateCreator< set((draft) => { draft.system.updates = updates; }), - setSystemUpdateNotificationSettings: (settings) => - set((draft) => { - draft.system.updateNotificationSettings = settings; - }), upsertSystemJob: (job) => set((draft) => { draft.system.jobs[job.id] = job; diff --git a/apps/web/lib/state/slices/system/types.ts b/apps/web/lib/state/slices/system/types.ts index 7b709d0a59..a9cc2291d7 100644 --- a/apps/web/lib/state/slices/system/types.ts +++ b/apps/web/lib/state/slices/system/types.ts @@ -5,7 +5,6 @@ import type { SnapshotInfo, LogFileInfo, UpdatesResponse, - UpdateNotificationSettings, SystemJob, SystemMetricsSnapshot, StorageMaintenanceRun, @@ -34,7 +33,6 @@ export type SystemSliceState = { backups: SystemBackupsState; logs: SystemLogsState; updates: UpdatesResponse | null; - updateNotificationSettings: UpdateNotificationSettings | null; jobs: SystemJobsMap; metrics: SystemMetricsSnapshot | null; storage: { @@ -53,7 +51,6 @@ export type SystemSliceActions = { setSystemLogs: (files: LogFileInfo[]) => void; setSystemLogTail: (lines: string[]) => void; setSystemUpdates: (updates: UpdatesResponse) => void; - setSystemUpdateNotificationSettings: (settings: UpdateNotificationSettings) => void; upsertSystemJob: (job: SystemJob) => void; clearSystemJob: (jobId: string) => void; setSystemMetricsSnapshot: (snapshot: SystemMetricsSnapshot) => void; diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts index fbea7422b5..a1cf9bc0cf 100644 --- a/apps/web/lib/state/slices/ui/types.ts +++ b/apps/web/lib/state/slices/ui/types.ts @@ -107,6 +107,9 @@ export type TaskDeletedNotification = { export type UpdateAvailableNotification = { version: string; url?: string; + title: string; + body: string; + occurrence_id: string; }; export type BottomTerminalState = { diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index 60f43dcbe5..2bb8029a0e 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -152,6 +152,9 @@ export type SystemErrorPayload = { export type UpdateAvailablePayload = { version: string; url?: string; + title: string; + body: string; + occurrence_id: string; }; export type WorkspacePayload = { diff --git a/apps/web/lib/types/system.ts b/apps/web/lib/types/system.ts index cecde93a12..2bc8d95918 100644 --- a/apps/web/lib/types/system.ts +++ b/apps/web/lib/types/system.ts @@ -79,13 +79,6 @@ export interface UpdatesResponse { manual_commands?: string[]; } -export type UpdateNotificationChannel = "desktop" | "in_view" | "both"; - -export interface UpdateNotificationSettings { - enabled: boolean; - channel: UpdateNotificationChannel; -} - export interface InstallState { running_as_service: boolean; managed_service: boolean; diff --git a/apps/web/lib/ws/handlers/notifications.test.ts b/apps/web/lib/ws/handlers/notifications.test.ts index 3302466dc8..f4cf81b2b1 100644 --- a/apps/web/lib/ws/handlers/notifications.test.ts +++ b/apps/web/lib/ws/handlers/notifications.test.ts @@ -4,6 +4,7 @@ import type { AppState } from "@/lib/state/store"; import { NOTIFICATION_EVENT_SESSION_CLARIFICATION_REQUESTED, NOTIFICATION_EVENT_SESSION_TURN_FINISHED, + NOTIFICATION_EVENT_SYSTEM_UPDATE_AVAILABLE, } from "@/lib/notifications/events"; import type { BackendMessageMap } from "@/lib/types/backend"; import { registerNotificationsHandlers } from "./notifications"; @@ -76,6 +77,38 @@ function getHandler( )[action]!; } +describe("notification handler registration", () => { + it("registers handlers for both semantic notification events", () => { + const handlers = registerNotificationsHandlers(makeStore()) as Record; + + expect(handlers[NOTIFICATION_EVENT_SESSION_TURN_FINISHED]).toEqual(expect.any(Function)); + expect(handlers[NOTIFICATION_EVENT_SESSION_CLARIFICATION_REQUESTED]).toEqual( + expect.any(Function), + ); + expect(handlers["office.inbox_item"]).toEqual(expect.any(Function)); + expect(handlers[NOTIFICATION_EVENT_SYSTEM_UPDATE_AVAILABLE]).toEqual(expect.any(Function)); + }); + + it("routes a Local update occurrence into the in-app toast bridge", () => { + const setUpdateAvailableNotification = vi.fn(); + const store = makeStore({ setUpdateAvailableNotification } as unknown as Partial); + const handler = ( + registerNotificationsHandlers(store) as Record void> + )[NOTIFICATION_EVENT_SYSTEM_UPDATE_AVAILABLE]!; + const payload = { + version: "v1.2.3", + url: "https://example.test/releases/v1.2.3", + title: "Kandev update available", + body: "Kandev v1.2.3 is available.", + occurrence_id: "v1.2.3", + }; + + handler({ payload }); + + expect(setUpdateAvailableNotification).toHaveBeenCalledWith(payload); + }); +}); + describe("semantic session notification handlers", () => { beforeEach(() => { vi.clearAllMocks(); @@ -90,16 +123,6 @@ describe("semantic session notification handlers", () => { delete (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__; }); - it("registers handlers for both semantic notification events", () => { - const handlers = registerNotificationsHandlers(makeStore()) as Record; - - expect(handlers[NOTIFICATION_EVENT_SESSION_TURN_FINISHED]).toEqual(expect.any(Function)); - expect(handlers[NOTIFICATION_EVENT_SESSION_CLARIFICATION_REQUESTED]).toEqual( - expect.any(Function), - ); - expect(handlers["office.inbox_item"]).toEqual(expect.any(Function)); - }); - it("delegates event deduplication to the native notification command", () => { const invoke = vi.fn().mockResolvedValue("shown"); (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ = { diff --git a/apps/web/lib/ws/handlers/notifications.ts b/apps/web/lib/ws/handlers/notifications.ts index a0286d9169..fddaa91c9f 100644 --- a/apps/web/lib/ws/handlers/notifications.ts +++ b/apps/web/lib/ws/handlers/notifications.ts @@ -3,6 +3,7 @@ import { NOTIFICATION_EVENT_OFFICE_INBOX_ITEM, NOTIFICATION_EVENT_SESSION_CLARIFICATION_REQUESTED, NOTIFICATION_EVENT_SESSION_TURN_FINISHED, + NOTIFICATION_EVENT_SYSTEM_UPDATE_AVAILABLE, } from "@/lib/notifications/events"; import { playWaitingForInputSound } from "@/lib/notifications/sound"; import { nativeNotifications } from "@/lib/desktop/native-notification-client"; @@ -10,6 +11,7 @@ import type { AppState } from "@/lib/state/store"; import type { OfficeInboxItemNotificationPayload, TaskSessionNotificationPayload, + UpdateAvailablePayload, } from "@/lib/types/backend"; import type { WsHandlers } from "@/lib/ws/handlers/types"; @@ -104,5 +106,7 @@ export function registerNotificationsHandlers(store: StoreApi): WsHand body: "A new item needs your attention.", }, ), + [NOTIFICATION_EVENT_SYSTEM_UPDATE_AVAILABLE]: (message: { payload: UpdateAvailablePayload }) => + store.getState().setUpdateAvailableNotification(message.payload), }; } diff --git a/apps/web/lib/ws/handlers/system-events.test.ts b/apps/web/lib/ws/handlers/system-events.test.ts deleted file mode 100644 index 2cee5b2fd1..0000000000 --- a/apps/web/lib/ws/handlers/system-events.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; -import type { BackendMessageMap } from "@/lib/types/backend"; -import { registerSystemEventsHandlers } from "./system-events"; - -function makeStore() { - const state = { - setUpdateAvailableNotification: vi.fn(), - } as unknown as AppState; - - return { getState: () => state } as unknown as StoreApi; -} - -function makeMessage( - payload: BackendMessageMap["system.update_available"]["payload"], -): BackendMessageMap["system.update_available"] { - return { - id: "msg-1", - type: "notification", - action: "system.update_available", - payload, - timestamp: new Date().toISOString(), - } as BackendMessageMap["system.update_available"]; -} - -describe("system.update_available handler", () => { - it("stores the notification so the toast bridge hook can consume it", () => { - const store = makeStore(); - const handler = registerSystemEventsHandlers(store)["system.update_available"]!; - - handler(makeMessage({ version: "v1.2.3", url: "https://example/v1.2.3" })); - - expect(store.getState().setUpdateAvailableNotification).toHaveBeenCalledWith({ - version: "v1.2.3", - url: "https://example/v1.2.3", - }); - }); -}); diff --git a/apps/web/lib/ws/handlers/system-events.ts b/apps/web/lib/ws/handlers/system-events.ts index 536baa6211..e49ad7f484 100644 --- a/apps/web/lib/ws/handlers/system-events.ts +++ b/apps/web/lib/ws/handlers/system-events.ts @@ -16,10 +16,5 @@ export function registerSystemEventsHandlers(store: StoreApi): WsHandl "system.metrics.updated": (message) => { store.getState().setSystemMetricsSnapshot(message.payload); }, - "system.update_available": (message) => { - // Persisted transiently (see slices/ui) so useUpdateAvailableToast can - // consume it and decide desktop/in-view delivery per saved settings. - store.getState().setUpdateAvailableNotification(message.payload); - }, }; } diff --git a/apps/web/src/settings-routes.test.ts b/apps/web/src/settings-routes.test.ts index 624f9eebca..5edfe58d4c 100644 --- a/apps/web/src/settings-routes.test.ts +++ b/apps/web/src/settings-routes.test.ts @@ -1,5 +1,6 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { isValidElement, type ReactElement } from "react"; +import { render, screen } from "@testing-library/react"; import IntegrationsGitLabPage from "@/app/settings/integrations/gitlab/page"; import { TaskActionsSettings } from "@/components/settings/general-settings"; @@ -7,6 +8,8 @@ import { workspaceId, workflowId } from "@/lib/types/ids"; import type { ListWorkspacesResponse, UserSettingsResponse } from "@/lib/types/http"; import { buildSettingsInitialStateForRoute, renderSettingsRoute } from "./settings-routes"; +vi.mock("@/components/settings/system/updates-card", () => ({ UpdatesCard: () => null })); + const ACTIVE_WORKSPACE_COOKIE = "kandev-active-workspace"; const OWNER_ID = "owner-1"; const TIMESTAMP = "2026-01-01T00:00:00Z"; @@ -109,18 +112,23 @@ describe("buildSettingsInitialStateForRoute", () => { expect(failed.userSettings).toBeUndefined(); }); - it("maps the update notification settings from the boot payload", () => { - const state = buildState({ - updateNotificationSettings: { enabled: false, channel: "desktop" }, - }); + it("does not recreate the retired update-notification state during hydration", () => { + const state = buildState(); - expect(state.system).toEqual({ - updateNotificationSettings: { enabled: false, channel: "desktop" }, - }); + expect(state.system).toBeUndefined(); }); }); describe("renderSettingsRoute", () => { + it("directs update notification settings to Notifications", () => { + render(renderSettingsRoute("/settings/system/updates")); + + expect(screen.getByText(/notification preferences are managed/i)).toBeTruthy(); + expect(screen.getByRole("link", { name: /notifications/i }).getAttribute("href")).toBe( + "/settings/general/notifications", + ); + }); + it("renders layout profile settings from General settings", () => { const route = renderSettingsRoute("/settings/general/layouts"); expect(isValidElement(route)).toBe(true); @@ -150,7 +158,6 @@ function buildState( availableAgents: [], availableTools: [], userSettingsResponse: null, - updateNotificationSettings: null, ...overrides, }); } diff --git a/apps/web/src/settings-routes.tsx b/apps/web/src/settings-routes.tsx index 4abe4696b4..b377f4936a 100644 --- a/apps/web/src/settings-routes.tsx +++ b/apps/web/src/settings-routes.tsx @@ -31,6 +31,7 @@ import { WorkspaceRepositoriesClient } from "@/app/settings/workspace/workspace- import { WorkspaceWorkflowsClient } from "@/app/settings/workspace/workspace-workflows-client"; import WorkspacesPage from "@/app/settings/workspace/page"; import { GitHubIntegrationPage } from "@/components/github/github-settings"; +import Link from "@/components/routing/app-link"; import { useAppStoreApi } from "@/components/state-provider"; import { EditorsSettings } from "@/components/settings/editors-settings"; import { @@ -59,7 +60,6 @@ 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"; @@ -71,7 +71,6 @@ 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, @@ -100,7 +99,7 @@ import type { WorkflowTemplate, Workspace, } from "@/lib/types/http"; -import type { LicenseEntry, UpdateNotificationSettings } from "@/lib/types/system"; +import type { LicenseEntry } from "@/lib/types/system"; type RouteRenderer = () => ReactNode; type RepositoryWithScripts = Repository & { scripts: RepositoryScript[] }; @@ -121,7 +120,6 @@ type SettingsInitialStateData = { availableAgents: Awaited>["agents"]; availableTools: NonNullable>["tools"]>; userSettingsResponse: UserSettingsResponse | null; - updateNotificationSettings: UpdateNotificationSettings | null; }; const licenseEntries = licenses as LicenseEntry[]; @@ -429,8 +427,14 @@ function renderUpdatesRoute() { title="Updates" description="Current vs latest release plus the full kandev changelog." > +

+ Notification preferences are managed in{" "} + + Notifications + + . +

- ); } @@ -472,23 +476,15 @@ function SettingsRouteBootstrap({ pathname }: { pathname: string }) { } async function loadSettingsInitialState(): Promise { - const [ - workspaces, - executors, - agents, - discovery, - available, - userSettingsResponse, - updateNotificationSettings, - ] = await Promise.all([ - listWorkspaces({ cache: "no-store" }).catch(() => ({ workspaces: [] })), - listExecutors({ cache: "no-store" }).catch(() => ({ executors: [] })), - listAgents({ cache: "no-store" }).catch(() => ({ agents: [] })), - listAgentDiscovery({ cache: "no-store" }).catch(() => ({ agents: [] })), - listAvailableAgents({ cache: "no-store" }).catch(() => ({ agents: [], tools: [] })), - fetchUserSettings({ cache: "no-store" }).catch(() => null), - fetchUpdateNotificationSettings({ cache: "no-store" }).catch(() => null), - ]); + 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), + ]); return buildSettingsInitialStateForRoute({ workspaces: workspaces.workspaces, @@ -498,7 +494,6 @@ async function loadSettingsInitialState(): Promise { availableAgents: available.agents, availableTools: available.tools ?? [], userSettingsResponse, - updateNotificationSettings, }); } @@ -510,7 +505,6 @@ export function buildSettingsInitialStateForRoute({ availableAgents, availableTools, userSettingsResponse, - updateNotificationSettings, }: SettingsInitialStateData): HydrationState { const workspaceItems = workspaces.map(mapWorkspaceItem); const activeWorkspaceId = resolveSettingsActiveWorkspaceId( @@ -538,7 +532,6 @@ export function buildSettingsInitialStateForRoute({ loaded: true, }, settingsData: { executorsLoaded: true, agentsLoaded: true }, - system: { updateNotificationSettings }, ...(mappedUserSettings.loaded ? { userSettings: { diff --git a/docs/decisions/0039-native-desktop-integration-boundary.md b/docs/decisions/0039-native-desktop-integration-boundary.md index 5a9f8a5a1d..3ba0aa51d5 100644 --- a/docs/decisions/0039-native-desktop-integration-boundary.md +++ b/docs/decisions/0039-native-desktop-integration-boundary.md @@ -47,8 +47,8 @@ confirmation in the existing System > Updates page. The backend is cleanly stopp relaunch. Desktop-owned launches reuse existing notification preferences and events but -route selected turn-finished, clarification-requested, and session-failure -delivery through the native notifier. The semantic session event boundary is +route selected turn-finished, clarification-requested, update-available, and +session-failure delivery through the native notifier. The semantic event boundary is defined by [the semantic notification-event decision](2026-07-24-semantic-notification-events.md). Desktop launches suppress the legacy backend shell-command and Web Notification @@ -58,6 +58,11 @@ reopen and second-instance activation focus the window but never infer a notific upstream desktop notification API cannot correlate desktop body clicks with a custom payload, so the app does not navigate from generic focus or activation events. +Native notification permission is requested only by the user-initiated control +on the existing Notifications settings page. Background events never prompt. +For update availability, the open SPA retains its in-app indication when native +permission is denied, unsupported, or delivery fails. + External `http`, `https`, and `mailto` links use a scoped native opener. Loopback app navigation, downloads, and blob URLs remain in the WebView. diff --git a/docs/decisions/2026-07-24-semantic-notification-events.md b/docs/decisions/2026-07-24-semantic-notification-events.md index 6ecc18d10f..25072d335b 100644 --- a/docs/decisions/2026-07-24-semantic-notification-events.md +++ b/docs/decisions/2026-07-24-semantic-notification-events.md @@ -28,6 +28,9 @@ inferred from generic session lifecycle state. agent-authored clarification-request message and uses the shared pending/request ID as its occurrence identity. Task and session identity are required. +- `system.update_available` is derived from the release poller's durable cached + version and uses the release version/tag as its occurrence identity. It is + install-wide and has no task or session identity. - Notification idempotency is keyed by provider, semantic event type, and occurrence identity. The task-session ID remains recorded for routing and audit but is not the deduplication boundary. @@ -36,6 +39,9 @@ inferred from generic session lifecycle state. - Local WebSocket and desktop notifications preserve the semantic event type and occurrence identity rather than collapsing both cases back into a waiting-state action. +- New-release delivery uses the same provider subscriptions, delivery history, + and retry semantics as other notifications. It does not maintain a parallel + update-only preference or de-duplication store. The user-visible contract is defined by [Semantic Notifications](../specs/platform/notifications.md). diff --git a/docs/plans/update-notification-reliability/plan.md b/docs/plans/update-notification-reliability/plan.md new file mode 100644 index 0000000000..e9d4137e06 --- /dev/null +++ b/docs/plans/update-notification-reliability/plan.md @@ -0,0 +1,372 @@ +--- +spec: docs/specs/platform/notifications.md +created: 2026-07-24 +status: draft +--- + +# Implementation Plan: Update Notification Reliability + +## Overview + +Move update-available delivery from the parallel `internal/system/updates` +settings/broadcast path into the canonical notification-provider service. +`system.update_available` uses the release version as its durable occurrence +identity, can be selected for Local, System, Apprise, and future providers, and +replays cached release state when a Local user subscriber arrives after the +startup poll. + +Remove the update-only enable/channel card and API. The Notifications page +remains the single policy surface, while an open Local client always retains an +in-app update indication if browser/native delivery is denied or fails. Native +permission moves to the existing user-initiated Notifications control. + +The provider/occurrence boundary is recorded in +[the semantic notification ADR](../../decisions/2026-07-24-semantic-notification-events.md), +and the desktop transport boundary is reconciled in +[ADR-0039](../../decisions/0039-native-desktop-integration-boundary.md). + +## Backend + +### Canonical provider event and delivery history + +Files: + +- `apps/backend/internal/notifications/service/service.go` +- `apps/backend/internal/notifications/service/service_test.go` +- `apps/backend/internal/notifications/providers/provider.go` +- `apps/backend/internal/notifications/providers/local.go` +- `apps/backend/internal/notifications/providers/local_test.go` +- `apps/backend/internal/notifications/store/sqlite.go` +- `apps/backend/internal/notifications/store/sqlite_test.go` + +Changes: + +- Add `EventSystemUpdateAvailable = "system.update_available"` to the + available-event catalog and event validation. +- Add `HandleUpdateAvailable(ctx, version, releaseURL)`, with version as the + occurrence ID and the copy/payload defined by the spec. +- Reuse `notification_deliveries` uniqueness for provider-scoped + de-duplication. System update deliveries store an empty task-session ID + rather than introducing a second “last notified version” key. +- Have the Local adapter report that no user subscriber was eligible. The + notification service then releases only that provider's delivery claim so a + cached replay can retry it. +- Add update availability to fresh Local/System default subscriptions. +- Add a replayable one-time notification migration marker that opts existing + Local/System providers into the new event without auto-subscribing Apprise + and without re-enabling a later user opt-out. + +### Release detection, cached replay, and startup wiring + +Files: + +- `apps/backend/internal/system/updates/service.go` +- `apps/backend/internal/system/updates/service_test.go` +- `apps/backend/internal/system/updates/notify_dispatch_test.go` +- `apps/backend/internal/system/updates/notify_settings.go` (delete) +- `apps/backend/internal/system/updates/notify_settings_test.go` (delete) +- `apps/backend/internal/system/updates/notify_store.go` (delete) +- `apps/backend/internal/system/updates/notify_store_test.go` (delete) +- `apps/backend/internal/system/updates/handler.go` +- `apps/backend/internal/system/updates/handler_notify_test.go` (delete) +- `apps/backend/internal/system/system.go` +- `apps/backend/internal/persistence/meta.go` +- `apps/backend/internal/persistence/meta_test.go` +- `apps/backend/internal/persistence/postgres_meta_test.go` +- `apps/backend/internal/gateway/websocket/hub.go` +- `apps/backend/internal/gateway/websocket/hub_user_subscription_test.go` +- `apps/backend/internal/backendapp/main.go` +- `apps/backend/internal/backendapp/gateway.go` +- Related backend application tests + +Changes: + +- Replace the update service's broadcaster, update-only settings store, mutex, + and `notified_update_version` persistence with a narrow notifier interface + implemented by the canonical notification service. +- After a successful release fetch/persist, invoke the notifier with the + cached tag and URL. +- Add `ReplayCachedUpdate(ctx)` that reads `kandev_meta` and invokes the + notifier only when the cached version is newer; it never refetches GitHub. +- Add a user-subscription listener to the WebSocket hub. Invoke listeners after + the user subscriber is registered and after releasing the hub lock. +- Retain the notification service returned by `provideGateway`, wire it into + the update service, and replay cached update state when the default user + subscribes. +- Remove `GET/PUT /api/v1/system/updates/notification-settings` and all boot + wiring for the parallel settings store. +- Delete the independent notified-version metadata helpers and tests. + +## Desktop + +### Native permission and allowlist + +Files: + +- `apps/desktop/src-tauri/src/native_notifications.rs` +- `apps/desktop/src-tauri/src/main.rs` +- `apps/desktop/src-tauri/build.rs` +- `apps/desktop/src-tauri/capabilities/default.json` +- `apps/desktop/src-tauri/permissions/autogenerated/*.toml` +- `apps/desktop/src-tauri/gen/schemas/*.json` +- `apps/web/lib/desktop/native-notification-client.ts` +- Related frontend bridge tests + +Changes: + +- Keep `system.update_available:` in the privileged native allowlist, + but remove the retired `session.waiting_for_input` prefix and use a semantic + event in missing-task validation tests. +- Split native permission state/query and permission request into narrow + origin-checked commands. Only the user-initiated request command may prompt. +- Make background `show_native_notification` return a denied/not-granted result + without prompting, while preserving event-identity release on actual display + failure. +- Expose permission state/request through the typed frontend desktop client and + regenerate Tauri permissions/schemas from the declared command list. + +## Frontend + +### Single provider settings surface + +Files: + +- `apps/web/components/settings/system/update-notifications-card.tsx` (delete) +- `apps/web/components/settings/system/update-notifications-card.test.tsx` (delete) +- `apps/web/src/settings-routes.tsx` +- `apps/web/src/settings-routes.test.ts` +- `apps/web/lib/api/domains/system-api.ts` +- `apps/web/lib/api/domains/system-api.test.ts` +- `apps/web/lib/types/system.ts` +- `apps/web/lib/types/backend.ts` +- `apps/web/lib/notifications/events.ts` +- `apps/web/components/settings/notifications-settings.tsx` +- `apps/web/components/settings/notifications-settings-actions.ts` +- `apps/web/components/settings/notifications-settings-actions.test.tsx` +- `apps/web/lib/state/default-state.ts` +- `apps/web/lib/state/hydration/hydrator.ts` +- `apps/web/lib/state/hydration/hydrator.test.ts` +- `apps/web/lib/state/slices/system/system-slice.ts` +- `apps/web/lib/state/slices/system/types.ts` +- `apps/web/lib/state/slices/ui/ui-slice.ts` +- `apps/web/lib/state/slices/ui/types.ts` +- `apps/web/lib/state/store.ts` + +Changes: + +- Remove the update-only settings type, API client, boot hydration, System + settings card, and save contributor. +- Add the update event label/description to the existing provider/event matrix. +- Make the existing Notifications permission control query and request the + native permission when the Tauri bridge is available, otherwise use the Web + Notification API. Display denied/unsupported state through the existing + settings feedback. +- Keep only the transient update occurrence state required to bridge a Local + WebSocket event into React's toast context. + +### Local delivery and guaranteed in-app fallback + +Files: + +- `apps/web/lib/ws/handlers/notifications.ts` +- `apps/web/lib/ws/handlers/notifications.test.ts` +- `apps/web/lib/ws/handlers/system-events.ts` +- `apps/web/lib/ws/handlers/system-events.test.ts` +- `apps/web/hooks/use-update-available-toast.ts` +- `apps/web/hooks/use-update-available-toast.test.ts` +- `apps/web/components/update-available-toast-bridge.tsx` + +Changes: + +- Register `system.update_available` with the canonical notification handlers + instead of the generic system-event path. +- Always show the in-app update toast for a delivered Local occurrence. +- Attempt native delivery without prompting when the bridge is available; + otherwise show a Web Notification only when permission is already granted. + Denied, unsupported, and command-failure paths retain the in-app toast. +- De-duplicate by the backend occurrence ID/version and remove the update-only + settings fetch/channel branches. + +## Mobile Design Contract + +- **Desktop outcome and mobile entry:** both use + **Settings > General > Notifications > Notification Events** to select + `Kandev update available` per provider. +- **Nearest exemplar:** the existing + `notification-events-table.tsx` stacked mobile event cards and + `mobile-notification-events.spec.ts` are the shipped settings pattern. +- **Hierarchy and primary action:** each event is a section; provider rows are + the primary level and their 44px checkbox targets change the subscription. + The shared floating Save action remains the persistence action. +- **Presentation and rationale:** inline stacked cards are retained because the + event matrix is a short, frequently reviewed settings list; no temporary + drawer or full-height surface is needed. +- **Scroll/geometry:** the settings page remains the single vertical scroll + owner, each touch target is at least 44px, and document horizontal overflow + remains zero. +- **Shared logic:** desktop table and mobile cards share provider drafts, + toggles, validation, and save coordination; only presentation differs. +- **Mobile proof:** the mobile Playwright test toggles the update event, saves, + reloads, verifies persistence, touch size, containment, and no horizontal + overflow. + +## Tests + +- **Provider routing and idempotency:** update occurrence reaches every selected + provider once, disabled/unselected providers are skipped, a failed provider + claim is released, and concurrent/replayed occurrences deduplicate. + - File: `apps/backend/internal/notifications/service/service_test.go` + - Method: fake repository plus capturing Local/Apprise/System adapters. +- **Subscription migration:** fresh defaults include the update event; existing + Local/System providers migrate once; Apprise is unchanged; a later opt-out + survives a second startup. + - File: `apps/backend/internal/notifications/store/sqlite_test.go` + - Method: fresh and replayed real SQLite schema tests. +- **Poll-before-client lifecycle:** immediate poll with no Local subscriber + leaves Local pending; first default-user subscription replays cached state + without a GitHub request; later subscriptions do not duplicate successful + deliveries. + - Files: `apps/backend/internal/system/updates/notify_dispatch_test.go`, + `apps/backend/internal/gateway/websocket/hub_user_subscription_test.go`, + and focused backend application wiring tests. + - Method: deterministic fetcher, fake notifier, real hub subscription order. +- **Removed settings surface:** the deleted route/API/state/card no longer + appears in backend routes, boot payload, or Updates page tests. +- **Native permission:** semantic allowlist validation, permission-state + mapping, no background prompt, user-initiated request, denied result, and + released identity after display error. + - File: `apps/desktop/src-tauri/src/native_notifications.rs` + - Method: Rust unit tests around pure validation/permission decisions. +- **Frontend delivery:** update event is registered with notification handlers, + always produces the in-app toast, attempts native/browser delivery only when + allowed, and retains the toast for denied/unsupported/rejected delivery. + - Files: `apps/web/lib/ws/handlers/notifications.test.ts`, + `apps/web/hooks/use-update-available-toast.test.ts` + - Method: Zustand handler tests and rendered hook tests with mocked bridges. +- **Permission UI:** existing Notifications control uses the native permission + bridge in Tauri and browser permission elsewhere. + - File: `apps/web/components/settings/notifications-settings-actions.test.tsx` + - Method: component/action tests with typed transport mocks. + +## E2E Tests + +- **Scenario:** at a 390px viewport, the update event is readable and + touch-operable, can be toggled and saved, survives reload, and causes no + horizontal overflow. + - File: `apps/web/e2e/tests/settings/mobile-notification-events.spec.ts` + - Verification: the repository's `mobile-chrome` Pixel 5 project. +- Delete + `apps/web/e2e/tests/system/update-notifications-settings.spec.ts`; the + update-only settings surface no longer exists. + +## Public and Internal Documentation + +- Update `docs/public/operations.md` to point update alert configuration to the + Notifications provider/event matrix. +- Add `system.update_available` and its occurrence payload to + `docs/public/websocket-api.md`. +- Keep `docs/specs/platform/notifications.md`, + `docs/specs/system-page/spec.md`, + `docs/specs/desktop-tauri-app/spec.md`, + `docs/decisions/2026-07-24-semantic-notification-events.md`, and + `docs/decisions/0039-native-desktop-integration-boundary.md` aligned. + +## Implementation Waves + +Wave 1: + +- [x] [task-01-provider-event-and-delivery](task-01-provider-event-and-delivery.md) + — `implementer` (balanced) +- [x] [task-03-native-permission-bridge](task-03-native-permission-bridge.md) + — `implementer` (balanced) + +Wave 2: + +- [x] [task-02-update-replay-and-wiring](task-02-update-replay-and-wiring.md) + — `implementer` (balanced), depends on Task 01 + +Wave 3: + +- [x] [task-04-frontend-provider-delivery](task-04-frontend-provider-delivery.md) + — `implementer` (balanced), depends on Tasks 01-03 + +Wave 4: + +- [x] [task-05-mobile-e2e-and-docs](task-05-mobile-e2e-and-docs.md) + — `test-engineer` (balanced), depends on Task 04 + +Workers execute sequentially in the shared worktree despite the logical Wave 1 +independence, because both tasks contribute to the same final notification +contract and Tauri schema generation is shared state. + +## Verification + +Targeted backend: + +```bash +cd apps/backend +go test ./internal/notifications/... +go test ./internal/system/updates ./internal/gateway/websocket ./internal/backendapp +golangci-lint run ./... --new-from-rev=767cc89e6 --timeout=5m +``` + +Targeted frontend: + +```bash +cd apps +pnpm --filter @kandev/web exec vitest run \ + lib/ws/handlers/notifications.test.ts \ + hooks/use-update-available-toast.test.ts \ + components/settings/notifications-settings-actions.test.tsx \ + lib/state/hydration/hydrator.test.ts \ + src/settings-routes.test.ts +cd web && pnpm run typecheck +``` + +Targeted desktop: + +```bash +cd apps/desktop/src-tauri +cargo test --features desktop-runtime native_notifications +cargo fmt --check +``` + +Focused production-build E2E: + +```bash +cd apps/web +pnpm e2e:run tests/settings/mobile-notification-events.spec.ts +``` + +Documentation: + +```bash +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +``` + +Final change-aware verification after commit: + +```bash +make fmt +make typecheck +make test +make lint +``` + +## Risks + +- The Local provider must distinguish “no eligible subscriber” from a + successful queued send without broadening that transport detail into every + provider. +- The hub subscription callback must run after registration and outside the + hub mutex; otherwise replay can miss the new subscriber or deadlock while + broadcasting back through the hub. +- Provider delivery uniqueness must remain the sole concurrency claim when a + poll tick, manual check, and subscription replay overlap. +- Native permission commands expand the privileged desktop bridge and therefore + require exact-origin validation, generated permission updates, and Rust plus + frontend contract tests. +- Deleting the parallel settings surface touches boot hydration and Zustand + composition; orphan searches and typecheck must prove no stale field/API + remains. diff --git a/docs/plans/update-notification-reliability/task-01-provider-event-and-delivery.md b/docs/plans/update-notification-reliability/task-01-provider-event-and-delivery.md new file mode 100644 index 0000000000..49c4900f10 --- /dev/null +++ b/docs/plans/update-notification-reliability/task-01-provider-event-and-delivery.md @@ -0,0 +1,56 @@ +--- +id: "01-provider-event-and-delivery" +title: "Route update occurrences through notification providers" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/platform/notifications.md" +--- + +# Task 01: Route Update Occurrences Through Notification Providers + +## Acceptance + +- `system.update_available` is selectable for every provider and uses the + release version as its provider-scoped delivery occurrence ID. +- A successful provider delivery deduplicates replay, while a Local send with + no eligible user subscriber releases only its own claim. +- Fresh and upgraded Local/System defaults include the event exactly once; + existing Apprise providers are unchanged and a later opt-out survives restart. + +## Verification + +```bash +cd apps/backend +go test ./internal/notifications/... +``` + +## Files likely touched + +- `apps/backend/internal/notifications/service/service.go` +- `apps/backend/internal/notifications/service/service_test.go` +- `apps/backend/internal/notifications/providers/provider.go` +- `apps/backend/internal/notifications/providers/local.go` +- `apps/backend/internal/notifications/providers/local_test.go` +- `apps/backend/internal/notifications/store/sqlite.go` +- `apps/backend/internal/notifications/store/sqlite_test.go` +- `apps/backend/internal/gateway/websocket/hub.go` +- `apps/backend/internal/gateway/websocket/hub_broadcast_test.go` + +## Dependencies + +None. + +## Inputs + +- Spec sections `What`, `Data Model`, `Failure Modes`, and + `Persistence Guarantees`. +- ADR `2026-07-24-semantic-notification-events`. +- Existing semantic-occurrence flow in + `apps/backend/internal/notifications/service/service.go`. + +## Output contract + +Report event copy/payload, occurrence and migration identities, provider failure +semantics, files changed, exact test results, risk tags, and task status update. diff --git a/docs/plans/update-notification-reliability/task-02-update-replay-and-wiring.md b/docs/plans/update-notification-reliability/task-02-update-replay-and-wiring.md new file mode 100644 index 0000000000..64c958dc7e --- /dev/null +++ b/docs/plans/update-notification-reliability/task-02-update-replay-and-wiring.md @@ -0,0 +1,65 @@ +--- +id: "02-update-replay-and-wiring" +title: "Replay cached updates when a Local user subscribes" +status: done +wave: 2 +depends_on: ["01-provider-event-and-delivery"] +plan: "plan.md" +spec: "../../specs/platform/notifications.md" +--- + +# Task 02: Replay Cached Updates When a Local User Subscribes + +## Acceptance + +- Release detection invokes the canonical notifier and no longer owns a + broadcaster, update-only policy store, or notified-version key. +- An immediate startup poll before any user subscriber leaves Local delivery + pending; the first default-user subscription replays cached state without a + GitHub request and delivers it once. +- The update notification settings endpoints and backend boot wiring are + removed without changing the existing update-check/apply API. + +## Verification + +```bash +cd apps/backend +go test ./internal/system/updates ./internal/gateway/websocket ./internal/backendapp +``` + +## Files likely touched + +- `apps/backend/internal/system/updates/service.go` +- `apps/backend/internal/system/updates/service_test.go` +- `apps/backend/internal/system/updates/notify_dispatch_test.go` +- `apps/backend/internal/system/updates/notify_settings*.go` +- `apps/backend/internal/system/updates/notify_store*.go` +- `apps/backend/internal/system/updates/handler.go` +- `apps/backend/internal/system/updates/handler_notify_test.go` +- `apps/backend/internal/system/system.go` +- `apps/backend/internal/persistence/meta.go` +- `apps/backend/internal/persistence/meta_test.go` +- `apps/backend/internal/persistence/postgres_meta_test.go` +- `apps/backend/internal/gateway/websocket/hub.go` +- `apps/backend/internal/gateway/websocket/hub_user_subscription_test.go` +- `apps/backend/internal/backendapp/main.go` +- `apps/backend/internal/backendapp/gateway.go` +- Related backend application tests + +## Dependencies + +- Task 01 must expose the canonical update notifier behavior. + +## Inputs + +- Spec scenario `startup release poll completes before any Local client + subscribes`. +- Plan sections `Release detection, cached replay, and startup wiring` and + `Risks`. +- Current startup order in `apps/backend/internal/backendapp/main.go`. + +## Output contract + +Report exact notifier interface, subscription-callback ordering, removed API and +metadata surfaces, deterministic lifecycle tests, commands/results, risk tags, +and task status update. diff --git a/docs/plans/update-notification-reliability/task-03-native-permission-bridge.md b/docs/plans/update-notification-reliability/task-03-native-permission-bridge.md new file mode 100644 index 0000000000..089d5f9fde --- /dev/null +++ b/docs/plans/update-notification-reliability/task-03-native-permission-bridge.md @@ -0,0 +1,56 @@ +--- +id: "03-native-permission-bridge" +title: "Move native notification permission to a user gesture" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/platform/notifications.md" +--- + +# Task 03: Move Native Notification Permission to a User Gesture + +## Acceptance + +- The native bridge accepts semantic session, failure, and update occurrences + but rejects the retired `session.waiting_for_input` prefix. +- Background delivery never requests permission; narrow origin-checked commands + query permission and request it only from a user action. +- Rust permissions/schemas and the typed frontend client expose the same command + contract, with tests for denied, duplicate, and failed-display behavior. + +## Verification + +```bash +cd apps/desktop/src-tauri +cargo test --features desktop-runtime native_notifications +cargo fmt --check +cd ../../ +pnpm --filter @kandev/web exec vitest run lib/desktop/native-notification-client.test.ts +``` + +## Files likely touched + +- `apps/desktop/src-tauri/src/native_notifications.rs` +- `apps/desktop/src-tauri/src/main.rs` +- `apps/desktop/src-tauri/build.rs` +- `apps/desktop/src-tauri/capabilities/default.json` +- `apps/desktop/src-tauri/permissions/autogenerated/*.toml` +- `apps/desktop/src-tauri/gen/schemas/*.json` +- `apps/web/lib/desktop/native-notification-client.ts` +- `apps/web/lib/desktop/native-notification-client.test.ts` + +## Dependencies + +None. + +## Inputs + +- Spec `Failure Modes` and denied-permission scenario. +- Desktop spec `Notification contract`. +- ADR-0039 exact-origin and narrow-command boundary. + +## Output contract + +Report command names and result types, exact-origin checks, allowlist changes, +generated artifacts, Rust/frontend tests, risk tags, and task status update. diff --git a/docs/plans/update-notification-reliability/task-04-frontend-provider-delivery.md b/docs/plans/update-notification-reliability/task-04-frontend-provider-delivery.md new file mode 100644 index 0000000000..0680101c19 --- /dev/null +++ b/docs/plans/update-notification-reliability/task-04-frontend-provider-delivery.md @@ -0,0 +1,83 @@ +--- +id: "04-frontend-provider-delivery" +title: "Use provider settings and preserve the in-app update indication" +status: done +wave: 3 +depends_on: + - "01-provider-event-and-delivery" + - "02-update-replay-and-wiring" + - "03-native-permission-bridge" +plan: "plan.md" +spec: "../../specs/platform/notifications.md" +--- + +# Task 04: Use Provider Settings and Preserve the In-App Update Indication + +## Acceptance + +- The Updates page has no update-only settings card/API/state; the existing + provider matrix labels and persists `system.update_available`. +- A delivered Local update occurrence always shows an in-app toast and attempts + native/browser delivery only without prompting; denied, unsupported, and + command-failure paths retain the toast. +- The Notifications permission control uses the native permission bridge in + Tauri and the Web Notification API elsewhere, exposing denied/unsupported + state in the existing UI. + +## Verification + +```bash +cd apps +pnpm --filter @kandev/web exec vitest run \ + lib/ws/handlers/notifications.test.ts \ + hooks/use-update-available-toast.test.ts \ + components/settings/notifications-settings-actions.test.tsx \ + lib/state/hydration/hydrator.test.ts \ + src/settings-routes.test.ts +cd web +pnpm run typecheck +``` + +## Files likely touched + +- `apps/web/components/settings/system/update-notifications-card*.tsx` +- `apps/web/src/settings-routes.tsx` +- `apps/web/src/settings-routes.test.ts` +- `apps/web/lib/api/domains/system-api.ts` +- `apps/web/lib/api/domains/system-api.test.ts` +- `apps/web/lib/types/system.ts` +- `apps/web/lib/types/backend.ts` +- `apps/web/lib/notifications/events.ts` +- `apps/web/components/settings/notifications-settings.tsx` +- `apps/web/components/settings/notifications-settings-actions.ts` +- `apps/web/components/settings/notifications-settings-actions.test.tsx` +- `apps/web/lib/state/default-state.ts` +- `apps/web/lib/state/hydration/hydrator.ts` +- `apps/web/lib/state/hydration/hydrator.test.ts` +- `apps/web/lib/state/slices/system/*` +- `apps/web/lib/state/slices/ui/*` +- `apps/web/lib/state/store.ts` +- `apps/web/lib/ws/handlers/notifications.ts` +- `apps/web/lib/ws/handlers/notifications.test.ts` +- `apps/web/lib/ws/handlers/system-events.ts` +- `apps/web/lib/ws/handlers/system-events.test.ts` +- `apps/web/hooks/use-update-available-toast.ts` +- `apps/web/hooks/use-update-available-toast.test.ts` +- `apps/web/components/update-available-toast-bridge.tsx` + +## Dependencies + +- Task 01 defines the event/payload contract. +- Task 02 removes the parallel backend policy API. +- Task 03 provides non-prompting native permission and delivery methods. + +## Inputs + +- Spec sections `API Surface`, `Failure Modes`, and mobile scenario. +- Plan `Frontend` and `Mobile Design Contract`. +- ADR-0046 settings save-coordinator invariant. + +## Output contract + +Report deleted orphan surfaces, event registration, fallback behavior, +permission UI behavior, tests/typecheck, risk tags, and task status update. diff --git a/docs/plans/update-notification-reliability/task-05-mobile-e2e-and-docs.md b/docs/plans/update-notification-reliability/task-05-mobile-e2e-and-docs.md new file mode 100644 index 0000000000..f805bed0a0 --- /dev/null +++ b/docs/plans/update-notification-reliability/task-05-mobile-e2e-and-docs.md @@ -0,0 +1,53 @@ +--- +id: "05-mobile-e2e-and-docs" +title: "Prove mobile provider settings and update the public contract" +status: done +wave: 4 +depends_on: ["04-frontend-provider-delivery"] +plan: "plan.md" +spec: "../../specs/platform/notifications.md" +--- + +# Task 05: Prove Mobile Provider Settings and Update the Public Contract + +## Acceptance + +- Mobile Playwright toggles the update event through the existing provider + matrix, saves, reloads, and proves persistence, 44px touch targets, + containment, and no document horizontal overflow. +- The obsolete update-only settings E2E is deleted. +- Public operations and WebSocket docs describe provider-based update + configuration and the version occurrence payload. + +## Verification + +```bash +cd apps/web +pnpm e2e:run tests/settings/mobile-notification-events.spec.ts +cd ../../ +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +``` + +## Files likely touched + +- `apps/web/e2e/tests/settings/mobile-notification-events.spec.ts` +- `apps/web/e2e/tests/system/update-notifications-settings.spec.ts` +- `docs/public/operations.md` +- `docs/public/websocket-api.md` + +## Dependencies + +- Task 04 must land the final provider settings and Local delivery surface. + +## Inputs + +- Spec mobile and denied-permission scenarios. +- Plan `Mobile Design Contract`, `E2E Tests`, and `Public and Internal + Documentation`. +- `/mobile-parity` and `/e2e` guidance. + +## Output contract + +Report RED/GREEN E2E evidence, rendered mobile geometry, removed obsolete test, +public-doc validation, files changed, risk tags, and task status update. diff --git a/docs/public/operations.md b/docs/public/operations.md index 3f7e1b4502..f5e2e5ac7c 100644 --- a/docs/public/operations.md +++ b/docs/public/operations.md @@ -239,7 +239,7 @@ 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. +Configure update alerts in **Settings > General > Notifications > Notification Events**. Select **Kandev update available** for each notification provider that should receive it: Local delivers the in-app update indication and can use an already-granted browser or native desktop notification; System and Apprise use their configured provider transports. There is no separate update-only channel selector. New Local and System providers include this event by default; existing Local and System providers receive it once on upgrade, while existing Apprise providers remain unchanged. Disabling the event for a provider stops that provider's delivery without disabling release checks. Each provider receives a release version at most once; a later version is a new occurrence. 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. diff --git a/docs/public/websocket-api.md b/docs/public/websocket-api.md index 9eb3dbbf7f..16a6b5ad09 100644 --- a/docs/public/websocket-api.md +++ b/docs/public/websocket-api.md @@ -760,6 +760,7 @@ File changes are batched for up to 100 ms and flushed immediately at 50 entries. | subscribed user | `user.settings.updated` | Requires `user.subscribe`. | | subscribed user | `session.turn_finished` | Local user notification for a completed agent turn, with task/session/occurrence/title/body fields; the turn ID is sent as both the envelope `id` and payload `occurrence_id`. It is not delivered by `session.subscribe`. | | subscribed user | `session.clarification_requested` | Local user notification for a structured agent question that needs an answer, with task/session/occurrence/title/body fields; the clarification pending ID is sent as both the envelope `id` and payload `occurrence_id`. It is not delivered by `session.subscribe`. | +| subscribed user | `system.update_available` | Local user notification for a newer Kandev release. Its payload is `{ "version", "url"?, "title", "body", "occurrence_id" }`; `occurrence_id` is the normalized release version/tag and is also the envelope `id`. Subscribe with `user.subscribe`, not `session.subscribe`. | | subscribed user | `office.inbox_item` | Local notification for a selected Office inbox event, with title/body fields. | | subscribed run | `run.event.appended` | Future events only; there is no replay cursor. | | metrics subscribers | `system.metrics.updated` | Live resource snapshot; collection interest follows subscribers. | diff --git a/docs/specs/desktop-tauri-app/spec.md b/docs/specs/desktop-tauri-app/spec.md index 8777975f7e..5a99089b24 100644 --- a/docs/specs/desktop-tauri-app/spec.md +++ b/docs/specs/desktop-tauri-app/spec.md @@ -138,6 +138,8 @@ For desktop-owned launches, Kandev uses native OS notifications for: - a completed agent turn when that provider event is selected; - a structured clarification request that needs a user answer when that provider event is selected; +- a newer Kandev release when `system.update_available` is selected for the + Local provider; - a session that failed. Notification delivery respects the existing notification settings and suppresses duplicate @@ -152,6 +154,11 @@ identity is deduplicated. Turn-finished and clarification-requested delivery follows the selected event checkboxes. Session-failure delivery is an independent safety alert whenever the Local provider is enabled; it does not reuse either semantic subscription. +Update-available delivery uses the release version as its occurrence identity +and the same Local-provider preference as browser delivery. The existing +Notifications settings control requests native permission from a user gesture; +background events never prompt. An open app keeps the in-app update indication +when native permission is denied or native delivery fails. ## Desktop bridge and permissions @@ -255,6 +262,10 @@ display before being shown. while Kandev is not focused, **WHEN** its native notification is delivered, **THEN** Kandev sends no duplicate browser or shell notification and generic app focus or activation does not navigate to an inferred task. +- **GIVEN** `system.update_available` is selected for Local and native + permission is denied, **WHEN** a newer release is detected, **THEN** the open + app retains its in-app update indication, does not prompt in the background, + and does not mark a task route from generic activation. - **GIVEN** a saved window position belongs to a disconnected display, **WHEN** Kandev reopens, **THEN** the main window appears within a currently visible display. - **GIVEN** the same settings page is opened in a browser or narrow/mobile viewport, **WHEN** it @@ -268,7 +279,7 @@ display before being shown. - Global shortcuts that operate while another application is focused. - Silent, forced, or downgrade updates; App Store/Microsoft Store/Snap distribution. - Auto-updating Linux `.deb` or `.rpm` packages outside their package managers. -- Notifications beyond waiting-for-input and session failure in this increment. +- Notification events beyond the canonical provider catalog in this increment. - A separate native settings window, multiple native windows, or a rewritten native UI. - Mobile Tauri targets. Native shortcuts have no mobile analogue; the shared settings page must remain responsive and preserve browser/mobile behavior. diff --git a/docs/specs/platform/notifications.md b/docs/specs/platform/notifications.md index f5827a2ca4..f78f115c37 100644 --- a/docs/specs/platform/notifications.md +++ b/docs/specs/platform/notifications.md @@ -19,6 +19,12 @@ The notification settings page also snapshots an empty provider store during a cold load. Providers fetched immediately afterward remain absent from the rendered draft, making an intact configuration look lost. +Update detection has the same need for durable, user-controlled delivery. +Users should learn that a newer Kandev release exists without maintaining a +second preference model that bypasses Local, System, Apprise, or future +notification providers. A denied or unavailable desktop notification must not +hide the update from an open Kandev window. + ## What - Notification settings expose two independent session events: @@ -27,11 +33,18 @@ rendered draft, making an intact configuration look lost. user response through Kandev's clarification flow. - A user may enable either event, both events, or neither event for each notification provider. +- **Kandev update available** is an independently selectable provider event. + It is emitted when the release poller durably observes a version newer than + the running Kandev version. - When a provider-create request omits its event selection, the clarification event is enabled by default and turn-finished remains opt-in. An explicitly empty selection enables neither event. - Existing `session.waiting_for_input` subscriptions migrate to **Agent needs an answer** only. Migration never opts a provider into **Agent turn finished**. +- Fresh default Local and System providers subscribe to update availability. + On upgrade, existing Local and System providers receive that subscription + once; a migration marker prevents later startup from re-enabling a choice the + user removed. Existing Apprise providers are never opted in automatically. - Session startup, readiness, idle state, and generic `WAITING_FOR_INPUT` transitions do not themselves send either notification. - A turn-finished notification uses: @@ -42,6 +55,13 @@ rendered draft, making an intact configuration look lost. - body: `The agent asked a question on "".` - The local browser and native desktop notification path uses the same semantic event and copy as remote providers such as Apprise. +- An update-available notification uses: + - title: `Kandev update available` + - body: `Kandev is available. Open Settings > System > Updates to review it.` +- The Local provider keeps an in-app update indication visible even when + browser/native permission is denied, unsupported, or delivery fails. Native + permission is requested only from the existing user-initiated control on the + Notifications settings page. - Native session-failure alerts remain an independent safety channel whenever the Local provider is enabled; they are not controlled by either semantic event checkbox. @@ -55,11 +75,14 @@ The durable provider subscription keys are: - `session.turn_finished` - `session.clarification_requested` +- `system.update_available` -Each delivery records both the task-session ID and a semantic occurrence ID: +Each delivery records a semantic occurrence ID and, for session-scoped events, +the task-session ID: - turn-finished occurrence ID: the completed turn ID; - clarification occurrence ID: the clarification request's pending/request ID. +- update-available occurrence ID: the normalized release version/tag. Delivery idempotency is scoped to provider, event type, and occurrence ID. Consequently, replaying one occurrence does not duplicate a delivery, while @@ -72,17 +95,22 @@ not emitted as a new notification event. ## API Surface - The notification-provider API returns the two semantic events in its - available-event catalog. + available-event catalog together with `system.update_available`. - Provider create and update requests persist the selected semantic event keys. - An omitted create-time event field applies the clarification-only default; an explicitly empty event array persists no subscriptions. - The WebSocket gateway may emit `session.turn_finished` and - `session.clarification_requested` for the local notification provider. + `session.clarification_requested` for the local notification provider, plus + `system.update_available` with `{ version, url, title, body, + occurrence_id }`. - Local semantic notifications carry the occurrence ID through the WebSocket envelope/payload so native de-duplication uses the same identity as backend delivery de-duplication. - Existing provider URLs, enabled state, and unrelated event subscriptions are unchanged. +- Update-notification policy is not exposed through a separate settings route + or channel selector. Provider enabled state and event subscriptions are the + authoritative configuration. ## State and Event Semantics @@ -100,6 +128,11 @@ A clarification bundle containing several questions is one occurrence and produces at most one notification per provider. User-authored, system-authored, malformed, or unscoped clarification-shaped messages do not notify. +An update is one install-wide occurrence per release version. Replaying cached +release state after a Local client subscribes retries only providers whose +delivery was not already recorded; Apprise, System, and other successful +providers remain deduplicated. + ## Permissions Notification configuration continues to use the existing settings @@ -110,6 +143,12 @@ Notification bodies contain the task title but not the question text. - A provider send failure releases that occurrence's de-duplication claim so a replay may retry it. It does not mark a later occurrence as delivered. +- If the startup release poll completes before a Local WebSocket subscriber + exists, Local delivery remains pending. The cached release is replayed when + an eligible user client subscribes, without refetching GitHub. +- If native/browser delivery is denied, unsupported, or fails, the open client + retains the in-app update indication. Background delivery never opens a + permission prompt. - Replayed event-bus messages do not create duplicate deliveries for the same provider and occurrence. - A provider refresh received while persisted-provider drafts are clean may @@ -125,11 +164,17 @@ Notification bodies contain the task title but not the question text. subscription remains disabled unless an already-existing semantic subscription is enabled. - Migration is idempotent and does not create duplicate subscriptions. +- The one-time update-event migration adds `system.update_available` only to + existing Local and System providers. It records completion separately so a + later user opt-out survives restart. - Existing delivery history remains available for audit, but legacy session-level deduplication does not suppress new semantic occurrences. - The de-duplication claim is inserted atomically with its semantic occurrence identity. A failed provider send removes that exact provider/event/occurrence claim. +- Update delivery history uses the same provider/event/occurrence records as + every other semantic notification. No independent “last notified update” + key is authoritative. ## Scenarios @@ -145,15 +190,33 @@ Notification bodies contain the task title but not the question text. selected, **THEN** each turn can produce one notification. - **GIVEN** a clarification event is delivered and then replayed, **WHEN** both copies reach the notification service, **THEN** only one delivery is sent. +- **GIVEN** Local is disabled for `system.update_available`, **WHEN** a newer + release is detected, **THEN** no Local update toast or desktop notification + is delivered. +- **GIVEN** Apprise is subscribed to `system.update_available`, **WHEN** a newer + release is detected, **THEN** Apprise receives the same update occurrence and + copy as Local and System providers. +- **GIVEN** an existing Local provider predates the update event, **WHEN** the + notification schema upgrade runs twice and the user removes the migrated + subscription between runs, **THEN** the second run does not re-enable it. +- **GIVEN** the startup release poll completes before any Local client + subscribes, **WHEN** the first user client subscribes, **THEN** the cached + release is delivered once without waiting for the next six-hour poll. +- **GIVEN** native/browser notification permission is denied or delivery + fails, **WHEN** an open Local client receives an update occurrence, **THEN** + the in-app update indication remains visible and no background permission + prompt is shown. - **GIVEN** an existing provider subscribed to `session.waiting_for_input`, **WHEN** the database is upgraded, **THEN** the provider is subscribed to clarification requests and not to turn completion. - **GIVEN** saved providers exist, **WHEN** the notifications settings route is opened from a cold frontend store, **THEN** the providers and their selections appear after loading without requiring a remount. -- **GIVEN** a 390px mobile viewport, **WHEN** both event rows are displayed, - **THEN** a stacked mobile composition keeps every label, description, provider, - and control readable and touch operable without horizontal scrolling. +- **GIVEN** a 390px mobile viewport, **WHEN** the notification event rows are displayed, + **THEN** the stacked composition includes the update event and keeps every + label, description, provider, and control readable and touch operable without + horizontal scrolling; a changed update subscription survives Save and + reload. ## Out of Scope @@ -164,8 +227,10 @@ Notification bodies contain the task title but not the question text. - Retrying provider deliveries beyond the existing notification-provider behavior. - Adding a new notification-settings fetch-error surface. +- A separate update-only enable switch or desktop/in-app/both channel policy. - Changing session lifecycle states or the chat waiting-state presentation. ## Implementation Plan -See [the implementation plan](../../plans/semantic-notifications/plan.md). +- [Semantic notification implementation](../../plans/semantic-notifications/plan.md) +- [Update notification reliability remediation](../../plans/update-notification-reliability/plan.md) diff --git a/docs/specs/system-page/spec.md b/docs/specs/system-page/spec.md index 2343df8096..761cc83e2e 100644 --- a/docs/specs/system-page/spec.md +++ b/docs/specs/system-page/spec.md @@ -41,6 +41,9 @@ A new **System** group is added to the existing settings sidebar (`apps/web/comp 6. **Updates** — `/settings/system/updates` - Shows running version, latest available version, and a "new version available" badge if newer. - **Check now** button — forces a re-poll (rate-limited 30s per process). + - Update alerts are configured with the existing provider/event matrix under + **Settings > General > Notifications**. The Updates page does not own a + second notification enable switch or delivery-channel preference. - Below: the embedded changelog list (the content currently rendered at `/settings/changelog` via `@/generated/changelog.json`). 7. **Licenses** — `/settings/system/licenses` - Renders a JSON manifest of all third-party OSS dependencies (npm + Go), each with its license name, version, repository URL, and license text. The manifest is generated at build time and committed to the repo. @@ -132,6 +135,10 @@ The page reads the JSON statically; no backend endpoint is needed. - **GIVEN** the disk-usage cache is 30 minutes old, **WHEN** the user reopens the Status page, **THEN** the cached value renders instantly with "as of <30 min ago>" and **no** refresh kicks off. - **GIVEN** the disk-usage cache is 3 hours old, **WHEN** the user reopens the Status page, **THEN** the stale value renders immediately, the page badge shows "Refreshing…", and the value updates when the background walk completes. - **GIVEN** the user is on `1.2.3` and the GitHub latest release is `1.2.4`, **WHEN** they open `/settings/system/updates`, **THEN** an "Update available" badge renders next to the version, the changelog list shows `1.2.4` highlighted as the new entry, and the System sidebar group shows a `1` badge. +- **GIVEN** the user wants to change update-alert delivery, **WHEN** they open + `/settings/system/updates`, **THEN** the page directs them to the existing + Notifications provider/event settings instead of rendering a separate + update-notification channel control. - **GIVEN** the user is running a kandev-managed user service and a newer release exists, **WHEN** they open `/settings/system/updates`, **THEN** the page shows **Apply update** and the confirmation queues a `self-update` job. - **GIVEN** the user is not running as a kandev-managed service or is running a `--system` service, **WHEN** they open `/settings/system/updates`, **THEN** the page does not render an update-apply control and instead shows the manual update commands. - **GIVEN** the user clicks **VACUUM** on the Database page, **WHEN** the operation completes, **THEN** the DB size delta is shown ("Reclaimed 12.3 MB"), the page Database stats refresh, and a transient info issue appears on the Status page.