Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions apps/objectos-one/src-tauri/assets/notification-bridge.js
Original file line number Diff line number Diff line change
@@ -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);
})();
36 changes: 35 additions & 1 deletion apps/objectos-one/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i64>) -> 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()
Expand Down
3 changes: 3 additions & 0 deletions apps/objectos-one/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
10 changes: 9 additions & 1 deletion apps/objectos-one/src-tauri/src/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading