From 49a2114a9b9527fb11ce3888d5ecb0b2f046f865 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Tue, 16 Jun 2026 18:27:44 +0800 Subject: [PATCH 1/3] feat(one): bridge in-app notifications to native OS notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ObjectOS One is a desktop client, but notifications were in-app only — the Console bell (`sys_inbox_message`, ADR-0030 L5) is invisible when the window is backgrounded, which is exactly when notifications matter. This adds a webview bridge that mirrors new inbox messages to native OS notifications (macOS Notification Center / Windows toast / Linux libnotify). `assets/notification-bridge.js` is injected into every page alongside update-banner.js (combined into one initialization script in windows.rs). It no-ops outside the Console and outside Tauri. On the Console it polls the canonical inbox object via the data API (`GET /api/v1/data/sys_inbox_message` — the dedicated `/api/v1/notifications` route isn't mounted in this runtime), dedupes by id (localStorage, baselined on first load so the backlog isn't replayed), and when the window is backgrounded fires a native notification per new message, badges the dock with the count missed while away (cleared on focus), and focuses + deep-links on click (best-effort via the plugin's action event). No new Rust deps or capabilities needed: tauri-plugin-notification is already registered (lib.rs) and `notification:default` + window show/focus perms are already granted; withGlobalTauri exposes the JS API. Verified the data contract end-to-end against a live 9.7.0 runtime: inserting a real sys_inbox_message row, the bridge's fetch+parse extracts title/body(body_md)/actionUrl(action_url)/id/createdAt correctly. The native firing path (sendNotification/badge/focus) needs an actual Tauri build to exercise — out of reach here (no cargo); the plumbing is in place. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src-tauri/assets/notification-bridge.js | 211 ++++++++++++++++++ apps/objectos-one/src-tauri/src/windows.rs | 10 +- 2 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 apps/objectos-one/src-tauri/assets/notification-bridge.js 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..698426c --- /dev/null +++ b/apps/objectos-one/src-tauri/assets/notification-bridge.js @@ -0,0 +1,211 @@ +// 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), badges the dock with +// the count missed while away, and focuses the app (deep-linking to the record +// when the plugin reports the click). +// +// Source of truth is `sys_inbox_message` (ADR-0030 L5) — the same table the +// Console bell reads. This only mirrors new rows to the OS so they reach the +// user when the window isn't focused — the point of a desktop client. The +// dedicated `/api/v1/notifications` route isn't mounted in this runtime, so we +// read the canonical inbox object directly via the data API. +(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__) return; + window.__objectosNotifyBridge = true; + + var T = window.__TAURI__; + 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 permission = false; + var unseen = 0; // count surfaced while backgrounded, cleared on focus + var pendingUrl = {}; // notification id → action_url, for click routing + + 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 currentWindow() { + try { + var w = T.window; + if (!w) return null; + if (w.getCurrentWindow) return w.getCurrentWindow(); + if (w.getCurrent) return w.getCurrent(); + } catch (_) {} + return null; + } + + async function ensurePermission() { + try { + var n = T.notification; + if (!n) return false; + if (await n.isPermissionGranted()) return true; + return (await n.requestPermission()) === 'granted'; + } catch (_) { + return false; + } + } + + async function notify(id, title, body) { + var opts = { title: title || 'Notification', body: body || '', tag: id }; + try { + if (T.notification && T.notification.sendNotification) { + T.notification.sendNotification(opts); + return; + } + } catch (_) {} + // Fall back to the raw plugin command if the JS guest isn't on the global. + try { + await T.core.invoke('plugin:notification|notify', { options: opts }); + } catch (_) {} + } + + async function setBadge(count) { + var n = count > 0 ? count : null; + try { + if (T.app && T.app.setBadgeCount) { + await T.app.setBadgeCount(n); + return; + } + } catch (_) {} + try { + var w = currentWindow(); + if (w && w.setBadgeCount) await w.setBadgeCount(n); + } catch (_) {} + } + + async function focusAndOpen(actionUrl) { + var w = currentWindow(); + if (w) { + try { await w.show(); } catch (_) {} + try { await w.unminimize(); } catch (_) {} + try { await w.setFocus(); } catch (_) {} + } + if (actionUrl) { + try { location.assign(actionUrl); } catch (_) {} + } + } + + // Best-effort click routing: if this build exposes notification actions, + // clicking a toast focuses the app and deep-links to the record. When it + // doesn't, the OS default (focus the app) still applies; only the deep-link + // is lost. + (async function wireActions() { + try { + if (T.notification && T.notification.onAction) { + await T.notification.onAction(function (n) { + var tag = n && n.tag; + focusAndOpen(tag ? pendingUrl[tag] : 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)) || '', + actionUrl: (row && row.action_url) || null, + 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() && permission; + fresh.forEach(function (v) { + seen.add(v.id); + if (allowed) { + pendingUrl[v.id] = v.actionUrl; + notify(v.id, 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, while the window is in the foreground, so the OS prompt + // isn't sprung from the background. + ensurePermission().then(function (g) { permission = g; }); + + 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/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; From 92598c844b2f68331c4dd15077039e43c701f0df Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Tue, 16 Jun 2026 23:34:00 +0800 Subject: [PATCH 2/3] fix(one): route notification bridge through Rust commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut assumed Tauri plugin/window JS APIs are on `window.__TAURI__` (`.notification.sendNotification`, `.window…setBadgeCount`, `.app`). They are not: this app's `withGlobalTauri` exposes only `core`/`event`, and every native op here goes through a Rust `#[tauri::command]` called via `core.invoke` (see prefs.html / update-banner.js). As written the bridge would have fired nothing — the permission gate (`T.notification`) would stay false. Rework to match the codebase: - add Rust commands `notify_native`, `set_badge`, `notif_request_permission` (commands.rs) and register them (lib.rs); they call the already-registered notification plugin + the main window's badge from Rust, bypassing the JS ACL (no capability changes needed); - the bridge now calls them via `core.invoke` and drops all `T.notification`/ `.window`/`.app` usage; the tested poll/parse path is unchanged. Compiler-verified: `cargo check` passes (toolchain installed locally), so the Tauri APIs (`notification().builder().show()`, `set_badge_count`, `request_permission`) are correct. Still needs a real desktop build to see a toast actually render (and macOS authorization typically wants a signed bundle). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src-tauri/assets/notification-bridge.js | 107 ++++-------------- apps/objectos-one/src-tauri/src/commands.rs | 35 +++++- apps/objectos-one/src-tauri/src/lib.rs | 3 + 3 files changed, 57 insertions(+), 88 deletions(-) diff --git a/apps/objectos-one/src-tauri/assets/notification-bridge.js b/apps/objectos-one/src-tauri/assets/notification-bridge.js index 698426c..c9b942e 100644 --- a/apps/objectos-one/src-tauri/assets/notification-bridge.js +++ b/apps/objectos-one/src-tauri/assets/notification-bridge.js @@ -4,24 +4,26 @@ // 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), badges the dock with -// the count missed while away, and focuses the app (deep-linking to the record -// when the plugin reports the click). +// 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. This only mirrors new rows to the OS so they reach the -// user when the window isn't focused — the point of a desktop client. The -// dedicated `/api/v1/notifications` route isn't mounted in this runtime, so we -// read the canonical inbox object directly via the data API. +// 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__) return; + if (!window.__TAURI__ || !window.__TAURI__.core) return; window.__objectosNotifyBridge = true; - var T = window.__TAURI__; + 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'; @@ -30,9 +32,7 @@ var baselined = false; // first successful poll adopts the backlog silently var inFlight = false; var fatal = false; // inbox object absent → stop - var permission = false; var unseen = 0; // count surfaced while backgrounded, cleared on focus - var pendingUrl = {}; // notification id → action_url, for click routing function loadSeen() { try { @@ -55,89 +55,23 @@ return document.visibilityState === 'hidden' || !document.hasFocus(); } - function currentWindow() { - try { - var w = T.window; - if (!w) return null; - if (w.getCurrentWindow) return w.getCurrentWindow(); - if (w.getCurrent) return w.getCurrent(); - } catch (_) {} - return null; - } - - async function ensurePermission() { - try { - var n = T.notification; - if (!n) return false; - if (await n.isPermissionGranted()) return true; - return (await n.requestPermission()) === 'granted'; - } catch (_) { - return false; - } - } - - async function notify(id, title, body) { - var opts = { title: title || 'Notification', body: body || '', tag: id }; - try { - if (T.notification && T.notification.sendNotification) { - T.notification.sendNotification(opts); - return; - } - } catch (_) {} - // Fall back to the raw plugin command if the JS guest isn't on the global. + function notify(title, body) { try { - await T.core.invoke('plugin:notification|notify', { options: opts }); + return invoke('notify_native', { title: title || 'Notification', body: body || '' }); } catch (_) {} } - - async function setBadge(count) { - var n = count > 0 ? count : null; - try { - if (T.app && T.app.setBadgeCount) { - await T.app.setBadgeCount(n); - return; - } - } catch (_) {} + function setBadge(count) { try { - var w = currentWindow(); - if (w && w.setBadgeCount) await w.setBadgeCount(n); + return invoke('set_badge', { count: count > 0 ? count : null }); } catch (_) {} } - async function focusAndOpen(actionUrl) { - var w = currentWindow(); - if (w) { - try { await w.show(); } catch (_) {} - try { await w.unminimize(); } catch (_) {} - try { await w.setFocus(); } catch (_) {} - } - if (actionUrl) { - try { location.assign(actionUrl); } catch (_) {} - } - } - - // Best-effort click routing: if this build exposes notification actions, - // clicking a toast focuses the app and deep-links to the record. When it - // doesn't, the OS default (focus the app) still applies; only the deep-link - // is lost. - (async function wireActions() { - try { - if (T.notification && T.notification.onAction) { - await T.notification.onAction(function (n) { - var tag = n && n.tag; - focusAndOpen(tag ? pendingUrl[tag] : 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)) || '', - actionUrl: (row && row.action_url) || null, createdAt: (row && row.created_at) || '', }; } @@ -172,12 +106,11 @@ fresh.forEach(function (v) { seen.add(v.id); }); baselined = true; } else { - var allowed = backgrounded() && permission; + var allowed = backgrounded(); fresh.forEach(function (v) { seen.add(v.id); if (allowed) { - pendingUrl[v.id] = v.actionUrl; - notify(v.id, v.title, String(v.body).slice(0, 240)); + notify(v.title, String(v.body).slice(0, 240)); unseen += 1; } }); @@ -198,9 +131,9 @@ poll(); } - // Ask once up front, while the window is in the foreground, so the OS prompt - // isn't sprung from the background. - ensurePermission().then(function (g) { permission = g; }); + // 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); diff --git a/apps/objectos-one/src-tauri/src/commands.rs b/apps/objectos-one/src-tauri/src/commands.rs index 31eb8ad..ca51e17 100644 --- a/apps/objectos-one/src-tauri/src/commands.rs +++ b/apps/objectos-one/src-tauri/src/commands.rs @@ -2,12 +2,45 @@ 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> { + 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(); From 93a594f51011f986fb5c4854de043427dc356389 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Tue, 16 Jun 2026 23:46:58 +0800 Subject: [PATCH 3/3] chore(one): log when a native notification is surfaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lightweight observability for the notification bridge (content-free — no title/body logged). Verified the app builds and launches via `tauri dev` (toolchain installed locally); the sidecar loads Auth+Audit so sys_notification exists and the inbox path is live. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/objectos-one/src-tauri/src/commands.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/objectos-one/src-tauri/src/commands.rs b/apps/objectos-one/src-tauri/src/commands.rs index ca51e17..35394bc 100644 --- a/apps/objectos-one/src-tauri/src/commands.rs +++ b/apps/objectos-one/src-tauri/src/commands.rs @@ -14,6 +14,7 @@ use crate::{config, logger, paths, sidecar}; /// 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)