Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 127 additions & 9 deletions desktop/src-tauri/src/macos_notifications.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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(())
}

Expand All @@ -158,15 +170,15 @@ fn ensure_bundled_application() -> Result<(), String> {
}
}

fn notification_permission_state_sync() -> Result<NotificationPermissionState, String> {
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<UNNotificationSettings>| {
// 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);
Expand All @@ -176,13 +188,35 @@ fn notification_permission_state_sync() -> Result<NotificationPermissionState, S
.map_err(|_| "macOS notification settings request timed out".to_string())
}

fn notification_permission_state_sync() -> Result<NotificationPermissionState, String> {
notification_settings_sync().map(|(status, _)| permission_state(status))
}

fn register_missing_badge(
settings: impl FnOnce() -> Result<(UNAuthorizationStatus, UNNotificationSetting), String>,
request: impl FnOnce() -> Result<NotificationPermissionState, String>,
) -> 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<NotificationPermissionState, String> {
tokio::task::spawn_blocking(notification_permission_state_sync)
.await
.map_err(|error| format!("macOS notification settings task failed: {error}"))?
}

fn notification_authorization_options() -> UNAuthorizationOptions {
// Register every interaction Buzz uses, including its Dock badge.
UNAuthorizationOptions::Alert | UNAuthorizationOptions::Sound | UNAuthorizationOptions::Badge
}

fn request_notification_access_sync() -> Result<NotificationPermissionState, String> {
ensure_bundled_application()?;

Expand All @@ -196,7 +230,7 @@ fn request_notification_access_sync() -> Result<NotificationPermissionState, Str
});
UNUserNotificationCenter::currentNotificationCenter()
.requestAuthorizationWithOptions_completionHandler(
UNAuthorizationOptions::Alert | UNAuthorizationOptions::Sound,
notification_authorization_options(),
&handler,
);

Expand Down Expand Up @@ -335,13 +369,97 @@ fn parse_target(serialized: &str) -> Option<serde_json::Value> {
#[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();
Expand Down
43 changes: 36 additions & 7 deletions desktop/tests/e2e/sidebar-snapshot.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading