Files
coco-app/src/pages/settings/index.tsx
copilot-swe-agent[bot] e6119e1b76 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>
2026-05-18 09:42:58 +00:00

163 lines
5.1 KiB
TypeScript

import { useState, useEffect } from "react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
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";
import Advanced from "@/components/Settings/Advanced";
import Extensions from "@/components/Settings/Extensions";
import { useConnectStore } from "@/stores/connectStore";
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];
function SettingsPage() {
const { t } = useTranslation();
const { setConfigId } = useExtensionsStore();
useTray();
const tabs: { name: string; icon: any; value: TabValue }[] = [
{ name: t("settings.tabs.general"), icon: Settings, value: "general" },
{ 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" },
];
const [selectedTab, setSelectedTab] = useState<TabValue>("general");
useEffect(() => {
const unlisten = listen("tab_index", (event) => {
const tabName = event.payload as TabValue;
if (tabValues.includes(tabName)) {
setSelectedTab(tabName);
}
});
const unsubscribeConnect = useConnectStore.subscribe((state) => {
platformAdapter.emitEvent("change-connect-store", state);
});
const unsubscribeAppStore = useAppStore.subscribe((state) => {
platformAdapter.emitEvent("change-app-store", state);
});
const unsubscribeAppearanceStore = useAppearanceStore.subscribe((state) => {
platformAdapter.emitEvent("change-appearance-store", state);
});
const unlisten2 = platformAdapter.listenEvent(
"config-extension",
({ payload }) => {
platformAdapter.showWindow();
setSelectedTab("extensions");
setConfigId(payload);
}
);
return () => {
unsubscribeConnect();
unsubscribeAppStore();
unsubscribeAppearanceStore();
unlisten.then((fn) => fn());
unlisten2.then((fn) => fn());
};
}, []);
useEffect(() => {
document.body.style.overflow =
selectedTab === "extensions" ? "hidden" : "auto";
}, [selectedTab]);
return (
<>
<div className="min-h-screen pb-8 bg-background text-foreground">
<div className="max-w-6xl mx-auto p-4">
<Tabs
value={selectedTab}
onValueChange={(v) => setSelectedTab(v as TabValue)}
>
<TabsList className="flex h-10 rounded-xl">
{tabs.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="flex-1 gap-2 h-full"
>
<tab.icon className="size-4" />
<span>{tab.name}</span>
</TabsTrigger>
))}
</TabsList>
<TabsContent value="general">
<SettingsPanel title="">
<GeneralSettings />
</SettingsPanel>
</TabsContent>
<TabsContent value="extensions">
<SettingsPanel title="">
<Extensions />
</SettingsPanel>
</TabsContent>
<TabsContent value="connect">
<Cloud />
</TabsContent>
<TabsContent value="advanced">
<SettingsPanel title="">
<Advanced />
</SettingsPanel>
</TabsContent>
{isMac && (
<TabsContent value="privacy">
<SettingsPanel title="">
<PrivacySettings />
</SettingsPanel>
</TabsContent>
)}
<TabsContent value="about">
<SettingsPanel title="">
<AboutView />
</SettingsPanel>
</TabsContent>
</Tabs>
</div>
</div>
<Footer />
</>
);
}
export default SettingsPage;