diff --git a/apps/objectos-one/src-tauri/assets/notification-bridge.js b/apps/objectos-one/src-tauri/assets/notification-bridge.js new file mode 100644 index 0000000..c9b942e --- /dev/null +++ b/apps/objectos-one/src-tauri/assets/notification-bridge.js @@ -0,0 +1,144 @@ +// Native OS notification bridge for ObjectOS One. +// +// Injected into every page of the main webview (alongside update-banner.js). +// Activates only on the Console — where the in-app inbox lives — and only +// inside Tauri. It polls the in-app inbox and, when the window is in the +// background, surfaces each NEW message as a native OS notification (macOS +// Notification Center / Windows toast / Linux libnotify) and badges the dock +// with the count missed while away (cleared on focus). +// +// Source of truth is `sys_inbox_message` (ADR-0030 L5) — the same table the +// Console bell reads. The dedicated `/api/v1/notifications` route isn't mounted +// in this runtime, so we read the canonical inbox object via the data API. +// +// All native work goes through Rust commands (`notify_native`, `set_badge`, +// `notif_request_permission`) via `core.invoke` — matching this app's pattern. +// `withGlobalTauri` only exposes `core`/`event`, NOT the plugin/window JS APIs, +// so we must not call `__TAURI__.notification`/`.window`/`.app` directly. +(function () { + if (window.__objectosNotifyBridge) return; + // Only on the Console (its login/app routes live under /_console), and only + // when the Tauri bridge is present (no-op in a plain browser). + if (!/\/_console(\/|$)/.test(location.pathname)) return; + if (!window.__TAURI__ || !window.__TAURI__.core) return; + window.__objectosNotifyBridge = true; + + var invoke = window.__TAURI__.core.invoke; + var INBOX_URL = '/api/v1/data/sys_inbox_message?sort=-created_at&limit=25'; + var POLL_MS = 20000; + var SEEN_KEY = '__objectos_notif_seen_v1'; + var MAX_SEEN = 500; + + var baselined = false; // first successful poll adopts the backlog silently + var inFlight = false; + var fatal = false; // inbox object absent → stop + var unseen = 0; // count surfaced while backgrounded, cleared on focus + + function loadSeen() { + try { + return new Set(JSON.parse(localStorage.getItem(SEEN_KEY) || '[]')); + } catch (_) { + return new Set(); + } + } + function saveSeen(set) { + try { + var arr = Array.from(set); + if (arr.length > MAX_SEEN) arr = arr.slice(arr.length - MAX_SEEN); + localStorage.setItem(SEEN_KEY, JSON.stringify(arr)); + } catch (_) {} + } + var seen = loadSeen(); + + // Surface only when the user isn't actively looking at the window. + function backgrounded() { + return document.visibilityState === 'hidden' || !document.hasFocus(); + } + + function notify(title, body) { + try { + return invoke('notify_native', { title: title || 'Notification', body: body || '' }); + } catch (_) {} + } + function setBadge(count) { + try { + return invoke('set_badge', { count: count > 0 ? count : null }); + } catch (_) {} + } + + // sys_inbox_message → the minimal shape we need for a toast. + function view(row) { + return { + id: row && row.id, + title: (row && row.title) || (row && row.topic) || 'Notification', + body: (row && (row.body_md || row.body)) || '', + createdAt: (row && row.created_at) || '', + }; + } + + async function poll() { + if (fatal || inFlight) return; + inFlight = true; + try { + var res = await fetch(INBOX_URL, { + headers: { accept: 'application/json' }, + credentials: 'same-origin', + }); + if (res.status === 401) return; // not signed in yet + if (res.status === 404) { fatal = true; return; } // no inbox object + if (!res.ok) return; + + var json = await res.json(); + var rows = (json && (json.records || json.items || json.data)) || []; + // Oldest-first so a burst toasts in chronological order. + var list = rows + .map(view) + .filter(function (v) { return v.id; }) + .sort(function (a, b) { + return String(a.createdAt).localeCompare(String(b.createdAt)); + }); + var fresh = list.filter(function (v) { return !seen.has(v.id); }); + if (!fresh.length) return; + + if (!baselined) { + // First poll after a (re)load: adopt existing messages as baseline so + // we don't replay the backlog as a burst of toasts. + fresh.forEach(function (v) { seen.add(v.id); }); + baselined = true; + } else { + var allowed = backgrounded(); + fresh.forEach(function (v) { + seen.add(v.id); + if (allowed) { + notify(v.title, String(v.body).slice(0, 240)); + unseen += 1; + } + }); + if (allowed && unseen > 0) setBadge(unseen); + } + saveSeen(seen); + } catch (_) { + // transient (offline, mid-navigation) — retry next tick + } finally { + inFlight = false; + } + } + + function onForeground() { + // The user is looking now — clear the "missed while away" badge. + unseen = 0; + setBadge(0); + poll(); + } + + // Ask once up front, in the foreground, so the OS prompt isn't sprung from + // the background. + try { invoke('notif_request_permission'); } catch (_) {} + + poll(); + setInterval(poll, POLL_MS); + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'visible') onForeground(); + }); + window.addEventListener('focus', onForeground); +})(); diff --git a/apps/objectos-one/src-tauri/src/commands.rs b/apps/objectos-one/src-tauri/src/commands.rs index 31eb8ad..35394bc 100644 --- a/apps/objectos-one/src-tauri/src/commands.rs +++ b/apps/objectos-one/src-tauri/src/commands.rs @@ -2,12 +2,46 @@ use std::collections::BTreeMap; -use tauri::{AppHandle, Emitter}; +use tauri::{AppHandle, Emitter, Manager}; use tauri_plugin_autostart::ManagerExt; +use tauri_plugin_notification::NotificationExt; use tauri_plugin_opener::OpenerExt; use crate::{config, logger, paths, sidecar}; +/// Post a native OS notification (macOS Notification Center / Windows toast / +/// Linux libnotify). Invoked by the injected notification bridge when a new +/// inbox message arrives while the window is backgrounded. +#[tauri::command] +pub fn notify_native(app: AppHandle, title: String, body: String) -> Result<(), String> { + logger::log_line("INFO", "native notification surfaced"); + app.notification() + .builder() + .title(title) + .body(body) + .show() + .map_err(|e| e.to_string()) +} + +/// Set (or clear, with `None`) the dock/taskbar unread badge. +#[tauri::command] +pub fn set_badge(app: AppHandle, count: Option) -> Result<(), String> { + if let Some(win) = app.get_webview_window("main") { + win.set_badge_count(count).map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// Ask the OS for notification authorization (no-op once the user has decided). +/// Called once when the Console loads, so the prompt appears in the foreground. +#[tauri::command] +pub fn notif_request_permission(app: AppHandle) -> Result<(), String> { + app.notification() + .request_permission() + .map(|_| ()) + .map_err(|e| e.to_string()) +} + #[tauri::command] pub fn get_config_snapshot() -> config::ConfigSnapshot { config::snapshot() diff --git a/apps/objectos-one/src-tauri/src/lib.rs b/apps/objectos-one/src-tauri/src/lib.rs index e5a016e..b77d3e5 100644 --- a/apps/objectos-one/src-tauri/src/lib.rs +++ b/apps/objectos-one/src-tauri/src/lib.rs @@ -46,6 +46,9 @@ pub fn run() { commands::open_data_dir, commands::autostart_get, commands::autostart_set, + commands::notify_native, + commands::set_badge, + commands::notif_request_permission, ]) .setup(|app| { let handle = app.handle().clone(); diff --git a/apps/objectos-one/src-tauri/src/windows.rs b/apps/objectos-one/src-tauri/src/windows.rs index d30ee3f..9082583 100644 --- a/apps/objectos-one/src-tauri/src/windows.rs +++ b/apps/objectos-one/src-tauri/src/windows.rs @@ -19,15 +19,23 @@ use crate::{logger::log_line, sidecar}; /// otherwise discard the splash page's listeners). const UPDATE_BANNER_JS: &str = include_str!("../assets/update-banner.js"); +/// Mirrors the Console's in-app inbox to native OS notifications when the +/// window is backgrounded (and keeps the dock badge in sync). Injected into +/// every page; it no-ops outside the Console and outside Tauri. +const NOTIFICATION_BRIDGE_JS: &str = include_str!("../assets/notification-bridge.js"); + pub fn build_main(app: &AppHandle) -> tauri::Result<()> { let app_handle = app.clone(); + // Combine the injected bootstrap scripts into one so both reliably run on + // every page the webview loads. + let bootstrap = format!("{UPDATE_BANNER_JS}\n{NOTIFICATION_BRIDGE_JS}"); let _win = WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into())) .title("ObjectOS") .inner_size(1280.0, 820.0) .min_inner_size(960.0, 600.0) .resizable(true) .center() - .initialization_script(UPDATE_BANNER_JS) + .initialization_script(bootstrap.as_str()) .on_navigation(move |url| { if is_external_link(url) { use tauri_plugin_opener::OpenerExt;