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(kiPqG8&9q9I`CS}m|-i4mtx#beW^?#Rf9 z#`f*8IDI++Sy@KJ#HcvMPusuy7L&4G^DBaS(^d^^ijLQRCDZ``nEJ)s`4D07aZ-{R zd-uj-#*8Sm@L12BX~yBhjks{Zj+z=bQd6UFD=WjCIdie#z;L`etQ(9y zbTW(n;@lDL%D*iWxK26vDYFt15@PsBY@|*hwOYdZ_1!RVppJ_)B0c>Xa&l}qbf^j2 zwplTM{&So>*~AQ}v3hk3=SgG_{c*PtAEr)%VZ>uh-}$=ChBqtl-c-}`w>}zF>hXgA z8fvv#QFyQT3__--M`F+*9SRCskek~Cr&DPC>~H}4_BGOO$IzjAtXL6)w+7e|6rdkEC8)mCHu6rbiBCi#KvNg;C4$`zPuhgcQzm*Ld`mfMPg#xCFn>Z zfFuf;6l+_hu(Y;@?v_5fr{P;>2?1A4t0CX+tM5;&YX)c}#PD_2;RUem#YjRYfM;=~BACbUp8KvY%1 zE^%=lv)SwpHYww@Gy~?&je^bQ#_{7#ie4PalP503$7>*7P{qIwv#qS^vn!i!EEaVa=Kt`3W9AT#GSd^w_nlJC-er;n@QlHZ-8gzrF~;?5X5i)Y>Y^ zV_(yDvkCwz@Aw>q)M!*JB+&yFFOJ6O(Gi$D*&si`IXOMBd9%1`dYH{FK0*yBEM(Dm z+~u}U9}V9G4IVyp!sQCAA!a~XOD%h|;XmLMg+k)dS-Z9Y7cbheaiayPskKK!xOT zxi(V1fK*Js6;`X8^$@{-L>!MO3gS1NnQ6p{6LGwP3H*5({q5~8T4 zrY7L10|3xXayp$0DLn_xPB;=EcI>dRHlAVCs(S3-Z^OEE7I`edrKQ!%FJfk9Jpsta z)Xd#~791}a0IS-3hd1K$V8?J{t5w3yo2@u@tQps@J7BkaJnQQvh!;^v#&c8qDR*B? z89?m-2y0bU)d^N=D*Hh~E(u3180nfqlo3;^HURtEl!{56A*r|eIT_HuvfCa}Mn@+v2^e;TO_ zMO^_9YW@p#FLL-700960@@Hqq00006Nkl1 BJ97X4 literal 0 HcmV?d00001 diff --git a/src-tauri/icons/tray.png b/src-tauri/icons/tray.png new file mode 100644 index 0000000000000000000000000000000000000000..991460f4c8131dbaded1c8b15fe19e6efdc4b428 GIT binary patch literal 1530 zcmVAyLHl0AqhAm(S(u0c^`_SGVLTRZDM~^!3;)}Xej7%ivo+$Q+wP;bfxw#HiRjYMI zMaAaL!!T}~nVa;Wy!;93>by97xEDKic(8Qo-#B%umlaU3e!YXCDmguIk58RW=dh@k z2Eb%8iN?!|8iXt_&&2rgW;8bT5*BTiNDvFEEc=#}g&d&6uZEI7}-5r#%&CPyUjhY&9b-I3&b}_IDF9|6)B>^x!iYzY0nqhi*qjEE+=&?-Pwt?@2SXV~m=h2l-xx~$BK#fxq5 zdP6vIqF2+4D|!4lh@2cF#0#n!IAOK5y}~O}UytJz$VbSH8#mZAVRt!>9BJg6V8nw50R)5k9%2Qw zy%g!qK~a>`6dH*~XX8dUE?w$Fb#*sNOFOV?RVU7#^+_L5@~Wy%{xLnl?c4gzg+ob6 zxO&&F4fci53l4=s-wG*M5rg2CmX;qWZ;~|V1JPV=w~FTG0BUPJXlfcjR+fS# zOKhx?{&dmcyLSU}I(97o$>dBMn%dghev7U&092^KV6d9u&! zHu*PQS!u_~lTLnux%~68$~+S%nxXJNMJ_mSk1Z4$#RA}W-`+rrt_oZyP)A1y%a(QG z!UZ41m$GP)UH(lkSYVTHMoUYOfWiRW?hqSMOVQNc-mbf99RRwifk5Cx%J4;Vl8OX~ zUAw&OjVD;Qt_ueac(G|yw_FQwWn~@mMXapsA^>?!KA!&l_L>w#u(r0go@A$%avt|(Q}gWP zBY=0<t^YCN9Nh%r& zfM~3riGLw~!sM?}jv;47cno!~bH1MPR|X_LjZ`M1X918b{;9gBIs5|v0RR7mn55VM g000I_L_t&o05Mo2U)d+HrT_o{07*qoM6N<$f-(Qry8r+H literal 0 HcmV?d00001 diff --git a/src-tauri/icons/unread-overlay.png b/src-tauri/icons/unread-overlay.png new file mode 100644 index 0000000000000000000000000000000000000000..a69aed56f0988a64fe76dac1910f1955cb5dab9c GIT binary patch literal 1064 zcmV+@1lRkCP)frgLQfWDCPn7p~ky&^QWL0nmxb+ zzC$x?0AhEr1L}|90+PO$H^jsrZ z`f&at>e%7j(%eR3txaYy=DY`nK@$z2GjD{lfw0cjK6+lj-#!ugCx^9M!$x!zdreki zq5%Z>#xr4*jBlpLryFRX-}Nh(gsL~zmYiuIuB8h5!QTobQ#TMLMdY`(&)qJ4=5o5> z&{Eq)T+1e5l`>a1;I9OEY^`^G!*QM~=+s)Q=tir7>3R)_ATPGock?hJ=+qK>sZT(& z+3#ONzxgzUFmY5%-9Q_1g_+F!pcB(!IXbn(Ud9c~=b!Ll?JLA4`&zmUR0;(gzY#nE zomyfq^$8SVUt#7IKIA)Z_@^*+&Ugw~=_BgO&MkQ)bps^`z6X+dj&rB@tUz-2F#Ine zu= zCe~~n&Ph5ON=u`~b4^L%nn}t;88r=X5KZtMn#{hM$$nmxcp`{W&WF*4^Z;5CWg>5r z7kT@RrNxoHv_9R8wNNQ%lFwMRlzskH+lkuMJW*rU052l(F2*gO7ietCtZ@_^>ql|> z7f|Y@a7w+roZ>SBX<6(vnlj7o^CrCb$~U9tJ@|H}+W==l!1n|H*Z;)xA;Q|#sP)iD zZ`c3}Amke|2>xEs0$erDO+#`JYE)q~G=~ka3Izs0K74-=4RXLs&;)v9*e{2F5<~)j zXfA>Q_zul!1Bx2OEZ`Y903tvDm?XpgTKI>+3t&-n&PJCTa8}4@-C6uE00030|8_gS i=>Px#21!IgR09CItwm7tXyF$C0000 diff --git a/src-tauri/icons/unread-source/tray.svg b/src-tauri/icons/unread-source/tray.svg new file mode 100644 index 000000000..4e42e35ec --- /dev/null +++ b/src-tauri/icons/unread-source/tray.svg @@ -0,0 +1 @@ + diff --git a/src-tauri/icons/unread-source/unread-overlay.svg b/src-tauri/icons/unread-source/unread-overlay.svg new file mode 100644 index 000000000..aa2d67e6a --- /dev/null +++ b/src-tauri/icons/unread-source/unread-overlay.svg @@ -0,0 +1 @@ + diff --git a/src-tauri/src/dock.rs b/src-tauri/src/dock.rs index 110d25c81..cd147e41b 100644 --- a/src-tauri/src/dock.rs +++ b/src-tauri/src/dock.rs @@ -7,6 +7,8 @@ pub(crate) enum Permission { #[cfg(target_os = "macos")] Default, #[cfg(target_os = "macos")] + Setup, + #[cfg(target_os = "macos")] Enabled, #[cfg(target_os = "macos")] Disabled, @@ -44,16 +46,8 @@ mod macos { UNAuthorizationOptions as Options, UNAuthorizationStatus as Authorization, UNNotificationSetting as Setting, UNNotificationSettings, UNUserNotificationCenter, }; - use std::{ - path::Path, - ptr::NonNull, - sync::{mpsc, Mutex}, - time::Duration, - }; + use std::{path::Path, ptr::NonNull, sync::mpsc, time::Duration}; - // 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(); bundle.bundleIdentifier().is_some() @@ -108,18 +102,12 @@ mod macos { 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 { + 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 repair - && authorization == Authorization::Authorized - && badge == Setting::NotSupported - { + } else if authorization == Authorization::Authorized && badge == Setting::NotSupported { Some(Options::Badge) } else { None @@ -130,14 +118,18 @@ mod macos { 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 { + } else if badge == Setting::Disabled { Permission::Disabled + } else { + Permission::Unavailable } } else { Permission::Unavailable @@ -147,23 +139,18 @@ mod macos { if !bundled() { return Ok(Permission::Unavailable); } - let mut repaired = CHECK - .try_lock() - .map_err(|_| "Dock permission check already in progress")?; - check(explicit, &mut repaired, settings, authorize) + check(explicit, 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()?; - if let Some(options) = options(authorization, badge, explicit, !*repaired) { + if let Some(options) = options(authorization, badge, explicit) { authorize(options)?; (authorization, badge) = settings()?; } - *repaired = true; Ok(project(authorization, badge)) } @@ -181,77 +168,74 @@ mod macos { ] { 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)); + 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 { - assert_eq!(result, None); + project(auth, badge) } - 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 - && matches!( - auth, - Authorization::Authorized - | Authorization::Provisional - | Authorization::Ephemeral - ) - ); - } + ); + 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_repair_remains_recoverable_on_a_later_check() { + fn failed_explicit_setup_remains_recoverable_without_startup_mutation() { // 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, + true, || { reads += 1; if (failure == 0 && reads == 1) || (failure == 2 && reads == 2) { @@ -270,14 +254,20 @@ mod macos { } ) .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, + || 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(( @@ -290,24 +280,21 @@ mod macos { )) }, |options| { - requested.push(options); + assert_eq!(options, Options::Badge); Ok(()) } ) .unwrap(), Permission::Enabled ); - assert_eq!(requested, vec![Options::Badge]); - assert!(checked); + assert_eq!(reads, 2); } } #[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") ) @@ -318,7 +305,6 @@ mod macos { assert_eq!( check( true, - &mut checked, || { reads += 1; Ok(if reads == 1 { diff --git a/src-tauri/src/indicator.rs b/src-tauri/src/indicator.rs new file mode 100644 index 000000000..7fca81468 --- /dev/null +++ b/src-tauri/src/indicator.rs @@ -0,0 +1,83 @@ +//! Tauri owns shell presentation; the host supplies one ordered unread boolean. +#[cfg(any(test, target_os = "windows"))] +const UNREAD: tauri::image::Image<'_> = tauri::include_image!("icons/unread-overlay.png"); +#[cfg(any(test, target_os = "linux"))] +const TRAY: tauri::image::Image<'_> = tauri::include_image!("icons/tray.png"); +#[cfg(any(test, target_os = "linux"))] +const TRAY_UNREAD: tauri::image::Image<'_> = tauri::include_image!("icons/tray-unread.png"); + +#[tauri::command] +pub(crate) fn unread_indicator_set( + window: tauri::WebviewWindow, + unread: bool, +) -> Result<(), String> { + if window.label() != "main" { + return Err("Unread indicators 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(target_os = "windows")] + return window + .set_overlay_icon(unread.then(|| UNREAD.clone())) + .map_err(|e| e.to_string()); + #[cfg(target_os = "linux")] + return linux_set(&window, unread).map_err(|e| e.to_string()); + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + let _ = unread; + Err("Unread indicators are unavailable on this platform".into()) + } +} + +#[cfg(target_os = "linux")] +fn linux_set(window: &tauri::WebviewWindow, unread: bool) -> tauri::Result<()> { + use tauri::{ + menu::{Menu, MenuItem}, + tray::TrayIconBuilder, + Manager, + }; + let app = window.app_handle(); + let icon = if unread { + TRAY_UNREAD.clone() + } else { + TRAY.clone() + }; + if let Some(tray) = app.tray_by_id("unread-indicator") { + return tray.set_icon(Some(icon)); + } + // Synchronous Tauri commands execute on the main thread. Tauri retains the + // tray for the app's lifetime: frontend reloads reuse it and its menu handler. + // No click handler/tooltip: Linux only guarantees the tray's menu interface. + let show = MenuItem::with_id(app, "unread-show", "Show Buzz", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&show])?; + TrayIconBuilder::with_id("unread-indicator") + .icon(icon) + .menu(&menu) + .on_menu_event(|app, event| { + if event.id().as_ref() != "unread-show" { + return; + } + if let Some(window) = app.get_webview_window("main") { + if let Err(error) = crate::notifications::focus(&window) { + eprintln!("Show Buzz failed: {error}"); + } + } + }) + .build(app)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn embedded_icons_are_small_rgba_and_unread_adds_a_shape() { + for icon in [&UNREAD, &TRAY, &TRAY_UNREAD] { + assert_eq!((icon.width(), icon.height()), (32, 32)); + assert_eq!(icon.rgba().len(), 32 * 32 * 4); + } + assert_ne!(TRAY.rgba(), TRAY_UNREAD.rgba()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d86371ca2..999f5c215 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod dock; +mod indicator; use dock::dock_permission; mod notifications; mod terminal; @@ -167,6 +168,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ notification_show, dock_permission, + indicator::unread_indicator_set, terminal_create_owner, terminal_spawn, terminal_read, diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs index 678902486..2e376dc84 100644 --- a/src-tauri/src/notifications.rs +++ b/src-tauri/src/notifications.rs @@ -99,7 +99,7 @@ fn respond(app: tauri::AppHandle, channel: Channel, id: String, outcom } } -fn focus(window: &tauri::WebviewWindow) -> Result<(), String> { +pub(crate) fn focus(window: &tauri::WebviewWindow) -> Result<(), String> { #[cfg(target_os = "linux")] { use gtk::prelude::GtkWindowExt; diff --git a/src/app/DockSettings.test.tsx b/src/app/DockSettings.test.tsx deleted file mode 100644 index ed19eca7b..000000000 --- a/src/app/DockSettings.test.tsx +++ /dev/null @@ -1,70 +0,0 @@ -// @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 deleted file mode 100644 index 5790a2267..000000000 --- a/src/app/DockSettings.tsx +++ /dev/null @@ -1,54 +0,0 @@ -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 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]} +

+
+ {(state.permission === "default" || + state.permission === "setup") && ( + + )} + +
+ + )} + {state.error && ( +
+

+ {state.error} +

+ {!indicator.macOS && ( + + )} +
+ )} +
+ ); +} diff --git a/src/app/services.ts b/src/app/services.ts index 2c22d997d..e9477de95 100644 --- a/src/app/services.ts +++ b/src/app/services.ts @@ -1,5 +1,5 @@ // FOUNDATION: Compose the bundled distribution, plugin runtime, and services here. -import { bindDockUnread } from "../features/notifications/dock-unread"; +import { bindUnreadIndicator } from "../features/notifications/indicator-unread"; import { provideNavigation } from "../features/navigation/service"; import { NotificationsService } from "../features/notifications/service"; import { @@ -42,8 +42,10 @@ export function createServices() { (target) => notificationAuthorized(communities, target), ); ctx.effect(() => bindMessageNotifications(notifications, communities)); - if (notifications.dock.available) - ctx.effect(() => bindDockUnread(communities, notifications.dock.setUnread)); + 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 039beb58c..8dff345ab 100644 --- a/src/features/notifications/desktop.test.ts +++ b/src/features/notifications/desktop.test.ts @@ -12,7 +12,7 @@ import { messageNotificationText } from "./content"; const sdk = vi.hoisted(() => ({ show: vi.fn(async (..._args: unknown[]) => {}), permission: vi.fn(async (_args: unknown) => "enabled"), - badge: vi.fn(async (_label?: string) => {}), + indicator: vi.fn(async (_args: unknown) => {}), })); const native = vi.hoisted(() => ({ value: true })); vi.mock("@tauri-apps/api/core", () => ({ @@ -20,15 +20,13 @@ vi.mock("@tauri-apps/api/core", () => ({ 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) {} }, })); -vi.mock("@tauri-apps/api/window", () => ({ - getCurrentWindow: () => ({ setBadgeLabel: sdk.badge }), -})); beforeEach(() => vi.stubGlobal("navigator", { platform: "MacIntel" })); vi.mock("react", async (original) => ({ ...(await original()), @@ -98,8 +96,8 @@ it("banner permission stays system-managed while Dock permission is queried sepa expect(service.snapshot().permission).toBe("unknown"); expect(sdk.show).not.toHaveBeenCalled(); expect(sdk.permission).toHaveBeenCalledExactlyOnceWith({ request: false }); - expect(service.dock.snapshot().permission).toBe("enabled"); - expect(sdk.badge).toHaveBeenCalledExactlyOnceWith(undefined); + expect(service.indicator.snapshot().permission).toBe("enabled"); + expect(sdk.indicator).toHaveBeenCalledExactlyOnceWith({ unread: false }); await submit("first"); await flush(); expect(sdk.show).toHaveBeenCalledOnce(); @@ -319,33 +317,52 @@ it("native presentation rejects at capacity before sending instead of evicting l }); it.each(["Win32", "Linux x86_64"])( - "%s has no Dock permission or setter calls", + "%s projects and clears unread without macOS permission calls or changing banners", async (platform) => { vi.stubGlobal("navigator", { platform }); - const { service, submit } = setup(); - await submit("banner"); + const { service, submit, ctx } = setup(); + await service.indicator.refresh(); + expect(service.indicator.available).toBe(true); + expect(service.indicator.macOS).toBe(false); + expect(sdk.indicator).toHaveBeenLastCalledWith({ unread: false }); + service.indicator.setUnread(true); await flush(); - expect(service.dock.available).toBe(false); + expect(sdk.indicator).toHaveBeenLastCalledWith({ unread: true }); + await service.indicator.request(); expect(sdk.permission).not.toHaveBeenCalled(); - expect(sdk.badge).not.toHaveBeenCalled(); + await submit("banner"); + await flush(); expect(sdk.show).toHaveBeenCalledOnce(); + await ctx.fiber.dispose(); + expect(sdk.indicator).toHaveBeenLastCalledWith({ unread: false }); }, ); -it("the macOS default binds explicit permission and exact Dock label/clear commands", async () => { +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.dock.setUnread(true); - await service.dock.refresh(); - expect(sdk.badge.mock.calls).toEqual([[undefined]]); - await service.dock.request(); + 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.badge).toHaveBeenLastCalledWith("•"); + expect(sdk.indicator).toHaveBeenLastCalledWith({ unread: true }); service.updatePreferences({ enabled: false }); - expect(sdk.badge).toHaveBeenLastCalledWith("•"); + expect(sdk.indicator).toHaveBeenLastCalledWith({ unread: true }); await ctx.fiber.dispose(); - expect(sdk.badge).toHaveBeenLastCalledWith(undefined); + expect(sdk.indicator).toHaveBeenLastCalledWith({ unread: false }); }); diff --git a/src/features/notifications/dock-unread.test.ts b/src/features/notifications/indicator-unread.test.ts similarity index 98% rename from src/features/notifications/dock-unread.test.ts rename to src/features/notifications/indicator-unread.test.ts index 17ee173ec..4550313d0 100644 --- a/src/features/notifications/dock-unread.test.ts +++ b/src/features/notifications/indicator-unread.test.ts @@ -4,7 +4,7 @@ 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"; +import { bindUnreadIndicator } from "./indicator-unread"; const cleanups: (() => void)[] = []; afterEach(() => { @@ -72,7 +72,7 @@ function setup() { } as unknown as Communities; const project = vi.fn(); const bind = () => { - const stop = bindDockUnread(communities, project); + const stop = bindUnreadIndicator(communities, project); cleanups.push(stop); return stop; }; diff --git a/src/features/notifications/dock-unread.ts b/src/features/notifications/indicator-unread.ts similarity index 98% rename from src/features/notifications/dock-unread.ts rename to src/features/notifications/indicator-unread.ts index 01cdaf2e7..cfcc32c4e 100644 --- a/src/features/notifications/dock-unread.ts +++ b/src/features/notifications/indicator-unread.ts @@ -2,7 +2,7 @@ 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( +export function bindUnreadIndicator( communities: Communities, project: (unread: boolean) => void, ) { diff --git a/src/features/notifications/dock.test.ts b/src/features/notifications/indicator.test.ts similarity index 90% rename from src/features/notifications/dock.test.ts rename to src/features/notifications/indicator.test.ts index 74f6f472f..a2c3ce7fb 100644 --- a/src/features/notifications/dock.test.ts +++ b/src/features/notifications/indicator.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, it, vi } from "vitest"; -import { createDockBadge, type DockPermission } from "./dock"; +import { createUnreadIndicator, type IndicatorPermission } from "./indicator"; function deferred() { let resolve!: (value: T) => void; @@ -14,13 +14,13 @@ const cleanups: (() => Promise)[] = []; afterEach(async () => { for (const stop of cleanups.splice(0)) await stop(); }); -function setup(initial: DockPermission = "enabled") { +function setup(initial: IndicatorPermission = "enabled") { const permission = vi.fn( - async (_request: boolean): Promise => initial, + async (_request: boolean): Promise => initial, ); const set = vi.fn(async (_unread: boolean) => {}); const host = new EventTarget(); - const dock = createDockBadge({ permission, set }, host); + const dock = createUnreadIndicator({ permission, set }, host); cleanups.push(dock.dispose); return { dock, set, permission, host }; } @@ -38,7 +38,7 @@ it("silently checks on startup and projects unread after allow, independently of await h.dock.dispose(); expect(h.set).toHaveBeenLastCalledWith(false); }); -it.each(["default", "disabled", "denied", "unavailable"] as const)( +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(); @@ -96,7 +96,7 @@ it("coalesces rapid changes behind one pending native write and makes teardown c it("late permission completion cannot revive a disposed badge", async () => { const h = setup(); await h.dock.refresh(); - const pending = deferred(); + const pending = deferred(); h.permission.mockImplementationOnce(() => pending.promise); const check = h.dock.request(); h.dock.setUnread(true); @@ -120,7 +120,7 @@ it("reports native write failure without a retry loop and accepts a later curren it("keeps an explicit request when a focus check was already pending", async () => { const h = setup("default"); await h.dock.refresh(); - const pending = deferred(); + const pending = deferred(); h.permission.mockImplementationOnce(() => pending.promise); const check = h.dock.refresh(); const request = h.dock.request(); diff --git a/src/features/notifications/dock.ts b/src/features/notifications/indicator.ts similarity index 77% rename from src/features/notifications/dock.ts rename to src/features/notifications/indicator.ts index 45c687732..02b126d44 100644 --- a/src/features/notifications/dock.ts +++ b/src/features/notifications/indicator.ts @@ -1,27 +1,34 @@ import { invoke, isTauri } from "@tauri-apps/api/core"; -import { getCurrentWindow } from "@tauri-apps/api/window"; -export type DockPermission = +export type IndicatorPermission = | "default" + | "setup" | "enabled" | "disabled" | "denied" | "unavailable"; -export interface DockPlatform { - permission(request: boolean): Promise; +export interface IndicatorPlatform { + macOS?: boolean; + permission(request: boolean): Promise; set(unread: boolean): Promise; } -export function dockPlatform(): DockPlatform | undefined { - if (!isTauri() || !/Mac/i.test(globalThis.navigator?.platform ?? "")) return; +export function indicatorPlatform(): IndicatorPlatform | undefined { + const os = globalThis.navigator?.platform ?? ""; + if (!isTauri() || !/Mac|Win|Linux/i.test(os)) return; + const macOS = /Mac/i.test(os); return { - permission: (request) => invoke("dock_permission", { request }), - set: (unread) => getCurrentWindow().setBadgeLabel(unread ? "•" : undefined), + macOS, + permission: (request) => + macOS + ? invoke("dock_permission", { request }) + : Promise.resolve("enabled"), + set: (unread) => invoke("unread_indicator_set", { unread }), }; } /** One ordered native projection. Pending work always converges on current intent. */ -export function createDockBadge( - platform = dockPlatform(), +export function createUnreadIndicator( + platform = indicatorPlatform(), host: | Pick | undefined = typeof window === "undefined" ? undefined : window, @@ -29,7 +36,7 @@ export function createDockBadge( let closed = false, unread = false; let state = Object.freeze({ - permission: "unavailable" as DockPermission, + permission: "unavailable" as IndicatorPermission, requesting: false as boolean, error: null as string | null, }); @@ -94,13 +101,14 @@ export function createDockBadge( const refresh = () => { void check(false); }; - project(); // Clear an earlier frontend's label before observing any new state. + project(); // Clear an earlier frontend's unread state before observing any new state. if (platform) { host?.addEventListener("focus", refresh); refresh(); } return { available: !!platform, + macOS: platform?.macOS ?? false, snapshot: () => state, subscribe(listener: () => void) { listeners.add(listener); @@ -125,4 +133,4 @@ export function createDockBadge( }, }; } -export type DockBadge = ReturnType; +export type UnreadIndicator = ReturnType; diff --git a/src/features/notifications/service.ts b/src/features/notifications/service.ts index 240872369..ff0758bc1 100644 --- a/src/features/notifications/service.ts +++ b/src/features/notifications/service.ts @@ -1,4 +1,4 @@ -import { createDockBadge } from "./dock"; +import { createUnreadIndicator } from "./indicator"; import { Service, type Context } from "@deepseek-ai/cordis"; import { createContributions } from "../../plugins/contributions"; import { parseOpenTarget, type OpenTarget } from "../navigation/targets"; @@ -65,7 +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(); + readonly indicator = createUnreadIndicator(); private readonly contributions; private readonly listeners = new Set<() => void>(); private readonly pending = new Set(); @@ -133,7 +133,7 @@ export class NotificationsService extends Service implements Notifications { this.listeners.clear(); if (typeof window !== "undefined") window.removeEventListener("focus", refresh); - return this.dock.dispose(); + return this.indicator.dispose(); }; }); void this.refreshPermission(); From 5b1639d5f10aa5824e150e0f0ed673884ec75673 Mon Sep 17 00:00:00 2001 From: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Date: Mon, 21 Sep 2026 13:22:04 -0600 Subject: [PATCH 5/6] fix(icons): give unread SVG sources accessible titles Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> --- src-tauri/icons/unread-source/tray-unread.svg | 2 +- src-tauri/icons/unread-source/tray.svg | 2 +- src-tauri/icons/unread-source/unread-overlay.svg | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/icons/unread-source/tray-unread.svg b/src-tauri/icons/unread-source/tray-unread.svg index 1f601801d..d042dc8f3 100644 --- a/src-tauri/icons/unread-source/tray-unread.svg +++ b/src-tauri/icons/unread-source/tray-unread.svg @@ -1 +1 @@ - +Buzz has unread activityConversations diff --git a/src-tauri/icons/unread-source/tray.svg b/src-tauri/icons/unread-source/tray.svg index 4e42e35ec..f6d7e0e10 100644 --- a/src-tauri/icons/unread-source/tray.svg +++ b/src-tauri/icons/unread-source/tray.svg @@ -1 +1 @@ - +BuzzConversations diff --git a/src-tauri/icons/unread-source/unread-overlay.svg b/src-tauri/icons/unread-source/unread-overlay.svg index aa2d67e6a..6723abee3 100644 --- a/src-tauri/icons/unread-source/unread-overlay.svg +++ b/src-tauri/icons/unread-source/unread-overlay.svg @@ -1 +1 @@ - +Unread activity From c8b94b6b799e397a68c7bbba5fcbbae2b600ee49 Mon Sep 17 00:00:00 2001 From: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Date: Mon, 21 Sep 2026 13:35:02 -0600 Subject: [PATCH 6/6] refactor(notifications): limit unread badges to macOS Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> --- docs/notifications.md | 43 +++----- src-tauri/Cargo.toml | 1 - src-tauri/icons/tray-unread.png | Bin 1707 -> 0 bytes src-tauri/icons/tray.png | Bin 1530 -> 0 bytes src-tauri/icons/unread-overlay.png | Bin 1064 -> 0 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 | 19 ++++ src-tauri/src/indicator.rs | 83 --------------- src-tauri/src/lib.rs | 5 +- src-tauri/src/notifications.rs | 2 +- src/app/UnreadIndicatorSettings.test.tsx | 52 ++++++--- src/app/UnreadIndicatorSettings.tsx | 99 ++++++++---------- src/features/notifications/desktop.test.ts | 15 +-- src/features/notifications/indicator.ts | 11 +- 17 files changed, 132 insertions(+), 239 deletions(-) delete mode 100644 src-tauri/icons/tray-unread.png delete mode 100644 src-tauri/icons/tray.png delete mode 100644 src-tauri/icons/unread-overlay.png delete mode 100644 src-tauri/icons/unread-source/README.md delete mode 100644 src-tauri/icons/unread-source/tray-unread.svg delete mode 100644 src-tauri/icons/unread-source/tray.svg delete mode 100644 src-tauri/icons/unread-source/unread-overlay.svg delete mode 100644 src-tauri/src/indicator.rs diff --git a/docs/notifications.md b/docs/notifications.md index e78d1351f..b7bb0b4b0 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -119,7 +119,7 @@ then Notification Center click, dismissal without navigation, and old-account or revoked-access rejection. A macOS pass is not Windows/Linux acceptance. -## Desktop unread indicator +## 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 @@ -131,28 +131,19 @@ clear or recompute the indicator. Disabling Channels does not stop host ownershi 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. +`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 → Desktop unread indicator shows the actual macOS badge +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, @@ -176,10 +167,8 @@ 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. +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 e377d404a..5e65fd95f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,7 +36,6 @@ 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/icons/tray-unread.png b/src-tauri/icons/tray-unread.png deleted file mode 100644 index 2f34a85004df9e604c62a8666569c5eeb63cc0e3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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(kiPqG8&9q9I`CS}m|-i4mtx#beW^?#Rf9 z#`f*8IDI++Sy@KJ#HcvMPusuy7L&4G^DBaS(^d^^ijLQRCDZ``nEJ)s`4D07aZ-{R zd-uj-#*8Sm@L12BX~yBhjks{Zj+z=bQd6UFD=WjCIdie#z;L`etQ(9y zbTW(n;@lDL%D*iWxK26vDYFt15@PsBY@|*hwOYdZ_1!RVppJ_)B0c>Xa&l}qbf^j2 zwplTM{&So>*~AQ}v3hk3=SgG_{c*PtAEr)%VZ>uh-}$=ChBqtl-c-}`w>}zF>hXgA z8fvv#QFyQT3__--M`F+*9SRCskek~Cr&DPC>~H}4_BGOO$IzjAtXL6)w+7e|6rdkEC8)mCHu6rbiBCi#KvNg;C4$`zPuhgcQzm*Ld`mfMPg#xCFn>Z zfFuf;6l+_hu(Y;@?v_5fr{P;>2?1A4t0CX+tM5;&YX)c}#PD_2;RUem#YjRYfM;=~BACbUp8KvY%1 zE^%=lv)SwpHYww@Gy~?&je^bQ#_{7#ie4PalP503$7>*7P{qIwv#qS^vn!i!EEaVa=Kt`3W9AT#GSd^w_nlJC-er;n@QlHZ-8gzrF~;?5X5i)Y>Y^ zV_(yDvkCwz@Aw>q)M!*JB+&yFFOJ6O(Gi$D*&si`IXOMBd9%1`dYH{FK0*yBEM(Dm z+~u}U9}V9G4IVyp!sQCAA!a~XOD%h|;XmLMg+k)dS-Z9Y7cbheaiayPskKK!xOT zxi(V1fK*Js6;`X8^$@{-L>!MO3gS1NnQ6p{6LGwP3H*5({q5~8T4 zrY7L10|3xXayp$0DLn_xPB;=EcI>dRHlAVCs(S3-Z^OEE7I`edrKQ!%FJfk9Jpsta z)Xd#~791}a0IS-3hd1K$V8?J{t5w3yo2@u@tQps@J7BkaJnQQvh!;^v#&c8qDR*B? z89?m-2y0bU)d^N=D*Hh~E(u3180nfqlo3;^HURtEl!{56A*r|eIT_HuvfCa}Mn@+v2^e;TO_ zMO^_9YW@p#FLL-700960@@Hqq00006Nkl1 BJ97X4 diff --git a/src-tauri/icons/tray.png b/src-tauri/icons/tray.png deleted file mode 100644 index 991460f4c8131dbaded1c8b15fe19e6efdc4b428..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1530 zcmVAyLHl0AqhAm(S(u0c^`_SGVLTRZDM~^!3;)}Xej7%ivo+$Q+wP;bfxw#HiRjYMI zMaAaL!!T}~nVa;Wy!;93>by97xEDKic(8Qo-#B%umlaU3e!YXCDmguIk58RW=dh@k z2Eb%8iN?!|8iXt_&&2rgW;8bT5*BTiNDvFEEc=#}g&d&6uZEI7}-5r#%&CPyUjhY&9b-I3&b}_IDF9|6)B>^x!iYzY0nqhi*qjEE+=&?-Pwt?@2SXV~m=h2l-xx~$BK#fxq5 zdP6vIqF2+4D|!4lh@2cF#0#n!IAOK5y}~O}UytJz$VbSH8#mZAVRt!>9BJg6V8nw50R)5k9%2Qw zy%g!qK~a>`6dH*~XX8dUE?w$Fb#*sNOFOV?RVU7#^+_L5@~Wy%{xLnl?c4gzg+ob6 zxO&&F4fci53l4=s-wG*M5rg2CmX;qWZ;~|V1JPV=w~FTG0BUPJXlfcjR+fS# zOKhx?{&dmcyLSU}I(97o$>dBMn%dghev7U&092^KV6d9u&! zHu*PQS!u_~lTLnux%~68$~+S%nxXJNMJ_mSk1Z4$#RA}W-`+rrt_oZyP)A1y%a(QG z!UZ41m$GP)UH(lkSYVTHMoUYOfWiRW?hqSMOVQNc-mbf99RRwifk5Cx%J4;Vl8OX~ zUAw&OjVD;Qt_ueac(G|yw_FQwWn~@mMXapsA^>?!KA!&l_L>w#u(r0go@A$%avt|(Q}gWP zBY=0<t^YCN9Nh%r& zfM~3riGLw~!sM?}jv;47cno!~bH1MPR|X_LjZ`M1X918b{;9gBIs5|v0RR7mn55VM g000I_L_t&o05Mo2U)d+HrT_o{07*qoM6N<$f-(Qry8r+H diff --git a/src-tauri/icons/unread-overlay.png b/src-tauri/icons/unread-overlay.png deleted file mode 100644 index a69aed56f0988a64fe76dac1910f1955cb5dab9c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1064 zcmV+@1lRkCP)frgLQfWDCPn7p~ky&^QWL0nmxb+ zzC$x?0AhEr1L}|90+PO$H^jsrZ z`f&at>e%7j(%eR3txaYy=DY`nK@$z2GjD{lfw0cjK6+lj-#!ugCx^9M!$x!zdreki zq5%Z>#xr4*jBlpLryFRX-}Nh(gsL~zmYiuIuB8h5!QTobQ#TMLMdY`(&)qJ4=5o5> z&{Eq)T+1e5l`>a1;I9OEY^`^G!*QM~=+s)Q=tir7>3R)_ATPGock?hJ=+qK>sZT(& z+3#ONzxgzUFmY5%-9Q_1g_+F!pcB(!IXbn(Ud9c~=b!Ll?JLA4`&zmUR0;(gzY#nE zomyfq^$8SVUt#7IKIA)Z_@^*+&Ugw~=_BgO&MkQ)bps^`z6X+dj&rB@tUz-2F#Ine zu= zCe~~n&Ph5ON=u`~b4^L%nn}t;88r=X5KZtMn#{hM$$nmxcp`{W&WF*4^Z;5CWg>5r z7kT@RrNxoHv_9R8wNNQ%lFwMRlzskH+lkuMJW*rU052l(F2*gO7ietCtZ@_^>ql|> z7f|Y@a7w+roZ>SBX<6(vnlj7o^CrCb$~U9tJ@|H}+W==l!1n|H*Z;)xA;Q|#sP)iD zZ`c3}Amke|2>xEs0$erDO+#`JYE)q~G=~ka3Izs0K74-=4RXLs&;)v9*e{2F5<~)j zXfA>Q_zul!1Bx2OEZ`Y903tvDm?XpgTKI>+3t&-n&PJCTa8}4@-C6uE00030|8_gS i=>Px#21!IgR09CItwm7tXyF$C0000Buzz has unread activityConversations diff --git a/src-tauri/icons/unread-source/tray.svg b/src-tauri/icons/unread-source/tray.svg deleted file mode 100644 index f6d7e0e10..000000000 --- a/src-tauri/icons/unread-source/tray.svg +++ /dev/null @@ -1 +0,0 @@ -BuzzConversations diff --git a/src-tauri/icons/unread-source/unread-overlay.svg b/src-tauri/icons/unread-source/unread-overlay.svg deleted file mode 100644 index 6723abee3..000000000 --- a/src-tauri/icons/unread-source/unread-overlay.svg +++ /dev/null @@ -1 +0,0 @@ -Unread activity diff --git a/src-tauri/src/dock.rs b/src-tauri/src/dock.rs index cd147e41b..10a410970 100644 --- a/src-tauri/src/dock.rs +++ b/src-tauri/src/dock.rs @@ -17,6 +17,25 @@ pub(crate) enum Permission { 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, diff --git a/src-tauri/src/indicator.rs b/src-tauri/src/indicator.rs deleted file mode 100644 index 7fca81468..000000000 --- a/src-tauri/src/indicator.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! Tauri owns shell presentation; the host supplies one ordered unread boolean. -#[cfg(any(test, target_os = "windows"))] -const UNREAD: tauri::image::Image<'_> = tauri::include_image!("icons/unread-overlay.png"); -#[cfg(any(test, target_os = "linux"))] -const TRAY: tauri::image::Image<'_> = tauri::include_image!("icons/tray.png"); -#[cfg(any(test, target_os = "linux"))] -const TRAY_UNREAD: tauri::image::Image<'_> = tauri::include_image!("icons/tray-unread.png"); - -#[tauri::command] -pub(crate) fn unread_indicator_set( - window: tauri::WebviewWindow, - unread: bool, -) -> Result<(), String> { - if window.label() != "main" { - return Err("Unread indicators 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(target_os = "windows")] - return window - .set_overlay_icon(unread.then(|| UNREAD.clone())) - .map_err(|e| e.to_string()); - #[cfg(target_os = "linux")] - return linux_set(&window, unread).map_err(|e| e.to_string()); - #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] - { - let _ = unread; - Err("Unread indicators are unavailable on this platform".into()) - } -} - -#[cfg(target_os = "linux")] -fn linux_set(window: &tauri::WebviewWindow, unread: bool) -> tauri::Result<()> { - use tauri::{ - menu::{Menu, MenuItem}, - tray::TrayIconBuilder, - Manager, - }; - let app = window.app_handle(); - let icon = if unread { - TRAY_UNREAD.clone() - } else { - TRAY.clone() - }; - if let Some(tray) = app.tray_by_id("unread-indicator") { - return tray.set_icon(Some(icon)); - } - // Synchronous Tauri commands execute on the main thread. Tauri retains the - // tray for the app's lifetime: frontend reloads reuse it and its menu handler. - // No click handler/tooltip: Linux only guarantees the tray's menu interface. - let show = MenuItem::with_id(app, "unread-show", "Show Buzz", true, None::<&str>)?; - let menu = Menu::with_items(app, &[&show])?; - TrayIconBuilder::with_id("unread-indicator") - .icon(icon) - .menu(&menu) - .on_menu_event(|app, event| { - if event.id().as_ref() != "unread-show" { - return; - } - if let Some(window) = app.get_webview_window("main") { - if let Err(error) = crate::notifications::focus(&window) { - eprintln!("Show Buzz failed: {error}"); - } - } - }) - .build(app)?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn embedded_icons_are_small_rgba_and_unread_adds_a_shape() { - for icon in [&UNREAD, &TRAY, &TRAY_UNREAD] { - assert_eq!((icon.width(), icon.height()), (32, 32)); - assert_eq!(icon.rgba().len(), 32 * 32 * 4); - } - assert_ne!(TRAY.rgba(), TRAY_UNREAD.rgba()); - } -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 999f5c215..9ea43e728 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,5 @@ mod dock; -mod indicator; -use dock::dock_permission; +use dock::{dock_permission, unread_indicator_set}; mod notifications; mod terminal; use notifications::{notification_show, Notifications}; @@ -168,7 +167,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ notification_show, dock_permission, - indicator::unread_indicator_set, + unread_indicator_set, terminal_create_owner, terminal_spawn, terminal_read, diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs index 2e376dc84..678902486 100644 --- a/src-tauri/src/notifications.rs +++ b/src-tauri/src/notifications.rs @@ -99,7 +99,7 @@ fn respond(app: tauri::AppHandle, channel: Channel, id: String, outcom } } -pub(crate) fn focus(window: &tauri::WebviewWindow) -> Result<(), String> { +fn focus(window: &tauri::WebviewWindow) -> Result<(), String> { #[cfg(target_os = "linux")] { use gtk::prelude::GtkWindowExt; diff --git a/src/app/UnreadIndicatorSettings.test.tsx b/src/app/UnreadIndicatorSettings.test.tsx index 9b339a420..e772d1724 100644 --- a/src/app/UnreadIndicatorSettings.test.tsx +++ b/src/app/UnreadIndicatorSettings.test.tsx @@ -9,15 +9,24 @@ import { } 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({ - macOS: true, permission, set: vi.fn(async () => {}), }); @@ -85,7 +94,33 @@ it("offers missing-badge setup only as an explicit action", async () => { .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 () => { +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", @@ -94,23 +129,16 @@ it("Windows/Linux settings describe the surface without fake permission controls 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")); + set.mockRejectedValueOnce(new Error("Dock unavailable")); await act(async () => { indicator.setUnread(true); }); expect(await screen.findByRole("alert")).toHaveTextContent( - "Shell unavailable", + "Dock unavailable", ); await userEvent .setup() - .click(screen.getByRole("button", { name: "Retry unread indicator" })); + .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 index abc3d93f5..bd5c7e1f3 100644 --- a/src/app/UnreadIndicatorSettings.tsx +++ b/src/app/UnreadIndicatorSettings.tsx @@ -10,65 +10,52 @@ export function UnreadIndicatorSettings({ if (!indicator.available) return null; return (
-

