Skip to content

Commit 3ce8478

Browse files
os-zhuangclaude
andauthored
feat(one): bridge in-app notifications to native OS notifications (#25)
* feat(one): bridge in-app notifications to native OS notifications 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) <noreply@anthropic.com> * fix(one): route notification bridge through Rust commands 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) <noreply@anthropic.com> * chore(one): log when a native notification is surfaced 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 919000c commit 3ce8478

4 files changed

Lines changed: 191 additions & 2 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// Native OS notification bridge for ObjectOS One.
2+
//
3+
// Injected into every page of the main webview (alongside update-banner.js).
4+
// Activates only on the Console — where the in-app inbox lives — and only
5+
// inside Tauri. It polls the in-app inbox and, when the window is in the
6+
// background, surfaces each NEW message as a native OS notification (macOS
7+
// Notification Center / Windows toast / Linux libnotify) and badges the dock
8+
// with the count missed while away (cleared on focus).
9+
//
10+
// Source of truth is `sys_inbox_message` (ADR-0030 L5) — the same table the
11+
// Console bell reads. The dedicated `/api/v1/notifications` route isn't mounted
12+
// in this runtime, so we read the canonical inbox object via the data API.
13+
//
14+
// All native work goes through Rust commands (`notify_native`, `set_badge`,
15+
// `notif_request_permission`) via `core.invoke` — matching this app's pattern.
16+
// `withGlobalTauri` only exposes `core`/`event`, NOT the plugin/window JS APIs,
17+
// so we must not call `__TAURI__.notification`/`.window`/`.app` directly.
18+
(function () {
19+
if (window.__objectosNotifyBridge) return;
20+
// Only on the Console (its login/app routes live under /_console), and only
21+
// when the Tauri bridge is present (no-op in a plain browser).
22+
if (!/\/_console(\/|$)/.test(location.pathname)) return;
23+
if (!window.__TAURI__ || !window.__TAURI__.core) return;
24+
window.__objectosNotifyBridge = true;
25+
26+
var invoke = window.__TAURI__.core.invoke;
27+
var INBOX_URL = '/api/v1/data/sys_inbox_message?sort=-created_at&limit=25';
28+
var POLL_MS = 20000;
29+
var SEEN_KEY = '__objectos_notif_seen_v1';
30+
var MAX_SEEN = 500;
31+
32+
var baselined = false; // first successful poll adopts the backlog silently
33+
var inFlight = false;
34+
var fatal = false; // inbox object absent → stop
35+
var unseen = 0; // count surfaced while backgrounded, cleared on focus
36+
37+
function loadSeen() {
38+
try {
39+
return new Set(JSON.parse(localStorage.getItem(SEEN_KEY) || '[]'));
40+
} catch (_) {
41+
return new Set();
42+
}
43+
}
44+
function saveSeen(set) {
45+
try {
46+
var arr = Array.from(set);
47+
if (arr.length > MAX_SEEN) arr = arr.slice(arr.length - MAX_SEEN);
48+
localStorage.setItem(SEEN_KEY, JSON.stringify(arr));
49+
} catch (_) {}
50+
}
51+
var seen = loadSeen();
52+
53+
// Surface only when the user isn't actively looking at the window.
54+
function backgrounded() {
55+
return document.visibilityState === 'hidden' || !document.hasFocus();
56+
}
57+
58+
function notify(title, body) {
59+
try {
60+
return invoke('notify_native', { title: title || 'Notification', body: body || '' });
61+
} catch (_) {}
62+
}
63+
function setBadge(count) {
64+
try {
65+
return invoke('set_badge', { count: count > 0 ? count : null });
66+
} catch (_) {}
67+
}
68+
69+
// sys_inbox_message → the minimal shape we need for a toast.
70+
function view(row) {
71+
return {
72+
id: row && row.id,
73+
title: (row && row.title) || (row && row.topic) || 'Notification',
74+
body: (row && (row.body_md || row.body)) || '',
75+
createdAt: (row && row.created_at) || '',
76+
};
77+
}
78+
79+
async function poll() {
80+
if (fatal || inFlight) return;
81+
inFlight = true;
82+
try {
83+
var res = await fetch(INBOX_URL, {
84+
headers: { accept: 'application/json' },
85+
credentials: 'same-origin',
86+
});
87+
if (res.status === 401) return; // not signed in yet
88+
if (res.status === 404) { fatal = true; return; } // no inbox object
89+
if (!res.ok) return;
90+
91+
var json = await res.json();
92+
var rows = (json && (json.records || json.items || json.data)) || [];
93+
// Oldest-first so a burst toasts in chronological order.
94+
var list = rows
95+
.map(view)
96+
.filter(function (v) { return v.id; })
97+
.sort(function (a, b) {
98+
return String(a.createdAt).localeCompare(String(b.createdAt));
99+
});
100+
var fresh = list.filter(function (v) { return !seen.has(v.id); });
101+
if (!fresh.length) return;
102+
103+
if (!baselined) {
104+
// First poll after a (re)load: adopt existing messages as baseline so
105+
// we don't replay the backlog as a burst of toasts.
106+
fresh.forEach(function (v) { seen.add(v.id); });
107+
baselined = true;
108+
} else {
109+
var allowed = backgrounded();
110+
fresh.forEach(function (v) {
111+
seen.add(v.id);
112+
if (allowed) {
113+
notify(v.title, String(v.body).slice(0, 240));
114+
unseen += 1;
115+
}
116+
});
117+
if (allowed && unseen > 0) setBadge(unseen);
118+
}
119+
saveSeen(seen);
120+
} catch (_) {
121+
// transient (offline, mid-navigation) — retry next tick
122+
} finally {
123+
inFlight = false;
124+
}
125+
}
126+
127+
function onForeground() {
128+
// The user is looking now — clear the "missed while away" badge.
129+
unseen = 0;
130+
setBadge(0);
131+
poll();
132+
}
133+
134+
// Ask once up front, in the foreground, so the OS prompt isn't sprung from
135+
// the background.
136+
try { invoke('notif_request_permission'); } catch (_) {}
137+
138+
poll();
139+
setInterval(poll, POLL_MS);
140+
document.addEventListener('visibilitychange', function () {
141+
if (document.visibilityState === 'visible') onForeground();
142+
});
143+
window.addEventListener('focus', onForeground);
144+
})();

