feat(camera): permission state machine, recovery UI, privacy panel

- Deduplicate NSCameraUsageDescription / NSMicrophoneUsageDescription
  in src-tauri/Info.plist and add scripts/check-info-plist.cjs +
  info-plist-check.yml workflow to enforce uniqueness in CI
- Rewrite Camera.tsx with explicit permission state machine
  (checking/prompting/granted/denied/restricted/error). Denial is
  detected via getUserMedia NotAllowedError instead of a silent
  60-second poll
- Add "Camera blocked" recovery panel: Open System Settings deep link
  (x-apple.systempreferences:...Privacy_Camera), Re-check button, and
  collapsible Diagnostics with copy-to-clipboard
- Always log err.name from getUserMedia rejections; drop console.log
  device noise
- Add Settings → Privacy panel listing Camera, Microphone,
  Accessibility, Screen Recording, Automation with status pills,
  per-row "Open in System Settings" buttons and a tccutil escape-hatch
  hint. macOS-only
- Add CONTRIBUTING.md documenting tccutil reset for dev-vs-release
  bundle id collisions and the useHttpsScheme requirement for
  Camera-hosting windows
- EN/ZH translations for all new strings

Agent-Logs-Url: https://github.com/infinilabs/coco-app/sessions/c8e85860-673a-4951-8677-8fcaeb8ad109

Co-authored-by: medcl <64487+medcl@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-05-18 09:42:58 +00:00
committed by GitHub
parent 71a51f6644
commit e6119e1b76
10 changed files with 1030 additions and 231 deletions

47
.github/workflows/info-plist-check.yml vendored Normal file
View File

@@ -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

83
CONTRIBUTING.md Normal file
View File

@@ -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.

96
package-lock.json generated
View File

@@ -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": [

View File

