diff --git a/.vscode/settings.json b/.vscode/settings.json index e5c529663..b413c611e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -59,6 +59,7 @@ "serde", "Shadcn", "swatinem", + "systempreferences", "tailwindcss", "tauri", "thiserror", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 18081bd28..ffa5b9c65 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,12 +2,14 @@ mod assistant; mod autostart; mod common; mod extension; +mod macos; mod search; mod selection_monitor; mod server; mod settings; mod setup; mod shortcut; + // We need this in main.rs, so it has to be pub pub mod util; @@ -206,6 +208,10 @@ pub fn run() { util::logging::app_log_dir, selection_monitor::set_selection_enabled, selection_monitor::get_selection_enabled, + macos::permissions::check_accessibility_trusted, + macos::permissions::open_accessibility_settings, + macos::permissions::open_screen_recording_settings, + macos::permissions::open_microphone_settings, ]) .setup(|app| { #[cfg(target_os = "macos")] diff --git a/src-tauri/src/macos/mod.rs b/src-tauri/src/macos/mod.rs new file mode 100644 index 000000000..a527b6368 --- /dev/null +++ b/src-tauri/src/macos/mod.rs @@ -0,0 +1 @@ +pub mod permissions; diff --git a/src-tauri/src/macos/permissions.rs b/src-tauri/src/macos/permissions.rs new file mode 100644 index 000000000..171b54089 --- /dev/null +++ b/src-tauri/src/macos/permissions.rs @@ -0,0 +1,58 @@ +#[tauri::command] +pub fn check_accessibility_trusted() -> bool { + cfg_if::cfg_if! { + if #[cfg(target_os = "macos")] { + let trusted = macos_accessibility_client::accessibility::application_is_trusted(); + log::info!(target: "coco_lib::permissions", "check_accessibility_trusted invoked: {}", trusted); + trusted + } else { + log::info!(target: "coco_lib::permissions", "check_accessibility_trusted invoked on non-macOS: false"); + false + } + } +} + +#[tauri::command] +pub fn open_accessibility_settings() { + cfg_if::cfg_if! { + if #[cfg(target_os = "macos")] { + use std::process::Command; + log::info!(target: "coco_lib::permissions", "open_accessibility_settings invoked"); + let _ = Command::new("open") + .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") + .status(); + } else { + // no-op on non-macOS + } + } +} + +#[tauri::command] +pub fn open_screen_recording_settings() { + cfg_if::cfg_if! { + if #[cfg(target_os = "macos")] { + use std::process::Command; + log::info!(target: "coco_lib::permissions", "open_screen_recording_settings invoked"); + let _ = Command::new("open") + .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenRecording") + .status(); + } else { + // no-op on non-macOS + } + } +} + +#[tauri::command] +pub fn open_microphone_settings() { + cfg_if::cfg_if! { + if #[cfg(target_os = "macos")] { + use std::process::Command; + log::info!(target: "coco_lib::permissions", "open_microphone_settings invoked"); + let _ = Command::new("open") + .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone") + .status(); + } else { + // no-op on non-macOS + } + } +} diff --git a/src-tauri/src/selection_monitor.rs b/src-tauri/src/selection_monitor.rs index 54d2f7c99..4cc831c4e 100644 --- a/src-tauri/src/selection_monitor.rs +++ b/src-tauri/src/selection_monitor.rs @@ -2,6 +2,7 @@ /// Coordinates use logical (Quartz) points with a top-left origin. /// Note: `y` is flipped on the backend to match the frontend’s usage. use tauri::Emitter; +use tauri::Manager; #[derive(serde::Serialize, Clone)] struct SelectionEventPayload { @@ -14,8 +15,8 @@ use once_cell::sync::Lazy; use std::sync::Mutex; use std::sync::atomic::{AtomicBool, Ordering}; -/// Global toggle: selection monitoring disabled for this release. -static SELECTION_ENABLED: AtomicBool = AtomicBool::new(false); +/// Global toggle: selection monitoring enabled for this release. +static SELECTION_ENABLED: AtomicBool = AtomicBool::new(true); /// Ensure we only start the monitor thread once. Allows delayed start after /// Accessibility permission is granted post-launch. @@ -24,6 +25,9 @@ static MONITOR_THREAD_STARTED: AtomicBool = AtomicBool::new(false); /// Guard to avoid spawning multiple permission watcher threads. #[cfg(target_os = "macos")] static PERMISSION_WATCHER_STARTED: AtomicBool = AtomicBool::new(false); +/// Guard to avoid spawning multiple selection store watcher threads. +#[cfg(target_os = "macos")] +static SELECTION_STORE_WATCHER_STARTED: AtomicBool = AtomicBool::new(false); /// Session flags for controlling macOS Accessibility prompts. #[cfg(target_os = "macos")] @@ -31,6 +35,8 @@ static SEEN_ACCESSIBILITY_TRUSTED_ONCE: AtomicBool = AtomicBool::new(false); #[cfg(target_os = "macos")] static LAST_ACCESSIBILITY_PROMPT: Lazy>> = Lazy::new(|| Mutex::new(None)); +#[cfg(target_os = "macos")] +static LAST_READ_WARN: Lazy>> = Lazy::new(|| Mutex::new(None)); #[derive(serde::Serialize, Clone)] struct SelectionEnabledPayload { @@ -95,7 +101,19 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) { use tauri::Emitter; // Sync initial enabled state to the frontend on startup. - set_selection_enabled_internal(&app_handle, is_selection_enabled()); + // Prefer disk-persisted Zustand store if present + #[cfg(target_os = "macos")] + ensure_selection_store_bootstrap(&app_handle); + if let Some(enabled) = read_selection_enabled_from_store(&app_handle) { + log::info!(target: "coco_lib::selection_monitor", "initial selection-enabled loaded from store: {}", enabled); + set_selection_enabled_internal(&app_handle, enabled); + } else { + log::warn!(target: "coco_lib::selection_monitor", "initial selection-enabled not found in store, falling back to in-memory flag"); + set_selection_enabled_internal(&app_handle, is_selection_enabled()); + } + // Start a light watcher to keep SELECTION_ENABLED in sync with disk + start_selection_store_watcher(app_handle.clone()); + log::info!(target: "coco_lib::selection_monitor", "selection store watcher started"); // Accessibility permission is required to read selected text in the foreground app. // If not granted, prompt the user once; if still not granted, skip starting the watcher. @@ -287,6 +305,7 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) { // If disabled: do not read AX / do not show popup; hide if currently visible. if !is_selection_enabled() { + log::debug!(target: "coco_lib::selection_monitor", "monitor loop: selection disabled"); if popup_visible { let _ = app_handle.emit("selection-detected", ""); popup_visible = false; @@ -444,6 +463,118 @@ fn ensure_accessibility_permission(app_handle: &tauri::AppHandle) -> bool { false } +/// Resolve the path to the zustand store file `selection-store.json`. +#[cfg(target_os = "macos")] +fn selection_store_path(app_handle: &tauri::AppHandle) -> std::path::PathBuf { + let mut dir = app_handle + .path() + .app_data_dir() + .expect("failed to find the local dir"); + dir.push("zustand"); + dir.push("selection-store.json"); + log::debug!(target: "coco_lib::selection_monitor", "selection_store_path resolved: {}", dir.display()); + dir +} + +#[cfg(target_os = "macos")] +fn ensure_selection_store_bootstrap(app_handle: &tauri::AppHandle) { + use std::fs; + use std::io::Write; + let mut dir = app_handle + .path() + .app_data_dir() + .expect("failed to find the local dir"); + dir.push("zustand"); + let _ = fs::create_dir_all(&dir); + let file = dir.join("selection-store.json"); + if !file.exists() { + let initial = serde_json::json!({ + "selectionEnabled": true, + "iconsOnly": false, + "toolbarConfig": [] + }); + if let Ok(mut f) = fs::File::create(&file) { + let _ = f.write_all( + serde_json::to_string(&initial) + .unwrap_or_else(|_| "{}".to_string()) + .as_bytes(), + ); + log::info!(target: "coco_lib::selection_monitor", "bootstrap selection-store.json created: {}", file.display()); + } + } +} + +/// Read `selectionEnabled` from the persisted zustand store. +/// Returns Some(bool) if read succeeds; None otherwise. +#[cfg(target_os = "macos")] +fn read_selection_enabled_from_store(app_handle: &tauri::AppHandle) -> Option { + use std::fs; + let path = selection_store_path(app_handle); + match fs::read_to_string(&path) { + Ok(content) => match serde_json::from_str::(&content) { + Ok(v) => { + let val = v.get("selectionEnabled").and_then(|b| b.as_bool()); + log::info!(target: "coco_lib::selection_monitor", "read_selection_enabled_from_store: {} -> {:?}", path.display(), val); + val + } + Err(e) => { + log::warn!(target: "coco_lib::selection_monitor", "read_selection_enabled_from_store: JSON parse failed for {}: {}", path.display(), e); + None + } + }, + Err(e) => { + use std::time::Duration; + use std::time::Instant; + let mut last = LAST_READ_WARN.lock().unwrap(); + let now = Instant::now(); + let allow = match *last { + Some(ts) => now.duration_since(ts) > Duration::from_secs(30), + None => true, + }; + if allow { + log::warn!(target: "coco_lib::selection_monitor", "read_selection_enabled_from_store: read failed for {}: {}", path.display(), e); + *last = Some(now); + } else { + log::debug!(target: "coco_lib::selection_monitor", "read_selection_enabled_from_store: read failed suppressed for {}", path.display()); + } + None + } + } +} + +/// Spawn a background watcher to sync `SELECTION_ENABLED` with disk every ~1s. +#[cfg(target_os = "macos")] +fn start_selection_store_watcher(app_handle: tauri::AppHandle) { + if SELECTION_STORE_WATCHER_STARTED.swap(true, Ordering::Relaxed) { + return; + } + std::thread::Builder::new() + .name("selection-store-watcher".into()) + .spawn(move || { + use std::time::{Duration, Instant}; + let mut last_check = Instant::now(); + let mut last_val: Option = None; + loop { + // Check approximately every second + if last_check.elapsed() >= Duration::from_secs(1) { + let current = read_selection_enabled_from_store(&app_handle); + if current.is_some() && current != last_val { + let enabled = current.unwrap(); + set_selection_enabled_internal(&app_handle, enabled); + log::info!(target: "coco_lib::selection_monitor", "selection-store-watcher: detected change, enabled={}", enabled); + last_val = current; + } + last_check = Instant::now(); + } + std::thread::sleep(Duration::from_millis(200)); + } + }) + .unwrap_or_else(|e| { + SELECTION_STORE_WATCHER_STARTED.store(false, Ordering::Relaxed); + panic!("selection-store-watcher: failed to spawn: {}", e); + }); +} + #[cfg(target_os = "macos")] fn collect_selection_permission_info() -> SelectionPermissionInfo { let exe_path = std::env::current_exe() diff --git a/src-tauri/src/setup/mod.rs b/src-tauri/src/setup/mod.rs index f8cab3c11..3d0422b37 100644 --- a/src-tauri/src/setup/mod.rs +++ b/src-tauri/src/setup/mod.rs @@ -110,6 +110,7 @@ pub(crate) async fn backend_setup(tauri_app_handle: AppHandle, app_lang: String) // Start system-wide selection monitor (macOS-only currently) #[cfg(target_os = "macos")] { + log::info!("backend_setup: starting system-wide selection monitor"); crate::selection_monitor::start_selection_monitor(tauri_app_handle.clone()); } diff --git a/src/components/Settings/Advanced/components/Permissions/index.tsx b/src/components/Settings/Advanced/components/Permissions/index.tsx new file mode 100644 index 000000000..f471143c0 --- /dev/null +++ b/src/components/Settings/Advanced/components/Permissions/index.tsx @@ -0,0 +1,207 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useMount } from "ahooks"; +import { ShieldCheck, Monitor, Mic, RotateCcw } from "lucide-react"; +import clsx from "clsx"; + +import platformAdapter from "@/utils/platformAdapter"; +import SettingsItem from "@/components/Settings/SettingsItem"; + +const Permissions = () => { + const { t } = useTranslation(); + const [accessibilityAuthorized, setAccessibilityAuthorized] = useState(null); + const [screenAuthorized, setScreenAuthorized] = useState(null); + const [microphoneAuthorized, setMicrophoneAuthorized] = useState(null); + + const refresh = async () => { + const [ax, sr, mic] = await Promise.all([ + platformAdapter.invokeBackend("check_accessibility_trusted"), + platformAdapter.checkScreenRecordingPermission(), + platformAdapter.checkMicrophonePermission(), + ]); + console.info("[permissions] refreshed", { accessibility: ax, screenRecording: sr, microphone: mic }); + setAccessibilityAuthorized(ax); + setScreenAuthorized(sr); + setMicrophoneAuthorized(mic); + }; + + useMount(refresh); + + const openAccessibilitySettings = async () => { + const window = await platformAdapter.getCurrentWebviewWindow(); + await window.setAlwaysOnTop(false); + console.info("[permissions] open accessibility settings"); + await platformAdapter.invokeBackend("open_accessibility_settings"); + await refresh(); + }; + + const requestScreenRecording = async () => { + const window = await platformAdapter.getCurrentWebviewWindow(); + await window.setAlwaysOnTop(false); + console.info("[permissions] request screen recording"); + await platformAdapter.requestScreenRecordingPermission(); + await platformAdapter.invokeBackend("open_screen_recording_settings"); + await refresh(); + }; + + const requestMicrophone = async () => { + const window = await platformAdapter.getCurrentWebviewWindow(); + await window.setAlwaysOnTop(false); + console.info("[permissions] request microphone"); + await platformAdapter.requestMicrophonePermission(); + await platformAdapter.invokeBackend("open_microphone_settings"); + await refresh(); + }; + + const [refreshing, setRefreshing] = useState(false); + const handleRefresh = async () => { + if (refreshing) return; + setRefreshing(true); + try { + await refresh(); + } finally { + setRefreshing(false); + } + }; + + useEffect(() => { + const unlisten1 = platformAdapter.listenEvent("selection-permission-required", async () => { + console.info("[permissions] selection-permission-required received"); + await refresh(); + }); + const unlisten2 = platformAdapter.listenEvent("selection-permission-info", async (evt: any) => { + console.info("[permissions] selection-permission-info", evt?.payload); + await refresh(); + }); + return () => { + unlisten1.then((fn) => fn()); + unlisten2.then((fn) => fn()); + }; + }, []); + + return ( + <> +

