From 773eb17fed10cd1478f30dbb18b7e59758036770 Mon Sep 17 00:00:00 2001 From: Anic888 Date: Tue, 23 Jun 2026 17:22:06 -0500 Subject: [PATCH 01/13] Sanitize snippet/macro ids to fix path traversal (H1) Add helpers::sanitize_id() rejecting path separators, traversal, NUL and absolute paths; apply to snippet/macro save+delete (the session-name fix was never applied here). Also add exceeds_size_limit() helper used by the read/search size caps. --- src-tauri/src/commands/macros.rs | 8 ++-- src-tauri/src/commands/snippets.rs | 8 ++-- src-tauri/src/helpers.rs | 72 +++++++++++++++++++++++++++++- 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/commands/macros.rs b/src-tauri/src/commands/macros.rs index 545748c..aacc546 100644 --- a/src-tauri/src/commands/macros.rs +++ b/src-tauri/src/commands/macros.rs @@ -1,13 +1,14 @@ use std::fs; use crate::models::Macro; -use crate::helpers::get_data_dir; +use crate::helpers::{get_data_dir, sanitize_id}; #[tauri::command] pub fn save_macro(mac: Macro) -> Result<(), String> { + let safe_id = sanitize_id(&mac.id)?; let macros_dir = get_data_dir()?.join("macros"); fs::create_dir_all(¯os_dir).map_err(|e| e.to_string())?; - let macro_path = macros_dir.join(format!("{}.json", mac.id)); + let macro_path = macros_dir.join(format!("{}.json", safe_id)); let json = serde_json::to_string_pretty(&mac).map_err(|e| e.to_string())?; fs::write(macro_path, json).map_err(|e| e.to_string()) } @@ -34,6 +35,7 @@ pub fn list_macros() -> Result, String> { #[tauri::command] pub fn delete_macro(id: String) -> Result<(), String> { - let macro_path = get_data_dir()?.join("macros").join(format!("{}.json", id)); + let safe_id = sanitize_id(&id)?; + let macro_path = get_data_dir()?.join("macros").join(format!("{}.json", safe_id)); fs::remove_file(macro_path).map_err(|e| e.to_string()) } diff --git a/src-tauri/src/commands/snippets.rs b/src-tauri/src/commands/snippets.rs index 87c52ab..9885af7 100644 --- a/src-tauri/src/commands/snippets.rs +++ b/src-tauri/src/commands/snippets.rs @@ -1,13 +1,14 @@ use std::fs; use crate::models::Snippet; -use crate::helpers::get_data_dir; +use crate::helpers::{get_data_dir, sanitize_id}; #[tauri::command] pub fn save_snippet(snippet: Snippet) -> Result<(), String> { + let safe_id = sanitize_id(&snippet.id)?; let snippets_dir = get_data_dir()?.join("snippets"); fs::create_dir_all(&snippets_dir).map_err(|e| e.to_string())?; - let snippet_path = snippets_dir.join(format!("{}.json", snippet.id)); + let snippet_path = snippets_dir.join(format!("{}.json", safe_id)); let json = serde_json::to_string_pretty(&snippet).map_err(|e| e.to_string())?; fs::write(snippet_path, json).map_err(|e| e.to_string()) } @@ -34,6 +35,7 @@ pub fn list_snippets() -> Result, String> { #[tauri::command] pub fn delete_snippet(id: String) -> Result<(), String> { - let snippet_path = get_data_dir()?.join("snippets").join(format!("{}.json", id)); + let safe_id = sanitize_id(&id)?; + let snippet_path = get_data_dir()?.join("snippets").join(format!("{}.json", safe_id)); fs::remove_file(snippet_path).map_err(|e| e.to_string()) } diff --git a/src-tauri/src/helpers.rs b/src-tauri/src/helpers.rs index 4084268..e975aaa 100644 --- a/src-tauri/src/helpers.rs +++ b/src-tauri/src/helpers.rs @@ -1,7 +1,77 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; pub(crate) fn get_data_dir() -> Result { dirs::data_dir() .map(|p| p.join("notepad-mac")) .ok_or_else(|| "Could not find data directory".to_string()) } + +/// Maximum bytes the editor will read into memory for a single file. +pub(crate) const MAX_READ_FILE_BYTES: u64 = 50 * 1024 * 1024; // 50 MiB +/// Maximum per-file size that global search will load. +pub(crate) const MAX_SEARCH_FILE_BYTES: u64 = 5 * 1024 * 1024; // 5 MiB + +/// Sanitize a filename component (e.g. a snippet/macro id) so it can never +/// escape its intended directory. Rejects empty, traversal, separators and NUL. +pub(crate) fn sanitize_id(id: &str) -> Result { + if id.is_empty() { + return Err("Invalid id: must not be empty".to_string()); + } + if id.contains("..") || id.contains('/') || id.contains('\\') || id.contains('\0') { + return Err("Invalid id: path separators and traversal are not allowed".to_string()); + } + // ids are app-generated (uuid / timestamp / slug); keep the charset conservative. + if !id.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.') { + return Err("Invalid id: illegal characters".to_string()); + } + Ok(id.to_string()) +} + +/// Return true if the file at `path` is larger than `max` bytes. +/// A path that cannot be stat'd is treated as not exceeding the limit. +pub(crate) fn exceeds_size_limit(path: &Path, max: u64) -> bool { + std::fs::metadata(path).map(|m| m.len() > max).unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_id_keeps_plain_ids() { + assert_eq!(sanitize_id("abc123").unwrap(), "abc123"); + assert_eq!(sanitize_id("my-snippet_1").unwrap(), "my-snippet_1"); + } + + #[test] + fn sanitize_id_rejects_traversal() { + assert!(sanitize_id("../../etc/passwd").is_err()); + assert!(sanitize_id("..").is_err()); + assert!(sanitize_id("a/b").is_err()); + assert!(sanitize_id("a\\b").is_err()); + } + + #[test] + fn sanitize_id_rejects_absolute_and_empty() { + assert!(sanitize_id("/etc/passwd").is_err()); + assert!(sanitize_id("").is_err()); + assert!(sanitize_id("foo\0bar").is_err()); + } + + #[test] + fn exceeds_size_limit_detects_large_file() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("f.txt"); + std::fs::write(&p, b"hello").unwrap(); // 5 bytes + assert!(exceeds_size_limit(&p, 4)); + assert!(!exceeds_size_limit(&p, 5)); + assert!(!exceeds_size_limit(&p, 1000)); + } + + #[test] + fn exceeds_size_limit_missing_file_is_false() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("does-not-exist"); + assert!(!exceeds_size_limit(&p, 0)); + } +} From e59f06cb285a0d17bf0a0cd273cd7de7da399e65 Mon Sep 17 00:00:00 2001 From: Anic888 Date: Tue, 23 Jun 2026 17:22:06 -0500 Subject: [PATCH 02/13] Harden git invocations: config-driven RCE + option injection Route every git command through git_cmd_base(), which sets GIT_CONFIG_NOSYSTEM and disables core.fsmonitor / core.hooksPath and the ext/file protocols. An untrusted repo's .git/config can no longer execute code on the automatic git_status (C3). Reject option-like branch/ref names in git_checkout/git_create_branch. --- src-tauri/src/commands/git.rs | 116 ++++++++++++++++++++++++++++------ 1 file changed, 98 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/commands/git.rs b/src-tauri/src/commands/git.rs index affadb7..9b58704 100644 --- a/src-tauri/src/commands/git.rs +++ b/src-tauri/src/commands/git.rs @@ -1,9 +1,43 @@ use std::process::Command; use crate::models::{GitStatus, GitFileStatus, GitCommit}; +/// Reject branch/ref names that could be interpreted as git options +/// (option injection), e.g. `--upload-pack=...` or `-B`. +fn validate_ref_name(name: &str) -> Result<(), String> { + if name.is_empty() { + return Err("Invalid name: must not be empty".to_string()); + } + // A leading '-' lets the value be parsed as a git option (option injection), + // e.g. `--upload-pack=...`, `-B`, `--orphan`. + if name.starts_with('-') { + return Err("Invalid name: must not start with '-'".to_string()); + } + if name.contains('\0') || name.contains('\n') { + return Err("Invalid name: illegal characters".to_string()); + } + Ok(()) +} + +/// Build a hardened `git` invocation. Neutralizes config-driven code-execution +/// vectors that would otherwise fire when operating on an untrusted repository +/// (e.g. `core.fsmonitor` / `core.hooksPath` running on `git status`, and the +/// `ext::`/`file::` transports). Caller still appends the subcommand + `.current_dir`. +fn git_cmd_base() -> Command { + let mut cmd = Command::new("git"); + cmd.env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_TERMINAL_PROMPT", "0") + .args([ + "-c", "core.fsmonitor=false", + "-c", "core.hooksPath=/dev/null", + "-c", "protocol.ext.allow=never", + "-c", "protocol.file.allow=user", + ]); + cmd +} + #[tauri::command] pub fn git_status(path: String) -> Result { - let output = Command::new("git") + let output = git_cmd_base() .args(["status", "--porcelain", "-b"]) .current_dir(&path) .output() @@ -81,7 +115,7 @@ pub fn git_status(path: String) -> Result { #[tauri::command] pub fn git_log(path: String, count: i32) -> Result, String> { - let output = Command::new("git") + let output = git_cmd_base() .args(["log", &format!("-{}", count), "--pretty=format:%H%x00%h%x00%s%x00%an%x00%ae%x00%ci"]) .current_dir(&path) .output() @@ -120,7 +154,7 @@ pub fn git_diff(path: String, file_path: Option) -> Result) -> Result Result<(), String> { - Command::new("git") + git_cmd_base() .args(["add", "--", &file_path]) .current_dir(&path) .output() @@ -141,7 +175,7 @@ pub fn git_stage(path: String, file_path: String) -> Result<(), String> { #[tauri::command] pub fn git_unstage(path: String, file_path: String) -> Result<(), String> { - Command::new("git") + git_cmd_base() .args(["reset", "HEAD", "--", &file_path]) .current_dir(&path) .output() @@ -151,7 +185,7 @@ pub fn git_unstage(path: String, file_path: String) -> Result<(), String> { #[tauri::command] pub fn git_commit(path: String, message: String) -> Result<(), String> { - let output = Command::new("git") + let output = git_cmd_base() .args(["commit", "-m", &message]) .current_dir(&path) .output() @@ -165,7 +199,7 @@ pub fn git_commit(path: String, message: String) -> Result<(), String> { #[tauri::command] pub fn git_push(path: String) -> Result { - let output = Command::new("git") + let output = git_cmd_base() .args(["push"]) .current_dir(&path) .output() @@ -181,7 +215,7 @@ pub fn git_push(path: String) -> Result { #[tauri::command] pub fn git_pull(path: String) -> Result { - let output = Command::new("git") + let output = git_cmd_base() .args(["pull"]) .current_dir(&path) .output() @@ -197,7 +231,7 @@ pub fn git_pull(path: String) -> Result { #[tauri::command] pub fn git_blame(path: String, file_path: String) -> Result { - let output = Command::new("git") + let output = git_cmd_base() .args(["blame", "--", &file_path]) .current_dir(&path) .output() @@ -208,7 +242,7 @@ pub fn git_blame(path: String, file_path: String) -> Result { #[tauri::command] pub fn git_init(path: String) -> Result<(), String> { - let output = Command::new("git") + let output = git_cmd_base() .args(["init"]) .current_dir(&path) .output() @@ -222,7 +256,7 @@ pub fn git_init(path: String) -> Result<(), String> { #[tauri::command] pub fn git_branches(path: String) -> Result, String> { - let output = Command::new("git") + let output = git_cmd_base() .args(["branch", "-a"]) .current_dir(&path) .output() @@ -239,7 +273,8 @@ pub fn git_branches(path: String) -> Result, String> { #[tauri::command] pub fn git_checkout(path: String, branch: String) -> Result<(), String> { - let output = Command::new("git") + validate_ref_name(&branch)?; + let output = git_cmd_base() .args(["checkout", &branch]) .current_dir(&path) .output() @@ -253,12 +288,12 @@ pub fn git_checkout(path: String, branch: String) -> Result<(), String> { #[tauri::command] pub fn git_add_remote(path: String, url: String) -> Result<(), String> { - let _ = Command::new("git") + let _ = git_cmd_base() .args(["remote", "remove", "origin"]) .current_dir(&path) .output(); - let output = Command::new("git") + let output = git_cmd_base() .args(["remote", "add", "origin", &url]) .current_dir(&path) .output() @@ -272,7 +307,7 @@ pub fn git_add_remote(path: String, url: String) -> Result<(), String> { #[tauri::command] pub fn git_get_remote(path: String) -> Result { - let output = Command::new("git") + let output = git_cmd_base() .args(["remote", "get-url", "origin"]) .current_dir(&path) .output() @@ -287,7 +322,8 @@ pub fn git_get_remote(path: String) -> Result { #[tauri::command] pub fn git_create_branch(path: String, name: String) -> Result<(), String> { - let output = Command::new("git") + validate_ref_name(&name)?; + let output = git_cmd_base() .args(["checkout", "-b", &name]) .current_dir(&path) .output() @@ -299,9 +335,53 @@ pub fn git_create_branch(path: String, name: String) -> Result<(), String> { Ok(()) } +#[cfg(test)] +mod tests { + use super::*; + + fn run_git(repo: &std::path::Path, args: &[&str]) { + let status = git_cmd_base().args(args).current_dir(repo).output(); + assert!(status.is_ok(), "git {:?} failed to spawn", args); + } + + #[test] + fn validate_ref_rejects_option_like_names() { + assert!(validate_ref_name("--upload-pack=touch /tmp/x").is_err()); + assert!(validate_ref_name("-B").is_err()); + assert!(validate_ref_name("").is_err()); + } + + #[test] + fn validate_ref_allows_normal_branches() { + assert!(validate_ref_name("main").is_ok()); + assert!(validate_ref_name("feature/login").is_ok()); + assert!(validate_ref_name("release-2.0").is_ok()); + } + + #[test] + fn git_status_does_not_run_untrusted_fsmonitor() { + let dir = tempfile::tempdir().unwrap(); + let repo = dir.path(); + run_git(repo, &["init"]); + run_git(repo, &["config", "user.email", "t@t"]); + run_git(repo, &["config", "user.name", "t"]); + + let sentinel = repo.join("PWNED"); + let hook = format!("sh -c \"touch '{}'\"", sentinel.display()); + run_git(repo, &["config", "core.fsmonitor", &hook]); + + let _ = git_status(repo.to_string_lossy().to_string()); + + assert!( + !sentinel.exists(), + "core.fsmonitor command executed on git_status — config-driven RCE vector is open" + ); + } +} + #[tauri::command] pub fn git_stage_all(path: String) -> Result<(), String> { - let output = Command::new("git") + let output = git_cmd_base() .args(["add", "-A"]) .current_dir(&path) .output() @@ -315,7 +395,7 @@ pub fn git_stage_all(path: String) -> Result<(), String> { #[tauri::command] pub fn git_discard(path: String, file_path: String) -> Result<(), String> { - let output = Command::new("git") + let output = git_cmd_base() .args(["checkout", "--", &file_path]) .current_dir(&path) .output() From 31700363d2aacc227993a7dff77c2d5500688160 Mon Sep 17 00:00:00 2001 From: Anic888 Date: Tue, 23 Jun 2026 17:22:06 -0500 Subject: [PATCH 03/13] Lock down terminal command execution (C1, C2) Remove script interpreters (bash/zsh/python3/node/swift) from execute_command's quick-run allowlist so the metacharacter/danger filters can't be trivially bypassed. Gate pty_write_force to accept ONLY confirmation keystrokes (Enter/Ctrl-C) and ONLY when a danger warning is pending (consumed one-shot), closing the direct force-write bypass. --- src-tauri/src/commands/terminal.rs | 58 +++++++++++++++++++++++++++--- src-tauri/src/state.rs | 4 +++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/commands/terminal.rs b/src-tauri/src/commands/terminal.rs index 86f3f01..7cefaf1 100644 --- a/src-tauri/src/commands/terminal.rs +++ b/src-tauri/src/commands/terminal.rs @@ -71,6 +71,13 @@ fn check_dangerous_command(cmd: &str) -> Option { None } +/// True only if `data` consists solely of confirmation control keystrokes +/// (Enter / newline / Ctrl-C) used to answer a danger prompt. Used to ensure +/// `pty_write_force` can never be abused to inject command text. +fn is_confirmation_keystrokes(data: &str) -> bool { + !data.is_empty() && data.bytes().all(|b| matches!(b, b'\r' | b'\n' | 0x03)) +} + #[tauri::command] pub fn pty_spawn(id: String, cwd: Option, window: tauri::Window) -> Result<(), String> { let pty_system = native_pty_system(); @@ -123,6 +130,7 @@ pub fn pty_spawn(id: String, cwd: Option, window: tauri::Window) -> Resu writer: Arc::new(Mutex::new(writer)), master: Arc::new(Mutex::new(pair.master)), command_buffer: Arc::new(Mutex::new(String::new())), + pending_danger: Arc::new(Mutex::new(false)), }); } @@ -170,12 +178,15 @@ pub fn pty_write(id: String, data: String, window: tauri::Window) -> Result<(), cmd_buf.clear(); if let Some(warning) = check_dangerous_command(&command) { - // Emit warning event instead of executing + // Flag a pending confirmation (consumed one-shot by + // pty_write_force) and emit a warning instead of executing. + if let Ok(mut pd) = session.pending_danger.lock() { *pd = true; } let _ = window.emit("pty-danger-warning", (&id, &command, &warning)); return Ok(()); } - // Safe command — write all data through + // Safe command — clear any stale pending flag and write through. + if let Ok(mut pd) = session.pending_danger.lock() { *pd = false; } let mut writer = session.writer.lock().map_err(|e| e.to_string())?; writer.write_all(data.as_bytes()).map_err(|e| e.to_string())?; writer.flush().map_err(|e| e.to_string())?; @@ -213,6 +224,21 @@ pub fn pty_write(id: String, data: String, window: tauri::Window) -> Result<(), pub fn pty_write_force(id: String, data: String) -> Result<(), String> { let sessions = PTY_SESSIONS.lock().map_err(|e| e.to_string())?; if let Some(session) = sessions.get(&id) { + // Force-write may ONLY deliver the confirmation keystrokes (Enter / Ctrl-C) + // that answer a danger prompt — never arbitrary command text — and only + // when a danger warning is actually pending (consumed one-shot). This closes + // the bypass where the webview called pty_write_force directly to run a + // blocked command. + if !is_confirmation_keystrokes(&data) { + return Err("pty_write_force only accepts confirmation keystrokes".to_string()); + } + let approved = { + let mut pd = session.pending_danger.lock().map_err(|e| e.to_string())?; + std::mem::replace(&mut *pd, false) + }; + if !approved { + return Err("No pending dangerous-command confirmation for this session".to_string()); + } let mut writer = session.writer.lock().map_err(|e| e.to_string())?; writer.write_all(data.as_bytes()).map_err(|e| e.to_string())?; writer.flush().map_err(|e| e.to_string())?; @@ -272,10 +298,12 @@ pub fn execute_command(command: String, cwd: Option) -> Result = parts.collect(); + // Interpreters (bash/zsh/python3/node/swift) are deliberately excluded: they + // execute arbitrary script files and thereby defeat the metacharacter/danger + // filters above. Use the gated built-in terminal for those. let allowed_programs = [ "git", "ls", "pwd", "cat", "echo", "head", "tail", "grep", "find", "wc", - "touch", "mkdir", "cp", "mv", "rm", "python3", "node", "npm", "cargo", - "rustc", "swift", "swiftc", "bash", "zsh" + "touch", "mkdir", "cp", "mv", "rm", "npm", "cargo", "rustc", "swiftc" ]; if !allowed_programs.contains(&program) { return Err(format!("'{}' is not allowed in quick-run mode. Use the built-in terminal for unrestricted commands.", program)); @@ -365,4 +393,26 @@ mod tests { assert!(check_dangerous_command("mkdir new_dir").is_none()); assert!(check_dangerous_command("cat README.md").is_none()); } + + #[test] + fn confirmation_keystrokes_accepts_control_only() { + assert!(is_confirmation_keystrokes("\r")); + assert!(is_confirmation_keystrokes("\n")); + assert!(is_confirmation_keystrokes("\u{3}")); // Ctrl-C + } + + #[test] + fn confirmation_keystrokes_rejects_command_text() { + assert!(!is_confirmation_keystrokes("rm -rf ~\r")); + assert!(!is_confirmation_keystrokes("ls")); + assert!(!is_confirmation_keystrokes("")); // empty is not a confirmation + } + + #[test] + fn execute_command_blocks_interpreters() { + assert!(execute_command("bash evil.sh".to_string(), None).is_err()); + assert!(execute_command("zsh evil.sh".to_string(), None).is_err()); + assert!(execute_command("python3 evil.py".to_string(), None).is_err()); + assert!(execute_command("node evil.js".to_string(), None).is_err()); + } } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 8988945..9177e13 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -7,6 +7,10 @@ pub(crate) struct PtySession { pub writer: Arc>>, pub master: Arc>>, pub command_buffer: Arc>, + /// Set when a dangerous command is flagged and awaiting user confirmation. + /// `pty_write_force` consumes it one-shot so it cannot be abused to bypass + /// the safety check with arbitrary command text. + pub pending_danger: Arc>, } lazy_static::lazy_static! { From 2f92482a54c623b793072d84fb0290902c85d9bd Mon Sep 17 00:00:00 2001 From: Anic888 Date: Tue, 23 Jun 2026 17:22:06 -0500 Subject: [PATCH 04/13] Scope file_exists and cap read/search file sizes (M2, M10, H4) Route file_exists through validate_path to remove the arbitrary host-path existence oracle. Enforce a 50 MiB cap in read_file/read_file_bytes and a 5 MiB per-file cap in global_search to prevent memory-exhaustion hangs. --- src-tauri/src/commands/filesystem.rs | 14 +++++++++++++- src-tauri/src/commands/search.rs | 6 ++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands/filesystem.rs b/src-tauri/src/commands/filesystem.rs index 0f5f408..7ad51f6 100644 --- a/src-tauri/src/commands/filesystem.rs +++ b/src-tauri/src/commands/filesystem.rs @@ -2,6 +2,7 @@ use std::fs; use std::path::{Path, PathBuf}; use chrono::{DateTime, Utc}; use crate::models::FileInfo; +use crate::helpers::{exceeds_size_limit, MAX_READ_FILE_BYTES}; /// Validate and canonicalize a path, ensuring no traversal outside home directory fn validate_path(path: &str) -> Result { @@ -39,12 +40,18 @@ fn validate_path(path: &str) -> Result { #[tauri::command] pub fn read_file(path: String) -> Result { let safe_path = validate_path(&path)?; + if exceeds_size_limit(&safe_path, MAX_READ_FILE_BYTES) { + return Err(format!("File is too large to open (limit {} MB).", MAX_READ_FILE_BYTES / (1024 * 1024))); + } fs::read_to_string(&safe_path).map_err(|e| e.to_string()) } #[tauri::command] pub fn read_file_bytes(path: String) -> Result, String> { let safe_path = validate_path(&path)?; + if exceeds_size_limit(&safe_path, MAX_READ_FILE_BYTES) { + return Err(format!("File is too large to open (limit {} MB).", MAX_READ_FILE_BYTES / (1024 * 1024))); + } fs::read(&safe_path).map_err(|e| e.to_string()) } @@ -107,7 +114,12 @@ pub fn read_directory(path: String) -> Result, String> { #[tauri::command] pub fn file_exists(path: String) -> bool { - PathBuf::from(&path).exists() + // Scope-check so this cannot be used as an existence oracle for arbitrary + // host paths (e.g. /etc/passwd, other users' files). + match validate_path(&path) { + Ok(p) => p.exists(), + Err(_) => false, + } } #[tauri::command] diff --git a/src-tauri/src/commands/search.rs b/src-tauri/src/commands/search.rs index b0ed05d..382ab14 100644 --- a/src-tauri/src/commands/search.rs +++ b/src-tauri/src/commands/search.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use ignore::WalkBuilder; use rayon::prelude::*; use crate::models::{SearchMatch, SearchResult}; +use crate::helpers::{exceeds_size_limit, MAX_SEARCH_FILE_BYTES}; #[tauri::command] pub fn global_search( @@ -83,6 +84,11 @@ pub fn global_search( .flat_map(|path| { let mut matches = Vec::new(); + // Skip oversized files to bound peak memory under rayon. + if exceeds_size_limit(path, MAX_SEARCH_FILE_BYTES) { + return matches; + } + if let Ok(content) = fs::read_to_string(path) { for (line_idx, line) in content.lines().enumerate() { for mat in pattern.find_iter(line) { From bb041e8b2fb408bd5e93a5108ccf6a8d1ff4ef00 Mon Sep 17 00:00:00 2001 From: Anic888 Date: Tue, 23 Jun 2026 17:22:06 -0500 Subject: [PATCH 05/13] Redact OAuth token material from cloud errors and logs (M9) Add redact_secrets() and wrap all cloud error bodies and token-response JSON before returning them; stop logging raw error objects in CloudStoragePanel. --- src-tauri/src/commands/cloud.rs | 66 ++++++++++++++++++++++------ src/components/CloudStoragePanel.tsx | 8 ++-- 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/commands/cloud.rs b/src-tauri/src/commands/cloud.rs index 959249e..3624474 100644 --- a/src-tauri/src/commands/cloud.rs +++ b/src-tauri/src/commands/cloud.rs @@ -1,6 +1,18 @@ use std::io::{Read, Write}; use crate::models::{OAuthCallbackResult, OAuthTokenResponse, CloudFile}; +/// Redact OAuth token material from strings before they reach error messages, +/// the UI, logs or crash reports. +fn redact_secrets(input: &str) -> String { + use regex::Regex; + // JSON form: "access_token": "value" + let json_re = Regex::new(r#"(?i)"(access_token|refresh_token|id_token)"\s*:\s*"[^"]*""#).unwrap(); + let s = json_re.replace_all(input, r#""${1}":"***REDACTED***""#).into_owned(); + // form / query form: access_token=value + let form_re = Regex::new(r"(?i)(access_token|refresh_token|id_token)=[^&\s]+").unwrap(); + form_re.replace_all(&s, "${1}=***REDACTED***").into_owned() +} + #[tauri::command] pub async fn open_oauth_window(url: String, expected_state: String) -> Result { use std::net::TcpListener; @@ -167,7 +179,7 @@ pub async fn exchange_oauth_token( if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(format!("Token exchange failed ({}): {}", status, body)); + return Err(format!("Token exchange failed ({}): {}", status, redact_secrets(&body))); } let data: serde_json::Value = response @@ -178,7 +190,7 @@ pub async fn exchange_oauth_token( let access_token = data["access_token"] .as_str() .map(|s| s.to_string()) - .ok_or_else(|| format!("No access_token in response: {}", data))?; + .ok_or_else(|| format!("No access_token in response: {}", redact_secrets(&data.to_string())))?; Ok(OAuthTokenResponse { access_token, @@ -240,7 +252,7 @@ pub async fn refresh_oauth_token( if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(format!("Token refresh failed ({}): {}", status, body)); + return Err(format!("Token refresh failed ({}): {}", status, redact_secrets(&body))); } let data: serde_json::Value = response @@ -251,7 +263,7 @@ pub async fn refresh_oauth_token( let access_token = data["access_token"] .as_str() .map(|s| s.to_string()) - .ok_or_else(|| format!("No access_token in refresh response: {}", data))?; + .ok_or_else(|| format!("No access_token in refresh response: {}", redact_secrets(&data.to_string())))?; Ok(OAuthTokenResponse { access_token, @@ -288,7 +300,7 @@ pub async fn cloud_list_files(provider: String, token: String, path: String) -> if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(format!("Google Drive API error ({}): {}", status, body)); + return Err(format!("Google Drive API error ({}): {}", status, redact_secrets(&body))); } let data: serde_json::Value = response.json().await.map_err(|e| e.to_string())?; @@ -324,7 +336,7 @@ pub async fn cloud_list_files(provider: String, token: String, path: String) -> if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(format!("Dropbox API error ({}): {}", status, body)); + return Err(format!("Dropbox API error ({}): {}", status, redact_secrets(&body))); } let data: serde_json::Value = response.json().await.map_err(|e| e.to_string())?; @@ -360,7 +372,7 @@ pub async fn cloud_list_files(provider: String, token: String, path: String) -> if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(format!("OneDrive API error ({}): {}", status, body)); + return Err(format!("OneDrive API error ({}): {}", status, redact_secrets(&body))); } let data: serde_json::Value = response.json().await.map_err(|e| e.to_string())?; @@ -424,7 +436,7 @@ pub async fn cloud_upload_file( if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(format!("Google Drive upload failed ({}): {}", status, body)); + return Err(format!("Google Drive upload failed ({}): {}", status, redact_secrets(&body))); } } "dropbox" => { @@ -450,7 +462,7 @@ pub async fn cloud_upload_file( if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(format!("Dropbox upload failed ({}): {}", status, body)); + return Err(format!("Dropbox upload failed ({}): {}", status, redact_secrets(&body))); } } "onedrive" => { @@ -472,7 +484,7 @@ pub async fn cloud_upload_file( if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(format!("OneDrive upload failed ({}): {}", status, body)); + return Err(format!("OneDrive upload failed ({}): {}", status, redact_secrets(&body))); } } _ => return Err("Unknown provider".to_string()), @@ -499,7 +511,7 @@ pub async fn cloud_download_file(provider: String, token: String, file_id: Strin if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(format!("Google Drive download failed ({}): {}", status, body)); + return Err(format!("Google Drive download failed ({}): {}", status, redact_secrets(&body))); } response.text().await.map_err(|e| e.to_string())? @@ -518,7 +530,7 @@ pub async fn cloud_download_file(provider: String, token: String, file_id: Strin if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(format!("Dropbox download failed ({}): {}", status, body)); + return Err(format!("Dropbox download failed ({}): {}", status, redact_secrets(&body))); } response.text().await.map_err(|e| e.to_string())? @@ -536,7 +548,7 @@ pub async fn cloud_download_file(provider: String, token: String, file_id: Strin if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(format!("OneDrive download failed ({}): {}", status, body)); + return Err(format!("OneDrive download failed ({}): {}", status, redact_secrets(&body))); } response.text().await.map_err(|e| e.to_string())? @@ -546,3 +558,31 @@ pub async fn cloud_download_file(provider: String, token: String, file_id: Strin Ok(content) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn redacts_json_tokens() { + let s = r#"{"access_token":"SECRET123","expires_in":3600,"refresh_token":"REFRESH456"}"#; + let out = redact_secrets(s); + assert!(!out.contains("SECRET123"), "access_token leaked: {}", out); + assert!(!out.contains("REFRESH456"), "refresh_token leaked: {}", out); + assert!(out.contains("3600"), "non-secret field should be preserved"); + } + + #[test] + fn redacts_form_tokens() { + let s = "grant_type=authorization_code&access_token=ABCDEF123&foo=bar"; + let out = redact_secrets(s); + assert!(!out.contains("ABCDEF123"), "form token leaked: {}", out); + assert!(out.contains("foo=bar")); + } + + #[test] + fn leaves_clean_text_untouched() { + let s = "Token exchange failed (400): invalid_grant"; + assert_eq!(redact_secrets(s), s); + } +} diff --git a/src/components/CloudStoragePanel.tsx b/src/components/CloudStoragePanel.tsx index 8339fb4..3fb4d5f 100644 --- a/src/components/CloudStoragePanel.tsx +++ b/src/components/CloudStoragePanel.tsx @@ -244,7 +244,7 @@ function CloudStoragePanel({ onClose, currentFilePath, currentFileContent, onFil setActiveProvider(provider); await loadFiles(provider); } catch (error) { - console.error('OAuth error:', error); + console.error('OAuth error'); alert(`Failed to connect to ${PROVIDERS[provider].name}: ${error}`); } @@ -291,7 +291,7 @@ function CloudStoragePanel({ onClose, currentFilePath, currentFileContent, onFil setFiles(fileList); setCurrentPath(path); } catch (error) { - console.error('Load files error:', error); + console.error('Load files error'); alert(`Failed to load files: ${error}`); } @@ -332,7 +332,7 @@ function CloudStoragePanel({ onClose, currentFilePath, currentFileContent, onFil alert(`Uploaded ${fileName} to ${PROVIDERS[activeProvider].name}`); await loadFiles(activeProvider, currentPath); } catch (error) { - console.error('Upload error:', error); + console.error('Upload error'); alert(`Failed to upload: ${error}`); } @@ -437,7 +437,7 @@ function CloudStoragePanel({ onClose, currentFilePath, currentFileContent, onFil onFileLoaded(content, file.name); onClose(); } catch (error) { - console.error('Download error:', error); + console.error('Download error'); alert(`Failed to download: ${error}`); } From 2ee346ffa10c2c420633a65c3a62d4d5b55ba1d2 Mon Sep 17 00:00:00 2001 From: Anic888 Date: Tue, 23 Jun 2026 17:29:34 -0500 Subject: [PATCH 06/13] Add PKCE (S256) to cloud OAuth authorization-code flow (M8) generate_pkce() command returns a random verifier + base64url(SHA-256) challenge (verified against the RFC 7636 test vector). The frontend sends code_challenge/code_challenge_method=S256 in the auth URL for all three providers and passes the verifier to exchange_oauth_token, which binds it into the token request. An intercepted loopback authorization code can no longer be redeemed without the verifier. --- src-tauri/src/commands/cloud.rs | 49 +++++++++++++++++++++++++++- src-tauri/src/main.rs | 1 + src/components/CloudStoragePanel.tsx | 15 +++++---- 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/commands/cloud.rs b/src-tauri/src/commands/cloud.rs index 3624474..4959a09 100644 --- a/src-tauri/src/commands/cloud.rs +++ b/src-tauri/src/commands/cloud.rs @@ -13,6 +13,31 @@ fn redact_secrets(input: &str) -> String { form_re.replace_all(&s, "${1}=***REDACTED***").into_owned() } +#[derive(serde::Serialize)] +pub struct PkcePair { + pub verifier: String, + pub challenge: String, +} + +/// RFC 7636 S256 transform: base64url(SHA-256(verifier)), no padding. +fn pkce_challenge(verifier: &str) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(verifier.as_bytes()); + base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, hasher.finalize()) +} + +/// Generate a PKCE (verifier, S256 challenge) pair for an OAuth authorization-code flow. +#[tauri::command] +pub fn generate_pkce() -> PkcePair { + use rand::RngCore; + let mut bytes = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut bytes); // 32 bytes -> 43-char base64url verifier + let verifier = base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, bytes); + let challenge = pkce_challenge(&verifier); + PkcePair { verifier, challenge } +} + #[tauri::command] pub async fn open_oauth_window(url: String, expected_state: String) -> Result { use std::net::TcpListener; @@ -131,10 +156,11 @@ pub async fn exchange_oauth_token( client_id: String, client_secret: String, redirect_uri: String, + code_verifier: Option, ) -> Result { let client = reqwest::Client::new(); - let (token_url, params) = match provider.as_str() { + let (token_url, mut params) = match provider.as_str() { "google" => ( "https://oauth2.googleapis.com/token", vec![ @@ -169,6 +195,12 @@ pub async fn exchange_oauth_token( _ => return Err("Unknown provider".to_string()), }; + // PKCE: bind the authorization code to the verifier so an intercepted + // loopback code cannot be redeemed without it. + if let Some(ref verifier) = code_verifier { + params.push(("code_verifier", verifier.as_str())); + } + let response = client .post(token_url) .form(¶ms) @@ -585,4 +617,19 @@ mod tests { let s = "Token exchange failed (400): invalid_grant"; assert_eq!(redact_secrets(s), s); } + + #[test] + fn pkce_challenge_matches_rfc7636_vector() { + // RFC 7636 Appendix B + let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + assert_eq!(pkce_challenge(verifier), "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); + } + + #[test] + fn generate_pkce_pair_is_consistent_and_random() { + let pair = generate_pkce(); + assert!(pair.verifier.len() >= 43 && pair.verifier.len() <= 128, "verifier length {}", pair.verifier.len()); + assert_eq!(pkce_challenge(&pair.verifier), pair.challenge); + assert_ne!(generate_pkce().verifier, pair.verifier); + } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 21d8d4e..344d9ef 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -222,6 +222,7 @@ fn main() { commands::search::global_search, commands::search::search_in_file, // Cloud Storage + commands::cloud::generate_pkce, commands::cloud::open_oauth_window, commands::cloud::exchange_oauth_token, commands::cloud::refresh_oauth_token, diff --git a/src/components/CloudStoragePanel.tsx b/src/components/CloudStoragePanel.tsx index 3fb4d5f..bab5da7 100644 --- a/src/components/CloudStoragePanel.tsx +++ b/src/components/CloudStoragePanel.tsx @@ -213,7 +213,8 @@ function CloudStoragePanel({ onClose, currentFilePath, currentFileContent, onFil try { const state = crypto.randomUUID(); - const authUrl = getAuthUrl(provider, creds.clientId, state); + const pkce = await invoke<{ verifier: string; challenge: string }>('generate_pkce'); + const authUrl = getAuthUrl(provider, creds.clientId, state, pkce.challenge); const callback = await invoke('open_oauth_window', { url: authUrl, @@ -226,6 +227,7 @@ function CloudStoragePanel({ onClose, currentFilePath, currentFileContent, onFil clientId: creds.clientId, clientSecret: creds.clientSecret, redirectUri: callback.redirect_uri, + codeVerifier: pkce.verifier, }); const tokenPayload: StoredOAuthToken = { @@ -251,16 +253,17 @@ function CloudStoragePanel({ onClose, currentFilePath, currentFileContent, onFil setLoading(false); }; - const getAuthUrl = (provider: Provider, clientId: string, state: string): string => { + const getAuthUrl = (provider: Provider, clientId: string, state: string, codeChallenge: string): string => { const redirectUri = 'http://localhost:8765/callback'; - + const pkce = `&code_challenge=${codeChallenge}&code_challenge_method=S256`; + switch (provider) { case 'google': - return `https://accounts.google.com/o/oauth2/v2/auth?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&scope=https://www.googleapis.com/auth/drive.file&access_type=offline&prompt=consent&state=${state}`; + return `https://accounts.google.com/o/oauth2/v2/auth?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&scope=https://www.googleapis.com/auth/drive.file&access_type=offline&prompt=consent&state=${state}${pkce}`; case 'dropbox': - return `https://www.dropbox.com/oauth2/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&token_access_type=offline&state=${state}`; + return `https://www.dropbox.com/oauth2/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&token_access_type=offline&state=${state}${pkce}`; case 'onedrive': - return `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&scope=Files.ReadWrite%20offline_access&state=${state}`; + return `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&scope=Files.ReadWrite%20offline_access&state=${state}${pkce}`; default: return ''; } From 19b50cfff23b25c1723fe41a24654f4171a74667 Mon Sep 17 00:00:00 2001 From: Anic888 Date: Tue, 23 Jun 2026 17:38:55 -0500 Subject: [PATCH 07/13] Add signed+notarized release pipeline and auto-updater (C4, H3) GitHub Actions release.yml (tauri-action) builds a universal macOS binary, signs it with Developer ID, notarizes+staples, and publishes a draft Release with the updater bundle + latest.json. Enable the Tauri updater (active + S256 pubkey + GitHub 'latest' endpoint) and add the 'updater' cargo feature. Add Hardened-Runtime Entitlements.plist (un-ignored so CI can use it). Full secret/setup checklist in docs/distribution-setup.md. Signing/notarization run in CI with the maintainer's Apple credentials; not verifiable here. --- .github/workflows/release.yml | 60 +++++++++++++++++++++++++ .gitignore | 1 - docs/distribution-setup.md | 84 +++++++++++++++++++++++++++++++++++ src-tauri/Cargo.lock | 22 +++++++++ src-tauri/Cargo.toml | 2 +- src-tauri/Entitlements.plist | 14 ++++++ src-tauri/tauri.conf.json | 7 ++- 7 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 docs/distribution-setup.md create mode 100644 src-tauri/Entitlements.plist diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..197c6b7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,60 @@ +name: Release + +# Cut a release by pushing a version tag, e.g. +# git tag v2.0.1 && git push origin v2.0.1 +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + release: + permissions: + contents: write + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin,x86_64-apple-darwin + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + + - name: Install frontend dependencies + run: npm install + + - name: Build, sign, notarize & publish + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # --- Code signing (Developer ID Application) --- + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + # --- Notarization (Apple ID + app-specific password) --- + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + # --- Updater artifact signing --- + TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} + TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + with: + tagName: ${{ github.ref_name }} + releaseName: 'RocketNote ${{ github.ref_name }}' + releaseBody: 'Download the .dmg below. The app is signed and notarized; existing installs update automatically.' + releaseDraft: true + prerelease: false + # Universal binary (Apple Silicon + Intel). tauri-action emits the + # signed updater bundle + latest.json and attaches them to the release. + args: --target universal-apple-darwin diff --git a/.gitignore b/.gitignore index 819dd69..e05e87b 100644 --- a/.gitignore +++ b/.gitignore @@ -31,5 +31,4 @@ backup-*/ icon.iconset/ fix-icons.sh generate-icons.sh -src-tauri/Entitlements.plist src-tauri/Info.plist diff --git a/docs/distribution-setup.md b/docs/distribution-setup.md new file mode 100644 index 0000000..02b5652 --- /dev/null +++ b/docs/distribution-setup.md @@ -0,0 +1,84 @@ +# RocketNote — Distribution Setup (signing · notarization · auto-update) + +This closes audit findings **C4** (unsigned/un-notarized macOS build) and **H3** (no update channel). Everything below is wired in `.github/workflows/release.yml`; you only need to add the GitHub **secrets** and cut a tag. + +> The build is signed + notarized **in CI** with your Apple credentials. Nothing here can be verified on a machine without your Apple Developer account, so treat this as a checklist, not a finished build. + +--- + +## 1. One-time: Apple code-signing identity + +1. In the Apple Developer portal create a **Developer ID Application** certificate (this is the cert for apps distributed *outside* the App Store). +2. In **Keychain Access** → export it (with its private key) as a `.p12` file, set an export password. +3. Base64-encode it for the secret: + ```sh + base64 -i DeveloperID.p12 | pbcopy + ``` +4. Find the exact identity string: + ```sh + security find-identity -v -p codesigning + # e.g. "Developer ID Application: Your Name (TEAMID1234)" + ``` + +## 2. One-time: notarization credentials + +- Use your **Apple ID** + an **app-specific password** (appleid.apple.com → Sign-In and Security → App-Specific Passwords). +- Your **Team ID** is the 10-char code in the identity above / in the developer portal membership page. + +## 3. Updater signing key — already generated ✅ + +A Tauri updater keypair was generated for you: + +- **Public key** is already committed in `src-tauri/tauri.conf.json` → `updater.pubkey`. +- **Private key** is at `~/.tauri/rocketnote_updater.key` on this machine (it was created with an **empty** password). + +Add its *contents* as the `TAURI_PRIVATE_KEY` secret: +```sh +cat ~/.tauri/rocketnote_updater.key | pbcopy +``` +> Prefer to control the key yourself? Regenerate with +> `cargo tauri signer generate --ci -w ~/.tauri/rocketnote_updater.key -f`, +> then paste the **new** public key into `updater.pubkey`. Keep the private key out of git. + +## 4. GitHub repository secrets + +Settings → Secrets and variables → Actions → **New repository secret**: + +| Secret | Value | +|---|---| +| `APPLE_CERTIFICATE` | base64 of the `.p12` (step 1.3) | +| `APPLE_CERTIFICATE_PASSWORD` | the `.p12` export password | +| `APPLE_SIGNING_IDENTITY` | `Developer ID Application: Your Name (TEAMID)` | +| `APPLE_ID` | your Apple ID email | +| `APPLE_PASSWORD` | the app-specific password | +| `APPLE_TEAM_ID` | your 10-char Team ID | +| `TAURI_PRIVATE_KEY` | contents of `~/.tauri/rocketnote_updater.key` | +| `TAURI_KEY_PASSWORD` | empty string (the key has no password) | + +`GITHUB_TOKEN` is provided automatically — do not add it. + +## 5. Cut a release + +```sh +git tag v2.0.1 +git push origin v2.0.1 +``` + +The workflow builds a **universal** (Apple Silicon + Intel) binary, signs it with Developer ID, notarizes + staples it, and creates a **draft** GitHub Release with: +- `RocketNote_*.dmg` — the installer users download; +- `RocketNote.app.tar.gz` + `RocketNote.app.tar.gz.sig` — the signed update bundle; +- `latest.json` — the updater manifest. + +Review the draft, then **Publish**. Publishing makes it the `latest` release, which is exactly what `updater.endpoints` points to: +`https://github.com/Anic888/rocketnote/releases/latest/download/latest.json`. + +## 6. How auto-update works after this + +On launch the app fetches `latest.json`, compares versions, verifies the bundle signature against the embedded `pubkey`, and (because `updater.dialog = true`) prompts the user to install. Ship a fix by bumping the version in `package.json` + `src-tauri/tauri.conf.json` and pushing a new tag. + +## Notes / gotchas + +- Bump the version in **both** `package.json` and `src-tauri/tauri.conf.json` (`package.version`) before tagging; the tag and the config version should match. +- First notarization can take a few minutes; tauri-action waits for it. +- If notarization fails on "hardened runtime" entitlements, the exceptions live in `src-tauri/Entitlements.plist`. +- `package-lock.json` is git-ignored, so CI uses `npm install` (not `npm ci`). diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f4ef02b..fbc6637 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2310,6 +2310,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -4176,6 +4182,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae1f57c291a6ab8e1d2e6b8ad0a35ff769c9925deb8a89de85425ff08762d0c" dependencies = [ "anyhow", + "base64 0.22.1", "bytes", "cocoa", "dirs-next", @@ -4192,7 +4199,9 @@ dependencies = [ "http", "ignore", "indexmap 1.9.3", + "infer", "log", + "minisign-verify", "nix 0.26.4", "notify-rust", "objc", @@ -4220,12 +4229,14 @@ dependencies = [ "tauri-utils", "tempfile", "thiserror 1.0.69", + "time", "tokio", "url", "uuid", "webkit2gtk", "webview2-com", "windows 0.39.0", + "zip", ] [[package]] @@ -6095,6 +6106,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "byteorder", + "crc32fast", + "crossbeam-utils", +] + [[package]] name = "zmij" version = "1.0.19" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9455ccc..2c1062f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -11,7 +11,7 @@ edition = "2021" tauri-build = { version = "1.5", features = [] } [dependencies] -tauri = { version = "1.5", features = ["dialog-all", "fs-all", "path-all", "shell-open", "shell-execute", "window-all", "clipboard-all", "notification-all", "http-request"] } +tauri = { version = "1.5", features = ["updater", "dialog-all", "fs-all", "path-all", "shell-open", "shell-execute", "window-all", "clipboard-all", "notification-all", "http-request"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" chrono = "0.4" diff --git a/src-tauri/Entitlements.plist b/src-tauri/Entitlements.plist new file mode 100644 index 0000000..1543f50 --- /dev/null +++ b/src-tauri/Entitlements.plist @@ -0,0 +1,14 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + + com.apple.security.cs.disable-library-validation + + + diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 878590f..d2ebcd8 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -104,7 +104,12 @@ "csp": "default-src 'self' data: blob:; script-src 'self' blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' https://api.openai.com https://api.anthropic.com https://api.deepseek.com https://www.googleapis.com https://api.dropboxapi.com https://content.dropboxapi.com https://graph.microsoft.com; img-src 'self' data: blob:; font-src 'self' data:; worker-src 'self' blob:;" }, "updater": { - "active": false + "active": true, + "dialog": true, + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDNFQ0MwNkIwN0EyMkU1ODEKUldTQjVTSjZzQWJNUHIwZTYwYkNWSlhzNmpsa01qb251Y2JjOFFjSWVoTFVYRTU0eWpkNFF0TmsK", + "endpoints": [ + "https://github.com/Anic888/rocketnote/releases/latest/download/latest.json" + ] }, "windows": [ { From 35859006bfb70602bfb646726d59634e1e5c99d7 Mon Sep 17 00:00:00 2001 From: Anic888 Date: Tue, 23 Jun 2026 17:51:24 -0500 Subject: [PATCH 08/13] =?UTF-8?q?Replace=20textarea=20editor=20with=20Code?= =?UTF-8?q?Mirror=206=20(virtualized)=20=E2=80=94=20fixes=20large-file=20f?= =?UTF-8?q?reeze=20(H4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old editor rendered the entire document as a highlight overlay + one DOM node per line, freezing the UI on multi-MB / many-line files. CodeMirror 6 virtualizes the viewport. Verified in-browser: a 50,000-line document keeps only ~82 line nodes in the DOM. Preserves the full EditorProps/EditorRef contract (App.tsx untouched): line numbers, per-language syntax highlighting, find/replace + navigate via @codemirror/search, native undo/redo history, word wrap, tab size, Cmd-wheel font zoom, theme, and the minimap. tsc + vite build pass. Note: bundle grew (~1.7MB) from CodeMirror + language packages; code-splitting is a separate optimization. --- package.json | 20 ++ src/components/Editor.css | 14 + src/components/Editor.tsx | 567 ++++++++++++++------------------------ 3 files changed, 245 insertions(+), 356 deletions(-) diff --git a/package.json b/package.json index 6deb859..5229493 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,27 @@ "tauri": "tauri" }, "dependencies": { + "@codemirror/commands": "^6.10.4", + "@codemirror/lang-cpp": "^6.0.3", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-java": "^6.0.2", + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/lang-php": "^6.0.2", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/lang-rust": "^6.0.2", + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/lang-xml": "^6.1.0", + "@codemirror/lang-yaml": "^6.1.3", + "@codemirror/language": "^6.12.3", + "@codemirror/search": "^6.7.1", + "@codemirror/state": "^6.7.0", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.43.2", "@tauri-apps/api": "^1.5.3", + "codemirror": "^6.0.2", "html-to-image": "^1.11.11", "lucide-react": "^0.577.0", "react": "^18.2.0", diff --git a/src/components/Editor.css b/src/components/Editor.css index febd93a..d230564 100644 --- a/src/components/Editor.css +++ b/src/components/Editor.css @@ -12,6 +12,20 @@ background: #ffffff; } +/* CodeMirror 6 host — fills the wrapper; CM6 virtualizes the viewport (H4 fix). */ +.editor-cm-host { + flex: 1; + min-width: 0; + height: 100%; + overflow: hidden; +} +.editor-cm-host .cm-editor { + height: 100%; +} +.editor-cm-host .cm-scroller { + overflow: auto; +} + /* Line numbers */ .line-numbers { display: flex; diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 7d6d676..37fa7c0 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -1,5 +1,27 @@ -import { forwardRef, useImperativeHandle, useRef, useEffect, useState, useCallback, useMemo } from 'react'; +import { forwardRef, useImperativeHandle, useRef, useEffect, useState, useCallback } from 'react'; import { Tab, EditorSettings } from '../types'; +import { EditorState, Compartment, Extension } from '@codemirror/state'; +import { + EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, + drawSelection, dropCursor, +} from '@codemirror/view'; +import { defaultKeymap, history, historyKeymap, indentWithTab, undo, redo } from '@codemirror/commands'; +import { searchKeymap, highlightSelectionMatches, search, findNext, findPrevious, setSearchQuery, SearchQuery } from '@codemirror/search'; +import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, indentUnit } from '@codemirror/language'; +import { oneDark } from '@codemirror/theme-one-dark'; +import { javascript } from '@codemirror/lang-javascript'; +import { html } from '@codemirror/lang-html'; +import { css } from '@codemirror/lang-css'; +import { json } from '@codemirror/lang-json'; +import { python } from '@codemirror/lang-python'; +import { rust } from '@codemirror/lang-rust'; +import { java } from '@codemirror/lang-java'; +import { cpp } from '@codemirror/lang-cpp'; +import { php } from '@codemirror/lang-php'; +import { markdown } from '@codemirror/lang-markdown'; +import { xml } from '@codemirror/lang-xml'; +import { sql } from '@codemirror/lang-sql'; +import { yaml } from '@codemirror/lang-yaml'; import './Editor.css'; interface EditorProps { @@ -23,415 +45,248 @@ export interface EditorRef { navigateSearch: (direction: 'next' | 'prev', query: string, caseSensitive: boolean, wholeWord: boolean, useRegex: boolean) => void; } -const Editor = forwardRef(({ - tab, - settings, - onChange, +function languageExtension(lang: string): Extension { + switch (lang) { + case 'javascript': return javascript({ jsx: true }); + case 'typescript': return javascript({ jsx: true, typescript: true }); + case 'html': case 'vue': case 'svelte': return html(); + case 'css': case 'scss': case 'less': return css(); + case 'json': return json(); + case 'python': return python(); + case 'rust': return rust(); + case 'java': case 'kotlin': return java(); + case 'c': case 'cpp': return cpp(); + case 'php': return php(); + case 'markdown': return markdown(); + case 'xml': return xml(); + case 'sql': case 'pgsql': case 'mysql': return sql(); + case 'yaml': return yaml(); + default: return []; + } +} + +const fontTheme = (size: number) => EditorView.theme({ + '&': { height: '100%', fontSize: `${size}px` }, + '.cm-scroller': { + fontFamily: "'SF Mono', 'Monaco', 'Menlo', 'Courier New', monospace", + lineHeight: '1.5', + }, +}); + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +const Editor = forwardRef(({ + tab, + settings, + onChange, onCursorChange, searchQuery = '', searchCaseSensitive = false, searchWholeWord = false, searchUseRegex = false, }, ref) => { - const textareaRef = useRef(null); - const lineNumbersRef = useRef(null); - const highlightRef = useRef(null); + const hostRef = useRef(null); + const viewRef = useRef(null); const minimapRef = useRef(null); - const [lineCount, setLineCount] = useState(1); const [fontSize, setFontSize] = useState(settings.fontSize); const [minimapScroll, setMinimapScroll] = useState(0); - const [currentMatchIndex, setCurrentMatchIndex] = useState(0); - const [matchPositions, setMatchPositions] = useState([]); - - // Undo/Redo stack - const undoStackRef = useRef<{ content: string; cursorPos: number }[]>([]); - const redoStackRef = useRef<{ content: string; cursorPos: number }[]>([]); - const lastPushedContentRef = useRef(tab.content); - - // Push to undo stack on significant changes (debounced) - const pushUndoRef = useRef | null>(null); - const pushUndo = useCallback((content: string, cursorPos: number) => { - if (pushUndoRef.current) clearTimeout(pushUndoRef.current); - pushUndoRef.current = setTimeout(() => { - if (content !== lastPushedContentRef.current) { - undoStackRef.current.push({ content: lastPushedContentRef.current, cursorPos }); - // Limit stack size - if (undoStackRef.current.length > 200) undoStackRef.current.shift(); - redoStackRef.current = []; // Clear redo on new change - lastPushedContentRef.current = content; - } - }, 300); - }, []); - - // Reset undo stack when switching tabs - useEffect(() => { - undoStackRef.current = []; - redoStackRef.current = []; - lastPushedContentRef.current = tab.content; - }, [tab.id]); - - // Sync fontSize with settings - useEffect(() => { - setFontSize(settings.fontSize); - }, [settings.fontSize]); - - // Calculate line numbers - useEffect(() => { - const lines = tab.content.split('\n').length; - setLineCount(lines); - }, [tab.content]); + const [minimapViewport, setMinimapViewport] = useState(20); - // Calculate match positions for navigation - useEffect(() => { - if (!searchQuery) { - setMatchPositions([]); - setCurrentMatchIndex(0); - return; - } + // Keep latest callbacks in refs so the (once-created) update listener never goes stale. + const onChangeRef = useRef(onChange); + const onCursorRef = useRef(onCursorChange); + onChangeRef.current = onChange; + onCursorRef.current = onCursorChange; - try { - let pattern = searchQuery; - if (!searchUseRegex) { - pattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - } - if (searchWholeWord) { - pattern = `\\b${pattern}\\b`; - } - - const flags = searchCaseSensitive ? 'g' : 'gi'; - const regex = new RegExp(pattern, flags); - const positions: number[] = []; - let match; - - while ((match = regex.exec(tab.content)) !== null) { - positions.push(match.index); - } - - setMatchPositions(positions); - if (positions.length > 0 && currentMatchIndex === 0) { - setCurrentMatchIndex(1); - } - } catch { - setMatchPositions([]); - } - }, [searchQuery, tab.content, searchCaseSensitive, searchWholeWord, searchUseRegex]); + // Compartments for settings that can change without recreating the view. + const cmp = useRef({ + gutter: new Compartment(), + lang: new Compartment(), + theme: new Compartment(), + wrap: new Compartment(), + tab: new Compartment(), + font: new Compartment(), + hist: new Compartment(), + }).current; - // Sync scroll between line numbers, textarea and highlight - const handleScroll = useCallback(() => { - if (textareaRef.current) { - if (lineNumbersRef.current) { - lineNumbersRef.current.scrollTop = textareaRef.current.scrollTop; - } - if (highlightRef.current) { - highlightRef.current.scrollTop = textareaRef.current.scrollTop; - highlightRef.current.scrollLeft = textareaRef.current.scrollLeft; - } - - const scrollPercent = textareaRef.current.scrollTop / - (textareaRef.current.scrollHeight - textareaRef.current.clientHeight); - setMinimapScroll(isNaN(scrollPercent) ? 0 : scrollPercent); - } + const refreshMinimap = useCallback(() => { + const view = viewRef.current; + if (!view) return; + const sc = view.scrollDOM; + const denom = sc.scrollHeight - sc.clientHeight; + setMinimapScroll(denom > 0 ? sc.scrollTop / denom : 0); + setMinimapViewport(sc.scrollHeight > 0 ? Math.max(20, (sc.clientHeight / sc.scrollHeight) * 100) : 20); }, []); - // Handle content change - const handleChange = useCallback((e: React.ChangeEvent) => { - const newContent = e.target.value; - pushUndo(newContent, e.target.selectionStart); - onChange(newContent); - }, [onChange, pushUndo]); + // Create the editor once. + useEffect(() => { + if (!hostRef.current) return; + const s = settings; + const state = EditorState.create({ + doc: tab.content, + extensions: [ + cmp.gutter.of(s.lineNumbers !== 'off' ? lineNumbers() : []), + highlightActiveLineGutter(), + cmp.hist.of(history()), + drawSelection(), + dropCursor(), + EditorState.allowMultipleSelections.of(true), + indentOnInput(), + syntaxHighlighting(defaultHighlightStyle, { fallback: true }), + bracketMatching(), + highlightActiveLine(), + highlightSelectionMatches(), + search({ top: true }), + keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, indentWithTab]), + cmp.tab.of([EditorState.tabSize.of(s.tabSize), indentUnit.of(' '.repeat(s.tabSize))]), + cmp.wrap.of(s.wordWrap === 'on' ? EditorView.lineWrapping : []), + cmp.lang.of(languageExtension(tab.language)), + cmp.theme.of(s.theme === 'dark' ? oneDark : []), + cmp.font.of(fontTheme(s.fontSize)), + EditorView.updateListener.of((u) => { + if (u.docChanged) onChangeRef.current(u.state.doc.toString()); + if (u.selectionSet || u.docChanged) { + const pos = u.state.selection.main.head; + const line = u.state.doc.lineAt(pos); + onCursorRef.current({ line: line.number, column: pos - line.from + 1 }); + } + }), + EditorView.domEventHandlers({ scroll: () => { refreshMinimap(); } }), + ], + }); + const view = new EditorView({ state, parent: hostRef.current }); + viewRef.current = view; + refreshMinimap(); + return () => { view.destroy(); viewRef.current = null; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - // Handle cursor position - const handleSelect = useCallback(() => { - if (textareaRef.current) { - const text = textareaRef.current.value; - const selectionStart = textareaRef.current.selectionStart; - - const textBeforeCursor = text.substring(0, selectionStart); - const lines = textBeforeCursor.split('\n'); - const line = lines.length; - const column = lines[lines.length - 1].length + 1; - - onCursorChange({ line, column }); + // External content changes (toolbar undo/redo, replace, programmatic) — sync if different. + useEffect(() => { + const view = viewRef.current; + if (!view) return; + const cur = view.state.doc.toString(); + if (cur !== tab.content) { + view.dispatch({ changes: { from: 0, to: cur.length, insert: tab.content } }); + refreshMinimap(); } - }, [onCursorChange]); + }, [tab.content, refreshMinimap]); - // Handle Tab key - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Tab') { - e.preventDefault(); - const textarea = textareaRef.current; - if (!textarea) return; + // Switching tabs: reset undo history for the new buffer. + useEffect(() => { + viewRef.current?.dispatch({ effects: cmp.hist.reconfigure(history()) }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tab.id]); - const start = textarea.selectionStart; - const end = textarea.selectionEnd; - const spaces = ' '.repeat(settings.tabSize); + // Reconfigure on setting / language changes. + useEffect(() => { viewRef.current?.dispatch({ effects: cmp.lang.reconfigure(languageExtension(tab.language)) }); }, [tab.language, cmp.lang]); + useEffect(() => { viewRef.current?.dispatch({ effects: cmp.theme.reconfigure(settings.theme === 'dark' ? oneDark : []) }); }, [settings.theme, cmp.theme]); + useEffect(() => { viewRef.current?.dispatch({ effects: cmp.wrap.reconfigure(settings.wordWrap === 'on' ? EditorView.lineWrapping : []) }); }, [settings.wordWrap, cmp.wrap]); + useEffect(() => { viewRef.current?.dispatch({ effects: cmp.gutter.reconfigure(settings.lineNumbers !== 'off' ? lineNumbers() : []) }); }, [settings.lineNumbers, cmp.gutter]); + useEffect(() => { viewRef.current?.dispatch({ effects: cmp.tab.reconfigure([EditorState.tabSize.of(settings.tabSize), indentUnit.of(' '.repeat(settings.tabSize))]) }); }, [settings.tabSize, cmp.tab]); - const newValue = tab.content.substring(0, start) + spaces + tab.content.substring(end); - onChange(newValue); + useEffect(() => { setFontSize(settings.fontSize); }, [settings.fontSize]); + useEffect(() => { viewRef.current?.dispatch({ effects: cmp.font.reconfigure(fontTheme(fontSize)) }); }, [fontSize, cmp.font]); - requestAnimationFrame(() => { - textarea.selectionStart = textarea.selectionEnd = start + settings.tabSize; - }); - } - }, [tab.content, settings.tabSize, onChange]); + // Live search highlighting driven by the app's find bar. + useEffect(() => { + viewRef.current?.dispatch({ + effects: setSearchQuery.of(new SearchQuery({ + search: searchQuery || '', + caseSensitive: searchCaseSensitive, + regexp: searchUseRegex, + wholeWord: searchWholeWord, + })), + }); + }, [searchQuery, searchCaseSensitive, searchWholeWord, searchUseRegex]); - // Handle zoom with Cmd/Ctrl + mouse wheel + // Cmd/Ctrl + wheel zoom. const handleWheel = useCallback((e: React.WheelEvent) => { if (e.metaKey || e.ctrlKey) { e.preventDefault(); - const delta = e.deltaY > 0 ? -1 : 1; - setFontSize(prev => Math.min(32, Math.max(10, prev + delta))); + setFontSize(prev => Math.min(32, Math.max(10, prev + (e.deltaY > 0 ? -1 : 1)))); } }, []); - // Click on minimap to scroll const handleMinimapClick = useCallback((e: React.MouseEvent) => { - if (textareaRef.current && minimapRef.current) { - const rect = minimapRef.current.getBoundingClientRect(); - const clickPercent = (e.clientY - rect.top) / rect.height; - const scrollTarget = clickPercent * (textareaRef.current.scrollHeight - textareaRef.current.clientHeight); - textareaRef.current.scrollTop = scrollTarget; - } - }, []); + const view = viewRef.current; + if (!view || !minimapRef.current) return; + const rect = minimapRef.current.getBoundingClientRect(); + const pct = (e.clientY - rect.top) / rect.height; + view.scrollDOM.scrollTop = pct * (view.scrollDOM.scrollHeight - view.scrollDOM.clientHeight); + refreshMinimap(); + }, [refreshMinimap]); - // Navigate to match position - const scrollToMatch = useCallback((index: number) => { - if (!textareaRef.current || matchPositions.length === 0) return; - - const pos = matchPositions[index]; - textareaRef.current.focus(); - textareaRef.current.setSelectionRange(pos, pos + searchQuery.length); - - // Scroll to make selection visible - const text = tab.content.substring(0, pos); - const lines = text.split('\n'); - const lineHeight = fontSize * 1.5; - const targetScroll = (lines.length - 5) * lineHeight; - textareaRef.current.scrollTop = Math.max(0, targetScroll); - }, [matchPositions, searchQuery, tab.content, fontSize]); - - // Expose methods via ref useImperativeHandle(ref, () => ({ - focus: () => textareaRef.current?.focus(), + focus: () => viewRef.current?.focus(), search: (query: string) => { - if (textareaRef.current && query) { - const text = textareaRef.current.value; - const index = text.toLowerCase().indexOf(query.toLowerCase()); - if (index !== -1) { - textareaRef.current.focus(); - textareaRef.current.setSelectionRange(index, index + query.length); - } - } + const view = viewRef.current; + if (!view || !query) return; + view.dispatch({ effects: setSearchQuery.of(new SearchQuery({ search: query })) }); + findNext(view); }, getSelectedText: () => { - if (textareaRef.current) { - const start = textareaRef.current.selectionStart; - const end = textareaRef.current.selectionEnd; - return textareaRef.current.value.substring(start, end); - } - return ''; - }, - undo: () => { - // Flush any pending undo push - if (pushUndoRef.current) { - clearTimeout(pushUndoRef.current); - pushUndoRef.current = null; - } - if (tab.content !== lastPushedContentRef.current) { - undoStackRef.current.push({ content: lastPushedContentRef.current, cursorPos: textareaRef.current?.selectionStart || 0 }); - lastPushedContentRef.current = tab.content; - } - - const entry = undoStackRef.current.pop(); - if (entry) { - redoStackRef.current.push({ content: tab.content, cursorPos: textareaRef.current?.selectionStart || 0 }); - lastPushedContentRef.current = entry.content; - onChange(entry.content); - requestAnimationFrame(() => { - if (textareaRef.current) { - textareaRef.current.selectionStart = textareaRef.current.selectionEnd = entry.cursorPos; - } - }); - } - }, - redo: () => { - const entry = redoStackRef.current.pop(); - if (entry) { - undoStackRef.current.push({ content: tab.content, cursorPos: textareaRef.current?.selectionStart || 0 }); - lastPushedContentRef.current = entry.content; - onChange(entry.content); - requestAnimationFrame(() => { - if (textareaRef.current) { - textareaRef.current.selectionStart = textareaRef.current.selectionEnd = entry.cursorPos; - } - }); - } + const view = viewRef.current; + if (!view) return ''; + const { from, to } = view.state.selection.main; + return view.state.sliceDoc(from, to); }, - replace: (search: string, replacement: string, all: boolean = false) => { - if (!textareaRef.current || !search) return 0; - - const text = textareaRef.current.value; - let count = 0; - + undo: () => { const v = viewRef.current; if (v) { undo(v); v.focus(); } }, + redo: () => { const v = viewRef.current; if (v) { redo(v); v.focus(); } }, + replace: (searchText: string, replacement: string, all = false): number => { + const view = viewRef.current; + if (!view || !searchText) return 0; + const text = view.state.doc.toString(); if (all) { - const regex = new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); + const regex = new RegExp(escapeRegex(searchText), 'gi'); const matches = text.match(regex); - count = matches ? matches.length : 0; - const newText = text.replace(regex, replacement); - onChange(newText); - } else { - const index = text.toLowerCase().indexOf(search.toLowerCase()); - if (index !== -1) { - const newText = text.substring(0, index) + replacement + text.substring(index + search.length); - onChange(newText); - count = 1; - - requestAnimationFrame(() => { - if (textareaRef.current) { - textareaRef.current.setSelectionRange(index, index + replacement.length); - } - }); - } + const count = matches ? matches.length : 0; + if (count) view.dispatch({ changes: { from: 0, to: text.length, insert: text.replace(regex, replacement) } }); + return count; } - - return count; + const idx = text.toLowerCase().indexOf(searchText.toLowerCase()); + if (idx === -1) return 0; + view.dispatch({ + changes: { from: idx, to: idx + searchText.length, insert: replacement }, + selection: { anchor: idx, head: idx + replacement.length }, + }); + return 1; }, - navigateSearch: (direction: 'next' | 'prev') => { - if (matchPositions.length === 0) return; - - let newIndex: number; - if (direction === 'next') { - newIndex = currentMatchIndex >= matchPositions.length ? 0 : currentMatchIndex; - } else { - newIndex = currentMatchIndex <= 1 ? matchPositions.length - 1 : currentMatchIndex - 2; - } - - setCurrentMatchIndex(newIndex + 1); - scrollToMatch(newIndex); + navigateSearch: (direction, query, caseSensitive, wholeWord, useRegex) => { + const view = viewRef.current; + if (!view || !query) return; + view.dispatch({ effects: setSearchQuery.of(new SearchQuery({ search: query, caseSensitive, regexp: useRegex, wholeWord })) }); + (direction === 'next' ? findNext : findPrevious)(view); + view.focus(); }, })); - const lineNumbers = useMemo(() => - Array.from({ length: lineCount }, (_, i) => i + 1), - [lineCount] - ); - - const editorStyle = { - fontSize: `${fontSize}px`, - fontFamily: "'SF Mono', 'Monaco', 'Menlo', 'Courier New', monospace", - lineHeight: '1.5', - }; - - // Generate highlighted content - const getHighlightedContent = useCallback(() => { - if (!searchQuery) return tab.content; - - try { - let pattern = searchQuery; - if (!searchUseRegex) { - pattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - } - if (searchWholeWord) { - pattern = `\\b${pattern}\\b`; - } - - const flags = searchCaseSensitive ? 'g' : 'gi'; - const regex = new RegExp(`(${pattern})`, flags); - - return tab.content.split(regex).map((part) => { - regex.lastIndex = 0; // Reset BEFORE test to avoid skipping matches - if (regex.test(part)) { - return `${part.replace(//g, '>')}`; - } - return part.replace(//g, '>'); - }).join(''); - } catch { - return tab.content.replace(/&/g, '&').replace(//g, '>'); - } - }, [tab.content, searchQuery, searchCaseSensitive, searchWholeWord, searchUseRegex]); - - // Calculate minimap viewport height - const minimapViewportHeight = textareaRef.current - ? Math.max(20, (textareaRef.current.clientHeight / textareaRef.current.scrollHeight) * 100) - : 20; - return (
- {settings.lineNumbers !== 'off' && ( -
- {lineNumbers.map(num => ( -
{num}
- ))} -
- )} -
- {searchQuery && ( -
- )} -