feat: notify user when a kandev update is detected (#1924)

* feat: notify user when a kandev update is detected

Adds a background-poller-driven update notification with a saved
preference for delivery channel (desktop / in-app banner / both) and
an enable/disable switch on the Updates settings page.

Backend:
- internal/system/updates: NotifySettings + NotifyStore persist the
  enable/channel preference on the existing install-wide settings
  table; notifyIfNewUpdate broadcasts a WS event the first time a
  newer release is observed, deduped durably via kandev_meta so a
  restart or repeat poll doesn't re-announce the same version.
- New GET/PUT /api/v1/system/updates/notification-settings endpoints.
- pkg/websocket: new system.update_available action.

Desktop (Tauri):
- native_notifications.rs: task_id is now optional (Option<String>,
  required only for session-scoped events) and the event allowlist
  accepts system.update_available: alongside the existing session
  events, so the update notification can use the native bridge.

Frontend:
- New UpdateNotificationsCard on the Updates settings page (Switch +
  channel Select), wired through the shared settings-save-provider
  floating Save/Discard action.
- useUpdateAvailableToast bridges the WS event to an in-app toast
  and/or the native/browser desktop notification, per the saved
  channel, deduped per version.

* docs: document the update notification channel setting

* fix: hydrate update notification settings and broadcast delivery ack

- Only mark a release notification as delivered when the broadcaster
  reports at least one connected client, closing a race where an
  update could be silently dropped before the first client connects.
- Hydrate updateNotificationSettings through the settings boot payload
  (loadSettingsInitialState -> hydrateState) instead of a component-
  level fetch, per the boot-payload -> store -> component data flow.
  Widen the hydration types (HydrationState) so callers can hydrate a
  partial system slice without fabricating the other system fields.
  UpdateNotificationsCard now only reads/writes the subscribed store
  state.
- Fix module-scope settings-fetch cache leaking across tests in
  use-update-available-toast.test.ts so the fetch-failure test
  actually exercises the catch/fallback path.
- Add API-client contract tests for fetchUpdateNotificationSettings
  and saveUpdateNotificationSettings (GET cache policy, PUT method
  and body).

* test: cover delayed hydration and boot-payload mapping for update notifications
This commit is contained in:
Clément Fiere
2026-07-24 23:40:24 +02:00
committed by GitHub
parent 2d950a72fb
commit f85b648d97
40 changed files with 1675 additions and 44 deletions
@@ -15,7 +15,12 @@ pub struct NativeNotificationRequest {
pub event_id: String,
pub title: String,
pub body: String,
pub task_id: String,
/// Present for session-scoped events (waiting-for-input, failed).
/// Absent for account/app-scoped events such as an available update,
/// which has no associated task. `#[serde(default)]` accepts payloads
/// that omit the key entirely (e.g. `JSON.stringify` drops `undefined`).
#[serde(default)]
pub task_id: Option<String>,
pub session_id: Option<String>,
}
@@ -83,25 +88,40 @@ impl NativeNotificationState {
}
}
/// Event identities this bridge will display. Session-scoped events require
/// a non-empty task_id (they're gated on task context); the update-available
/// event has no task and must omit it.
const SESSION_EVENT_PREFIXES: [&str; 4] = [
"session.waiting_for_input:",
"session.failed:",
"session.turn_finished:",
"session.clarification_requested:",
];
const UPDATE_AVAILABLE_PREFIX: &str = "system.update_available:";
fn validate_request(request: &NativeNotificationRequest) -> Result<(), String> {
if !request.event_id.starts_with("session.turn_finished:")
&& !request
.event_id
.starts_with("session.clarification_requested:")
&& !request.event_id.starts_with("session.failed:")
{
let is_session_event = SESSION_EVENT_PREFIXES
.iter()
.any(|prefix| request.event_id.starts_with(prefix));
let is_update_event = request.event_id.starts_with(UPDATE_AVAILABLE_PREFIX);
if !is_session_event && !is_update_event {
return Err("unsupported native notification event".to_string());
}
if request.event_id.len() > 256
|| request.title.is_empty()
|| request.title.len() > 160
|| request.body.len() > 1000
|| request.task_id.is_empty()
|| request.task_id.len() > 256
|| request.session_id.as_ref().is_some_and(|id| id.len() > 256)
|| request.task_id.as_ref().is_some_and(|id| id.len() > 256)
{
return Err("invalid native notification payload".to_string());
}
if is_session_event {
match request.task_id.as_deref() {
Some(id) if !id.is_empty() => {}
_ => return Err("invalid native notification payload".to_string()),
}
}
Ok(())
}
@@ -159,11 +179,21 @@ mod tests {
event_id: event_id.to_string(),
title: "Agent needs your answer".to_string(),
body: "The agent asked a question.".to_string(),
task_id: task_id.to_string(),
task_id: Some(task_id.to_string()),
session_id: Some("session-1".to_string()),
}
}
fn update_available_request(event_id: &str) -> NativeNotificationRequest {
NativeNotificationRequest {
event_id: event_id.to_string(),
title: "Update available".to_string(),
body: "Kandev v1.2.3 is available.".to_string(),
task_id: None,
session_id: None,
}
}
#[test]
fn event_identity_is_delivered_at_most_once() {
let state = NativeNotificationState::default();
@@ -184,6 +214,16 @@ mod tests {
assert!(state.claim("session.failed:0"));
}
#[test]
fn bridge_rejects_events_outside_supported_notification_types() {
let request = request("office.inbox_item:item-1", "task-1");
assert_eq!(
validate_request(&request),
Err("unsupported native notification event".to_string())
);
}
#[test]
fn bridge_accepts_semantic_session_events() {
for event_id in [
@@ -194,6 +234,33 @@ mod tests {
}
}
#[test]
fn bridge_accepts_update_available_events_without_task_id() {
let request = update_available_request("system.update_available:v1.2.3");
assert_eq!(validate_request(&request), Ok(()));
}
#[test]
fn bridge_rejects_session_events_missing_task_id() {
let mut request = request("session.waiting_for_input:session-1", "task-1");
request.task_id = None;
assert_eq!(
validate_request(&request),
Err("invalid native notification payload".to_string())
);
}
#[test]
fn update_available_identity_is_delivered_at_most_once() {
let state = NativeNotificationState::default();
let request = update_available_request("system.update_available:v1.2.3");
assert!(state.claim(&request.event_id));
assert!(!state.claim(&request.event_id));
}
#[test]
fn denied_delivery_does_not_claim_the_event() {
let state = NativeNotificationState::default();