373 lines
15 KiB
Markdown
373 lines
15 KiB
Markdown
---
|
|
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:<version>` 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.
|