mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user