@@ -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 `<dict>` 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 <plist>/<dict>): ${target}`);
return;
}
// Count occurrences of each <key>NAME</key> in the file.
// We only look at top-level-ish keys here; the regex is intentionally
// simple because plists put every key inside <key>…</key>.
const keyRegex = /<key>([^<]+)<\/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();

View File

@@ -2,45 +2,52 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSCameraUsageDescription</key>
<string>Request camera access for WebRTC</string>
<key>NSMicrophoneUsageDescription</key>
<string>Request microphone access for WebRTC</string>
<key>CFBundleIdentifier</key>
<string>rs.coco.app</string>
<key>CFBundleExecutable</key>
<string>coco</string>
<string>coco</string>
<key>NSPrefPaneIconLabel</key>
<string>coco-ai</string>
<key>LSUIElement</key>
<true/>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>rs.coco.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>coco</string>
</array>
</dict>
</array>
<key>NSMicrophoneUsageDescription</key>
<string>Coco AI needs access to your microphone for voice input and audio recording features.</string>
<key>NSCameraUsageDescription</key>
<string>Coco AI requires camera access for scanning documents and capturing images.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Coco AI uses speech recognition to convert your voice into text for a hands-free experience.</string>
<key>NSAppleEventsUsageDescription</key>
<string>Coco AI requires access to Apple Events to enable certain features, such as opening files and applications.</string>
<key>NSAccessibility</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>rs.coco.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>coco</string>
</array>
</dict>
</array>
<!--
Privacy usage descriptions.
NOTE: Each NS*UsageDescription key MUST appear exactly once in this file.
Duplicate keys cause undefined behavior in the macOS plist parsers used
during signing/notarization and can lead to the key being silently
stripped from the final bundled Info.plist, which causes the OS to
refuse the TCC prompt and `getUserMedia` / native APIs to fail with
permission errors that the user cannot recover from.
A CI check (`scripts/check-info-plist.cjs`) enforces this invariant.
-->
<key>NSMicrophoneUsageDescription</key>
<string>Coco AI needs access to your microphone for voice input and audio recording features.</string>
<key>NSCameraUsageDescription</key>
<string>Coco AI requires camera access for scanning documents and capturing images.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Coco AI uses speech recognition to convert your voice into text for a hands-free experience.</string>
<key>NSAppleEventsUsageDescription</key>
<string>Coco AI requires access to Apple Events to enable certain features, such as opening files and applications.</string>
<key>NSAccessibility</key>
<true/>
</dict>
</plist>
</plist>

View File

@@ -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<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(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<MediaStream | null>(null);
const [stream, setStream] = useState<MediaStream | null>(null);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [selectedDeviceId, setSelectedDeviceId] = useState<string>("");
const [mirrored, setMirrored] = useState(true);
const [error, setError] = useState<string>("");
const [permission, setPermission] = useState<PermissionState>("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<void>((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 (
<div className="flex flex-col items-center justify-center gap-4 px-6 py-8 text-white/80 max-w-md mx-auto">
<CameraOff size={48} className="text-white/60" />
<h3 className="text-base font-semibold text-white">{t(titleKey)}</h3>
<p className="text-sm text-center text-white/70 whitespace-pre-line">
{t(bodyKey)}
</p>
<div className="flex flex-col gap-2 w-full mt-2">
{isMac && isDenied && (
<button
onClick={openSystemSettings}
className="flex items-center justify-center gap-2 px-4 py-2 rounded-md bg-white text-black hover:bg-white/90 transition-colors text-sm font-medium"
>
<SettingsIcon size={16} />
{t("camera.actions.openSystemSettings")}
</button>
)}
<button
onClick={recheck}
className="flex items-center justify-center gap-2 px-4 py-2 rounded-md bg-white/10 text-white hover:bg-white/20 transition-colors text-sm"
>
<RefreshCcw size={16} />
{t("camera.actions.recheck")}
</button>
</div>
<div className="w-full mt-4 border-t border-white/10 pt-3">
<button
onClick={() => setDiagnosticsOpen((v) => !v)}
className="flex items-center gap-1 text-xs text-white/60 hover:text-white/80 transition-colors"
>
{diagnosticsOpen ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
{t("camera.diagnostics.title")}
</button>
{diagnosticsOpen && (
<div className="mt-2 rounded-md bg-black/40 border border-white/10 p-3 text-xs font-mono text-white/70 select-text">
<dl className="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1">
{Object.entries(diagnostics).map(([k, v]) => (
<div key={k} className="contents">
<dt className="text-white/50">{k}</dt>
<dd className="break-all">{String(v)}</dd>
</div>
))}
</dl>
<button
onClick={copyDiagnostics}
className="mt-3 flex items-center gap-1 text-xs text-white/70 hover:text-white transition-colors"
>
{diagnosticsCopied ? (
<Check size={12} />
) : (
<Copy size={12} />
)}
{diagnosticsCopied
? t("camera.diagnostics.copied")
: t("camera.diagnostics.copy")}
</button>
</div>
)}
</div>
</div>
);
};
const inRecoveryState =
permission === "denied" ||
permission === "restricted" ||
permission === "error";
return (
<div className="flex flex-col h-full bg-black select-none overflow-hidden rounded-b-lg">
{/* Camera viewport */}
<div className="relative flex-1 flex items-center justify-center overflow-hidden">
{error ? (
<div className="flex flex-col items-center gap-3 text-white/70">
<CameraOff size={48} />
<p className="text-sm">{error}</p>
</div>
{inRecoveryState ? (
renderRecoveryPanel()
) : (
<>
{!ready && (
{(permission === "checking" ||
permission === "prompting" ||
!ready) && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-white/80 z-10">
<div className="w-8 h-8 border-2 border-white/30 border-t-white/80 rounded-full animate-spin" />
<p className="text-sm">{t("camera.initializing")}</p>
<p className="text-sm">
{permission === "prompting"
? t("camera.prompting")
: t("camera.initializing")}
</p>
</div>
)}
<video
@@ -297,37 +532,40 @@ const Camera = () => {
</div>
{/* Controls bar */}
<div className="flex items-center justify-center gap-4 py-3 px-4 bg-black/80 shrink-0">
<button
onClick={toggleMirror}
className={clsx("p-2 rounded-full transition-colors", {
"bg-white/20 text-white": mirrored,
"bg-white/10 text-white/60 hover:text-white hover:bg-white/20": !mirrored,
})}
title={t("camera.mirror")}
>
<FlipHorizontal2 size={20} />
</button>
<button
onClick={takePhoto}
disabled={!stream}
className="p-3 rounded-full bg-white text-black hover:bg-white/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title={t("camera.takePhoto")}
>
<CameraIcon size={24} />
</button>
{devices.length > 1 && (
{!inRecoveryState && (
<div className="flex items-center justify-center gap-4 py-3 px-4 bg-black/80 shrink-0">
<button
onClick={switchCamera}
className="p-2 rounded-full bg-white/10 text-white/60 hover:text-white hover:bg-white/20 transition-colors"
title={t("camera.switchCamera")}
onClick={toggleMirror}
className={clsx("p-2 rounded-full transition-colors", {
"bg-white/20 text-white": mirrored,
"bg-white/10 text-white/60 hover:text-white hover:bg-white/20":
!mirrored,
})}
title={t("camera.mirror")}
>
<SwitchCamera size={20} />
<FlipHorizontal2 size={20} />
</button>
)}
</div>
<button
onClick={takePhoto}
disabled={!stream}
className="p-3 rounded-full bg-white text-black hover:bg-white/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title={t("camera.takePhoto")}
>
<CameraIcon size={24} />
</button>
{devices.length > 1 && (
<button
onClick={switchCamera}
className="p-2 rounded-full bg-white/10 text-white/60 hover:text-white hover:bg-white/20 transition-colors"
title={t("camera.switchCamera")}
>
<SwitchCamera size={20} />
</button>
)}
</div>
)}
{/* Hidden canvas for photo capture */}
<canvas ref={canvasRef} className="hidden" />

View File

@@ -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.<key>
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<boolean>;
/** Triggers the OS prompt (if applicable). */
request?: () => Promise<unknown> | 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<Record<string, Status>>({});
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<string, Status> = {};
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 (
<div className="text-sm text-[#666] dark:text-white/60 p-4">
{t("privacy.title")} {" "}
<span className="opacity-70">macOS only</span>
</div>
);
}
const renderStatus = (s: Status) => {
if (s === "granted") {
return (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-green-500/10 text-green-600 dark:text-green-400">
<Check size={12} />
{t("privacy.status.granted")}
</span>
);
}
if (s === "denied") {
return (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-red-500/10 text-red-600 dark:text-red-400">
<X size={12} />
{t("privacy.status.denied")}
</span>
);
}
return (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-gray-500/10 text-gray-500 dark:text-gray-400">
<HelpCircle size={12} />
{t("privacy.status.unknown")}
</span>
);
};
return (
<div className="flex flex-col gap-4">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{t("privacy.title")}
</h2>
<p className="mt-1 text-sm text-[#666] dark:text-white/60">
{t("privacy.description")}
</p>
</div>
<button
onClick={refresh}
disabled={refreshing}
className="flex items-center gap-1 text-xs px-3 py-1.5 rounded-md border border-[#e5e5e5] dark:border-white/10 text-[#333] dark:text-white/80 hover:bg-black/5 dark:hover:bg-white/5 disabled:opacity-50"
>
<RefreshCcw
size={12}
className={refreshing ? "animate-spin" : undefined}
/>
{t("privacy.actions.refresh")}
</button>
</div>
<ul className="flex flex-col divide-y divide-[#e5e5e5] dark:divide-white/10 rounded-lg border border-[#e5e5e5] dark:border-white/10">
{rows.map((row) => {
const Icon = row.icon;
const status = statuses[row.id] ?? "unknown";
return (
<li
key={row.id}
className="flex items-center gap-3 px-4 py-3 first:rounded-t-lg last:rounded-b-lg"
>
<Icon
size={18}
className="text-[#666] dark:text-white/60 shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-[#333] dark:text-white/90">
{t(`privacy.items.${row.i18nKey}.name`)}
</span>
{renderStatus(status)}
</div>
<p className="text-xs text-[#666] dark:text-white/50 mt-0.5">
{t(`privacy.items.${row.i18nKey}.description`)}
</p>
</div>
<button
onClick={() =>
platformAdapter.openUrl(row.systemSettingsUrl)
}
className="flex items-center gap-1 text-xs px-3 py-1.5 rounded-md border border-[#e5e5e5] dark:border-white/10 text-[#333] dark:text-white/80 hover:bg-black/5 dark:hover:bg-white/5"
title={row.systemSettingsUrl}
>
<ExternalLink size={12} />
{t("privacy.actions.open")}
</button>
</li>
);
})}
</ul>
<div className="rounded-lg bg-yellow-500/5 border border-yellow-500/20 p-3 text-xs text-[#333] dark:text-white/80">
<p className="font-medium mb-1">{t("privacy.tccHint.title")}</p>
<pre className="whitespace-pre-wrap font-mono text-[11px] leading-snug text-[#555] dark:text-white/70 m-0">
{t("privacy.tccHint.body")}
</pre>
</div>
</div>
);
};
export default PrivacySettings;

View File

@@ -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 <Service> rs.coco.app\n\nReplace <Service> with Camera, Microphone, Accessibility, ScreenCapture, or AppleEvents. Then relaunch Coco AI. We can't run this for you because the app is sandboxed."
}
}
}

View File

@@ -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。由于应用运行在沙盒中我们无法自动执行此命令。"
}
}
}

View File

@@ -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() {
<Advanced />
</SettingsPanel>
</TabsContent>
{isMac && (
<TabsContent value="privacy">
<SettingsPanel title="">
<PrivacySettings />
</SettingsPanel>
</TabsContent>
)}
<TabsContent value="about">
<SettingsPanel title="">
<AboutView />