From 8ab8cf6d66d1bbeb2b7665c92f1ea58388a87999 Mon Sep 17 00:00:00 2001 From: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Date: Mon, 21 Sep 2026 09:29:56 -0600 Subject: [PATCH 1/2] fix(desktop): register macOS badges for new and existing installs Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/macos_notifications.rs | 136 +++++++++++++++++-- 1 file changed, 127 insertions(+), 9 deletions(-) diff --git a/desktop/src-tauri/src/macos_notifications.rs b/desktop/src-tauri/src/macos_notifications.rs index 5bcedd8975b..dde4ae1d8e4 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(); From f9a6ff2b0f7368497e5e74f0d9a8fd77bccd68af Mon Sep 17 00:00:00 2001 From: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Date: Mon, 21 Sep 2026 11:35:38 -0600 Subject: [PATCH 2/2] test(desktop): synchronize snapshot mutation revalidation Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> --- desktop/tests/e2e/sidebar-snapshot.spec.ts | 43 ++++++++++++++++++---- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/desktop/tests/e2e/sidebar-snapshot.spec.ts b/desktop/tests/e2e/sidebar-snapshot.spec.ts index eaf8797f11e..c8a3af46946 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({