diff --git a/.gitignore b/.gitignore index fdd131b..1e2e6a8 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ dist-ssr # Local Copilot memory .memory/ .shared/ +.worktrees/ # Rust / Tauri target/ diff --git a/package-lock.json b/package-lock.json index 344db9a..2b813da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,11 @@ "version": "0.1.3", "dependencies": { "@tanstack/react-virtual": "^3.13.18", - "@tauri-apps/api": "^2", + "@tauri-apps/api": "2.9.0", "@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", - "@tauri-apps/plugin-updater": "^2.10.1", + "@tauri-apps/plugin-updater": "2.9.0", "react": "^19.1.0", "react-dom": "^19.1.0" }, @@ -2047,9 +2047,9 @@ } }, "node_modules/@tauri-apps/api": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", - "integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.0.tgz", + "integrity": "sha512-qD5tMjh7utwBk9/5PrTA/aGr3i5QaJ/Mlt7p8NilQ45WgbifUNPyKWsA63iQ8YfQq6R8ajMapU+/Q8nMcPRLNw==", "license": "Apache-2.0 OR MIT", "funding": { "type": "opencollective", @@ -2301,12 +2301,12 @@ } }, "node_modules/@tauri-apps/plugin-updater": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz", - "integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.9.0.tgz", + "integrity": "sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg==", "license": "MIT OR Apache-2.0", "dependencies": { - "@tauri-apps/api": "^2.10.1" + "@tauri-apps/api": "^2.6.0" } }, "node_modules/@testing-library/dom": { diff --git a/package.json b/package.json index 8815ae6..e22ddbd 100644 --- a/package.json +++ b/package.json @@ -27,11 +27,11 @@ }, "dependencies": { "@tanstack/react-virtual": "^3.13.18", - "@tauri-apps/api": "^2", + "@tauri-apps/api": "2.9.0", "@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", - "@tauri-apps/plugin-updater": "^2.10.1", + "@tauri-apps/plugin-updater": "2.9.0", "react": "^19.1.0", "react-dom": "^19.1.0" }, diff --git a/src-tauri/src/platform/macos.rs b/src-tauri/src/platform/macos.rs index a6fd23b..39a4731 100644 --- a/src-tauri/src/platform/macos.rs +++ b/src-tauri/src/platform/macos.rs @@ -3,17 +3,16 @@ use std::sync::{Mutex, OnceLock}; // --- Last frontmost app tracking --- -static LAST_FRONTMOST_APP_NAME: OnceLock>> = OnceLock::new(); +static LAST_FRONTMOST_APP: OnceLock>> = OnceLock::new(); pub fn set_last_frontmost_app_name(name: String) { - let cell = LAST_FRONTMOST_APP_NAME.get_or_init(|| Mutex::new(None)); + let cell = LAST_FRONTMOST_APP.get_or_init(|| Mutex::new(None)); let mut guard = cell.lock().unwrap_or_else(|e| e.into_inner()); - *guard = Some(name); + *guard = query_frontmost_app_bundle_id().map(|bundle_id| (name, bundle_id)); } -#[allow(dead_code)] -pub fn get_last_frontmost_app_name() -> Option { - let cell = LAST_FRONTMOST_APP_NAME.get_or_init(|| Mutex::new(None)); +fn get_last_frontmost_app() -> Option<(String, String)> { + let cell = LAST_FRONTMOST_APP.get_or_init(|| Mutex::new(None)); let guard = cell.lock().unwrap_or_else(|e| e.into_inner()); guard.clone() } @@ -54,6 +53,10 @@ pub fn query_frontmost_app_info() -> (Option, Option) { (query_frontmost_app_name(), query_frontmost_app_bundle_id()) } +fn is_paste_target_bundle_frontmost(target_bundle_id: &str, current: Option<&str>) -> bool { + current == Some(target_bundle_id) +} + // --- Cursor position --- pub fn get_cursor_position() -> Option<(f64, f64)> { @@ -116,13 +119,57 @@ pub fn perform_paste(app: &tauri::AppHandle) -> Result<(), String> { std::thread::sleep(Duration::from_millis(10)); } - std::thread::sleep(Duration::from_millis(200)); + if !hidden.load(Ordering::SeqCst) { + return Err("timed out hiding PowerPaste before paste".to_string()); + } + + let (target_name, target_bundle_id) = get_last_frontmost_app() + .ok_or_else(|| "no previous app available to receive paste".to_string())?; + let status = Command::new("open") + .args(["-b", &target_bundle_id]) + .status() + .map_err(|e| format!("failed to reactivate {target_name}: {e}"))?; + if !status.success() { + return Err(format!("failed to reactivate {target_name}")); + } + + for _ in 0..50 { + let current = query_frontmost_app_bundle_id(); + if is_paste_target_bundle_frontmost(&target_bundle_id, current.as_deref()) { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + + if !is_paste_target_bundle_frontmost( + &target_bundle_id, + query_frontmost_app_bundle_id().as_deref(), + ) { + return Err(format!( + "timed out waiting for {target_name} to receive paste" + )); + } eprintln!("[powerpaste] paste_text: sending Cmd+V..."); let output = Command::new("osascript") .args([ "-e", - "tell application \"System Events\" to keystroke \"v\" using command down", + "on run argv", + "-e", + "set targetBundleId to item 1 of argv", + "-e", + "tell application \"System Events\"", + "-e", + "set frontmostBundleId to bundle identifier of first application process whose frontmost is true", + "-e", + "if frontmostBundleId is not equal to targetBundleId then error \"paste target lost focus\"", + "-e", + "keystroke \"v\" using command down", + "-e", + "end tell", + "-e", + "end run", + &target_bundle_id, ]) .output() .map_err(|e| format!("failed to run osascript for paste: {e}"))?; @@ -547,3 +594,24 @@ pub fn set_clipboard_files(paths: &[String]) -> Result<(), String> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::is_paste_target_bundle_frontmost; + + #[test] + fn paste_target_bundle_must_be_frontmost_before_pasting() { + assert!(is_paste_target_bundle_frontmost( + "com.apple.TextEdit", + Some("com.apple.TextEdit") + )); + assert!(!is_paste_target_bundle_frontmost( + "com.apple.TextEdit", + Some("com.apple.finder") + )); + assert!(!is_paste_target_bundle_frontmost( + "com.apple.TextEdit", + None + )); + } +} diff --git a/src-tauri/src/settings_store.rs b/src-tauri/src/settings_store.rs index 10aaf40..d219303 100644 --- a/src-tauri/src/settings_store.rs +++ b/src-tauri/src/settings_store.rs @@ -1,7 +1,6 @@ use crate::models::{ConnectedProviderInfo, Settings, SyncProvider, UiMode}; use crate::paths::{app_data_dir, settings_path}; use base64::Engine as _; -use rand::RngCore; use std::fs; const KEYRING_SERVICE: &str = "PowerPaste"; @@ -200,7 +199,7 @@ pub fn ensure_sync_salt_b64(app: &tauri::AppHandle, mut se return Ok(settings); } let mut salt = [0u8; 16]; - rand::thread_rng().fill_bytes(&mut salt); + rand::fill(&mut salt); settings.sync_salt_b64 = Some(base64::engine::general_purpose::STANDARD.encode(salt)); save_settings(app, &settings)?; Ok(settings) @@ -209,6 +208,6 @@ pub fn ensure_sync_salt_b64(app: &tauri::AppHandle, mut se fn new_device_id() -> String { // Random, stable per-install identifier. let mut bytes = [0u8; 16]; - rand::thread_rng().fill_bytes(&mut bytes); + rand::fill(&mut bytes); base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) } diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs index 47f1f8f..e4a5670 100644 --- a/src-tauri/src/sync.rs +++ b/src-tauri/src/sync.rs @@ -5,7 +5,6 @@ use crate::settings_store; use base64::Engine as _; use chacha20poly1305::aead::{Aead, KeyInit}; use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; -use rand::RngCore; use serde::{Deserialize, Serialize}; use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; @@ -51,7 +50,7 @@ fn encrypt(passphrase: &str, salt: &[u8], plaintext: &[u8]) -> Result { + it("rechecks permissions when the permissions window regains focus", async () => { + window.history.pushState({}, "", "/?permissions=1"); + vi.resetModules(); + const { checkPermissions } = await import("./api"); + const { default: PermissionsApp } = await import("./App"); + const check = vi.mocked(checkPermissions); + check.mockClear(); + + render(); + await waitFor(() => expect(check).toHaveBeenCalledTimes(1)); + + fireEvent.focus(window); + + await waitFor(() => expect(check).toHaveBeenCalledTimes(2)); + window.history.pushState({}, "", "/"); + }); + it("renders and shows tray clipboard items", async () => { render(); diff --git a/src/App.tsx b/src/App.tsx index cad27c8..00d4267 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -722,7 +722,11 @@ function App() { if (!IS_PERMISSIONS_WINDOW) return; let cancelled = false; - void (async () => { + let checking = false; + + const refreshPermissions = async () => { + if (checking) return; + checking = true; setCheckingPermissions(true); try { const res = await checkPermissions(); @@ -740,12 +744,18 @@ function App() { executable_path: "", }); } finally { + checking = false; if (!cancelled) setCheckingPermissions(false); } - })(); + }; + + const onFocus = () => void refreshPermissions(); + window.addEventListener("focus", onFocus); + void refreshPermissions(); return () => { cancelled = true; + window.removeEventListener("focus", onFocus); }; }, []); diff --git a/src/components/PermissionsModal.tsx b/src/components/PermissionsModal.tsx index b341204..d44b675 100644 --- a/src/components/PermissionsModal.tsx +++ b/src/components/PermissionsModal.tsx @@ -110,7 +110,7 @@ export function PermissionsModal(props: PermissionsModalProps) { ) : null}
- After granting permissions, click Re-check below to verify. + PowerPaste re-checks automatically when you return from System Settings.
) : null}