+ {t("settings.advanced.permissions.title")} +

+ +
+ +
+ {accessibilityAuthorized ? ( + + {t("settings.common.status.authorized")} + + ) : ( + + {t("settings.common.status.notAuthorized")} + + )} + + +
+
+ + +
+ {screenAuthorized ? ( + + {t("settings.common.status.authorized")} + + ) : ( + + {t("settings.common.status.notAuthorized")} + + )} + + +
+
+ + +
+ {microphoneAuthorized ? ( + + {t("settings.common.status.authorized")} + + ) : ( + + {t("settings.common.status.notAuthorized")} + + )} + + +
+
+
+ + ); +}; + +export default Permissions; diff --git a/src/components/Settings/Advanced/index.tsx b/src/components/Settings/Advanced/index.tsx index c33a02d48..5966f916f 100644 --- a/src/components/Settings/Advanced/index.tsx +++ b/src/components/Settings/Advanced/index.tsx @@ -11,6 +11,13 @@ import { } from "lucide-react"; import { useMount } from "ahooks"; import { isNil } from "lodash-es"; +import { + Select, + SelectTrigger, + SelectContent, + SelectItem, + SelectValue, +} from "@/components/ui/select"; import Shortcuts from "./components/Shortcuts"; import SettingsItem from "../SettingsItem"; @@ -23,13 +30,8 @@ import UpdateSettings from "./components/UpdateSettings"; import SettingsToggle from "../SettingsToggle"; import SelectionSettings from "./components/Selection"; import { isMac } from "@/utils/platform"; -import { - Select, - SelectTrigger, - SelectContent, - SelectItem, - SelectValue, -} from "@/components/ui/select"; +import Permissions from "./components/Permissions"; + const Advanced = () => { const { t } = useTranslation(); @@ -196,6 +198,8 @@ const Advanced = () => { })} + {isMac && } + {isMac && } diff --git a/src/components/Settings/SettingsItem.tsx b/src/components/Settings/SettingsItem.tsx index c3507580d..96d42cb7a 100644 --- a/src/components/Settings/SettingsItem.tsx +++ b/src/components/Settings/SettingsItem.tsx @@ -14,19 +14,19 @@ export default function SettingsItem({ children, }: SettingsItemProps) { return ( -
-
+
+
-
+

{title}

-

+

{description}

- {children} +
{children}
); } diff --git a/src/hooks/useSelectionEnabled.ts b/src/hooks/useSelectionEnabled.ts deleted file mode 100644 index 9a1192224..000000000 --- a/src/hooks/useSelectionEnabled.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useMount } from "ahooks"; - -import platformAdapter from "@/utils/platformAdapter"; -import { useSelectionStore } from "@/stores/selectionStore"; - -export default function useSelectionEnabled() { - useMount(async () => { - try { - const enabled = await platformAdapter.invokeBackend("get_selection_enabled"); - useSelectionStore.getState().setSelectionEnabled(!!enabled); - } catch (e) { - console.error("get_selection_enabled failed:", e); - } - - const unlisten = await platformAdapter.listenEvent( - "selection-enabled", - ({ payload }: any) => { - useSelectionStore.getState().setSelectionEnabled(!!payload?.enabled); - } - ); - - return () => { - unlisten && unlisten(); - }; - }); -} \ No newline at end of file diff --git a/src/hooks/useTray.ts b/src/hooks/useTray.ts index 1c731fdfa..f4477ddc7 100644 --- a/src/hooks/useTray.ts +++ b/src/hooks/useTray.ts @@ -18,7 +18,7 @@ export const useTray = () => { const showCocoShortcuts = useAppStore((state) => state.showCocoShortcuts); const selectionEnabled = useSelectionStore((state) => state.selectionEnabled); - // const setSelectionEnabled = useSelectionStore((state) => state.setSelectionEnabled); + const setSelectionEnabled = useSelectionStore((state) => state.setSelectionEnabled); useUpdateEffect(() => { if (showCocoShortcuts.length === 0) return; @@ -65,18 +65,18 @@ export const useTray = () => { itemPromises.push(PredefinedMenuItem.new({ item: "Separator" })); - // if (isMac) { - // itemPromises.push( - // MenuItem.new({ - // text: selectionEnabled - // ? t("tray.selectionDisable") - // : t("tray.selectionEnable"), - // action: async () => { - // setSelectionEnabled(!selectionEnabled); - // }, - // }) - // ); - // } + if (isMac) { + itemPromises.push( + MenuItem.new({ + text: selectionEnabled + ? t("tray.selectionDisable") + : t("tray.selectionEnable"), + action: async () => { + setSelectionEnabled(!selectionEnabled); + }, + }) + ); + } itemPromises.push( MenuItem.new({ diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 9b36c1ae5..0880f5701 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -187,6 +187,21 @@ "description": "Get early access to new features. May be unstable." } }, + "permissions": { + "title": "Permissions", + "accessibility": { + "title": "Accessibility", + "description": "Required to read selected text in the foreground app. Grant in System Settings → Privacy & Security → Accessibility." + }, + "screenRecording": { + "title": "Screen Recording", + "description": "Required for window/screen screenshots and sharing. Grant in System Settings → Privacy & Security → Screen Recording." + }, + "microphone": { + "title": "Microphone", + "description": "Required for voice input and recording. Grant in System Settings → Privacy & Security → Microphone." + } + }, "other": { "title": "Other Settings", "connectionTimeout": { @@ -229,6 +244,16 @@ "extensionsContent": "Extensions settings content", "advancedContent": "Advanced Settings content" }, + "common": { + "status": { + "authorized": "Authorized", + "notAuthorized": "Not Authorized" + }, + "actions": { + "openNow": "Open Settings", + "refresh": "Refresh" + } + }, "extensions": { "title": "Extensions", "list": { diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index ee9e2a78d..620fff8fe 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -187,6 +187,21 @@ "description": "抢先体验新功能,可能不稳定。" } }, + "permissions": { + "title": "权限设置", + "accessibility": { + "title": "辅助功能(Accessibility)", + "description": "用于读取前台应用的选中文本,需在「隐私与安全 → 辅助功能」中授权。" + }, + "screenRecording": { + "title": "屏幕录制", + "description": "用于窗口/屏幕截图与共享,需要在「隐私与安全 → 屏幕录制」中授权。" + }, + "microphone": { + "title": "麦克风", + "description": "用于语音输入与录音功能,需要在「隐私与安全 → 麦克风」中授权。" + } + }, "other": { "title": "其它设置", "connectionTimeout": { @@ -229,6 +244,16 @@ "extensionsContent": "扩展设置内容", "advancedContent": "高级设置内容" }, + "common": { + "status": { + "authorized": "已授权", + "notAuthorized": "未授权" + }, + "actions": { + "openNow": "去授权", + "refresh": "刷新状态" + } + }, "extensions": { "title": "扩展", "list": { diff --git a/src/routes/outlet.tsx b/src/routes/outlet.tsx index dad9eac18..cd99af0f0 100644 --- a/src/routes/outlet.tsx +++ b/src/routes/outlet.tsx @@ -18,7 +18,7 @@ import { useExtensionsStore } from "@/stores/extensionsStore"; import { useSelectionStore, startSelectionStorePersistence } from "@/stores/selectionStore"; import { useServers } from "@/hooks/useServers"; import { useDeepLinkManager } from "@/hooks/useDeepLinkManager"; -// import { useSelectionWindow } from "@/hooks/useSelectionWindow"; +import { useSelectionWindow } from "@/hooks/useSelectionWindow"; export default function LayoutOutlet() { const location = useLocation(); @@ -128,7 +128,7 @@ export default function LayoutOutlet() { }); // --- Selection window --- - // useSelectionWindow(); + useSelectionWindow(); return ( <> diff --git a/src/stores/selectionStore.ts b/src/stores/selectionStore.ts index ee528824e..ad1628d41 100644 --- a/src/stores/selectionStore.ts +++ b/src/stores/selectionStore.ts @@ -33,7 +33,7 @@ export const useSelectionStore = create((set) => ({ setIconsOnly: (iconsOnly) => set({ iconsOnly }), toolbarConfig: [], setToolbarConfig: (toolbarConfig) => set({ toolbarConfig }), - selectionEnabled: false, + selectionEnabled: true, setSelectionEnabled: (selectionEnabled) => set({ selectionEnabled }), })); diff --git a/src/types/platform.ts b/src/types/platform.ts index 9bc455864..cd7b47dee 100644 --- a/src/types/platform.ts +++ b/src/types/platform.ts @@ -58,6 +58,14 @@ export interface EventPayloads { "selection-detected": string; "selection-enabled": boolean; "change-selection-store": any; + "selection-permission-required": boolean; + "selection-permission-info": { + bundle_id: string; + exe_path: string; + in_applications: boolean; + is_dmg: boolean; + is_dev_guess: boolean; + }; } // Window operation interface