mirror of
https://github.com/infinilabs/coco-app.git
synced 2026-08-29 10:09:30 +02:00
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:
committed by
GitHub
parent
71a51f6644
commit
e6119e1b76
47
.github/workflows/info-plist-check.yml
vendored
Normal file
47
.github/workflows/info-plist-check.yml
vendored
Normal 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
83
CONTRIBUTING.md
Normal 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
96
package-lock.json
generated
@@ -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": [
|
||||
|
||||
141
scripts/check-info-plist.cjs
Normal file
141
scripts/check-info-plist.cjs
Normal 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();
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
237
src/components/Settings/PrivacySettings.tsx
Normal file
237
src/components/Settings/PrivacySettings.tsx
Normal 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;
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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。由于应用运行在沙盒中,我们无法自动执行此命令。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user