crabcamera 0.9.2

Advanced cross-platform camera integration for Tauri applications
Documentation
/// Permission status enum
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum PermissionStatus {
    /// Permission granted
    Granted,
    /// Permission denied
    Denied,
    /// Permission not determined (user hasn't been asked yet)
    NotDetermined,
    /// Permission restricted (parental controls, etc)
    Restricted,
}

impl std::fmt::Display for PermissionStatus {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            PermissionStatus::Granted => write!(f, "granted"),
            PermissionStatus::Denied => write!(f, "denied"),
            PermissionStatus::NotDetermined => write!(f, "not_determined"),
            PermissionStatus::Restricted => write!(f, "restricted"),
        }
    }
}

/// Check camera permission status
/// Returns permission status for the current platform
pub fn check_permission() -> PermissionStatus {
    check_permission_detailed().status
}

/// Check camera permission status with detailed information
pub fn check_permission_detailed() -> PermissionInfo {
    #[cfg(target_os = "windows")]
    {
        check_permission_windows()
    }

    #[cfg(target_os = "macos")]
    {
        check_permission_macos()
    }

    #[cfg(target_os = "linux")]
    {
        check_permission_linux()
    }

    #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
    {
        PermissionInfo {
            status: PermissionStatus::NotDetermined,
            message: "Platform not supported".to_string(),
            can_request: false,
        }
    }
}

/// Detailed permission information
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PermissionInfo {
    /// The current status of the permission.
    pub status: PermissionStatus,
    /// A human-readable message describing the permission state.
    pub message: String,
    /// Whether the application can request this permission from the user.
    pub can_request: bool,
}

#[cfg(target_os = "windows")]
fn check_permission_windows() -> PermissionInfo {
    // On Windows 10+, camera access is controlled by Privacy settings
    // Check if we can enumerate devices as a proxy for permission
    use nokhwa::query;

    match query(nokhwa::utils::ApiBackend::Auto) {
        Ok(devices) if !devices.is_empty() => PermissionInfo {
            status: PermissionStatus::Granted,
            message: "Camera access granted via Windows Privacy settings".to_string(),
            can_request: false,
        },
        Ok(_) => PermissionInfo {
            status: PermissionStatus::NotDetermined,
            message: "No cameras found - permission may not be granted".to_string(),
            can_request: true,
        },
        Err(e) => PermissionInfo {
            status: PermissionStatus::Denied,
            message: format!("Camera access denied: {e}"),
            can_request: true,
        },
    }
}

#[cfg(target_os = "macos")]
fn check_permission_macos() -> PermissionInfo {
    use objc::runtime::{Class, Object};
    use objc::{msg_send, sel, sel_impl};
    use std::ffi::CString;

    unsafe {
        // Get AVCaptureDevice class
        let Some(av_capture_device_class) = Class::get("AVCaptureDevice") else {
            return PermissionInfo {
                status: PermissionStatus::NotDetermined,
                message: "AVFoundation not available".to_string(),
                can_request: false,
            };
        };

        // Build an NSString for the video media type (AVMediaTypeVideo == @"vide").
        // NOTE: AVCaptureDevice has no `mediaTypeForString:` selector; sending it
        // raises an unrecognized-selector NSException which aborts the process.
        let Some(ns_string_class) = Class::get("NSString") else {
            return PermissionInfo {
                status: PermissionStatus::NotDetermined,
                message: "Foundation not available".to_string(),
                can_request: false,
            };
        };
        let Ok(av_media_type_video) = CString::new(crate::constants::AV_MEDIA_TYPE_VIDEO) else {
            return PermissionInfo {
                status: PermissionStatus::NotDetermined,
                message: "Invalid media type string".to_string(),
                can_request: false,
            };
        };
        let media_type: *mut Object =
            msg_send![ns_string_class, stringWithUTF8String: av_media_type_video.as_ptr()];

        // Check authorization status
        let auth_status: i64 =
            msg_send![av_capture_device_class, authorizationStatusForMediaType: media_type];

        // AVAuthorizationStatus enum values:
        // 0 = NotDetermined
        // 1 = Restricted
        // 2 = Denied
        // 3 = Authorized

        match auth_status {
            3 => PermissionInfo {
                status: PermissionStatus::Granted,
                message: "Camera access authorized".to_string(),
                can_request: false,
            },
            2 => PermissionInfo {
                status: PermissionStatus::Denied,
                message: "Camera access denied - enable in System Preferences > Security & Privacy > Camera".to_string(),
                can_request: false,
            },
            1 => PermissionInfo {
                status: PermissionStatus::Restricted,
                message: "Camera access restricted by system policy".to_string(),
                can_request: false,
            },
            _ => PermissionInfo {
                status: PermissionStatus::NotDetermined,
                message: "Camera permission not yet requested".to_string(),
                can_request: true,
            },
        }
    }
}

