From ce1bf9ee8387025b7a1135b63cb5dfc5e81c918a Mon Sep 17 00:00:00 2001
From: Carl
<32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Date: Mon, 21 Sep 2026 12:20:15 -0600
Subject: [PATCH 1/6] feat(notifications): project selected-community unread to
macOS Dock
Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
---
Cargo.lock | 6 +
docs/notifications.md | 43 +++-
src-tauri/Cargo.toml | 4 +
src-tauri/capabilities/default.json | 1 +
src-tauri/src/dock.rs | 232 ++++++++++++++++++
src-tauri/src/lib.rs | 3 +
src/app/DockSettings.test.tsx | 70 ++++++
src/app/DockSettings.tsx | 54 ++++
src/app/NotificationSettings.tsx | 2 +
src/app/services.ts | 3 +
.../notifications/dock-unread.test.ts | 206 ++++++++++++++++
src/features/notifications/dock-unread.ts | 96 ++++++++
src/features/notifications/dock.test.ts | 131 ++++++++++
src/features/notifications/dock.ts | 128 ++++++++++
src/features/notifications/service.ts | 3 +
15 files changed, 978 insertions(+), 4 deletions(-)
create mode 100644 src-tauri/src/dock.rs
create mode 100644 src/app/DockSettings.test.tsx
create mode 100644 src/app/DockSettings.tsx
create mode 100644 src/features/notifications/dock-unread.test.ts
create mode 100644 src/features/notifications/dock-unread.ts
create mode 100644 src/features/notifications/dock.test.ts
create mode 100644 src/features/notifications/dock.ts
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..ac1351c6b 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,37 @@ 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 indicator
+
+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, subscriptions, or storage.
+Personal space, account/session changes, access loss and app disposal clear or
+recompute the indicator. Disabling Channels does not stop host ownership.
+
+Settings → Notifications → Dock unread indicator shows the macOS badge setting.
+A fresh permission prompt occurs only after **Allow notifications and badges**;
+it requests Alert, Sound and Badge together. The first native check per process
+can add Badge alone for an already Authorized installation whose badge setting
+is NotSupported. Denied authorization and explicitly Disabled badges are never
+re-requested. Focus and **Check Dock permission** refresh the current setting;
+errors withhold the dot and are shown, not automatically retried. Desktop alert
+preferences do not alter this unread indicator. macOS System Settings controls
+badge opt-out.
+
+This capability requires an actual macOS `.app` bundle. Unbundled `tauri dev` never
+calls UserNotifications or borrows Terminal's badge permission. Other desktop
+platforms and browsers have no Dock adapter. Existing banner delivery/clicks and
+their acceptance limits above are unchanged.
+
+Tests use real relay/unread services for projection transitions, deferred native
+boundaries for ordering, and mounted Settings controls for explicit permission
+intent. Native tests cover the authorization/setting matrix and reject unbundled
+framework calls. These are not proof of a visible Dock dot. Bundled acceptance
+must separately exercise first permission, Badge-only repair, deny/disable,
+startup/arrival/read clearing and switching with isolated identities; 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/capabilities/default.json b/src-tauri/capabilities/default.json
index 868e796cc..cb47cbcec 100644
--- a/src-tauri/capabilities/default.json
+++ b/src-tauri/capabilities/default.json
@@ -5,6 +5,7 @@
"windows": ["main"],
"permissions": [
"core:window:allow-start-dragging",
+ "core:window:allow-set-badge-label",
"core:window:allow-internal-toggle-maximize",
{
"identifier": "opener:allow-open-url",
diff --git a/src-tauri/src/dock.rs b/src-tauri/src/dock.rs
new file mode 100644
index 000000000..204780f6f
--- /dev/null
+++ b/src-tauri/src/dock.rs
@@ -0,0 +1,232 @@
+//! 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")]
+ Enabled,
+ #[cfg(target_os = "macos")]
+ Disabled,
+ #[cfg(target_os = "macos")]
+ Denied,
+ Unavailable,
+}
+
+#[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, Mutex},
+ time::Duration,
+ };
+
+ // One repair attempt per process; explicit permission requests never race it.
+ static CHECK: Mutex = Mutex::new(false);
+ 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,
+ repair: bool,
+ ) -> Option {
+ if explicit && authorization == Authorization::NotDetermined {
+ Some(Options::Alert | Options::Sound | Options::Badge)
+ } else if repair
+ && 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 matches!(
+ authorization,
+ Authorization::Authorized | Authorization::Provisional | Authorization::Ephemeral
+ ) {
+ if badge == Setting::Enabled {
+ Permission::Enabled
+ } else {
+ Permission::Disabled
+ }
+ } else {
+ Permission::Unavailable
+ }
+ }
+ pub(super) fn permission(explicit: bool) -> Result {
+ if !bundled() {
+ return Ok(Permission::Unavailable);
+ }
+ let mut repaired = CHECK
+ .try_lock()
+ .map_err(|_| "Dock permission check already in progress")?;
+ let (mut authorization, mut badge) = settings()?;
+ let repair = !*repaired;
+ *repaired = true;
+ if let Some(options) = options(authorization, badge, explicit, repair) {
+ 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] {
+ for repair in [false, true] {
+ let result = options(auth, badge, explicit, repair);
+ if explicit && auth == Authorization::NotDetermined {
+ assert_eq!(
+ result,
+ Some(Options::Alert | Options::Sound | Options::Badge)
+ );
+ } else if repair
+ && auth == Authorization::Authorized
+ && badge == Setting::NotSupported
+ {
+ assert_eq!(result, Some(Options::Badge));
+ } else {
+ assert_eq!(result, None);
+ }
+ assert_eq!(
+ project(auth, badge) == Permission::Enabled,
+ badge == Setting::Enabled
+ && matches!(
+ auth,
+ Authorization::Authorized
+ | Authorization::Provisional
+ | Authorization::Ephemeral
+ )
+ );
+ }
+ }
+ }
+ }
+ }
+ #[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..d86371ca2 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -1,3 +1,5 @@
+mod dock;
+use dock::dock_permission;
mod notifications;
mod terminal;
use notifications::{notification_show, Notifications};
@@ -164,6 +166,7 @@ pub fn run() {
.manage(PluginManager(Manager::from_env()))
.invoke_handler(tauri::generate_handler![
notification_show,
+ dock_permission,
terminal_create_owner,
terminal_spawn,
terminal_read,
diff --git a/src/app/DockSettings.test.tsx b/src/app/DockSettings.test.tsx
new file mode 100644
index 000000000..ed19eca7b
--- /dev/null
+++ b/src/app/DockSettings.test.tsx
@@ -0,0 +1,70 @@
+// @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 {
+ createDockBadge,
+ type DockPermission,
+} from "../features/notifications/dock";
+import { DockSettings } from "./DockSettings";
+
+const cleanups: (() => Promise)[] = [];
+afterEach(async () => {
+ cleanup();
+ for (const stop of cleanups.splice(0)) await stop();
+});
+function setup(initial: DockPermission) {
+ const permission = vi.fn(async (): Promise => initial);
+ const dock = createDockBadge({ 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: DockPermission) => 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]]);
+ },
+);
diff --git a/src/app/DockSettings.tsx b/src/app/DockSettings.tsx
new file mode 100644
index 000000000..5790a2267
--- /dev/null
+++ b/src/app/DockSettings.tsx
@@ -0,0 +1,54 @@
+import { useSyncExternalStore } from "react";
+import type { DockBadge } from "../features/notifications/dock";
+
+export function DockSettings({ dock }: { dock: DockBadge }) {
+ const state = useSyncExternalStore(dock.subscribe, dock.snapshot);
+ if (!dock.available) return null;
+ return (
+
+
Dock unread indicator
+
+ A dot 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 indicator.
+
+
+ {state.requesting
+ ? "Waiting for system permission…"
+ : {
+ default: "Allow notifications to enable the Dock indicator.",
+ 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.error && (
+
+ {state.error}
+
+ )}
+
+ );
+}
diff --git a/src/app/NotificationSettings.tsx b/src/app/NotificationSettings.tsx
index 706f6729f..2d286a30e 100644
--- a/src/app/NotificationSettings.tsx
+++ b/src/app/NotificationSettings.tsx
@@ -1,3 +1,4 @@
+import { DockSettings } from "./DockSettings";
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/services.ts b/src/app/services.ts
index ed644fe58..2c22d997d 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 { bindDockUnread } from "../features/notifications/dock-unread";
import { provideNavigation } from "../features/navigation/service";
import { NotificationsService } from "../features/notifications/service";
import {
@@ -41,6 +42,8 @@ export function createServices() {
(target) => notificationAuthorized(communities, target),
);
ctx.effect(() => bindMessageNotifications(notifications, communities));
+ if (notifications.dock.available)
+ ctx.effect(() => bindDockUnread(communities, notifications.dock.setUnread));
let disposal: Promise | undefined;
return {
notifications,
diff --git a/src/features/notifications/dock-unread.test.ts b/src/features/notifications/dock-unread.test.ts
new file mode 100644
index 000000000..17ee173ec
--- /dev/null
+++ b/src/features/notifications/dock-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 { bindDockUnread } from "./dock-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 = bindDockUnread(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/dock-unread.ts b/src/features/notifications/dock-unread.ts
new file mode 100644
index 000000000..01cdaf2e7
--- /dev/null
+++ b/src/features/notifications/dock-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 bindDockUnread(
+ 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/dock.test.ts b/src/features/notifications/dock.test.ts
new file mode 100644
index 000000000..74f6f472f
--- /dev/null
+++ b/src/features/notifications/dock.test.ts
@@ -0,0 +1,131 @@
+import { afterEach, expect, it, vi } from "vitest";
+import { createDockBadge, type DockPermission } from "./dock";
+
+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: DockPermission = "enabled") {
+ const permission = vi.fn(
+ async (_request: boolean): Promise => initial,
+ );
+ const set = vi.fn(async (_unread: boolean) => {});
+ const host = new EventTarget();
+ const dock = createDockBadge({ 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", "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/dock.ts b/src/features/notifications/dock.ts
new file mode 100644
index 000000000..f25858563
--- /dev/null
+++ b/src/features/notifications/dock.ts
@@ -0,0 +1,128 @@
+import { invoke, isTauri } from "@tauri-apps/api/core";
+import { getCurrentWindow } from "@tauri-apps/api/window";
+
+export type DockPermission =
+ | "default"
+ | "enabled"
+ | "disabled"
+ | "denied"
+ | "unavailable";
+export interface DockPlatform {
+ permission(request: boolean): Promise;
+ set(unread: boolean): Promise;
+}
+export function dockPlatform(): DockPlatform | undefined {
+ if (!isTauri() || !/Mac/i.test(globalThis.navigator?.platform ?? "")) return;
+ return {
+ permission: (request) => invoke("dock_permission", { request }),
+ set: (unread) => getCurrentWindow().setBadgeLabel(unread ? "•" : undefined),
+ };
+}
+
+/** One ordered native projection. Pending work always converges on current intent. */
+export function createDockBadge(
+ platform = dockPlatform(),
+ host:
+ | Pick
+ | undefined = typeof window === "undefined" ? undefined : window,
+) {
+ let closed = false,
+ unread = false;
+ let state = Object.freeze({
+ permission: "unavailable" as DockPermission,
+ requesting: false,
+ 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 label 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 DockBadge = ReturnType;
diff --git a/src/features/notifications/service.ts b/src/features/notifications/service.ts
index 98c8b1c61..240872369 100644
--- a/src/features/notifications/service.ts
+++ b/src/features/notifications/service.ts
@@ -1,3 +1,4 @@
+import { createDockBadge } from "./dock";
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 dock = createDockBadge();
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.dock.dispose();
};
});
void this.refreshPermission();
From 633ebe69c4633c6307c7ca3e450798a77cec45d4 Mon Sep 17 00:00:00 2001
From: Carl
<32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Date: Mon, 21 Sep 2026 12:26:10 -0600
Subject: [PATCH 2/6] fix(notifications): keep failed Dock repair recoverable
Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
---
docs/notifications.md | 5 +-
src-tauri/src/dock.rs | 142 ++++++++++++++++++++-
src/features/notifications/desktop.test.ts | 87 +++++++++----
3 files changed, 206 insertions(+), 28 deletions(-)
diff --git a/docs/notifications.md b/docs/notifications.md
index ac1351c6b..5b97adf9e 100644
--- a/docs/notifications.md
+++ b/docs/notifications.md
@@ -131,11 +131,12 @@ recompute the indicator. Disabling Channels does not stop host ownership.
Settings → Notifications → Dock unread indicator shows the macOS badge setting.
A fresh permission prompt occurs only after **Allow notifications and badges**;
-it requests Alert, Sound and Badge together. The first native check per process
+it requests Alert, Sound and Badge together. The first successful native check per process
can add Badge alone for an already Authorized installation whose badge setting
is NotSupported. Denied authorization and explicitly Disabled badges are never
re-requested. Focus and **Check Dock permission** refresh the current setting;
-errors withhold the dot and are shown, not automatically retried. Desktop alert
+errors withhold the dot and are shown. A later focus or explicit check can retry
+a failed check/repair; there is no retry loop. Desktop alert
preferences do not alter this unread indicator. macOS System Settings controls
badge opt-out.
diff --git a/src-tauri/src/dock.rs b/src-tauri/src/dock.rs
index 204780f6f..110d25c81 100644
--- a/src-tauri/src/dock.rs
+++ b/src-tauri/src/dock.rs
@@ -51,7 +51,8 @@ mod macos {
time::Duration,
};
- // One repair attempt per process; explicit permission requests never race it.
+ // One successful initial check per process; failures remain recoverable.
+ // Serialization also keeps explicit permission requests from racing repair.
static CHECK: Mutex = Mutex::new(false);
fn bundled() -> bool {
let bundle = NSBundle::mainBundle();
@@ -149,13 +150,20 @@ mod macos {
let mut repaired = CHECK
.try_lock()
.map_err(|_| "Dock permission check already in progress")?;
+ check(explicit, &mut repaired, settings, authorize)
+ }
+ fn check(
+ explicit: bool,
+ repaired: &mut bool,
+ mut settings: impl FnMut() -> Result<(Authorization, Setting), String>,
+ mut authorize: impl FnMut(Options) -> Result<(), String>,
+ ) -> Result {
let (mut authorization, mut badge) = settings()?;
- let repair = !*repaired;
- *repaired = true;
- if let Some(options) = options(authorization, badge, explicit, repair) {
+ if let Some(options) = options(authorization, badge, explicit, !*repaired) {
authorize(options)?;
(authorization, badge) = settings()?;
}
+ *repaired = true;
Ok(project(authorization, badge))
}
@@ -188,6 +196,37 @@ mod macos {
} else {
assert_eq!(result, None);
}
+ let mut checked = !repair;
+ let mut reads = 0;
+ let mut requests = Vec::new();
+ let permission = check(
+ explicit,
+ &mut checked,
+ || {
+ reads += 1;
+ Ok(if reads == 1 {
+ (auth, badge)
+ } else {
+ (Authorization::Authorized, Setting::Enabled)
+ })
+ },
+ |options| {
+ requests.push(options);
+ Ok(())
+ },
+ )
+ .unwrap();
+ assert_eq!(requests, result.into_iter().collect::>());
+ assert_eq!(reads, if result.is_some() { 2 } else { 1 });
+ assert!(checked);
+ assert_eq!(
+ permission,
+ if result.is_some() {
+ Permission::Enabled
+ } else {
+ project(auth, badge)
+ }
+ );
assert_eq!(
project(auth, badge) == Permission::Enabled,
badge == Setting::Enabled
@@ -204,6 +243,101 @@ mod macos {
}
}
#[test]
+ fn failed_repair_remains_recoverable_on_a_later_check() {
+ // Fail initial settings, authorization, then post-authorization settings.
+ for failure in 0..3 {
+ let mut checked = false;
+ let mut reads = 0;
+ let mut requests = 0;
+ assert!(check(
+ false,
+ &mut checked,
+ || {
+ 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!(!checked);
+ assert_eq!(requests, if failure == 0 { 0 } else { 1 });
+ let mut reads = 0;
+ let mut requested = Vec::new();
+ assert_eq!(
+ check(
+ false,
+ &mut checked,
+ || {
+ reads += 1;
+ Ok((
+ Authorization::Authorized,
+ if reads == 1 {
+ Setting::NotSupported
+ } else {
+ Setting::Enabled
+ },
+ ))
+ },
+ |options| {
+ requested.push(options);
+ Ok(())
+ }
+ )
+ .unwrap(),
+ Permission::Enabled
+ );
+ assert_eq!(requested, vec![Options::Badge]);
+ assert!(checked);
+ }
+ }
+ #[test]
+ fn no_prompt_on_startup_then_explicit_action_uses_fresh_settings() {
+ let mut checked = false;
+ assert_eq!(
+ check(
+ false,
+ &mut checked,
+ || Ok((Authorization::NotDetermined, Setting::NotSupported)),
+ |_| panic!("startup must not request permission")
+ )
+ .unwrap(),
+ Permission::Default
+ );
+ let mut reads = 0;
+ assert_eq!(
+ check(
+ true,
+ &mut checked,
+ || {
+ 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);
diff --git a/src/features/notifications/desktop.test.ts b/src/features/notifications/desktop.test.ts
index 8955287f9..039beb58c 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,26 @@ 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"),
+ badge: vi.fn(async (_label?: string) => {}),
}));
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);
+ throw new Error(`Unexpected native command: ${command}`);
+ },
Channel: class {
constructor(public onmessage: (response: unknown) => void) {}
},
}));
+vi.mock("@tauri-apps/api/window", () => ({
+ getCurrentWindow: () => ({ setBadgeLabel: sdk.badge }),
+}));
+beforeEach(() => vi.stubGlobal("navigator", { platform: "MacIntel" }));
vi.mock("react", async (original) => ({
...(await original()),
useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) =>
@@ -29,6 +39,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 +76,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 +87,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.dock.snapshot().permission).toBe("enabled");
+ expect(sdk.badge).toHaveBeenCalledExactlyOnceWith(undefined);
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 +116,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 +147,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 +173,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 +182,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 +251,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 +276,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 +305,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 +314,38 @@ 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 permission or setter calls",
+ async (platform) => {
+ vi.stubGlobal("navigator", { platform });
+ const { service, submit } = setup();
+ await submit("banner");
+ await flush();
+ expect(service.dock.available).toBe(false);
+ expect(sdk.permission).not.toHaveBeenCalled();
+ expect(sdk.badge).not.toHaveBeenCalled();
+ expect(sdk.show).toHaveBeenCalledOnce();
+ },
+);
+
+it("the macOS default binds explicit permission and exact Dock label/clear commands", async () => {
+ sdk.permission.mockResolvedValueOnce("default");
+ const { service, ctx } = setup();
+ service.dock.setUnread(true);
+ await service.dock.refresh();
+ expect(sdk.badge.mock.calls).toEqual([[undefined]]);
+ await service.dock.request();
+ expect(sdk.permission.mock.calls).toEqual([
+ [{ request: false }],
+ [{ request: true }],
+ ]);
+ expect(sdk.badge).toHaveBeenLastCalledWith("•");
+ service.updatePreferences({ enabled: false });
+ expect(sdk.badge).toHaveBeenLastCalledWith("•");
+ await ctx.fiber.dispose();
+ expect(sdk.badge).toHaveBeenLastCalledWith(undefined);
+});
From e76a64b9de3db7c34aee221f0d310edaf528bd22 Mon Sep 17 00:00:00 2001
From: Carl
<32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Date: Mon, 21 Sep 2026 12:28:50 -0600
Subject: [PATCH 3/6] fix(notifications): type mutable Dock request state
Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
---
src/features/notifications/dock.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/features/notifications/dock.ts b/src/features/notifications/dock.ts
index f25858563..45c687732 100644
--- a/src/features/notifications/dock.ts
+++ b/src/features/notifications/dock.ts
@@ -30,7 +30,7 @@ export function createDockBadge(
unread = false;
let state = Object.freeze({
permission: "unavailable" as DockPermission,
- requesting: false,
+ requesting: false as boolean,
error: null as string | null,
});
const listeners = new Set<() => void>();
From 0f4b564c5abec93f11f847665be98a96f3626018 Mon Sep 17 00:00:00 2001
From: Carl
<32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Date: Mon, 21 Sep 2026 13:14:29 -0600
Subject: [PATCH 4/6] feat(notifications): use Tauri unread surfaces across
desktop platforms
Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
---
docs/notifications.md | 81 +++++---
src-tauri/Cargo.toml | 1 +
src-tauri/capabilities/default.json | 1 -
src-tauri/icons/tray-unread.png | Bin 0 -> 1707 bytes
src-tauri/icons/tray.png | Bin 0 -> 1530 bytes
src-tauri/icons/unread-overlay.png | Bin 0 -> 1064 bytes
src-tauri/icons/unread-source/README.md | 38 ++++
src-tauri/icons/unread-source/tray-unread.svg | 1 +
src-tauri/icons/unread-source/tray.svg | 1 +
.../icons/unread-source/unread-overlay.svg | 1 +
src-tauri/src/dock.rs | 180 ++++++++----------
src-tauri/src/indicator.rs | 83 ++++++++
src-tauri/src/lib.rs | 2 +
src-tauri/src/notifications.rs | 2 +-
src/app/DockSettings.test.tsx | 70 -------
src/app/DockSettings.tsx | 54 ------
src/app/NotificationSettings.tsx | 4 +-
src/app/UnreadIndicatorSettings.test.tsx | 116 +++++++++++
src/app/UnreadIndicatorSettings.tsx | 75 ++++++++
src/app/services.ts | 8 +-
src/features/notifications/desktop.test.ts | 55 ++++--
...nread.test.ts => indicator-unread.test.ts} | 4 +-
.../{dock-unread.ts => indicator-unread.ts} | 2 +-
.../{dock.test.ts => indicator.test.ts} | 14 +-
.../notifications/{dock.ts => indicator.ts} | 34 ++--
src/features/notifications/service.ts | 6 +-
26 files changed, 535 insertions(+), 298 deletions(-)
create mode 100644 src-tauri/icons/tray-unread.png
create mode 100644 src-tauri/icons/tray.png
create mode 100644 src-tauri/icons/unread-overlay.png
create mode 100644 src-tauri/icons/unread-source/README.md
create mode 100644 src-tauri/icons/unread-source/tray-unread.svg
create mode 100644 src-tauri/icons/unread-source/tray.svg
create mode 100644 src-tauri/icons/unread-source/unread-overlay.svg
create mode 100644 src-tauri/src/indicator.rs
delete mode 100644 src/app/DockSettings.test.tsx
delete mode 100644 src/app/DockSettings.tsx
create mode 100644 src/app/UnreadIndicatorSettings.test.tsx
create mode 100644 src/app/UnreadIndicatorSettings.tsx
rename src/features/notifications/{dock-unread.test.ts => indicator-unread.test.ts} (98%)
rename src/features/notifications/{dock-unread.ts => indicator-unread.ts} (98%)
rename src/features/notifications/{dock.test.ts => indicator.test.ts} (90%)
rename src/features/notifications/{dock.ts => indicator.ts} (77%)
diff --git a/docs/notifications.md b/docs/notifications.md
index 5b97adf9e..e78d1351f 100644
--- a/docs/notifications.md
+++ b/docs/notifications.md
@@ -119,36 +119,67 @@ 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 indicator
+## Desktop unread indicator
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, subscriptions, or storage.
-Personal space, account/session changes, access loss and app disposal clear or
-recompute the indicator. Disabling Channels does not stop host ownership.
-
-Settings → Notifications → Dock unread indicator shows the macOS badge setting.
-A fresh permission prompt occurs only after **Allow notifications and badges**;
-it requests Alert, Sound and Badge together. The first successful native check per process
-can add Badge alone for an already Authorized installation whose badge setting
-is NotSupported. Denied authorization and explicitly Disabled badges are never
-re-requested. Focus and **Check Dock permission** refresh the current setting;
-errors withhold the dot and are shown. A later focus or explicit check can retry
-a failed check/repair; there is no retry loop. Desktop alert
-preferences do not alter this unread indicator. macOS System Settings controls
-badge opt-out.
-
-This capability requires an actual macOS `.app` bundle. Unbundled `tauri dev` never
-calls UserNotifications or borrows Terminal's badge permission. Other desktop
-platforms and browsers have no Dock adapter. Existing banner delivery/clicks and
-their acceptance limits above are unchanged.
+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
+platform APIs:
+
+- **macOS:** `set_badge_label` adds/removes the Dock dot.
+- **Windows:** `set_overlay_icon` adds/removes a dot overlay on the main window's
+ taskbar icon. This is not a numeric badge or an attention request.
+- **Linux:** `TrayIconBuilder` owns a single normal/unread tray icon for the app
+ process, with a **Show Buzz** menu action using existing foregrounding behavior.
+ The first setter creates it; frontend reloads reuse the same tray and handler.
+ Clearing restores the normal icon, including on frontend teardown; process exit
+ removes the tray. This does not introduce close-to-tray or background operation.
+ A compatible AppIndicator/system-tray host is required. Some desktops need an
+ extension, and an accepted setter call does not prove the tray is visible.
+ No numeric libunity badge, tooltip, or unsupported tray click callback is used.
+
+Tauri embeds the small Phosphor-based icons at compile time; there is no runtime
+image loader or separate frontend tray-resource lifecycle. Browsers have no shell
+indicator. Observable setter failures appear in Settings with explicit retry;
+there is no automatic retry loop or claim of OS display acknowledgement.
+
+### macOS permission setup
+
+Settings → Notifications → Desktop unread indicator 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, and mounted Settings controls for explicit permission
-intent. Native tests cover the authorization/setting matrix and reject unbundled
-framework calls. These are not proof of a visible Dock dot. Bundled acceptance
-must separately exercise first permission, Badge-only repair, deny/disable,
-startup/arrival/read clearing and switching with isolated identities; distribution
+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 shell indicator. Per-platform native acceptance
+must exercise startup/arrival/read clearing, account/community/access changes,
+reload and exit under an isolated packaged identity. macOS also needs first
+permission, explicit missing-badge setup, deny/disable and legacy-banner interaction;
+Windows needs taskbar overlay/clear; Linux needs tray host availability, normal vs
+unread artwork, reload reuse and minimized **Show Buzz** behavior. Distribution
signing and packaged account support remain separate work.
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 5e65fd95f..e377d404a 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -36,6 +36,7 @@ objc2-user-notifications = { version = "=0.3.2", default-features = false, featu
tauri-winrt-notification = "=0.8.1"
[target.'cfg(target_os = "linux")'.dependencies]
+tauri = { version = "2", features = ["tray-icon"] }
zbus = { version = "=5.19.0", default-features = false, features = ["async-io"] }
futures-lite = "=2.6.1"
gtk = "0.18"
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
index cb47cbcec..868e796cc 100644
--- a/src-tauri/capabilities/default.json
+++ b/src-tauri/capabilities/default.json
@@ -5,7 +5,6 @@
"windows": ["main"],
"permissions": [
"core:window:allow-start-dragging",
- "core:window:allow-set-badge-label",
"core:window:allow-internal-toggle-maximize",
{
"identifier": "opener:allow-open-url",
diff --git a/src-tauri/icons/tray-unread.png b/src-tauri/icons/tray-unread.png
new file mode 100644
index 0000000000000000000000000000000000000000..2f34a85004df9e604c62a8666569c5eeb63cc0e3
GIT binary patch
literal 1707
zcmV;c22}ZpP)7l-JElqCL`cuKDPOY1Ea%C7pEphqw_7pMB}C=
z_=md9#}bWG@DXQ3lbI+A*~XNqAS#cdP+Fjrw%>mDYx|X!7Ga6mmvip9_ndRj{oQlV
zy`LKY^Y>B#NOoyZeU<&Z!|t*Owp7~lBuVPucF$b_pi4|loRpN5l%1TMTtIoBZ1eJc
zuWlE|?{EQK@l~_QW_8@o&nbOB_lv@Ag7bEle$e#XQT*qnn(_3JsWSky44%orWooth
zl1imo!I|SIQ^-ayr+9TMIlgQ%nJW4YHs&8(bSv^{i4{%ttul-L{sp(9`i+bgnGkc(
z5dbxJ{0SHTi^0EV;9iuSqLo?SB%aIu#}Ll>?9RJJYpo+k*uw(g;xoA8TsjRPcZtSM
zcpH|=mauc`p4efC(VB!qNC4b*I(Pgd9U{pumD&O5*|X;xT=6ggw6htQ)Ujjrm^CXB
zqekgqG$Q0zaOFB;d(k
- A dot 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 indicator.
-
-
- {state.requesting
- ? "Waiting for system permission…"
- : {
- default: "Allow notifications to enable the Dock indicator.",
- 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.error && (
-
- {state.error}
-
- )}
-
- );
-}
diff --git a/src/app/NotificationSettings.tsx b/src/app/NotificationSettings.tsx
index 2d286a30e..2f6689c2f 100644
--- a/src/app/NotificationSettings.tsx
+++ b/src/app/NotificationSettings.tsx
@@ -1,4 +1,4 @@
-import { DockSettings } from "./DockSettings";
+import { UnreadIndicatorSettings } from "./UnreadIndicatorSettings";
import { useSyncExternalStore } from "react";
import type { NotificationsService } from "../features/notifications/service";
@@ -105,7 +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..9b339a420
--- /dev/null
+++ b/src/app/UnreadIndicatorSettings.test.tsx
@@ -0,0 +1,116 @@
+// @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 cleanups: (() => Promise)[] = [];
+afterEach(async () => {
+ cleanup();
+ for (const stop of cleanups.splice(0)) await stop();
+});
+function setup(initial: IndicatorPermission) {
+ const permission = vi.fn(async (): Promise => initial);
+ const dock = createUnreadIndicator({
+ macOS: true,
+ 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("Windows/Linux settings describe the surface without fake permission controls and offer write retry", async () => {
+ const set = vi.fn(async (_unread: boolean) => {});
+ const indicator = createUnreadIndicator({
+ permission: async () => "enabled",
+ set,
+ });
+ cleanups.push(indicator.dispose);
+ render();
+ await act(() => indicator.refresh());
+ expect(
+ screen.getByRole("heading", { name: "Desktop unread indicator" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(/system tray on supported Linux desktops/),
+ ).toBeInTheDocument();
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ set.mockRejectedValueOnce(new Error("Shell unavailable"));
+ await act(async () => {
+ indicator.setUnread(true);
+ });
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Shell unavailable",
+ );
+ await userEvent
+ .setup()
+ .click(screen.getByRole("button", { name: "Retry unread indicator" }));
+ 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..abc3d93f5
--- /dev/null
+++ b/src/app/UnreadIndicatorSettings.tsx
@@ -0,0 +1,75 @@
+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 (
+
+
Desktop unread indicator
+
+ A dot 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 indicator. The indicator appears in the Dock on macOS,
+ the taskbar on Windows, and the system tray on supported Linux desktops.
+
+ {indicator.macOS && (
+ <>
+
+ {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]}
+
- A dot 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 indicator. The indicator appears in the Dock on macOS,
- the taskbar on Windows, and the system tray on supported Linux desktops.
+ 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.
- {indicator.macOS && (
- <>
-
- {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.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]}
+