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 ? "Done" : "Cancel"}
+
+
+ {!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 && (
+
+
+ System Permissions
+
+
+ {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 */}
Advanced
@@ -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;
+}