diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 59496dabd4f..c0f6a2ddc58 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -674,6 +674,47 @@ const NOTIFICATIONS_LEARN_MORE_URL: &str = pub const NOTIFICATIONS_TROUBLESHOOT_URL: &str = "https://docs.warp.dev/terminal/more-features/notifications#troubleshooting-notifications"; +/// Bundle identifier for the Notifications pane on macOS 13 (Ventura) and later, where System +/// Preferences was replaced by System Settings and the pane moved to an extension bundle. +#[cfg(target_os = "macos")] +const MAC_SYSTEM_SETTINGS_NOTIFICATIONS_PANE_ID: &str = + "com.apple.Notifications-Settings.extension"; + +/// Bundle identifier for the Notifications pane on pre-Ventura macOS (System Preferences), +/// down through the project's minimum-supported macOS 10.14. +#[cfg(target_os = "macos")] +const MAC_LEGACY_SYSTEM_PREFERENCES_NOTIFICATIONS_PANE_ID: &str = + "com.apple.preference.notifications"; + +/// Builds the `x-apple.systempreferences:` URL that deep-links into the Notifications pane of +/// macOS System Settings (or System Preferences on older macOS), given whether the current OS +/// is in the "System Settings" era (see [`warp_core::macos::is_system_settings_era`]) and the +/// app's bundle identifier. Split out from [`mac_notification_settings_url`] so both branches can +/// be exercised deterministically in tests regardless of which macOS version they run on. +/// +/// When possible, the URL is scoped with Warp's own bundle identifier (via the undocumented but +/// widely-relied-upon `?id=` query parameter) so the user lands directly on Warp's entry instead +/// of the generic Notifications list. +#[cfg(target_os = "macos")] +fn mac_notification_settings_url_for(is_system_settings_era: bool, app_id: &str) -> String { + let pane_id = if is_system_settings_era { + MAC_SYSTEM_SETTINGS_NOTIFICATIONS_PANE_ID + } else { + MAC_LEGACY_SYSTEM_PREFERENCES_NOTIFICATIONS_PANE_ID + }; + format!("x-apple.systempreferences:{pane_id}?id={app_id}") +} + +/// Builds the `x-apple.systempreferences:` URL that deep-links into the Notifications pane of +/// macOS System Settings for the current OS version and app. +#[cfg(target_os = "macos")] +fn mac_notification_settings_url() -> String { + mac_notification_settings_url_for( + warp_core::macos::is_system_settings_era(), + &ChannelState::app_id().to_string(), + ) +} + const DEBOUNCE_PERIOD: Duration = Duration::from_millis(40); /// Key used in user preferences to persist the "don't show again" choice for the OSC 52 @@ -785,7 +826,7 @@ pub struct BlockNotification { } /// The reason for sending/discovering the notification -#[derive(Copy, Clone, Debug, Serialize)] +#[derive(Copy, Clone, Debug, Serialize, PartialEq, Eq)] pub enum NotificationsTrigger { LongRunningCommand(bool /* command_succeeded */, Duration), AgentTaskCompleted(bool /* task_succeeded */), @@ -25312,6 +25353,10 @@ impl TerminalView { } }); } + #[cfg(target_os = "macos")] + OpenSystemSettings => { + ctx.open_url(&mac_notification_settings_url()); + } } send_telemetry_from_ctx!(TelemetryEvent::NotificationsErrorBannerAction(action), ctx); @@ -25430,6 +25475,10 @@ impl TerminalView { NotificationsDiscoveryBanner::Closed; ctx.notify(); } + #[cfg(target_os = "macos")] + OpenSystemSettings => { + ctx.open_url(&mac_notification_settings_url()); + } } send_telemetry_from_ctx!( diff --git a/app/src/terminal/view/inline_banner/notifications_discovery.rs b/app/src/terminal/view/inline_banner/notifications_discovery.rs index fdb9d99f567..939fc081cf9 100644 --- a/app/src/terminal/view/inline_banner/notifications_discovery.rs +++ b/app/src/terminal/view/inline_banner/notifications_discovery.rs @@ -1,3 +1,4 @@ +use pathfinder_color::ColorU; use serde::Serialize; use warpui::Element; use warpui::elements::MouseStateHandle; @@ -11,13 +12,18 @@ use crate::appearance::Appearance; use crate::terminal::session_settings::NotificationsMode; use crate::terminal::view::{InlineBannerId, NotificationsTrigger, TerminalAction}; -#[derive(Clone, Copy, Debug, Serialize)] +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] pub enum NotificationsDiscoveryBannerAction { LearnMore, Troubleshoot, TurnOn(NotificationsTrigger), Configure, Close, + /// Opens the Notifications pane of System Settings, deep-linked to Warp's own entry when + /// possible. Only offered once the user has denied the OS-level permissions request, since + /// macOS won't show the request again. + #[cfg(target_os = "macos")] + OpenSystemSettings, } #[derive(Default)] @@ -27,6 +33,8 @@ pub struct NotificationsDiscoveryBannerMouseStates { pub turn_on: MouseStateHandle, pub configure: MouseStateHandle, pub close: MouseStateHandle, + #[cfg(target_os = "macos")] + pub open_system_settings: MouseStateHandle, } /// State necessary to render the (singleton) notifications discovery banner. @@ -35,15 +43,16 @@ pub struct NotificationsDiscoveryBannerState { pub mouse_states: NotificationsDiscoveryBannerMouseStates, } -pub fn render_inline_notifications_discovery_banner( +/// Builds the title and (non-close) buttons offered by the banner for the given mode/outcome. +/// Extracted from [`render_inline_notifications_discovery_banner`] so tests can assert on +/// exactly which actions are offered without needing to introspect the rendered `Element` tree. +fn notifications_discovery_banner_title_and_buttons( trigger: NotificationsTrigger, request_outcome: Option, state: &NotificationsDiscoveryBannerState, notifications_mode: NotificationsMode, - appearance: &Appearance, -) -> Box { - let active_ui_text_color = appearance.theme().active_ui_text_color().into_solid(); - + active_ui_text_color: ColorU, +) -> (&'static str, Vec) { let learn_more_button = InlineBannerTextButton { text: "Learn more".to_string(), text_color: active_ui_text_color, @@ -102,49 +111,87 @@ pub fn render_inline_notifications_discovery_banner( NotificationsMode::Enabled => { // Determine the messaging based on what the user's response was to the // permissions request (if any) - let (title, docs_button) = match request_outcome { + let (title, mut leading_buttons) = match request_outcome { Some(request_outcome) => match request_outcome { RequestPermissionsOutcome::Accepted => ( "Success! You are now ready to receive desktop notifications.", - learn_more_button, - ), - RequestPermissionsOutcome::PermissionsDenied => ( - "Warp was denied permissions to send you notifications.", - troubleshoot_button, + vec![learn_more_button], ), + // One push below is macOS-only, so this can't be a single `vec![...]` + // literal on all platforms. + #[allow(clippy::vec_init_then_push)] + RequestPermissionsOutcome::PermissionsDenied => { + let mut buttons = vec![]; + // Once macOS has denied the request, it won't show the OS prompt again, + // so offer a direct path to System Settings instead. + #[cfg(target_os = "macos")] + buttons.push(InlineBannerTextButton { + text: "Open System Settings".to_string(), + text_color: active_ui_text_color, + button_state: InlineBannerButtonState { + on_click_event: TerminalAction::NotificationsDiscoveryBanner( + NotificationsDiscoveryBannerAction::OpenSystemSettings, + ), + mouse_state_handle: state.mouse_states.open_system_settings.clone(), + }, + font: Default::default(), + position_id: None, + variant: InlineBannerTextButtonVariant::Primary, + }); + buttons.push(troubleshoot_button); + ( + "Warp was denied permissions to send you notifications.", + buttons, + ) + } RequestPermissionsOutcome::OtherError { .. } => ( "Something went wrong while requesting permissions.", - troubleshoot_button, + vec![troubleshoot_button], ), }, None => ( "Don't forget to 'Allow' the permissions request to finish setting up notifications.", - learn_more_button, + vec![learn_more_button], ), }; - ( - title, - vec![ - docs_button, - InlineBannerTextButton { - text: "Configure notifications".to_string(), - text_color: active_ui_text_color, - button_state: InlineBannerButtonState { - on_click_event: TerminalAction::NotificationsDiscoveryBanner( - NotificationsDiscoveryBannerAction::Configure, - ), - mouse_state_handle: state.mouse_states.configure.clone(), - }, - font: Default::default(), - position_id: None, - variant: InlineBannerTextButtonVariant::Secondary, - }, - ], - ) + leading_buttons.push(InlineBannerTextButton { + text: "Configure notifications".to_string(), + text_color: active_ui_text_color, + button_state: InlineBannerButtonState { + on_click_event: TerminalAction::NotificationsDiscoveryBanner( + NotificationsDiscoveryBannerAction::Configure, + ), + mouse_state_handle: state.mouse_states.configure.clone(), + }, + font: Default::default(), + position_id: None, + variant: InlineBannerTextButtonVariant::Secondary, + }); + + (title, leading_buttons) } }; + (title, buttons) +} + +pub fn render_inline_notifications_discovery_banner( + trigger: NotificationsTrigger, + request_outcome: Option, + state: &NotificationsDiscoveryBannerState, + notifications_mode: NotificationsMode, + appearance: &Appearance, +) -> Box { + let active_ui_text_color = appearance.theme().active_ui_text_color().into_solid(); + let (title, buttons) = notifications_discovery_banner_title_and_buttons( + trigger, + request_outcome, + state, + notifications_mode, + active_ui_text_color, + ); + let close_button = InlineBannerCloseButton(InlineBannerButtonState { on_click_event: TerminalAction::NotificationsDiscoveryBanner( NotificationsDiscoveryBannerAction::Close, @@ -163,3 +210,7 @@ pub fn render_inline_notifications_discovery_banner( }, ) } + +#[cfg(test)] +#[path = "notifications_discovery_tests.rs"] +mod tests; diff --git a/app/src/terminal/view/inline_banner/notifications_discovery_tests.rs b/app/src/terminal/view/inline_banner/notifications_discovery_tests.rs new file mode 100644 index 00000000000..969d8ed20e8 --- /dev/null +++ b/app/src/terminal/view/inline_banner/notifications_discovery_tests.rs @@ -0,0 +1,105 @@ +use pathfinder_color::ColorU; + +use super::*; + +fn state() -> NotificationsDiscoveryBannerState { + NotificationsDiscoveryBannerState { + banner_id: 0, + mouse_states: Default::default(), + } +} + +fn has_action( + buttons: &[InlineBannerTextButton], + action: NotificationsDiscoveryBannerAction, +) -> bool { + buttons.iter().any(|button| { + matches!( + &button.button_state.on_click_event, + TerminalAction::NotificationsDiscoveryBanner(a) if *a == action + ) + }) +} + +#[cfg(target_os = "macos")] +#[test] +fn enabled_permissions_denied_offers_open_system_settings() { + let (_, buttons) = notifications_discovery_banner_title_and_buttons( + NotificationsTrigger::NeedsAttention, + Some(RequestPermissionsOutcome::PermissionsDenied), + &state(), + NotificationsMode::Enabled, + ColorU::white(), + ); + + assert!(has_action( + &buttons, + NotificationsDiscoveryBannerAction::OpenSystemSettings + )); + assert!(has_action( + &buttons, + NotificationsDiscoveryBannerAction::Troubleshoot + )); + assert!(has_action( + &buttons, + NotificationsDiscoveryBannerAction::Configure + )); +} + +#[cfg(not(target_os = "macos"))] +#[test] +fn enabled_permissions_denied_offers_no_system_settings_cta_on_non_mac() { + let (_, buttons) = notifications_discovery_banner_title_and_buttons( + NotificationsTrigger::NeedsAttention, + Some(RequestPermissionsOutcome::PermissionsDenied), + &state(), + NotificationsMode::Enabled, + ColorU::white(), + ); + + assert!(has_action( + &buttons, + NotificationsDiscoveryBannerAction::Troubleshoot + )); + assert!(has_action( + &buttons, + NotificationsDiscoveryBannerAction::Configure + )); +} + +#[test] +fn enabled_accepted_does_not_offer_open_system_settings() { + let (_, buttons) = notifications_discovery_banner_title_and_buttons( + NotificationsTrigger::NeedsAttention, + Some(RequestPermissionsOutcome::Accepted), + &state(), + NotificationsMode::Enabled, + ColorU::white(), + ); + + #[cfg(target_os = "macos")] + assert!(!has_action( + &buttons, + NotificationsDiscoveryBannerAction::OpenSystemSettings + )); + assert!(has_action( + &buttons, + NotificationsDiscoveryBannerAction::Configure + )); +} + +#[test] +fn unset_offers_turn_on() { + let (_, buttons) = notifications_discovery_banner_title_and_buttons( + NotificationsTrigger::NeedsAttention, + None, + &state(), + NotificationsMode::Unset, + ColorU::white(), + ); + + assert!(has_action( + &buttons, + NotificationsDiscoveryBannerAction::TurnOn(NotificationsTrigger::NeedsAttention) + )); +} diff --git a/app/src/terminal/view/inline_banner/notifications_error.rs b/app/src/terminal/view/inline_banner/notifications_error.rs index 35ab78e08fc..13fb5a463ca 100644 --- a/app/src/terminal/view/inline_banner/notifications_error.rs +++ b/app/src/terminal/view/inline_banner/notifications_error.rs @@ -1,3 +1,4 @@ +use pathfinder_color::ColorU; use serde::Serialize; use warpui::Element; use warpui::elements::MouseStateHandle; @@ -10,9 +11,14 @@ use super::{ use crate::appearance::Appearance; use crate::terminal::view::{InlineBannerId, TerminalAction}; -#[derive(Clone, Copy, Debug, Serialize)] +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] pub enum NotificationsErrorBannerAction { SetPermissions, + /// Opens the Notifications pane of System Settings, deep-linked to Warp's own entry when + /// possible. Only offered once the user has already denied the OS-level permissions request, + /// since macOS won't show the request again and `SetPermissions` would be a no-op. + #[cfg(target_os = "macos")] + OpenSystemSettings, Troubleshoot, Close, } @@ -22,6 +28,8 @@ pub struct NotificationsErrorBannerMouseStates { pub troubleshoot: MouseStateHandle, pub close: MouseStateHandle, pub set_permissions: MouseStateHandle, + #[cfg(target_os = "macos")] + pub open_system_settings: MouseStateHandle, } /// State necessary to render the (singleton) notifications error banner. @@ -30,14 +38,14 @@ pub struct NotificationsErrorBannerState { pub mouse_states: NotificationsErrorBannerMouseStates, } -pub fn render_inline_notifications_error_banner( - title: &str, - state: &NotificationsErrorBannerState, +/// Builds the (non-close) buttons offered by the banner for the given error state. Extracted +/// from [`render_inline_notifications_error_banner`] so tests can assert on exactly which +/// actions are offered without needing to introspect the rendered `Element` tree. +fn notifications_error_banner_buttons( error: &Option, - appearance: &Appearance, -) -> Box { - let active_ui_text_color = appearance.theme().active_ui_text_color().into_solid(); - + state: &NotificationsErrorBannerState, + active_ui_text_color: ColorU, +) -> Vec { let mut buttons: Vec = vec![]; // If permissions haven't been granted or denied, add a button to set the permissions. @@ -57,6 +65,25 @@ pub fn render_inline_notifications_error_banner( }); } + // If the user has already denied permissions, re-requesting them is a no-op on macOS (the + // system won't show the prompt again), so offer a direct path to System Settings instead. + #[cfg(target_os = "macos")] + if matches!(error, Some(NotificationSendError::PermissionsDenied)) { + buttons.push(InlineBannerTextButton { + text: "Open System Settings".to_string(), + text_color: active_ui_text_color, + button_state: InlineBannerButtonState { + on_click_event: TerminalAction::NotificationsErrorBanner( + NotificationsErrorBannerAction::OpenSystemSettings, + ), + mouse_state_handle: state.mouse_states.open_system_settings.clone(), + }, + font: Default::default(), + position_id: None, + variant: InlineBannerTextButtonVariant::Primary, + }); + } + buttons.push(InlineBannerTextButton { text: "Troubleshoot".to_string(), text_color: active_ui_text_color, @@ -71,6 +98,18 @@ pub fn render_inline_notifications_error_banner( variant: InlineBannerTextButtonVariant::Secondary, }); + buttons +} + +pub fn render_inline_notifications_error_banner( + title: &str, + state: &NotificationsErrorBannerState, + error: &Option, + appearance: &Appearance, +) -> Box { + let active_ui_text_color = appearance.theme().active_ui_text_color().into_solid(); + let buttons = notifications_error_banner_buttons(error, state, active_ui_text_color); + let close_button = InlineBannerCloseButton(InlineBannerButtonState { on_click_event: TerminalAction::NotificationsErrorBanner( NotificationsErrorBannerAction::Close, @@ -89,3 +128,7 @@ pub fn render_inline_notifications_error_banner( }, ) } + +#[cfg(test)] +#[path = "notifications_error_tests.rs"] +mod tests; diff --git a/app/src/terminal/view/inline_banner/notifications_error_tests.rs b/app/src/terminal/view/inline_banner/notifications_error_tests.rs new file mode 100644 index 00000000000..6ba32e3d8d2 --- /dev/null +++ b/app/src/terminal/view/inline_banner/notifications_error_tests.rs @@ -0,0 +1,103 @@ +use pathfinder_color::ColorU; + +use super::*; + +fn state() -> NotificationsErrorBannerState { + NotificationsErrorBannerState { + banner_id: 0, + mouse_states: Default::default(), + } +} + +fn has_action(buttons: &[InlineBannerTextButton], action: NotificationsErrorBannerAction) -> bool { + buttons.iter().any(|button| { + matches!( + &button.button_state.on_click_event, + TerminalAction::NotificationsErrorBanner(a) if *a == action + ) + }) +} + +#[test] +fn permissions_not_yet_granted_offers_set_permissions_but_not_open_system_settings() { + let buttons = notifications_error_banner_buttons( + &Some(NotificationSendError::PermissionsNotYetGranted), + &state(), + ColorU::white(), + ); + + assert!(has_action( + &buttons, + NotificationsErrorBannerAction::SetPermissions + )); + #[cfg(target_os = "macos")] + assert!(!has_action( + &buttons, + NotificationsErrorBannerAction::OpenSystemSettings + )); + assert!(has_action( + &buttons, + NotificationsErrorBannerAction::Troubleshoot + )); +} + +#[cfg(target_os = "macos")] +#[test] +fn permissions_denied_offers_open_system_settings_but_not_set_permissions() { + let buttons = notifications_error_banner_buttons( + &Some(NotificationSendError::PermissionsDenied), + &state(), + ColorU::white(), + ); + + assert!(has_action( + &buttons, + NotificationsErrorBannerAction::OpenSystemSettings + )); + assert!(!has_action( + &buttons, + NotificationsErrorBannerAction::SetPermissions + )); + assert!(has_action( + &buttons, + NotificationsErrorBannerAction::Troubleshoot + )); +} + +#[cfg(not(target_os = "macos"))] +#[test] +fn permissions_denied_offers_no_actionable_button_on_non_mac() { + let buttons = notifications_error_banner_buttons( + &Some(NotificationSendError::PermissionsDenied), + &state(), + ColorU::white(), + ); + + assert!(!has_action( + &buttons, + NotificationsErrorBannerAction::SetPermissions + )); + assert!(has_action( + &buttons, + NotificationsErrorBannerAction::Troubleshoot + )); +} + +#[test] +fn no_error_offers_only_troubleshoot() { + let buttons = notifications_error_banner_buttons(&None, &state(), ColorU::white()); + + assert!(!has_action( + &buttons, + NotificationsErrorBannerAction::SetPermissions + )); + #[cfg(target_os = "macos")] + assert!(!has_action( + &buttons, + NotificationsErrorBannerAction::OpenSystemSettings + )); + assert!(has_action( + &buttons, + NotificationsErrorBannerAction::Troubleshoot + )); +} diff --git a/app/src/terminal/view_tests.rs b/app/src/terminal/view_tests.rs index 50a44575b6f..a3aa5dbcd77 100644 --- a/app/src/terminal/view_tests.rs +++ b/app/src/terminal/view_tests.rs @@ -9183,3 +9183,152 @@ fn back_button_label_resolves_token_only_parent_linkage() { }); }); } + +#[cfg(target_os = "macos")] +#[test] +fn mac_notification_settings_url_for_selects_pane_by_os_era() { + let app_id = "dev.warp.Warp-Local"; + + assert_eq!( + mac_notification_settings_url_for(true, app_id), + "x-apple.systempreferences:com.apple.Notifications-Settings.extension?id=dev.warp.Warp-Local" + ); + assert_eq!( + mac_notification_settings_url_for(false, app_id), + "x-apple.systempreferences:com.apple.preference.notifications?id=dev.warp.Warp-Local" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn mac_notification_settings_url_uses_current_app_id() { + let url = mac_notification_settings_url(); + let app_id = warp_core::channel::ChannelState::app_id().to_string(); + assert!( + url.ends_with(&format!("?id={app_id}")), + "expected the URL to be scoped to the current app ID ({app_id}), got: {url}" + ); + assert!( + url.starts_with(&format!( + "x-apple.systempreferences:{MAC_SYSTEM_SETTINGS_NOTIFICATIONS_PANE_ID}" + )) || url.starts_with(&format!( + "x-apple.systempreferences:{MAC_LEGACY_SYSTEM_PREFERENCES_NOTIFICATIONS_PANE_ID}" + )), + "expected a deep link into a known Notifications pane, got: {url}" + ); +} + +/// Regression test for the denied-notifications error banner: drives the banner into a real +/// `PermissionsDenied` state via the same production code path a failed notification send +/// takes, then dispatches the exact action the rendered CTA fires, and captures the URL that +/// reaches the platform's open-url hook. +#[cfg(target_os = "macos")] +#[test] +fn error_banner_open_system_settings_action_opens_correct_url() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + let terminal = add_window_with_terminal(&mut app, None); + + let opened_urls = Rc::new(RefCell::new(Vec::new())); + let opened_urls_clone = opened_urls.clone(); + app.update(|ctx| { + ctx.set_before_open_url(move |url, _ctx| { + opened_urls_clone.borrow_mut().push(url.to_string()); + url.to_string() + }); + }); + + terminal.update(&mut app, |view, ctx| { + view.show_notification_error(NotificationSendError::PermissionsDenied, ctx); + }); + + // Confirm we actually reached the denied state the CTA's render condition checks. + terminal.read(&app, |view, _| { + assert!( + matches!( + view.inline_banners_state + .notifications_error_banner + .banner_type, + NotificationsErrorBannerType::Open { .. } + ), + "expected the error banner to be open" + ); + assert!(matches!( + view.inline_banners_state.notifications_error_banner.error, + Some(NotificationSendError::PermissionsDenied) + )); + }); + + // Dispatch the exact action the rendered "Open System Settings" button fires. + terminal.update(&mut app, |view, ctx| { + view.notifications_error_banner_action( + NotificationsErrorBannerAction::OpenSystemSettings, + ctx, + ); + }); + + let urls = opened_urls.borrow(); + assert_eq!( + urls.len(), + 1, + "expected exactly one URL to be opened: {urls:?}" + ); + let app_id = warp_core::channel::ChannelState::app_id().to_string(); + assert!( + urls[0].ends_with(&format!("?id={app_id}")), + "unexpected URL opened: {}", + urls[0] + ); + }); +} + +/// Regression test for the notifications discovery banner's denied-outcome branch (the banner +/// shown immediately after the user denies the macOS permission prompt from "Enable"). +#[cfg(target_os = "macos")] +#[test] +fn discovery_banner_open_system_settings_action_opens_correct_url() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + let terminal = add_window_with_terminal(&mut app, None); + + let opened_urls = Rc::new(RefCell::new(Vec::new())); + let opened_urls_clone = opened_urls.clone(); + app.update(|ctx| { + ctx.set_before_open_url(move |url, _ctx| { + opened_urls_clone.borrow_mut().push(url.to_string()); + url.to_string() + }); + }); + + terminal.update(&mut app, |view, ctx| { + let banner_id = view.inline_banners_state.next_banner_id(); + view.inline_banners_state.notifications_discovery_banner = + NotificationsDiscoveryBanner::Open { + trigger: NotificationsTrigger::NeedsAttention, + request_outcome: Some(RequestPermissionsOutcome::PermissionsDenied), + state: NotificationsDiscoveryBannerState { + banner_id, + mouse_states: Default::default(), + }, + }; + + view.notifications_discovery_banner_action( + NotificationsDiscoveryBannerAction::OpenSystemSettings, + ctx, + ); + }); + + let urls = opened_urls.borrow(); + assert_eq!( + urls.len(), + 1, + "expected exactly one URL to be opened: {urls:?}" + ); + let app_id = warp_core::channel::ChannelState::app_id().to_string(); + assert!( + urls[0].ends_with(&format!("?id={app_id}")), + "unexpected URL opened: {}", + urls[0] + ); + }); +} diff --git a/crates/warp_core/Cargo.toml b/crates/warp_core/Cargo.toml index 701160579da..9fbc87721ca 100644 --- a/crates/warp_core/Cargo.toml +++ b/crates/warp_core/Cargo.toml @@ -70,6 +70,7 @@ objc2-foundation = { workspace = true, features = [ "NSURL", "NSFileManager", "NSBundle", + "NSProcessInfo", ] } tempfile.workspace = true diff --git a/crates/warp_core/src/macos.rs b/crates/warp_core/src/macos.rs index 4aba9df781d..b7f9f695029 100644 --- a/crates/warp_core/src/macos.rs +++ b/crates/warp_core/src/macos.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use objc2_foundation::NSBundle; +use objc2_foundation::{NSBundle, NSOperatingSystemVersion, NSProcessInfo}; /// Apple Developer Team ID used for code signing and validation. pub const APPLE_TEAM_ID: &str = "2BBY89MBSN"; @@ -10,3 +10,20 @@ pub fn get_bundle_path() -> Result { let path = bundle.bundlePath(); Ok(path.to_string()) } + +/// macOS 13.0 (Ventura), the version at which System Preferences was replaced by System +/// Settings. Several `x-apple.systempreferences:` deep-link pane identifiers changed at this +/// boundary (e.g. notifications moved from `com.apple.preference.notifications` to +/// `com.apple.Notifications-Settings.extension`). +const SYSTEM_SETTINGS_MIN_VERSION: NSOperatingSystemVersion = NSOperatingSystemVersion { + majorVersion: 13, + minorVersion: 0, + patchVersion: 0, +}; + +/// Returns whether the running macOS version uses "System Settings" (Ventura/13.0 and later) +/// rather than the older "System Preferences" (pre-Ventura, back through the project's +/// minimum-supported 10.14). +pub fn is_system_settings_era() -> bool { + NSProcessInfo::processInfo().isOperatingSystemAtLeastVersion(SYSTEM_SETTINGS_MIN_VERSION) +}