diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index ef8e3e5f9..2cda2830c 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1302,7 +1302,7 @@ pub fn run() { tokio::spawn(updater::check_for_updates(app.handle().clone())); - if permissions::do_permissions_check().necessary_granted() { + if permissions::do_permissions_check(true).necessary_granted() { open_main_window(app_handle.clone()); } else { permissions::open_permissions_window(app); diff --git a/apps/desktop/src-tauri/src/permissions.rs b/apps/desktop/src-tauri/src/permissions.rs index d4ee20868..d6365d566 100644 --- a/apps/desktop/src-tauri/src/permissions.rs +++ b/apps/desktop/src-tauri/src/permissions.rs @@ -1,138 +1,153 @@ -use std::process::Command; - use serde::{Deserialize, Serialize}; use tauri::{Manager, WebviewUrl, WebviewWindow, Wry}; +#[cfg(target_os = "macos")] +use nokhwa_bindings_macos::{AVAuthorizationStatus, AVMediaType}; + #[derive(Serialize, Deserialize, specta::Type)] #[serde(rename_all = "camelCase")] -pub enum MacOSPermissionSettings { +pub enum OSPermission { ScreenRecording, Camera, Microphone, } -#[derive(Serialize, Deserialize, specta::Type)] -#[serde(rename_all = "camelCase")] -pub enum OSPermissionSettings { - MacOS(MacOSPermissionSettings), -} - #[tauri::command] #[specta::specta] -pub fn open_permission_settings(settings: OSPermissionSettings) { - match settings { - OSPermissionSettings::MacOS(macos) => match macos { - MacOSPermissionSettings::ScreenRecording => { +pub fn open_permission_settings(permission: OSPermission) { + #[cfg(target_os = "macos")] + { + use std::process::Command; + + match permission { + OSPermission::ScreenRecording => { Command::new("open") - .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") - .spawn() - .expect("Failed to open Screen Recording settings"); + .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") + .spawn() + .expect("Failed to open Screen Recording settings"); } - MacOSPermissionSettings::Camera => { + OSPermission::Camera => { Command::new("open") .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Camera") .spawn() .expect("Failed to open Camera settings"); } - MacOSPermissionSettings::Microphone => { + OSPermission::Microphone => { Command::new("open") .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone") .spawn() .expect("Failed to open Microphone settings"); } - }, + } } } #[tauri::command] #[specta::specta] -pub async fn request_permission(permission: OSPermissionSettings) { - match permission { - OSPermissionSettings::MacOS(macos) => { - #[cfg(target_os = "macos")] - { - use objc::{runtime::*, *}; - let cls = class!(AVCaptureDevice); - use nokhwa_bindings_macos::core_media::AVMediaTypeAudio; - use tauri_nspanel::block::ConcreteBlock; - - match macos { - MacOSPermissionSettings::ScreenRecording => { - scap::request_permission(); - } - MacOSPermissionSettings::Camera => { - nokhwa::nokhwa_initialize(|_| {}); - } - MacOSPermissionSettings::Microphone => unsafe { - let wrapper = move |_: BOOL| {}; - - let objc_fn_block: ConcreteBlock<(BOOL,), (), _> = - ConcreteBlock::new(wrapper); - let objc_fn_pass = objc_fn_block.copy(); - let _: () = msg_send![cls, requestAccessForMediaType:(AVMediaTypeAudio.clone()) completionHandler:objc_fn_pass]; - }, - } +pub async fn request_permission(permission: OSPermission) { + #[cfg(target_os = "macos")] + { + match permission { + OSPermission::ScreenRecording => { + scap::request_permission(); } + OSPermission::Camera => request_av_permission(AVMediaType::Video), + OSPermission::Microphone => request_av_permission(AVMediaType::Audio), } } } -#[derive(Serialize, Deserialize, specta::Type)] +#[cfg(target_os = "macos")] +fn request_av_permission(media_type: AVMediaType) { + use objc::{runtime::*, *}; + use tauri_nspanel::block::ConcreteBlock; + + let callback = move |_: BOOL| {}; + let cls = class!(AVCaptureDevice); + let objc_fn_block: ConcreteBlock<(BOOL,), (), _> = ConcreteBlock::new(callback); + let objc_fn_pass = objc_fn_block.copy(); + unsafe { + let _: () = msg_send![cls, requestAccessForMediaType:media_type.into_ns_str() completionHandler:objc_fn_pass]; + }; +} + +#[derive(Serialize, Deserialize, Debug, specta::Type)] #[serde(rename_all = "camelCase")] -pub struct MacOSPermissionsCheck { - screen_recording: bool, - microphone: bool, - camera: bool, +pub enum OSPermissionStatus { + // This platform does not require this permission + NotNeeded, + // The user has neither granted nor denied permission + Empty, + // The user has explicitly granted permission + Granted, + // The user has denied permission, or has granted it but not yet restarted + Denied, } -#[derive(Serialize, Deserialize, specta::Type)] -#[serde(rename_all = "camelCase", tag = "os")] -pub enum OSPermissionsCheck { - MacOS(MacOSPermissionsCheck), - Other, +impl OSPermissionStatus { + fn permitted(&self) -> bool { + match self { + Self::NotNeeded | Self::Granted => true, + _ => false, + } + } +} + +#[derive(Serialize, Deserialize, Debug, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct OSPermissionsCheck { + screen_recording: OSPermissionStatus, + microphone: OSPermissionStatus, + camera: OSPermissionStatus, } impl OSPermissionsCheck { pub fn necessary_granted(&self) -> bool { - match self { - Self::MacOS(macos) => macos.screen_recording && macos.microphone && macos.camera, - Self::Other => true, - } + self.screen_recording.permitted() && self.microphone.permitted() && self.camera.permitted() } } #[tauri::command] #[specta::specta] -pub fn do_permissions_check() -> OSPermissionsCheck { +pub fn do_permissions_check(initial_check: bool) -> OSPermissionsCheck { #[cfg(target_os = "macos")] { - OSPermissionsCheck::MacOS(MacOSPermissionsCheck { - screen_recording: scap::has_permission(), - microphone: { - use nokhwa_bindings_macos::{AVAuthorizationStatus, AVMediaType}; - use objc::*; - - let cls = objc::class!(AVCaptureDevice); - let status: AVAuthorizationStatus = unsafe { - msg_send![cls, authorizationStatusForMediaType:AVMediaType::Audio.into_ns_str()] - }; - matches!(status, AVAuthorizationStatus::Authorized) - }, - camera: { - use nokhwa_bindings_macos::{AVAuthorizationStatus, AVMediaType}; - use objc::*; - - let cls = objc::class!(AVCaptureDevice); - let status: AVAuthorizationStatus = unsafe { - msg_send![cls, authorizationStatusForMediaType:AVMediaType::Video.into_ns_str()] - }; - matches!(status, AVAuthorizationStatus::Authorized) + OSPermissionsCheck { + screen_recording: { + let result = scap::has_permission(); + match (result, initial_check) { + (true, _) => OSPermissionStatus::Granted, + (false, true) => OSPermissionStatus::Empty, + (false, false) => OSPermissionStatus::Denied, + } }, - }) + microphone: check_av_permission(AVMediaType::Audio), + camera: check_av_permission(AVMediaType::Video), + } } #[cfg(not(target_os = "macos"))] - OSpermissiosCheck::Other + { + OSPermissionsCheck { + screen_recording: OSPermissionStatus::NotNeeded, + microphone: OSPermissionStatus::NotNeeded, + camera: OSPermissionStatus::NotNeeded, + }; + } +} + +#[cfg(target_os = "macos")] +pub fn check_av_permission(media_type: AVMediaType) -> OSPermissionStatus { + use objc::*; + + let cls = objc::class!(AVCaptureDevice); + let status: AVAuthorizationStatus = + unsafe { msg_send![cls, authorizationStatusForMediaType:media_type.into_ns_str()] }; + match status { + AVAuthorizationStatus::NotDetermined => OSPermissionStatus::Empty, + AVAuthorizationStatus::Authorized => OSPermissionStatus::Granted, + _ => OSPermissionStatus::Denied, + } } #[tauri::command] @@ -145,7 +160,7 @@ pub fn open_permissions_window(app: &impl Manager) { WebviewWindow::builder(app, "permissions", WebviewUrl::App("/permissions".into())) .title("Cap") - .inner_size(300.0, 325.0) + .inner_size(300.0, 256.0) .resizable(false) .maximized(false) .shadow(true) diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index c630fa816..3ddeeaec2 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -18,6 +18,16 @@ } } }, + "plugins": { + "updater": { + "active": false, + "endpoints": [ + "https://cdn.crabnebula.app/update/cap/cap/{{target}}-{{arch}}/{{current_version}}" + ], + "dialog": true, + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEUyOTAzOTdFNzJFQkRFOTMKUldTVDN1dHlmam1RNHFXb1VYTXlrQk1iMFFkcjN0YitqZlA5WnZNY0ZtQ1dvM1dxK211M3VIYUQK" + } + }, "bundle": { "active": true, "targets": "all", diff --git a/apps/desktop/src-tauri/tauri.conf.prod.json b/apps/desktop/src-tauri/tauri.conf.prod.json index 30fd36d36..ed0566867 100644 --- a/apps/desktop/src-tauri/tauri.conf.prod.json +++ b/apps/desktop/src-tauri/tauri.conf.prod.json @@ -4,11 +4,7 @@ "identifier": "so.cap.desktop", "plugins": { "updater": { - "endpoints": [ - "https://cdn.crabnebula.app/update/cap/cap/{{target}}-{{arch}}/{{current_version}}" - ], - "dialog": true, - "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEUyOTAzOTdFNzJFQkRFOTMKUldTVDN1dHlmam1RNHFXb1VYTXlrQk1iMFFkcjN0YitqZlA5WnZNY0ZtQ1dvM1dxK211M3VIYUQK" + "active": true } }, "bundle": { diff --git a/apps/desktop/src/routes/permissions.tsx b/apps/desktop/src/routes/permissions.tsx index 127fa3ea5..3378f464b 100644 --- a/apps/desktop/src/routes/permissions.tsx +++ b/apps/desktop/src/routes/permissions.tsx @@ -2,7 +2,7 @@ import { cx } from "cva"; import { Button } from "@cap/ui-solid"; import { exit } from "@tauri-apps/plugin-process"; -import { commands } from "../utils/tauri"; +import { commands, OSPermission, OSPermissionStatus, OSPermissionsCheck } from "../utils/tauri"; import { createEffect, createResource, @@ -10,86 +10,66 @@ import { Switch, Match, createSignal, + Show, } from "solid-js"; import { createTimer } from "@solid-primitives/timer"; import { getCurrentWindow } from "@tauri-apps/api/window"; +function isPermitted(status?: OSPermissionStatus): boolean { + return status === "granted" || status === "notNeeded"; +} + export default function () { + const steps = [ + { name: "Screen Recording", key: "screenRecording" as const }, + { name: "Camera", key: "camera" as const }, + { name: "Microphone", key: "microphone" as const }, + ] as const; + + const [currentStepIndex, setCurrentStepIndex] = createSignal(0); + const [initialCheck, setInitialCheck] = createSignal(true); const [check, checkActions] = createResource(() => - commands.doPermissionsCheck() + commands.doPermissionsCheck(initialCheck()) ); + const currentStep = () => steps[currentStepIndex()]; + const currentStepStatus = () => check.latest?.[currentStep().key]; - createTimer(() => checkActions.refetch(), 100, setInterval); - - const [currentStep, setCurrentStep] = createSignal(0); - - const permissionsGranted = async () => { - const c = check.latest; - if (c?.os === "macOS") { - const hasScreenRecording = c?.screenRecording; - const hasCamera = c?.camera || (await checkCameraPermission()); - const hasMicrophone = - c?.microphone || (await checkMicrophonePermission()); - - return hasScreenRecording && hasCamera && hasMicrophone; - } - return false; - }; - - const checkCameraPermission = async () => { - try { - const cameraStream = await navigator.mediaDevices.getUserMedia({ - video: true, - }); - cameraStream.getTracks().forEach((track) => track.stop()); - return true; - } catch (error) { - console.error("Error requesting camera permission:", error); - return false; + createEffect(() => { + if (!initialCheck()) { + createTimer(() => checkActions.refetch(), 100, setInterval); } - }; + }) - const checkMicrophonePermission = async () => { - try { - const microphoneStream = await navigator.mediaDevices.getUserMedia({ - audio: true, - }); - microphoneStream.getTracks().forEach((track) => track.stop()); - return true; - } catch (error) { - console.error("Error requesting microphone permission:", error); - return false; - } - }; + createEffect(() => { + const c = check.latest; + const neededStep = steps.findIndex((step) => !isPermitted(c?.[step.key])); - createEffect(async () => { - if (await permissionsGranted()) { + if (neededStep === -1) { + // All permissions now granted commands.openMainWindow(); const window = getCurrentWindow(); window.close(); } + else { + setCurrentStepIndex(neededStep); + } }); - const steps = [ - { name: "Screen Recording", key: "screenRecording" }, - { name: "Camera", key: "camera" }, - { name: "Microphone", key: "microphone" }, - ] as const; - - const currentPermission = () => steps[currentStep()]; - - const nextStep = () => { - if ( - currentStep() < steps.length - 1 && - check.latest?.[currentPermission().key] - ) { - setCurrentStep(currentStep() + 1); + const requestPermission = () => { + // After this, we will get "denied" instead of "empty" values for screen recording permission + try { + commands.requestPermission(currentStep().key); + } + catch (err) { + console.error(`Error occurred while requesting permission: ${err}`); } - }; + setInitialCheck(false); + } - createEffect(() => { - commands.requestPermission({ macOS: currentPermission().key }); - }); + const openSettings = () => { + commands.openPermissionSettings(currentStep().key); + setInitialCheck(false); + } return (
@@ -103,47 +83,29 @@ export default function () {

}> - - - {(latestCheck) => ( -
-
- - {currentPermission().name} Permission - - {latestCheck()[currentPermission().key] ? ( - Granted - ) : ( - <> - - - )} -
-
- )} -
-
+
+

+ {currentStep().name} Permission +

+
+ Open Settings} + > + + + +
+
-
- - -
); } diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index b1bc058dd..fe10f512a 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -151,13 +151,13 @@ async openEditor(id: string) : Promise { async openMainWindow() : Promise { await TAURI_INVOKE("open_main_window"); }, -async openPermissionSettings(settings: OSPermissionSettings) : Promise { - await TAURI_INVOKE("open_permission_settings", { settings }); +async openPermissionSettings(permission: OSPermission) : Promise { + await TAURI_INVOKE("open_permission_settings", { permission }); }, -async doPermissionsCheck() : Promise { - return await TAURI_INVOKE("do_permissions_check"); +async doPermissionsCheck(initialCheck: boolean) : Promise { + return await TAURI_INVOKE("do_permissions_check", { initialCheck }); }, -async requestPermission(permission: OSPermissionSettings) : Promise { +async requestPermission(permission: OSPermission) : Promise { await TAURI_INVOKE("request_permission", { permission }); }, async uploadRenderedVideo(videoId: string, project: ProjectConfiguration) : Promise> { @@ -231,11 +231,10 @@ export type EditorStateChanged = { playhead_position: number } export type HotkeysConfiguration = { show: boolean } export type InProgressRecording = { recordingDir: string; displaySource: DisplaySource } export type JsonValue = [T] -export type MacOSPermissionSettings = "screenRecording" | "camera" | "microphone" -export type MacOSPermissionsCheck = { screenRecording: boolean; microphone: boolean; camera: boolean } export type NewRecordingAdded = { path: string } -export type OSPermissionSettings = { macOS: MacOSPermissionSettings } -export type OSPermissionsCheck = ({ os: "macOS" } & MacOSPermissionsCheck) | { os: "other" } +export type OSPermission = "screenRecording" | "camera" | "microphone" +export type OSPermissionStatus = "notNeeded" | "empty" | "granted" | "denied" +export type OSPermissionsCheck = { screenRecording: OSPermissionStatus; microphone: OSPermissionStatus; camera: OSPermissionStatus } export type ProjectConfiguration = { aspectRatio: AspectRatio | null; background: BackgroundConfiguration; camera: CameraConfiguration; audio: AudioConfiguration; cursor: CursorConfiguration; hotkeys: HotkeysConfiguration } export type ProjectRecordings = { display: Video; camera: Video | null; audio: Audio | null } export type RecordingMeta = { pretty_name: string; sharing?: SharingMeta | null; display: Display; camera?: CameraMeta | null; audio?: AudioMeta | null }