Skip to content

Commit 49a2114

Browse files
os-zhuangclaude
andcommitted
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>
1 parent f072cbb commit 49a2114

2 files changed

Lines changed: 220 additions & 1 deletion

File tree

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
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), badges the dock with
8+
// the count missed while away, and focuses the app (deep-linking to the record
9+
// when the plugin reports the click).
10+
//
11+
// Source of truth is `sys_inbox_message` (ADR-0030 L5) — the same table the
12+
// Console bell reads. This only mirrors new rows to the OS so they reach the
13+
// user when the window isn't focused — the point of a desktop client. The
14+
// dedicated `/api/v1/notifications` route isn't mounted in this runtime, so we
15+
// read the canonical inbox object directly via the data API.
16+
(function () {
17+
if (window.__objectosNotifyBridge) return;
18+
// Only on the Console (its login/app routes live under /_console), and only
19+
// when the Tauri bridge is present (no-op in a plain browser).
20+
if (!/\/_console(\/|$)/.test(location.pathname)) return;
21+
if (!window.__TAURI__) return;
22+
window.__objectosNotifyBridge = true;
23+
24+
var T = window.__TAURI__;
25+
var INBOX_URL = '/api/v1/data/sys_inbox_message?sort=-created_at&limit=25';
26+
var POLL_MS = 20000;
27+
var SEEN_KEY = '__objectos_notif_seen_v1';
28+
var MAX_SEEN = 500;
29+
30+
var baselined = false; // first successful poll adopts the backlog silently
31+
var inFlight = false;
32+
var fatal = false; // inbox object absent → stop
33+
var permission = false;
34+
var unseen = 0; // count surfaced while backgrounded, cleared on focus
35+
var pendingUrl = {}; // notification id → action_url, for click routing
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 currentWindow() {
59+
try {
60+
var w = T.window;
61+
if (!w) return null;
62+
if (w.getCurrentWindow) return w.getCurrentWindow();
63+
if (w.getCurrent) return w.getCurrent();
64+
} catch (_) {}
65+
return null;
66+
}
67+
68+
async function ensurePermission() {
69+
try {
70+
var n = T.notification;
71+
if (!n) return false;
72+
if (await n.isPermissionGranted()) return true;
73+
return (await n.requestPermission()) === 'granted';
74+
} catch (_) {
75+
return false;
76+
}
77+
}
78+
79+
async function notify(id, title, body) {
80+
var opts = { title: title || 'Notification', body: body || '', tag: id };
81+
try {
82+
if (T.notification && T.notification.sendNotification) {
83+
T.notification.sendNotification(opts);
84+
return;
85+
}
86+
} catch (_) {}
87+
// Fall back to the raw plugin command if the JS guest isn't on the global.
88+
try {
89+
await T.core.invoke('plugin:notification|notify', { options: opts });
90+
} catch (_) {}
91+
}
92+
93+
async function setBadge(count) {
94+
var n = count > 0 ? count : null;
95+
try {
96+
if (T.app && T.app.setBadgeCount) {
97+
await T.app.setBadgeCount(n);
98+
return;
99+
}
100+
} catch (_) {}
101+
try {
102+
var w = currentWindow();
103+
if (w && w.setBadgeCount) await w.setBadgeCount(n);
104+
} catch (_) {}
105+
}
106+
107+
async function focusAndOpen(actionUrl) {
108+
var w = currentWindow();
109+
if (w) {
110+
try { await w.show(); } catch (_) {}
111+
try { await w.unminimize(); } catch (_) {}
112+
try { await w.setFocus(); } catch (_) {}
113+
}
114+
if (actionUrl) {
115+
try { location.assign(actionUrl); } catch (_) {}
116+
}
117+
}
118+
119+
// Best-effort click routing: if this build exposes notification actions,
120+
// clicking a toast focuses the app and deep-links to the record. When it
121+
// doesn't, the OS default (focus the app) still applies; only the deep-link
122+
// is lost.
123+
(async function wireActions() {
124+
try {
125+
if (T.notification && T.notification.onAction) {
126+
await T.notification.onAction(function (n) {
127+
var tag = n && n.tag;
128+
focusAndOpen(tag ? pendingUrl[tag] : null);
129+
});
130+
}
131+
} catch (_) {}
132+
})();
133+
134+
// sys_inbox_message → the minimal shape we need for a toast.
135+
function view(row) {
136+
return {
137+
id: row && row.id,
138+
title: (row && row.title) || (row && row.topic) || 'Notification',
139+
body: (row && (row.body_md || row.body)) || '',
140+
actionUrl: (row && row.action_url) || null,
141+
createdAt: (row && row.created_at) || '',
142+
};
143+
}
144+
145+
async function poll() {
146+
if (fatal || inFlight) return;
147+
inFlight = true;
148+
try {
149+
var res = await fetch(INBOX_URL, {
150+
headers: { accept: 'application/json' },
151+
credentials: 'same-origin',
152+
});
153+
if (res.status === 401) return; // not signed in yet
154+
if (res.status === 404) { fatal = true; return; } // no inbox object
155+
if (!res.ok) return;
156+
157+
var json = await res.json();
158+
var rows = (json && (json.records || json.items || json.data)) || [];
159+
// Oldest-first so a burst toasts in chronological order.
160+
var list = rows
161+
.map(view)
162+
.filter(function (v) { return v.id; })
163+
.sort(function (a, b) {
164+
return String(a.createdAt).localeCompare(String(b.createdAt));
165+
});
166+
var fresh = list.filter(function (v) { return !seen.has(v.id); });
167+
if (!fresh.length) return;
168+
169+
if (!baselined) {
170+
// First poll after a (re)load: adopt existing messages as baseline so
171+
// we don't replay the backlog as a burst of toasts.
172+
fresh.forEach(function (v) { seen.add(v.id); });
173+
baselined = true;
174+
} else {
175+
var allowed = backgrounded() && permission;
176+
fresh.forEach(function (v) {
177+
seen.add(v.id);
178+
if (allowed) {
179+
pendingUrl[v.id] = v.actionUrl;
180+
notify(v.id, v.title, String(v.body).slice(0, 240));
181+
unseen += 1;
182+
}
183+
});
184+
if (allowed && unseen > 0) setBadge(unseen);
185+
}
186+
saveSeen(seen);
187+
} catch (_) {
188+
// transient (offline, mid-navigation) — retry next tick
189+
} finally {
190+
inFlight = false;
191+
}
192+
}
193+
194+
function onForeground() {
195+
// The user is looking now — clear the "missed while away" badge.
196+
unseen = 0;
197+
setBadge(0);
198+
poll();
199+
}
200+
201+
// Ask once up front, while the window is in the foreground, so the OS prompt
202+
// isn't sprung from the background.
203+
ensurePermission().then(function (g) { permission = g; });
204+
205+
poll();
206+
setInterval(poll, POLL_MS);
207+
document.addEventListener('visibilitychange', function () {
208+
if (document.visibilityState === 'visible') onForeground();
209+
});
210+
window.addEventListener('focus', onForeground);
211+
})();

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)