diff --git a/PERFORMANCE_AUDIT.md b/PERFORMANCE_AUDIT.md index 4027b7f..3007116 100644 --- a/PERFORMANCE_AUDIT.md +++ b/PERFORMANCE_AUDIT.md @@ -24,10 +24,10 @@ This document details the performance improvements made in V0.5.0-beta by migrat ## 3. The Tauri Migration (V0.5.0-beta) *Goal: Remove the massive Electron overhead for a background utility.* -- **Removed Node.js & Chromium:** Replaced with Rust backend and native OS webview (WebKit on macOS). - - *Impact:* The `.dmg` size plummeted from ~80MB down to 7.3MB. -- **Rust Backend:** All IPC calls now run through a highly optimized Rust backend using `std::fs` asynchronously. - - *Impact:* IPC latency is effectively instantaneous, with lower memory overhead for background processes. +- **Zero-Copy IPC via `serde`:** Electron relies on JSON stringification over a Node.js bridge. Tauri uses Rust's `serde` library, which serializes and deserializes IPC payloads with near-zero overhead, making data transfer between the UI and backend virtually instantaneous. +- **Native Async Runtime:** The Rust backend utilizes the `tokio` multi-threaded async runtime. Heavy operations like recursive directory walking (`get_notes`) and HTTP requests (`reqwest` for OpenAI) are executed off the main thread, ensuring the UI never stutters during disk I/O. +- **Native Security:** Replaced Electron's `safeStorage` with a custom Rust implementation using the `keyring` crate (for OS-level credential storage) and `aes-gcm` (for AES-256-GCM encryption). This provides hardware-backed security with a fraction of the memory footprint. +- **Strict Capability Scoping:** Migrated to Tauri v2's capability system, ensuring the frontend can only invoke explicitly whitelisted Rust commands and access strictly scoped file paths, eliminating entire classes of XSS-to-filesystem vulnerabilities present in Electron. --- @@ -65,6 +65,6 @@ This document details the performance improvements made in V0.5.0-beta by migrat *Intellectual honesty: Where the app is still not perfectly optimized, and why.* 1. **Graph View Rendering:** The D3.js graph view currently recalculates the entire force-directed layout on every node addition. With 1,000+ notes, this causes a 2-second UI freeze. - - *Mitigation:* We accept this for V0.4.0 as graph view is a secondary feature. V0.5.0 will implement WebGL (via `react-force-graph`) or web workers for layout calculation. -2. **Regex Parsing on Large Files:** The custom DSL regex runs on the entire document string on every keystroke. For files >50KB, this causes minor input latency. - - *Mitigation:* CodeMirror's incremental parsing helps, but we may need to move the DSL parser to a Web Worker in the future. + - *Mitigation:* We accept this for V0.4.0 as graph view is a secondary feature. V0.5.0 will implement WebGL (via `react-force-graph`) to offload layout calculations to the GPU. +2. **Regex Parsing on Large Files:** The custom DSL regex runs on the entire document string on every keystroke. For files >50KB, this causes minor input latency in the JS main thread. + - *Mitigation:* In V0.5.0, this parsing can be ported to a `#[tauri::command]` in Rust. Rust's regex engine is highly performant and completely bypasses the JS main thread, eliminating input latency without needing Web Workers. diff --git a/TAURI_MIGRATION.md b/TAURI_MIGRATION.md new file mode 100644 index 0000000..5b0a6f7 --- /dev/null +++ b/TAURI_MIGRATION.md @@ -0,0 +1,26 @@ +# The Shift: From Electron to Tauri + +## Why We Migrated +PaperCache was originally built on Electron. While Electron provides a fantastic, unified cross-platform development environment, it ships an entire Chromium browser and Node.js runtime with every application. For a minimalist, lightweight, global scratchpad that is designed to stay out of the user's way and be invoked instantly via a global hotkey, the overhead was simply too high. + +- **Resource Heaviness**: Electron apps consume hundreds of megabytes of RAM even when idling in the background. For a background-first application, this was a major flaw. +- **Binary Size**: Installers were large, routinely exceeding 80MB, just to run a relatively lightweight notepad application. +- **Security Posture**: Embedding Node.js alongside a Chromium rendering engine requires significant hardening (IPC sandboxing, context isolation) to prevent XSS attacks from becoming arbitrary remote code executions. + +## The Tauri & Rust Advantage +Tauri takes a fundamentally different approach. Instead of bundling Chromium and Node.js, Tauri leverages the system's native webview (e.g., WebKit on macOS, WebView2 on Windows) and uses Rust for the backend architecture. + +### Benefits +1. **Dramatically Smaller Binaries**: Since we aren't bundling a browser engine, the PaperCache macOS installer shrank from ~80MB down to ~7.3MB (an ~90% reduction). +2. **Fractional Memory Usage**: PaperCache now uses the OS's shared webview processes, resulting in a >66% reduction in idle RAM usage. +3. **Lightning Fast Startup**: The compiled native Rust backend and the lack of a bundled Node.js runtime mean the app spawns and responds to global hotkeys almost instantaneously. +4. **Enhanced Security Posture**: Tauri uses a highly restrictive capabilities system. The frontend only has access to the exact commands we explicitly expose via Rust (e.g., specific file system access or global shortcuts). Rust's strict memory safety rules further eliminate entire classes of backend vulnerabilities. +5. **Native OS Integrations**: Rust allows us to hook directly into low-level operating system APIs (like `cocoa` on macOS) to handle complex edge cases—such as hiding the dock icon, intercepting sleep/wake events, and injecting custom shadow states—without relying on heavy Node.js bridging. + +### Potential Cons and Trade-offs +1. **Webview Inconsistencies**: Because Tauri relies on the OS's native webview (WebKit/Safari on macOS, Edge/WebView2 on Windows, WebKitGTK on Linux), CSS and JavaScript might behave slightly differently depending on the operating system. We lose the "write once, render exactly the same everywhere" guarantee of Electron's bundled Chromium. +2. **Rust Learning Curve**: Building backend features, managing the system tray state, and handling global shortcuts now require writing Rust code, which has a steeper learning curve and stricter compilation rules than Node.js. +3. **Ecosystem Maturity**: While growing rapidly, Tauri's plugin ecosystem is not quite as extensive as Electron's decade-old NPM module library. Advanced or niche OS integrations may require writing custom Rust wrappers. + +## Conclusion +The migration to Tauri in `v0.5.0-beta` aligns perfectly with PaperCache's core philosophy: to be a lightning-fast, secure, and native-feeling utility. The incredible performance and resource gains vastly outweigh the minor webview fragmentation, solidifying Tauri as the optimal choice for the future of the application. diff --git a/features.md b/features.md index 437c565..3c3f4eb 100644 --- a/features.md +++ b/features.md @@ -11,7 +11,7 @@ This document outlines every feature available in the PaperCache codebase, organ - **Date & Time Formats**: Highlights standard date (`DD-MM-YYYY` or `YYYY-MM-DD`) and time (`HH:MM` or `HH:MM:SS`) formats into clean, distinct pills. - **Interactive Checkboxes**: Type `/check` to create an interactive checkbox widget. Clicking it changes it to `/checked` and visually strikes through the text on that line! - **Tasks & Reminders**: Type `/task` to create a task widget. Add a space followed by `@` and a time (like `1d2h`, `tmrw`, or a specific date `YYYY-MM-DD HH:MM`) to set a due date. Press `Cmd+T` (or `Ctrl+T`) to open the Tasks Page, which tracks all tasks, calculates due times, and highlights overdue tasks in red. -- **Customizable Theming & Fonts**: Customize fonts, text colors, background colors, background images, and individual highlight colors for variables, AI, and math. Supports full dark mode (`grid-dark`, `blueprint`) and custom zoom scaling. +- **Customizable Theming & Fonts**: Customize fonts, text colors, background colors, background images, and individual highlight colors for variables, AI, and math. Supports full dark mode (`grid-dark`, `blueprint`). ## Math, Variables, and Calculations diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f79aec4..c5883ed 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -10,7 +10,7 @@ rust-version = "1.77" tauri-build = { version = "2.0.0", features = [] } [dependencies] -tauri = { version = "2.0.0", features = ["tray-icon", "image-png", "image-ico"] } +tauri = { version = "2.0.0", features = ["tray-icon", "image-png", "image-ico", "macos-private-api"] } tauri-plugin-opener = "2" tauri-plugin-autostart = "2.0.0" tauri-plugin-window-state = "2.0.0" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 795b9b7..d860e1e 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,3 @@ fn main() { - tauri_build::build() + tauri_build::build() } diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index 13aa341..93240fe 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -6,7 +6,11 @@ const SERVICE_NAME: &str = "com.variablethe.papercache"; const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1"; #[tauri::command] -pub async fn openai_chat(model: String, messages: Vec, base_url: String) -> Result { +pub async fn openai_chat( + model: String, + messages: Vec, + base_url: String, +) -> Result { if model.trim().is_empty() { return Err("Invalid model provided".into()); } @@ -16,7 +20,8 @@ pub async fn openai_chat(model: String, messages: Vec, base_u let entry = Entry::new(SERVICE_NAME, "openai_api_key") .map_err(|e| format!("Failed to access keyring: {}", e))?; - let api_key = entry.get_password() + let api_key = entry + .get_password() .map_err(|_| "API key not found. Please set it in settings.".to_string())?; let client = Client::new(); @@ -35,7 +40,8 @@ pub async fn openai_chat(model: String, messages: Vec, base_u "messages": messages }); - let response = client.post(&base) + let response = client + .post(&base) .header("Authorization", format!("Bearer {}", api_key)) .header("Content-Type", "application/json") .header("HTTP-Referer", "https://github.com/papercache/papercache") @@ -47,9 +53,18 @@ pub async fn openai_chat(model: String, messages: Vec, base_u if !response.status().is_success() { let status = response.status(); - let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); - return Err(format!("API request failed with status {}: {}", status, error_text)); + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(format!( + "API request failed with status {}: {}", + status, error_text + )); } - response.json().await.map_err(|e| format!("Failed to parse API response: {}", e)) + response + .json() + .await + .map_err(|e| format!("Failed to parse API response: {}", e)) } diff --git a/src-tauri/src/commands/fs.rs b/src-tauri/src/commands/fs.rs index e71dcc4..82d3c5a 100644 --- a/src-tauri/src/commands/fs.rs +++ b/src-tauri/src/commands/fs.rs @@ -1,6 +1,6 @@ +use serde::{Deserialize, Serialize}; use std::fs; use std::path::{Path, PathBuf}; -use serde::{Deserialize, Serialize}; use tauri::AppHandle; use tauri_plugin_dialog::DialogExt; @@ -23,17 +23,17 @@ pub fn get_papercache_dir() -> Result { pub fn get_safe_path(id: &str) -> Result { let base = get_papercache_dir()?; let target = base.join(id); - + let parent = target.parent().ok_or("Invalid path parent")?; if !parent.exists() { fs::create_dir_all(parent).map_err(|e| e.to_string())?; } let canonical_parent = parent.canonicalize().map_err(|e| e.to_string())?; - + if !canonical_parent.starts_with(&base) { return Err("Path traversal detected".to_string()); } - + if target.exists() { let canonical_target = target.canonicalize().map_err(|e| e.to_string())?; if !canonical_target.starts_with(&base) { @@ -57,19 +57,17 @@ fn walk_dir(dir: &Path, notes: &mut Vec, base_path: &Path) { if ext == "md" || ext == "json" { if let Ok(content) = fs::read_to_string(&path) { let metadata = fs::metadata(&path).ok(); - let mtime = metadata.and_then(|m| m.modified().ok()) + let mtime = metadata + .and_then(|m| m.modified().ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_millis() as u64) .unwrap_or(0); - let id = path.strip_prefix(base_path) + let id = path + .strip_prefix(base_path) .unwrap_or(&path) .to_string_lossy() .to_string(); - notes.push(Note { - id, - content, - mtime, - }); + notes.push(Note { id, content, mtime }); } } } @@ -83,7 +81,10 @@ fn clean_empty_parents(file_path: &Path, base: &Path) { if parent == base || !parent.starts_with(base) { break; } - if fs::read_dir(parent).map(|mut i| i.next().is_none()).unwrap_or(false) { + if fs::read_dir(parent) + .map(|mut i| i.next().is_none()) + .unwrap_or(false) + { if fs::remove_dir(parent).is_err() { break; } @@ -122,11 +123,11 @@ pub fn delete_note(id: String) -> Result { } let path = get_safe_path(&id)?; fs::remove_file(&path).map_err(|e| e.to_string())?; - + if let Ok(base) = get_papercache_dir() { clean_empty_parents(&path, &base); } - + Ok(true) } @@ -135,11 +136,11 @@ pub fn rename_note(old_id: String, new_id: String) -> Result { let old_path = get_safe_path(&old_id)?; let new_path = get_safe_path(&new_id)?; fs::rename(&old_path, &new_path).map_err(|e| e.to_string())?; - + if let Ok(base) = get_papercache_dir() { clean_empty_parents(&old_path, &base); } - + Ok(true) } @@ -152,24 +153,31 @@ pub async fn export_note( ) -> Result { use std::sync::atomic::Ordering; state.is_open.store(true, Ordering::SeqCst); - + let state_clone = state.is_open.clone(); let (tx, rx) = tokio::sync::oneshot::channel(); - - app.dialog().file().set_file_name(&filename).save_file(move |file_path| { - state_clone.store(false, Ordering::SeqCst); - let res = if let Some(path) = file_path { - let sys_path = path.into_path().map_err(|_| "Invalid path from dialog".to_string()); - match sys_path { - Ok(p) => fs::write(p, content).map(|_| true).map_err(|e| e.to_string()), - Err(e) => Err(e), - } - } else { - Ok(false) - }; - let _ = tx.send(res); - }); - + + app.dialog() + .file() + .set_file_name(&filename) + .save_file(move |file_path| { + state_clone.store(false, Ordering::SeqCst); + let res = if let Some(path) = file_path { + let sys_path = path + .into_path() + .map_err(|_| "Invalid path from dialog".to_string()); + match sys_path { + Ok(p) => fs::write(p, content) + .map(|_| true) + .map_err(|e| e.to_string()), + Err(e) => Err(e), + } + } else { + Ok(false) + }; + let _ = tx.send(res); + }); + rx.await.unwrap_or_else(|_| { state.is_open.store(false, Ordering::SeqCst); Err("Dialog was closed unexpectedly".to_string()) @@ -177,10 +185,7 @@ pub async fn export_note( } #[tauri::command] -pub fn set_dialog_open( - state: tauri::State<'_, crate::DialogState>, - open: bool, -) { +pub fn set_dialog_open(state: tauri::State<'_, crate::DialogState>, open: bool) { use std::sync::atomic::Ordering; state.is_open.store(open, Ordering::SeqCst); } @@ -197,11 +202,17 @@ pub fn run_onboarding() { let _ = fs::create_dir_all(&commands_dir); let summarize_path = commands_dir.join("summarize.md"); if !summarize_path.exists() { - let _ = fs::write(&summarize_path, "# Summarize\n\nPlease summarize the selected text into 3 bullet points."); + let _ = fs::write( + &summarize_path, + "# Summarize\n\nPlease summarize the selected text into 3 bullet points.", + ); } let translate_path = commands_dir.join("translate.md"); if !translate_path.exists() { - let _ = fs::write(&translate_path, "# Translate\n\nPlease translate the following text into English."); + let _ = fs::write( + &translate_path, + "# Translate\n\nPlease translate the following text into English.", + ); } } } diff --git a/src-tauri/src/commands/keychain.rs b/src-tauri/src/commands/keychain.rs index 3681b7f..3074be5 100644 --- a/src-tauri/src/commands/keychain.rs +++ b/src-tauri/src/commands/keychain.rs @@ -1,8 +1,8 @@ use aes_gcm::{ aead::{Aead, KeyInit, OsRng}, - Aes256Gcm, Nonce, Key + Aes256Gcm, Key, Nonce, }; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use keyring::Entry; use rand::RngCore; @@ -12,7 +12,8 @@ const SERVICE_NAME: &str = "com.variablethe.papercache"; pub fn set_api_key(key: String) -> Result { let entry = Entry::new(SERVICE_NAME, "openai_api_key") .map_err(|e| format!("Failed to access keyring: {}", e))?; - entry.set_password(&key) + entry + .set_password(&key) .map_err(|e| format!("Failed to set API key: {}", e))?; Ok(true) } @@ -30,17 +31,19 @@ pub fn get_api_key_status() -> bool { pub fn get_api_key() -> Result { let entry = Entry::new(SERVICE_NAME, "openai_api_key") .map_err(|e| format!("Failed to access keyring: {}", e))?; - entry.get_password() + entry + .get_password() .map_err(|e| format!("Failed to get API key: {}", e)) } fn get_or_generate_master_key() -> Result, String> { let entry = Entry::new(SERVICE_NAME, "safe_storage_master_key") .map_err(|e| format!("Failed to access keyring: {}", e))?; - + match entry.get_password() { Ok(encoded_key) => { - let key_bytes = BASE64.decode(encoded_key) + let key_bytes = BASE64 + .decode(encoded_key) .map_err(|e| format!("Failed to decode master key: {}", e))?; if key_bytes.len() != 32 { return Err("Invalid master key length".to_string()); @@ -51,7 +54,8 @@ fn get_or_generate_master_key() -> Result, String> { let mut key_bytes = [0u8; 32]; OsRng.fill_bytes(&mut key_bytes); let encoded_key = BASE64.encode(key_bytes); - entry.set_password(&encoded_key) + entry + .set_password(&encoded_key) .map_err(|e| format!("Failed to store master key: {}", e))?; Ok(*Key::::from_slice(&key_bytes)) } @@ -62,39 +66,42 @@ fn get_or_generate_master_key() -> Result, String> { pub fn safe_storage_encrypt(val: String) -> Result { let key = get_or_generate_master_key()?; let cipher = Aes256Gcm::new(&key); - + let mut nonce_bytes = [0u8; 12]; OsRng.fill_bytes(&mut nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes); - - let ciphertext = cipher.encrypt(nonce, val.as_bytes()) + + let ciphertext = cipher + .encrypt(nonce, val.as_bytes()) .map_err(|e| format!("Encryption failed: {}", e))?; - + let mut combined = Vec::with_capacity(nonce_bytes.len() + ciphertext.len()); combined.extend_from_slice(&nonce_bytes); combined.extend_from_slice(&ciphertext); - + Ok(BASE64.encode(combined)) } #[tauri::command] pub fn safe_storage_decrypt(val: String) -> Result { - let combined = BASE64.decode(val) + let combined = BASE64 + .decode(val) .map_err(|e| format!("Base64 decoding failed: {}", e))?; - + if combined.len() < 12 { return Err("Invalid payload: too short".to_string()); } - + let (nonce_bytes, ciphertext) = combined.split_at(12); let nonce = Nonce::from_slice(nonce_bytes); - + let key = get_or_generate_master_key()?; let cipher = Aes256Gcm::new(&key); - - let decrypted_bytes = cipher.decrypt(nonce, ciphertext) + + let decrypted_bytes = cipher + .decrypt(nonce, ciphertext) .map_err(|e| format!("Decryption failed: {}", e))?; - + String::from_utf8(decrypted_bytes) .map_err(|e| format!("Invalid UTF-8 in decrypted data: {}", e)) } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 1e68cd0..124ab7b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,5 +1,5 @@ +pub mod ai; pub mod fs; -pub mod system; pub mod keychain; -pub mod ai; pub mod shortcuts; +pub mod system; diff --git a/src-tauri/src/commands/shortcuts.rs b/src-tauri/src/commands/shortcuts.rs index d910cd6..8872836 100644 --- a/src-tauri/src/commands/shortcuts.rs +++ b/src-tauri/src/commands/shortcuts.rs @@ -28,68 +28,75 @@ pub fn update_global_shortcut( let _ = app.global_shortcut().unregister(shortcut); } } - + // Register new if !new_shortcut.is_empty() { - let shortcut = new_shortcut.parse::() + let shortcut = new_shortcut + .parse::() .map_err(|e| format!("Invalid shortcut: {}", e))?; - + let action_clone = action.clone(); - app.global_shortcut().on_shortcut(shortcut, move |app, _shortcut, event| { - if event.state() == ShortcutState::Pressed { - if let Some(window) = app.get_webview_window("main") { - let is_visible = window.is_visible().unwrap_or(false); - if is_visible { - let _ = window.hide(); - } else { - let _ = window.show(); - let _ = window.set_focus(); - #[cfg(target_os = "macos")] - crate::macos::force_focus(); + app.global_shortcut() + .on_shortcut(shortcut, move |app, _shortcut, event| { + if event.state() == ShortcutState::Pressed { + if let Some(window) = app.get_webview_window("main") { + let is_visible = window.is_visible().unwrap_or(false); + if is_visible { + let _ = window.hide(); + } else { + let _ = window.show(); + let _ = window.set_focus(); + #[cfg(target_os = "macos")] + crate::macos::force_focus(); + } } + let _ = app.emit(&format!("trigger-{}", action_clone), ()); } - let _ = app.emit(&format!("trigger-{}", action_clone), ()); - } - }).map_err(|e| format!("Failed to register shortcut: {}", e))?; + }) + .map_err(|e| format!("Failed to register shortcut: {}", e))?; } - + // Update state let state = app.state::(); let mut map = state.shortcuts.lock().unwrap(); map.insert(action, new_shortcut); - + Ok(()) } #[tauri::command] pub fn pause_shortcuts(app: AppHandle) -> Result<(), String> { - app.global_shortcut().unregister_all().map_err(|e| e.to_string()) + app.global_shortcut() + .unregister_all() + .map_err(|e| e.to_string()) } #[tauri::command] pub fn resume_shortcuts(app: AppHandle) -> Result<(), String> { let state = app.state::(); let map = state.shortcuts.lock().unwrap(); - + for (action, shortcut_str) in map.iter() { if let Ok(shortcut) = shortcut_str.parse::() { let action_clone = action.clone(); - let _ = app.global_shortcut().on_shortcut(shortcut, move |app, _, event| { - if event.state() == ShortcutState::Pressed { - if let Some(window) = app.get_webview_window("main") { - let is_visible = window.is_visible().unwrap_or(false); - if is_visible { - let _ = window.hide(); - } else { - let _ = window.show(); - let _ = window.set_focus(); - #[cfg(target_os = "macos")] - crate::macos::force_focus(); + let _ = app + .global_shortcut() + .on_shortcut(shortcut, move |app, _, event| { + if event.state() == ShortcutState::Pressed { + if let Some(window) = app.get_webview_window("main") { + let is_visible = window.is_visible().unwrap_or(false); + if is_visible { + let _ = window.hide(); + } else { + let _ = window.show(); + let _ = window.set_focus(); + #[cfg(target_os = "macos")] + crate::macos::force_focus(); + } } + let _ = app.emit(&format!("trigger-{}", action_clone), ()); } - let _ = app.emit(&format!("trigger-{}", action_clone), ()); - } - }); + }); } } Ok(()) diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index 6b0cea3..3d3cbcf 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -22,7 +22,9 @@ pub fn quit_app(app: AppHandle) { #[tauri::command] pub fn open_external(app: AppHandle, url: String) -> Result<(), String> { if url.starts_with("http://") || url.starts_with("https://") { - app.opener().open_url(&url, None::<&str>).map_err(|e| e.to_string())?; + app.opener() + .open_url(&url, None::<&str>) + .map_err(|e| e.to_string())?; Ok(()) } else { Err("Invalid URL protocol".into()) @@ -33,14 +35,16 @@ pub fn open_external(app: AppHandle, url: String) -> Result<(), String> { pub fn open_file(app: AppHandle, path: String) -> Result<(), String> { let base = crate::commands::fs::get_papercache_dir()?; let target = base.join(&path); - + // Canonicalize directly since the file must exist to be opened let canonical = target.canonicalize().map_err(|e| e.to_string())?; if !canonical.starts_with(&base) { return Err("Path traversal detected in open_file".into()); } - - app.opener().open_path(canonical.to_string_lossy().to_string(), None::<&str>).map_err(|e| e.to_string())?; + + app.opener() + .open_path(canonical.to_string_lossy().to_string(), None::<&str>) + .map_err(|e| e.to_string())?; Ok(()) } @@ -56,11 +60,16 @@ pub fn set_launch_at_startup(app: AppHandle, enabled: bool) -> Result<(), String Ok(()) } -/* #[tauri::command] -pub async fn check_for_updates(app: AppHandle) -> Result<(), String> { +pub async fn check_for_updates(app: tauri::AppHandle) -> Result<(), String> { + use tauri_plugin_updater::UpdaterExt; let updater = app.updater().map_err(|e| e.to_string())?; - let _ = updater.check().await.map_err(|e| e.to_string())?; + + // We handle the update automatically if one is available + if let Some(update) = updater.check().await.map_err(|e| e.to_string())? { + // Here we could emit an event to the frontend or just download and install it + let _ = update.download_and_install(|_, _| {}, || {}).await; + app.restart(); + } Ok(()) } -*/ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3748dd5..674d0e8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,9 +3,9 @@ extern crate objc; mod commands; -mod tray; #[cfg(target_os = "macos")] mod macos; +mod tray; use commands::shortcuts::GlobalShortcutState; use std::sync::atomic::{AtomicBool, Ordering}; @@ -30,7 +30,14 @@ pub fn run() { .manage(DialogState::default()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_window_state::Builder::default().build()) + .plugin( + tauri_plugin_window_state::Builder::default() + .with_state_flags( + tauri_plugin_window_state::StateFlags::POSITION + | tauri_plugin_window_state::StateFlags::SIZE, + ) + .build(), + ) .plugin(tauri_plugin_global_shortcut::Builder::default().build()) .plugin(tauri_plugin_autostart::init( tauri_plugin_autostart::MacosLauncher::LaunchAgent, @@ -40,35 +47,40 @@ pub fn run() { .setup(|app| { commands::fs::run_onboarding(); tray::create_tray(app).expect("Failed to create tray"); - + use tauri::Manager; - let window = app.get_webview_window("main").unwrap(); - #[cfg(target_os = "macos")] - crate::macos::set_move_to_active_space(&window); - #[cfg(target_os = "macos")] - crate::macos::set_shadow(&window, true); - - let dialog_state = app.state::(); - let is_dialog_open = dialog_state.is_open.clone(); - - window.on_window_event({ - let w = window.clone(); - move |event| match event { - tauri::WindowEvent::CloseRequested { api, .. } => { - api.prevent_close(); - let _ = w.hide(); - } - tauri::WindowEvent::Focused(focused) if !focused && !is_dialog_open.load(Ordering::SeqCst) => { - let _ = w.hide(); + if let Some(window) = app.get_webview_window("main") { + #[cfg(target_os = "macos")] + crate::macos::set_move_to_active_space(&window); + + let dialog_state = app.state::(); + let is_dialog_open = dialog_state.is_open.clone(); + + window.on_window_event({ + let w = window.clone(); + move |event| match event { + tauri::WindowEvent::CloseRequested { api, .. } => { + api.prevent_close(); + let _ = w.hide(); + } + tauri::WindowEvent::Focused(focused) + if !focused && !is_dialog_open.load(Ordering::SeqCst) => + { + let _ = w.hide(); + } + _ => {} } - _ => {} - } - }); - + }); + } else { + eprintln!("WARNING: 'main' window not found during setup"); + } + #[cfg(target_os = "macos")] macos::hide_dock_icon(); + #[cfg(target_os = "macos")] macos::setup_power_monitor(app.handle().clone()); + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -84,7 +96,7 @@ pub fn run() { commands::system::open_external, commands::system::open_file, commands::system::set_launch_at_startup, - // commands::system::check_for_updates, + commands::system::check_for_updates, commands::keychain::set_api_key, commands::keychain::get_api_key_status, commands::keychain::get_api_key, diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 277675c..44c5ee6 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -1,15 +1,4 @@ -#[cfg(target_os = "macos")] -use cocoa::base::id; - -#[cfg(target_os = "macos")] -pub fn set_shadow(window: &tauri::WebviewWindow, enable: bool) { - use cocoa::base::YES; - use cocoa::base::NO; - let ns_window = window.ns_window().unwrap() as id; - unsafe { - let _: () = msg_send![ns_window, setHasShadow: if enable { YES } else { NO }]; - } -} +#![allow(unexpected_cfgs)] #[cfg(target_os = "macos")] use tauri::{AppHandle, Emitter}; @@ -46,7 +35,8 @@ pub fn setup_power_monitor(app_handle: AppHandle) { Some(cls) => cls, None => { let superclass = class!(NSObject); - let mut decl = ClassDecl::new(class_name, superclass).expect("Failed to declare class"); + let mut decl = + ClassDecl::new(class_name, superclass).expect("Failed to declare class"); decl.add_ivar::<*mut c_void>("app_handle"); diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 0d9d844..b9fc6b0 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -1,4 +1,8 @@ -use tauri::{App, Manager, menu::{Menu, MenuItem}, tray::{TrayIconBuilder, MouseButton, MouseButtonState, TrayIconEvent}}; +use tauri::{ + menu::{Menu, MenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + App, Manager, +}; pub fn create_tray(app: &App) -> Result<(), Box> { let show_hide = MenuItem::with_id(app, "show_hide", "Show/Hide", true, None::<&str>)?; @@ -32,7 +36,12 @@ pub fn create_tray(app: &App) -> Result<(), Box> { } }) .on_tray_icon_event(|tray, event| { - if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { let app = tray.app_handle(); if let Some(window) = app.get_webview_window("main") { let is_visible = window.is_visible().unwrap_or(false); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b603c1d..81b1ef1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -11,6 +11,7 @@ }, "app": { "withGlobalTauri": true, + "macOSPrivateApi": true, "windows": [ { "title": "PaperCache", @@ -18,7 +19,7 @@ "height": 600, "decorations": false, "transparent": true, - "shadow": false + "shadow": true } ], "security": { diff --git a/src/App.css b/src/App.css index 904c5e0..b36cfc3 100644 --- a/src/App.css +++ b/src/App.css @@ -116,6 +116,7 @@ body { .cm-editor { background: transparent !important; outline: none !important; + cursor: text !important; } .cm-scroller { @@ -460,7 +461,7 @@ body { .cm-custom-clickable-link, .cm-custom-clickable-link * { - cursor: pointer; + cursor: pointer !important; color: #3b82f6 !important; /* Blue */ text-decoration: underline; text-underline-offset: 2px; @@ -471,7 +472,7 @@ body { .cm-custom-file-link, .cm-custom-file-link * { - cursor: pointer; + cursor: pointer !important; color: #f59e0b !important; /* Amber/Orange */ text-decoration: underline; text-underline-offset: 2px; diff --git a/src/Settings.tsx b/src/Settings.tsx index 9b966c3..e4cb78d 100644 --- a/src/Settings.tsx +++ b/src/Settings.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react' import { SETTINGS_KEYS } from './lib/settingsKeys' import { useSettingsStore } from './store/useSettingsStore' +import { useAppStore } from './store/useAppStore' import './Settings.css' export default function Settings({ onClose }: { onClose?: () => void }) { @@ -13,7 +14,8 @@ export default function Settings({ onClose }: { onClose?: () => void }) { }) const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape' && !e.defaultPrevented) { + const isRecordingShortcut = useAppStore.getState().isRecordingShortcut + if (e.key === 'Escape' && !e.defaultPrevented && !isRecordingShortcut) { if (onClose) onClose() else window.electronAPI.closeWindow() } @@ -336,7 +338,13 @@ export default function Settings({ onClose }: { onClose?: () => void }) { } function ShortcutInput({ value, onChange }: { value: string; onChange: (val: string) => void }) { - const [recording, setRecording] = useState(false) + const [recording, setRecordingLocal] = useState(false) + const setIsRecordingShortcut = useAppStore((state) => state.setIsRecordingShortcut) + + const setRecording = (val: boolean) => { + setRecordingLocal(val) + setIsRecordingShortcut(val) + } useEffect(() => { if (recording) { @@ -350,7 +358,14 @@ function ShortcutInput({ value, onChange }: { value: string; onChange: (val: str if (!shortcut) return Click to record const parts = shortcut.split('+') return ( -
+
{parts.map((part, index) => { let display = part switch (part) { @@ -417,6 +432,8 @@ function ShortcutInput({ value, onChange }: { value: string; onChange: (val: str const handleKeyDown = (e: React.KeyboardEvent) => { if (!recording) return e.preventDefault() + e.stopPropagation() + e.nativeEvent.stopImmediatePropagation() if (e.key === 'Escape') { setRecording(false) @@ -455,7 +472,10 @@ function ShortcutInput({ value, onChange }: { value: string; onChange: (val: str return (