fix(desktop): register macOS badges for new and existing installs (#7783)

## 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>
This commit is contained in:
Wes
2026-09-21 11:12:38 -07:00
committed by GitHub
parent a61239f0d8
commit a3117a4762
2 changed files with 163 additions and 16 deletions

View File

@@ -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<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);
@@ -176,6 +188,23 @@ 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)
@@ -183,6 +212,11 @@ pub(crate) async fn notification_permission_state() -> Result<NotificationPermis
.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()?;
@@ -196,7 +230,7 @@ fn request_notification_access_sync() -> Result<NotificationPermissionState, Str
});
UNUserNotificationCenter::currentNotificationCenter()
.requestAuthorizationWithOptions_completionHandler(
UNAuthorizationOptions::Alert | UNAuthorizationOptions::Sound,
notification_authorization_options(),
&handler,
);
@@ -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();

View File

@@ -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({