From a3117a4762e3349054474e221c2e9cc5fea60dd9 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 21 Sep 2026 11:12:38 -0700 Subject: [PATCH] fix(desktop): register macOS badges for new and existing installs (#7783) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Request macOS badge authorization alongside alerts and sounds. - Repair existing bundled installations at startup only when native status is `Authorized` and `badgeSetting` is `NotSupported` (the omitted/unregistered interaction). Leave user-disabled badges, denied notifications, new users, and provisional/ephemeral authorization untouched. - Reuse the native query/request path in one background startup operation. No frontend changes, read-state resets, persisted migration flags, timers, or in-process retries. ## Validation - Full `just ci` passed at implementation head `e6c4bc640`, before the test-seam extraction and additional regression coverage. - Native patch head `8ab8cf6d6`: normal pre-commit/pre-push hooks passed, including macOS Tauri clippy (default and mesh-llm), complete Tauri test suite (3,208 passed, 19 ignored), and repository file-size gate. - Regression coverage: all five native authorization statuses × all three badge states; request exactly once only for Authorized + NotSupported; settings/request failure propagation; startup wiring; complete authorization options. - Mutation verification: removing the startup task and removing the Badge option each fail the corresponding regression. - Isolated bundled native app on macOS 26.6.2: requesting Alert | Sound produced Authorized + badge NotSupported; requesting Alert | Sound | Badge then produced badge Enabled with no error. A direct AppKit Dock label of 7 was visibly rendered in the isolated app. - Independent review found no remaining code blocker at final head. ## CI repair (`f9a6ff2b0`) - The original browser smoke failure was a 500ms startup assertion racing loaded-runner rendering. Hosted failure snapshots contained all 14 expected rows; the unchanged test also reproduced the failure locally with 6× CPU throttling. - Reuse the existing one-shot mock channel response gate in the affected test. Wait for startup settlement, defer a second real revalidation, mutate display state while pending, release, then assert after query settlement. No production changes in this follow-up. - All 10 sidebar snapshot E2Es pass at clean head `f9a6ff2b0`; the repaired case also passed 3/3 CPU-throttled runs before commit. Temporarily removing display preservation or persisting the display mutation each caused the corresponding assertion to fail; both mutants were restored. - Normal pre-commit/pre-push gates passed, including desktop checks/typechecking, all 6,508 desktop tests, and the file-size gate. Independent artifact/source review found no blocker in the repair. - New hosted CI is running: https://github.com/block/buzz/actions/runs/35633308957. This is not a claim that hosted CI is green. Exact-range security review still requires organization-member authorization. ## Runtime limitation This fixes a verified missing authorization registration and provides an automatic existing-install path. It is **not yet proof that the reported blank Dock badge in the released Buzz app is fully resolved**: patched bundled Buzz startup and unread-to-Dock recovery have not been exercised. The isolated app is native API evidence, not a replacement for that release smoke test. Installed Buzz and its read state were not modified. Before release, verify an existing Alert/Sound-only install gains the badge setting on launch and renders an unread Dock badge; verify a user-disabled badge remains disabled. --------- Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/macos_notifications.rs | 136 +++++++++++++++++-- desktop/tests/e2e/sidebar-snapshot.spec.ts | 43 +++++- 2 files changed, 163 insertions(+), 16 deletions(-) diff --git a/desktop/src-tauri/src/macos_notifications.rs b/desktop/src-tauri/src/macos_notifications.rs index 5bcedd8975..dde4ae1d8e 100644 --- a/desktop/src-tauri/src/macos_notifications.rs +++ b/desktop/src-tauri/src/macos_notifications.rs @@ -25,7 +25,7 @@ use objc2_foundation::{NSBundle, NSDictionary, NSError, NSObject, NSObjectProtoc use objc2_user_notifications::{ UNAuthorizationOptions, UNAuthorizationStatus, UNMutableNotificationContent, UNNotificationDefaultActionIdentifier, UNNotificationPresentationOptions, - UNNotificationRequest, UNNotificationResponse, UNNotificationSettings, + UNNotificationRequest, UNNotificationResponse, UNNotificationSetting, UNNotificationSettings, UNUserNotificationCenter, UNUserNotificationCenterDelegate, }; use tauri::{AppHandle, Emitter}; @@ -144,6 +144,18 @@ pub(crate) fn init(app: &AppHandle) -> tauri::Result<()> { // process-lifetime state, matching the application-lifetime delegate Apple // documents and avoiding mutable global or per-notification registrations. std::mem::forget(delegate); + + // Older releases registered alerts/sounds but omitted Badge. Repair only + // that unregistered interaction, never an explicit user opt-out. This runs + // once per bundled process without blocking startup or prompting new users. + tauri::async_runtime::spawn_blocking(|| { + let result = + register_missing_badge(notification_settings_sync, request_notification_access_sync); + if let Err(error) = result { + // Leave the native setting unchanged; a later launch can retry. + eprintln!("buzz-desktop: failed to register macOS badge authorization: {error}"); + } + }); Ok(()) } @@ -158,15 +170,15 @@ fn ensure_bundled_application() -> Result<(), String> { } } -fn notification_permission_state_sync() -> Result { +fn notification_settings_sync() -> Result<(UNAuthorizationStatus, UNNotificationSetting), String> { ensure_bundled_application()?; let (sender, receiver) = mpsc::sync_channel(1); let handler = RcBlock::new(move |settings: NonNull| { // SAFETY: Apple guarantees a live UNNotificationSettings object for // the duration of this completion handler. - let status = unsafe { settings.as_ref() }.authorizationStatus(); - let _ = sender.send(permission_state(status)); + let settings = unsafe { settings.as_ref() }; + let _ = sender.send((settings.authorizationStatus(), settings.badgeSetting())); }); UNUserNotificationCenter::currentNotificationCenter() .getNotificationSettingsWithCompletionHandler(&handler); @@ -176,6 +188,23 @@ fn notification_permission_state_sync() -> Result Result { + notification_settings_sync().map(|(status, _)| permission_state(status)) +} + +fn register_missing_badge( + settings: impl FnOnce() -> Result<(UNAuthorizationStatus, UNNotificationSetting), String>, + request: impl FnOnce() -> Result, +) -> Result<(), String> { + let (permission, badge) = settings()?; + if permission == UNAuthorizationStatus::Authorized + && badge == UNNotificationSetting::NotSupported + { + request()?; + } + Ok(()) +} + #[tauri::command] pub(crate) async fn notification_permission_state() -> Result { tokio::task::spawn_blocking(notification_permission_state_sync) @@ -183,6 +212,11 @@ pub(crate) async fn notification_permission_state() -> Result UNAuthorizationOptions { + // Register every interaction Buzz uses, including its Dock badge. + UNAuthorizationOptions::Alert | UNAuthorizationOptions::Sound | UNAuthorizationOptions::Badge +} + fn request_notification_access_sync() -> Result { ensure_bundled_application()?; @@ -196,7 +230,7 @@ fn request_notification_access_sync() -> Result Option { #[cfg(test)] mod tests { use super::{ - is_application_bundle_layout, is_bundled_application, parse_target, permission_state, - queue_activation, take_pending_activations, NotificationPermissionState, - MAX_PENDING_ACTIVATIONS, + is_application_bundle_layout, is_bundled_application, notification_authorization_options, + parse_target, permission_state, queue_activation, register_missing_badge, + take_pending_activations, NotificationPermissionState, MAX_PENDING_ACTIVATIONS, + }; + use objc2_user_notifications::{ + UNAuthorizationOptions, UNAuthorizationStatus, UNNotificationSetting, }; - use objc2_user_notifications::UNAuthorizationStatus; use std::path::Path; + #[test] + fn registers_only_unrequested_badges_for_already_authorized_installs() { + for permission in [ + UNAuthorizationStatus::NotDetermined, + UNAuthorizationStatus::Denied, + UNAuthorizationStatus::Authorized, + UNAuthorizationStatus::Provisional, + UNAuthorizationStatus::Ephemeral, + ] { + for badge in [ + UNNotificationSetting::NotSupported, + UNNotificationSetting::Disabled, + UNNotificationSetting::Enabled, + ] { + let mut requests = 0; + register_missing_badge( + || Ok((permission, badge)), + || { + requests += 1; + Ok(NotificationPermissionState::Granted) + }, + ) + .expect("registration succeeds"); + assert_eq!( + requests, + usize::from( + permission == UNAuthorizationStatus::Authorized + && badge == UNNotificationSetting::NotSupported + ), + "permission={permission:?}, badge={badge:?}", + ); + } + } + } + + #[test] + fn badge_registration_propagates_settings_and_request_failures() { + let error = register_missing_badge( + || Err("settings unavailable".into()), + || panic!("must not request when settings failed"), + ) + .expect_err("settings failure"); + assert_eq!(error, "settings unavailable"); + let error = register_missing_badge( + || { + Ok(( + UNAuthorizationStatus::Authorized, + UNNotificationSetting::NotSupported, + )) + }, + || Err("request failed".into()), + ) + .expect_err("request failure"); + assert_eq!(error, "request failed"); + } + + #[test] + fn bundled_startup_wires_native_badge_registration() { + // Complement behavioral tests with a wiring guard: removing the startup + // task must not leave the isolated operation tests green. + let source = include_str!("macos_notifications.rs"); + let init = source + .split("pub(crate) fn init(") + .nth(1) + .expect("init") + .split("fn ensure_bundled_application") + .next() + .expect("init body"); + assert!(init.contains("tauri::async_runtime::spawn_blocking")); + assert!(init.contains("register_missing_badge(")); + assert!(init.contains("notification_settings_sync,")); + assert!(init.contains("request_notification_access_sync")); + } + + #[test] + fn requests_all_interactions_used_by_buzz() { + let options = notification_authorization_options(); + assert!(options.contains(UNAuthorizationOptions::Alert)); + assert!(options.contains(UNAuthorizationOptions::Sound)); + assert!(options.contains(UNAuthorizationOptions::Badge)); + } + #[test] fn activation_queue_is_bounded_and_drained() { let _ = take_pending_activations(); diff --git a/desktop/tests/e2e/sidebar-snapshot.spec.ts b/desktop/tests/e2e/sidebar-snapshot.spec.ts index eaf8797f11..c8a3af4694 100644 --- a/desktop/tests/e2e/sidebar-snapshot.spec.ts +++ b/desktop/tests/e2e/sidebar-snapshot.spec.ts @@ -294,24 +294,53 @@ test("matching not-modified preserves display mutations without persisting them" }) => { const optimisticName = "optimistic-local-channel"; await seedSnapshot(page, { hash: MATCHING_HASH }); - await installMockBridge(page, { - channelsReadDelayMs: READ_DELAY_MS, - honorChannelsKnownHash: true, - }); + await installMockBridge(page, { honorChannelsKnownHash: true }); await page.goto("/"); await expect(page.locator('[data-channel-id^="snapshot-"]')).toHaveCount( FULL_SNAPSHOT.length, - { timeout: 500 }, ); + await expect + .poll(() => + page.evaluate(() => + window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState(["channels"]), + ), + ) + .toMatchObject({ fetchStatus: "idle", status: "success" }); + + // Exercise the mutation during a real revalidation, not a race between boot + // rendering and a fixed response delay. The cold-boot test covers first paint. + await page.evaluate(() => { + window.__BUZZ_E2E_DEFER_NEXT_CHANNELS_READ__?.(); + void window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + exact: true, + }); + }); + await expect + .poll(() => page.evaluate(() => window.__BUZZ_E2E_CHANNELS_READ_PENDING__)) + .toBe(1); await mutateDisplayedChannels(page, optimisticName); await expect( page.locator('[data-channel-id="optimistic-channel"]'), ).toBeVisible(); + expect( + await page.evaluate(() => window.__BUZZ_E2E_RELEASE_CHANNELS_READ__?.()), + ).toBe(1); + // Invocation logging only proves the request started. Wait for the query to + // consume its response before checking either display state or persistence. await expect - .poll(() => getChannelsPayloads(page)) - .toEqual([{ knownHash: MATCHING_HASH }]); + .poll(() => + page.evaluate(() => + window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState(["channels"]), + ), + ) + .toMatchObject({ fetchStatus: "idle", status: "success" }); + expect(await getChannelsPayloads(page)).toEqual([ + { knownHash: MATCHING_HASH }, + { knownHash: MATCHING_HASH }, + ]); await expect .poll(() => readPersistedSnapshot(page)) .toEqual({