apps/objectos-one/src-tauri/src/commands.rs

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,46 @@
22
33
use std::collections::BTreeMap;
44

5-
use tauri::{AppHandle, Emitter};
5+
use tauri::{AppHandle, Emitter, Manager};
66
use tauri_plugin_autostart::ManagerExt;
7+
use tauri_plugin_notification::NotificationExt;
78
use tauri_plugin_opener::OpenerExt;
89

910
use crate::{config, logger, paths, sidecar};
1011

12+
/// Post a native OS notification (macOS Notification Center / Windows toast /
13+
/// Linux libnotify). Invoked by the injected notification bridge when a new
14+
/// inbox message arrives while the window is backgrounded.
15+
#[tauri::command]
16+
pub fn notify_native(app: AppHandle, title: String, body: String) -> Result<(), String> {
17+
logger::log_line("INFO", "native notification surfaced");
18+
app.notification()
19+
.builder()
20+
.title(title)
21+
.body(body)
22+
.show()
23+
.map_err(|e| e.to_string())
24+
}
25+
26+
/// Set (or clear, with `None`) the dock/taskbar unread badge.
27+
#[tauri::command]
28+
pub fn set_badge(app: AppHandle, count: Option<i64>) -> Result<(), String> {
29+
if let Some(win) = app.get_webview_window("main") {
30+
win.set_badge_count(count).map_err(|e| e.to_string())?;
31+
}
32+
Ok(())
33+
}
34+
35+
/// Ask the OS for notification authorization (no-op once the user has decided).
36+
/// Called once when the Console loads, so the prompt appears in the foreground.
37+
#[tauri::command]
38+
pub fn notif_request_permission(app: AppHandle) -> Result<(), String> {
39+
app.notification()
40+
.request_permission()
41+
.map(|_| ())
42+
.map_err(|e| e.to_string())
43+
}
44+
1145
#[tauri::command]
1246
pub fn get_config_snapshot() -> config::ConfigSnapshot {
1347
config::snapshot()

apps/objectos-one/src-tauri/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ pub fn run() {
4646
commands::open_data_dir,
4747
commands::autostart_get,
4848
commands::autostart_set,
49+
commands::notify_native,
50+
commands::set_badge,
51+
commands::notif_request_permission,
4952
])
5053
.setup(|app| {
5154
let handle = app.handle().clone();

apps/objectos-one/src-tauri/src/windows.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,23 @@ use crate::{logger::log_line, sidecar};
1919
/// otherwise discard the splash page's listeners).
2020
const UPDATE_BANNER_JS: &str = include_str!("../assets/update-banner.js");
2121

22+
/// Mirrors the Console's in-app inbox to native OS notifications when the
23+
/// window is backgrounded (and keeps the dock badge in sync). Injected into
24+
/// every page; it no-ops outside the Console and outside Tauri.
25+
const NOTIFICATION_BRIDGE_JS: &str = include_str!("../assets/notification-bridge.js");
26+
2227
pub fn build_main(app: &AppHandle) -> tauri::Result<()> {
2328
let app_handle = app.clone();
29+
// Combine the injected bootstrap scripts into one so both reliably run on
30+
// every page the webview loads.
31+
let bootstrap = format!("{UPDATE_BANNER_JS}\n{NOTIFICATION_BRIDGE_JS}");
2432
let _win = WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into()))
2533
.title("ObjectOS")
2634
.inner_size(1280.0, 820.0)
2735
.min_inner_size(960.0, 600.0)
2836
.resizable(true)
2937
.center()
30-
.initialization_script(UPDATE_BANNER_JS)
38+
.initialization_script(bootstrap.as_str())
3139
.on_navigation(move |url| {
3240
if is_external_link(url) {
3341
use tauri_plugin_opener::OpenerExt;

0 commit comments

Comments
 (0)