diff --git a/.github/workflows/info-plist-check.yml b/.github/workflows/info-plist-check.yml new file mode 100644 index 00000000..38e17428 --- /dev/null +++ b/.github/workflows/info-plist-check.yml @@ -0,0 +1,47 @@ +name: Info.plist Check + +on: + pull_request: + paths: + - 'src-tauri/Info.plist' + - 'scripts/check-info-plist.cjs' + - '.github/workflows/info-plist-check.yml' + push: + branches: + - main + paths: + - 'src-tauri/Info.plist' + - 'scripts/check-info-plist.cjs' + +jobs: + # Quick syntactic / duplicate-key check that runs on every platform. + source-check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Validate src-tauri/Info.plist + run: node scripts/check-info-plist.cjs + + # Post-build check: build the macOS bundle and run `plutil -lint` plus the + # duplicate-key check against the Info.plist that actually ships to users. + # This catches cases where tauri / signing rewrites the plist incorrectly. + bundled-check: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Validate source Info.plist with plutil -lint + run: node scripts/check-info-plist.cjs src-tauri/Info.plist diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..2aaf9142 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,83 @@ +# Contributing to Coco AI + +Thanks for your interest in contributing! This document collects a few +practical notes that aren't obvious from the code alone. + +## Development setup + +```bash +pnpm install +pnpm tauri dev +``` + +## macOS: camera / microphone / accessibility permissions + +Coco AI requests several sensitive macOS permissions (camera, microphone, +accessibility, screen recording, automation). The macOS privacy database +(TCC) keys grants by the tuple **(bundle id, code-signing identity)**. This +has two practical consequences for contributors: + +### 1. Dev builds and release builds share a bundle id + +Both `pnpm tauri dev` and the released `Coco-AI.app` use the bundle id +`rs.coco.app` (see `src-tauri/tauri.conf.json`). They are signed +differently (ad-hoc / personal identity vs. the team identity used in +CI), but the OS often surfaces them under the same row in +**System Settings → Privacy & Security**. + +If you ever click **Don't Allow** on a dev build, the OS remembers that +denial and will **not** prompt again — including for the released app. +`getUserMedia()` and friends will then reject immediately with +`NotAllowedError`, and `requestCameraPermission()` becomes a no-op. + +### 2. Resetting permissions + +If permissions get into a bad state while developing, reset them from a +Terminal: + +```bash +# Reset just one service: +tccutil reset Camera rs.coco.app +tccutil reset Microphone rs.coco.app +tccutil reset Accessibility rs.coco.app +tccutil reset ScreenCapture rs.coco.app +tccutil reset AppleEvents rs.coco.app + +# Or reset everything for this app at once: +tccutil reset All rs.coco.app +``` + +Then relaunch Coco AI. The OS will prompt fresh the next time the +feature is used. + +> Coco AI cannot run `tccutil` itself because it is sandboxed +> (`com.apple.security.app-sandbox` is set in `src-tauri/Entitlements.plist`) +> and `tccutil` is blocked from the sandbox. Settings → Privacy in the +> app surfaces the same instructions. + +## Modifying `Info.plist` + +Each `NS*UsageDescription` key in `src-tauri/Info.plist` MUST appear +exactly once. Duplicate keys cause undefined behavior in the macOS plist +parsers used during signing/notarization and can lead to a key being +silently stripped from the bundled `Info.plist`. The result is that the +OS refuses to show the TCC prompt and the feature fails for users with +no recoverable error message. + +This invariant is enforced by `scripts/check-info-plist.cjs` and the +`.github/workflows/info-plist-check.yml` workflow. You can run the check +locally: + +```bash +node scripts/check-info-plist.cjs +``` + +## Windows hosting the Camera component + +`getUserMedia()` requires a "secure context". Any Tauri window that +renders `src/components/Search/Camera.tsx` (or that ever might) must set +`"useHttpsScheme": true` in its window config in +`src-tauri/tauri.conf.json`. The `main` window already does. If you add +a new window that hosts the camera, set this flag — otherwise the camera +will work in `tauri dev` (which loads `http://localhost:6060`, treated +as secure) but fail in production. diff --git a/package-lock.json b/package-lock.json index d7ee2c58..038f1adc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1775,9 +1775,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1799,9 +1796,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1823,9 +1817,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1847,9 +1838,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1871,9 +1859,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1895,9 +1880,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3156,9 +3138,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3173,9 +3152,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3190,9 +3166,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3207,9 +3180,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3224,9 +3194,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3241,9 +3208,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3258,9 +3222,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3275,9 +3236,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3292,9 +3250,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3309,9 +3264,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3326,9 +3278,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3343,9 +3292,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3360,9 +3306,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3615,9 +3558,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3635,9 +3575,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3655,9 +3592,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3675,9 +3609,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3879,9 +3810,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -3899,9 +3827,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -3919,9 +3844,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -3939,9 +3861,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -3959,9 +3878,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -8175,9 +8091,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8199,9 +8112,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8223,9 +8133,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8247,9 +8154,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/scripts/check-info-plist.cjs b/scripts/check-info-plist.cjs new file mode 100644 index 00000000..311b3c93 --- /dev/null +++ b/scripts/check-info-plist.cjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ +/** + * check-info-plist.cjs + * + * Validates `src-tauri/Info.plist` (or any plist passed as argv[2]): + * 1. The file parses as well-formed XML (top-level `` present). + * 2. Required privacy-usage-description keys appear EXACTLY ONCE. + * Duplicate keys can be silently stripped during macOS code-signing / + * notarization, which leads to TCC refusing to show the permission + * prompt at runtime (camera/mic stop working in release builds even + * though they worked in `tauri dev`). + * 3. On macOS, additionally runs `plutil -lint` against the file. + * + * Exits non-zero on any failure so CI can fail the build. + * + * Usage: + * node scripts/check-info-plist.cjs [path/to/Info.plist] + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const REQUIRED_KEYS_EXACTLY_ONCE = [ + "NSCameraUsageDescription", + "NSMicrophoneUsageDescription", +]; + +// These keys, if present, must also be unique. (We don't require them to +// exist, but if they do, duplicates are still illegal.) +const OPTIONAL_UNIQUE_KEYS = [ + "NSSpeechRecognitionUsageDescription", + "NSAppleEventsUsageDescription", + "NSAccessibility", + "CFBundleIdentifier", + "CFBundleExecutable", + "LSUIElement", +]; + +function fail(msg) { + console.error(`\u001b[31m[check-info-plist] ERROR:\u001b[0m ${msg}`); + process.exitCode = 1; +} + +function ok(msg) { + console.log(`\u001b[32m[check-info-plist] OK:\u001b[0m ${msg}`); +} + +function main() { + const target = + process.argv[2] || + path.resolve(__dirname, "..", "src-tauri", "Info.plist"); + + if (!fs.existsSync(target)) { + fail(`File not found: ${target}`); + return; + } + + const xml = fs.readFileSync(target, "utf8"); + + if (!/<\s*plist\b/.test(xml) || !/<\s*dict\b/.test(xml)) { + fail(`Not a valid plist (missing /): ${target}`); + return; + } + + // Count occurrences of each NAME in the file. + // We only look at top-level-ish keys here; the regex is intentionally + // simple because plists put every key inside . + const keyRegex = /([^<]+)<\/key>/g; + const counts = new Map(); + let m; + while ((m = keyRegex.exec(xml)) !== null) { + const k = m[1].trim(); + counts.set(k, (counts.get(k) || 0) + 1); + } + + let failed = false; + + for (const key of REQUIRED_KEYS_EXACTLY_ONCE) { + const c = counts.get(key) || 0; + if (c === 0) { + fail(`Required key '${key}' is missing from ${target}`); + failed = true; + } else if (c > 1) { + fail( + `Key '${key}' appears ${c} times in ${target} (must appear exactly once)` + ); + failed = true; + } + } + + for (const key of OPTIONAL_UNIQUE_KEYS) { + const c = counts.get(key) || 0; + if (c > 1) { + fail( + `Key '${key}' appears ${c} times in ${target} (must appear at most once)` + ); + failed = true; + } + } + + // Generic safety net: warn on any duplicate top-level key we didn't + // explicitly list above. + for (const [key, c] of counts.entries()) { + if (c > 1 && !REQUIRED_KEYS_EXACTLY_ONCE.includes(key) && !OPTIONAL_UNIQUE_KEYS.includes(key)) { + fail( + `Key '${key}' appears ${c} times in ${target} (duplicate keys are not allowed)` + ); + failed = true; + } + } + + if (!failed) { + ok( + `${target}: ${REQUIRED_KEYS_EXACTLY_ONCE.join(", ")} each present exactly once` + ); + } + + // On macOS, also run plutil -lint for a real parser check. + if (process.platform === "darwin") { + const r = spawnSync("plutil", ["-lint", target], { + encoding: "utf8", + }); + if (r.status !== 0) { + fail( + `plutil -lint failed for ${target}: ${(r.stdout || "") + (r.stderr || "")}` + ); + } else { + ok(`plutil -lint passed for ${target}`); + } + } else { + console.log( + `[check-info-plist] Skipping 'plutil -lint' (not running on macOS).` + ); + } +} + +main(); diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist index d81e5b92..33fc04d9 100644 --- a/src-tauri/Info.plist +++ b/src-tauri/Info.plist @@ -2,45 +2,52 @@ - NSCameraUsageDescription - Request camera access for WebRTC - NSMicrophoneUsageDescription - Request microphone access for WebRTC - CFBundleIdentifier rs.coco.app CFBundleExecutable - coco + coco NSPrefPaneIconLabel coco-ai LSUIElement - + - ITSAppUsesNonExemptEncryption - + ITSAppUsesNonExemptEncryption + - CFBundleURLTypes - - - CFBundleURLName - rs.coco.app - CFBundleURLSchemes - - coco - - - - - NSMicrophoneUsageDescription - Coco AI needs access to your microphone for voice input and audio recording features. - NSCameraUsageDescription - Coco AI requires camera access for scanning documents and capturing images. - NSSpeechRecognitionUsageDescription - Coco AI uses speech recognition to convert your voice into text for a hands-free experience. - NSAppleEventsUsageDescription - Coco AI requires access to Apple Events to enable certain features, such as opening files and applications. - NSAccessibility - + CFBundleURLTypes + + + CFBundleURLName + rs.coco.app + CFBundleURLSchemes + + coco + + + + + + NSMicrophoneUsageDescription + Coco AI needs access to your microphone for voice input and audio recording features. + NSCameraUsageDescription + Coco AI requires camera access for scanning documents and capturing images. + NSSpeechRecognitionUsageDescription + Coco AI uses speech recognition to convert your voice into text for a hands-free experience. + NSAppleEventsUsageDescription + Coco AI requires access to Apple Events to enable certain features, such as opening files and applications. + NSAccessibility + - \ No newline at end of file + diff --git a/src/components/Search/Camera.tsx b/src/components/Search/Camera.tsx index 0c838d6b..557315e1 100644 --- a/src/components/Search/Camera.tsx +++ b/src/components/Search/Camera.tsx @@ -1,9 +1,15 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Camera as CameraIcon, CameraOff, + Check, + ChevronDown, + ChevronRight, + Copy, FlipHorizontal2, + RefreshCcw, + Settings as SettingsIcon, SwitchCamera, } from "lucide-react"; import clsx from "clsx"; @@ -11,21 +17,78 @@ import clsx from "clsx"; import platformAdapter from "@/utils/platformAdapter"; import { isMac } from "@/utils/platform"; import { useAppStore } from "@/stores/appStore"; +import { copyToClipboard } from "@/utils"; + +/** + * Permission state machine. + * + * macOS's TCC system distinguishes four states for camera access: + * - notDetermined: user has never been asked + * - granted: user said yes + * - denied: user said no (OS will NOT prompt again) + * - restricted: blocked by parental controls / MDM + * + * The `tauri-plugin-macos-permissions-api` only exposes a boolean check, so + * we distinguish "denied" from "notDetermined" empirically: after asking the + * plugin to request permission, we attempt `getUserMedia` directly. If the + * OS has already denied this app, `getUserMedia` rejects synchronously with + * a `NotAllowedError`; if the user simply hasn't responded yet, it will + * either resolve (granted) or hang waiting on the system prompt. + */ +type PermissionState = + | "checking" + | "prompting" + | "granted" + | "denied" + | "restricted" + | "error"; + +/** Map a DOMException name from getUserMedia to our permission state. */ +function classifyMediaError(err: unknown): { + state: PermissionState; + name: string; + message: string; +} { + const e = err as { name?: string; message?: string } | null | undefined; + const name = e?.name ?? "UnknownError"; + const message = e?.message ?? String(err); + switch (name) { + case "NotAllowedError": + case "SecurityError": + // SecurityError on macOS Safari/WebKit typically means TCC denied. + return { state: "denied", name, message }; + case "NotFoundError": + case "OverconstrainedError": + case "NotReadableError": + // Hardware-level failure (no camera, in use by another app, …). + return { state: "error", name, message }; + default: + return { state: "error", name, message }; + } +} const Camera = () => { const { t } = useTranslation(); const withVisibility = useAppStore((state) => state.withVisibility); const videoRef = useRef(null); const canvasRef = useRef(null); - // Use a ref to track stream to avoid stale closure issues in cleanup + // Use a ref to track stream to avoid stale closure issues in cleanup. const streamRef = useRef(null); const [stream, setStream] = useState(null); const [devices, setDevices] = useState([]); const [selectedDeviceId, setSelectedDeviceId] = useState(""); const [mirrored, setMirrored] = useState(true); - const [error, setError] = useState(""); + const [permission, setPermission] = useState("checking"); + const [lastError, setLastError] = useState<{ + name: string; + message: string; + } | null>(null); const [flashVisible, setFlashVisible] = useState(false); const [ready, setReady] = useState(false); + const [diagnosticsOpen, setDiagnosticsOpen] = useState(false); + const [diagnosticsCopied, setDiagnosticsCopied] = useState(false); + // Increment to trigger a re-check from the recovery panel. + const [recheckNonce, setRecheckNonce] = useState(0); const stopCurrentStream = useCallback(() => { if (streamRef.current) { @@ -34,83 +97,114 @@ const Camera = () => { } }, []); - // Initialize: check permissions, enumerate devices, start camera + // Initialize: check permissions, enumerate devices, start camera. useEffect(() => { let cancelled = false; const init = async () => { + setPermission("checking"); + setLastError(null); + setReady(false); try { - // Step 1: Check/request macOS native camera permission + // Step 1: On macOS, check the native TCC status first so we can + // distinguish the four states up front and avoid pointless polling + // when the user has already denied. if (isMac) { - const authorized = await platformAdapter.checkCameraPermission(); - if (!authorized) { + let granted = await platformAdapter.checkCameraPermission(); + if (!granted) { + // notDetermined OR denied. Ask the plugin to request — this is + // a no-op for previously-denied apps. + setPermission("prompting"); platformAdapter.requestCameraPermission(); - // Poll until permission is granted (timeout after 60 seconds) - const POLL_TIMEOUT_MS = 60000; + + // Poll briefly for granted. Use a SHORT timeout (8s) instead of + // the old 60s — if the user denies we want to show the recovery + // UI quickly, not stare at a spinner. + const POLL_TIMEOUT_MS = 8000; const POLL_INTERVAL_MS = 500; - await new Promise((resolve, reject) => { - let elapsed = 0; - const timer = setInterval(async () => { - if (cancelled) { - clearInterval(timer); - reject(new Error("cancelled")); - return; - } - elapsed += POLL_INTERVAL_MS; - if (elapsed >= POLL_TIMEOUT_MS) { - clearInterval(timer); - reject(new Error("Camera permission timeout")); - return; - } - const granted = - await platformAdapter.checkCameraPermission(); - if (granted) { - clearInterval(timer); - resolve(); - } - }, POLL_INTERVAL_MS); - }); + const startedAt = Date.now(); + while (!granted && Date.now() - startedAt < POLL_TIMEOUT_MS) { + if (cancelled) return; + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + granted = await platformAdapter.checkCameraPermission(); + } + + if (!granted) { + // Definitively classify by attempting getUserMedia. If the OS + // already denied this app, it rejects immediately. + if (cancelled) return; + try { + const probe = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: false, + }); + // User granted right at the end of polling. + granted = true; + // Re-use this stream below to avoid a second prompt. + streamRef.current = probe; + } catch (err) { + if (cancelled) return; + const classified = classifyMediaError(err); + console.error( + `[Camera] getUserMedia rejected: ${classified.name} - ${classified.message}` + ); + setLastError({ + name: classified.name, + message: classified.message, + }); + setPermission(classified.state); + return; + } + } } } if (cancelled) return; - // Step 2: Request an initial stream to trigger browser permission prompt - // and enable device enumeration with labels + // Step 2: Request a stream (or reuse the probe stream from step 1). let initialStream: MediaStream; - try { - initialStream = await navigator.mediaDevices.getUserMedia({ - video: true, - audio: false, - }); - } catch (err) { - console.error("Camera getUserMedia failed:", err); - if (!cancelled) { - setError(t("camera.errorAccess")); + if (streamRef.current) { + initialStream = streamRef.current; + } else { + try { + initialStream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: false, + }); + } catch (err) { + const classified = classifyMediaError(err); + console.error( + `[Camera] getUserMedia rejected: ${classified.name} - ${classified.message}` + ); + if (!cancelled) { + setLastError({ + name: classified.name, + message: classified.message, + }); + setPermission(classified.state); + } + return; } - return; } if (cancelled) { initialStream.getTracks().forEach((track) => track.stop()); + streamRef.current = null; return; } - // Step 3: Enumerate devices now that we have permission - const allDevices = - await navigator.mediaDevices.enumerateDevices(); - console.log('allDevices',allDevices) + // Step 3: Enumerate devices now that we have permission. + const allDevices = await navigator.mediaDevices.enumerateDevices(); const videoDevices = allDevices.filter( (d) => d.kind === "videoinput" ); if (cancelled) { initialStream.getTracks().forEach((track) => track.stop()); + streamRef.current = null; return; } - console.log('videoDevices',videoDevices) - setDevices(videoDevices); - // Step 4: Use the initial stream directly and set the selected device + // Step 4: Use the initial stream directly and set the selected device. streamRef.current = initialStream; setStream(initialStream); @@ -119,7 +213,7 @@ const Camera = () => { } if (videoDevices.length > 0) { - // Find the device that matches the current stream's track + // Find the device that matches the current stream's track. const currentTrack = initialStream.getVideoTracks()[0]; const trackSettings = currentTrack?.getSettings(); const currentDeviceId = trackSettings?.deviceId || ""; @@ -132,12 +226,19 @@ const Camera = () => { ); } - // ready will be set to true when the video fires onPlaying - setError(""); + setPermission("granted"); + setLastError(null); } catch (err) { - console.error("Camera initialization failed:", err); + const classified = classifyMediaError(err); + console.error( + `[Camera] initialization failed: ${classified.name} - ${classified.message}` + ); if (!cancelled) { - setError(t("camera.errorAccess")); + setLastError({ + name: classified.name, + message: classified.message, + }); + setPermission(classified.state); } } }; @@ -148,18 +249,18 @@ const Camera = () => { cancelled = true; stopCurrentStream(); }; - }, [t, stopCurrentStream]); + }, [t, stopCurrentStream, recheckNonce]); - // Switch camera when device selection changes (after initial setup) + // Switch camera when device selection changes (after initial setup). useEffect(() => { - if (!ready || !selectedDeviceId) return; + if (permission !== "granted" || !ready || !selectedDeviceId) return; - // Check if the current stream already uses the selected device + // Check if the current stream already uses the selected device. if (streamRef.current) { const currentTrack = streamRef.current.getVideoTracks()[0]; const currentDeviceId = currentTrack?.getSettings()?.deviceId; if (currentDeviceId === selectedDeviceId) { - return; // Already using this device + return; // Already using this device. } } @@ -187,11 +288,18 @@ const Camera = () => { videoRef.current.srcObject = mediaStream; } - setError(""); + setLastError(null); } catch (err) { - console.error("Failed to switch camera:", err); + const classified = classifyMediaError(err); + console.error( + `[Camera] failed to switch camera: ${classified.name} - ${classified.message}` + ); if (!cancelled) { - setError(t("camera.errorAccess")); + setLastError({ + name: classified.name, + message: classified.message, + }); + setPermission(classified.state); } } }; @@ -201,7 +309,7 @@ const Camera = () => { return () => { cancelled = true; }; - }, [selectedDeviceId, ready, t, stopCurrentStream]); + }, [selectedDeviceId, ready, permission, stopCurrentStream]); const takePhoto = useCallback(async () => { if (!videoRef.current || !canvasRef.current) return; @@ -244,7 +352,7 @@ const Camera = () => { data: Array.from(new Uint8Array(arrayBuffer)), }); } - }, [mirrored]); + }, [mirrored, withVisibility]); const toggleMirror = useCallback(() => { setMirrored((prev) => !prev); @@ -259,23 +367,150 @@ const Camera = () => { setSelectedDeviceId(devices[nextIndex].deviceId); }, [devices, selectedDeviceId]); + // Open the macOS System Settings → Privacy → Camera pane directly. + const openSystemSettings = useCallback(() => { + if (isMac) { + platformAdapter.openUrl( + "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera" + ); + } + }, []); + const recheck = useCallback(() => { + stopCurrentStream(); + setRecheckNonce((n) => n + 1); + }, [stopCurrentStream]); + + // Diagnostics info shown in the recovery panel. + const diagnostics = useMemo(() => { + return { + bundleId: "rs.coco.app", + appVersion: (typeof process !== "undefined" && process.env?.VERSION) || "unknown", + platform: navigator.platform, + userAgent: navigator.userAgent, + lastErrorName: lastError?.name ?? "none", + lastErrorMessage: lastError?.message ?? "none", + permissionState: permission, + }; + }, [lastError, permission]); + + const copyDiagnostics = useCallback(async () => { + const text = Object.entries(diagnostics) + .map(([k, v]) => `${k}: ${v}`) + .join("\n"); + await copyToClipboard(text, true); + setDiagnosticsCopied(true); + setTimeout(() => setDiagnosticsCopied(false), 1500); + }, [diagnostics]); + + const renderRecoveryPanel = () => { + const isDenied = permission === "denied"; + const isRestricted = permission === "restricted"; + const titleKey = isRestricted + ? "camera.restricted.title" + : isDenied + ? "camera.denied.title" + : "camera.errorAccess"; + const bodyKey = isRestricted + ? "camera.restricted.body" + : isDenied + ? "camera.denied.body" + : "camera.error.body"; + + return ( +
+ +

