diff --git a/.vscode/settings.json b/.vscode/settings.json index 07ed9b7..a97e8d0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,6 +15,7 @@ "donutbrowser", "dpkg", "dtolnay", + "dyld", "elif", "esbuild", "eslintcache", diff --git a/package.json b/package.json index a54f32b..c7a7dae 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@tauri-apps/plugin-dialog": "^2.2.2", "@tauri-apps/plugin-fs": "~2.3.0", "@tauri-apps/plugin-opener": "^2.2.7", + "ahooks": "^3.8.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -47,7 +48,8 @@ "react-dom": "^19.1.0", "react-icons": "^5.5.0", "sonner": "^2.0.5", - "tailwind-merge": "^3.3.0" + "tailwind-merge": "^3.3.0", + "tauri-plugin-macos-permissions-api": "^2.3.0" }, "devDependencies": { "@biomejs/biome": "1.9.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b72ca83..f517ec8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: '@tauri-apps/plugin-opener': specifier: ^2.2.7 version: 2.2.7 + ahooks: + specifier: ^3.8.5 + version: 3.8.5(react@19.1.0) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -83,6 +86,9 @@ importers: tailwind-merge: specifier: ^3.3.0 version: 3.3.0 + tauri-plugin-macos-permissions-api: + specifier: ^2.3.0 + version: 2.3.0 devDependencies: '@biomejs/biome': specifier: 1.9.4 @@ -255,6 +261,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.27.6': + resolution: {integrity: sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -1690,6 +1700,12 @@ packages: resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} engines: {node: '>= 14'} + ahooks@3.8.5: + resolution: {integrity: sha512-Y+MLoJpBXVdjsnnBjE5rOSPkQ4DK+8i5aPDzLJdIOsCpo/fiAeXcBY1Y7oWgtOK0TpOz0gFa/XcyO1UGdoqLcw==} + engines: {node: '>=8.0.0'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -1940,6 +1956,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + dayjs@1.11.13: + resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -2409,6 +2428,9 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + intersection-observer@0.12.2: + resolution: {integrity: sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==} + into-stream@6.0.0: resolution: {integrity: sha512-XHbaOAvP+uFKUFsOgoNPRjLkwB+I22JFPFe5OjTkQ0nwgj6+pSjb4NmB6VMxaPshLiOf+zcpOCBQuLwC1KHhZA==} engines: {node: '>=10'} @@ -2556,6 +2578,10 @@ packages: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true + js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2694,6 +2720,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + log-update@6.1.0: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} @@ -2985,6 +3014,9 @@ packages: peerDependencies: react: ^19.1.0 + react-fast-compare@3.2.2: + resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + react-icons@5.5.0: resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==} peerDependencies: @@ -3054,6 +3086,9 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3110,6 +3145,10 @@ packages: scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + screenfull@5.2.0: + resolution: {integrity: sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==} + engines: {node: '>=0.10.0'} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -3330,6 +3369,9 @@ packages: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} + tauri-plugin-macos-permissions-api@2.3.0: + resolution: {integrity: sha512-pZp0jmDySysBqrGueknd1a7Rr4XEO9aXpMv9TNrT2PDHP0MSH20njieOagsFYJ5MCVb8A+wcaK0cIkjUC2dOww==} + tinyglobby@0.2.14: resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} engines: {node: '>=12.0.0'} @@ -3674,6 +3716,8 @@ snapshots: '@babel/core': 7.27.4 '@babel/helper-plugin-utils': 7.27.1 + '@babel/runtime@7.27.6': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -4936,6 +4980,19 @@ snapshots: agent-base@7.1.3: {} + ahooks@3.8.5(react@19.1.0): + dependencies: + '@babel/runtime': 7.27.6 + dayjs: 1.11.13 + intersection-observer: 0.12.2 + js-cookie: 3.0.5 + lodash: 4.17.21 + react: 19.1.0 + react-fast-compare: 3.2.2 + resize-observer-polyfill: 1.5.1 + screenfull: 5.2.0 + tslib: 2.8.1 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -5231,6 +5288,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + dayjs@1.11.13: {} + debug@3.2.7: dependencies: ms: 2.1.3 @@ -5840,6 +5899,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + intersection-observer@0.12.2: {} + into-stream@6.0.0: dependencies: from2: 2.3.0 @@ -5993,6 +6054,8 @@ snapshots: jiti@2.4.2: {} + js-cookie@3.0.5: {} + js-tokens@4.0.0: {} js-yaml@4.1.0: @@ -6120,6 +6183,8 @@ snapshots: lodash.merge@4.6.2: {} + lodash@4.17.21: {} + log-update@6.1.0: dependencies: ansi-escapes: 7.0.0 @@ -6414,6 +6479,8 @@ snapshots: react: 19.1.0 scheduler: 0.26.0 + react-fast-compare@3.2.2: {} + react-icons@5.5.0(react@19.1.0): dependencies: react: 19.1.0 @@ -6493,6 +6560,8 @@ snapshots: require-directory@2.1.1: {} + resize-observer-polyfill@1.5.1: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -6573,6 +6642,8 @@ snapshots: scheduler@0.26.0: {} + screenfull@5.2.0: {} + semver@6.3.1: {} semver@7.7.2: {} @@ -6861,6 +6932,10 @@ snapshots: mkdirp: 3.0.1 yallist: 5.0.0 + tauri-plugin-macos-permissions-api@2.3.0: + dependencies: + '@tauri-apps/api': 2.5.0 + tinyglobby@0.2.14: dependencies: fdir: 6.4.5(picomatch@4.0.2) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8cd94f1..a32aee8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1012,6 +1012,7 @@ dependencies = [ "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-fs", + "tauri-plugin-macos-permissions", "tauri-plugin-opener", "tauri-plugin-shell", "tempfile", @@ -2346,6 +2347,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "macos-accessibility-client" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edf7710fbff50c24124331760978fb9086d6de6288dcdb38b25a97f8b1bdebbb" +dependencies = [ + "core-foundation 0.9.4", + "core-foundation-sys", +] + [[package]] name = "markup5ever" version = "0.11.0" @@ -4407,6 +4418,21 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-macos-permissions" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5607e0707d37d7b20e287cf0ce396d1efebe7b833b8e9cbd2ea4257091d9c604" +dependencies = [ + "macos-accessibility-client", + "objc2 0.6.1", + "objc2-foundation 0.3.1", + "serde", + "tauri", + "tauri-plugin", + "thiserror 2.0.12", +] + [[package]] name = "tauri-plugin-opener" version = "2.2.7" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7acfacf..3a02c21 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,6 +27,7 @@ tauri-plugin-fs = "2" tauri-plugin-shell = "2" tauri-plugin-deep-link = "2" tauri-plugin-dialog = "2" +tauri-plugin-macos-permissions = "2" directories = "6" reqwest = { version = "0.12", features = ["json", "stream"] } tokio = { version = "1", features = ["full"] } diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist index 9ee81ac..55f434a 100644 --- a/src-tauri/Info.plist +++ b/src-tauri/Info.plist @@ -2,47 +2,27 @@ + NSCameraUsageDescription + Donut Browser needs camera access to enable camera functionality in web browsers. Each website will still ask for your permission individually. + NSMicrophoneUsageDescription + Donut Browser needs microphone access to enable microphone functionality in web browsers. Each website will still ask for your permission individually. CFBundleDisplayName Donut Browser CFBundleName Donut Browser CFBundleIdentifier com.donutbrowser + CFBundleURLName + com.donutbrowser CFBundleExecutable donutbrowser CFBundleVersion 1 - CFBundleShortVersionString - 0.3.2 - CFBundlePackageType - APPL CFBundleIconFile icon.icns - CFBundleSignature - ???? - CFBundleIconFile - icon.icns - CFBundleURLTypes - - - CFBundleURLName - Web Browser - CFBundleURLSchemes - - http - https - - CFBundleURLIconFile - icon.icns - LSHandlerRank - Owner - - LSApplicationCategoryType public.app-category.productivity NSHumanReadableCopyright Copyright © 2025 Donut Browser - LSMinimumSystemVersion - 10.13 \ No newline at end of file diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index d58ce48..b5ace9a 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -20,6 +20,11 @@ "shell:allow-stdin-write", "deep-link:default", "dialog:default", - "dialog:allow-open" + "dialog:allow-open", + "macos-permissions:default", + "macos-permissions:allow-request-microphone-permission", + "macos-permissions:allow-request-camera-permission", + "macos-permissions:allow-check-microphone-permission", + "macos-permissions:allow-check-camera-permission" ] } diff --git a/src-tauri/entitlements.plist b/src-tauri/entitlements.plist index c514412..f9bb99f 100644 --- a/src-tauri/entitlements.plist +++ b/src-tauri/entitlements.plist @@ -12,5 +12,21 @@ com.apple.security.files.downloads.read-write + com.apple.security.device.camera + + com.apple.security.device.audio-output + + com.apple.security.device.microphone + + com.apple.security.device.audio-input + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.allow-dyld-environment-variables + + com.apple.security.cs.disable-library-validation + \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fd8acf6..1c535fb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -172,6 +172,7 @@ pub fn run() { .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_macos_permissions::init()) .setup(|app| { // Create the main window programmatically #[allow(unused_variables)] diff --git a/src/app/page.tsx b/src/app/page.tsx index 6d2acf4..28ffb34 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,6 +3,7 @@ import { ChangeVersionDialog } from "@/components/change-version-dialog"; import { CreateProfileDialog } from "@/components/create-profile-dialog"; import { ImportProfileDialog } from "@/components/import-profile-dialog"; +import { PermissionDialog } from "@/components/permission-dialog"; import { ProfilesDataTable } from "@/components/profile-data-table"; import { ProfileSelectorDialog } from "@/components/profile-selector-dialog"; import { ProxySettingsDialog } from "@/components/proxy-settings-dialog"; @@ -21,6 +22,8 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications"; +import { usePermissions } from "@/hooks/use-permissions"; +import type { PermissionType } from "@/hooks/use-permissions"; import { useUpdateNotifications } from "@/hooks/use-update-notifications"; import { showErrorToast } from "@/lib/toast-utils"; import type { BrowserProfile, ProxySettings } from "@/types"; @@ -58,6 +61,11 @@ export default function Home() { const [currentProfileForVersionChange, setCurrentProfileForVersionChange] = useState(null); const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false); + const [permissionDialogOpen, setPermissionDialogOpen] = useState(false); + const [currentPermissionType, setCurrentPermissionType] = + useState("microphone"); + const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } = + usePermissions(); // Simple profiles loader without updates check (for use as callback) const loadProfiles = useCallback(async () => { @@ -119,6 +127,13 @@ export default function Home() { }; }, [loadProfilesWithUpdateCheck, checkForUpdates]); + // Check permissions when they are initialized + useEffect(() => { + if (isInitialized) { + void checkAllPermissions(); + } + }, [isInitialized]); + const checkStartupPrompt = async () => { // Only check once during app startup to prevent reopening after dismissing notifications if (hasCheckedStartupPrompt) return; @@ -137,6 +152,42 @@ export default function Home() { } }; + const checkAllPermissions = async () => { + try { + // Wait for permissions to be initialized before checking + if (!isInitialized) { + return; + } + + // Check if any permissions are not granted - prioritize missing permissions + if (!isMicrophoneAccessGranted) { + setCurrentPermissionType("microphone"); + setPermissionDialogOpen(true); + } else if (!isCameraAccessGranted) { + setCurrentPermissionType("camera"); + setPermissionDialogOpen(true); + } + } catch (error) { + console.error("Failed to check permissions:", error); + } + }; + + const checkNextPermission = () => { + try { + if (!isMicrophoneAccessGranted) { + setCurrentPermissionType("microphone"); + setPermissionDialogOpen(true); + } else if (!isCameraAccessGranted) { + setCurrentPermissionType("camera"); + setPermissionDialogOpen(true); + } else { + setPermissionDialogOpen(false); + } + } catch (error) { + console.error("Failed to check next permission:", error); + } + }; + const checkStartupUrls = async () => { try { const hasStartupUrl = await invoke( @@ -533,6 +584,15 @@ export default function Home() { runningProfiles={runningProfiles} /> ))} + + { + setPermissionDialogOpen(false); + }} + permissionType={currentPermissionType} + onPermissionGranted={checkNextPermission} + /> ); } diff --git a/src/components/permission-dialog.tsx b/src/components/permission-dialog.tsx new file mode 100644 index 0000000..bb57d7c --- /dev/null +++ b/src/components/permission-dialog.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { LoadingButton } from "@/components/loading-button"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { usePermissions } from "@/hooks/use-permissions"; +import type { PermissionType } from "@/hooks/use-permissions"; +import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; +import { useEffect, useState } from "react"; +import { BsCamera, BsMic } from "react-icons/bs"; + +interface PermissionDialogProps { + isOpen: boolean; + onClose: () => void; + permissionType: PermissionType; + onPermissionGranted?: () => void; +} + +export function PermissionDialog({ + isOpen, + onClose, + permissionType, + onPermissionGranted, +}: PermissionDialogProps) { + const [isRequesting, setIsRequesting] = useState(false); + const [isMacOS, setIsMacOS] = useState(false); + const { + requestPermission, + isMicrophoneAccessGranted, + isCameraAccessGranted, + } = usePermissions(); + + // Check if we're on macOS and close dialog if not + useEffect(() => { + const userAgent = navigator.userAgent; + const isMac = userAgent.includes("Mac"); + setIsMacOS(isMac); + + // If not macOS, close the dialog as permissions aren't needed + if (!isMac) { + onClose(); + } + }, [onClose]); + + // Get current permission status + const isCurrentPermissionGranted = + permissionType === "microphone" + ? isMicrophoneAccessGranted + : isCameraAccessGranted; + + // Auto-close dialog when permission is granted + useEffect(() => { + if (isCurrentPermissionGranted && isOpen) { + onPermissionGranted?.(); + } + }, [isCurrentPermissionGranted, isOpen, onPermissionGranted]); + + const getPermissionIcon = (type: PermissionType) => { + switch (type) { + case "microphone": + return ; + case "camera": + return ; + } + }; + + const getPermissionTitle = (type: PermissionType) => { + switch (type) { + case "microphone": + return "Microphone Access Required"; + case "camera": + return "Camera Access Required"; + } + }; + + const getPermissionDescription = (type: PermissionType) => { + switch (type) { + case "microphone": + return "Donut Browser needs access to your microphone to enable microphone functionality in web browsers. Each website that wants to use your microphone will still ask for your permission individually."; + case "camera": + return "Donut Browser needs access to your camera to enable camera functionality in web browsers. Each website that wants to use your camera will still ask for your permission individually."; + } + }; + + const getStatusBadge = (isGranted: boolean) => { + if (isGranted) { + return ( + + Granted + + ); + } + return Not Granted; + }; + + const handleRequestPermission = async () => { + setIsRequesting(true); + try { + await requestPermission(permissionType); + showSuccessToast( + `${getPermissionTitle(permissionType).replace( + " Required", + "", + )} permission requested`, + ); + } catch (error) { + console.error("Failed to request permission:", error); + showErrorToast("Failed to request permission"); + } finally { + setIsRequesting(false); + } + }; + + // Don't render if not macOS + if (!isMacOS) { + return null; + } + + return ( + + + +
+ {getPermissionIcon(permissionType)} +
+ + {getPermissionTitle(permissionType)} + + + {getPermissionDescription(permissionType)} + +
+ +
+ {isCurrentPermissionGranted && ( +
+

+ ✅ Permission granted! Browsers launched from Donut Browser can + now access your {permissionType}. +

+
+ )} + + {!isCurrentPermissionGranted && ( +
+

+ ⚠️ Permission not granted. Click the button below to request + access to your {permissionType}. +

+
+ )} +
+ + + + + {!isCurrentPermissionGranted && ( + { + handleRequestPermission().catch(console.error); + }} + className="min-w-24" + > + Grant Access + + )} + +
+
+ ); +} diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index 0f55bf3..dad9346 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -19,10 +19,13 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { usePermissions } from "@/hooks/use-permissions"; +import type { PermissionType } from "@/hooks/use-permissions"; import { showSuccessToast } from "@/lib/toast-utils"; import { invoke } from "@tauri-apps/api/core"; import { useTheme } from "next-themes"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { BsCamera, BsMic } from "react-icons/bs"; interface AppSettings { set_as_default_browser: boolean; @@ -32,6 +35,12 @@ interface AppSettings { auto_delete_unused_binaries: boolean; } +interface PermissionInfo { + permission_type: PermissionType; + isGranted: boolean; + description: string; +} + interface SettingsDialogProps { isOpen: boolean; onClose: () => void; @@ -57,18 +66,46 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { const [isSaving, setIsSaving] = useState(false); const [isSettingDefault, setIsSettingDefault] = useState(false); const [isClearingCache, setIsClearingCache] = useState(false); + const [permissions, setPermissions] = useState([]); + const [isLoadingPermissions, setIsLoadingPermissions] = useState(false); + const [requestingPermission, setRequestingPermission] = + useState(null); + const [isMacOS, setIsMacOS] = useState(false); const { setTheme } = useTheme(); + const { + requestPermission, + isMicrophoneAccessGranted, + isCameraAccessGranted, + } = usePermissions(); + + const getPermissionDescription = useCallback((type: PermissionType) => { + switch (type) { + case "microphone": + return "Access to microphone for browser applications"; + case "camera": + return "Access to camera for browser applications"; + } + }, []); useEffect(() => { if (isOpen) { - void loadSettings(); - void checkDefaultBrowserStatus(); + loadSettings().catch(console.error); + checkDefaultBrowserStatus().catch(console.error); + + // Check if we're on macOS + const userAgent = navigator.userAgent; + const isMac = userAgent.includes("Mac"); + setIsMacOS(isMac); + + if (isMac) { + loadPermissions().catch(console.error); + } // Set up interval to check default browser status const intervalId = setInterval(() => { - void checkDefaultBrowserStatus(); - }, 500); // Check every 2 seconds + checkDefaultBrowserStatus().catch(console.error); + }, 500); // Check every 500ms // Cleanup interval on component unmount or dialog close return () => { @@ -77,6 +114,32 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { } }, [isOpen]); + // Update permissions when the permission states change + useEffect(() => { + if (isMacOS) { + const permissionList: PermissionInfo[] = [ + { + permission_type: "microphone", + isGranted: isMicrophoneAccessGranted, + description: getPermissionDescription("microphone"), + }, + { + permission_type: "camera", + isGranted: isCameraAccessGranted, + description: getPermissionDescription("camera"), + }, + ]; + setPermissions(permissionList); + } else { + setPermissions([]); + } + }, [ + isMacOS, + isMicrophoneAccessGranted, + isCameraAccessGranted, + getPermissionDescription, + ]); + const loadSettings = async () => { setIsLoading(true); try { @@ -90,6 +153,36 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { } }; + const loadPermissions = async () => { + setIsLoadingPermissions(true); + try { + if (!isMacOS) { + // On non-macOS platforms, don't show permissions + setPermissions([]); + return; + } + + const permissionList: PermissionInfo[] = [ + { + permission_type: "microphone", + isGranted: isMicrophoneAccessGranted, + description: getPermissionDescription("microphone"), + }, + { + permission_type: "camera", + isGranted: isCameraAccessGranted, + description: getPermissionDescription("camera"), + }, + ]; + + setPermissions(permissionList); + } catch (error) { + console.error("Failed to load permissions:", error); + } finally { + setIsLoadingPermissions(false); + } + }; + const checkDefaultBrowserStatus = async () => { try { const isDefault = await invoke("is_default_browser"); @@ -127,6 +220,49 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { } }; + const handleRequestPermission = async (permissionType: PermissionType) => { + setRequestingPermission(permissionType); + try { + await requestPermission(permissionType); + showSuccessToast( + `${getPermissionDisplayName(permissionType)} access requested`, + ); + } catch (error) { + console.error("Failed to request permission:", error); + } finally { + setRequestingPermission(null); + } + }; + + const getPermissionIcon = (type: PermissionType) => { + switch (type) { + case "microphone": + return ; + case "camera": + return ; + } + }; + + const getPermissionDisplayName = (type: PermissionType) => { + switch (type) { + case "microphone": + return "Microphone"; + case "camera": + return "Camera"; + } + }; + + const getStatusBadge = (isGranted: boolean) => { + if (isGranted) { + return ( + + Granted + + ); + } + return Not Granted; + }; + const handleSave = async () => { setIsSaving(true); try { @@ -204,7 +340,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { { - void handleSetDefaultBrowser(); + handleSetDefaultBrowser().catch(console.error); }} disabled={isDefaultBrowser} variant={isDefaultBrowser ? "outline" : "default"} @@ -284,6 +420,69 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {

+ {/* Permissions Section - Only show on macOS */} + {isMacOS && ( +
+ + + {isLoadingPermissions ? ( +
+ Loading permissions... +
+ ) : ( +
+ {permissions.map((permission) => ( +
+
+ {getPermissionIcon(permission.permission_type)} +
+
+ {getPermissionDisplayName( + permission.permission_type, + )} +
+
+ {permission.description} +
+
+
+
+ {getStatusBadge(permission.isGranted)} + {!permission.isGranted && ( + { + handleRequestPermission( + permission.permission_type, + ).catch(console.error); + }} + > + Grant + + )} +
+
+ ))} +
+ )} + +

+ These permissions allow browsers launched from Donut Browser to + access system resources. Each website will still ask for your + permission individually. +

+
+ )} + {/* Advanced Section */}
@@ -291,7 +490,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { { - void handleClearCache(); + handleClearCache().catch(console.error); }} variant="outline" className="w-full" @@ -314,7 +513,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { { - void handleSave(); + handleSave().catch(console.error); }} disabled={isLoading || !hasChanges} > diff --git a/src/hooks/use-permissions.ts b/src/hooks/use-permissions.ts new file mode 100644 index 0000000..aa1f9ae --- /dev/null +++ b/src/hooks/use-permissions.ts @@ -0,0 +1,174 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +// Platform-specific imports +let macOSPermissions: + | typeof import("tauri-plugin-macos-permissions-api") + | null = null; + +// Dynamically import macOS permissions only when needed +const loadMacOSPermissions = async () => { + if (macOSPermissions) return macOSPermissions; + + try { + macOSPermissions = await import("tauri-plugin-macos-permissions-api"); + return macOSPermissions; + } catch (error) { + console.warn("Failed to load macOS permissions API:", error); + return null; + } +}; + +export type PermissionType = "microphone" | "camera"; + +export interface UsePermissionsReturn { + requestPermission: (type: PermissionType) => Promise; + isMicrophoneAccessGranted: boolean; + isCameraAccessGranted: boolean; + isInitialized: boolean; +} + +export function usePermissions(): UsePermissionsReturn { + const [isMicrophoneAccessGranted, setIsMicrophoneAccessGranted] = + useState(false); + const [isCameraAccessGranted, setIsCameraAccessGranted] = useState(false); + const [currentPlatform, setCurrentPlatform] = useState(null); + const [isInitialized, setIsInitialized] = useState(false); + const intervalRef = useRef(null); + + // Check permissions status + const checkPermissions = useCallback(async () => { + if (!currentPlatform) return; + + if (currentPlatform !== "macos") { + // Windows/Linux - assume permissions are granted + setIsMicrophoneAccessGranted(true); + setIsCameraAccessGranted(true); + setIsInitialized(true); + return; + } + + // macOS - use the permissions API + try { + const permissions = await loadMacOSPermissions(); + if (permissions) { + const [micGranted, camGranted] = await Promise.all([ + permissions.checkMicrophonePermission(), + permissions.checkCameraPermission(), + ]); + + setIsMicrophoneAccessGranted(micGranted); + setIsCameraAccessGranted(camGranted); + setIsInitialized(true); + } + } catch (error) { + console.error("Failed to check permissions on macOS:", error); + setIsInitialized(true); + } + }, [currentPlatform]); + + // Request permission + const requestPermission = useCallback( + async (type: PermissionType): Promise => { + if (!currentPlatform || currentPlatform !== "macos") return; + + // macOS - use the permissions API + try { + const permissions = await loadMacOSPermissions(); + if (!permissions) return; + + if (type === "microphone") { + await permissions.requestMicrophonePermission(); + + // Poll for permission status change + const pollMicPermission = async () => { + const granted = await permissions.checkMicrophonePermission(); + setIsMicrophoneAccessGranted(granted); + + if (!granted) { + setTimeout(() => { + void pollMicPermission(); + }, 1000); + } + }; + + await pollMicPermission(); + } + + if (type === "camera") { + await permissions.requestCameraPermission(); + + // Poll for permission status change + const pollCamPermission = async () => { + const granted = await permissions.checkCameraPermission(); + setIsCameraAccessGranted(granted); + + if (!granted) { + setTimeout(() => { + void pollCamPermission(); + }, 1000); + } + }; + + await pollCamPermission(); + } + } catch (error) { + console.error(`Failed to request ${type} permission on macOS:`, error); + } + }, + [currentPlatform], + ); + + // Initialize platform detection and start interval checking + useEffect(() => { + const initializePlatform = async () => { + try { + // Detect platform - on macOS we need permissions, on others we don't + const userAgent = navigator.userAgent; + let platformName = "unknown"; + + if (userAgent.includes("Mac")) { + platformName = "macos"; + } else if (userAgent.includes("Win")) { + platformName = "windows"; + } else if (userAgent.includes("Linux")) { + platformName = "linux"; + } + + setCurrentPlatform(platformName); + } catch (error) { + console.error("Failed to detect platform:", error); + // Fallback - assume non-macOS + setCurrentPlatform("unknown"); + } + }; + + initializePlatform().catch(console.error); + }, []); + + // Set up interval checking when platform is determined + useEffect(() => { + if (!currentPlatform) return; + + // Initial check + void checkPermissions(); + + // Set up 500ms interval for checking permissions + intervalRef.current = setInterval(() => { + void checkPermissions(); + }, 500); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [currentPlatform, checkPermissions]); + + return { + requestPermission, + isMicrophoneAccessGranted, + isCameraAccessGranted, + isInitialized, + }; +} diff --git a/src/types.ts b/src/types.ts index 0751a81..899ed26 100644 --- a/src/types.ts +++ b/src/types.ts @@ -40,3 +40,17 @@ export interface AppVersionInfo { version: string; is_nightly: boolean; } + +export type PermissionType = "microphone" | "camera" | "location"; + +export type PermissionStatus = + | "granted" + | "denied" + | "not_determined" + | "restricted"; + +export interface PermissionInfo { + permission_type: PermissionType; + status: PermissionStatus; + description: string; +}