mirror of
https://github.com/block/buzz.git
synced 2026-11-07 18:01:06 +00:00
## 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>
541 lines
20 KiB
Rust
541 lines
20 KiB
Rust
//! Modern macOS notification delivery and activation routing.
|
|
//!
|
|
//! Apple delivers every notification response through one process-wide
|
|
//! `UNUserNotificationCenterDelegate`. The delegate is installed once during
|
|
//! app setup and retained for the process lifetime. Notification targets live
|
|
//! in `userInfo`, so there are no per-notification listeners, waiter threads,
|
|
//! or request maps to leak when Notification Center clears a notification.
|
|
|
|
use std::{
|
|
collections::VecDeque,
|
|
path::Path,
|
|
ptr::NonNull,
|
|
sync::{mpsc, Mutex, OnceLock},
|
|
time::Duration,
|
|
};
|
|
|
|
use block2::{Block, RcBlock};
|
|
use objc2::{
|
|
define_class, msg_send,
|
|
rc::Retained,
|
|
runtime::{AnyObject, Bool, ProtocolObject},
|
|
AnyThread, DefinedClass,
|
|
};
|
|
use objc2_foundation::{NSBundle, NSDictionary, NSError, NSObject, NSObjectProtocol, NSString};
|
|
use objc2_user_notifications::{
|
|
UNAuthorizationOptions, UNAuthorizationStatus, UNMutableNotificationContent,
|
|
UNNotificationDefaultActionIdentifier, UNNotificationPresentationOptions,
|
|
UNNotificationRequest, UNNotificationResponse, UNNotificationSetting, UNNotificationSettings,
|
|
UNUserNotificationCenter, UNUserNotificationCenterDelegate,
|
|
};
|
|
use tauri::{AppHandle, Emitter};
|
|
|
|
use crate::commands::NATIVE_NOTIFICATION_ACTIVATED_EVENT;
|
|
|
|
const TARGET_USER_INFO_KEY: &str = "buzzNotificationTarget";
|
|
const MAX_PENDING_ACTIVATIONS: usize = 64;
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub(crate) enum NotificationPermissionState {
|
|
Default,
|
|
Denied,
|
|
Granted,
|
|
}
|
|
|
|
fn permission_state(status: UNAuthorizationStatus) -> NotificationPermissionState {
|
|
match status {
|
|
UNAuthorizationStatus::Denied => NotificationPermissionState::Denied,
|
|
UNAuthorizationStatus::Authorized
|
|
| UNAuthorizationStatus::Provisional
|
|
| UNAuthorizationStatus::Ephemeral => NotificationPermissionState::Granted,
|
|
_ => NotificationPermissionState::Default,
|
|
}
|
|
}
|
|
|
|
static PENDING_ACTIVATIONS: OnceLock<Mutex<VecDeque<serde_json::Value>>> = OnceLock::new();
|
|
|
|
struct NotificationDelegateIvars {
|
|
app: AppHandle,
|
|
}
|
|
|
|
define_class!(
|
|
// SAFETY: NSObject permits AnyThread subclasses, and AppHandle is Send +
|
|
// Sync. Apple does not guarantee a queue for notification delegate calls;
|
|
// both Tauri operations used by the callbacks are thread-safe.
|
|
#[unsafe(super(NSObject))]
|
|
#[name = "BuzzNotificationCenterDelegate"]
|
|
#[thread_kind = AnyThread]
|
|
#[ivars = NotificationDelegateIvars]
|
|
struct NotificationDelegate;
|
|
|
|
unsafe impl NSObjectProtocol for NotificationDelegate {}
|
|
|
|
unsafe impl UNUserNotificationCenterDelegate for NotificationDelegate {
|
|
#[unsafe(method(userNotificationCenter:willPresentNotification:withCompletionHandler:))]
|
|
fn will_present_notification(
|
|
&self,
|
|
_center: &UNUserNotificationCenter,
|
|
_notification: &objc2_user_notifications::UNNotification,
|
|
completion_handler: &Block<dyn Fn(UNNotificationPresentationOptions)>,
|
|
) {
|
|
// Preserve the prior macOS behavior: keep foreground notifications
|
|
// in Notification Center without interrupting the user with a banner.
|
|
completion_handler.call((UNNotificationPresentationOptions::List,));
|
|
}
|
|
|
|
#[unsafe(method(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:))]
|
|
fn did_receive_notification_response(
|
|
&self,
|
|
_center: &UNUserNotificationCenter,
|
|
response: &UNNotificationResponse,
|
|
completion_handler: &Block<dyn Fn()>,
|
|
) {
|
|
if &*response.actionIdentifier() == unsafe { UNNotificationDefaultActionIdentifier } {
|
|
if let Some(target) = target_from_response(response) {
|
|
queue_activation(target);
|
|
crate::tray_menu::show_main_window(&self.ivars().app);
|
|
if let Err(error) = self
|
|
.ivars()
|
|
.app
|
|
.emit(NATIVE_NOTIFICATION_ACTIVATED_EVENT, ())
|
|
{
|
|
eprintln!(
|
|
"buzz-desktop: failed to emit macOS notification activation: {error}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apple requires this for every response, including dismissals and
|
|
// malformed notifications that Buzz intentionally ignores.
|
|
completion_handler.call(());
|
|
}
|
|
}
|
|
);
|
|
|
|
impl NotificationDelegate {
|
|
fn new(app: AppHandle) -> Retained<Self> {
|
|
let delegate = Self::alloc().set_ivars(NotificationDelegateIvars { app });
|
|
unsafe { msg_send![super(delegate), init] }
|
|
}
|
|
}
|
|
|
|
/// Install the one application-lifetime notification response delegate.
|
|
pub(crate) fn init(app: &AppHandle) -> tauri::Result<()> {
|
|
if !is_bundled_application() {
|
|
// UNUserNotificationCenter raises an Objective-C exception when the
|
|
// current process has no application bundle (notably `tauri dev`).
|
|
// objc2 cannot turn that exception into a Rust error, so do not call
|
|
// into the framework at all in this environment.
|
|
eprintln!(
|
|
"buzz-desktop: macOS notifications disabled because the process is not running from an app bundle"
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
let center = UNUserNotificationCenter::currentNotificationCenter();
|
|
let delegate = NotificationDelegate::new(app.clone());
|
|
let delegate: Retained<ProtocolObject<dyn UNUserNotificationCenterDelegate>> =
|
|
ProtocolObject::from_retained(delegate);
|
|
center.setDelegate(Some(&delegate));
|
|
|
|
// UNUserNotificationCenter.delegate is weak. This object is deliberately
|
|
// 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(())
|
|
}
|
|
|
|
fn ensure_bundled_application() -> Result<(), String> {
|
|
if is_bundled_application() {
|
|
Ok(())
|
|
} else {
|
|
Err(
|
|
"macOS notifications are unavailable when Buzz is not running from an app bundle"
|
|
.to_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 settings = unsafe { settings.as_ref() };
|
|
let _ = sender.send((settings.authorizationStatus(), settings.badgeSetting()));
|
|
});
|
|
UNUserNotificationCenter::currentNotificationCenter()
|
|
.getNotificationSettingsWithCompletionHandler(&handler);
|
|
|
|
receiver
|
|
.recv_timeout(Duration::from_secs(10))
|
|
.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()?;
|
|
|
|
let (sender, receiver) = mpsc::sync_channel(1);
|
|
let handler = RcBlock::new(move |_granted: Bool, error: *mut NSError| {
|
|
let result = match unsafe { error.as_ref() } {
|
|
Some(error) => Err(format!("macOS notification authorization failed: {error}")),
|
|
None => Ok(()),
|
|
};
|
|
let _ = sender.send(result);
|
|
});
|
|
UNUserNotificationCenter::currentNotificationCenter()
|
|
.requestAuthorizationWithOptions_completionHandler(
|
|
notification_authorization_options(),
|
|
&handler,
|
|
);
|
|
|
|
receiver
|
|
.recv_timeout(Duration::from_secs(60))
|
|
.map_err(|_| "macOS notification authorization request timed out".to_string())??;
|
|
notification_permission_state_sync()
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn request_notification_access() -> Result<NotificationPermissionState, String> {
|
|
tokio::task::spawn_blocking(request_notification_access_sync)
|
|
.await
|
|
.map_err(|error| format!("macOS notification authorization task failed: {error}"))?
|
|
}
|
|
|
|
fn show_sync(
|
|
title: String,
|
|
body: Option<String>,
|
|
target: Option<serde_json::Value>,
|
|
) -> Result<(), String> {
|
|
ensure_bundled_application()?;
|
|
if notification_permission_state_sync()? != NotificationPermissionState::Granted {
|
|
return Err("macOS notification permission is not granted".to_string());
|
|
}
|
|
|
|
let content = UNMutableNotificationContent::new();
|
|
content.setTitle(&NSString::from_str(&title));
|
|
if let Some(body) = body {
|
|
content.setBody(&NSString::from_str(&body));
|
|
}
|
|
|
|
if let Some(target) = target {
|
|
let serialized = serde_json::to_string(&target)
|
|
.map_err(|error| format!("failed to serialize notification target: {error}"))?;
|
|
let key = NSString::from_str(TARGET_USER_INFO_KEY);
|
|
let value = NSString::from_str(&serialized);
|
|
let user_info = NSDictionary::<NSString, NSString>::from_slices(&[&*key], &[&*value]);
|
|
// SAFETY: Both the key and value are property-list-safe NSString values.
|
|
unsafe {
|
|
let user_info =
|
|
Retained::cast_unchecked::<NSDictionary<AnyObject, AnyObject>>(user_info);
|
|
content.setUserInfo(&user_info);
|
|
}
|
|
}
|
|
|
|
let identifier = NSString::from_str(&uuid::Uuid::new_v4().to_string());
|
|
let request =
|
|
UNNotificationRequest::requestWithIdentifier_content_trigger(&identifier, &content, None);
|
|
let (sender, receiver) = mpsc::sync_channel(1);
|
|
let delivery_handler = RcBlock::new(move |error: *mut NSError| {
|
|
let result = match unsafe { error.as_ref() } {
|
|
Some(error) => Err(format!("failed to deliver macOS notification: {error}")),
|
|
None => Ok(()),
|
|
};
|
|
let _ = sender.send(result);
|
|
});
|
|
UNUserNotificationCenter::currentNotificationCenter()
|
|
.addNotificationRequest_withCompletionHandler(&request, Some(&delivery_handler));
|
|
|
|
receiver
|
|
.recv_timeout(Duration::from_secs(10))
|
|
.map_err(|_| "macOS notification delivery request timed out".to_string())?
|
|
}
|
|
|
|
pub(crate) async fn show(
|
|
title: String,
|
|
body: Option<String>,
|
|
target: Option<serde_json::Value>,
|
|
) -> Result<(), String> {
|
|
tokio::task::spawn_blocking(move || show_sync(title, body, target))
|
|
.await
|
|
.map_err(|error| format!("macOS notification delivery task failed: {error}"))?
|
|
}
|
|
|
|
fn queue_activation(target: serde_json::Value) {
|
|
let queue = PENDING_ACTIVATIONS.get_or_init(Default::default);
|
|
let Ok(mut queue) = queue.lock() else {
|
|
eprintln!("buzz-desktop: macOS notification activation queue is unavailable");
|
|
return;
|
|
};
|
|
if queue.len() == MAX_PENDING_ACTIVATIONS {
|
|
queue.pop_front();
|
|
}
|
|
queue.push_back(target);
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) fn take_pending_activations() -> Result<Vec<serde_json::Value>, String> {
|
|
let queue = PENDING_ACTIVATIONS.get_or_init(Default::default);
|
|
let mut queue = queue
|
|
.lock()
|
|
.map_err(|_| "macOS notification activation queue is unavailable".to_string())?;
|
|
Ok(queue.drain(..).collect())
|
|
}
|
|
|
|
fn is_bundled_application() -> bool {
|
|
let bundle = NSBundle::mainBundle();
|
|
bundle.bundleIdentifier().is_some()
|
|
&& bundle.executablePath().is_some_and(|executable_path| {
|
|
is_application_bundle_layout(
|
|
Path::new(&bundle.bundlePath().to_string()),
|
|
Path::new(&executable_path.to_string()),
|
|
)
|
|
})
|
|
}
|
|
|
|
fn is_application_bundle_layout(bundle_path: &Path, executable_path: &Path) -> bool {
|
|
let Some(macos_path) = executable_path.parent() else {
|
|
return false;
|
|
};
|
|
let Some(contents_path) = macos_path.parent() else {
|
|
return false;
|
|
};
|
|
|
|
bundle_path
|
|
.extension()
|
|
.is_some_and(|extension| extension == "app")
|
|
&& macos_path.file_name() == Some("MacOS".as_ref())
|
|
&& contents_path.file_name() == Some("Contents".as_ref())
|
|
&& contents_path.parent() == Some(bundle_path)
|
|
}
|
|
|
|
fn target_from_response(response: &UNNotificationResponse) -> Option<serde_json::Value> {
|
|
let user_info = response.notification().request().content().userInfo();
|
|
let key = NSString::from_str(TARGET_USER_INFO_KEY);
|
|
let target = user_info.objectForKey(key.as_ref())?;
|
|
let target = target.downcast::<NSString>().ok()?;
|
|
parse_target(&target.to_string())
|
|
}
|
|
|
|
fn parse_target(serialized: &str) -> Option<serde_json::Value> {
|
|
serde_json::from_str(serialized).ok()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
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 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();
|
|
for index in 0..=MAX_PENDING_ACTIVATIONS {
|
|
queue_activation(serde_json::json!({ "index": index }));
|
|
}
|
|
|
|
let activations = take_pending_activations().expect("activation queue");
|
|
assert_eq!(activations.len(), MAX_PENDING_ACTIVATIONS);
|
|
assert_eq!(activations[0]["index"], 1);
|
|
assert!(take_pending_activations()
|
|
.expect("drained activation queue")
|
|
.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn cargo_test_process_is_not_treated_as_bundled() {
|
|
assert!(!is_bundled_application());
|
|
}
|
|
|
|
#[test]
|
|
fn requires_the_executable_to_use_the_app_bundle_layout() {
|
|
assert!(is_application_bundle_layout(
|
|
Path::new("/Applications/Buzz.app"),
|
|
Path::new("/Applications/Buzz.app/Contents/MacOS/buzz-desktop"),
|
|
));
|
|
assert!(!is_application_bundle_layout(
|
|
Path::new("/tmp/Fake.app"),
|
|
Path::new("/tmp/Fake.app/buzz-desktop"),
|
|
));
|
|
assert!(!is_application_bundle_layout(
|
|
Path::new("/Users/developer/buzz/desktop/src-tauri/target/debug"),
|
|
Path::new("/Users/developer/buzz/desktop/src-tauri/target/debug/buzz-desktop"),
|
|
));
|
|
assert!(!is_application_bundle_layout(
|
|
Path::new("/Applications/Buzz.app"),
|
|
Path::new("/Applications/Other.app/Contents/MacOS/buzz-desktop"),
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn maps_native_authorization_states_to_frontend_contract() {
|
|
assert_eq!(
|
|
permission_state(UNAuthorizationStatus::NotDetermined),
|
|
NotificationPermissionState::Default
|
|
);
|
|
assert_eq!(
|
|
permission_state(UNAuthorizationStatus::Denied),
|
|
NotificationPermissionState::Denied
|
|
);
|
|
for status in [
|
|
UNAuthorizationStatus::Authorized,
|
|
UNAuthorizationStatus::Provisional,
|
|
UNAuthorizationStatus::Ephemeral,
|
|
] {
|
|
assert_eq!(
|
|
permission_state(status),
|
|
NotificationPermissionState::Granted
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parses_opaque_notification_target() {
|
|
let target =
|
|
parse_target(r#"{"channelId":"channel","eventId":"event","threadRootId":"root"}"#)
|
|
.expect("valid target");
|
|
|
|
assert_eq!(target["channelId"], "channel");
|
|
assert_eq!(target["eventId"], "event");
|
|
assert_eq!(target["threadRootId"], "root");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_malformed_notification_target() {
|
|
assert!(parse_target("not-json").is_none());
|
|
}
|
|
}
|