diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e707156..6b06511 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,3 +100,50 @@ jobs: - name: Clippy (windows-gnu) run: cargo clippy --target x86_64-pc-windows-gnu --all-targets -- -D warnings + + desktop-dev-build: + name: Desktop Dev Build (${{ matrix.os }}) + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-2025-vs2026] + + steps: + - uses: actions/checkout@v6 + + - name: Install Linux desktop dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry and target dir + uses: Swatinem/rust-cache@v2 + with: + key: desktop-dev-${{ matrix.os }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: desktop/package-lock.json + + - name: Install desktop dependencies + working-directory: desktop + run: npm ci + + - name: Desktop build (no bundle) + working-directory: desktop + run: npm run build:no-bundle diff --git a/.github/workflows/desktop-dev.yml b/.github/workflows/desktop-dev.yml new file mode 100644 index 0000000..417ecd4 --- /dev/null +++ b/.github/workflows/desktop-dev.yml @@ -0,0 +1,55 @@ +name: Desktop Dev Build + +on: + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: "-D warnings" + +jobs: + desktop_dev: + name: Desktop no-bundle build (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-2025-vs2026] + + steps: + - uses: actions/checkout@v6 + + - name: Install Linux desktop dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry and target dir + uses: Swatinem/rust-cache@v2 + with: + key: desktop-dev-${{ matrix.os }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: desktop/package-lock.json + + - name: Install desktop dependencies + working-directory: desktop + run: npm ci + + - name: Desktop build (no bundle) + working-directory: desktop + run: npm run build:no-bundle diff --git a/README.md b/README.md index 6b4722c..2a738f2 100644 --- a/README.md +++ b/README.md @@ -29,16 +29,18 @@ Use it when you want: ## Status BeforePaste is under active development. The macOS desktop tray app is the -primary product path today. The CLI, pipeline redactor, terminal paste guard, -and VS Code terminal bridge are available for development and advanced use. +primary published product path today. Windows and Linux tray support is in +source-level development, with Ctrl+V smart paste wired for VS Code AI terminal +targets. The CLI, pipeline redactor, terminal paste guard, and VS Code terminal +bridge are available for development and advanced use. Platform scope today: | Platform | Desktop tray | Normal paste protection | Safe paste shortcut | CLI | |---|---:|---:|---:|---:| | macOS | Yes | `Cmd+V` in AI targets | `Cmd+Ctrl+V` | Yes | -| Windows | Source/dev only | Not yet | CLI only | Yes | -| Linux | Source/dev only | Not yet | CLI only | Yes | +| Windows | Source/dev only | `Ctrl+V` for VS Code AI terminals | `Ctrl+Alt+V` | Yes | +| Linux | Source/dev only | `Ctrl+V` for VS Code AI terminals | `Ctrl+Alt+V` | Yes | ## Install @@ -148,12 +150,14 @@ be granted while the selected paste mode is still off or needs attention. | Mode | Shortcut | Behavior | |---|---|---| -| Advanced | `Cmd+V` on macOS | Intercepts normal paste, checks whether the frontmost target is an AI target, redacts only for AI targets, then pastes. | -| Safe Paste Shortcut Only | `Cmd+Ctrl+V` on macOS | Leaves normal `Cmd+V` alone. The explicit shortcut always pastes a protected copy of the clipboard. | +| Advanced | `Cmd+V` on macOS, `Ctrl+V` on Windows/Linux desktop builds | Intercepts normal paste, checks whether the current target is an enabled AI target, redacts only for AI targets, then pastes. | +| Safe Paste Shortcut Only | `Cmd+Ctrl+V` on macOS, `Ctrl+Alt+V` on Windows/Linux | Leaves normal paste alone. The explicit shortcut always pastes a protected copy of the clipboard. | -Advanced mode is the recommended default for macOS because it protects the -normal paste habit in AI targets. Safe Paste Shortcut Only is useful when you -want an explicit action and no normal paste interception. +Advanced mode is the recommended default because it protects the normal paste +habit in AI targets. On Windows/Linux source desktop builds, Advanced mode is +currently limited to targets published by the VS Code extension or a manual +target override. Safe Paste Shortcut Only is useful when you want an explicit +action and no normal paste interception. ## Desktop Tray @@ -191,6 +195,11 @@ Supported target types: opencode. - VS Code integrated terminals through the BeforePaste VS Code extension. +Native app and browser-tab detection are macOS-first today. Windows/Linux +desktop development builds use VS Code extension state and manual target +overrides for smart `Ctrl+V`; if no AI target is known, BeforePaste passes +through to normal paste. + Browser matching is positive-only. If BeforePaste cannot read the active tab URL or cannot confirm an AI target, it performs a normal paste. This avoids rewriting clipboard content in places like cloud console secret fields, GitHub diff --git a/desktop/README.md b/desktop/README.md index 9d90750..f4e417a 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -48,9 +48,15 @@ Feature scope by platform: - macOS: tray, preferences, safe paste shortcut, target-aware Cmd+V protection, browser/app/terminal detection. -- Windows: CLI is published. Desktop source builds remain available for - development, but public Windows desktop artifacts are paused while packaging - is stabilized. -- Linux: CLI is published. Desktop source builds remain available for - development, but public Linux desktop artifacts are paused pending the - upstream Tauri GTK dependency update. +- Windows: tray and preferences build from source. Advanced mode registers a + global Ctrl+V shortcut; VS Code AI terminal targets are detected through the + BeforePaste VS Code extension, and unknown targets pass through as normal + paste. +- Linux: tray and preferences build from source. Advanced mode registers a + global Ctrl+V shortcut; VS Code AI terminal targets are detected through the + BeforePaste VS Code extension. Pass-through paste requires one of `xdotool`, + `wtype`, or `ydotool` depending on the session. + +Public Windows and Linux desktop artifacts remain paused until packaging, +code-signing, and Linux GTK dependency policy are finalized. The source-level +tray implementation is available for development builds. diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index a72d61f..93f03f1 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -106,6 +106,8 @@ struct RuntimeStatus { permissions: PermissionStatus, beforepaste_enabled: bool, protect_normal_paste: bool, + normal_paste_hotkey: String, + normal_paste_hotkey_registered: bool, normal_paste_event_tap_started: bool, normal_paste_event_tap_installed: bool, force_paste_hotkey: String, @@ -117,9 +119,11 @@ struct RuntimeStatus { struct AppState { engine: Arc>, target: Arc>>, + normal_paste_hotkey: Mutex, force_paste_hotkey: Mutex, beforepaste_enabled: AtomicBool, protect_normal_paste: AtomicBool, + normal_paste_running: AtomicBool, normal_paste_event_tap_started: AtomicBool, normal_paste_event_tap_installed: AtomicBool, tray_menu: Mutex>, @@ -159,7 +163,10 @@ fn install_vscode_bridge() -> Result { return Err("BeforePaste VS Code extension package was not found.".to_string()); }; let Some(code) = find_code_cli() else { - return Err("VS Code 'code' command was not found. Install it from VS Code Command Palette first.".to_string()); + return Err( + "VS Code 'code' command was not found. Install it from VS Code Command Palette first." + .to_string(), + ); }; let output = Command::new(code) .arg("--install-extension") @@ -183,9 +190,15 @@ fn open_privacy_settings(kind: String) -> Result<(), String> { #[cfg(target_os = "macos")] { let url = match kind.as_str() { - "accessibility" => "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", - "input_monitoring" => "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent", - "automation" => "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation", + "accessibility" => { + "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" + } + "input_monitoring" => { + "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent" + } + "automation" => { + "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation" + } _ => "x-apple.systempreferences:com.apple.preference.security?Privacy", }; Command::new("/usr/bin/open") @@ -217,6 +230,16 @@ fn runtime_status(app: &tauri::AppHandle, state: &Arc) -> RuntimeStatu && app .global_shortcut() .is_registered(force_paste_hotkey.as_str()); + let normal_paste_hotkey = state + .normal_paste_hotkey + .lock() + .ok() + .map(|value| value.clone()) + .unwrap_or_else(normal_paste_hotkey); + let normal_paste_hotkey_registered = !normal_paste_hotkey.is_empty() + && app + .global_shortcut() + .is_registered(normal_paste_hotkey.as_str()); let current_target = state .target .lock() @@ -229,6 +252,8 @@ fn runtime_status(app: &tauri::AppHandle, state: &Arc) -> RuntimeStatu permissions: permission_status(), beforepaste_enabled: state.beforepaste_enabled.load(Ordering::SeqCst), protect_normal_paste: state.protect_normal_paste.load(Ordering::SeqCst), + normal_paste_hotkey, + normal_paste_hotkey_registered, normal_paste_event_tap_started: state.normal_paste_event_tap_started.load(Ordering::SeqCst), normal_paste_event_tap_installed: state .normal_paste_event_tap_installed @@ -255,6 +280,17 @@ fn platform_name() -> &'static str { } } +fn normal_paste_hotkey() -> String { + #[cfg(target_os = "macos")] + { + String::new() + } + #[cfg(not(target_os = "macos"))] + { + "Control+KeyV".to_string() + } +} + #[tauri::command] fn save_config( app: tauri::AppHandle, @@ -284,7 +320,9 @@ fn save_config( "save_config protect_normal_paste={protect_normal_paste}" )); if protect_normal_paste { - ensure_normal_paste_event_tap(state.inner().clone()).map_err(|e| e.to_string())?; + ensure_normal_paste_protection(&app, state.inner().clone()).map_err(|e| e.to_string())?; + } else { + disable_normal_paste_protection(&app, state.inner()); } let _ = app.emit("beforepaste-config-updated", ()); Ok(()) @@ -296,7 +334,7 @@ fn set_normal_paste_mode( protect: bool, ) -> Result<(), String> { let mut config = Config::load(); - config.protect_normal_paste = protect && cfg!(target_os = "macos"); + config.protect_normal_paste = protect; let protect_normal_paste = config.protect_normal_paste; config.save().map_err(|e| e.to_string())?; state @@ -308,7 +346,9 @@ fn set_normal_paste_mode( .protect_normal_paste .store(protect_normal_paste, Ordering::SeqCst); if protect_normal_paste { - ensure_normal_paste_event_tap(Arc::clone(&state)).map_err(|e| e.to_string())?; + ensure_normal_paste_protection(app, Arc::clone(&state)).map_err(|e| e.to_string())?; + } else { + disable_normal_paste_protection(app, &state); } schedule_tray_status_update(app.clone(), Arc::clone(&state), Duration::from_millis(150)); let _ = app.emit("beforepaste-config-updated", ()); @@ -487,7 +527,13 @@ fn tray_text(lang: Lang, key: &str) -> &'static str { fn build_tray(app: &tauri::App, state: &Arc) -> tauri::Result<()> { let lang = Config::load().lang; - let status = MenuItem::with_id(app, "status", tray_text(lang, "status_checking"), false, None::<&str>)?; + let status = MenuItem::with_id( + app, + "status", + tray_text(lang, "status_checking"), + false, + None::<&str>, + )?; let target = MenuItem::with_id( app, "target", @@ -509,7 +555,13 @@ fn build_tray(app: &tauri::App, state: &Arc) -> tauri::Result<()> { true, None::<&str>, )?; - let doctor = MenuItem::with_id(app, "open_doctor", tray_text(lang, "doctor"), true, None::<&str>)?; + let doctor = MenuItem::with_id( + app, + "open_doctor", + tray_text(lang, "doctor"), + true, + None::<&str>, + )?; let advanced = CheckMenuItem::with_id( app, "mode_advanced", @@ -529,8 +581,13 @@ fn build_tray(app: &tauri::App, state: &Arc) -> tauri::Result<()> { let quit = MenuItem::with_id(app, "quit", tray_text(lang, "quit"), true, None::<&str>)?; let separator = PredefinedMenuItem::separator(app)?; let separator2 = PredefinedMenuItem::separator(app)?; - let mode_menu = - Submenu::with_id_and_items(app, "mode", tray_text(lang, "mode"), true, &[&advanced, &safe_only])?; + let mode_menu = Submenu::with_id_and_items( + app, + "mode", + tray_text(lang, "mode"), + true, + &[&advanced, &safe_only], + )?; let menu = Menu::with_items( app, &[ @@ -646,7 +703,7 @@ fn tray_labels(status: &RuntimeStatus, state: &Arc) -> TrayLabels { format!("Last target: {target}") }, stats, - advanced_checked: status.platform == "macos" && status.protect_normal_paste, + advanced_checked: status.protect_normal_paste, } } @@ -693,14 +750,14 @@ fn overall_status_label(status: &RuntimeStatus, cmdv: &str, safe: &str, lang: La }; } let ready = if lang == Lang::ZH { "就绪" } else { "Ready" }; - if status.platform == "macos" && status.protect_normal_paste && cmdv != ready { + if status.protect_normal_paste && cmdv != ready { return if lang == Lang::ZH { format!("BeforePaste:{cmdv}") } else { format!("BeforePaste: {cmdv}") }; } - if status.platform == "macos" && status.protect_normal_paste { + if status.protect_normal_paste { if status.current_target.is_some() { if lang == Lang::ZH { "BeforePaste:保护中".to_string() @@ -725,33 +782,61 @@ fn overall_status_label(status: &RuntimeStatus, cmdv: &str, safe: &str, lang: La fn cmdv_status_label(status: &RuntimeStatus, lang: Lang) -> String { if !status.beforepaste_enabled { - return if lang == Lang::ZH { "未启用" } else { "Disabled" }.to_string(); - } - if status.platform != "macos" { - return if lang == Lang::ZH { "不支持" } else { "Not supported" }.to_string(); + return if lang == Lang::ZH { + "未启用" + } else { + "Disabled" + } + .to_string(); } if !status.protect_normal_paste { return if lang == Lang::ZH { "关闭" } else { "Off" }.to_string(); } - let mut missing = Vec::new(); - if !status.permissions.accessibility { - missing.push(if lang == Lang::ZH { "辅助功能" } else { "Accessibility" }); - } - if !status.permissions.input_monitoring { - missing.push(if lang == Lang::ZH { "输入监控" } else { "Input Monitoring" }); - } - if !missing.is_empty() { + if status.platform == "macos" { + let mut missing = Vec::new(); + if !status.permissions.accessibility { + missing.push(if lang == Lang::ZH { + "辅助功能" + } else { + "Accessibility" + }); + } + if !status.permissions.input_monitoring { + missing.push(if lang == Lang::ZH { + "输入监控" + } else { + "Input Monitoring" + }); + } + if !missing.is_empty() { + return if lang == Lang::ZH { + format!("缺少权限:{}", missing.join(" + ")) + } else { + format!("Missing {}", missing.join(" + ")) + }; + } + } else if !status.normal_paste_hotkey_registered { return if lang == Lang::ZH { - format!("缺少权限:{}", missing.join(" + ")) + "Ctrl+V 未注册".to_string() } else { - format!("Missing {}", missing.join(" + ")) + "Ctrl+V not registered".to_string() }; } if !status.normal_paste_event_tap_installed { return if status.normal_paste_event_tap_started { - if lang == Lang::ZH { "正在恢复" } else { "Installing" }.to_string() + if lang == Lang::ZH { + "正在恢复" + } else { + "Installing" + } + .to_string() } else { - if lang == Lang::ZH { "需要重启" } else { "Restart Required" }.to_string() + if lang == Lang::ZH { + "需要重启" + } else { + "Restart Required" + } + .to_string() }; } if lang == Lang::ZH { "就绪" } else { "Ready" }.to_string() @@ -759,7 +844,12 @@ fn cmdv_status_label(status: &RuntimeStatus, lang: Lang) -> String { fn safe_paste_status_label(status: &RuntimeStatus, lang: Lang) -> String { if !status.beforepaste_enabled { - return if lang == Lang::ZH { "未启用" } else { "Disabled" }.to_string(); + return if lang == Lang::ZH { + "未启用" + } else { + "Disabled" + } + .to_string(); } let hotkey = display_hotkey(&status.force_paste_hotkey); if status.force_paste_hotkey_registered { @@ -779,7 +869,12 @@ fn safe_paste_status_label(status: &RuntimeStatus, lang: Lang) -> String { fn format_target_reason(reason: Option<&str>, lang: Lang) -> String { let Some(reason) = reason else { - return if lang == Lang::ZH { "当前不是 AI 目标" } else { "Not AI target" }.to_string(); + return if lang == Lang::ZH { + "当前不是 AI 目标" + } else { + "Not AI target" + } + .to_string(); }; let mut parts = reason.split(':'); let source = parts.next().unwrap_or_default(); @@ -800,9 +895,12 @@ fn format_target_reason(reason: Option<&str>, lang: Lang) -> String { format!("{} web", target_label(kind)) } } - "shortcut" => { - if lang == Lang::ZH { "安全粘贴" } else { "Safe paste" }.to_string() + "shortcut" => if lang == Lang::ZH { + "安全粘贴" + } else { + "Safe paste" } + .to_string(), _ => title_case(reason), } } @@ -930,9 +1028,11 @@ fn vscode_bridge_status() -> VscodeBridgeStatus { let message = if installed { "BeforePaste VS Code extension is installed.".to_string() } else if vsix_path.is_some() { - "Install the BeforePaste VS Code extension to detect AI CLIs in integrated terminals.".to_string() + "Install the BeforePaste VS Code extension to detect AI CLIs in integrated terminals." + .to_string() } else { - "BeforePaste VS Code extension is not installed, and the local .vsix package was not found.".to_string() + "BeforePaste VS Code extension is not installed, and the local .vsix package was not found." + .to_string() }; VscodeBridgeStatus { installed, @@ -978,10 +1078,7 @@ fn local_vscode_vsix() -> Option { .map(|root| root.join("vscode-extension/beforepaste-0.1.0.vsix")) }), ]; - candidates - .into_iter() - .flatten() - .find(|path| path.exists()) + candidates.into_iter().flatten().find(|path| path.exists()) } fn find_code_cli() -> Option { @@ -1206,11 +1303,44 @@ fn start_paste_event_tap(state: Arc) -> anyhow::Result<()> { Ok(()) } -#[cfg(not(target_os = "macos"))] -fn start_paste_event_tap(_state: Arc) -> anyhow::Result<()> { - Ok(()) +fn ensure_normal_paste_protection( + app: &tauri::AppHandle, + state: Arc, +) -> anyhow::Result<()> { + #[cfg(target_os = "macos")] + { + let _ = app; + ensure_normal_paste_event_tap(state) + } + #[cfg(not(target_os = "macos"))] + { + update_normal_paste_shortcut(app, &state, true)?; + state + .normal_paste_event_tap_started + .store(true, Ordering::SeqCst); + state + .normal_paste_event_tap_installed + .store(true, Ordering::SeqCst); + Ok(()) + } } +fn disable_normal_paste_protection(app: &tauri::AppHandle, state: &Arc) { + #[cfg(not(target_os = "macos"))] + { + let _ = update_normal_paste_shortcut(app, state, false); + state + .normal_paste_event_tap_installed + .store(false, Ordering::SeqCst); + } + #[cfg(target_os = "macos")] + { + let _ = app; + let _ = state; + } +} + +#[cfg(target_os = "macos")] fn ensure_normal_paste_event_tap(state: Arc) -> anyhow::Result<()> { if state .normal_paste_event_tap_started @@ -1241,6 +1371,108 @@ fn handle_force_redact_paste(state: Arc) { }); } +fn handle_normal_paste(app: tauri::AppHandle, state: Arc) { + if state + .normal_paste_running + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + desktop_debug("normal_paste drop: already running"); + return; + } + + thread::spawn(move || { + let result = with_normal_paste_shortcut_unregistered(&app, &state, || { + if !state.protect_normal_paste.load(Ordering::SeqCst) + || !state.beforepaste_enabled.load(Ordering::SeqCst) + { + desktop_debug("normal_paste passthrough: disabled"); + return protected_paste::emit_plain_system_paste(); + } + + let reason = state + .target + .lock() + .ok() + .and_then(|target| target.clone()) + .or_else(protected_paste::current_target_reason); + let Some(reason) = reason else { + desktop_debug("normal_paste passthrough: no target"); + return protected_paste::emit_plain_system_paste(); + }; + + desktop_debug(&format!("normal_paste protect: target={reason}")); + state + .engine + .lock() + .map_err(|_| anyhow::anyhow!("engine cache lock poisoned")) + .and_then(|mut engine| engine.paste_with_cached_target(Some(reason))) + }); + if let Err(error) = result { + eprintln!("BeforePaste normal paste failed: {error}"); + } + thread::sleep(Duration::from_millis(150)); + state.normal_paste_running.store(false, Ordering::SeqCst); + }); +} + +fn with_normal_paste_shortcut_unregistered( + app: &tauri::AppHandle, + state: &Arc, + f: F, +) -> anyhow::Result<()> +where + F: FnOnce() -> anyhow::Result<()>, +{ + let hotkey = state + .normal_paste_hotkey + .lock() + .map(|hotkey| hotkey.clone()) + .unwrap_or_default(); + let was_registered = !hotkey.is_empty() && app.global_shortcut().is_registered(hotkey.as_str()); + if was_registered { + app.global_shortcut().unregister(hotkey.as_str())?; + } + let result = f(); + thread::sleep(Duration::from_millis(80)); + if was_registered && state.protect_normal_paste.load(Ordering::SeqCst) { + if let Err(error) = app.global_shortcut().register(hotkey.as_str()) { + desktop_debug(&format!("normal_paste re-register failed: {error}")); + } + } + result +} + +#[cfg(not(target_os = "macos"))] +fn update_normal_paste_shortcut( + app: &tauri::AppHandle, + state: &Arc, + enabled: bool, +) -> anyhow::Result<()> { + let hotkey = normal_paste_hotkey(); + let mut current = state + .normal_paste_hotkey + .lock() + .map_err(|_| anyhow::anyhow!("normal paste shortcut lock poisoned"))?; + if !current.is_empty() && current.as_str() != hotkey { + let _ = app.global_shortcut().unregister(current.as_str()); + } + if enabled { + if !app.global_shortcut().is_registered(hotkey.as_str()) { + app.global_shortcut().register(hotkey.as_str())?; + } + *current = hotkey.clone(); + desktop_debug(&format!("normal_paste_hotkey registered={hotkey}")); + } else { + if !current.is_empty() && app.global_shortcut().is_registered(current.as_str()) { + let _ = app.global_shortcut().unregister(current.as_str()); + } + *current = hotkey; + desktop_debug("normal_paste_hotkey unregistered"); + } + Ok(()) +} + fn update_force_paste_shortcut( app: &tauri::AppHandle, state: &Arc, @@ -1356,12 +1588,23 @@ fn main() { tauri::Builder::default() .plugin( tauri_plugin_global_shortcut::Builder::new() - .with_handler(|app, _shortcut, event| { + .with_handler(|app, shortcut, event| { if event.state != ShortcutState::Pressed { return; } let state = app.state::>().inner().clone(); - handle_force_redact_paste(state); + let pressed = (*shortcut).into_string().to_ascii_lowercase(); + let normal = state + .normal_paste_hotkey + .lock() + .ok() + .map(|value| value.to_ascii_lowercase()) + .unwrap_or_default(); + if !normal.is_empty() && pressed == normal { + handle_normal_paste(app.clone(), state); + } else { + handle_force_redact_paste(state); + } }) .build(), ) @@ -1400,9 +1643,11 @@ fn main() { config.clone(), ))), target: Arc::new(Mutex::new(protected_paste::current_target_reason())), + normal_paste_hotkey: Mutex::new(String::new()), force_paste_hotkey: Mutex::new(String::new()), beforepaste_enabled: AtomicBool::new(config.beforepaste_enabled), protect_normal_paste: AtomicBool::new(config.protect_normal_paste), + normal_paste_running: AtomicBool::new(false), normal_paste_event_tap_started: AtomicBool::new(false), normal_paste_event_tap_installed: AtomicBool::new(false), tray_menu: Mutex::new(None), @@ -1422,7 +1667,7 @@ fn main() { eprintln!("BeforePaste failed to sync launch at login: {error}"); } if config.protect_normal_paste { - ensure_normal_paste_event_tap(Arc::clone(&state)).map_err(|error| { + ensure_normal_paste_protection(&app.handle().clone(), Arc::clone(&state)).map_err(|error| { eprintln!("BeforePaste failed to start paste event tap: {error}"); error })?; diff --git a/desktop/ui/main.js b/desktop/ui/main.js index a33b335..4d7046f 100644 --- a/desktop/ui/main.js +++ b/desktop/ui/main.js @@ -287,13 +287,13 @@ function renderConfig(config, platform = currentPlatform) { applyStaticCopy(); fields.beforepasteEnabled.checked = Boolean(config.beforepaste_enabled); applyPlatformCopy(currentPlatform); - const advancedMode = currentPlatform === "macos" && Boolean(config.protect_normal_paste); + const advancedMode = Boolean(config.protect_normal_paste); fields.modeAdvanced.checked = advancedMode; - fields.modeAdvanced.disabled = currentPlatform !== "macos"; + fields.modeAdvanced.disabled = false; fields.modeSafeOnly.checked = !advancedMode; fields.protectNormalPaste.checked = advancedMode; - fields.protectNormalPaste.disabled = currentPlatform !== "macos"; + fields.protectNormalPaste.disabled = false; fields.forcePasteHotkey.value = config.force_paste_hotkey; fields.launchAtLogin.checked = Boolean(config.launch_at_login); fields.deepScan.checked = config.enable_deep_scan; @@ -389,11 +389,11 @@ function applyPlatformCopy(platform) { : `Needed for automatic ${label} protection. Restart BeforePaste after changing this permission.`; } else { fields.normalPasteCopy.textContent = currentLang === "ZH" - ? "当前平台暂不支持自动保护普通粘贴,请使用安全粘贴快捷键。" - : "Target-aware normal paste protection is not available on this platform yet. Use the safe paste shortcut."; + ? `在 VS Code AI 终端或手动指定目标中按 ${label} 时,先脱敏再粘贴;无法识别目标时正常粘贴。` + : `Redact before ${label} in VS Code AI terminals or manual targets; pass through when no AI target is known.`; fields.inputMonitoringCopy.textContent = currentLang === "ZH" - ? "此平台的安全粘贴快捷键不需要输入监控。" - : "Not required for the safe paste shortcut on this platform."; + ? "此平台通过全局快捷键接管 Ctrl+V,不使用 macOS 输入监控。" + : "This platform uses a global Ctrl+V shortcut instead of macOS Input Monitoring."; } } @@ -460,15 +460,18 @@ function renderDoctor(status) { if (!status.beforepaste_enabled) { cmdVLabel = tr("disabled"); cmdVState = "muted"; - } else if (currentPlatform !== "macos") { - cmdVLabel = tr("notSupported"); - cmdVState = "muted"; } else if (!status.protect_normal_paste) { cmdVLabel = tr("off"); cmdVState = "muted"; - } else if (!status.permissions.accessibility || !status.permissions.input_monitoring) { + } else if ( + currentPlatform === "macos" + && (!status.permissions.accessibility || !status.permissions.input_monitoring) + ) { cmdVLabel = tr("grantPermission"); cmdVState = "warn"; + } else if (currentPlatform !== "macos" && !status.normal_paste_hotkey_registered) { + cmdVLabel = `${normalPasteLabel(currentPlatform)} ${tr("notRegistered")}`; + cmdVState = "warn"; } else if (!status.normal_paste_event_tap_installed) { cmdVLabel = status.normal_paste_event_tap_started ? tr("retrying") : tr("needsRestart"); cmdVState = "warn"; @@ -510,11 +513,6 @@ function renderDoctor(status) { : `Grant ${missing.join(" and ")} in macOS Privacy settings, then reopen BeforePaste.` : tr("restartCopy"); summaryState = "warn"; - } else if (currentPlatform !== "macos") { - summaryTitle = tr("safeShortcutReady"); - summaryCopy = currentLang === "ZH" - ? `使用 ${formatHotkeyForDisplay(status.force_paste_hotkey)} 粘贴脱敏后的内容。普通 ${normalPasteLabel(currentPlatform)} 暂不支持自动保护。` - : `Use ${formatHotkeyForDisplay(status.force_paste_hotkey)} for redacted paste. Normal ${normalPasteLabel(currentPlatform)} protection is not available yet.`; } else if (!status.protect_normal_paste) { summaryTitle = tr("safeShortcutReady"); summaryCopy = currentLang === "ZH" diff --git a/src/config.rs b/src/config.rs index 37482fe..ecfec42 100644 --- a/src/config.rs +++ b/src/config.rs @@ -38,10 +38,9 @@ pub struct Config { #[serde(default = "default_true")] pub beforepaste_enabled: bool, pub silent: bool, - /// Protect the normal paste shortcut (`Cmd+V` on macOS) by intercepting it - /// with a keyboard event tap and only rewriting when an AI target is active. - /// Implemented on macOS; Windows/Linux keep the explicit safe-paste shortcut - /// until their platform-specific keyboard hooks are available. + /// Protect the normal paste shortcut by intercepting it and only rewriting + /// when an AI target is active. macOS uses a keyboard event tap for + /// `Cmd+V`; Windows/Linux use the desktop tray's global `Ctrl+V` shortcut. #[serde(default = "default_protect_normal_paste")] pub protect_normal_paste: bool, #[serde(default = "default_force_paste_hotkey")] @@ -195,7 +194,7 @@ fn default_true() -> bool { } fn default_protect_normal_paste() -> bool { - cfg!(target_os = "macos") + true } fn default_redact_style() -> RedactStyle { diff --git a/src/protected_paste.rs b/src/protected_paste.rs index 5a0d82f..5647d26 100644 --- a/src/protected_paste.rs +++ b/src/protected_paste.rs @@ -1,6 +1,5 @@ use std::fs::{self, OpenOptions}; use std::io::Write; -#[cfg(target_os = "macos")] use std::path::PathBuf; #[cfg(target_os = "macos")] use std::process::{Command, Stdio}; @@ -17,7 +16,6 @@ use crate::detector::Detector; use crate::notify; use crate::redact_cli; use crate::stats; -#[cfg(target_os = "macos")] use crate::targets::{self, TargetSurface}; const TARGET_CACHE_FILE: &str = "target-state.json"; @@ -43,14 +41,15 @@ struct TargetSnapshot { expires_at: u64, } -#[cfg(target_os = "macos")] #[derive(Debug, Clone, Deserialize)] struct TerminalTarget { kind: String, + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] cwd: String, #[serde(default)] terminal_app: Option, #[serde(default)] + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] terminal_id: Option, expires_at: u64, } @@ -107,6 +106,10 @@ pub fn run() -> anyhow::Result<()> { paste_with(reason, &engine.config, &engine.detector, RestoreMode::Sync) } +pub fn emit_plain_system_paste() -> anyhow::Result<()> { + emit_system_paste() +} + fn paste_with( reason: Option, config: &Config, @@ -282,6 +285,12 @@ fn detect_current_target() -> Option { #[cfg(not(target_os = "macos"))] fn detect_current_target() -> Option { + let config = Config::load(); + if let Some(target) = active_terminal_by_app("vscode") { + if targets::enabled_on(&config, TargetSurface::Vscode, &target.kind) { + return Some(format!("cli:{}", target.kind)); + } + } None } @@ -547,7 +556,6 @@ fn active_terminal_by_id(terminal_app: &str, terminal_id: &str) -> Option Option { active_terminal(|target| target.terminal_app.as_deref() == Some(terminal_app)) } @@ -562,7 +570,6 @@ fn read_terminal_target_by_tty(tty: &str) -> Option { read_terminal_target_path(state_path(tty)) } -#[cfg(target_os = "macos")] fn active_terminal(mut predicate: impl FnMut(&TerminalTarget) -> bool) -> Option { let entries = fs::read_dir(states_dir()).ok()?; let mut matches = Vec::new(); @@ -584,7 +591,6 @@ fn active_terminal(mut predicate: impl FnMut(&TerminalTarget) -> bool) -> Option } } -#[cfg(target_os = "macos")] fn read_terminal_target_path(path: PathBuf) -> Option { let target: TerminalTarget = serde_json::from_str(&fs::read_to_string(path).ok()?).ok()?; if target.expires_at <= now_secs() { @@ -599,7 +605,6 @@ fn state_path(tty: &str) -> PathBuf { states_dir().join(format!("{}.json", state_key(tty))) } -#[cfg(target_os = "macos")] fn states_dir() -> PathBuf { config::base_dir().join("terminal-targets") } @@ -739,7 +744,31 @@ fn emit_system_paste() -> anyhow::Result<()> { #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] fn emit_system_paste() -> anyhow::Result<()> { - anyhow::bail!("protected-paste system paste is not implemented on this OS yet") + let mut attempts = Vec::new(); + if std::env::var_os("WAYLAND_DISPLAY").is_some() { + attempts.push(("wtype", vec!["-M", "ctrl", "v", "-m", "ctrl"])); + attempts.push(("ydotool", vec!["key", "29:1", "47:1", "47:0", "29:0"])); + } + if std::env::var_os("DISPLAY").is_some() { + attempts.push(("xdotool", vec!["key", "ctrl+v"])); + } + if attempts.is_empty() { + attempts.push(("xdotool", vec!["key", "ctrl+v"])); + attempts.push(("wtype", vec!["-M", "ctrl", "v", "-m", "ctrl"])); + } + + let mut errors = Vec::new(); + for (program, args) in attempts { + match std::process::Command::new(program).args(args).status() { + Ok(status) if status.success() => return Ok(()), + Ok(status) => errors.push(format!("{program} exited with {status}")), + Err(error) => errors.push(format!("{program}: {error}")), + } + } + anyhow::bail!( + "protected-paste system paste needs xdotool, wtype, or ydotool on Linux ({})", + errors.join("; ") + ) } fn now_secs() -> u64 { @@ -758,8 +787,61 @@ fn now_millis() -> u64 { #[cfg(test)] mod tests { - #[cfg(target_os = "macos")] use super::*; + use serial_test::serial; + use std::path::Path; + + struct ConfigHomeGuard { + saved: Option, + } + + impl ConfigHomeGuard { + fn set(path: &Path) -> Self { + let saved = std::env::var_os("BEFOREPASTE_CONFIG_HOME"); + std::env::set_var("BEFOREPASTE_CONFIG_HOME", path); + Self { saved } + } + } + + impl Drop for ConfigHomeGuard { + fn drop(&mut self) { + if let Some(saved) = self.saved.take() { + std::env::set_var("BEFOREPASTE_CONFIG_HOME", saved); + } else { + std::env::remove_var("BEFOREPASTE_CONFIG_HOME"); + } + } + } + + #[test] + #[serial] + fn active_terminal_by_app_reads_vscode_state() { + let dir = tempfile::tempdir().unwrap(); + let _guard = ConfigHomeGuard::set(dir.path()); + let state_dir = states_dir(); + std::fs::create_dir_all(&state_dir).unwrap(); + let now = now_secs(); + std::fs::write( + state_dir.join("vscode-test.json"), + format!( + r#"{{ + "tty": "vscode:test:1", + "cmd": "codex", + "kind": "codex", + "cwd": "/tmp/beforepaste", + "terminal_app": "vscode", + "terminal_id": "1", + "updated_at": {now}, + "expires_at": {} +}}"#, + now + 60 + ), + ) + .unwrap(); + + let target = active_terminal_by_app("vscode").unwrap(); + assert_eq!(target.kind, "codex"); + } #[cfg(target_os = "macos")] #[test]