Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ dist-ssr
# Local Copilot memory
.memory/
.shared/
.worktrees/

# Rust / Tauri
target/
Expand Down
18 changes: 9 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment on lines +30 to +34
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
Expand Down
84 changes: 76 additions & 8 deletions src-tauri/src/platform/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,16 @@ use std::sync::{Mutex, OnceLock};

// --- Last frontmost app tracking ---

static LAST_FRONTMOST_APP_NAME: OnceLock<Mutex<Option<String>>> = OnceLock::new();
static LAST_FRONTMOST_APP: OnceLock<Mutex<Option<(String, String)>>> = 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));
}
Comment on lines 8 to 12

#[allow(dead_code)]
pub fn get_last_frontmost_app_name() -> Option<String> {
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()
}
Expand Down Expand Up @@ -54,6 +53,10 @@ pub fn query_frontmost_app_info() -> (Option<String>, Option<String>) {
(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)> {
Expand Down Expand Up @@ -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}"))?;
Expand Down Expand Up @@ -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
));
}
}
5 changes: 2 additions & 3 deletions src-tauri/src/settings_store.rs
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -200,7 +199,7 @@ pub fn ensure_sync_salt_b64<R: tauri::Runtime>(app: &tauri::AppHandle<R>, 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)
Expand All @@ -209,6 +208,6 @@ pub fn ensure_sync_salt_b64<R: tauri::Runtime>(app: &tauri::AppHandle<R>, 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)
}
3 changes: 1 addition & 2 deletions src-tauri/src/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -51,7 +50,7 @@ fn encrypt(passphrase: &str, salt: &[u8], plaintext: &[u8]) -> Result<SyncEncryp
let cipher = ChaCha20Poly1305::new(key);

let mut nonce_bytes = [0u8; 12];
rand::thread_rng().fill_bytes(&mut nonce_bytes);
rand::fill(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);

let ct = cipher
Expand Down
17 changes: 17 additions & 0 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,23 @@ import App from "./App";
import { writeClipboardText, pasteItem } from "./api";

describe("App", () => {
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(<PermissionsApp />);
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(<App />);

Expand Down
14 changes: 12 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
};
}, []);

Expand Down
2 changes: 1 addition & 1 deletion src/components/PermissionsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export function PermissionsModal(props: PermissionsModalProps) {
</>
) : null}
<div className="hint">
After granting permissions, click <strong>Re-check</strong> below to verify.
PowerPaste re-checks automatically when you return from System Settings.
</div>
</div>
) : null}
Expand Down
Loading