diff --git a/Cargo.lock b/Cargo.lock
index 1317d17c1..7f4936fcc 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -338,11 +338,15 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
name = "buzz-foundation"
version = "0.0.0"
dependencies = [
+ "block2",
"buzzodz-plugins",
"futures-lite",
"gtk",
"libc",
"mac-notification-sys",
+ "objc2",
+ "objc2-foundation",
+ "objc2-user-notifications",
"portable-pty",
"serde",
"tauri",
@@ -2508,6 +2512,8 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
dependencies = [
+ "bitflags 2.13.1",
+ "block2",
"objc2",
"objc2-foundation",
]
diff --git a/docs/notifications.md b/docs/notifications.md
index 3b1e4971f..b7bb0b4b0 100644
--- a/docs/notifications.md
+++ b/docs/notifications.md
@@ -73,8 +73,9 @@ mac-notification-sys on macOS, the freedesktop notification interface through
zbus on Linux, and tauri-winrt-notification on Windows. Linux uses the already
locked zbus dependency directly because notify-rust's send-then-listen wrapper
can lose early actions. No dependency upgrade or new native FFI is needed.
-Permission and sound remain system-controlled; no permission-only plugin
-or synthetic desktop permission prompt is installed. The main-window-only bridge
+Banner permission and sound remain system-controlled; no permission-only plugin
+or synthetic desktop notification is installed. The macOS Dock settings below
+provide an explicit system authorization action. The main-window-only bridge
carries display text and an opaque presentation ID, never an account, credential
or navigation destination. Its Tauri response channel is registered before native
submission.
@@ -94,9 +95,9 @@ framework's stale minimized-state focus guard. Compositor
focus policy still applies. Dismissal never navigates. Observable send/focus
failures reach Settings without retry; a focus error does not discard navigation.
-Desktop permission state is not observable through these backends. Settings
+Banner permission state is not observable through these backends. Settings
describes permission and sound as system-controlled, without ineffective desktop
-permission or sound controls. The bridge accepts a submission before waiting for
+banner permission or sound controls. The bridge accepts a submission before waiting for
interaction: acceptance is **not** proof that a visible banner appeared. The macOS
backend does not expose all delivery failures, and no uniform withdrawal/receipt
guarantee is promised.
@@ -116,3 +117,58 @@ For macOS, Windows and Linux, manual acceptance includes background and minimize
Buzz, two distinct message/thread targets, immediate banner click, banner fade
then Notification Center click, dismissal without navigation, and old-account or
revoked-access rejection. A macOS pass is not Windows/Linux acceptance.
+
+
+## macOS Dock unread badge
+
+The host projects one dot from the selected community's existing unread selectors:
+observed unread messages (including thread replies) or explicit channel-unread
+intent. It is not an exact message count or evidence of complete history. Unknown
+and observed-zero both omit the dot. Existing bounded evidence/read-state owns
+startup and updates; this projection adds no relay reads, network subscriptions,
+or storage. Personal space, account/session changes, access loss and host disposal
+clear or recompute the indicator. Disabling Channels does not stop host ownership.
+Desktop alert preferences do not alter this unread indicator.
+
+One ordered host writer calls a main-window-only command using Tauri's standard
+`set_badge_label` API. macOS draws the badge; no custom artwork is supplied.
+Windows, Linux and browsers have no shell unread indicator or badge Settings in
+this version, and do not bind the unread projection or invoke the Dock commands.
+Their existing banner behavior is unchanged. No taskbar overlay, tray icon/menu,
+new image assets or tray dependency is added.
+
+Observable setter failures appear in Settings; **Check Dock permission** retries
+using current unread intent. There is no automatic retry loop or claim of OS
+display acknowledgement.
+
+### macOS permission setup
+
+Settings → Notifications → Dock unread badge shows the actual macOS badge
+setting. **Allow notifications and badges** explicitly requests Alert, Sound and
+Badge for a fresh NotDetermined identity. **Set up Dock badges** explicitly requests
+Badge alone when an already Authorized identity reports NotSupported. Startup,
+focus, and **Check Dock permission** only read settings; they never register or
+repair permissions. Denied authorization and explicitly Disabled badges are never
+re-requested. macOS System Settings controls badge opt-out. Errors withhold the dot
+and are shown; a later focus or explicit check can retry a failed read.
+
+This permission capability requires an actual macOS `.app` bundle. Unbundled
+`tauri dev` never calls UserNotifications or borrows Terminal's badge permission.
+The native bridge is necessary because the official Tauri notification plugin's
+current desktop permission methods return Granted without querying these settings.
+Existing banner delivery/clicks and their acceptance limits above are unchanged.
+Windows and Linux do not use the macOS permission bridge or display its controls.
+
+### Validation boundary
+
+Tests use real relay/unread services for projection transitions, deferred native
+boundaries for ordering, default-adapter command dispatch, and mounted Settings
+controls for explicit setup. Native tests cover the authorization/setting matrix,
+no startup mutation, error recovery and rejection of unbundled framework calls.
+No browser journeys are added: these contracts are below the browser layer.
+
+These checks do not prove a visible Dock badge. Native macOS acceptance must
+exercise startup/arrival/read clearing, account/community/access changes, reload
+and exit under an isolated packaged identity. First permission, explicit
+missing-badge setup, deny/disable and legacy-banner interaction also need native
+acceptance. Distribution signing and packaged account support remain separate work.
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 8b52c5dc5..5e65fd95f 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -27,6 +27,10 @@ libc = "0.2"
[target.'cfg(target_os = "macos")'.dependencies]
mac-notification-sys = "=0.6.15"
+block2 = "=0.6.2"
+objc2 = "=0.6.4"
+objc2-foundation = { version = "=0.3.2", default-features = false, features = ["NSBundle", "NSString", "NSError"] }
+objc2-user-notifications = { version = "=0.3.2", default-features = false, features = ["UNUserNotificationCenter", "UNNotificationSettings", "block2"] }
[target.'cfg(target_os = "windows")'.dependencies]
tauri-winrt-notification = "=0.8.1"
diff --git a/src-tauri/src/dock.rs b/src-tauri/src/dock.rs
new file mode 100644
index 000000000..10a410970
--- /dev/null
+++ b/src-tauri/src/dock.rs
@@ -0,0 +1,371 @@
+//! Dock authorization is separate from the legacy banner delivery backend.
+use serde::Serialize;
+
+#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub(crate) enum Permission {
+ #[cfg(target_os = "macos")]
+ Default,
+ #[cfg(target_os = "macos")]
+ Setup,
+ #[cfg(target_os = "macos")]
+ Enabled,
+ #[cfg(target_os = "macos")]
+ Disabled,
+ #[cfg(target_os = "macos")]
+ Denied,
+ Unavailable,
+}
+
+#[tauri::command]
+pub(crate) fn unread_indicator_set(
+ window: tauri::WebviewWindow,
+ unread: bool,
+) -> Result<(), String> {
+ if window.label() != "main" {
+ return Err("Dock badges belong to the main window".into());
+ }
+ #[cfg(target_os = "macos")]
+ return window
+ .set_badge_label(unread.then(|| "•".into()))
+ .map_err(|e| e.to_string());
+ #[cfg(not(target_os = "macos"))]
+ {
+ let _ = unread;
+ Err("Dock badges are only available on macOS".into())
+ }
+}
+
+#[tauri::command]
+pub(crate) async fn dock_permission(
+ window: tauri::WebviewWindow,
+ request: bool,
+) -> Result {
+ if window.label() != "main" {
+ return Err("Dock badges belong to the main window".into());
+ }
+ #[cfg(target_os = "macos")]
+ return tauri::async_runtime::spawn_blocking(move || macos::permission(request))
+ .await
+ .map_err(|error| error.to_string())?;
+ #[cfg(not(target_os = "macos"))]
+ {
+ let _ = request;
+ Ok(Permission::Unavailable)
+ }
+}
+
+#[cfg(target_os = "macos")]
+mod macos {
+ use super::Permission;
+ use block2::RcBlock;
+ use objc2::runtime::Bool;
+ use objc2_foundation::{NSBundle, NSError};
+ use objc2_user_notifications::{
+ UNAuthorizationOptions as Options, UNAuthorizationStatus as Authorization,
+ UNNotificationSetting as Setting, UNNotificationSettings, UNUserNotificationCenter,
+ };
+ use std::{path::Path, ptr::NonNull, sync::mpsc, time::Duration};
+
+ fn bundled() -> bool {
+ let bundle = NSBundle::mainBundle();
+ bundle.bundleIdentifier().is_some()
+ && bundle.executablePath().is_some_and(|executable| {
+ bundle_layout(
+ Path::new(&bundle.bundlePath().to_string()),
+ Path::new(&executable.to_string()),
+ )
+ })
+ }
+ fn bundle_layout(bundle: &Path, executable: &Path) -> bool {
+ let Some(macos) = executable.parent() else {
+ return false;
+ };
+ let Some(contents) = macos.parent() else {
+ return false;
+ };
+ bundle.extension().is_some_and(|ext| ext == "app")
+ && macos.file_name() == Some("MacOS".as_ref())
+ && contents.file_name() == Some("Contents".as_ref())
+ && contents.parent() == Some(bundle)
+ }
+ fn settings() -> Result<(Authorization, Setting), String> {
+ // Calling UNUserNotificationCenter outside an app bundle raises an ObjC
+ // exception. Check actual identity/layout, including for debug bundles.
+ if !bundled() {
+ return Err("Dock permission requires a bundled macOS app".into());
+ }
+ let (tx, rx) = mpsc::sync_channel(1);
+ let handler = RcBlock::new(move |settings: NonNull| {
+ // SAFETY: Apple's completion parameter is valid for this callback.
+ let settings = unsafe { settings.as_ref() };
+ let _ = tx.send((settings.authorizationStatus(), settings.badgeSetting()));
+ });
+ UNUserNotificationCenter::currentNotificationCenter()
+ .getNotificationSettingsWithCompletionHandler(&handler);
+ rx.recv_timeout(Duration::from_secs(10))
+ .map_err(|_| "Dock settings request timed out".into())
+ }
+ fn authorize(options: Options) -> Result<(), String> {
+ if !bundled() {
+ return Err("Dock permission requires a bundled macOS app".into());
+ }
+ let (tx, rx) = mpsc::sync_channel(1);
+ let handler = RcBlock::new(move |_: Bool, error: *mut NSError| {
+ // SAFETY: Apple's optional error is valid for this callback.
+ let error = unsafe { error.as_ref() };
+ let _ = tx.send(error.map_or(Ok(()), |error| Err(error.to_string())));
+ });
+ UNUserNotificationCenter::currentNotificationCenter()
+ .requestAuthorizationWithOptions_completionHandler(options, &handler);
+ rx.recv_timeout(Duration::from_secs(60))
+ .map_err(|_| "Dock authorization request timed out".to_string())?
+ }
+ fn options(authorization: Authorization, badge: Setting, explicit: bool) -> Option {
+ if !explicit {
+ None
+ } else if authorization == Authorization::NotDetermined {
+ Some(Options::Alert | Options::Sound | Options::Badge)
+ } else if authorization == Authorization::Authorized && badge == Setting::NotSupported {
+ Some(Options::Badge)
+ } else {
+ None
+ }
+ }
+ fn project(authorization: Authorization, badge: Setting) -> Permission {
+ if authorization == Authorization::NotDetermined {
+ Permission::Default
+ } else if authorization == Authorization::Denied {
+ Permission::Denied
+ } else if authorization == Authorization::Authorized && badge == Setting::NotSupported {
+ Permission::Setup
+ } else if matches!(
+ authorization,
+ Authorization::Authorized | Authorization::Provisional | Authorization::Ephemeral
+ ) {
+ if badge == Setting::Enabled {
+ Permission::Enabled
+ } else if badge == Setting::Disabled {
+ Permission::Disabled
+ } else {
+ Permission::Unavailable
+ }
+ } else {
+ Permission::Unavailable
+ }
+ }
+ pub(super) fn permission(explicit: bool) -> Result {
+ if !bundled() {
+ return Ok(Permission::Unavailable);
+ }
+ check(explicit, settings, authorize)
+ }
+ fn check(
+ explicit: bool,
+ mut settings: impl FnMut() -> Result<(Authorization, Setting), String>,
+ mut authorize: impl FnMut(Options) -> Result<(), String>,
+ ) -> Result {
+ let (mut authorization, mut badge) = settings()?;
+ if let Some(options) = options(authorization, badge, explicit) {
+ authorize(options)?;
+ (authorization, badge) = settings()?;
+ }
+ Ok(project(authorization, badge))
+ }
+
+ #[cfg(test)]
+ mod tests {
+ use super::*;
+ #[test]
+ fn authorization_matrix_preserves_choices_and_only_prompts_on_explicit_action() {
+ for auth in [
+ Authorization::NotDetermined,
+ Authorization::Denied,
+ Authorization::Authorized,
+ Authorization::Provisional,
+ Authorization::Ephemeral,
+ ] {
+ for badge in [Setting::NotSupported, Setting::Disabled, Setting::Enabled] {
+ for explicit in [false, true] {
+ let expected = if explicit && auth == Authorization::NotDetermined {
+ Some(Options::Alert | Options::Sound | Options::Badge)
+ } else if explicit
+ && auth == Authorization::Authorized
+ && badge == Setting::NotSupported
+ {
+ Some(Options::Badge)
+ } else {
+ None
+ };
+ let mut reads = 0;
+ let mut requests = Vec::new();
+ let permission = check(
+ explicit,
+ || {
+ reads += 1;
+ Ok(if reads == 1 {
+ (auth, badge)
+ } else {
+ (Authorization::Authorized, Setting::Enabled)
+ })
+ },
+ |options| {
+ requests.push(options);
+ Ok(())
+ },
+ )
+ .unwrap();
+ assert_eq!(requests, expected.into_iter().collect::>());
+ assert_eq!(reads, if expected.is_some() { 2 } else { 1 });
+ assert_eq!(
+ permission,
+ if expected.is_some() {
+ Permission::Enabled
+ } else {
+ project(auth, badge)
+ }
+ );
+ assert_eq!(
+ project(auth, badge) == Permission::Enabled,
+ badge == Setting::Enabled
+ && matches!(
+ auth,
+ Authorization::Authorized
+ | Authorization::Provisional
+ | Authorization::Ephemeral
+ )
+ );
+ }
+ }
+ }
+ assert_eq!(
+ project(Authorization::Authorized, Setting::NotSupported),
+ Permission::Setup
+ );
+ assert_eq!(
+ project(Authorization::Authorized, Setting::Disabled),
+ Permission::Disabled
+ );
+ }
+ #[test]
+ fn failed_explicit_setup_remains_recoverable_without_startup_mutation() {
+ // Fail initial settings, authorization, then post-authorization settings.
+ for failure in 0..3 {
+ let mut reads = 0;
+ let mut requests = 0;
+ assert!(check(
+ true,
+ || {
+ reads += 1;
+ if (failure == 0 && reads == 1) || (failure == 2 && reads == 2) {
+ Err("settings failed".into())
+ } else {
+ Ok((Authorization::Authorized, Setting::NotSupported))
+ }
+ },
+ |_| {
+ requests += 1;
+ if failure == 1 {
+ Err("authorization failed".into())
+ } else {
+ Ok(())
+ }
+ }
+ )
+ .is_err());
+ assert_eq!(requests, if failure == 0 { 0 } else { 1 });
+ assert_eq!(
+ check(
+ false,
+ || Ok((Authorization::Authorized, Setting::NotSupported)),
+ |_| panic!("refresh must not repair permission")
+ )
+ .unwrap(),
+ Permission::Setup
+ );
+ let mut reads = 0;
+ assert_eq!(
+ check(
+ true,
+ || {
+ reads += 1;
+ Ok((
+ Authorization::Authorized,
+ if reads == 1 {
+ Setting::NotSupported
+ } else {
+ Setting::Enabled
+ },
+ ))
+ },
+ |options| {
+ assert_eq!(options, Options::Badge);
+ Ok(())
+ }
+ )
+ .unwrap(),
+ Permission::Enabled
+ );
+ assert_eq!(reads, 2);
+ }
+ }
+ #[test]
+ fn no_prompt_on_startup_then_explicit_action_uses_fresh_settings() {
+ assert_eq!(
+ check(
+ false,
+ || Ok((Authorization::NotDetermined, Setting::NotSupported)),
+ |_| panic!("startup must not request permission")
+ )
+ .unwrap(),
+ Permission::Default
+ );
+ let mut reads = 0;
+ assert_eq!(
+ check(
+ true,
+ || {
+ reads += 1;
+ Ok(if reads == 1 {
+ (Authorization::NotDetermined, Setting::NotSupported)
+ } else {
+ (Authorization::Denied, Setting::Disabled)
+ })
+ },
+ |options| {
+ assert_eq!(options, Options::Alert | Options::Sound | Options::Badge);
+ Ok(())
+ }
+ )
+ .unwrap(),
+ Permission::Denied
+ );
+ assert_eq!(reads, 2);
+ }
+ #[test]
+ fn unbundled_process_never_calls_notification_center() {
+ assert!(!bundled());
+ assert_eq!(permission(false).unwrap(), Permission::Unavailable);
+ assert_eq!(permission(true).unwrap(), Permission::Unavailable);
+ assert!(settings().is_err());
+ assert!(authorize(Options::Badge).is_err());
+ }
+ #[test]
+ fn bundle_requires_the_real_executable_layout() {
+ assert!(bundle_layout(
+ Path::new("/Applications/Buzz.app"),
+ Path::new("/Applications/Buzz.app/Contents/MacOS/Buzz")
+ ));
+ for path in [
+ "/Applications/Other.app/Contents/MacOS/Buzz",
+ "/Applications/Buzz.app/Buzz",
+ "/target/debug/Buzz",
+ ] {
+ assert!(!bundle_layout(
+ Path::new("/Applications/Buzz.app"),
+ Path::new(path)
+ ));
+ }
+ }
+ }
+}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index a2a84f1ca..9ea43e728 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -1,3 +1,5 @@
+mod dock;
+use dock::{dock_permission, unread_indicator_set};
mod notifications;
mod terminal;
use notifications::{notification_show, Notifications};
@@ -164,6 +166,8 @@ pub fn run() {
.manage(PluginManager(Manager::from_env()))
.invoke_handler(tauri::generate_handler![
notification_show,
+ dock_permission,
+ unread_indicator_set,
terminal_create_owner,
terminal_spawn,
terminal_read,
diff --git a/src/app/NotificationSettings.tsx b/src/app/NotificationSettings.tsx
index 706f6729f..2f6689c2f 100644
--- a/src/app/NotificationSettings.tsx
+++ b/src/app/NotificationSettings.tsx
@@ -1,3 +1,4 @@
+import { UnreadIndicatorSettings } from "./UnreadIndicatorSettings";
import { useSyncExternalStore } from "react";
import type { NotificationsService } from "../features/notifications/service";
@@ -104,6 +105,7 @@ export function NotificationSettings({
Message alerts cover the selected community while Buzz is running.
Reading history and reconnecting stay quiet.
+
{state.preferencesError && (
{state.preferencesError}
diff --git a/src/app/UnreadIndicatorSettings.test.tsx b/src/app/UnreadIndicatorSettings.test.tsx
new file mode 100644
index 000000000..e772d1724
--- /dev/null
+++ b/src/app/UnreadIndicatorSettings.test.tsx
@@ -0,0 +1,144 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+import { act, cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, expect, it, vi } from "vitest";
+import {
+ createUnreadIndicator,
+ type IndicatorPermission,
+} from "../features/notifications/indicator";
+import { UnreadIndicatorSettings } from "./UnreadIndicatorSettings";
+
+const native = vi.hoisted(() => ({ value: true }));
+const invoke = vi.hoisted(() => vi.fn());
+vi.mock("@tauri-apps/api/core", () => ({
+ isTauri: () => native.value,
+ invoke,
+}));
+
+const cleanups: (() => Promise
)[] = [];
+afterEach(async () => {
+ cleanup();
+ for (const stop of cleanups.splice(0)) await stop();
+ vi.unstubAllGlobals();
+ vi.clearAllMocks();
+ native.value = true;
+});
+function setup(initial: IndicatorPermission) {
+ const permission = vi.fn(async (): Promise => initial);
+ const dock = createUnreadIndicator({
+ permission,
+ set: vi.fn(async () => {}),
+ });
+ cleanups.push(dock.dispose);
+ render( );
+ return { permission, dock };
+}
+it("requests permission only through the explicit Settings button and updates status", async () => {
+ const h = setup("default");
+ await act(() => h.dock.refresh());
+ expect(h.permission.mock.calls).toEqual([[false]]);
+ expect(screen.getByText(/not a message count/)).toBeInTheDocument();
+ let resolve!: (permission: IndicatorPermission) => void;
+ h.permission.mockImplementationOnce(
+ () =>
+ new Promise((done) => {
+ resolve = done;
+ }),
+ );
+ const user = userEvent.setup();
+ await user.click(
+ screen.getByRole("button", { name: "Allow notifications and badges" }),
+ );
+ expect(
+ screen.getByRole("button", { name: "Allow notifications and badges" }),
+ ).toBeDisabled();
+ expect(
+ screen.getByText("Waiting for system permission…"),
+ ).toBeInTheDocument();
+ await act(async () => {
+ resolve("enabled");
+ await h.dock.refresh();
+ });
+ expect(
+ screen.getByText("Dock badges are allowed by macOS."),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "Allow notifications and badges" }),
+ ).not.toBeInTheDocument();
+});
+it.each(["disabled", "denied", "unavailable"] as const)(
+ "never offers a request over %s",
+ async (permission) => {
+ const h = setup(permission);
+ await act(() => h.dock.refresh());
+ expect(
+ screen.queryByRole("button", { name: "Allow notifications and badges" }),
+ ).not.toBeInTheDocument();
+ await userEvent
+ .setup()
+ .click(screen.getByRole("button", { name: "Check Dock permission" }));
+ expect(h.permission.mock.calls).toEqual([[false], [false]]);
+ },
+);
+
+it("offers missing-badge setup only as an explicit action", async () => {
+ const h = setup("setup");
+ await act(() => h.dock.refresh());
+ expect(h.permission.mock.calls).toEqual([[false]]);
+ expect(
+ screen.queryByRole("button", { name: "Allow notifications and badges" }),
+ ).not.toBeInTheDocument();
+ await userEvent
+ .setup()
+ .click(screen.getByRole("button", { name: "Set up Dock badges" }));
+ expect(h.permission.mock.calls).toEqual([[false], [true]]);
+});
+it.each([
+ [true, "Win32"],
+ [true, "Linux x86_64"],
+ [true, ""],
+ [false, "MacIntel"],
+] as const)(
+ "hides Dock settings and makes no IPC calls for native=%s platform=%s",
+ async (tauri, platform) => {
+ native.value = tauri;
+ vi.stubGlobal("navigator", { platform });
+ const indicator = createUnreadIndicator();
+ cleanups.push(indicator.dispose);
+ const { container } = render(
+ ,
+ );
+ await act(async () => {
+ indicator.setUnread(true);
+ await indicator.refresh();
+ await indicator.request();
+ await indicator.dispose();
+ });
+ expect(container).toBeEmptyDOMElement();
+ expect(invoke).not.toHaveBeenCalled();
+ },
+);
+
+it("shows a Dock write error and retries through the existing permission check", async () => {
+ const set = vi.fn(async (_unread: boolean) => {});
+ const indicator = createUnreadIndicator({
+ permission: async () => "enabled",
+ set,
+ });
+ cleanups.push(indicator.dispose);
+ render( );
+ await act(() => indicator.refresh());
+ set.mockRejectedValueOnce(new Error("Dock unavailable"));
+ await act(async () => {
+ indicator.setUnread(true);
+ });
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Dock unavailable",
+ );
+ await userEvent
+ .setup()
+ .click(screen.getByRole("button", { name: "Check Dock permission" }));
+ expect(set).toHaveBeenLastCalledWith(true);
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+});
diff --git a/src/app/UnreadIndicatorSettings.tsx b/src/app/UnreadIndicatorSettings.tsx
new file mode 100644
index 000000000..bd5c7e1f3
--- /dev/null
+++ b/src/app/UnreadIndicatorSettings.tsx
@@ -0,0 +1,62 @@
+import { useSyncExternalStore } from "react";
+import type { UnreadIndicator } from "../features/notifications/indicator";
+
+export function UnreadIndicatorSettings({
+ indicator,
+}: {
+ indicator: UnreadIndicator;
+}) {
+ const state = useSyncExternalStore(indicator.subscribe, indicator.snapshot);
+ if (!indicator.available) return null;
+ return (
+
+
Dock unread badge
+
+ A dot in the macOS Dock shows observed unread activity or a channel
+ marked unread in the selected community. It is not a message count.
+ Desktop alert choices do not change this badge.
+
+
+ {state.requesting
+ ? "Waiting for system permission…"
+ : {
+ default:
+ "Allow notifications and badges to show the unread dot in the Dock.",
+ setup: "Set up Dock badges to show the unread dot.",
+ enabled: "Dock badges are allowed by macOS.",
+ disabled:
+ "Badges are off. Change the badge setting in macOS System Settings.",
+ denied:
+ "Notifications are blocked. Allow them in macOS System Settings.",
+ unavailable:
+ "Dock permission is unavailable. Run the bundled macOS app to use badges.",
+ }[state.permission]}
+
+
+ {(state.permission === "default" || state.permission === "setup") && (
+ void indicator.request()}
+ >
+ {state.permission === "setup"
+ ? "Set up Dock badges"
+ : "Allow notifications and badges"}
+
+ )}
+ void indicator.refresh()}
+ >
+ Check Dock permission
+
+
+ {state.error && (
+
+ {state.error}
+
+ )}
+
+ );
+}
diff --git a/src/app/services.ts b/src/app/services.ts
index ed644fe58..e9477de95 100644
--- a/src/app/services.ts
+++ b/src/app/services.ts
@@ -1,4 +1,5 @@
// FOUNDATION: Compose the bundled distribution, plugin runtime, and services here.
+import { bindUnreadIndicator } from "../features/notifications/indicator-unread";
import { provideNavigation } from "../features/navigation/service";
import { NotificationsService } from "../features/notifications/service";
import {
@@ -41,6 +42,10 @@ export function createServices() {
(target) => notificationAuthorized(communities, target),
);
ctx.effect(() => bindMessageNotifications(notifications, communities));
+ if (notifications.indicator.available)
+ ctx.effect(() =>
+ bindUnreadIndicator(communities, notifications.indicator.setUnread),
+ );
let disposal: Promise | undefined;
return {
notifications,
diff --git a/src/features/notifications/desktop.test.ts b/src/features/notifications/desktop.test.ts
index 8955287f9..ee05871d4 100644
--- a/src/features/notifications/desktop.test.ts
+++ b/src/features/notifications/desktop.test.ts
@@ -1,7 +1,7 @@
import { Context } from "@deepseek-ai/cordis";
import { renderToStaticMarkup } from "react-dom/server";
import { createElement } from "react";
-import { afterEach, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { NotificationSettings } from "../../app/NotificationSettings";
import { PluginRuntime } from "../../plugins/runtime";
import { provideNavigation } from "../navigation/service";
@@ -10,16 +10,24 @@ import { NotificationsService } from "./service";
import { messageNotificationText } from "./content";
const sdk = vi.hoisted(() => ({
- invoke: vi.fn(async () => {}),
+ show: vi.fn(async (..._args: unknown[]) => {}),
+ permission: vi.fn(async (_args: unknown) => "enabled"),
+ indicator: vi.fn(async (_args: unknown) => {}),
}));
const native = vi.hoisted(() => ({ value: true }));
vi.mock("@tauri-apps/api/core", () => ({
isTauri: () => native.value,
- invoke: sdk.invoke,
+ invoke: (command: string, args: unknown) => {
+ if (command === "notification_show") return sdk.show(command, args);
+ if (command === "dock_permission") return sdk.permission(args);
+ if (command === "unread_indicator_set") return sdk.indicator(args);
+ throw new Error(`Unexpected native command: ${command}`);
+ },
Channel: class {
constructor(public onmessage: (response: unknown) => void) {}
},
}));
+beforeEach(() => vi.stubGlobal("navigator", { platform: "MacIntel" }));
vi.mock("react", async (original) => ({
...(await original()),
useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) =>
@@ -29,6 +37,7 @@ const contexts: Context[] = [];
afterEach(async () => {
for (const ctx of contexts.splice(0)) await ctx.fiber.dispose();
vi.resetAllMocks();
+ vi.unstubAllGlobals();
native.value = true;
});
function setup() {
@@ -65,7 +74,7 @@ it("the default service sends desktop banners via the native bridge and shared p
await submit("first");
await submit("first");
await flush();
- expect(sdk.invoke).toHaveBeenCalledExactlyOnceWith("notification_show", {
+ expect(sdk.show).toHaveBeenCalledExactlyOnceWith("notification_show", {
title: "Buzz",
body: "New mentions",
id: expect.any(String),
@@ -76,28 +85,28 @@ it("the default service sends desktop banners via the native bridge and shared p
service.updatePreferences({ enabled: true, categories: { mention: false } });
await submit("category-off");
await flush();
- expect(sdk.invoke).toHaveBeenCalledTimes(1);
+ expect(sdk.show).toHaveBeenCalledTimes(1);
});
-it("desktop permission remains system-managed without a permission RPC or shim", async () => {
+it("banner permission stays system-managed while Dock permission is queried separately", async () => {
const { service, submit } = setup();
await flush();
await service.refreshPermission();
await service.requestPermission();
expect(service.snapshot().permission).toBe("unknown");
- expect(sdk.invoke).not.toHaveBeenCalled();
+ expect(sdk.show).not.toHaveBeenCalled();
+ expect(sdk.permission).toHaveBeenCalledExactlyOnceWith({ request: false });
+ expect(service.indicator.snapshot().permission).toBe("enabled");
+ expect(sdk.indicator).toHaveBeenCalledExactlyOnceWith({ unread: false });
await submit("first");
await flush();
- expect(sdk.invoke).toHaveBeenCalledOnce();
- expect(sdk.invoke).toHaveBeenCalledWith(
- "notification_show",
- expect.anything(),
- );
+ expect(sdk.show).toHaveBeenCalledOnce();
+ expect(sdk.show).toHaveBeenCalledWith("notification_show", expect.anything());
});
it("observable SDK failures surface once without retry or a browser fallback", async () => {
const { service, submit } = setup();
- sdk.invoke.mockImplementationOnce(() => {
+ sdk.show.mockImplementationOnce(() => {
throw new Error("SDK unavailable");
});
await submit("failed");
@@ -105,10 +114,10 @@ it("observable SDK failures surface once without retry or a browser fallback", a
expect(service.snapshot().error).toBe("SDK unavailable");
await submit("failed");
await flush();
- expect(sdk.invoke).toHaveBeenCalledOnce();
+ expect(sdk.show).toHaveBeenCalledOnce();
await submit("next");
await flush();
- expect(sdk.invoke).toHaveBeenCalledTimes(2);
+ expect(sdk.show).toHaveBeenCalledTimes(2);
});
it("desktop settings explain OS sound and running-app exact clicks", async () => {
@@ -136,7 +145,7 @@ it("non-Tauri runs select the unchanged browser adapter, never the native SDK",
const platform = createNotifications();
expect(platform.label).toBe("Browser notifications");
expect(await platform.permission()).toBe("unsupported");
- expect(sdk.invoke).not.toHaveBeenCalled();
+ expect(sdk.show).not.toHaveBeenCalled();
});
it("the production desktop adapter forwards the message title and preview unchanged", async () => {
@@ -162,7 +171,7 @@ it("the production desktop adapter forwards the message title and preview unchan
),
);
await flush();
- expect(sdk.invoke).toHaveBeenCalledExactlyOnceWith("notification_show", {
+ expect(sdk.show).toHaveBeenCalledExactlyOnceWith("notification_show", {
title: "Pinky mentioned you in #Room",
body: "Hello Wes",
id: expect.any(String),
@@ -171,7 +180,7 @@ it("the production desktop adapter forwards the message title and preview unchan
});
function presentation(index = 0) {
- const call = sdk.invoke.mock.calls[index] as unknown as [
+ const call = sdk.show.mock.calls[index] as unknown as [
string,
{
id: string;
@@ -240,7 +249,7 @@ it("native close/error never opens or retries; focus failure still preserves exa
expect(service.snapshot().error).toBe("Window focus failed");
await service.refreshPermission();
await flush();
- expect(sdk.invoke).toHaveBeenCalledTimes(3);
+ expect(sdk.show).toHaveBeenCalledTimes(3);
});
it("account replacement and service disposal fence previously displayed native clicks", async () => {
@@ -265,7 +274,7 @@ it("the click channel exists before native submission, including immediate activ
const { navigation, submit } = setup();
const open = vi.fn();
navigation.subscribe(() => open(navigation.snapshot().entry.target));
- sdk.invoke.mockImplementationOnce(async (...args: unknown[]) => {
+ sdk.show.mockImplementationOnce(async (...args: unknown[]) => {
const { id, onEvent } = args[1] as ReturnType;
onEvent.onmessage({ id, kind: "activated" });
});
@@ -294,7 +303,7 @@ it("native presentation rejects at capacity before sending instead of evicting l
failed,
),
).rejects.toThrow("maximum 128");
- expect(sdk.invoke).toHaveBeenCalledTimes(128);
+ expect(sdk.show).toHaveBeenCalledTimes(128);
const first = presentation(0);
first.onEvent.onmessage({ id: first.id, kind: "activated" });
expect(activate).toHaveBeenCalledOnce();
@@ -303,6 +312,60 @@ it("native presentation rejects at capacity before sending instead of evicting l
activate,
failed,
);
- expect(sdk.invoke).toHaveBeenCalledTimes(129);
+ expect(sdk.show).toHaveBeenCalledTimes(129);
platform.dispose();
});
+
+it.each(["Win32", "Linux x86_64"])(
+ "%s has no Dock IPC or Settings and keeps existing banners",
+ async (platform) => {
+ vi.stubGlobal("navigator", { platform });
+ const { service, submit, ctx } = setup();
+ await service.indicator.refresh();
+ expect(service.indicator.available).toBe(false);
+ expect(
+ renderToStaticMarkup(
+ createElement(NotificationSettings, { notifications: service }),
+ ),
+ ).not.toContain("Dock unread badge");
+ service.indicator.setUnread(true);
+ await flush();
+ await service.indicator.request();
+ expect(sdk.permission).not.toHaveBeenCalled();
+ await submit("banner");
+ await flush();
+ expect(sdk.show).toHaveBeenCalledOnce();
+ await ctx.fiber.dispose();
+ expect(sdk.indicator).not.toHaveBeenCalled();
+ expect(sdk.permission).not.toHaveBeenCalled();
+ },
+);
+
+it("browser runs create no shell indicator", async () => {
+ native.value = false;
+ const { service, ctx } = setup();
+ service.indicator.setUnread(true);
+ await service.indicator.refresh();
+ await ctx.fiber.dispose();
+ expect(service.indicator.available).toBe(false);
+ expect(sdk.permission).not.toHaveBeenCalled();
+ expect(sdk.indicator).not.toHaveBeenCalled();
+});
+
+it("the macOS default binds explicit permission and ordered unread/clear commands", async () => {
+ sdk.permission.mockResolvedValueOnce("default");
+ const { service, ctx } = setup();
+ service.indicator.setUnread(true);
+ await service.indicator.refresh();
+ expect(sdk.indicator.mock.calls).toEqual([[{ unread: false }]]);
+ await service.indicator.request();
+ expect(sdk.permission.mock.calls).toEqual([
+ [{ request: false }],
+ [{ request: true }],
+ ]);
+ expect(sdk.indicator).toHaveBeenLastCalledWith({ unread: true });
+ service.updatePreferences({ enabled: false });
+ expect(sdk.indicator).toHaveBeenLastCalledWith({ unread: true });
+ await ctx.fiber.dispose();
+ expect(sdk.indicator).toHaveBeenLastCalledWith({ unread: false });
+});
diff --git a/src/features/notifications/indicator-unread.test.ts b/src/features/notifications/indicator-unread.test.ts
new file mode 100644
index 000000000..4550313d0
--- /dev/null
+++ b/src/features/notifications/indicator-unread.test.ts
@@ -0,0 +1,206 @@
+import { afterEach, expect, it, vi } from "vitest";
+import type { Communities } from "../communities/service";
+import { createRelaySession } from "../relay/session";
+import type { RelayEvent } from "../relay/events";
+import { readJournal, type ReadJournal } from "../relay/read-state-storage";
+import { keypair, message, metadata, roster, signed } from "../relay/testing";
+import { bindUnreadIndicator } from "./indicator-unread";
+
+const cleanups: (() => void)[] = [];
+afterEach(() => {
+ for (const stop of cleanups.splice(0).reverse()) stop();
+});
+function setup() {
+ const viewer = keypair(),
+ peer = keypair(),
+ relay = keypair();
+ let incoming = (_events: readonly RelayEvent[]) => {};
+ let journal: ReadJournal | undefined;
+ const query = vi.fn(async () => [] as RelayEvent[]);
+ const owner = createRelaySession(
+ {
+ viewer: viewer.pubkey,
+ relayAuthor: relay.pubkey,
+ query,
+ media: () => undefined,
+ readState: {
+ decode: async () => [],
+ sign: async () => {
+ throw new Error("No publication in this fixture");
+ },
+ publish: async () => {},
+ },
+ subscribe(callbacks) {
+ incoming = callbacks.receive;
+ return { update() {}, retry() {}, dispose() {} };
+ },
+ },
+ {
+ readStateStorage: {
+ async update(change) {
+ journal = readJournal(change(journal), viewer.pubkey);
+ return journal;
+ },
+ close() {},
+ },
+ // Local read intent is the contract; no publication lease is acquired.
+ readPublisherLock: async () => {},
+ },
+ );
+ cleanups.push(owner.dispose);
+ const client = {
+ viewer: viewer.pubkey as string | undefined,
+ selected: "https://one.example" as string | null,
+ memberships: [{ id: "https://one.example" }],
+ };
+ const relayState = {
+ status: "ready",
+ viewer: viewer.pubkey,
+ session: owner.session,
+ };
+ const listeners = new Set<() => void>();
+ const subscribe = (fn: () => void) => {
+ listeners.add(fn);
+ return () => {
+ listeners.delete(fn);
+ };
+ };
+ const communities = {
+ snapshot: () => client,
+ subscribe,
+ relay: { snapshot: () => relayState, subscribe },
+ } as unknown as Communities;
+ const project = vi.fn();
+ const bind = () => {
+ const stop = bindUnreadIndicator(communities, project);
+ cleanups.push(stop);
+ return stop;
+ };
+ return {
+ ...owner,
+ viewer,
+ peer,
+ relay,
+ client,
+ relayState,
+ project,
+ query,
+ bind,
+ notify() {
+ for (const fn of listeners) fn();
+ },
+ emit(events: readonly RelayEvent[]) {
+ incoming(events);
+ },
+ grant(id = "room", time = 10) {
+ incoming([
+ roster(relay, id, [viewer.pubkey], time),
+ metadata(relay, id, id, time),
+ ]);
+ },
+ };
+}
+const target = { kind: "channel", channelId: "room" } as const;
+it("starts clear for unknown evidence; projects arrivals and explicit read clearing without new reads", async () => {
+ const h = setup();
+ h.grant();
+ h.bind();
+ expect(h.project.mock.calls).toEqual([[false]]);
+ expect(h.session.unread.snapshot(target).observedCount).toBeNull();
+ const row = message(h.peer, "room", "arrival", 11);
+ h.emit([row]);
+ expect(h.project.mock.calls).toEqual([[false], [true]]);
+ await h.session.unread.markThrough(target, row.id);
+ expect(h.project.mock.calls).toEqual([[false], [true], [false]]);
+ expect(h.query).not.toHaveBeenCalled();
+});
+it("restores existing unread at binding startup, including thread-only activity and manual unread", async () => {
+ const h = setup();
+ h.grant();
+ const root = message(h.viewer, "room", "my thread", 11);
+ const reply = message(h.peer, "room", "reply", 12, [
+ ["e", root.id, "", "reply"],
+ ]);
+ h.emit([root, reply]);
+ h.bind();
+ expect(h.project).toHaveBeenLastCalledWith(true);
+ await h.session.unread.markThrough(
+ { kind: "thread", channelId: "room", rootId: root.id },
+ reply.id,
+ );
+ expect(h.project).toHaveBeenLastCalledWith(false);
+ await h.session.unread.markUnreadLocal(target);
+ expect(h.project).toHaveBeenLastCalledWith(true);
+ await h.session.unread.markThrough(target, root.id);
+ expect(h.project).toHaveBeenLastCalledWith(false);
+});
+it("uses all accessible channels, ignores own/auxiliary traffic, and clears on deletion or access loss", () => {
+ const h = setup();
+ h.bind();
+ h.grant();
+ h.emit([message(h.viewer, "room", "own", 11)]);
+ expect(h.project.mock.calls).toEqual([[false]]);
+ h.grant("second");
+ const row = message(h.peer, "second", "unread elsewhere", 12);
+ h.emit([row]);
+ expect(h.project).toHaveBeenLastCalledWith(true);
+ h.emit([
+ signed(h.peer, {
+ kind: 5,
+ content: "",
+ tags: [
+ ["h", "second"],
+ ["e", row.id],
+ ],
+ }),
+ ]);
+ expect(h.project).toHaveBeenLastCalledWith(false);
+ h.emit([message(h.peer, "room", "private", 13)]);
+ expect(h.project).toHaveBeenLastCalledWith(true);
+ h.emit([roster(h.relay, "room", [], 14)]);
+ expect(h.project).toHaveBeenLastCalledWith(false);
+});
+it.each(["personal", "viewer", "disconnected", "membership"])(
+ "clears and detaches old evidence on %s transition",
+ (transition) => {
+ const h = setup();
+ h.grant();
+ h.bind();
+ h.emit([message(h.peer, "room", "before switch", 11)]);
+ if (transition === "personal") h.client.selected = null;
+ if (transition === "viewer") h.client.viewer = keypair().pubkey;
+ if (transition === "disconnected") h.relayState.status = "disconnected";
+ if (transition === "membership") h.client.memberships = [];
+ h.notify();
+ expect(h.project).toHaveBeenLastCalledWith(false);
+ const calls = h.project.mock.calls.length;
+ h.emit([message(h.peer, "room", "retired session", 12)]);
+ expect(h.project).toHaveBeenCalledTimes(calls);
+ },
+);
+it("retargets an already-open community and ignores the prior session after switching and teardown", () => {
+ const h = setup(),
+ other = setup();
+ h.grant();
+ other.grant();
+ const stop = h.bind();
+ h.emit([message(h.peer, "room", "first community", 11)]);
+ h.client.selected = "https://two.example";
+ h.client.memberships.push({ id: h.client.selected });
+ h.client.viewer = other.viewer.pubkey;
+ h.relayState.viewer = other.viewer.pubkey;
+ h.relayState.session = other.session;
+ h.notify();
+ expect(h.project).toHaveBeenLastCalledWith(false);
+ h.emit([message(h.peer, "room", "background community", 12)]);
+ expect(h.project).toHaveBeenLastCalledWith(false);
+ other.emit([message(other.peer, "room", "selected community", 13)]);
+ expect(h.project).toHaveBeenLastCalledWith(true);
+ stop();
+ expect(h.project).toHaveBeenLastCalledWith(false);
+ const calls = h.project.mock.calls.length;
+ other.emit([message(other.peer, "room", "after stop", 14)]);
+ h.notify();
+ stop();
+ expect(h.project).toHaveBeenCalledTimes(calls);
+});
diff --git a/src/features/notifications/indicator-unread.ts b/src/features/notifications/indicator-unread.ts
new file mode 100644
index 000000000..cfcc32c4e
--- /dev/null
+++ b/src/features/notifications/indicator-unread.ts
@@ -0,0 +1,96 @@
+import type { Communities } from "../communities/service";
+import type { RelaySession } from "../relay/session";
+
+/** A host projection of existing evidence, never a count or a new read owner. */
+export function bindUnreadIndicator(
+ communities: Communities,
+ project: (unread: boolean) => void,
+) {
+ let closed = false;
+ let session: RelaySession | undefined;
+ let identity = "";
+ let previous: boolean | undefined;
+ let stopRoster = () => {};
+ const channels = new Map void>();
+ const publish = (unread: boolean) => {
+ if (unread === previous) return;
+ previous = unread;
+ project(unread);
+ };
+ const clear = () => {
+ stopRoster();
+ for (const stop of channels.values()) stop();
+ channels.clear();
+ };
+ const update = () => {
+ if (closed) return;
+ const client = communities.snapshot();
+ const relay = communities.relay.snapshot();
+ const next = `${client.viewer ?? ""}:${client.selected ?? ""}`;
+ const owned =
+ relay.status === "ready" &&
+ client.viewer &&
+ relay.viewer === client.viewer &&
+ client.memberships.some((item) => item.id === client.selected)
+ ? relay.session
+ : undefined;
+ if (session === owned && identity === next) return;
+ clear();
+ session = owned;
+ identity = next;
+ publish(false);
+ if (!owned) return;
+ const viewer = client.viewer as string;
+ const valid = () => !closed && session === owned && identity === next;
+ const changed = () => {
+ if (!valid()) return;
+ publish(
+ [...channels.keys()].some((channelId) => {
+ const snapshot = owned.unread.snapshot({
+ kind: "channel",
+ channelId,
+ });
+ return (
+ snapshot.manual !== "none" || (snapshot.observedCount ?? 0) > 0
+ );
+ }),
+ );
+ };
+ const roster = () => {
+ if (!valid()) return;
+ const ids = new Set(
+ owned.channels
+ .list()
+ .channels.filter((channel) => channel.members?.includes(viewer))
+ .map((channel) => channel.id),
+ );
+ for (const [id, stop] of channels) {
+ if (ids.has(id)) continue;
+ stop();
+ channels.delete(id);
+ }
+ for (const channelId of ids) {
+ if (!channels.has(channelId))
+ channels.set(
+ channelId,
+ owned.unread.subscribe({ kind: "channel", channelId }, changed),
+ );
+ }
+ changed();
+ };
+ stopRoster = owned.channels.subscribeList(roster);
+ roster();
+ };
+ const stop = communities.subscribe(update);
+ const stopRelay = communities.relay.subscribe(update);
+ update();
+ return () => {
+ if (closed) return;
+ closed = true;
+ stop();
+ stopRelay();
+ clear();
+ session = undefined;
+ publish(false);
+ };
+}
diff --git a/src/features/notifications/indicator.test.ts b/src/features/notifications/indicator.test.ts
new file mode 100644
index 000000000..a2c3ce7fb
--- /dev/null
+++ b/src/features/notifications/indicator.test.ts
@@ -0,0 +1,131 @@
+import { afterEach, expect, it, vi } from "vitest";
+import { createUnreadIndicator, type IndicatorPermission } from "./indicator";
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (error: Error) => void;
+ const promise = new Promise((yes, no) => {
+ resolve = yes;
+ reject = no;
+ });
+ return { promise, resolve, reject };
+}
+const cleanups: (() => Promise)[] = [];
+afterEach(async () => {
+ for (const stop of cleanups.splice(0)) await stop();
+});
+function setup(initial: IndicatorPermission = "enabled") {
+ const permission = vi.fn(
+ async (_request: boolean): Promise => initial,
+ );
+ const set = vi.fn(async (_unread: boolean) => {});
+ const host = new EventTarget();
+ const dock = createUnreadIndicator({ permission, set }, host);
+ cleanups.push(dock.dispose);
+ return { dock, set, permission, host };
+}
+it("silently checks on startup and projects unread after allow, independently of alert preferences", async () => {
+ const h = setup("default");
+ h.dock.setUnread(true);
+ await h.dock.refresh();
+ expect(h.permission.mock.calls).toEqual([[false]]);
+ expect(h.set.mock.calls).toEqual([[false]]);
+ h.permission.mockResolvedValueOnce("enabled");
+ await h.dock.request();
+ expect(h.permission.mock.calls).toEqual([[false], [true]]);
+ expect(h.set.mock.calls).toEqual([[false], [true]]);
+ h.dock.setUnread(false);
+ await h.dock.dispose();
+ expect(h.set).toHaveBeenLastCalledWith(false);
+});
+it.each(["default", "setup", "disabled", "denied", "unavailable"] as const)(
+ "withholds the dot for %s and clears on changed system permission",
+ async (permission) => {
+ const h = setup();
+ await h.dock.refresh();
+ h.dock.setUnread(true);
+ await vi.waitFor(() => expect(h.set).toHaveBeenLastCalledWith(true));
+ h.permission.mockResolvedValueOnce(permission);
+ await h.dock.refresh();
+ expect(h.set).toHaveBeenLastCalledWith(false);
+ h.dock.setUnread(true);
+ expect(h.set).toHaveBeenLastCalledWith(false);
+ },
+);
+it("refreshes on focus, fails closed, reports errors, and only retries on a later action", async () => {
+ const h = setup();
+ await h.dock.refresh();
+ h.dock.setUnread(true);
+ const count = h.permission.mock.calls.length;
+ h.permission.mockRejectedValueOnce(new Error("OS unavailable"));
+ h.host.dispatchEvent(new Event("focus"));
+ await h.dock.refresh();
+ expect(h.dock.snapshot()).toMatchObject({
+ permission: "unavailable",
+ error: "OS unavailable",
+ });
+ expect(h.set).toHaveBeenLastCalledWith(false);
+ expect(h.permission).toHaveBeenCalledTimes(count + 1);
+ await h.dock.refresh();
+ expect(h.dock.snapshot()).toMatchObject({
+ permission: "enabled",
+ error: null,
+ });
+ expect(h.set).toHaveBeenLastCalledWith(true);
+});
+it("coalesces rapid changes behind one pending native write and makes teardown clear win", async () => {
+ const h = setup();
+ await h.dock.refresh();
+ const pending = deferred();
+ h.set.mockImplementationOnce(() => pending.promise);
+ h.dock.setUnread(true);
+ expect(h.set.mock.calls).toEqual([[false], [true]]);
+ h.dock.setUnread(false);
+ h.dock.setUnread(true);
+ const closing = h.dock.dispose();
+ h.dock.setUnread(true);
+ expect(h.set.mock.calls).toEqual([[false], [true]]);
+ pending.resolve();
+ await closing;
+ expect(h.set.mock.calls).toEqual([[false], [true], [false]]);
+ const count = h.permission.mock.calls.length;
+ h.host.dispatchEvent(new Event("focus"));
+ await h.dock.refresh();
+ expect(h.permission).toHaveBeenCalledTimes(count);
+});
+it("late permission completion cannot revive a disposed badge", async () => {
+ const h = setup();
+ await h.dock.refresh();
+ const pending = deferred();
+ h.permission.mockImplementationOnce(() => pending.promise);
+ const check = h.dock.request();
+ h.dock.setUnread(true);
+ await h.dock.dispose();
+ pending.resolve("enabled");
+ await check;
+ expect(h.set).toHaveBeenLastCalledWith(false);
+});
+it("reports native write failure without a retry loop and accepts a later current-intent action", async () => {
+ const h = setup();
+ await h.dock.refresh();
+ h.set.mockRejectedValueOnce(new Error("Dock unavailable"));
+ h.dock.setUnread(true);
+ await vi.waitFor(() =>
+ expect(h.dock.snapshot().error).toBe("Dock unavailable"),
+ );
+ expect(h.set.mock.calls).toEqual([[false], [true]]);
+ await h.dock.refresh();
+ expect(h.set.mock.calls).toEqual([[false], [true], [true]]);
+});
+it("keeps an explicit request when a focus check was already pending", async () => {
+ const h = setup("default");
+ await h.dock.refresh();
+ const pending = deferred();
+ h.permission.mockImplementationOnce(() => pending.promise);
+ const check = h.dock.refresh();
+ const request = h.dock.request();
+ pending.resolve("default");
+ await check;
+ await request;
+ expect(h.permission.mock.calls).toEqual([[false], [false], [true]]);
+});
diff --git a/src/features/notifications/indicator.ts b/src/features/notifications/indicator.ts
new file mode 100644
index 000000000..4bef9fc8e
--- /dev/null
+++ b/src/features/notifications/indicator.ts
@@ -0,0 +1,129 @@
+import { invoke, isTauri } from "@tauri-apps/api/core";
+
+export type IndicatorPermission =
+ | "default"
+ | "setup"
+ | "enabled"
+ | "disabled"
+ | "denied"
+ | "unavailable";
+export interface IndicatorPlatform {
+ permission(request: boolean): Promise;
+ set(unread: boolean): Promise;
+}
+export function indicatorPlatform(): IndicatorPlatform | undefined {
+ const os = globalThis.navigator?.platform ?? "";
+ if (!isTauri() || !/Mac/i.test(os)) return;
+ return {
+ permission: (request) => invoke("dock_permission", { request }),
+ set: (unread) => invoke("unread_indicator_set", { unread }),
+ };
+}
+
+/** One ordered native projection. Pending work always converges on current intent. */
+export function createUnreadIndicator(
+ platform = indicatorPlatform(),
+ host:
+ | Pick
+ | undefined = typeof window === "undefined" ? undefined : window,
+) {
+ let closed = false,
+ unread = false;
+ let state = Object.freeze({
+ permission: "unavailable" as IndicatorPermission,
+ requesting: false as boolean,
+ error: null as string | null,
+ });
+ const listeners = new Set<() => void>();
+ let desired = false;
+ let applied: boolean | undefined;
+ let writing: Promise | undefined;
+ let checking: Promise | undefined;
+ const publish = (patch: Partial) => {
+ if (closed) return;
+ state = Object.freeze({ ...state, ...patch });
+ for (const listener of listeners) listener();
+ };
+ const failed = (error: unknown) =>
+ publish({ error: error instanceof Error ? error.message : String(error) });
+ function project() {
+ desired = !closed && unread && state.permission === "enabled";
+ if (!platform || writing || desired === applied) return;
+ let attempted = desired;
+ writing = (async () => {
+ while (applied !== desired) {
+ const next = desired;
+ attempted = next;
+ try {
+ await platform.set(next);
+ applied = next;
+ } catch (error) {
+ failed(error);
+ // No retry loop. A later user action or unread transition can retry.
+ if (desired === next) break;
+ }
+ }
+ })().finally(() => {
+ writing = undefined;
+ if (desired !== attempted) project();
+ });
+ }
+ function check(request: boolean): Promise {
+ if (closed || !platform) return Promise.resolve();
+ if (checking) {
+ return request && !state.requesting
+ ? checking.then(() => check(true))
+ : checking;
+ }
+ publish({ requesting: request, error: null });
+ checking = Promise.resolve()
+ .then(() => platform.permission(request))
+ .then((permission) => {
+ publish({ permission });
+ })
+ .catch((error: unknown) => {
+ publish({ permission: "unavailable" });
+ failed(error);
+ })
+ .finally(() => {
+ checking = undefined;
+ publish({ requesting: false });
+ project();
+ });
+ return checking;
+ }
+ const refresh = () => {
+ void check(false);
+ };
+ project(); // Clear an earlier frontend's unread state before observing any new state.
+ if (platform) {
+ host?.addEventListener("focus", refresh);
+ refresh();
+ }
+ return {
+ available: !!platform,
+ snapshot: () => state,
+ subscribe(listener: () => void) {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ refresh: () => check(false),
+ request: () => check(true),
+ setUnread(value: boolean) {
+ if (!closed) {
+ unread = value;
+ project();
+ }
+ },
+ async dispose() {
+ closed = true;
+ host?.removeEventListener("focus", refresh);
+ listeners.clear();
+ project();
+ while (writing) await writing;
+ },
+ };
+}
+export type UnreadIndicator = ReturnType;
diff --git a/src/features/notifications/service.ts b/src/features/notifications/service.ts
index 98c8b1c61..ff0758bc1 100644
--- a/src/features/notifications/service.ts
+++ b/src/features/notifications/service.ts
@@ -1,3 +1,4 @@
+import { createUnreadIndicator } from "./indicator";
import { Service, type Context } from "@deepseek-ai/cordis";
import { createContributions } from "../../plugins/contributions";
import { parseOpenTarget, type OpenTarget } from "../navigation/targets";
@@ -64,6 +65,7 @@ export type NotificationSnapshot = Readonly<{
/** Running-session delivery only: no notification journal, inbox, or recovery protocol. */
export class NotificationsService extends Service implements Notifications {
+ readonly indicator = createUnreadIndicator();
private readonly contributions;
private readonly listeners = new Set<() => void>();
private readonly pending = new Set();
@@ -131,6 +133,7 @@ export class NotificationsService extends Service implements Notifications {
this.listeners.clear();
if (typeof window !== "undefined")
window.removeEventListener("focus", refresh);
+ return this.indicator.dispose();
};
});
void this.refreshPermission();