From 1ff0cb2ae02a0532c6f828ef16e13250a949b828 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:33:04 +0100 Subject: [PATCH] fix(desktop): streamline macos permission requests and onboarding --- apps/desktop/src-tauri/src/permissions.rs | 454 +++++++++++++----- .../new-main/useRequestPermission.ts | 15 +- .../src/routes/(window-chrome)/onboarding.tsx | 29 +- apps/desktop/src/utils/os-permissions.test.ts | 96 ++++ apps/desktop/src/utils/os-permissions.ts | 74 +++ 5 files changed, 521 insertions(+), 147 deletions(-) create mode 100644 apps/desktop/src/utils/os-permissions.test.ts create mode 100644 apps/desktop/src/utils/os-permissions.ts diff --git a/apps/desktop/src-tauri/src/permissions.rs b/apps/desktop/src-tauri/src/permissions.rs index f765173e45..5e1364fe91 100644 --- a/apps/desktop/src-tauri/src/permissions.rs +++ b/apps/desktop/src-tauri/src/permissions.rs @@ -1,7 +1,15 @@ use serde::{Deserialize, Serialize}; +#[cfg(target_os = "macos")] +use crate::{general_settings::GeneralSettingsStore, windows::CapWindowId}; #[cfg(target_os = "macos")] use cidre::av; +#[cfg(target_os = "macos")] +use objc2_app_kit::{NSApplicationActivationOptions, NSRunningApplication}; +#[cfg(target_os = "macos")] +use std::{future::Future, str::FromStr, time::Duration}; +#[cfg(target_os = "macos")] +use tauri::Manager; use tracing::instrument; #[cfg(target_os = "macos")] @@ -34,7 +42,237 @@ fn macos_prompt_accessibility_access() { } } -#[derive(Debug, Serialize, Deserialize, specta::Type)] +#[cfg(target_os = "macos")] +fn macos_run_on_main_thread( + app: &tauri::AppHandle, + callback: impl FnOnce() -> R + Send + 'static, +) -> Option { + use std::sync::mpsc; + + let (tx, rx) = mpsc::sync_channel(1); + + if let Err(err) = app.run_on_main_thread(move || { + let _ = tx.send(callback()); + }) { + tracing::warn!("Failed to run permission action on main thread: {err}"); + return None; + } + + rx.recv_timeout(Duration::from_secs(2)).ok() +} + +#[cfg(target_os = "macos")] +fn macos_permission_settings_url(permission: &OSPermission) -> &'static str { + match permission { + OSPermission::ScreenRecording => { + "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture" + } + OSPermission::Camera => { + "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera" + } + OSPermission::Microphone => { + "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone" + } + OSPermission::Accessibility => { + "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" + } + } +} + +#[cfg(target_os = "macos")] +fn macos_permission_needs_settings_fallback(permission: &OSPermission) -> bool { + matches!( + permission, + OSPermission::ScreenRecording | OSPermission::Accessibility + ) +} + +#[cfg(target_os = "macos")] +fn macos_focus_permission_window(app: &tauri::AppHandle) { + if let Some(window) = ["onboarding", "main", "settings"] + .into_iter() + .find_map(|label| app.get_webview_window(label)) + { + let _ = window.show(); + let _ = window.set_focus(); + } +} + +#[cfg(target_os = "macos")] +fn macos_activate_permission_request(app: &tauri::AppHandle) { + if let Err(err) = app.set_activation_policy(tauri::ActivationPolicy::Regular) { + tracing::warn!("Failed to set activation policy to Regular: {err}"); + } + + macos_focus_permission_window(app); + + if let Some(current_app) = unsafe { + NSRunningApplication::runningApplicationWithProcessIdentifier(std::process::id() as _) + } { + unsafe { + current_app + .activateWithOptions(NSApplicationActivationOptions::ActivateIgnoringOtherApps); + } + } +} + +#[cfg(target_os = "macos")] +fn macos_restore_activation_policy(app: &tauri::AppHandle) { + let should_hide_dock = GeneralSettingsStore::get(app) + .ok() + .flatten() + .is_some_and(|settings| settings.hide_dock_icon); + + if should_hide_dock + && app.webview_windows().keys().all(|label| { + CapWindowId::from_str(label) + .map(|window_id| !window_id.activates_dock()) + .unwrap_or(false) + }) + && let Err(err) = app.set_activation_policy(tauri::ActivationPolicy::Accessory) + { + tracing::warn!("Failed to restore activation policy to Accessory: {err}"); + } +} + +#[cfg(target_os = "macos")] +fn macos_permission_status(permission: &OSPermission, initial_check: bool) -> OSPermissionStatus { + match permission { + OSPermission::ScreenRecording => { + let granted = scap_screencapturekit::has_permission(); + match (granted, initial_check) { + (true, _) => OSPermissionStatus::Granted, + (false, true) => OSPermissionStatus::Empty, + (false, false) => OSPermissionStatus::Denied, + } + } + OSPermission::Camera => { + match av::CaptureDevice::authorization_status_for_media_type(av::MediaType::video()) { + Ok(av::AuthorizationStatus::NotDetermined) => OSPermissionStatus::Empty, + Ok(av::AuthorizationStatus::Authorized) => OSPermissionStatus::Granted, + Ok(_) => OSPermissionStatus::Denied, + Err(err) => { + tracing::error!("Failed to query AV permission status: {err}"); + OSPermissionStatus::Denied + } + } + } + OSPermission::Microphone => { + match av::CaptureDevice::authorization_status_for_media_type(av::MediaType::audio()) { + Ok(av::AuthorizationStatus::NotDetermined) => OSPermissionStatus::Empty, + Ok(av::AuthorizationStatus::Authorized) => OSPermissionStatus::Granted, + Ok(_) => OSPermissionStatus::Denied, + Err(err) => { + tracing::error!("Failed to query AV permission status: {err}"); + OSPermissionStatus::Denied + } + } + } + OSPermission::Accessibility => { + if unsafe { AXIsProcessTrusted() } { + OSPermissionStatus::Granted + } else if initial_check { + OSPermissionStatus::Empty + } else { + OSPermissionStatus::Denied + } + } + } +} + +#[cfg(target_os = "macos")] +fn macos_request_permission(app: &tauri::AppHandle, permission: &OSPermission) { + match permission { + OSPermission::ScreenRecording => { + if macos_run_on_main_thread(app, macos_prompt_screen_recording_access).is_none() { + macos_prompt_screen_recording_access(); + } + } + OSPermission::Camera => { + futures::executor::block_on(av::CaptureDevice::request_access_for_media_type( + av::MediaType::video(), + )) + .ok(); + } + OSPermission::Microphone => { + futures::executor::block_on(av::CaptureDevice::request_access_for_media_type( + av::MediaType::audio(), + )) + .ok(); + } + OSPermission::Accessibility => { + if macos_run_on_main_thread(app, macos_prompt_accessibility_access).is_none() { + macos_prompt_accessibility_access(); + } + } + } +} + +#[cfg(target_os = "macos")] +async fn macos_wait_for_permission_update_with( + mut check: TCheck, + mut sleep: impl FnMut() -> TSleep, +) -> bool +where + TCheck: FnMut() -> bool, + TSleep: Future, +{ + if check() { + return true; + } + + for _ in 0..10 { + sleep().await; + if check() { + return true; + } + } + + false +} + +#[cfg(target_os = "macos")] +async fn macos_wait_for_permission_update(permission: &OSPermission) -> bool { + macos_wait_for_permission_update_with( + || macos_permission_status(permission, false).permitted(), + || tokio::time::sleep(Duration::from_millis(200)), + ) + .await +} + +#[cfg(target_os = "macos")] +fn macos_open_permission_settings(app: &tauri::AppHandle, permission: &OSPermission) { + use std::process::Command; + + let process = Command::new("open") + .arg(macos_permission_settings_url(permission)) + .spawn(); + + match process { + Ok(mut process) => { + let app = app.clone(); + tokio::spawn(async move { + match tokio::task::spawn_blocking(move || process.wait()).await { + Ok(Err(err)) => { + tracing::error!("Error waiting for permission settings process: {err}"); + } + Err(err) => { + tracing::error!("Join error waiting for permission settings: {err}"); + } + _ => {} + } + crate::tray::refresh_tray_menu_for_app(&app); + macos_restore_activation_policy(&app); + }); + } + Err(err) => { + tracing::error!("Failed to open permission settings: {err}"); + macos_restore_activation_policy(app); + } + } +} + +#[derive(Debug, Serialize, Deserialize, specta::Type, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum OSPermission { ScreenRecording, @@ -48,53 +286,8 @@ pub enum OSPermission { pub fn open_permission_settings(_app: tauri::AppHandle, _permission: OSPermission) { #[cfg(target_os = "macos")] { - match _permission { - OSPermission::ScreenRecording => macos_prompt_screen_recording_access(), - OSPermission::Accessibility => macos_prompt_accessibility_access(), - _ => {} - } - - use std::process::Command; - - let process = match _permission { - OSPermission::ScreenRecording => Command::new("open") - .arg( - "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture", - ) - .spawn(), - OSPermission::Camera => Command::new("open") - .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Camera") - .spawn(), - OSPermission::Microphone => Command::new("open") - .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone") - .spawn(), - OSPermission::Accessibility => Command::new("open") - .arg( - "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", - ) - .spawn(), - }; - - match process { - Ok(mut process) => { - let app = _app.clone(); - tokio::spawn(async move { - match tokio::task::spawn_blocking(move || process.wait()).await { - Ok(Err(err)) => { - tracing::error!("Error waiting for permission settings process: {err}"); - } - Err(err) => { - tracing::error!("Join error waiting for permission settings: {err}"); - } - _ => {} - } - crate::tray::refresh_tray_menu_for_app(&app); - }); - } - Err(err) => { - tracing::error!("Failed to open permission settings: {err}"); - } - } + macos_activate_permission_request(&_app); + macos_open_permission_settings(&_app, &_permission); } } @@ -104,48 +297,22 @@ pub fn open_permission_settings(_app: tauri::AppHandle, _permission: OSPermissio pub async fn request_permission(_app: tauri::AppHandle, _permission: OSPermission) { #[cfg(target_os = "macos")] { - let needs_activation = - matches!(_permission, OSPermission::Camera | OSPermission::Microphone); + macos_activate_permission_request(&_app); - if needs_activation - && let Err(err) = _app.set_activation_policy(tauri::ActivationPolicy::Regular) - { - tracing::warn!("Failed to set activation policy to Regular: {err}"); - } + let permission = _permission; + let app = _app.clone(); + tauri::async_runtime::spawn_blocking(move || { + macos_request_permission(&app, &permission); + }) + .await + .ok(); - match _permission { - OSPermission::ScreenRecording => { - macos_prompt_screen_recording_access(); - } - OSPermission::Camera => { - tauri::async_runtime::spawn_blocking(|| { - futures::executor::block_on(av::CaptureDevice::request_access_for_media_type( - av::MediaType::video(), - )) - .ok(); - }) - .await - .ok(); - } - OSPermission::Microphone => { - tauri::async_runtime::spawn_blocking(|| { - futures::executor::block_on(av::CaptureDevice::request_access_for_media_type( - av::MediaType::audio(), - )) - .ok(); - }) - .await - .ok(); - } - OSPermission::Accessibility => { - macos_prompt_accessibility_access(); - } - } + let granted = macos_wait_for_permission_update(&_permission).await; - if needs_activation - && let Err(err) = _app.set_activation_policy(tauri::ActivationPolicy::Accessory) - { - tracing::warn!("Failed to restore activation policy to Accessory: {err}"); + if macos_permission_needs_settings_fallback(&_permission) && !granted { + macos_open_permission_settings(&_app, &_permission); + } else { + macos_restore_activation_policy(&_app); } } @@ -191,38 +358,14 @@ impl OSPermissionsCheck { pub fn do_permissions_check(_initial_check: bool) -> OSPermissionsCheck { #[cfg(target_os = "macos")] { - use cidre::av::{AuthorizationStatus, CaptureDevice, MediaType}; - - fn check_av_permission(media_type: &'static MediaType) -> OSPermissionStatus { - match CaptureDevice::authorization_status_for_media_type(media_type) { - Ok(AuthorizationStatus::NotDetermined) => OSPermissionStatus::Empty, - Ok(AuthorizationStatus::Authorized) => OSPermissionStatus::Granted, - Ok(_) => OSPermissionStatus::Denied, - Err(err) => { - tracing::error!("Failed to query AV permission status: {err}"); - OSPermissionStatus::Denied - } - } - } - OSPermissionsCheck { - screen_recording: { - let result = scap_screencapturekit::has_permission(); - match (result, _initial_check) { - (true, _) => OSPermissionStatus::Granted, - (false, true) => OSPermissionStatus::Empty, - (false, false) => OSPermissionStatus::Denied, - } - }, - microphone: check_av_permission(MediaType::audio()), - camera: check_av_permission(MediaType::video()), - accessibility: if unsafe { AXIsProcessTrusted() } { - OSPermissionStatus::Granted - } else if _initial_check { - OSPermissionStatus::Empty - } else { - OSPermissionStatus::Denied - }, + screen_recording: macos_permission_status( + &OSPermission::ScreenRecording, + _initial_check, + ), + microphone: macos_permission_status(&OSPermission::Microphone, _initial_check), + camera: macos_permission_status(&OSPermission::Camera, _initial_check), + accessibility: macos_permission_status(&OSPermission::Accessibility, _initial_check), } } @@ -236,3 +379,78 @@ pub fn do_permissions_check(_initial_check: bool) -> OSPermissionsCheck { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn permission_status_permitted_matches_granted_states() { + assert!(OSPermissionStatus::Granted.permitted()); + assert!(OSPermissionStatus::NotNeeded.permitted()); + assert!(!OSPermissionStatus::Empty.permitted()); + assert!(!OSPermissionStatus::Denied.permitted()); + } + + #[cfg(target_os = "macos")] + #[test] + fn permission_settings_urls_match_expected_privacy_pages() { + assert_eq!( + macos_permission_settings_url(&OSPermission::ScreenRecording), + "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture" + ); + assert_eq!( + macos_permission_settings_url(&OSPermission::Accessibility), + "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" + ); + assert_eq!( + macos_permission_settings_url(&OSPermission::Camera), + "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera" + ); + assert_eq!( + macos_permission_settings_url(&OSPermission::Microphone), + "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone" + ); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn permission_update_wait_returns_true_once_permission_is_observed() { + let mut checks = [false, false, true, true].into_iter(); + + let granted = + macos_wait_for_permission_update_with(|| checks.next().unwrap_or(true), || async {}) + .await; + + assert!(granted); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn permission_update_wait_returns_false_when_permission_never_changes() { + let mut checks = [false, false, false].into_iter(); + + let granted = + macos_wait_for_permission_update_with(|| checks.next().unwrap_or(false), || async {}) + .await; + + assert!(!granted); + } + + #[cfg(target_os = "macos")] + #[test] + fn settings_fallback_only_applies_to_screen_and_accessibility() { + assert!(macos_permission_needs_settings_fallback( + &OSPermission::ScreenRecording + )); + assert!(macos_permission_needs_settings_fallback( + &OSPermission::Accessibility + )); + assert!(!macos_permission_needs_settings_fallback( + &OSPermission::Camera + )); + assert!(!macos_permission_needs_settings_fallback( + &OSPermission::Microphone + )); + } +} diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/useRequestPermission.ts b/apps/desktop/src/routes/(window-chrome)/new-main/useRequestPermission.ts index 3630e4f2a9..cc448962f7 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/useRequestPermission.ts +++ b/apps/desktop/src/routes/(window-chrome)/new-main/useRequestPermission.ts @@ -1,6 +1,7 @@ import { useQueryClient } from "@tanstack/solid-query"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { devicesSnapshot } from "~/utils/devices"; +import { requestAndVerifyPermission } from "~/utils/os-permissions"; import { commands, type OSPermissionStatus } from "~/utils/tauri"; export default function useRequestPermission() { @@ -11,22 +12,10 @@ export default function useRequestPermission() { currentStatus?: OSPermissionStatus, ) { try { - if (currentStatus === "denied") { - await commands.openPermissionSettings(type); - return; - } - const window = getCurrentWindow(); await window.setAlwaysOnTop(false); try { - await commands.requestPermission(type); - - const check = await commands.doPermissionsCheck(false); - const status = type === "camera" ? check.camera : check.microphone; - - if (status !== "granted") { - await commands.openPermissionSettings(type); - } + await requestAndVerifyPermission(commands, type, currentStatus); } finally { await window.setAlwaysOnTop(true); } diff --git a/apps/desktop/src/routes/(window-chrome)/onboarding.tsx b/apps/desktop/src/routes/(window-chrome)/onboarding.tsx index b0079de9ef..3880b40c6d 100644 --- a/apps/desktop/src/routes/(window-chrome)/onboarding.tsx +++ b/apps/desktop/src/routes/(window-chrome)/onboarding.tsx @@ -18,6 +18,10 @@ import { } from "solid-js"; import { createStore } from "solid-js/store"; import { generalSettingsStore } from "~/store"; +import { + isPermissionGranted as isPermitted, + requestAndVerifyPermission, +} from "~/utils/os-permissions"; import { commands, type OSPermission, @@ -108,10 +112,6 @@ const modes: ModeDetail[] = [ }, ]; -function isPermitted(status?: OSPermissionStatus): boolean { - return status === "granted" || status === "notNeeded"; -} - type SetupPermission = { name: string; key: OSPermission; @@ -2001,19 +2001,16 @@ function PermissionsStep(props: { if (requestingPermission()) return; setRequestingPermission(true); try { - await commands.requestPermission(permission); + const status = check()?.[permission] as OSPermissionStatus | undefined; setInitialCheck(false); - const result = await commands.doPermissionsCheck(false); - setCheck(result as unknown as Record); - const notYetPermitted = - (permission === "screenRecording" && - !isPermitted(result.screenRecording)) || - (permission === "accessibility" && !isPermitted(result.accessibility)); - if (notYetPermitted) { - await commands.openPermissionSettings(permission); - if (permission === "screenRecording") { - await maybePromptRestartForScreenRecording(); - } + const result = await requestAndVerifyPermission( + commands, + permission, + status, + ); + setCheck(result.check as unknown as Record); + if (result.openedSettings && permission === "screenRecording") { + await maybePromptRestartForScreenRecording(); } } catch (err) { console.error(`Error requesting permission: ${err}`); diff --git a/apps/desktop/src/utils/os-permissions.test.ts b/apps/desktop/src/utils/os-permissions.test.ts new file mode 100644 index 0000000000..69305d339c --- /dev/null +++ b/apps/desktop/src/utils/os-permissions.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + isPermissionGranted, + permissionStatusFor, + requestAndVerifyPermission, +} from "~/utils/os-permissions"; + +describe("os-permissions", () => { + it("treats only granted and not-needed statuses as permitted", () => { + expect(isPermissionGranted("granted")).toBe(true); + expect(isPermissionGranted("notNeeded")).toBe(true); + expect(isPermissionGranted("empty")).toBe(false); + expect(isPermissionGranted("denied")).toBe(false); + }); + + it("maps a permission key to the matching OS permission status", () => { + const check = { + screenRecording: "granted", + microphone: "empty", + camera: "denied", + accessibility: "notNeeded", + } as const; + + expect(permissionStatusFor(check, "screenRecording")).toBe("granted"); + expect(permissionStatusFor(check, "microphone")).toBe("empty"); + expect(permissionStatusFor(check, "camera")).toBe("denied"); + expect(permissionStatusFor(check, "accessibility")).toBe("notNeeded"); + }); + + it("does not open settings after a successful permission request", async () => { + const client = { + requestPermission: vi.fn().mockResolvedValue(undefined), + openPermissionSettings: vi.fn().mockResolvedValue(undefined), + doPermissionsCheck: vi.fn().mockResolvedValue({ + screenRecording: "empty", + microphone: "granted", + camera: "empty", + accessibility: "empty", + }), + }; + + const result = await requestAndVerifyPermission(client, "microphone"); + + expect(client.requestPermission).toHaveBeenCalledWith("microphone"); + expect(client.openPermissionSettings).not.toHaveBeenCalled(); + expect(result.status).toBe("granted"); + expect(result.openedSettings).toBe(false); + }); + + it("opens settings when the OS still reports the permission as ungranted", async () => { + const client = { + requestPermission: vi.fn().mockResolvedValue(undefined), + openPermissionSettings: vi.fn().mockResolvedValue(undefined), + doPermissionsCheck: vi.fn().mockResolvedValue({ + screenRecording: "denied", + microphone: "empty", + camera: "empty", + accessibility: "empty", + }), + }; + + const result = await requestAndVerifyPermission(client, "screenRecording"); + + expect(client.requestPermission).toHaveBeenCalledWith("screenRecording"); + expect(client.openPermissionSettings).toHaveBeenCalledWith( + "screenRecording", + ); + expect(result.status).toBe("denied"); + expect(result.openedSettings).toBe(true); + }); + + it("skips the native request and goes straight to settings for denied permissions", async () => { + const client = { + requestPermission: vi.fn().mockResolvedValue(undefined), + openPermissionSettings: vi.fn().mockResolvedValue(undefined), + doPermissionsCheck: vi.fn().mockResolvedValue({ + screenRecording: "empty", + microphone: "empty", + camera: "empty", + accessibility: "denied", + }), + }; + + const result = await requestAndVerifyPermission( + client, + "accessibility", + "denied", + ); + + expect(client.requestPermission).not.toHaveBeenCalled(); + expect(client.openPermissionSettings).toHaveBeenCalledWith("accessibility"); + expect(result.status).toBe("denied"); + expect(result.openedSettings).toBe(true); + }); +}); diff --git a/apps/desktop/src/utils/os-permissions.ts b/apps/desktop/src/utils/os-permissions.ts new file mode 100644 index 0000000000..64934d0147 --- /dev/null +++ b/apps/desktop/src/utils/os-permissions.ts @@ -0,0 +1,74 @@ +import type { + OSPermission, + OSPermissionStatus, + OSPermissionsCheck, +} from "~/utils/tauri"; + +export function isPermissionGranted(status?: OSPermissionStatus): boolean { + return status === "granted" || status === "notNeeded"; +} + +export function permissionStatusFor( + check: OSPermissionsCheck, + permission: OSPermission, +): OSPermissionStatus { + switch (permission) { + case "screenRecording": + return check.screenRecording; + case "microphone": + return check.microphone; + case "camera": + return check.camera; + case "accessibility": + return check.accessibility; + } +} + +type PermissionClient = { + requestPermission: (permission: OSPermission) => Promise; + openPermissionSettings: (permission: OSPermission) => Promise; + doPermissionsCheck: (initialCheck: boolean) => Promise; +}; + +export type PermissionRequestResult = { + check: OSPermissionsCheck; + status: OSPermissionStatus; + openedSettings: boolean; +}; + +export async function requestAndVerifyPermission( + client: PermissionClient, + permission: OSPermission, + currentStatus?: OSPermissionStatus, +): Promise { + if (currentStatus === "denied") { + await client.openPermissionSettings(permission); + const check = await client.doPermissionsCheck(false); + return { + check, + status: permissionStatusFor(check, permission), + openedSettings: true, + }; + } + + await client.requestPermission(permission); + + const check = await client.doPermissionsCheck(false); + const status = permissionStatusFor(check, permission); + + if (isPermissionGranted(status)) { + return { + check, + status, + openedSettings: false, + }; + } + + await client.openPermissionSettings(permission); + + return { + check, + status, + openedSettings: true, + }; +}