#[cfg(target_os = "linux")]
fn check_permission_linux() -> PermissionInfo {
    use crate::constants::LINUX_VIDEO_DEVICE_PREFIX;
    use std::fs;
    use std::path::Path;

    // Check if any video devices exist
    let video_devices: Vec<_> = (0..10)
        .map(|i| format!("{LINUX_VIDEO_DEVICE_PREFIX}{i}"))
        .filter(|path| Path::new(path).exists())
        .collect();

    if video_devices.is_empty() {
        return PermissionInfo {
            status: PermissionStatus::NotDetermined,
            message: format!("No video devices found at {LINUX_VIDEO_DEVICE_PREFIX}*"),
            can_request: false,
        };
    }

    // Check if we can read from first video device
    let first_device = &video_devices[0];
    match fs::metadata(first_device) {
        Ok(_metadata) => {
            // Check if we have read permission (via group membership)
            if check_linux_group_membership() {
                PermissionInfo {
                    status: PermissionStatus::Granted,
                    message: format!(
                        "Camera access granted (user in video group, {first_device} found)"
                    ),
                    can_request: false,
                }
            } else {
                PermissionInfo {
                    status: PermissionStatus::Denied,
                    message: format!("Camera device {first_device} exists but user not in video group - run: sudo usermod -a -G video $USER"),
                    can_request: true,
                }
            }
        }
        Err(e) => PermissionInfo {
            status: PermissionStatus::Denied,
            message: format!("Cannot access {first_device}: {e}"),
            can_request: true,
        },
    }
}

#[cfg(target_os = "linux")]
fn check_linux_group_membership() -> bool {
    use std::process::Command;

    // Check if user is in 'video' or 'plugdev' group
    let output = Command::new("groups").output().ok();

    if let Some(output) = output {
        if let Ok(groups) = String::from_utf8(output.stdout) {
            return groups.contains("video") || groups.contains("plugdev");
        }
    }

    // Fallback: assume permission if we can't check groups
    false
}

#[cfg(test)]
mod tests {
    use super::{check_permission, check_permission_detailed, PermissionInfo, PermissionStatus};

    #[test]
    fn test_permission_status_display_values() {
        assert_eq!(PermissionStatus::Granted.to_string(), "granted");
        assert_eq!(PermissionStatus::Denied.to_string(), "denied");
        assert_eq!(
            PermissionStatus::NotDetermined.to_string(),
            "not_determined"
        );
        assert_eq!(PermissionStatus::Restricted.to_string(), "restricted");
    }

    #[test]
    fn test_check_permission_returns_valid_status() {
        let status = check_permission();
        match status {
            PermissionStatus::Granted
            | PermissionStatus::Denied
            | PermissionStatus::NotDetermined
            | PermissionStatus::Restricted => {}
        }
    }

    #[test]
    fn test_check_permission_detailed_shape() {
        let info = check_permission_detailed();
        assert!(!info.message.is_empty());

        match info.status {
            PermissionStatus::Granted
            | PermissionStatus::Denied
            | PermissionStatus::NotDetermined
            | PermissionStatus::Restricted => {}
        }
    }

    #[test]
    fn test_permission_info_serde_roundtrip() {
        let info = PermissionInfo {
            status: PermissionStatus::Denied,
            message: "camera blocked".to_string(),
            can_request: true,
        };

        let json = serde_json::to_string(&info).expect("PermissionInfo should serialize");
        let decoded: PermissionInfo =
            serde_json::from_str(&json).expect("PermissionInfo should deserialize");

        assert_eq!(decoded.status, PermissionStatus::Denied);
        assert_eq!(decoded.message, "camera blocked");
        assert!(decoded.can_request);
    }
}