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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user