Skip to content

Commit 92598c8

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

3 files changed

Lines changed: 57 additions & 88 deletions

File tree

apps/objectos-one/src-tauri/assets/notification-bridge.js

Lines changed: 20 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,26 @@
44
// Activates only on the Console — where the in-app inbox lives — and only
55
// inside Tauri. It polls the in-app inbox and, when the window is in the
66
// 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).
7+
// Notification Center / Windows toast / Linux libnotify) and badges the dock
8+
// with the count missed while away (cleared on focus).
109
//
1110
// 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.
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.
1618
(function () {
1719
if (window.__objectosNotifyBridge) return;
1820
// Only on the Console (its login/app routes live under /_console), and only
1921
// when the Tauri bridge is present (no-op in a plain browser).
2022
if (!/\/_console(\/|$)/.test(location.pathname)) return;
21-
if (!window.__TAURI__) return;
23+
if (!window.__TAURI__ || !window.__TAURI__.core) return;
2224
window.__objectosNotifyBridge = true;
2325

24-
var T = window.__TAURI__;
26+
var invoke = window.__TAURI__.core.invoke;
2527
var INBOX_URL = '/api/v1/data/sys_inbox_message?sort=-created_at&limit=25';
2628
var POLL_MS = 20000;
2729
var SEEN_KEY = '__objectos_notif_seen_v1';
@@ -30,9 +32,7 @@
3032
var baselined = false; // first successful poll adopts the backlog silently
3133
var inFlight = false;
3234
var fatal = false; // inbox object absent → stop
33-
var permission = false;
3435
var unseen = 0; // count surfaced while backgrounded, cleared on focus
35-
var pendingUrl = {}; // notification id → action_url, for click routing
3636

3737
function loadSeen() {
3838
try {
@@ -55,89 +55,23 @@
5555
return document.visibilityState === 'hidden' || !document.hasFocus();
5656
}
5757

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.
58+
function notify(title, body) {
8859
try {
89-
await T.core.invoke('plugin:notification|notify', { options: opts });
60+
return invoke('notify_native', { title: title || 'Notification', body: body || '' });
9061
} catch (_) {}
9162
}
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 (_) {}
63+
function setBadge(count) {
10164
try {
102-
var w = currentWindow();
103-
if (w && w.setBadgeCount) await w.setBadgeCount(n);
65+
return invoke('set_badge', { count: count > 0 ? count : null });
10466
} catch (_) {}
10567
}
10668

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-
13469
// sys_inbox_message → the minimal shape we need for a toast.
13570
function view(row) {
13671
return {
13772
id: row && row.id,
13873
title: (row && row.title) || (row && row.topic) || 'Notification',
13974
body: (row && (row.body_md || row.body)) || '',
140-
actionUrl: (row && row.action_url) || null,
14175
createdAt: (row && row.created_at) || '',
14276
};
14377
}
@@ -172,12 +106,11 @@
172106
fresh.forEach(function (v) { seen.add(v.id); });
173107
baselined = true;
174108
} else {
175-
var allowed = backgrounded() && permission;
109+
var allowed = backgrounded();
176110
fresh.forEach(function (v) {
177111
seen.add(v.id);
178112
if (allowed) {
179-
pendingUrl[v.id] = v.actionUrl;
180-
notify(v.id, v.title, String(v.body).slice(0, 240));
113+
notify(v.title, String(v.body).slice(0, 240));
181114
unseen += 1;
182115
}
183116
});
@@ -198,9 +131,9 @@
198131
poll();
199132
}
200133

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; });
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 (_) {}
204137

205138
poll();
206139
setInterval(poll, POLL_MS);

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,45 @@
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+
app.notification()
18+
.builder()
19+
.title(title)
20+
.body(body)
21+
.show()
22+
.map_err(|e| e.to_string())
23+
}
24+
25+
/// Set (or clear, with `None`) the dock/taskbar unread badge.
26+
#[tauri::command]
27+
pub fn set_badge(app: AppHandle, count: Option<i64>) -> Result<(), String> {
28+
if let Some(win) = app.get_webview_window("main") {
29+
win.set_badge_count(count).map_err(|e| e.to_string())?;
30+
}
31+
Ok(())
32+
}
33+
34+
/// Ask the OS for notification authorization (no-op once the user has decided).
35+
/// Called once when the Console loads, so the prompt appears in the foreground.
36+
#[tauri::command]
37+
pub fn notif_request_permission(app: AppHandle) -> Result<(), String> {
38+
app.notification()
39+
.request_permission()
40+
.map(|_| ())
41+
.map_err(|e| e.to_string())
42+
}
43+
1144
#[tauri::command]
1245
pub fn get_config_snapshot() -> config::ConfigSnapshot {
1346
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();

0 commit comments

Comments
 (0)