Desktop unread indicator

+

Dock unread badge

- 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.permission === "default" || - state.permission === "setup") && ( - - )} - -
- - )} +

+ {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") && ( + + )} + +
{state.error && ( -
-

- {state.error} -

- {!indicator.macOS && ( - - )} -
+

+ {state.error} +

)}
); diff --git a/src/features/notifications/desktop.test.ts b/src/features/notifications/desktop.test.ts index 8dff345ab..ee05871d4 100644 --- a/src/features/notifications/desktop.test.ts +++ b/src/features/notifications/desktop.test.ts @@ -317,24 +317,27 @@ it("native presentation rejects at capacity before sending instead of evicting l }); it.each(["Win32", "Linux x86_64"])( - "%s projects and clears unread without macOS permission calls or changing banners", + "%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(true); - expect(service.indicator.macOS).toBe(false); - expect(sdk.indicator).toHaveBeenLastCalledWith({ unread: false }); + expect(service.indicator.available).toBe(false); + expect( + renderToStaticMarkup( + createElement(NotificationSettings, { notifications: service }), + ), + ).not.toContain("Dock unread badge"); service.indicator.setUnread(true); await flush(); - expect(sdk.indicator).toHaveBeenLastCalledWith({ unread: true }); 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).toHaveBeenLastCalledWith({ unread: false }); + expect(sdk.indicator).not.toHaveBeenCalled(); + expect(sdk.permission).not.toHaveBeenCalled(); }, ); diff --git a/src/features/notifications/indicator.ts b/src/features/notifications/indicator.ts index 02b126d44..4bef9fc8e 100644 --- a/src/features/notifications/indicator.ts +++ b/src/features/notifications/indicator.ts @@ -8,20 +8,14 @@ export type IndicatorPermission = | "denied" | "unavailable"; export interface IndicatorPlatform { - macOS?: boolean; permission(request: boolean): Promise; set(unread: boolean): Promise; } export function indicatorPlatform(): IndicatorPlatform | undefined { const os = globalThis.navigator?.platform ?? ""; - if (!isTauri() || !/Mac|Win|Linux/i.test(os)) return; - const macOS = /Mac/i.test(os); + if (!isTauri() || !/Mac/i.test(os)) return; return { - macOS, - permission: (request) => - macOS - ? invoke("dock_permission", { request }) - : Promise.resolve("enabled"), + permission: (request) => invoke("dock_permission", { request }), set: (unread) => invoke("unread_indicator_set", { unread }), }; } @@ -108,7 +102,6 @@ export function createUnreadIndicator( } return { available: !!platform, - macOS: platform?.macOS ?? false, snapshot: () => state, subscribe(listener: () => void) { listeners.add(listener);