From 8db08c7832b602cd3b8f7fc02f3561eac67af527 Mon Sep 17 00:00:00 2001 From: Aditya Date: Tue, 23 Jun 2026 07:47:08 +0530 Subject: [PATCH 1/6] fix: resolve window cascading and shadow glitches, update performance audit --- PERFORMANCE_AUDIT.md | 14 +++++++------- src-tauri/src/lib.rs | 2 +- src-tauri/src/macos.rs | 12 ++++++++++-- 3 files changed, 18 insertions(+), 10 deletions(-) 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/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3748dd5..f4956ec 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -46,7 +46,7 @@ pub fn run() { #[cfg(target_os = "macos")] crate::macos::set_move_to_active_space(&window); #[cfg(target_os = "macos")] - crate::macos::set_shadow(&window, true); + crate::macos::disable_window_cascading(&window); let dialog_state = app.state::(); let is_dialog_open = dialog_state.is_open.clone(); diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 277675c..91eddb5 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -3,14 +3,22 @@ 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; + use cocoa::base::{YES, NO}; let ns_window = window.ns_window().unwrap() as id; unsafe { let _: () = msg_send![ns_window, setHasShadow: if enable { YES } else { NO }]; } } +#[cfg(target_os = "macos")] +pub fn disable_window_cascading(window: &tauri::WebviewWindow) { + let ns_window = window.ns_window().unwrap() as id; + unsafe { + use cocoa::base::NO; + let _: () = msg_send![ns_window, setShouldCascadeWindows: NO]; + } +} + #[cfg(target_os = "macos")] use tauri::{AppHandle, Emitter}; From f30b20ed3ae71874ee9ed0d53692b018cc343e01 Mon Sep 17 00:00:00 2001 From: Aditya Date: Tue, 23 Jun 2026 07:55:38 +0530 Subject: [PATCH 2/6] fix: remove invalid Objective-C selector call causing panic on launch --- src-tauri/src/lib.rs | 45 ++++++++++++++++++++++-------------------- src-tauri/src/macos.rs | 8 -------- 2 files changed, 24 insertions(+), 29 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f4956ec..6d7c176 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -42,33 +42,36 @@ pub fn run() { 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::disable_window_cascading(&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(); + 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(); + } + _ => {} } - 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![ diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 91eddb5..1b28754 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -10,14 +10,6 @@ pub fn set_shadow(window: &tauri::WebviewWindow, enable: bool) { } } -#[cfg(target_os = "macos")] -pub fn disable_window_cascading(window: &tauri::WebviewWindow) { - let ns_window = window.ns_window().unwrap() as id; - unsafe { - use cocoa::base::NO; - let _: () = msg_send![ns_window, setShouldCascadeWindows: NO]; - } -} #[cfg(target_os = "macos")] use tauri::{AppHandle, Emitter}; From 666e275e8c451347925b6d83cf1e1eda619994c8 Mon Sep 17 00:00:00 2001 From: Aditya Date: Tue, 23 Jun 2026 07:59:31 +0530 Subject: [PATCH 3/6] fix: enable macOSPrivateApi to fix black corners on transparent windows --- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) 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/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": { From be279b7c1343386e5d71d8d4f3a98d63af076b68 Mon Sep 17 00:00:00 2001 From: Aditya Date: Tue, 23 Jun 2026 08:06:46 +0530 Subject: [PATCH 4/6] fix: shortcut input styling, prevent Escape from closing app, prevent window drift --- src-tauri/src/lib.rs | 2 +- src/Settings.tsx | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6d7c176..33fbdee 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -30,7 +30,7 @@ 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::SIZE).build()) .plugin(tauri_plugin_global_shortcut::Builder::default().build()) .plugin(tauri_plugin_autostart::init( tauri_plugin_autostart::MacosLauncher::LaunchAgent, diff --git a/src/Settings.tsx b/src/Settings.tsx index 9b966c3..b5539e0 100644 --- a/src/Settings.tsx +++ b/src/Settings.tsx @@ -350,7 +350,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 +424,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) @@ -465,6 +474,7 @@ function ShortcutInput({ value, onChange }: { value: string; onChange: (val: str borderRadius: '6px', cursor: 'pointer', minWidth: '220px', + margin: '0 auto', display: 'flex', justifyContent: 'center', alignItems: 'center', From ffc9f57a0253e10ac33712b4171fd770973eb546 Mon Sep 17 00:00:00 2001 From: Aditya Date: Tue, 23 Jun 2026 09:02:40 +0530 Subject: [PATCH 5/6] v0.5.0-beta: Tauri Migration --- features.md | 2 +- src-tauri/build.rs | 2 +- src-tauri/src/commands/ai.rs | 27 +++++++-- src-tauri/src/commands/fs.rs | 87 ++++++++++++++++------------ src-tauri/src/commands/keychain.rs | 45 ++++++++------ src-tauri/src/commands/mod.rs | 4 +- src-tauri/src/commands/shortcuts.rs | 77 +++++++++++++----------- src-tauri/src/commands/system.rs | 25 +++++--- src-tauri/src/lib.rs | 29 ++++++---- src-tauri/src/macos.rs | 16 +---- src-tauri/src/tray.rs | 13 ++++- src/App.css | 5 +- src/Settings.tsx | 17 +++++- src/api.ts | 5 +- src/components/Editor.tsx | 6 ++ src/components/MainActionMenu.tsx | 11 ++-- src/hooks/useGlobalHotkey.ts | 29 ++++------ src/lib/editor/MathEvaluator.test.ts | 47 ++++----------- src/lib/editor/MathEvaluator.ts | 62 ++++++++++++-------- src/store/useAppStore.test.ts | 4 +- src/store/useAppStore.ts | 16 ++--- src/types.d.ts | 2 +- 22 files changed, 294 insertions(+), 237 deletions(-) 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/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 33fbdee..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().with_state_flags(tauri_plugin_window_state::StateFlags::SIZE).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,15 +47,15 @@ pub fn run() { .setup(|app| { commands::fs::run_onboarding(); tray::create_tray(app).expect("Failed to create tray"); - + use tauri::Manager; 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 { @@ -56,7 +63,9 @@ pub fn run() { api.prevent_close(); let _ = w.hide(); } - tauri::WindowEvent::Focused(focused) if !focused && !is_dialog_open.load(Ordering::SeqCst) => { + tauri::WindowEvent::Focused(focused) + if !focused && !is_dialog_open.load(Ordering::SeqCst) => + { let _ = w.hide(); } _ => {} @@ -65,13 +74,13 @@ pub fn run() { } 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![ @@ -87,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 1b28754..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, 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/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 b5539e0..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) { @@ -464,7 +472,10 @@ function ShortcutInput({ value, onChange }: { value: string; onChange: (val: str return (