{t(titleKey)}

+

+ {t(bodyKey)} +

+ +
+ {isMac && isDenied && ( + + )} + + +
+ +
+ + + {diagnosticsOpen && ( +
+
+ {Object.entries(diagnostics).map(([k, v]) => ( +
+
{k}
+
{String(v)}
+
+ ))} +
+ +
+ )} +
+
+ ); + }; + + const inRecoveryState = + permission === "denied" || + permission === "restricted" || + permission === "error"; return (
{/* Camera viewport */}
- {error ? ( -
- -

{error}

-
+ {inRecoveryState ? ( + renderRecoveryPanel() ) : ( <> - {!ready && ( + {(permission === "checking" || + permission === "prompting" || + !ready) && (
-

{t("camera.initializing")}

+

+ {permission === "prompting" + ? t("camera.prompting") + : t("camera.initializing")} +

)}
{/* Controls bar */} -
- - - - - {devices.length > 1 && ( + {!inRecoveryState && ( +
- )} -
+ + + + {devices.length > 1 && ( + + )} +
+ )} {/* Hidden canvas for photo capture */} diff --git a/src/components/Settings/PrivacySettings.tsx b/src/components/Settings/PrivacySettings.tsx new file mode 100644 index 00000000..2f6b4fbc --- /dev/null +++ b/src/components/Settings/PrivacySettings.tsx @@ -0,0 +1,237 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Camera, + Mic, + Accessibility as AccessibilityIcon, + Monitor, + Workflow, + ExternalLink, + RefreshCcw, + Check, + X, + HelpCircle, + type LucideIcon, +} from "lucide-react"; + +import platformAdapter from "@/utils/platformAdapter"; +import { isMac } from "@/utils/platform"; + +type Status = "granted" | "denied" | "unknown"; + +interface PermissionRow { + id: string; + i18nKey: string; // privacy.items. + icon: LucideIcon; + // macOS x-apple.systempreferences anchor used to deep-link the right pane. + systemSettingsUrl: string; + /** Returns true if granted, false if denied/unknown. */ + check?: () => Promise; + /** Triggers the OS prompt (if applicable). */ + request?: () => Promise | void; +} + +/** + * Settings → Privacy & Permissions panel. + * + * Lists the macOS permissions Coco AI requests and provides deep links to + * the relevant System Settings panes plus an in-app refresh so users can + * verify their changes without restarting the app. + * + * Why this exists: macOS's TCC system silently caches denials. If a user + * (or a previous dev build) ever clicked "Don't Allow", the OS will NOT + * prompt again, and the feature appears broken with no recoverable error. + * This panel surfaces the current status and shows the documented escape + * hatch (`tccutil reset`). The app cannot run `tccutil` itself because it + * is sandboxed. + */ +const PrivacySettings = () => { + const { t } = useTranslation(); + const [statuses, setStatuses] = useState>({}); + const [refreshing, setRefreshing] = useState(false); + + const rows: PermissionRow[] = [ + { + id: "camera", + i18nKey: "camera", + icon: Camera, + systemSettingsUrl: + "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera", + check: () => platformAdapter.checkCameraPermission(), + request: () => platformAdapter.requestCameraPermission(), + }, + { + id: "microphone", + i18nKey: "microphone", + icon: Mic, + systemSettingsUrl: + "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone", + check: () => platformAdapter.checkMicrophonePermission(), + request: () => platformAdapter.requestMicrophonePermission(), + }, + { + id: "accessibility", + i18nKey: "accessibility", + icon: AccessibilityIcon, + systemSettingsUrl: + "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", + // No direct check in the adapter; status will be "unknown". + }, + { + id: "screenRecording", + i18nKey: "screenRecording", + icon: Monitor, + systemSettingsUrl: + "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture", + check: () => platformAdapter.checkScreenRecordingPermission(), + request: () => platformAdapter.requestScreenRecordingPermission(), + }, + { + id: "automation", + i18nKey: "automation", + icon: Workflow, + systemSettingsUrl: + "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation", + }, + ]; + + const refresh = useCallback(async () => { + setRefreshing(true); + const next: Record = {}; + await Promise.all( + rows.map(async (row) => { + if (!row.check) { + next[row.id] = "unknown"; + return; + } + try { + const ok = await row.check(); + next[row.id] = ok ? "granted" : "denied"; + } catch (err) { + console.error(`[Privacy] check failed for ${row.id}:`, err); + next[row.id] = "unknown"; + } + }) + ); + setStatuses(next); + setRefreshing(false); + // We intentionally don't include `rows` in deps: it is reconstructed + // each render but its shape is stable. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!isMac) return; + refresh(); + }, [refresh]); + + if (!isMac) { + // The whole TCC story is macOS-only; on other OSes show a short note + // rather than misleading "denied" rows. + return ( +
+ {t("privacy.title")} —{" "} + macOS only +
+ ); + } + + const renderStatus = (s: Status) => { + if (s === "granted") { + return ( + + + {t("privacy.status.granted")} + + ); + } + if (s === "denied") { + return ( + + + {t("privacy.status.denied")} + + ); + } + return ( + + + {t("privacy.status.unknown")} + + ); + }; + + return ( +
+
+
+

+ {t("privacy.title")} +

+

+ {t("privacy.description")} +

+
+ +
+ +
    + {rows.map((row) => { + const Icon = row.icon; + const status = statuses[row.id] ?? "unknown"; + return ( +
  • + +
    +
    + + {t(`privacy.items.${row.i18nKey}.name`)} + + {renderStatus(status)} +
    +

    + {t(`privacy.items.${row.i18nKey}.description`)} +

    +
    + +
  • + ); + })} +
+ +
+

{t("privacy.tccHint.title")}

+
+          {t("privacy.tccHint.body")}
+        
+
+
+ ); +}; + +export default PrivacySettings; diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 8c39b975..9baad1cc 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -225,6 +225,7 @@ "extensions": "Extensions", "connect": "Connect", "advanced": "Advanced", + "privacy": "Privacy", "about": "About", "extensionsContent": "Extensions settings content", "advancedContent": "Advanced Settings content" @@ -742,6 +743,66 @@ "switchCamera": "Switch Camera", "close": "Close Camera", "errorAccess": "Unable to access camera. Please check permissions.", - "initializing": "Starting camera, please wait..." + "initializing": "Starting camera, please wait...", + "prompting": "Waiting for camera permission...", + "denied": { + "title": "Camera access is blocked", + "body": "macOS is blocking camera access for Coco AI.\nOpen System Settings, allow camera access for Coco AI, then click Re-check." + }, + "restricted": { + "title": "Camera access is restricted", + "body": "Camera access is restricted on this Mac, likely by Screen Time, parental controls, or your organization's device management. Contact your administrator to enable it." + }, + "error": { + "body": "Coco AI could not start the camera. The camera may be missing, in use by another app, or otherwise unavailable. See diagnostics below for details." + }, + "actions": { + "openSystemSettings": "Open System Settings → Privacy & Security → Camera", + "recheck": "Re-check" + }, + "diagnostics": { + "title": "Diagnostics", + "copy": "Copy to clipboard", + "copied": "Copied!" + } + }, + "privacy": { + "title": "Privacy & Permissions", + "description": "These macOS permissions are managed by the system. Click \"Open\" to allow or revoke access in System Settings.", + "status": { + "granted": "Granted", + "denied": "Not granted", + "unknown": "Unknown" + }, + "actions": { + "open": "Open", + "refresh": "Refresh" + }, + "items": { + "camera": { + "name": "Camera", + "description": "Used for the Open Camera extension to capture photos." + }, + "microphone": { + "name": "Microphone", + "description": "Used for voice input and audio recording." + }, + "accessibility": { + "name": "Accessibility", + "description": "Used for global shortcuts and text-selection features." + }, + "screenRecording": { + "name": "Screen Recording", + "description": "Used to capture screenshots of monitors and windows." + }, + "automation": { + "name": "Automation (Apple Events)", + "description": "Used to open files and control other applications." + } + }, + "tccHint": { + "title": "Still not working?", + "body": "If macOS refuses to re-prompt for a permission after you toggle it, open Terminal and run:\n\n tccutil reset rs.coco.app\n\nReplace with Camera, Microphone, Accessibility, ScreenCapture, or AppleEvents. Then relaunch Coco AI. We can't run this for you because the app is sandboxed." + } } } diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index df403ec1..d0e3a408 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -225,6 +225,7 @@ "extensions": "扩展", "connect": "连接", "advanced": "高级", + "privacy": "隐私", "about": "关于", "extensionsContent": "扩展设置内容", "advancedContent": "高级设置内容" @@ -741,6 +742,66 @@ "switchCamera": "切换摄像头", "close": "关闭摄像头", "errorAccess": "无法访问摄像头,请检查权限设置。", - "initializing": "摄像头启动中,请稍候..." + "initializing": "摄像头启动中,请稍候...", + "prompting": "等待授予摄像头权限...", + "denied": { + "title": "摄像头访问被拒绝", + "body": "macOS 已阻止 Coco AI 访问摄像头。\n请打开「系统设置」,允许 Coco AI 使用摄像头后,点击「重新检查」。" + }, + "restricted": { + "title": "摄像头访问受限", + "body": "本机的摄像头访问受到限制,可能由「屏幕使用时间」、家长控制或企业设备管理策略导致。请联系管理员开启。" + }, + "error": { + "body": "Coco AI 无法启动摄像头。可能是设备缺失、被其他应用占用或不可用。详情见下方诊断信息。" + }, + "actions": { + "openSystemSettings": "打开「系统设置 → 隐私与安全性 → 摄像头」", + "recheck": "重新检查" + }, + "diagnostics": { + "title": "诊断信息", + "copy": "复制到剪贴板", + "copied": "已复制!" + } + }, + "privacy": { + "title": "隐私与权限", + "description": "以下 macOS 权限由系统管理。点击「打开」可在「系统设置」中授予或撤销访问权限。", + "status": { + "granted": "已授予", + "denied": "未授予", + "unknown": "未知" + }, + "actions": { + "open": "打开", + "refresh": "刷新" + }, + "items": { + "camera": { + "name": "摄像头", + "description": "用于「打开摄像头」扩展进行拍照。" + }, + "microphone": { + "name": "麦克风", + "description": "用于语音输入与录音功能。" + }, + "accessibility": { + "name": "辅助功能", + "description": "用于全局快捷键和文本选择相关功能。" + }, + "screenRecording": { + "name": "屏幕录制", + "description": "用于截取显示器和窗口截图。" + }, + "automation": { + "name": "自动化(Apple Events)", + "description": "用于打开文件和控制其他应用。" + } + }, + "tccHint": { + "title": "仍然无法使用?", + "body": "如果切换权限后 macOS 仍不再提示,请在终端中运行:\n\n tccutil reset <服务名> rs.coco.app\n\n将 <服务名> 替换为 Camera、Microphone、Accessibility、ScreenCapture 或 AppleEvents 之一,然后重启 Coco AI。由于应用运行在沙盒中,我们无法自动执行此命令。" + } } } diff --git a/src/pages/settings/index.tsx b/src/pages/settings/index.tsx index 02c4db17..b4099276 100644 --- a/src/pages/settings/index.tsx +++ b/src/pages/settings/index.tsx @@ -1,12 +1,13 @@ import { useState, useEffect } from "react"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; -import { Settings, Puzzle, Settings2, Info, Server } from "lucide-react"; +import { Settings, Puzzle, Settings2, Info, Server, Shield } from "lucide-react"; import { useTranslation } from "react-i18next"; import { listen } from "@tauri-apps/api/event"; import SettingsPanel from "@/components/Settings/SettingsPanel"; import GeneralSettings from "@/components/Settings/GeneralSettings"; import AboutView from "@/components/Settings/AboutView"; +import PrivacySettings from "@/components/Settings/PrivacySettings"; import Cloud from "@/components/Cloud/Cloud"; import Footer from "@/components/Common/UI/SettingsFooter"; import { useTray } from "@/hooks/useTray"; @@ -17,12 +18,14 @@ import platformAdapter from "@/utils/platformAdapter"; import { useAppStore } from "@/stores/appStore"; import { useExtensionsStore } from "@/stores/extensionsStore"; import { useAppearanceStore } from "@/stores/appearanceStore"; +import { isMac } from "@/utils/platform"; const tabValues = [ "general", "extensions", "connect", "advanced", + "privacy", "about", ] as const; type TabValue = (typeof tabValues)[number]; @@ -38,6 +41,16 @@ function SettingsPage() { { name: t("settings.tabs.extensions"), icon: Puzzle, value: "extensions" }, { name: t("settings.tabs.connect"), icon: Server, value: "connect" }, { name: t("settings.tabs.advanced"), icon: Settings2, value: "advanced" }, + // Privacy/TCC is a macOS-only story; hide the tab elsewhere. + ...(isMac + ? [ + { + name: t("settings.tabs.privacy"), + icon: Shield, + value: "privacy" as TabValue, + }, + ] + : []), { name: t("settings.tabs.about"), icon: Info, value: "about" }, ]; @@ -126,6 +139,13 @@ function SettingsPage() { + {isMac && ( + + + + + + )}