From ccf69d826ef618d78b3251654638b7a392e4f831 Mon Sep 17 00:00:00 2001 From: Kang Date: Sat, 29 Aug 2026 10:54:11 +0800 Subject: [PATCH 01/32] feat(file-explorer): add symlink target management in PropertiesDialog - Enhanced the PropertiesDialog component to support editing symlink targets for remote files. - Introduced a new state for symlink targets and updated the UI to include an input field for this attribute. - Implemented validation to ensure symlink targets are provided when editing symlink properties. - Updated the backend to handle symlink target updates, including new commands for remote symlink management. - Adjusted file properties handling to include symlink target information in both local and remote contexts. --- src-tauri/src/cmd/local_fs.rs | 43 +- src-tauri/src/cmd/sftp.rs | 18 + src-tauri/src/core/sftp/mod.rs | 25 ++ src-tauri/src/core/sftp/scp_enhanced.rs | 38 ++ src-tauri/src/core/sftp/scp_normal.rs | 97 ++++- src-tauri/src/core/sftp/sftp_backend/fs.rs | 27 +- src-tauri/src/core/sftp/traits.rs | 377 ++++++++++++++++++ src-tauri/src/core/sftp/util.rs | 57 +++ src-tauri/src/lib.rs | 1 + .../russh-sftp/src/client/rawsession.rs | 48 +++ .../vendor/russh-sftp/src/client/session.rs | 21 + .../russh-sftp/src/protocol/readlink.rs | 79 +++- .../vendor/russh-sftp/src/protocol/symlink.rs | 104 ++++- .../file-explorer/PropertiesDialog.test.tsx | 177 ++++++++ .../dialog/file-explorer/PropertiesDialog.tsx | 181 +++++++-- src/types/global.d.ts | 1 + 16 files changed, 1240 insertions(+), 54 deletions(-) create mode 100644 src/components/dialog/file-explorer/PropertiesDialog.test.tsx diff --git a/src-tauri/src/cmd/local_fs.rs b/src-tauri/src/cmd/local_fs.rs index a1a3385e..6b179cec 100644 --- a/src-tauri/src/cmd/local_fs.rs +++ b/src-tauri/src/cmd/local_fs.rs @@ -427,6 +427,12 @@ async fn file_entry_from_path(path: &Path, name: String) -> AppResult async fn file_properties_from_path(path: &Path) -> AppResult { let symlink_metadata = tokio::fs::symlink_metadata(path).await?; + let is_symlink = symlink_metadata.file_type().is_symlink(); + let symlink_target = if is_symlink { + Some(path_to_string(tokio::fs::read_link(path).await?)) + } else { + None + }; let metadata = tokio::fs::metadata(path) .await .unwrap_or_else(|_| symlink_metadata.clone()); @@ -438,7 +444,8 @@ async fn file_properties_from_path(path: &Path) -> AppResult { Ok(FileProperties { name, is_dir: metadata.is_dir(), - is_symlink: symlink_metadata.file_type().is_symlink(), + is_symlink, + symlink_target, size: if metadata.is_dir() { 0 } else { metadata.len() }, permissions: permissions_string(&metadata, metadata.is_dir()), owner: owner_string(&metadata), @@ -780,6 +787,40 @@ mod tests { cleanup(&root).await; } + #[tokio::test] + async fn regular_file_properties_have_no_symlink_target() { + let root = temp_test_dir("regular-properties"); + tokio::fs::create_dir_all(&root).await.unwrap(); + let file = root.join("file.txt"); + tokio::fs::write(&file, b"hello").await.unwrap(); + + let properties = file_properties_from_path(&file).await.unwrap(); + assert!(!properties.is_symlink); + assert_eq!(properties.symlink_target, None); + + cleanup(&root).await; + } + + #[cfg(unix)] + #[tokio::test] + async fn dangling_symlink_properties_preserve_relative_target() { + use std::os::unix::fs::symlink; + + let root = temp_test_dir("dangling-symlink-properties"); + tokio::fs::create_dir_all(&root).await.unwrap(); + let link = root.join("current"); + symlink("../missing-release", &link).unwrap(); + + let properties = file_properties_from_path(&link).await.unwrap(); + assert!(properties.is_symlink); + assert_eq!( + properties.symlink_target.as_deref(), + Some("../missing-release") + ); + + cleanup(&root).await; + } + #[tokio::test] async fn ensure_local_session_rejects_non_local_sessions() { let manager = SessionManager::new(); diff --git a/src-tauri/src/cmd/sftp.rs b/src-tauri/src/cmd/sftp.rs index c88319b1..24c55d67 100644 --- a/src-tauri/src/cmd/sftp.rs +++ b/src-tauri/src/cmd/sftp.rs @@ -229,6 +229,24 @@ pub async fn create_remote_symlink( sftp::create_remote_symlink(state.inner().clone(), &session_id, &link_path, &target_path).await } +#[tauri::command] +pub async fn update_remote_symlink_target( + state: tauri::State<'_, Arc>, + session_id: String, + path: String, + raw_path_token: Option, + target_path: String, +) -> AppResult<()> { + sftp::update_remote_symlink_target( + state.inner().clone(), + &session_id, + &path, + raw_path_token.as_deref(), + &target_path, + ) + .await +} + #[tauri::command] pub async fn chmod_remote_file( state: tauri::State<'_, Arc>, diff --git a/src-tauri/src/core/sftp/mod.rs b/src-tauri/src/core/sftp/mod.rs index 9c1b70ce..5d4edbab 100644 --- a/src-tauri/src/core/sftp/mod.rs +++ b/src-tauri/src/core/sftp/mod.rs @@ -2319,6 +2319,31 @@ pub async fn create_remote_symlink( Ok(()) } +pub async fn update_remote_symlink_target( + manager: Arc, + session_id: &str, + path: &str, + raw_path_token: Option<&str>, + target_path: &str, +) -> AppResult<()> { + let auto_fs = get_or_create_auto_fs(&manager, session_id).await?; + let guard = auto_fs.backend().await?; + let fs = guard.as_ref().unwrap(); + let path_ref = RemotePathRef::new(path, raw_path_token)?; + fs.update_symlink_target_ref(&path_ref, target_path).await?; + + tracing::debug!( + target: "user_action", + action = "update", + entity = "remote_symlink", + session_id = %session_id, + remote_path = path, + "User changed remote symbolic link target" + ); + + Ok(()) +} + pub async fn chmod_remote_file( manager: Arc, session_id: &str, diff --git a/src-tauri/src/core/sftp/scp_enhanced.rs b/src-tauri/src/core/sftp/scp_enhanced.rs index dc4fcc01..7d296302 100644 --- a/src-tauri/src/core/sftp/scp_enhanced.rs +++ b/src-tauri/src/core/sftp/scp_enhanced.rs @@ -18,6 +18,10 @@ struct ExecResult { stderr: Vec, } +fn parse_find_symlink_target(output: &[u8]) -> String { + String::from_utf8_lossy(output.strip_suffix(&[0]).unwrap_or(output)).into_owned() +} + impl ScpEnhancedBackend { pub(crate) fn new(ssh_handle: Arc) -> Self { Self { ssh_handle } @@ -731,6 +735,17 @@ impl RemoteFs for ScpEnhancedBackend { let is_dir = file_type.contains("directory"); let is_symlink = file_type.contains("symbolic link") || file_type.contains("symlink"); + let symlink_target = if is_symlink { + let output = self + .exec_ok(&format!( + "LC_ALL=C find {} -maxdepth 0 -printf '%l\\0'", + sh_quote(path) + )) + .await?; + Some(parse_find_symlink_target(&output)) + } else { + None + }; let is_symlink_to_dir = is_symlink && self .exec(&format!("test -d {}", sh_quote(path))) @@ -752,6 +767,7 @@ impl RemoteFs for ScpEnhancedBackend { name, is_dir, is_symlink, + symlink_target, size, permissions, owner, @@ -1323,3 +1339,25 @@ impl RemoteFs for ScpEnhancedBackend { .map_err(|error| AppError::Channel(format!("Failed to read copied file size: {error}"))) } } + +#[cfg(test)] +mod tests { + use super::parse_find_symlink_target; + + #[test] + fn find_target_parser_preserves_relative_and_trailing_spaces() { + assert_eq!( + parse_find_symlink_target(b"../release/v2\0"), + "../release/v2" + ); + assert_eq!(parse_find_symlink_target(b"release v3 \0"), "release v3 "); + } + + #[test] + fn find_target_parser_accepts_dangling_target_text() { + assert_eq!( + parse_find_symlink_target(b"missing-release\0"), + "missing-release" + ); + } +} diff --git a/src-tauri/src/core/sftp/scp_normal.rs b/src-tauri/src/core/sftp/scp_normal.rs index 194c1662..eaf9a475 100644 --- a/src-tauri/src/core/sftp/scp_normal.rs +++ b/src-tauri/src/core/sftp/scp_normal.rs @@ -300,16 +300,36 @@ async fn exec_command_with_stdin( }) } +fn split_ls_fields(line: &str) -> Option<(Vec<&str>, &str)> { + let bytes = line.as_bytes(); + let mut fields = Vec::with_capacity(8); + let mut cursor = 0; + while fields.len() < 8 { + while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() { + cursor += 1; + } + let start = cursor; + while cursor < bytes.len() && !bytes[cursor].is_ascii_whitespace() { + cursor += 1; + } + if start == cursor { + return None; + } + fields.push(&line[start..cursor]); + } + while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() { + cursor += 1; + } + (cursor < bytes.len()).then(|| (fields, &line[cursor..])) +} + fn parse_ls_line(line: &str) -> Option { - let line = line.trim(); - if line.is_empty() || line.starts_with("total ") { + let line = line.trim_start(); + if line.trim_end().is_empty() || line.starts_with("total ") { return None; } - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() < 9 { - return None; - } + let (parts, raw_name) = split_ls_fields(line)?; let perms = parts[0]; if perms.len() < 10 { @@ -323,8 +343,6 @@ fn parse_ls_line(line: &str) -> Option { let group = parts[3].to_string(); let size: u64 = parts[4].parse().unwrap_or(0); - // parts[5..8] are month/day/time-or-year; everything from index 8 onward is the name - let raw_name = parts[8..].join(" "); if raw_name.is_empty() { return None; } @@ -336,7 +354,7 @@ fn parse_ls_line(line: &str) -> Option { raw_name.to_string() } } else { - raw_name + raw_name.to_string() }; if name == "." || name == ".." { @@ -365,14 +383,13 @@ fn remote_child_path(parent: &str, name: &str) -> String { } fn parse_ls_line_to_properties(line: &str, path: &str) -> AppResult { - let line = line.trim(); - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() < 9 { + let line = line.trim_start(); + let Some((parts, raw_name)) = split_ls_fields(line) else { return Err(AppError::Channel(format!( "Failed to parse stat output for '{}'", path ))); - } + }; let perms = parts[0]; if perms.len() < 10 { @@ -389,21 +406,27 @@ fn parse_ls_line_to_properties(line: &str, path: &str) -> AppResult ") { - raw_name[..pos].to_string() + ( + raw_name[..pos].to_string(), + Some(raw_name[pos + " -> ".len()..].to_string()), + ) } else { - raw_name.to_string() + return Err(AppError::Channel(format!( + "Symbolic link target was missing from stat output for '{}'", + path + ))); } } else { - raw_name + (raw_name.to_string(), None) }; Ok(FileProperties { name, is_dir, is_symlink, + symlink_target, size, permissions: perms.to_string(), owner: owner.clone(), @@ -1532,3 +1555,41 @@ impl RemoteFs for ScpNormalBackend { .map_err(|error| AppError::Channel(format!("Failed to read copied file size: {error}"))) } } + +#[cfg(test)] +mod tests { + use super::parse_ls_line_to_properties; + + #[test] + fn ls_properties_parser_keeps_relative_symlink_target() { + let props = parse_ls_line_to_properties( + "lrwxrwxrwx 1 root root 11 Aug 29 12:00 current -> releases/v2", + "/opt/app/current", + ) + .unwrap(); + assert_eq!(props.name, "current"); + assert!(props.is_symlink); + assert_eq!(props.symlink_target.as_deref(), Some("releases/v2")); + } + + #[test] + fn ls_properties_parser_keeps_dangling_and_spaced_target() { + let props = parse_ls_line_to_properties( + "lrwxrwxrwx 1 root root 19 Aug 29 12:00 current -> missing release ", + "/opt/app/current", + ) + .unwrap(); + assert_eq!(props.symlink_target.as_deref(), Some("missing release ")); + } + + #[test] + fn ls_properties_parser_sets_no_target_for_regular_file() { + let props = parse_ls_line_to_properties( + "-rw-r--r-- 1 root root 12 Aug 29 12:00 config.toml", + "/opt/app/config.toml", + ) + .unwrap(); + assert!(!props.is_symlink); + assert_eq!(props.symlink_target, None); + } +} diff --git a/src-tauri/src/core/sftp/sftp_backend/fs.rs b/src-tauri/src/core/sftp/sftp_backend/fs.rs index 1abcd202..478ece6d 100644 --- a/src-tauri/src/core/sftp/sftp_backend/fs.rs +++ b/src-tauri/src/core/sftp/sftp_backend/fs.rs @@ -166,6 +166,17 @@ impl RemoteFs for SftpBackend { let raw_path = self.remote_path_bytes(path); let attrs = sftp.symlink_metadata_bytes(raw_path.clone()).await?; let is_symlink = sftp_attrs_is_symlink(&attrs); + let symlink_target = if is_symlink { + match sftp.read_link_bytes(raw_path.clone()).await { + Ok(target) => Some(self.decode_path_from_sftp(&target)), + Err(error) => { + let _ = sftp.close().await; + return Err(error.into()); + } + } + } else { + None + }; let target_attrs = if is_symlink { sftp.metadata_bytes(raw_path).await.ok() } else { @@ -213,6 +224,7 @@ impl RemoteFs for SftpBackend { name, is_dir, is_symlink, + symlink_target, size: attrs.size.unwrap_or(0), permissions, owner, @@ -326,8 +338,21 @@ impl RemoteFs for SftpBackend { } async fn create_symlink(&self, link_path: &str, target_path: &str) -> AppResult<()> { + let link_path = RemotePathRef::new(link_path, None)?; + self.create_symlink_ref(&link_path, target_path).await + } + + async fn create_symlink_ref( + &self, + link_path: &RemotePathRef, + target_path: &str, + ) -> AppResult<()> { let sftp = self.open_sftp().await?; - sftp.symlink_openssh(target_path, link_path).await?; + sftp.symlink_openssh_bytes( + self.encode_path_for_sftp(target_path), + self.remote_path_bytes(link_path), + ) + .await?; let _ = sftp.close().await; Ok(()) } diff --git a/src-tauri/src/core/sftp/traits.rs b/src-tauri/src/core/sftp/traits.rs index 9a65ced3..57f5da35 100644 --- a/src-tauri/src/core/sftp/traits.rs +++ b/src-tauri/src/core/sftp/traits.rs @@ -43,6 +43,21 @@ pub(crate) trait RemoteFs: Send + Sync { } async fn create_file(&self, path: &str, mode: Option) -> AppResult<()>; async fn create_symlink(&self, link_path: &str, target_path: &str) -> AppResult<()>; + async fn create_symlink_ref( + &self, + link_path: &RemotePathRef, + target_path: &str, + ) -> AppResult<()> { + self.create_symlink(link_path.display_path(), target_path) + .await + } + async fn update_symlink_target_ref( + &self, + path: &RemotePathRef, + target_path: &str, + ) -> AppResult<()> { + replace_symlink_target(&RemoteFsSymlinkOps(self), path, target_path).await + } async fn update_attrs(&self, path: &str, update: &RemoteFileAttributeUpdate) -> AppResult<()>; async fn update_attrs_ref( &self, @@ -124,3 +139,365 @@ pub(crate) trait RemoteFs: Send + Sync { parent_controller: Option>, ) -> AppResult; } + +#[async_trait::async_trait] +trait SymlinkReplacementOps: Send + Sync { + async fn stat(&self, path: &RemotePathRef) -> AppResult; + async fn create(&self, path: &RemotePathRef, target_path: &str) -> AppResult<()>; + async fn rename(&self, old_path: &RemotePathRef, new_path: &RemotePathRef) -> AppResult<()>; + async fn remove(&self, path: &RemotePathRef) -> AppResult<()>; +} + +struct RemoteFsSymlinkOps<'a, T: RemoteFs + ?Sized>(&'a T); + +#[async_trait::async_trait] +impl SymlinkReplacementOps for RemoteFsSymlinkOps<'_, T> { + async fn stat(&self, path: &RemotePathRef) -> AppResult { + self.0.stat_ref(path).await + } + + async fn create(&self, path: &RemotePathRef, target_path: &str) -> AppResult<()> { + self.0.create_symlink_ref(path, target_path).await + } + + async fn rename(&self, old_path: &RemotePathRef, new_path: &RemotePathRef) -> AppResult<()> { + self.0.rename_ref(old_path, new_path).await + } + + async fn remove(&self, path: &RemotePathRef) -> AppResult<()> { + self.0.remove_file_ref(path).await + } +} + +async fn ensure_symlink( + fs: &(impl SymlinkReplacementOps + ?Sized), + path: &RemotePathRef, +) -> AppResult<()> { + let properties = fs.stat(path).await.map_err(|error| { + crate::error::AppError::Channel(format!( + "Failed to verify symbolic link '{}': {error}", + path.display_path() + )) + })?; + if properties.is_symlink { + Ok(()) + } else { + Err(crate::error::AppError::Config(format!( + "Remote path '{}' is no longer a symbolic link; refusing to replace it", + path.display_path() + ))) + } +} + +async fn cleanup_after_failure( + fs: &(impl SymlinkReplacementOps + ?Sized), + path: &RemotePathRef, + primary_error: crate::error::AppError, +) -> crate::error::AppError { + match fs.remove(path).await { + Ok(()) => primary_error, + Err(cleanup_error) => crate::error::AppError::Channel(format!( + "{primary_error}; cleanup of '{}' also failed: {cleanup_error}", + path.display_path() + )), + } +} + +async fn replace_symlink_target( + fs: &(impl SymlinkReplacementOps + ?Sized), + original: &RemotePathRef, + target_path: &str, +) -> AppResult<()> { + if target_path.trim().is_empty() { + return Err(crate::error::AppError::Config( + "Symbolic link target cannot be empty".to_string(), + )); + } + + ensure_symlink(fs, original).await?; + + let suffix = uuid::Uuid::new_v4().simple().to_string(); + let temp = original.sibling(&format!(".nyaterm-link-{suffix}")); + let backup = original.sibling(&format!(".nyaterm-backup-{suffix}")); + + fs.create(&temp, target_path).await.map_err(|error| { + crate::error::AppError::Channel(format!( + "Failed to create temporary symbolic link '{}': {error}", + temp.display_path() + )) + })?; + + if let Err(error) = ensure_symlink(fs, original).await { + return Err(cleanup_after_failure(fs, &temp, error).await); + } + + if let Err(error) = fs.rename(original, &backup).await { + let error = crate::error::AppError::Channel(format!( + "Failed to move original symbolic link '{}' to backup: {error}", + original.display_path() + )); + return Err(cleanup_after_failure(fs, &temp, error).await); + } + + if let Err(replacement_error) = fs.rename(&temp, original).await { + return match fs.rename(&backup, original).await { + Ok(()) => { + let error = crate::error::AppError::Channel(format!( + "Failed to replace symbolic link '{}': {replacement_error}; the original link was restored", + original.display_path() + )); + Err(cleanup_after_failure(fs, &temp, error).await) + } + Err(rollback_error) => Err(crate::error::AppError::Channel(format!( + "Failed to replace symbolic link '{}': {replacement_error}; rollback from '{}' also failed: {rollback_error}", + original.display_path(), + backup.display_path() + ))), + }; + } + + if let Err(error) = fs.remove(&backup).await { + tracing::warn!( + original_path = original.display_path(), + backup_path = backup.display_path(), + error = %error, + "Symbolic link target was updated, but backup cleanup failed" + ); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::AppError; + use std::collections::HashSet; + use std::sync::Mutex; + + struct RecordingOps { + actions: Mutex>, + failures: Mutex>, + is_symlink: bool, + } + + impl Default for RecordingOps { + fn default() -> Self { + Self { + actions: Mutex::new(Vec::new()), + failures: Mutex::new(HashSet::new()), + is_symlink: true, + } + } + } + + impl RecordingOps { + fn failing(actions: &[&str]) -> Self { + Self { + actions: Mutex::new(Vec::new()), + failures: Mutex::new(actions.iter().map(|value| (*value).to_string()).collect()), + is_symlink: true, + } + } + + fn regular_file() -> Self { + Self { + is_symlink: false, + ..Self::default() + } + } + + fn record(&self, action: String, failure_key: &str) -> AppResult<()> { + self.actions.lock().unwrap().push(action); + if self.failures.lock().unwrap().contains(failure_key) { + Err(AppError::Channel(format!("injected {failure_key} failure"))) + } else { + Ok(()) + } + } + + fn actions(&self) -> Vec { + self.actions.lock().unwrap().clone() + } + } + + #[async_trait::async_trait] + impl SymlinkReplacementOps for RecordingOps { + async fn stat(&self, path: &RemotePathRef) -> AppResult { + self.record(format!("stat:{}", path.display_path()), "stat")?; + Ok(FileProperties { + name: "current".to_string(), + is_dir: false, + is_symlink: self.is_symlink, + symlink_target: self.is_symlink.then(|| "releases/v2".to_string()), + size: 0, + permissions: "lrwxrwxrwx".to_string(), + owner: "root".to_string(), + group: "root".to_string(), + uid: "0".to_string(), + gid: "0".to_string(), + mtime: 0, + atime: 0, + }) + } + + async fn create(&self, path: &RemotePathRef, target_path: &str) -> AppResult<()> { + self.record( + format!("create:{}->{target_path}", path.display_path()), + "create", + ) + } + + async fn rename( + &self, + old_path: &RemotePathRef, + new_path: &RemotePathRef, + ) -> AppResult<()> { + let failure_key = if old_path.display_path().contains("nyaterm-link") { + "commit" + } else if old_path.display_path().contains("nyaterm-backup") { + "rollback" + } else { + "backup" + }; + self.record( + format!( + "rename:{}->{}", + old_path.display_path(), + new_path.display_path() + ), + failure_key, + ) + } + + async fn remove(&self, path: &RemotePathRef) -> AppResult<()> { + let failure_key = if path.display_path().contains("nyaterm-backup") { + "remove_backup" + } else { + "remove_temp" + }; + self.record(format!("remove:{}", path.display_path()), failure_key) + } + } + + fn original() -> RemotePathRef { + RemotePathRef::new("/opt/app/current", None).unwrap() + } + + #[tokio::test] + async fn replacement_creates_temp_before_moving_original() { + let ops = RecordingOps::default(); + replace_symlink_target(&ops, &original(), " releases/v3 ") + .await + .unwrap(); + let actions = ops.actions(); + let create = actions + .iter() + .position(|action| action.starts_with("create:")) + .unwrap(); + let backup = actions + .iter() + .position(|action| action.starts_with("rename:/opt/app/current->")) + .unwrap(); + assert!(create < backup); + assert!(actions[create].ends_with("-> releases/v3 ")); + assert!( + actions + .last() + .unwrap() + .starts_with("remove:/opt/app/.nyaterm-backup-") + ); + } + + #[tokio::test] + async fn missing_or_non_symlink_original_is_never_recreated() { + let missing = RecordingOps::failing(&["stat"]); + replace_symlink_target(&missing, &original(), "releases/v3") + .await + .unwrap_err(); + assert_eq!(missing.actions(), ["stat:/opt/app/current"]); + + let regular = RecordingOps::regular_file(); + let error = replace_symlink_target(®ular, &original(), "releases/v3") + .await + .unwrap_err(); + assert!(error.to_string().contains("no longer a symbolic link")); + assert_eq!(regular.actions(), ["stat:/opt/app/current"]); + } + + #[tokio::test] + async fn temporary_link_failure_leaves_original_untouched() { + let ops = RecordingOps::failing(&["create"]); + replace_symlink_target(&ops, &original(), "releases/v3") + .await + .unwrap_err(); + let actions = ops.actions(); + assert_eq!(actions.len(), 2); + assert_eq!(actions[0], "stat:/opt/app/current"); + assert!(actions[1].starts_with("create:/opt/app/.nyaterm-link-")); + } + + #[tokio::test] + async fn replacement_failure_rolls_back_original() { + let ops = RecordingOps::failing(&["commit"]); + let error = replace_symlink_target(&ops, &original(), "releases/v3") + .await + .unwrap_err(); + let actions = ops.actions(); + assert!(error.to_string().contains("original link was restored")); + assert!(actions.iter().any(|action| { + action.starts_with("rename:/opt/app/.nyaterm-backup-") + && action.ends_with("->/opt/app/current") + })); + assert!( + actions + .last() + .unwrap() + .starts_with("remove:/opt/app/.nyaterm-link-") + ); + } + + #[tokio::test] + async fn replacement_and_rollback_failures_are_both_reported() { + let ops = RecordingOps::failing(&["commit", "rollback"]); + let error = replace_symlink_target(&ops, &original(), "releases/v3") + .await + .unwrap_err() + .to_string(); + assert!(error.contains("injected commit failure")); + assert!(error.contains("injected rollback failure")); + } + + #[tokio::test] + async fn failed_backup_move_cleans_temp_without_committing() { + let ops = RecordingOps::failing(&["backup"]); + replace_symlink_target(&ops, &original(), "releases/v3") + .await + .unwrap_err(); + let actions = ops.actions(); + assert!( + actions + .last() + .unwrap() + .starts_with("remove:/opt/app/.nyaterm-link-") + ); + assert!(!actions.iter().any(|action| { + action.starts_with("rename:/opt/app/.nyaterm-link-") + && action.ends_with("->/opt/app/current") + })); + } + + #[tokio::test] + async fn backup_cleanup_failure_does_not_undo_successful_replacement() { + let ops = RecordingOps::failing(&["remove_backup"]); + replace_symlink_target(&ops, &original(), "missing-release") + .await + .unwrap(); + assert!( + ops.actions() + .last() + .unwrap() + .starts_with("remove:/opt/app/.nyaterm-backup-") + ); + } +} diff --git a/src-tauri/src/core/sftp/util.rs b/src-tauri/src/core/sftp/util.rs index ae0e4438..1544b8f3 100644 --- a/src-tauri/src/core/sftp/util.rs +++ b/src-tauri/src/core/sftp/util.rs @@ -59,6 +59,43 @@ impl RemotePathRef { pub(crate) fn raw_path(&self) -> Option<&[u8]> { self.raw_path.as_deref() } + + pub(crate) fn sibling(&self, file_name: &str) -> Self { + let display_path = sibling_path(self.display_path().as_bytes(), file_name.as_bytes()) + .map_or_else( + || file_name.to_string(), + |bytes| String::from_utf8_lossy(&bytes).into_owned(), + ); + let raw_path = self + .raw_path() + .and_then(|path| sibling_path(path, file_name.as_bytes())); + Self { + display_path, + raw_path, + } + } +} + +fn sibling_path(path: &[u8], file_name: &[u8]) -> Option> { + if file_name.is_empty() { + return None; + } + let parent = path + .iter() + .rposition(|byte| *byte == b'/') + .map(|index| &path[..index]) + .unwrap_or_default(); + let mut sibling = Vec::with_capacity(parent.len() + file_name.len() + 1); + if parent.is_empty() { + if path.starts_with(b"/") { + sibling.push(b'/'); + } + } else { + sibling.extend_from_slice(parent); + sibling.push(b'/'); + } + sibling.extend_from_slice(file_name); + Some(sibling) } pub(crate) fn raw_path_token(raw_path: &[u8]) -> String { @@ -76,6 +113,7 @@ pub struct FileProperties { pub name: String, pub is_dir: bool, pub is_symlink: bool, + pub symlink_target: Option, pub size: u64, pub permissions: String, pub owner: String, @@ -533,6 +571,24 @@ mod tests { assert_eq!(path_ref.raw_path().unwrap(), raw_path); } + #[test] + fn remote_path_sibling_preserves_raw_parent_bytes() { + let raw_path = b"/remote/\x80dir/\x81link"; + let token = raw_path_token(raw_path); + let path_ref = + RemotePathRef::new("/remote/display-dir/display-link", Some(&token)).unwrap(); + let sibling = path_ref.sibling(".nyaterm-link-test"); + + assert_eq!( + sibling.raw_path().unwrap(), + b"/remote/\x80dir/.nyaterm-link-test" + ); + assert_eq!( + sibling.display_path(), + "/remote/display-dir/.nyaterm-link-test" + ); + } + #[test] fn percent_encodes_windows_invalid_characters() { assert_eq!( @@ -674,6 +730,7 @@ mod tests { name: "file".to_string(), is_dir: false, is_symlink: false, + symlink_target: None, size: 0, permissions: permissions.to_string(), owner: owner.to_string(), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a6754170..3451ca1f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -278,6 +278,7 @@ pub fn run() { cmd::sftp::create_remote_file, cmd::sftp::create_remote_dir, cmd::sftp::create_remote_symlink, + cmd::sftp::update_remote_symlink_target, cmd::sftp::chmod_remote_file, cmd::sftp::update_remote_file_attributes, cmd::sftp::download_remote_directory, diff --git a/src-tauri/vendor/russh-sftp/src/client/rawsession.rs b/src-tauri/vendor/russh-sftp/src/client/rawsession.rs index 16c9f4d8..cfd7f771 100644 --- a/src-tauri/vendor/russh-sftp/src/client/rawsession.rs +++ b/src-tauri/vendor/russh-sftp/src/client/rawsession.rs @@ -935,6 +935,25 @@ impl RawSftpSession { ReadLink { id, path: path.into(), + path_bytes: None, + } + .into(), + ) + .await?; + + into_with_status!(result, Name) + } + + pub async fn readlink_bytes(&self, path_bytes: Vec) -> SftpResult { + let id = self.use_next_id(); + let path = String::from_utf8_lossy(&path_bytes).into_owned(); + let result = self + .request( + Some(id), + ReadLink { + id, + path, + path_bytes: Some(path_bytes), } .into(), ) @@ -956,6 +975,8 @@ impl RawSftpSession { id, linkpath: path.into(), targetpath: target.into(), + linkpath_bytes: None, + targetpath_bytes: None, } .into(), ) @@ -977,6 +998,33 @@ impl RawSftpSession { id, linkpath: target.into(), targetpath: link.into(), + linkpath_bytes: None, + targetpath_bytes: None, + } + .into(), + ) + .await?; + + into_status!(result) + } + + pub async fn symlink_openssh_bytes( + &self, + target_bytes: Vec, + link_bytes: Vec, + ) -> SftpResult { + let id = self.use_next_id(); + let linkpath = String::from_utf8_lossy(&target_bytes).into_owned(); + let targetpath = String::from_utf8_lossy(&link_bytes).into_owned(); + let result = self + .request( + Some(id), + Symlink { + id, + linkpath, + targetpath, + linkpath_bytes: Some(target_bytes), + targetpath_bytes: Some(link_bytes), } .into(), ) diff --git a/src-tauri/vendor/russh-sftp/src/client/session.rs b/src-tauri/vendor/russh-sftp/src/client/session.rs index a6ab1666..77487520 100644 --- a/src-tauri/vendor/russh-sftp/src/client/session.rs +++ b/src-tauri/vendor/russh-sftp/src/client/session.rs @@ -311,6 +311,15 @@ impl SftpSession { } } + /// Reads a symbolic link using raw bytes for the link path and target. + pub async fn read_link_bytes(&self, path_bytes: Vec) -> SftpResult> { + let name = self.session.readlink_bytes(path_bytes).await?; + match name.files.first() { + Some(file) => Ok(file.filename_bytes.clone()), + None => Err(Error::UnexpectedBehavior("no file".to_owned())), + } + } + /// Removes the specified folder. pub async fn remove_dir>(&self, path: P) -> SftpResult<()> { self.session.rmdir(path).await.map(|_| ()) @@ -373,6 +382,18 @@ impl SftpSession { self.session.symlink_openssh(target, link).await.map(|_| ()) } + /// Creates an OpenSSH-compatible symlink using raw bytes for both paths. + pub async fn symlink_openssh_bytes( + &self, + target_bytes: Vec, + link_bytes: Vec, + ) -> SftpResult<()> { + self.session + .symlink_openssh_bytes(target_bytes, link_bytes) + .await + .map(|_| ()) + } + /// Queries metadata about the remote file. pub async fn metadata>(&self, path: P) -> SftpResult { Ok(self.session.stat(path).await?.attrs) diff --git a/src-tauri/vendor/russh-sftp/src/protocol/readlink.rs b/src-tauri/vendor/russh-sftp/src/protocol/readlink.rs index 4dfdda68..a5324d40 100644 --- a/src-tauri/vendor/russh-sftp/src/protocol/readlink.rs +++ b/src-tauri/vendor/russh-sftp/src/protocol/readlink.rs @@ -1,11 +1,88 @@ +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use super::{impl_packet_for, impl_request_id, Packet, RequestId}; /// Implementation for `SSH_FXP_READLINK` -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug)] pub struct ReadLink { pub id: u32, pub path: String, + /// Raw bytes of the path, preserving its original encoding. + pub path_bytes: Option>, +} + +impl Serialize for ReadLink { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + use serde::ser::SerializeStruct; + let mut state = serializer.serialize_struct("ReadLink", 2)?; + state.serialize_field("id", &self.id)?; + match &self.path_bytes { + Some(bytes) => state.serialize_field("path", bytes)?, + None => state.serialize_field("path", &self.path)?, + } + state.end() + } +} + +impl<'de> Deserialize<'de> for ReadLink { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::{self, SeqAccess, Visitor}; + use std::fmt; + + struct ReadLinkVisitor; + + impl<'de> Visitor<'de> for ReadLinkVisitor { + type Value = ReadLink; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("struct ReadLink") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let id = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(0, &self))?; + let path_bytes: Vec = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?; + Ok(ReadLink { + id, + path: String::from_utf8_lossy(&path_bytes).into_owned(), + path_bytes: Some(path_bytes), + }) + } + } + + deserializer.deserialize_struct("ReadLink", &["id", "path"], ReadLinkVisitor) + } } impl_request_id!(ReadLink); impl_packet_for!(ReadLink); + +#[cfg(test)] +mod tests { + use super::ReadLink; + + #[test] + fn serializes_raw_path_bytes_without_lossy_conversion() { + let raw_path = b"/remote/\x80link".to_vec(); + let bytes = crate::ser::to_bytes(&ReadLink { + id: 7, + path: "/remote/display-link".to_string(), + path_bytes: Some(raw_path.clone()), + }) + .unwrap(); + assert!(bytes.ends_with(&raw_path)); + assert!(!bytes.ends_with(b"/remote/display-link")); + } +} diff --git a/src-tauri/vendor/russh-sftp/src/protocol/symlink.rs b/src-tauri/vendor/russh-sftp/src/protocol/symlink.rs index 89546702..0967bd2e 100644 --- a/src-tauri/vendor/russh-sftp/src/protocol/symlink.rs +++ b/src-tauri/vendor/russh-sftp/src/protocol/symlink.rs @@ -1,12 +1,114 @@ +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use super::{impl_packet_for, impl_request_id, Packet, RequestId}; /// Implementation for `SSH_FXP_SYMLINK` -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug)] pub struct Symlink { pub id: u32, pub linkpath: String, pub targetpath: String, + /// Raw bytes of `linkpath`, preserving its original encoding. + pub linkpath_bytes: Option>, + /// Raw bytes of `targetpath`, preserving its original encoding. + pub targetpath_bytes: Option>, +} + +impl Serialize for Symlink { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + use serde::ser::SerializeStruct; + let mut state = serializer.serialize_struct("Symlink", 3)?; + state.serialize_field("id", &self.id)?; + match &self.linkpath_bytes { + Some(bytes) => state.serialize_field("linkpath", bytes)?, + None => state.serialize_field("linkpath", &self.linkpath)?, + } + match &self.targetpath_bytes { + Some(bytes) => state.serialize_field("targetpath", bytes)?, + None => state.serialize_field("targetpath", &self.targetpath)?, + } + state.end() + } +} + +impl<'de> Deserialize<'de> for Symlink { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::{self, SeqAccess, Visitor}; + use std::fmt; + + struct SymlinkVisitor; + + impl<'de> Visitor<'de> for SymlinkVisitor { + type Value = Symlink; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("struct Symlink") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let id = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(0, &self))?; + let linkpath_bytes: Vec = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?; + let targetpath_bytes: Vec = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(2, &self))?; + Ok(Symlink { + id, + linkpath: String::from_utf8_lossy(&linkpath_bytes).into_owned(), + targetpath: String::from_utf8_lossy(&targetpath_bytes).into_owned(), + linkpath_bytes: Some(linkpath_bytes), + targetpath_bytes: Some(targetpath_bytes), + }) + } + } + + deserializer.deserialize_struct( + "Symlink", + &["id", "linkpath", "targetpath"], + SymlinkVisitor, + ) + } } impl_request_id!(Symlink); impl_packet_for!(Symlink); + +#[cfg(test)] +mod tests { + use super::Symlink; + + #[test] + fn serializes_raw_target_and_link_bytes() { + let raw_target = b"../release/\x81".to_vec(); + let raw_link = b"/remote/\x80current".to_vec(); + let bytes = crate::ser::to_bytes(&Symlink { + id: 9, + linkpath: "display-target".to_string(), + targetpath: "display-link".to_string(), + linkpath_bytes: Some(raw_target.clone()), + targetpath_bytes: Some(raw_link.clone()), + }) + .unwrap(); + let target_pos = bytes + .windows(raw_target.len()) + .position(|window| window == raw_target) + .unwrap(); + let link_pos = bytes + .windows(raw_link.len()) + .position(|window| window == raw_link) + .unwrap(); + assert!(target_pos < link_pos); + } +} diff --git a/src/components/dialog/file-explorer/PropertiesDialog.test.tsx b/src/components/dialog/file-explorer/PropertiesDialog.test.tsx new file mode 100644 index 00000000..f3317f67 --- /dev/null +++ b/src/components/dialog/file-explorer/PropertiesDialog.test.tsx @@ -0,0 +1,177 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { invoke } from "@/lib/invoke"; +import type { FileProperties } from "@/types/global"; +import PropertiesDialog, { + type PropertiesDialogData, +} from "./PropertiesDialog"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("@/lib/invoke", () => ({ invoke: vi.fn() })); +vi.mock("sonner", () => ({ + toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() }, +})); + +const symlinkProperties: FileProperties = { + name: "current", + is_dir: true, + is_symlink: true, + symlink_target: "releases/v2", + size: 11, + permissions: "lrwxrwxrwx", + owner: "root", + group: "root", + uid: "0", + gid: "0", + mtime: 0, + atime: 0, +}; + +const remoteData: PropertiesDialogData = { + sessionId: "session-1", + backend: "remote", + fullPath: "/opt/app/current", + rawPathToken: "raw-current", + name: "current", + is_dir: true, +}; + +describe("PropertiesDialog symlink target", () => { + beforeEach(() => { + vi.mocked(invoke).mockReset(); + }); + + it("shows the original target and identifies links before directories", async () => { + vi.mocked(invoke).mockResolvedValueOnce(symlinkProperties); + + render(); + + const targetInput = await screen.findByRole("textbox", { + name: "fileExplorer.symlinkTarget", + }); + expect((targetInput as HTMLInputElement).value).toBe("releases/v2"); + expect(screen.getByText("fileExplorer.symbolicLink")).not.toBeNull(); + expect(screen.queryByText("fileExplorer.folder")).toBeNull(); + }); + + it("preserves target whitespace and saves it before attribute changes", async () => { + vi.mocked(invoke) + .mockResolvedValueOnce(symlinkProperties) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined); + const onClose = vi.fn(); + const onSuccess = vi.fn(); + + render( + , + ); + + fireEvent.change( + await screen.findByRole("textbox", { + name: "fileExplorer.symlinkTarget", + }), + { target: { value: " ../releases/v3 " } }, + ); + fireEvent.change(screen.getAllByDisplayValue("root")[0], { + target: { value: "deploy" }, + }); + fireEvent.click(screen.getByRole("button", { name: "dialog.save" })); + + await waitFor(() => expect(invoke).toHaveBeenCalledTimes(3)); + expect(invoke).toHaveBeenNthCalledWith(2, "update_remote_symlink_target", { + sessionId: "session-1", + path: "/opt/app/current", + rawPathToken: "raw-current", + targetPath: " ../releases/v3 ", + }); + expect(invoke).toHaveBeenNthCalledWith(3, "update_remote_file_attributes", { + sessionId: "session-1", + path: "/opt/app/current", + rawPathToken: "raw-current", + update: { + mode: null, + owner: "deploy", + group: null, + recursive: false, + }, + }); + expect(onSuccess).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("rejects blank targets without sending an update", async () => { + vi.mocked(invoke).mockResolvedValueOnce(symlinkProperties); + const { toast } = await import("sonner"); + + render(); + + fireEvent.change( + await screen.findByRole("textbox", { + name: "fileExplorer.symlinkTarget", + }), + { target: { value: " " } }, + ); + fireEvent.click(screen.getByRole("button", { name: "dialog.save" })); + + expect(toast.error).toHaveBeenCalledWith( + "fileExplorer.symlinkTargetRequired", + ); + expect(invoke).toHaveBeenCalledTimes(1); + }); + + it("closes without an update when nothing changed", async () => { + vi.mocked(invoke).mockResolvedValueOnce(symlinkProperties); + const onClose = vi.fn(); + + render(); + + await screen.findByRole("textbox", { name: "fileExplorer.symlinkTarget" }); + fireEvent.click(screen.getByRole("button", { name: "dialog.save" })); + + expect(invoke).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("does not expose target editing for local links or regular remote files", async () => { + vi.mocked(invoke).mockResolvedValueOnce(symlinkProperties); + const { unmount } = render( + , + ); + + await screen.findByText("fileExplorer.symbolicLink"); + expect( + screen.queryByRole("textbox", { name: "fileExplorer.symlinkTarget" }), + ).toBeNull(); + unmount(); + + vi.mocked(invoke).mockResolvedValueOnce({ + ...symlinkProperties, + is_dir: false, + is_symlink: false, + symlink_target: null, + }); + render( + , + ); + + await screen.findByText("fileExplorer.file"); + expect( + screen.queryByRole("textbox", { name: "fileExplorer.symlinkTarget" }), + ).toBeNull(); + }); +}); diff --git a/src/components/dialog/file-explorer/PropertiesDialog.tsx b/src/components/dialog/file-explorer/PropertiesDialog.tsx index 8009ad9b..4d91f373 100644 --- a/src/components/dialog/file-explorer/PropertiesDialog.tsx +++ b/src/components/dialog/file-explorer/PropertiesDialog.tsx @@ -90,7 +90,11 @@ function parsePermissionsToOctal(perms: string): string { return `${special}${u}${g}${o}`; } -export default function PropertiesDialog({ data, onClose, onSuccess }: PropertiesDialogProps) { +export default function PropertiesDialog({ + data, + onClose, + onSuccess, +}: PropertiesDialogProps) { const { t } = useTranslation(); const [properties, setProperties] = useState(null); const [loading, setLoading] = useState(true); @@ -99,12 +103,18 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie const [octal, setOctal] = useState("0644"); const [ownerInput, setOwnerInput] = useState(""); const [groupInput, setGroupInput] = useState(""); + const [symlinkTarget, setSymlinkTarget] = useState(""); const [recursive, setRecursive] = useState(false); const [isSaving, setIsSaving] = useState(false); - const initialOctal = properties ? parsePermissionsToOctal(properties.permissions) : "0644"; + const initialOctal = properties + ? parsePermissionsToOctal(properties.permissions) + : "0644"; const initialOwner = properties?.owner || properties?.uid || ""; const initialGroup = properties?.group || properties?.gid || ""; + const initialSymlinkTarget = properties?.symlink_target ?? ""; const canEditAttributes = data.backend === "remote"; + const canEditSymlinkTarget = + data.backend === "remote" && properties?.is_symlink === true; useEffect(() => { let isMounted = true; @@ -128,6 +138,7 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie setOctal(parsePermissionsToOctal(props.permissions)); setOwnerInput(props.owner || props.uid || ""); setGroupInput(props.group || props.gid || ""); + setSymlinkTarget(props.symlink_target ?? ""); setRecursive(false); } }) @@ -147,6 +158,10 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie const nextOwner = ownerInput.trim(); const nextGroup = groupInput.trim(); + if (canEditSymlinkTarget && !symlinkTarget.trim()) { + toast.error(t("fileExplorer.symlinkTargetRequired")); + return; + } if (!nextOwner || !nextGroup) { toast.error(t("fileExplorer.ownerGroupRequired")); return; @@ -158,21 +173,40 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie group: nextGroup !== initialGroup ? nextGroup : null, recursive: data.is_dir && recursive, }; + const symlinkTargetChanged = + canEditSymlinkTarget && symlinkTarget !== initialSymlinkTarget; + const attributesChanged = !!(update.mode || update.owner || update.group); - if (!update.mode && !update.owner && !update.group) { + if (!attributesChanged && !symlinkTargetChanged) { onClose(); return; } setIsSaving(true); try { - await invoke("update_remote_file_attributes", { - sessionId: data.sessionId, - path: data.fullPath, - rawPathToken: data.rawPathToken, - update, - }); - toast.success(t("fileExplorer.propertiesSaved")); + if (symlinkTargetChanged) { + await invoke("update_remote_symlink_target", { + sessionId: data.sessionId, + path: data.fullPath, + rawPathToken: data.rawPathToken, + targetPath: symlinkTarget, + }); + } + if (attributesChanged) { + await invoke("update_remote_file_attributes", { + sessionId: data.sessionId, + path: data.fullPath, + rawPathToken: data.rawPathToken, + update, + }); + } + toast.success( + t( + symlinkTargetChanged + ? "fileExplorer.symlinkTargetSaved" + : "fileExplorer.propertiesSaved", + ), + ); await onSuccess?.(); onClose(); } catch (e) { @@ -201,6 +235,7 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie }; const getFileType = () => { + if (properties?.is_symlink) return t("fileExplorer.symbolicLink"); if (data.is_dir) return t("fileExplorer.folder"); const ext = data.name.split(".").pop()?.toLowerCase(); if (ext === "sh" || ext === "bash") return t("fileExplorer.shellScript"); @@ -212,19 +247,29 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie }; return ( - !v && !isSaving && onClose()}> + !v && !isSaving && onClose()} + > {data.is_dir ? ( - + ) : ( )} - + {t("fileExplorer.propertiesOf", { name: data.name })} @@ -253,16 +298,42 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie
{[ - { key: "type", label: t("fileExplorer.type"), value: getFileType() }, + { + key: "type", + label: t("fileExplorer.type"), + value: getFileType(), + }, { key: "location", label: t("fileExplorer.location"), value: ( - + {getLocation()} ), }, + ...(canEditSymlinkTarget + ? [ + { + key: "symlinkTarget", + label: t("fileExplorer.symlinkTarget"), + value: ( + + setSymlinkTarget(event.target.value) + } + /> + ), + }, + ] + : []), { key: "size", label: t("fileExplorer.size"), @@ -271,12 +342,20 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie { key: "mtime", label: t("fileExplorer.mtime"), - value: {formatTime(properties.mtime)}, + value: ( + + {formatTime(properties.mtime)} + + ), }, { key: "atime", label: t("fileExplorer.atime"), - value: {formatTime(properties.atime)}, + value: ( + + {formatTime(properties.atime)} + + ), }, { key: "owner", @@ -285,7 +364,9 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie {properties.owner || "-"}{" "} {properties.uid && ( - [{properties.uid}] + + [{properties.uid}] + )} ), @@ -297,15 +378,21 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie {properties.group || "-"}{" "} {properties.gid && ( - [{properties.gid}] + + [{properties.gid}] + )} ), }, ].map((row) => (
- {row.label}: - {row.value} + + {row.label}: + + + {row.value} +
))}
@@ -321,7 +408,9 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie
diff --git a/src/pages/NewSessionPage.tsx b/src/pages/NewSessionPage.tsx index 927b83fe..ecaadd69 100644 --- a/src/pages/NewSessionPage.tsx +++ b/src/pages/NewSessionPage.tsx @@ -104,6 +104,7 @@ function normalizeSftpSettings(value: SavedConnection["sftp"] | undefined): Sftp shell_detection_timeout_ms: value?.shell_detection_timeout_ms ?? DEFAULT_SFTP_SHELL_DETECTION_TIMEOUT_MS, filename_encoding: value?.filename_encoding || "", + pipeline_depth: value?.pipeline_depth, }; } diff --git a/src/types/global.d.ts b/src/types/global.d.ts index de48f49c..803c509a 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -430,6 +430,8 @@ export interface SftpSettings { cwd_follow_mode: SftpCwdFollowMode; shell_detection_timeout_ms: number; filename_encoding?: string; + /** Override SFTP single-file pipeline depth. Undefined means automatic. */ + pipeline_depth?: number; } export type AlgorithmRisk = "modern" | "legacy" | "insecure"; From 61677c2e71465d6823343b242a79e32389cd728a Mon Sep 17 00:00:00 2001 From: Kang Date: Sun, 30 Aug 2026 01:21:35 +0800 Subject: [PATCH 10/32] chore(i18n): add SFTP pipeline depth translations for multiple locales - Added new translation keys for SFTP pipeline depth configuration in English, Korean, Simplified Chinese, and Traditional Chinese locale files. - Included descriptions to enhance user understanding of the pipeline depth setting and its impact on SFTP performance. --- src/i18n/locales/en.json | 3 +++ src/i18n/locales/ko.json | 3 +++ src/i18n/locales/zh-CN.json | 3 +++ src/i18n/locales/zh-TW.json | 3 +++ 4 files changed, 12 insertions(+) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b8072298..5d21fd14 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -655,6 +655,9 @@ "sftpFilenameEncoding": "SFTP filename encoding", "sftpFilenameEncodingDesc": "Use this when remote filenames are not UTF-8.", "sftpFilenameEncodingFollowTerminal": "Follow terminal encoding", + "sftpPipelineDepth": "Pipeline depth", + "sftpPipelineDepthAuto": "Automatic", + "sftpPipelineDepthDesc": "Controls the number of in-flight SFTP requests for a single file. Higher values may improve throughput on high-latency networks, but use more connection and server resources. Automatic is recommended.", "sftpShellDetectionTimeout": "Shell detection timeout", "sftpShellDetectionTimeoutDesc": "How long to wait for shell type detection before skipping directory tracking setup.", "sftpShellDetectionTimeoutInvalid": "Shell detection timeout must be between {{min}} and {{max}} ms", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index ccb10aaa..6715ade1 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -654,6 +654,9 @@ "sftpFilenameEncoding": "SFTP 파일 이름 인코딩", "sftpFilenameEncodingDesc": "원격 파일 이름이 UTF-8이 아닐 때 사용합니다.", "sftpFilenameEncodingFollowTerminal": "터미널 인코딩 따르기", + "sftpPipelineDepth": "파이프라인 깊이", + "sftpPipelineDepthAuto": "자동", + "sftpPipelineDepthDesc": "단일 파일에서 동시에 처리되는 SFTP 요청 수를 제어합니다. 값이 클수록 지연 시간이 긴 네트워크에서 처리량이 향상될 수 있지만 연결 및 서버 리소스를 더 많이 사용합니다. 자동 설정을 권장합니다.", "sftpShellDetectionTimeout": "셸 감지 시간 제한", "sftpShellDetectionTimeoutDesc": "디렉터리 추적 설정을 건너뛰기 전에 셸 유형 감지를 기다릴 최대 시간입니다.", "sftpShellDetectionTimeoutInvalid": "셸 감지 시간 제한은 {{min}}~{{max}}ms 사이여야 합니다", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index a056dc6d..9b408da1 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -654,6 +654,9 @@ "sftpFilenameEncoding": "SFTP 文件名编码", "sftpFilenameEncodingDesc": "远程文件名不是 UTF-8 时使用此设置。", "sftpFilenameEncodingFollowTerminal": "跟随终端编码", + "sftpPipelineDepth": "Pipeline 深度", + "sftpPipelineDepthAuto": "自动", + "sftpPipelineDepthDesc": "控制单个文件同时进行的 SFTP 请求数量。较大的值可能提升高延迟网络下的传输速度,但会占用更多连接和服务器资源。建议保持自动。", "sftpShellDetectionTimeout": "Shell 探测超时", "sftpShellDetectionTimeoutDesc": "等待 Shell 类型探测的最长时间,超时后会跳过目录跟随初始化。", "sftpShellDetectionTimeoutInvalid": "Shell 探测超时必须在 {{min}} 到 {{max}} ms 之间", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 788eb25b..69c8fd72 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -654,6 +654,9 @@ "sftpFilenameEncoding": "SFTP 檔名編碼", "sftpFilenameEncodingDesc": "遠端檔名不是 UTF-8 時使用此設定。", "sftpFilenameEncodingFollowTerminal": "跟隨終端編碼", + "sftpPipelineDepth": "Pipeline 深度", + "sftpPipelineDepthAuto": "自動", + "sftpPipelineDepthDesc": "控制單一檔案同時進行的 SFTP 請求數量。較大的值可能提升高延遲網路下的傳輸速度,但會占用更多連線和伺服器資源。建議保持自動。", "sftpShellDetectionTimeout": "Shell 探測逾時", "sftpShellDetectionTimeoutDesc": "等待 Shell 類型探測的最長時間,逾時後會略過目錄跟隨初始化。", "sftpShellDetectionTimeoutInvalid": "Shell 探測逾時必須在 {{min}} 到 {{max}} ms 之間", From 078e8be4f4082cd7dce54ca7a1b591af6cddb2ea Mon Sep 17 00:00:00 2001 From: Kang Date: Sun, 30 Aug 2026 15:14:16 +0800 Subject: [PATCH 11/32] feat(mcp): implement active session reporting and associated hooks - Added `useMcpActiveSession` hook to manage and report the active MCP session ID. - Integrated the hook into the main `App` component to ensure active session updates. - Created tests for the `useMcpActiveSession` hook to validate session reporting behavior. - Introduced `report_mcp_active_session` command in the Tauri backend to handle session updates from the frontend. - Updated MCP tool definitions to include new capabilities and ensure proper session management. --- .../crates/nyaterm-mcp-protocol/src/lib.rs | 215 ++++++ src-tauri/crates/nyaterm-mcp/src/bridge.rs | 390 ++++++++-- src-tauri/crates/nyaterm-mcp/src/main.rs | 138 ++-- src-tauri/src/cmd/mcp.rs | 14 + src-tauri/src/core/capabilities/catalog.rs | 138 +--- src-tauri/src/core/capabilities/mod.rs | 4 +- src-tauri/src/core/capabilities/policy.rs | 689 ++++++++++++++---- src-tauri/src/core/capabilities/scope.rs | 135 +++- src-tauri/src/core/capabilities/sftp.rs | 232 ++++++ src-tauri/src/core/mcp/host.rs | 260 +++++-- src-tauri/src/lib.rs | 1 + src/App.tsx | 2 + src/hooks/useMcpActiveSession.test.tsx | 38 + src/hooks/useMcpActiveSession.ts | 10 + src/i18n/mcpApprovalTranslations.test.ts | 12 + 15 files changed, 1780 insertions(+), 498 deletions(-) create mode 100644 src/hooks/useMcpActiveSession.test.tsx create mode 100644 src/hooks/useMcpActiveSession.ts create mode 100644 src/i18n/mcpApprovalTranslations.test.ts diff --git a/src-tauri/crates/nyaterm-mcp-protocol/src/lib.rs b/src-tauri/crates/nyaterm-mcp-protocol/src/lib.rs index fe34a65b..b29d1b15 100644 --- a/src-tauri/crates/nyaterm-mcp-protocol/src/lib.rs +++ b/src-tauri/crates/nyaterm-mcp-protocol/src/lib.rs @@ -42,6 +42,181 @@ pub mod tool { pub const OUTPUT_READ: &str = "tool_output_read"; } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CapabilityAccess { + Read, + SensitiveRead, + Write, + DestructiveWrite, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct McpToolDefinition { + pub tool: &'static str, + pub capability: &'static str, + pub description: &'static str, + pub access: CapabilityAccess, + pub requires_session: bool, + pub read_only_hint: bool, + pub destructive_hint: bool, + pub open_world_hint: bool, +} + +pub const MCP_TOOL_REGISTRY: &[McpToolDefinition] = &[ + McpToolDefinition { + tool: tool::GET_ENVIRONMENT, + capability: capability::ENVIRONMENT, + description: "Return scoped NyaTerm sessions and the optional active and default sessions.", + access: CapabilityAccess::Read, + requires_session: false, + read_only_hint: true, + destructive_hint: false, + open_world_hint: false, + }, + McpToolDefinition { + tool: tool::SESSION_GET, + capability: capability::SESSION_GET, + description: "Return safe metadata and capability availability for a scoped session.", + access: CapabilityAccess::Read, + requires_session: true, + read_only_hint: true, + destructive_hint: false, + open_world_hint: false, + }, + McpToolDefinition { + tool: tool::TERMINAL_EXECUTE, + capability: capability::TERMINAL_EXECUTE, + description: "Execute a command in an existing scoped NyaTerm terminal session.", + access: CapabilityAccess::Write, + requires_session: true, + read_only_hint: false, + destructive_hint: false, + open_world_hint: true, + }, + McpToolDefinition { + tool: tool::TERMINAL_RECENT_OUTPUT, + capability: capability::TERMINAL_RECENT_OUTPUT, + description: "Read recent ANSI-free terminal output for a scoped session.", + access: CapabilityAccess::SensitiveRead, + requires_session: true, + read_only_hint: true, + destructive_hint: false, + open_world_hint: true, + }, + McpToolDefinition { + tool: tool::SFTP_HOME, + capability: capability::SFTP_HOME, + description: "Return the remote home directory.", + access: CapabilityAccess::SensitiveRead, + requires_session: true, + read_only_hint: true, + destructive_hint: false, + open_world_hint: true, + }, + McpToolDefinition { + tool: tool::SFTP_LIST, + capability: capability::SFTP_LIST, + description: "List a remote directory.", + access: CapabilityAccess::SensitiveRead, + requires_session: true, + read_only_hint: true, + destructive_hint: false, + open_world_hint: true, + }, + McpToolDefinition { + tool: tool::SFTP_STAT, + capability: capability::SFTP_STAT, + description: "Read remote path metadata.", + access: CapabilityAccess::SensitiveRead, + requires_session: true, + read_only_hint: true, + destructive_hint: false, + open_world_hint: true, + }, + McpToolDefinition { + tool: tool::SFTP_READ_TEXT, + capability: capability::SFTP_READ, + description: "Read up to 64 KiB of a remote UTF-8 text file.", + access: CapabilityAccess::SensitiveRead, + requires_session: true, + read_only_hint: true, + destructive_hint: false, + open_world_hint: true, + }, + McpToolDefinition { + tool: tool::SFTP_WRITE_TEXT, + capability: capability::SFTP_WRITE, + description: "Write a remote UTF-8 text file with optional conflict protection.", + access: CapabilityAccess::Write, + requires_session: true, + read_only_hint: false, + destructive_hint: false, + open_world_hint: true, + }, + McpToolDefinition { + tool: tool::SFTP_MKDIR, + capability: capability::SFTP_MKDIR, + description: "Create a remote directory.", + access: CapabilityAccess::Write, + requires_session: true, + read_only_hint: false, + destructive_hint: false, + open_world_hint: true, + }, + McpToolDefinition { + tool: tool::SFTP_RENAME, + capability: capability::SFTP_RENAME, + description: "Rename or move a remote path.", + access: CapabilityAccess::Write, + requires_session: true, + read_only_hint: false, + destructive_hint: false, + open_world_hint: true, + }, + McpToolDefinition { + tool: tool::SFTP_DELETE, + capability: capability::SFTP_DELETE, + description: "Delete a remote path using NyaTerm's existing delete semantics.", + access: CapabilityAccess::DestructiveWrite, + requires_session: true, + read_only_hint: false, + destructive_hint: true, + open_world_hint: true, + }, + McpToolDefinition { + tool: tool::SFTP_CHMOD, + capability: capability::SFTP_CHMOD, + description: "Change remote path permissions.", + access: CapabilityAccess::Write, + requires_session: true, + read_only_hint: false, + destructive_hint: false, + open_world_hint: true, + }, + McpToolDefinition { + tool: tool::OUTPUT_READ, + capability: capability::OUTPUT_READ, + description: "Read another chunk of a large result produced on this MCP connection.", + access: CapabilityAccess::SensitiveRead, + requires_session: false, + read_only_hint: true, + destructive_hint: false, + open_world_hint: false, + }, +]; + +pub fn definition_for_tool(name: &str) -> Option<&'static McpToolDefinition> { + MCP_TOOL_REGISTRY + .iter() + .find(|definition| definition.tool == name) +} + +pub fn definition_for_capability(id: &str) -> Option<&'static McpToolDefinition> { + MCP_TOOL_REGISTRY + .iter() + .find(|definition| definition.capability == id) +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DiscoveryDocument { @@ -195,3 +370,43 @@ pub struct OutputReadArgs { #[serde(default)] pub max_bytes: Option, } + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::*; + + #[test] + fn registry_is_unique_and_annotations_match_access() { + let mut tools = HashSet::new(); + let mut capabilities = HashSet::new(); + for definition in MCP_TOOL_REGISTRY { + assert!(tools.insert(definition.tool), "duplicate tool: {}", definition.tool); + assert!( + capabilities.insert(definition.capability), + "duplicate capability: {}", + definition.capability + ); + assert_eq!( + definition.read_only_hint, + matches!( + definition.access, + CapabilityAccess::Read | CapabilityAccess::SensitiveRead + ) + ); + assert_eq!( + definition.destructive_hint, + definition.access == CapabilityAccess::DestructiveWrite + ); + if definition.access == CapabilityAccess::DestructiveWrite { + assert!(definition.destructive_hint); + } + assert_eq!(definition_for_tool(definition.tool), Some(definition)); + assert_eq!( + definition_for_capability(definition.capability), + Some(definition) + ); + } + } +} diff --git a/src-tauri/crates/nyaterm-mcp/src/bridge.rs b/src-tauri/crates/nyaterm-mcp/src/bridge.rs index e7e4eeda..127f5c06 100644 --- a/src-tauri/crates/nyaterm-mcp/src/bridge.rs +++ b/src-tauri/crates/nyaterm-mcp/src/bridge.rs @@ -41,6 +41,7 @@ struct Connection { pub struct BridgeClient { endpoint: BridgeEndpoint, connection: Arc>>, + identity: Arc>>, } impl BridgeClient { @@ -48,12 +49,14 @@ impl BridgeClient { Self { endpoint, connection: Arc::new(Mutex::new(None)), + identity: Arc::new(Mutex::new(None)), } } pub async fn identify(&self, name: String, version: Option) { - let params = - serde_json::to_value(ClientIdentifyParams { name, version }).unwrap_or_default(); + let identity = ClientIdentifyParams { name, version }; + *self.identity.lock().await = Some(identity.clone()); + let params = serde_json::to_value(identity).unwrap_or_default(); let _ = self.rpc("client.identify", params).await; } @@ -71,70 +74,104 @@ impl BridgeClient { }) .map_err(|error| bridge_error("invalid_argument", &error.to_string()))?; let mut guard = self.connection.lock().await; - if guard.is_none() { - *guard = Some(connect(&self.endpoint).await.map_err(io_error)?); - } + self.ensure_connection(&mut guard, true).await?; let connection = guard.as_mut().unwrap(); - let id = connection.next_id; - connection.next_id += 1; - write_request( - connection, - RpcRequest { - id, - method: "capability.execute".into(), - params, - }, - ) - .await - .map_err(io_error)?; - let response = tokio::select! { + let result = tokio::select! { _ = cancellation.cancelled() => { let endpoint = self.endpoint.clone(); tokio::spawn(async move { let _ = cancel_request(&endpoint, &request_id).await; }); *guard = None; return Err(bridge_error("cancelled", "The MCP tool call was cancelled.")); } - response = read_response(connection) => response.map_err(io_error)?, + result = connection_rpc(connection, "capability.execute", params) => result, }; - if response.id != id { - *guard = None; - return Err(bridge_error( - "bridge_disconnected", - "MCP bridge response ID mismatch.", - )); - } - match (response.result, response.error) { - (Some(value), None) => Ok(value), - (_, Some(error)) => Err(error), - _ => Err(bridge_error( - "bridge_disconnected", - "MCP bridge returned an empty response.", - )), - } + finish_rpc(&mut guard, result) } async fn rpc(&self, method: &str, params: Value) -> Result { let mut guard = self.connection.lock().await; - if guard.is_none() { - *guard = Some(connect(&self.endpoint).await.map_err(io_error)?); - } + self.ensure_connection(&mut guard, method != "client.identify") + .await?; let connection = guard.as_mut().unwrap(); - let id = connection.next_id; - connection.next_id += 1; - write_request( - connection, - RpcRequest { - id, - method: method.into(), - params, - }, - ) + let result = connection_rpc(connection, method, params).await; + finish_rpc(&mut guard, result) + } + + async fn ensure_connection( + &self, + guard: &mut Option, + replay_identity: bool, + ) -> Result<(), RpcError> { + if guard.is_some() { + return Ok(()); + } + let mut connection = connect(&self.endpoint).await.map_err(io_error)?; + if replay_identity && let Some(identity) = self.identity.lock().await.clone() { + let params = serde_json::to_value(identity) + .map_err(|error| bridge_error("invalid_argument", &error.to_string()))?; + match connection_rpc(&mut connection, "client.identify", params).await { + Ok(_) => {} + Err(ConnectionRpcError::Remote(error)) => return Err(error), + Err(ConnectionRpcError::Disconnected(error)) => return Err(error), + } + } + *guard = Some(connection); + Ok(()) + } +} + +enum ConnectionRpcError { + Remote(RpcError), + Disconnected(RpcError), +} + +fn finish_rpc( + guard: &mut Option, + result: Result, +) -> Result { + match result { + Ok(value) => Ok(value), + Err(ConnectionRpcError::Remote(error)) => Err(error), + Err(ConnectionRpcError::Disconnected(error)) => { + *guard = None; + Err(error) + } + } +} + +async fn connection_rpc( + connection: &mut Connection, + method: &str, + params: Value, +) -> Result { + let id = connection.next_id; + connection.next_id += 1; + write_request( + connection, + RpcRequest { + id, + method: method.into(), + params, + }, + ) + .await + .map_err(|error| ConnectionRpcError::Disconnected(io_error(error)))?; + let response = read_response(connection) .await - .map_err(io_error)?; - let response = read_response(connection).await.map_err(io_error)?; - response - .error - .map_or_else(|| Ok(response.result.unwrap_or(Value::Null)), Err) + .map_err(|error| ConnectionRpcError::Disconnected(io_error(error)))?; + if response.id != id { + return Err(ConnectionRpcError::Disconnected(bridge_error( + "bridge_disconnected", + "MCP bridge response ID mismatch.", + ))); + } + match (response.result, response.error) { + (Some(value), None) => Ok(value), + (_, Some(error)) => Err(ConnectionRpcError::Remote(error)), + _ => Err(ConnectionRpcError::Disconnected(bridge_error( + "bridge_disconnected", + "MCP bridge returned an empty response.", + ))), } } @@ -144,43 +181,36 @@ async fn connect(endpoint: &BridgeEndpoint) -> std::io::Result { let mut connection = Connection { reader: BufReader::new(read), writer: write, - next_id: 2, + next_id: 1, }; let params = serde_json::to_value(AuthParams { token: endpoint.token.clone(), generation: endpoint.generation.clone(), }) .map_err(std::io::Error::other)?; - write_request( - &mut connection, - RpcRequest { - id: 1, - method: "auth".into(), - params, - }, - ) - .await?; - if read_response(&mut connection).await?.error.is_some() { - return Err(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - "NyaTerm MCP authentication failed", - )); - } + connection_rpc(&mut connection, "auth", params) + .await + .map_err(|error| match error { + ConnectionRpcError::Remote(error) | ConnectionRpcError::Disconnected(error) => { + std::io::Error::new(std::io::ErrorKind::PermissionDenied, error.message) + } + })?; Ok(connection) } async fn cancel_request(endpoint: &BridgeEndpoint, request_id: &str) -> std::io::Result<()> { let mut connection = connect(endpoint).await?; - write_request( + connection_rpc( &mut connection, - RpcRequest { - id: 2, - method: "request.cancel".into(), - params: json!({ "requestId": request_id }), - }, + "request.cancel", + json!({ "requestId": request_id }), ) - .await?; - let _ = read_response(&mut connection).await?; + .await + .map_err(|error| match error { + ConnectionRpcError::Remote(error) | ConnectionRpcError::Disconnected(error) => { + std::io::Error::other(error.message) + } + })?; Ok(()) } @@ -268,3 +298,211 @@ fn bridge_error(code: &str, message: &str) -> RpcError { message: message.into(), } } + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use tokio::net::TcpListener; + + use super::*; + + async fn request( + lines: &mut tokio::io::Lines>, + ) -> RpcRequest { + serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap() + } + + async fn respond(writer: &mut tokio::net::tcp::OwnedWriteHalf, id: u64, result: Value) { + let mut bytes = serde_json::to_vec(&RpcResponse { + id, + result: Some(result), + error: None, + }) + .unwrap(); + bytes.push(b'\n'); + writer.write_all(&bytes).await.unwrap(); + } + + async fn authenticate( + stream: TcpStream, + auth_count: &AtomicUsize, + ) -> ( + tokio::io::Lines>, + tokio::net::tcp::OwnedWriteHalf, + ) { + let (reader, mut writer) = stream.into_split(); + let mut lines = BufReader::new(reader).lines(); + let auth = request(&mut lines).await; + assert_eq!(auth.method, "auth"); + assert_eq!(auth.params["token"], "test-token"); + auth_count.fetch_add(1, Ordering::SeqCst); + respond(&mut writer, auth.id, json!({ "authenticated": true })).await; + (lines, writer) + } + + #[tokio::test] + async fn disconnect_invalidates_and_next_call_reauthenticates_and_identifies() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let auth_count = Arc::new(AtomicUsize::new(0)); + let server_count = auth_count.clone(); + let server = tokio::spawn(async move { + let (first, _) = listener.accept().await.unwrap(); + let (mut lines, mut writer) = authenticate(first, &server_count).await; + let identify = request(&mut lines).await; + assert_eq!(identify.method, "client.identify"); + respond(&mut writer, identify.id, json!({ "identified": true })).await; + let call = request(&mut lines).await; + assert_eq!(call.method, "capability.execute"); + respond(&mut writer, call.id, json!({ "value": "first" })).await; + drop(writer); + + let (second, _) = listener.accept().await.unwrap(); + let (mut lines, mut writer) = authenticate(second, &server_count).await; + let identify = request(&mut lines).await; + assert_eq!(identify.method, "client.identify"); + respond(&mut writer, identify.id, json!({ "identified": true })).await; + let call = request(&mut lines).await; + respond(&mut writer, call.id, json!({ "value": "recovered" })).await; + }); + + let client = BridgeClient::new(BridgeEndpoint::for_test(port)); + client + .identify("bridge-test".into(), Some("1.0".into())) + .await; + let first = client + .call("get_environment", json!({}), CancellationToken::new()) + .await + .unwrap(); + assert_eq!(first["value"], "first"); + + let disconnected = client + .call("get_environment", json!({}), CancellationToken::new()) + .await + .unwrap_err(); + assert_eq!(disconnected.code, "bridge_disconnected"); + + let recovered = client + .call("get_environment", json!({}), CancellationToken::new()) + .await + .unwrap(); + assert_eq!(recovered["value"], "recovered"); + server.await.unwrap(); + assert_eq!(auth_count.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn response_id_mismatch_invalidates_the_connection() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let auth_count = Arc::new(AtomicUsize::new(0)); + let server_count = auth_count.clone(); + let server = tokio::spawn(async move { + let (first, _) = listener.accept().await.unwrap(); + let (mut lines, mut writer) = authenticate(first, &server_count).await; + let call = request(&mut lines).await; + respond(&mut writer, call.id + 1, json!({ "wrong": true })).await; + drop(writer); + + let (second, _) = listener.accept().await.unwrap(); + let (mut lines, mut writer) = authenticate(second, &server_count).await; + let call = request(&mut lines).await; + respond(&mut writer, call.id, json!({ "recovered": true })).await; + }); + + let client = BridgeClient::new(BridgeEndpoint::for_test(port)); + let mismatch = client + .call("get_environment", json!({}), CancellationToken::new()) + .await + .unwrap_err(); + assert_eq!(mismatch.code, "bridge_disconnected"); + let recovered = client + .call("get_environment", json!({}), CancellationToken::new()) + .await + .unwrap(); + assert_eq!(recovered["recovered"], true); + server.await.unwrap(); + assert_eq!(auth_count.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn invalid_response_invalidates_the_connection() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let auth_count = Arc::new(AtomicUsize::new(0)); + let server_count = auth_count.clone(); + let server = tokio::spawn(async move { + let (first, _) = listener.accept().await.unwrap(); + let (mut lines, mut writer) = authenticate(first, &server_count).await; + let _ = request(&mut lines).await; + writer.write_all(b"not-json\n").await.unwrap(); + drop(writer); + + let (second, _) = listener.accept().await.unwrap(); + let (mut lines, mut writer) = authenticate(second, &server_count).await; + let call = request(&mut lines).await; + respond(&mut writer, call.id, json!({ "recovered": true })).await; + }); + + let client = BridgeClient::new(BridgeEndpoint::for_test(port)); + let invalid = client + .call("get_environment", json!({}), CancellationToken::new()) + .await + .unwrap_err(); + assert_eq!(invalid.code, "bridge_disconnected"); + assert!( + client + .call("get_environment", json!({}), CancellationToken::new()) + .await + .unwrap()["recovered"] + .as_bool() + .unwrap() + ); + server.await.unwrap(); + assert_eq!(auth_count.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn empty_response_invalidates_the_connection() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let auth_count = Arc::new(AtomicUsize::new(0)); + let server_count = auth_count.clone(); + let server = tokio::spawn(async move { + let (first, _) = listener.accept().await.unwrap(); + let (mut lines, mut writer) = authenticate(first, &server_count).await; + let call = request(&mut lines).await; + let mut bytes = serde_json::to_vec(&RpcResponse { + id: call.id, + result: None, + error: None, + }) + .unwrap(); + bytes.push(b'\n'); + writer.write_all(&bytes).await.unwrap(); + drop(writer); + + let (second, _) = listener.accept().await.unwrap(); + let (mut lines, mut writer) = authenticate(second, &server_count).await; + let call = request(&mut lines).await; + respond(&mut writer, call.id, json!({ "recovered": true })).await; + }); + + let client = BridgeClient::new(BridgeEndpoint::for_test(port)); + let empty = client + .call("get_environment", json!({}), CancellationToken::new()) + .await + .unwrap_err(); + assert_eq!(empty.code, "bridge_disconnected"); + assert_eq!( + client + .call("get_environment", json!({}), CancellationToken::new()) + .await + .unwrap()["recovered"], + true + ); + server.await.unwrap(); + assert_eq!(auth_count.load(Ordering::SeqCst), 2); + } +} diff --git a/src-tauri/crates/nyaterm-mcp/src/main.rs b/src-tauri/crates/nyaterm-mcp/src/main.rs index ab2f4dd6..ff084d99 100644 --- a/src-tauri/crates/nyaterm-mcp/src/main.rs +++ b/src-tauri/crates/nyaterm-mcp/src/main.rs @@ -4,9 +4,9 @@ use std::sync::Arc; use bridge::{BridgeClient, BridgeEndpoint, endpoint_from_environment_or_discovery}; use nyaterm_mcp_protocol::{ - EmptyArgs, OutputReadArgs, PathArgs, SessionArgs, SftpChmodArgs, SftpMkdirArgs, - SftpReadTextArgs, SftpRenameArgs, SftpWriteTextArgs, TerminalExecuteArgs, - TerminalRecentOutputArgs, tool, + EmptyArgs, MCP_TOOL_REGISTRY, McpToolDefinition, OutputReadArgs, PathArgs, SessionArgs, + SftpChmodArgs, SftpMkdirArgs, SftpReadTextArgs, SftpRenameArgs, SftpWriteTextArgs, + TerminalExecuteArgs, TerminalRecentOutputArgs, tool, }; use rmcp::model::{ CallToolRequestParams, CallToolResponse, CallToolResult, Implementation, ListToolsResult, @@ -89,94 +89,37 @@ impl ServerHandler for NyaTermMcp { } fn build_tools() -> Vec { - vec![ - tool_def::( - tool::GET_ENVIRONMENT, - "Return scoped NyaTerm sessions and the optional default session.", - true, - false, - ), - tool_def::( - tool::SESSION_GET, - "Return safe metadata and capability availability for a scoped session.", - true, - false, - ), - tool_def::( - tool::TERMINAL_EXECUTE, - "Execute a command in an existing scoped NyaTerm terminal session.", - false, - false, - ), - tool_def::( - tool::TERMINAL_RECENT_OUTPUT, - "Read recent ANSI-free terminal output for a scoped session.", - true, - false, - ), - tool_def::( - tool::SFTP_HOME, - "Return the remote home directory.", - true, - false, - ), - tool_def::(tool::SFTP_LIST, "List a remote directory.", true, false), - tool_def::(tool::SFTP_STAT, "Read remote path metadata.", true, false), - tool_def::( - tool::SFTP_READ_TEXT, - "Read up to 64 KiB of a remote UTF-8 text file.", - true, - false, - ), - tool_def::( - tool::SFTP_WRITE_TEXT, - "Write a remote UTF-8 text file with optional conflict protection.", - false, - false, - ), - tool_def::(tool::SFTP_MKDIR, "Create a remote directory.", false, false), - tool_def::( - tool::SFTP_RENAME, - "Rename or move a remote path.", - false, - false, - ), - tool_def::( - tool::SFTP_DELETE, - "Delete a remote path using NyaTerm's existing delete semantics.", - false, - true, - ), - tool_def::( - tool::SFTP_CHMOD, - "Change remote path permissions.", - false, - false, - ), - tool_def::( - tool::OUTPUT_READ, - "Read another chunk of a large result produced on this MCP connection.", - true, - false, - ), - ] + MCP_TOOL_REGISTRY + .iter() + .map(|definition| match definition.tool { + tool::GET_ENVIRONMENT => tool_def::(definition), + tool::SESSION_GET | tool::SFTP_HOME => tool_def::(definition), + tool::TERMINAL_EXECUTE => tool_def::(definition), + tool::TERMINAL_RECENT_OUTPUT => tool_def::(definition), + tool::SFTP_LIST | tool::SFTP_STAT | tool::SFTP_DELETE => { + tool_def::(definition) + } + tool::SFTP_READ_TEXT => tool_def::(definition), + tool::SFTP_WRITE_TEXT => tool_def::(definition), + tool::SFTP_MKDIR => tool_def::(definition), + tool::SFTP_RENAME => tool_def::(definition), + tool::SFTP_CHMOD => tool_def::(definition), + tool::OUTPUT_READ => tool_def::(definition), + _ => unreachable!("registry contains an unknown MCP tool"), + }) + .collect() } -fn tool_def( - name: &'static str, - description: &'static str, - read_only: bool, - destructive: bool, -) -> Tool { +fn tool_def(definition: &McpToolDefinition) -> Tool { let schema = serde_json::to_value(schemars::schema_for!(T)) .unwrap_or_else(|_| json!({ "type": "object" })); let object = schema.as_object().cloned().unwrap_or_else(Map::new); - let mut item = Tool::new(name, description, object); + let mut item = Tool::new(definition.tool, definition.description, object); item.annotations = Some( ToolAnnotations::new() - .read_only(read_only) - .destructive(destructive) - .open_world(false), + .read_only(definition.read_only_hint) + .destructive(definition.destructive_hint) + .open_world(definition.open_world_hint), ); item } @@ -193,7 +136,7 @@ async fn main() -> Result<(), Box> { #[cfg(test)] mod tests { - use nyaterm_mcp_protocol::{RpcRequest, RpcResponse}; + use nyaterm_mcp_protocol::{MCP_TOOL_REGISTRY, RpcRequest, RpcResponse}; use tokio::{ io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines, ReadHalf, WriteHalf}, net::TcpListener, @@ -201,6 +144,31 @@ mod tests { use super::*; + #[test] + fn listed_tool_annotations_come_from_the_shared_registry() { + let tools = build_tools(); + assert_eq!(tools.len(), MCP_TOOL_REGISTRY.len()); + for definition in MCP_TOOL_REGISTRY { + let tool = tools + .iter() + .find(|tool| tool.name == definition.tool) + .unwrap(); + let value = serde_json::to_value(tool).unwrap(); + assert_eq!( + value["annotations"]["readOnlyHint"], + definition.read_only_hint + ); + assert_eq!( + value["annotations"]["destructiveHint"], + definition.destructive_hint + ); + assert_eq!( + value["annotations"]["openWorldHint"], + definition.open_world_hint + ); + } + } + async fn send_client_message(writer: &mut WriteHalf, raw: &str) { let value = serde_json::from_str::(raw).expect("valid MCP client message"); writer diff --git a/src-tauri/src/cmd/mcp.rs b/src-tauri/src/cmd/mcp.rs index 8f2caeb5..53a32579 100644 --- a/src-tauri/src/cmd/mcp.rs +++ b/src-tauri/src/cmd/mcp.rs @@ -69,6 +69,20 @@ pub async fn respond_external_mcp_approval( manager.respond_approval(&request_id, decision).await } +#[tauri::command] +pub async fn report_mcp_active_session( + window: tauri::WebviewWindow, + manager: tauri::State<'_, Arc>, + session_id: Option, +) -> AppResult<()> { + if !crate::window_state::is_main_window_label(window.label()) { + return Err(AppError::Config( + "Only a NyaTerm main window can report its active MCP session.".into(), + )); + } + manager.set_active_session(window.label(), session_id).await +} + #[tauri::command] pub fn get_external_mcp_client_configs( manager: tauri::State<'_, Arc>, diff --git a/src-tauri/src/core/capabilities/catalog.rs b/src-tauri/src/core/capabilities/catalog.rs index 3add6729..d3dd3893 100644 --- a/src-tauri/src/core/capabilities/catalog.rs +++ b/src-tauri/src/core/capabilities/catalog.rs @@ -1,136 +1,28 @@ -use nyaterm_mcp_protocol::{capability, tool}; +pub use nyaterm_mcp_protocol::CapabilityAccess; +use nyaterm_mcp_protocol::{McpToolDefinition, definition_for_tool}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CapabilityAccess { - Read, - SensitiveRead, - Write, - DestructiveWrite, -} - -#[derive(Debug, Clone, Copy)] -pub struct CapabilityDefinition { - pub id: &'static str, - pub mcp_tool: Option<&'static str>, - pub access: CapabilityAccess, - pub requires_session: bool, -} - -pub const CATALOG: &[CapabilityDefinition] = &[ - CapabilityDefinition { - id: capability::ENVIRONMENT, - mcp_tool: Some(tool::GET_ENVIRONMENT), - access: CapabilityAccess::Read, - requires_session: false, - }, - CapabilityDefinition { - id: capability::SESSION_GET, - mcp_tool: Some(tool::SESSION_GET), - access: CapabilityAccess::Read, - requires_session: true, - }, - CapabilityDefinition { - id: capability::TERMINAL_EXECUTE, - mcp_tool: Some(tool::TERMINAL_EXECUTE), - access: CapabilityAccess::Write, - requires_session: true, - }, - CapabilityDefinition { - id: capability::TERMINAL_RECENT_OUTPUT, - mcp_tool: Some(tool::TERMINAL_RECENT_OUTPUT), - access: CapabilityAccess::SensitiveRead, - requires_session: true, - }, - CapabilityDefinition { - id: capability::SFTP_HOME, - mcp_tool: Some(tool::SFTP_HOME), - access: CapabilityAccess::SensitiveRead, - requires_session: true, - }, - CapabilityDefinition { - id: capability::SFTP_LIST, - mcp_tool: Some(tool::SFTP_LIST), - access: CapabilityAccess::SensitiveRead, - requires_session: true, - }, - CapabilityDefinition { - id: capability::SFTP_STAT, - mcp_tool: Some(tool::SFTP_STAT), - access: CapabilityAccess::SensitiveRead, - requires_session: true, - }, - CapabilityDefinition { - id: capability::SFTP_READ, - mcp_tool: Some(tool::SFTP_READ_TEXT), - access: CapabilityAccess::SensitiveRead, - requires_session: true, - }, - CapabilityDefinition { - id: capability::SFTP_WRITE, - mcp_tool: Some(tool::SFTP_WRITE_TEXT), - access: CapabilityAccess::Write, - requires_session: true, - }, - CapabilityDefinition { - id: capability::SFTP_MKDIR, - mcp_tool: Some(tool::SFTP_MKDIR), - access: CapabilityAccess::Write, - requires_session: true, - }, - CapabilityDefinition { - id: capability::SFTP_RENAME, - mcp_tool: Some(tool::SFTP_RENAME), - access: CapabilityAccess::Write, - requires_session: true, - }, - CapabilityDefinition { - id: capability::SFTP_DELETE, - mcp_tool: Some(tool::SFTP_DELETE), - access: CapabilityAccess::DestructiveWrite, - requires_session: true, - }, - CapabilityDefinition { - id: capability::SFTP_CHMOD, - mcp_tool: Some(tool::SFTP_CHMOD), - access: CapabilityAccess::Write, - requires_session: true, - }, - CapabilityDefinition { - id: capability::OUTPUT_READ, - mcp_tool: Some(tool::OUTPUT_READ), - access: CapabilityAccess::SensitiveRead, - requires_session: false, - }, -]; - -#[cfg(test)] -fn capability_by_id(id: &str) -> Option<&'static CapabilityDefinition> { - CATALOG.iter().find(|definition| definition.id == id) -} - -pub fn capability_for_tool(name: &str) -> Option<&'static CapabilityDefinition> { - CATALOG - .iter() - .find(|definition| definition.mcp_tool == Some(name)) +pub fn capability_for_tool(name: &str) -> Option<&'static McpToolDefinition> { + definition_for_tool(name) } #[cfg(test)] mod tests { + use nyaterm_mcp_protocol::{CapabilityAccess, MCP_TOOL_REGISTRY, capability, tool}; + use super::*; #[test] - fn delete_is_destructive_and_all_tools_are_unique() { + fn every_registered_tool_has_a_host_capability() { + for definition in MCP_TOOL_REGISTRY { + assert_eq!(capability_for_tool(definition.tool), Some(definition)); + } assert_eq!( - capability_by_id(capability::SFTP_DELETE).unwrap().access, + capability_for_tool(tool::SFTP_DELETE).unwrap().capability, + capability::SFTP_DELETE + ); + assert_eq!( + capability_for_tool(tool::SFTP_DELETE).unwrap().access, CapabilityAccess::DestructiveWrite ); - let mut tools = CATALOG - .iter() - .filter_map(|item| item.mcp_tool) - .collect::>(); - let before = tools.len(); - tools.sort_unstable(); - tools.dedup(); - assert_eq!(tools.len(), before); } } diff --git a/src-tauri/src/core/capabilities/mod.rs b/src-tauri/src/core/capabilities/mod.rs index 3fe4d35f..329b2144 100644 --- a/src-tauri/src/core/capabilities/mod.rs +++ b/src-tauri/src/core/capabilities/mod.rs @@ -8,9 +8,9 @@ mod terminal; pub use catalog::{CapabilityAccess, capability_for_tool}; pub use output_store::OutputStore; -pub use policy::{PolicyDecision, assess_command_risk, decide_policy}; +pub use policy::{PolicyDecision, RiskAssessment, assess_command_risk, decide_policy}; pub use recent_output::RecentOutputStore; -pub use scope::McpScope; +pub use scope::{McpScope, McpScopeSnapshot}; pub use terminal::{ TerminalExecuteRequest, TerminalExecutionPresentation, execute_terminal_command, }; diff --git a/src-tauri/src/core/capabilities/policy.rs b/src-tauri/src/core/capabilities/policy.rs index fc1e9c69..4865a4b0 100644 --- a/src-tauri/src/core/capabilities/policy.rs +++ b/src-tauri/src/core/capabilities/policy.rs @@ -10,15 +10,16 @@ pub enum PolicyDecision { } #[derive(Debug, Clone)] -pub struct CommandRisk { +pub struct RiskAssessment { pub level: RiskLevel, pub reason: String, + pub auto_executable: bool, } pub fn decide_policy( mode: &AiPermissionMode, access: CapabilityAccess, - command_risk: Option<&RiskLevel>, + assessment: Option<&RiskAssessment>, ) -> PolicyDecision { if matches!( access, @@ -30,7 +31,10 @@ pub fn decide_policy( if access == CapabilityAccess::DestructiveWrite { return PolicyDecision::RequireApproval; } - if command_risk.is_some_and(|risk| *risk >= RiskLevel::High) { + if assessment.is_some_and(|risk| risk.level >= RiskLevel::High) { + return PolicyDecision::RequireApproval; + } + if *mode == AiPermissionMode::Auto && assessment.is_some_and(|risk| !risk.auto_executable) { return PolicyDecision::RequireApproval; } match (mode, access) { @@ -45,110 +49,93 @@ pub fn decide_policy( } } -pub fn assess_command_risk(command: &str) -> CommandRisk { - let normalized = command - .trim() - .replace("\r\n", "\n") - .replace('\n', " ") - .to_ascii_lowercase(); - let compact = normalized.split_whitespace().collect::>().join(" "); - if compact.is_empty() { - return risk(RiskLevel::Medium, "empty command"); +pub fn assess_command_risk(command: &str) -> RiskAssessment { + let normalized = command.trim().replace("\r\n", "\n").replace('\r', "\n"); + if normalized.is_empty() { + return risk(RiskLevel::Medium, "empty command", false); } - if is_root_rm_command(&compact) - || (compact.starts_with("dd ") && compact.contains("of=/dev/")) - || contains_any( - &compact, - &[ - "mkfs", - "wipefs", - ":(){", - "shutdown", - "poweroff", - "reboot", - "halt", - "systemctl stop ssh", - "systemctl stop sshd", - "service ssh stop", - "service sshd stop", - ], - ) + if normalized + .split_whitespace() + .collect::() + .contains(":(){:|:&};:") { return risk( RiskLevel::Critical, "matches irreversible or system-disruptive command pattern", + false, ); } - if compact.starts_with("sudo ") - || contains_any( - &compact, - &[ - "rm -r", - "rm -f", - " rmdir ", - " chmod -r", - " chown -r", - "systemctl restart", - "systemctl stop", - "service ", - "apt install", - "apt remove", - "apt purge", - "yum install", - "yum remove", - "dnf install", - "dnf remove", - "pacman -s", - "pacman -r", - "brew install", - "brew uninstall", - "npm install -g", - "pip install", - "docker rm", - "docker rmi", - "docker system prune", - "kubectl delete", - "kubectl drain", - "kubectl apply", - "kubectl replace", - "git reset --hard", - "git clean -fd", - ], - ) + let tokens = tokenize_shell(&normalized.to_ascii_lowercase()); + if tokens.is_empty() { + return risk(RiskLevel::Medium, "command could not be classified", false); + } + let stages = command_stages(&tokens); + if stages.is_empty() { + return risk(RiskLevel::Medium, "command could not be classified", false); + } + + if stages.iter().any(|stage| is_critical_stage(stage)) { + return risk( + RiskLevel::Critical, + "matches irreversible or system-disruptive command pattern", + false, + ); + } + if is_download_pipe_to_shell(&tokens) + || has_sensitive_write_redirection(&tokens) + || stages.iter().any(|stage| is_high_risk_stage(stage)) { return risk( RiskLevel::High, - "matches privileged, destructive, restart, package, container, or cluster mutation pattern", + "matches privileged or high-impact mutation pattern", + false, ); } - if contains_any( - &format!(" {compact} "), - &[ - " > ", - ">>", - " tee ", - " touch ", - " mkdir ", - " cp ", - " mv ", - " chmod ", - " chown ", - " setfacl ", - " export ", - "git checkout", - "git switch", - "git pull", - "git merge", - "npm run", - "make install", - ], - ) { - return risk( + + let has_redirection = tokens + .iter() + .any(|token| matches!(token.as_str(), ">" | ">>")); + let mut saw_write = has_redirection; + for stage in &stages { + match classify_stage(stage) { + StageClass::ReadOnly => {} + StageClass::Write => saw_write = true, + StageClass::Unknown => { + return risk( + RiskLevel::Medium, + "command is not explicitly classified as safe for automatic execution", + false, + ); + } + } + } + if saw_write { + risk( RiskLevel::Medium, - "matches local write or state-changing command pattern", - ); + "matches a known ordinary state-changing command pattern", + true, + ) + } else { + risk( + RiskLevel::Low, + "matches read-only diagnostic command patterns", + true, + ) } - let readonly = [ +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StageClass { + ReadOnly, + Write, + Unknown, +} + +fn classify_stage(stage: &[String]) -> StageClass { + let Some((command, args)) = executable_and_args(stage) else { + return StageClass::Unknown; + }; + let read_only = [ "ls", "pwd", "whoami", @@ -165,68 +152,423 @@ pub fn assess_command_risk(command: &str) -> CommandRisk { "du", "free", "top", + "htop", "ps", "ss", "netstat", - "ip ", "journalctl", - "systemctl status", - "docker ps", - "docker logs", - "kubectl get", - "kubectl describe", - "git status", - "git log", - "git diff", + "printenv", + "which", + "whereis", ]; - if readonly - .iter() - .any(|prefix| compact == prefix.trim() || compact.starts_with(&format!("{prefix} "))) - { - return risk(RiskLevel::Low, "matches read-only diagnostic pattern"); + if read_only.contains(&command) { + return StageClass::ReadOnly; } - risk( - RiskLevel::Medium, - "no explicit read-only pattern matched; defaulting to medium", - ) + if command == "env" && args.is_empty() { + return StageClass::ReadOnly; + } + if command == "ip" + && (args.is_empty() + || args.first().is_some_and(|arg| { + matches!( + arg.as_str(), + "a" | "addr" | "address" | "link" | "route" | "neigh" | "rule" + ) + })) + { + return StageClass::ReadOnly; + } + if command == "systemctl" && args.first().is_some_and(|arg| arg == "status") { + return StageClass::ReadOnly; + } + if command == "docker" + && args + .first() + .is_some_and(|arg| matches!(arg.as_str(), "ps" | "logs" | "inspect")) + { + return StageClass::ReadOnly; + } + if command == "kubectl" + && args + .first() + .is_some_and(|arg| matches!(arg.as_str(), "get" | "describe" | "logs" | "explain")) + { + return StageClass::ReadOnly; + } + if command == "git" + && args + .first() + .is_some_and(|arg| matches!(arg.as_str(), "status" | "log" | "diff" | "show")) + { + return StageClass::ReadOnly; + } + + let ordinary_write = [ + "touch", "mkdir", "cp", "mv", "chmod", "chown", "setfacl", "export", + ]; + if ordinary_write.contains(&command) { + return StageClass::Write; + } + if command == "git" + && args.first().is_some_and(|arg| { + matches!( + arg.as_str(), + "checkout" | "switch" | "pull" | "merge" | "add" | "commit" + ) + }) + { + return StageClass::Write; + } + if command == "make" && args.iter().any(|arg| arg == "install") { + return StageClass::Write; + } + StageClass::Unknown } -fn risk(level: RiskLevel, reason: &str) -> CommandRisk { - CommandRisk { +fn is_critical_stage(stage: &[String]) -> bool { + let Some((command, args)) = executable_and_args(stage) else { + return false; + }; + if command == "rm" && is_root_rm_args(args) { + return true; + } + if command == "dd" && args.iter().any(|arg| arg.starts_with("of=/dev/")) { + return true; + } + if matches!( + command, + "mkfs" | "wipefs" | "shutdown" | "poweroff" | "reboot" | "halt" + ) || command.starts_with("mkfs.") + { + return true; + } + (command == "systemctl" + && args.first().is_some_and(|arg| arg == "stop") + && args.iter().skip(1).any(|arg| { + matches!( + arg.as_str(), + "ssh" | "sshd" | "ssh.service" | "sshd.service" + ) + })) + || (command == "service" + && args + .first() + .is_some_and(|arg| matches!(arg.as_str(), "ssh" | "sshd")) + && args.get(1).is_some_and(|arg| arg == "stop")) + || stage.join("").contains(":(){:|:&};:") +} + +fn is_high_risk_stage(stage: &[String]) -> bool { + let Some((command, args)) = executable_and_args(stage) else { + return false; + }; + if matches!(command, "sudo" | "doas" | "su") { + return true; + } + if matches!( + command, + "rm" | "rmdir" + | "truncate" + | "iptables" + | "ip6tables" + | "nft" + | "ufw" + | "useradd" + | "userdel" + | "usermod" + | "passwd" + | "visudo" + ) { + return true; + } + if command == "find" + && args + .iter() + .any(|arg| matches!(arg.as_str(), "-delete" | "-exec" | "-execdir")) + { + return true; + } + if command == "ip" + && args.iter().any(|arg| { + matches!( + arg.as_str(), + "add" | "delete" | "del" | "replace" | "change" | "set" | "flush" + ) + }) + { + return true; + } + if matches!(command, "chmod" | "chown") + && args + .iter() + .any(|arg| arg.starts_with('-') && (arg.contains('r') || arg.contains('R'))) + { + return true; + } + if matches!(command, "cp" | "mv" | "chmod" | "chown") + && args.iter().any(|arg| is_sensitive_terminal_path(arg)) + { + return true; + } + if command == "sed" && args.iter().any(|arg| arg == "-i" || arg.starts_with("-i")) { + return true; + } + if command == "perl" + && args.iter().any(|arg| { + arg.starts_with('-') + && arg.chars().any(|flag| flag == 'p') + && arg.chars().any(|flag| flag == 'i') + }) + { + return true; + } + if command == "systemctl" + && args + .first() + .is_some_and(|arg| matches!(arg.as_str(), "restart" | "stop" | "disable" | "mask")) + { + return true; + } + if command == "service" { + return true; + } + if matches!( + command, + "apt" | "apt-get" | "yum" | "dnf" | "pacman" | "brew" | "pip" | "pip3" + ) && args.iter().any(|arg| { + matches!( + arg.as_str(), + "install" | "remove" | "purge" | "uninstall" | "-s" | "-r" + ) + }) { + return true; + } + if command == "npm" && args.iter().any(|arg| arg == "-g" || arg == "--global") { + return true; + } + if command == "docker" { + return matches!(args.first().map(String::as_str), Some("rm" | "rmi")) + || args.starts_with(&["system".into(), "prune".into()]) + || args.starts_with(&["compose".into(), "down".into()]); + } + if command == "kubectl" + && args + .first() + .is_some_and(|arg| matches!(arg.as_str(), "delete" | "drain" | "apply" | "replace")) + { + return true; + } + if command == "terraform" + && args + .first() + .is_some_and(|arg| matches!(arg.as_str(), "apply" | "destroy")) + { + return true; + } + if command == "helm" && args.first().is_some_and(|arg| arg == "uninstall") { + return true; + } + if command == "git" + && ((args.first().is_some_and(|arg| arg == "reset") + && args.iter().any(|arg| arg == "--hard")) + || (args.first().is_some_and(|arg| arg == "clean") + && args + .iter() + .any(|arg| arg.starts_with('-') && arg.contains('f')))) + { + return true; + } + if matches!(command, "mysql" | "psql") && contains_sql_word(args, "drop") { + return true; + } + command == "redis-cli" + && args + .iter() + .any(|arg| matches!(arg.as_str(), "flushall" | "flushdb")) +} + +fn is_download_pipe_to_shell(tokens: &[String]) -> bool { + tokens.iter().enumerate().any(|(index, token)| { + if token != "|" { + return false; + } + let left_start = tokens[..index] + .iter() + .rposition(|token| matches!(token.as_str(), ";" | "&" | "&&" | "||" | "|")) + .map_or(0, |position| position + 1); + let right_end = tokens[index + 1..] + .iter() + .position(|token| matches!(token.as_str(), ";" | "&" | "&&" | "||" | "|")) + .map_or(tokens.len(), |position| index + 1 + position); + let left = executable_and_args(&tokens[left_start..index]).map(|value| value.0); + let right = executable_and_args(&tokens[index + 1..right_end]).map(|value| value.0); + matches!(left, Some("curl" | "wget")) + && matches!(right, Some("sh" | "bash" | "zsh" | "fish")) + }) +} + +fn has_sensitive_write_redirection(tokens: &[String]) -> bool { + tokens + .windows(2) + .any(|pair| matches!(pair[0].as_str(), ">" | ">>") && is_sensitive_terminal_path(&pair[1])) +} + +fn executable_and_args(stage: &[String]) -> Option<(&str, &[String])> { + let mut index = 0; + while stage + .get(index) + .is_some_and(|token| token.contains('=') && !token.starts_with('=')) + { + index += 1; + } + let mut command = stage.get(index)?; + if basename(command) == "env" { + index += 1; + while stage.get(index).is_some_and(|token| { + token.starts_with('-') || (token.contains('=') && !token.starts_with('=')) + }) { + index += 1; + } + let Some(nested) = stage.get(index) else { + return Some(("env", &[])); + }; + command = nested; + } + Some((basename(command), &stage[index + 1..])) +} + +fn is_sensitive_terminal_path(value: &str) -> bool { + [ + "/etc", "/boot", "/bin", "/sbin", "/usr", "/lib", "/lib64", "/var/lib", "/root", + ] + .iter() + .any(|root| value == *root || value.starts_with(&format!("{root}/"))) + || value == "~/.ssh" + || value.starts_with("~/.ssh/") + || value.contains("/.ssh/") +} + +fn basename(command: &str) -> &str { + command.rsplit(['/', '\\']).next().unwrap_or(command) +} + +fn is_root_rm_args(args: &[String]) -> bool { + let recursive_force = args.iter().any(|arg| { + arg.starts_with('-') + && arg.chars().any(|flag| flag == 'r') + && arg.chars().any(|flag| flag == 'f') + }); + recursive_force + && args + .iter() + .any(|arg| matches!(arg.as_str(), "/" | "/*" | "--no-preserve-root")) +} + +fn contains_sql_word(args: &[String], needle: &str) -> bool { + args.iter().any(|arg| { + arg.split(|character: char| !character.is_ascii_alphanumeric() && character != '_') + .any(|word| word == needle) + }) +} + +fn command_stages(tokens: &[String]) -> Vec> { + let mut stages = Vec::new(); + let mut current = Vec::new(); + for token in tokens { + if matches!(token.as_str(), ";" | "&" | "&&" | "||" | "|") { + if !current.is_empty() { + stages.push(std::mem::take(&mut current)); + } + } else if !matches!(token.as_str(), ">" | ">>") { + current.push(token.clone()); + } + } + if !current.is_empty() { + stages.push(current); + } + stages +} + +fn tokenize_shell(command: &str) -> Vec { + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut quote = None; + let mut escaped = false; + let mut chars = command.chars().peekable(); + while let Some(character) = chars.next() { + if escaped { + current.push(character); + escaped = false; + continue; + } + if character == '\\' && quote != Some('\'') { + escaped = true; + continue; + } + if matches!(character, '\'' | '"') { + if quote == Some(character) { + quote = None; + } else if quote.is_none() { + quote = Some(character); + } else { + current.push(character); + } + continue; + } + if quote.is_none() && character == '\n' { + push_token(&mut tokens, &mut current); + tokens.push(";".into()); + continue; + } + if quote.is_none() && character.is_whitespace() { + push_token(&mut tokens, &mut current); + continue; + } + if quote.is_none() && matches!(character, ';' | '|' | '&' | '>') { + push_token(&mut tokens, &mut current); + let mut operator = character.to_string(); + if chars.peek() == Some(&character) && matches!(character, '|' | '&' | '>') { + operator.push(chars.next().unwrap()); + } + tokens.push(operator); + continue; + } + current.push(character); + } + push_token(&mut tokens, &mut current); + tokens +} + +fn push_token(tokens: &mut Vec, current: &mut String) { + if !current.is_empty() { + tokens.push(std::mem::take(current)); + } +} + +pub(crate) fn risk(level: RiskLevel, reason: &str, auto_executable: bool) -> RiskAssessment { + RiskAssessment { level, reason: reason.to_string(), + auto_executable, } } -fn contains_any(command: &str, patterns: &[&str]) -> bool { - patterns.iter().any(|pattern| command.contains(pattern)) -} - -fn is_root_rm_command(command: &str) -> bool { - let tokens = command.split_whitespace().collect::>(); - tokens.first() == Some(&"rm") - && tokens - .iter() - .any(|token| token.starts_with('-') && token.contains('r') && token.contains('f')) - && tokens - .iter() - .skip(1) - .any(|token| matches!(*token, "/" | "/*" | "--no-preserve-root")) -} - #[cfg(test)] mod tests { use super::*; #[test] fn permission_matrix_is_conservative() { + let safe = risk(RiskLevel::Medium, "known write", true); + let unknown = risk(RiskLevel::Medium, "unknown", false); + let high = risk(RiskLevel::High, "high", false); assert_eq!( decide_policy(&AiPermissionMode::Observer, CapabilityAccess::Write, None), PolicyDecision::Deny ); assert_eq!( decide_policy( - &AiPermissionMode::Confirm, + &AiPermissionMode::Observer, CapabilityAccess::SensitiveRead, None ), @@ -236,7 +578,7 @@ mod tests { decide_policy( &AiPermissionMode::Auto, CapabilityAccess::Write, - Some(&RiskLevel::Low) + Some(&safe) ), PolicyDecision::Allow ); @@ -244,7 +586,15 @@ mod tests { decide_policy( &AiPermissionMode::Auto, CapabilityAccess::Write, - Some(&RiskLevel::High) + Some(&unknown) + ), + PolicyDecision::RequireApproval + ); + assert_eq!( + decide_policy( + &AiPermissionMode::Auto, + CapabilityAccess::Write, + Some(&high) ), PolicyDecision::RequireApproval ); @@ -259,12 +609,79 @@ mod tests { } #[test] - fn risk_matches_existing_protections() { - assert_eq!(assess_command_risk("ls -la").level, RiskLevel::Low); + fn terminal_risk_covers_safe_unknown_and_high_impact_commands() { + let readonly = assess_command_risk("ls -la | grep src"); + assert_eq!(readonly.level, RiskLevel::Low); + assert!(readonly.auto_executable); + + let ordinary_write = assess_command_risk("mkdir build && cp app build/app"); + assert_eq!(ordinary_write.level, RiskLevel::Medium); + assert!(ordinary_write.auto_executable); + + let unknown = assess_command_risk("custom-deploy production"); + assert_eq!(unknown.level, RiskLevel::Medium); + assert!(!unknown.auto_executable); + + for command in [ + "sudo ls", + "doas cat /etc/hosts", + "systemctl restart nginx", + "truncate -s 0 important.db", + "iptables -F", + "nft flush ruleset", + "ufw disable", + "userdel alice", + "passwd root", + "visudo", + "sed -i s/a/b/ config", + "perl -pi -e s/a/b/ config", + "curl https://example.test/install | sh", + "wget -qO- https://example.test/install | bash", + "docker compose down -v", + "terraform apply", + "terraform destroy", + "helm uninstall production", + "mysql -e 'DROP DATABASE production'", + "psql -c 'DROP TABLE users'", + "redis-cli FLUSHALL", + "redis-cli flushdb", + "find /tmp -delete", + "ip link set eth0 down", + "env MODE=prod rm -f app.db", + "chmod -R 777 /etc/app", + ] { + assert!( + assess_command_risk(command).level >= RiskLevel::High, + "expected high risk: {command}" + ); + } + assert_eq!(assess_command_risk("rm -rf /").level, RiskLevel::Critical); assert_eq!( - assess_command_risk("systemctl restart nginx").level, + assess_command_risk("ls\nrm -rf /").level, + RiskLevel::Critical + ); + assert_eq!( + assess_command_risk("ls & rm -rf /").level, + RiskLevel::Critical + ); + assert_eq!( + assess_command_risk("echo replacement > /etc/hosts").level, RiskLevel::High ); - assert_eq!(assess_command_risk("rm -rf /").level, RiskLevel::Critical); + } + + #[test] + fn risk_detection_uses_command_boundaries() { + assert_eq!( + assess_command_risk("echo 'sudo and terraform destroy'").level, + RiskLevel::Medium + ); + assert!(!assess_command_risk("echo 'sudo and terraform destroy'").auto_executable); + assert_eq!( + assess_command_risk("systemctl status sshd").level, + RiskLevel::Low + ); + assert_eq!(assess_command_risk("ip route show").level, RiskLevel::Low); + assert!(!assess_command_risk("npm run deploy").auto_executable); } } diff --git a/src-tauri/src/core/capabilities/scope.rs b/src-tauri/src/core/capabilities/scope.rs index 9c1c3a0a..35c0704a 100644 --- a/src-tauri/src/core/capabilities/scope.rs +++ b/src-tauri/src/core/capabilities/scope.rs @@ -1,26 +1,80 @@ use std::collections::HashSet; +use crate::core::session::SessionInfo; use crate::error::{AppError, AppResult}; #[derive(Debug, Clone)] -pub struct McpScope { +pub enum McpScope { + Explicit { + session_ids: HashSet, + default_session_id: Option, + }, + CurrentWindow { + owner_window_label: String, + }, + AllSessions, +} + +#[derive(Debug, Clone)] +pub struct McpScopeSnapshot { pub session_ids: HashSet, pub default_session_id: Option, } impl McpScope { - pub fn new( + pub fn explicit( session_ids: impl IntoIterator, default_session_id: Option, ) -> Self { let session_ids = session_ids.into_iter().collect::>(); let default_session_id = default_session_id.filter(|id| session_ids.contains(id)); - Self { + Self::Explicit { session_ids, default_session_id, } } + pub fn current_window(owner_window_label: impl Into) -> Self { + Self::CurrentWindow { + owner_window_label: owner_window_label.into(), + } + } + + pub fn resolve(&self, sessions: &[SessionInfo]) -> McpScopeSnapshot { + match self { + Self::Explicit { + session_ids, + default_session_id, + } => { + let live_ids = sessions + .iter() + .map(|session| session.id.as_str()) + .collect::>(); + let session_ids = session_ids + .iter() + .filter(|id| live_ids.contains(id.as_str())) + .cloned() + .collect::>(); + let default_session_id = default_session_id + .as_ref() + .filter(|id| session_ids.contains(*id)) + .cloned(); + McpScopeSnapshot { + session_ids, + default_session_id, + } + } + Self::CurrentWindow { owner_window_label } => { + dynamic_snapshot(sessions.iter().filter(|session| { + session.owner_window_label.as_deref() == Some(owner_window_label.as_str()) + })) + } + Self::AllSessions => dynamic_snapshot(sessions.iter()), + } + } +} + +impl McpScopeSnapshot { pub fn require(&self, session_id: &str) -> AppResult<()> { if self.session_ids.contains(session_id) { Ok(()) @@ -44,20 +98,81 @@ impl McpScope { } } +fn dynamic_snapshot<'a>(sessions: impl Iterator) -> McpScopeSnapshot { + let session_ids = sessions + .map(|session| session.id.clone()) + .collect::>(); + let default_session_id = (session_ids.len() == 1) + .then(|| session_ids.iter().next().cloned()) + .flatten(); + McpScopeSnapshot { + session_ids, + default_session_id, + } +} + #[cfg(test)] mod tests { + use crate::config::AiExecutionProfile; + use crate::core::session::SessionType; + use super::*; + + fn session(id: &str, owner: &str) -> SessionInfo { + SessionInfo { + id: id.into(), + name: id.into(), + session_type: SessionType::SSH, + started_at: String::new(), + connection_id: None, + connected: true, + owner_window_label: Some(owner.into()), + ai_execution_profile: AiExecutionProfile::Auto, + injection_active: true, + remote_file_browser_enabled: true, + remote_stats_enabled: true, + ssh_profile: None, + } + } #[test] - fn resolves_only_scoped_default() { - let scope = McpScope::new(["a".into(), "b".into()], Some("a".into())); - assert_eq!(scope.resolve_terminal_session(None).unwrap(), "a"); - assert!(scope.resolve_terminal_session(Some("c")).is_err()); + fn explicit_scope_is_frozen_and_drops_closed_sessions() { + let scope = McpScope::explicit(["a".into(), "b".into()], Some("a".into())); + let initial = scope.resolve(&[session("a", "main"), session("b", "main")]); + assert_eq!(initial.resolve_terminal_session(None).unwrap(), "a"); + assert!(initial.resolve_terminal_session(Some("c")).is_err()); + + let changed = scope.resolve(&[session("b", "main"), session("c", "main")]); + assert_eq!(changed.session_ids, HashSet::from(["b".into()])); + assert!(changed.default_session_id.is_none()); + } + + #[test] + fn current_window_scope_resolves_new_sessions_and_excludes_other_windows() { + let scope = McpScope::current_window("main"); + let initial = scope.resolve(&[session("a", "main"), session("x", "main-2")]); + assert_eq!(initial.session_ids, HashSet::from(["a".into()])); + assert_eq!(initial.default_session_id.as_deref(), Some("a")); + + let changed = scope.resolve(&[ + session("a", "main"), + session("b", "main"), + session("x", "main-2"), + ]); + assert_eq!(changed.session_ids, HashSet::from(["a".into(), "b".into()])); + assert!(changed.default_session_id.is_none()); } #[test] - fn multiple_sessions_without_default_require_explicit_id() { - let scope = McpScope::new(["a".into(), "b".into()], None); - assert!(scope.resolve_terminal_session(None).is_err()); + fn all_sessions_scope_resolves_new_sessions() { + let scope = McpScope::AllSessions; + assert_eq!(scope.resolve(&[session("a", "main")]).session_ids.len(), 1); + assert_eq!( + scope + .resolve(&[session("a", "main"), session("b", "main-2")]) + .session_ids + .len(), + 2 + ); } } diff --git a/src-tauri/src/core/capabilities/sftp.rs b/src-tauri/src/core/capabilities/sftp.rs index f40b2745..746243dd 100644 --- a/src-tauri/src/core/capabilities/sftp.rs +++ b/src-tauri/src/core/capabilities/sftp.rs @@ -4,6 +4,160 @@ use crate::core::session::{SessionInfo, SessionManager, SessionType}; use crate::core::sftp::{self, FileEntry, FileProperties, RemoteTextFile, WriteRemoteTextResult}; use crate::error::{AppError, AppResult}; +use super::RiskAssessment; +use super::policy::risk; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SftpRiskOperation { + Read, + Write, + Mkdir, + Rename, + Delete, + Chmod, +} + +pub fn assess_sftp_risk( + operation: SftpRiskOperation, + path: &str, + destination_path: Option<&str>, + force: bool, + mode: Option<&str>, +) -> RiskAssessment { + if operation == SftpRiskOperation::Delete { + return risk( + crate::config::RiskLevel::High, + "remote path deletion is destructive", + false, + ); + } + if operation == SftpRiskOperation::Read { + return risk( + crate::config::RiskLevel::Medium, + "remote file access may expose sensitive data", + true, + ); + } + let (path, path_has_parent_traversal) = normalize_remote_path(path); + let (destination, destination_has_parent_traversal) = destination_path + .map(normalize_remote_path) + .unwrap_or_default(); + let sensitive = + is_sensitive_path(&path) || (!destination.is_empty() && is_sensitive_path(&destination)); + if force { + return risk( + crate::config::RiskLevel::High, + "force write bypasses optimistic concurrency protection", + false, + ); + } + if sensitive || path_has_parent_traversal || destination_has_parent_traversal { + return risk( + crate::config::RiskLevel::High, + "mutation targets a sensitive or ambiguously resolved remote path", + false, + ); + } + if operation == SftpRiskOperation::Chmod && mode.is_some_and(is_dangerous_mode) { + return risk( + crate::config::RiskLevel::Medium, + "permission change grants broad remote access", + true, + ); + } + risk( + crate::config::RiskLevel::Medium, + "ordinary remote filesystem mutation", + true, + ) +} + +fn normalize_remote_path(path: &str) -> (String, bool) { + let path = path.trim().replace('\\', "/"); + let absolute = path.starts_with('/'); + let home = path == "~" || path.starts_with("~/"); + let mut parent_traversal = false; + let mut parts = Vec::new(); + for part in path.split('/') { + match part { + "" | "." => {} + ".." => { + parent_traversal = true; + if parts.last().is_some_and(|part| *part != "~") { + parts.pop(); + } + } + _ => parts.push(part), + } + } + let joined = parts.join("/"); + let normalized = if absolute && joined.is_empty() { + "/".to_string() + } else if absolute { + format!("/{joined}") + } else if home && !joined.starts_with('~') { + format!("~/{joined}") + } else { + joined + }; + let normalized = if normalized == "/" { + normalized + } else { + normalized.trim_end_matches('/').to_string() + }; + (normalized, parent_traversal) +} + +fn is_sensitive_path(path: &str) -> bool { + const SYSTEM_ROOTS: &[&str] = &[ + "/etc", "/boot", "/bin", "/sbin", "/usr", "/lib", "/lib64", "/var/lib", "/root", + ]; + if path == "/" + || SYSTEM_ROOTS + .iter() + .any(|root| path == *root || path.starts_with(&format!("{root}/"))) + { + return true; + } + let components = path.split('/').collect::>(); + if components.contains(&".ssh") + || path == "~/.ssh" + || path.starts_with("~/.ssh/") + || path == ".ssh" + || path.starts_with(".ssh/") + || path.contains("/.ssh/") + { + return true; + } + let basename = components.last().copied().unwrap_or_default(); + matches!( + basename, + "authorized_keys" | "sshd_config" | "sudoers" | "passwd" | "shadow" | "group" | "crontab" + ) || components.iter().any(|part| { + matches!( + *part, + "sudoers.d" + | "systemd" + | "nginx" + | "cron" + | "cron.d" + | "cron.daily" + | "cron.hourly" + | "cron.monthly" + | "cron.weekly" + ) + }) || matches!( + path.rsplit_once('.').map(|(_, extension)| extension), + Some("service" | "socket" | "timer" | "target") + ) +} + +fn is_dangerous_mode(mode: &str) -> bool { + let mode = mode.trim().strip_prefix("0o").unwrap_or(mode.trim()); + u32::from_str_radix(mode.trim_start_matches('0'), 8) + .is_ok_and(|value| matches!(value, 0o666 | 0o777)) +} + pub fn is_available(info: &SessionInfo) -> bool { info.connected && info.session_type == SessionType::SSH && info.remote_file_browser_enabled } @@ -114,3 +268,81 @@ pub async fn chmod( require_available(&manager, session_id).await?; sftp::chmod_remote_file(manager, session_id, path, mode).await } + +#[cfg(test)] +mod tests { + use crate::config::RiskLevel; + + use super::*; + + #[test] + fn assesses_remote_filesystem_mutations_dynamically() { + let ordinary = assess_sftp_risk( + SftpRiskOperation::Write, + "/home/alice/notes.txt", + None, + false, + None, + ); + assert_eq!(ordinary.level, RiskLevel::Medium); + assert!(ordinary.auto_executable); + + for path in [ + "/etc/nginx/nginx.conf", + "/home/alice/.ssh/authorized_keys", + "/home/alice/.ssh", + "~/.ssh/config", + "/var/lib/app/state", + "/tmp/example.service", + ] { + let assessment = assess_sftp_risk(SftpRiskOperation::Write, path, None, false, None); + assert_eq!(assessment.level, RiskLevel::High, "path: {path}"); + assert!(!assessment.auto_executable); + } + + assert_eq!( + assess_sftp_risk( + SftpRiskOperation::Write, + "/home/alice/notes.txt", + None, + true, + None, + ) + .level, + RiskLevel::High + ); + assert_eq!( + assess_sftp_risk( + SftpRiskOperation::Rename, + "/home/alice/config", + Some("/etc/app.conf"), + false, + None, + ) + .level, + RiskLevel::High + ); + assert_eq!( + assess_sftp_risk( + SftpRiskOperation::Chmod, + "~/.ssh/authorized_keys", + None, + false, + Some("0777"), + ) + .level, + RiskLevel::High + ); + assert_eq!( + assess_sftp_risk( + SftpRiskOperation::Delete, + "/home/alice/notes.txt", + None, + false, + None, + ) + .level, + RiskLevel::High + ); + } +} diff --git a/src-tauri/src/core/mcp/host.rs b/src-tauri/src/core/mcp/host.rs index b3f9326d..cf5398eb 100644 --- a/src-tauri/src/core/mcp/host.rs +++ b/src-tauri/src/core/mcp/host.rs @@ -32,8 +32,9 @@ use crate::core::SessionManager; use crate::core::ai::{AppendAiAuditRequest, append_ai_audit, redact_sensitive_text}; use crate::core::capabilities::sftp as sftp_capability; use crate::core::capabilities::{ - CapabilityAccess, McpScope, OutputStore, PolicyDecision, TerminalExecuteRequest, - assess_command_risk, capability_for_tool, decide_policy, execute_terminal_command, + CapabilityAccess, McpScope, McpScopeSnapshot, OutputStore, PolicyDecision, RiskAssessment, + TerminalExecuteRequest, assess_command_risk, capability_for_tool, decide_policy, + execute_terminal_command, }; use crate::core::session::{SessionInfo, SessionType}; use crate::error::{AppError, AppResult}; @@ -116,7 +117,7 @@ struct ExternalRuntime { settings: ExternalMcpSettings, owner_window_label: String, generation: String, - scoped_session_count: usize, + scope: Arc, cancellation: CancellationToken, last_activity: Arc>, approval_waiters: Arc, @@ -145,6 +146,7 @@ pub struct McpManager { persistent_startup: Mutex>, approvals: Arc, request_cancellations: Mutex>, + active_sessions: RwLock>, external_connections: AtomicUsize, last_error: StdMutex>, } @@ -168,6 +170,7 @@ impl McpManager { persistent_startup: Mutex::new(None), approvals: Arc::new(McpApprovalManager::default()), request_cancellations: Mutex::new(HashMap::new()), + active_sessions: RwLock::new(HashMap::new()), external_connections: AtomicUsize::new(0), last_error: StdMutex::new(None), }) @@ -236,37 +239,31 @@ impl McpManager { "The MCP bridge is not initialized.".into(), )); } - { + let unchanged = { let current = self.external.lock().await; if let Some(current) = current.as_ref() { if current.owner_window_label != owner_window_label { return Err(AppError::Config("External MCP is already bound to another NyaTerm window. Disable it before enabling it from this window.".into())); } - if current.settings == settings { - return Ok(self.status_from(Some(current))); - } + current.settings == settings + } else { + false } + }; + if unchanged { + return Ok(self.status().await); } self.disable_external(false).await?; - let mut session_ids = self - .sessions - .list_sessions() - .await - .into_iter() - .filter(|session| { - settings.session_scope == ExternalMcpSessionScope::AllSessions - || session.owner_window_label.as_deref() == Some(owner_window_label) - }) - .map(|session| session.id) - .collect::>(); - session_ids.sort(); - let default_session_id = (session_ids.len() == 1).then(|| session_ids[0].clone()); + let scope = Arc::new(match settings.session_scope { + ExternalMcpSessionScope::CurrentWindow => McpScope::current_window(owner_window_label), + ExternalMcpSessionScope::AllSessions => McpScope::AllSessions, + }); let generation = uuid::Uuid::new_v4().to_string(); let token = random_token(); let cancellation = CancellationToken::new(); let credential = Arc::new(Credential { token: token.clone(), - scope: Arc::new(McpScope::new(session_ids.clone(), default_session_id)), + scope: scope.clone(), permission_mode: settings.permission_mode.clone(), source: EXTERNAL_SOURCE.into(), owner_window_label: Some(owner_window_label.to_string()), @@ -282,7 +279,7 @@ impl McpManager { settings: settings.clone(), owner_window_label: owner_window_label.to_string(), generation: generation.clone(), - scoped_session_count: session_ids.len(), + scope, cancellation: cancellation.clone(), last_activity: last_activity.clone(), approval_waiters: approval_waiters.clone(), @@ -343,6 +340,7 @@ impl McpManager { } pub async fn owner_window_closed(&self, label: &str) { + self.active_sessions.write().await.remove(label); let matches = self .external .lock() @@ -366,20 +364,32 @@ impl McpManager { pub async fn status(&self) -> McpRuntimeStatus { let external = self.external.lock().await; - self.status_from(external.as_ref()) - } - - fn status_from(&self, external: Option<&ExternalRuntime>) -> McpRuntimeStatus { + let metadata = external.as_ref().map(|state| { + ( + state.owner_window_label.clone(), + state.generation.clone(), + state.scope.clone(), + ) + }); + drop(external); + let scoped_session_count = if let Some((_, _, scope)) = metadata.as_ref() { + scope + .resolve(&self.sessions.list_sessions().await) + .session_ids + .len() + } else { + 0 + }; let port = self.port.load(Ordering::SeqCst); McpRuntimeStatus { - enabled: external.is_some(), - running: external.is_some() && port != 0, + enabled: metadata.is_some(), + running: metadata.is_some() && port != 0, error: self.last_error.lock().unwrap().clone(), - owner_window_label: external.map(|state| state.owner_window_label.clone()), - scoped_session_count: external.map_or(0, |state| state.scoped_session_count), + owner_window_label: metadata.as_ref().map(|state| state.0.clone()), + scoped_session_count, connection_count: self.external_connections.load(Ordering::SeqCst), port: (port != 0).then_some(port), - generation: external.map(|state| state.generation.clone()), + generation: metadata.map(|state| state.1), } } @@ -417,7 +427,7 @@ impl McpManager { generation.clone(), Arc::new(Credential { token: token.clone(), - scope: Arc::new(McpScope::new(session_ids, default_session_id)), + scope: Arc::new(McpScope::explicit(session_ids, default_session_id)), permission_mode, source: source.to_string(), owner_window_label, @@ -443,6 +453,31 @@ impl McpManager { self.approvals.respond(request_id, decision).await } + pub async fn set_active_session( + &self, + owner_window_label: &str, + session_id: Option, + ) -> AppResult<()> { + if let Some(session_id) = session_id { + let info = self.sessions.session_info(&session_id).await?; + if info.owner_window_label.as_deref() != Some(owner_window_label) { + return Err(AppError::Config( + "The active session does not belong to the reporting window.".into(), + )); + } + self.active_sessions + .write() + .await + .insert(owner_window_label.to_string(), session_id); + } else { + self.active_sessions + .write() + .await + .remove(owner_window_label); + } + Ok(()) + } + pub async fn cancel_pending_approvals(&self) { self.approvals.cancel_all().await; } @@ -678,7 +713,7 @@ impl McpManager { Ok(value) => { self.audit( context, - definition.id, + definition.capability, None, None, Some("inherited"), @@ -693,7 +728,7 @@ impl McpManager { Err(error) => { self.audit( context, - definition.id, + definition.capability, None, None, Some("inherited"), @@ -707,13 +742,17 @@ impl McpManager { } } } - let session_id = match self.resolve_session(context, tool_name, &arguments) { + let scope = context + .credential + .scope + .resolve(&self.sessions.list_sessions().await); + let session_id = match Self::resolve_session(&scope, tool_name, &arguments) { Ok(session_id) => session_id, Err(error) => { let mapped = map_error(error); self.audit( context, - definition.id, + definition.capability, arguments.get("sessionId").and_then(Value::as_str), None, Some("validation_denied"), @@ -732,22 +771,75 @@ impl McpManager { "A target session is required for this capability.", )); } - let risk = if tool_name == tool::TERMINAL_EXECUTE { - Some( - assess_command_risk(&parse::(arguments.clone())?.command) - .level, - ) - } else { - None + let assessment: Option = match tool_name { + tool::TERMINAL_EXECUTE => Some(assess_command_risk( + &parse::(arguments.clone())?.command, + )), + tool::SFTP_WRITE_TEXT => { + let args = parse::(arguments.clone())?; + Some(sftp_capability::assess_sftp_risk( + sftp_capability::SftpRiskOperation::Write, + &args.path, + None, + args.force.unwrap_or(false), + None, + )) + } + tool::SFTP_MKDIR => { + let args = parse::(arguments.clone())?; + Some(sftp_capability::assess_sftp_risk( + sftp_capability::SftpRiskOperation::Mkdir, + &args.path, + None, + false, + args.mode.as_deref(), + )) + } + tool::SFTP_RENAME => { + let args = parse::(arguments.clone())?; + Some(sftp_capability::assess_sftp_risk( + sftp_capability::SftpRiskOperation::Rename, + &args.old_path, + Some(&args.new_path), + false, + None, + )) + } + tool::SFTP_DELETE => { + let args = parse::(arguments.clone())?; + Some(sftp_capability::assess_sftp_risk( + sftp_capability::SftpRiskOperation::Delete, + &args.path, + None, + false, + None, + )) + } + tool::SFTP_CHMOD => { + let args = parse::(arguments.clone())?; + Some(sftp_capability::assess_sftp_risk( + sftp_capability::SftpRiskOperation::Chmod, + &args.path, + None, + false, + Some(&args.mode), + )) + } + _ => None, }; let policy = decide_policy( &context.credential.permission_mode, definition.access, - risk.as_ref(), + assessment.as_ref(), ); - let grant_key = session_id.clone().map(|id| (id, definition.id.to_string())); + let risk = assessment.as_ref().map(|value| value.level.clone()); + let grant_key = session_id + .clone() + .map(|id| (id, definition.capability.to_string())); let grantable = definition.access != CapabilityAccess::DestructiveWrite - && risk.as_ref().is_none_or(|value| *value < RiskLevel::High); + && assessment + .as_ref() + .is_none_or(|value| value.auto_executable && value.level < RiskLevel::High); let granted = grantable && match grant_key.as_ref() { Some(key) => context.grants.lock().await.contains(key), @@ -757,7 +849,7 @@ impl McpManager { if policy == PolicyDecision::Deny { self.audit( context, - definition.id, + definition.capability, session_id.as_deref(), risk, Some("policy_denied"), @@ -801,7 +893,7 @@ impl McpManager { let event = ApprovalRequestEvent { request_id: uuid::Uuid::new_v4().to_string(), client: context.client.lock().unwrap().clone(), - capability: definition.id.to_string(), + capability: definition.capability.to_string(), session_id: Some(target.to_string()), session_name: Some(info.name), parameter_summary: summarize(tool_name, &arguments), @@ -829,7 +921,7 @@ impl McpManager { Err(error) => { self.audit( context, - definition.id, + definition.capability, Some(target), risk.clone(), Some("approval_unavailable"), @@ -846,7 +938,7 @@ impl McpManager { if decision == ApprovalDecision::Deny { self.audit( context, - definition.id, + definition.capability, Some(target), risk, approval, @@ -861,16 +953,18 @@ impl McpManager { "The operation was denied by the user.", )); } - if decision == ApprovalDecision::AllowSession && grantable { - if let Some(key) = grant_key { - context.grants.lock().await.insert(key); - } + if decision == ApprovalDecision::AllowSession + && grantable + && let Some(key) = grant_key + { + context.grants.lock().await.insert(key); } } let result = tokio::select! { _ = cancellation.cancelled() => Err(failure("cancelled", "The MCP request was cancelled.")), value = self.dispatch( context, + &scope, tool_name, arguments.clone(), session_id.as_deref(), @@ -882,7 +976,7 @@ impl McpManager { Ok(value) => { self.audit( context, - definition.id, + definition.capability, session_id.as_deref(), risk, approval, @@ -897,7 +991,7 @@ impl McpManager { Err(error) => { self.audit( context, - definition.id, + definition.capability, session_id.as_deref(), risk, approval, @@ -913,8 +1007,7 @@ impl McpManager { } fn resolve_session( - &self, - context: &ConnectionContext, + scope: &McpScopeSnapshot, tool_name: &str, arguments: &Value, ) -> AppResult> { @@ -923,9 +1016,7 @@ impl McpManager { } if tool_name == tool::TERMINAL_EXECUTE { let args: TerminalExecuteArgs = serde_json::from_value(arguments.clone())?; - return context - .credential - .scope + return scope .resolve_terminal_session(args.session_id.as_deref()) .map(Some); } @@ -933,13 +1024,14 @@ impl McpManager { .get("sessionId") .and_then(Value::as_str) .ok_or_else(|| AppError::Config("sessionId is required.".into()))?; - context.credential.scope.require(id)?; + scope.require(id)?; Ok(Some(id.to_string())) } async fn dispatch( &self, context: &ConnectionContext, + scope: &McpScopeSnapshot, name: &str, arguments: Value, session_id: Option<&str>, @@ -948,15 +1040,24 @@ impl McpManager { match name { tool::GET_ENVIRONMENT => { let mut sessions = Vec::new(); - for id in &context.credential.scope.session_ids { + for id in &scope.session_ids { if let Ok(info) = self.sessions.session_info(id).await { sessions.push(safe_metadata(&info)); } } sessions.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); - Ok( - json!({ "defaultSessionId": context.credential.scope.default_session_id, "sessions": sessions }), - ) + let active_session_id = + if let Some(owner) = context.credential.owner_window_label.as_deref() { + let active_sessions = self.active_sessions.read().await; + scoped_active_session_id(active_sessions.get(owner), scope) + } else { + None + }; + Ok(json!({ + "activeSessionId": active_session_id, + "defaultSessionId": scope.default_session_id, + "sessions": sessions, + })) } tool::SESSION_GET => { let args: SessionArgs = parse(arguments)?; @@ -1300,6 +1401,14 @@ fn sftp_available(info: &SessionInfo) -> bool { fn safe_metadata(info: &SessionInfo) -> Value { json!({ "id": info.id, "name": info.name, "type": session_type_name(&info.session_type), "connected": info.connected }) } +fn scoped_active_session_id( + active_session_id: Option<&String>, + scope: &McpScopeSnapshot, +) -> Option { + active_session_id + .filter(|id| scope.session_ids.contains(*id)) + .cloned() +} fn access_risk(value: CapabilityAccess) -> RiskLevel { match value { CapabilityAccess::Read => RiskLevel::Low, @@ -1432,6 +1541,25 @@ mod tests { assert_eq!(URL_SAFE_NO_PAD.decode(random_token()).unwrap().len(), 32); } + #[test] + fn active_session_must_be_live_and_scoped() { + let scope = McpScopeSnapshot { + session_ids: HashSet::from(["session-a".into()]), + default_session_id: None, + }; + assert_eq!( + scoped_active_session_id(Some(&"session-a".into()), &scope).as_deref(), + Some("session-a") + ); + assert!(scoped_active_session_id(Some(&"session-b".into()), &scope).is_none()); + + let closed_scope = McpScopeSnapshot { + session_ids: HashSet::new(), + default_session_id: None, + }; + assert!(scoped_active_session_id(Some(&"session-a".into()), &closed_scope).is_none()); + } + #[tokio::test] async fn rpc_reader_requires_a_newline_and_enforces_the_limit() { let (mut writer, reader) = tokio::io::duplex(MAX_RPC_LINE_BYTES + 16); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4b92fa3c..0205cfb8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -167,6 +167,7 @@ pub fn run() { cmd::mcp::notify_mcp_session_restore_complete, cmd::mcp::set_external_mcp_enabled, cmd::mcp::respond_external_mcp_approval, + cmd::mcp::report_mcp_active_session, cmd::mcp::get_external_mcp_client_configs, cmd::ai::detect_claude_code_cli, cmd::ai::get_claude_code_account_status, diff --git a/src/App.tsx b/src/App.tsx index d80243d7..02f312fa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,7 @@ import { useFileDocumentCloseGuard } from "./hooks/useFileDocumentCloseGuard"; import { useGlobalShortcuts } from "./hooks/useGlobalShortcuts"; import { useIdleLock } from "./hooks/useIdleLock"; import { useMacSelectionGuard } from "./hooks/useMacSelectionGuard"; +import { useMcpActiveSession } from "./hooks/useMcpActiveSession"; import { useModalChildWindows } from "./hooks/useModalChildWindows"; import { useRemoteGpuOverview } from "./hooks/useRemoteGpuOverview"; import { useRemoteNpuOverview } from "./hooks/useRemoteNpuOverview"; @@ -3070,6 +3071,7 @@ function App() { !activePane.connectError ? activePane.sessionId : null; + useMcpActiveSession(activeSessionId); const activeSshSessionId = activePane && activePane.paneKind === "terminal" && diff --git a/src/hooks/useMcpActiveSession.test.tsx b/src/hooks/useMcpActiveSession.test.tsx new file mode 100644 index 00000000..1edc6041 --- /dev/null +++ b/src/hooks/useMcpActiveSession.test.tsx @@ -0,0 +1,38 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; +import { useMcpActiveSession } from "./useMcpActiveSession"; + +const mocks = vi.hoisted(() => ({ invoke: vi.fn() })); + +vi.mock("@/lib/invoke", () => ({ invoke: mocks.invoke })); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.invoke.mockResolvedValue(undefined); +}); + +it("reports active session changes and clearing to the MCP host", async () => { + const { rerender } = renderHook(({ sessionId }) => useMcpActiveSession(sessionId), { + initialProps: { sessionId: "session-a" as string | null }, + }); + + await waitFor(() => + expect(mocks.invoke).toHaveBeenLastCalledWith("report_mcp_active_session", { + sessionId: "session-a", + }), + ); + + rerender({ sessionId: "session-b" }); + await waitFor(() => + expect(mocks.invoke).toHaveBeenLastCalledWith("report_mcp_active_session", { + sessionId: "session-b", + }), + ); + + rerender({ sessionId: null }); + await waitFor(() => + expect(mocks.invoke).toHaveBeenLastCalledWith("report_mcp_active_session", { + sessionId: null, + }), + ); +}); diff --git a/src/hooks/useMcpActiveSession.ts b/src/hooks/useMcpActiveSession.ts new file mode 100644 index 00000000..5a6f9e1d --- /dev/null +++ b/src/hooks/useMcpActiveSession.ts @@ -0,0 +1,10 @@ +import { useEffect } from "react"; +import { invoke } from "@/lib/invoke"; + +export function useMcpActiveSession(activeSessionId: string | null) { + useEffect(() => { + void invoke("report_mcp_active_session", { + sessionId: activeSessionId, + }).catch(() => {}); + }, [activeSessionId]); +} diff --git a/src/i18n/mcpApprovalTranslations.test.ts b/src/i18n/mcpApprovalTranslations.test.ts new file mode 100644 index 00000000..690b40ae --- /dev/null +++ b/src/i18n/mcpApprovalTranslations.test.ts @@ -0,0 +1,12 @@ +import { expect, it } from "vitest"; +import en from "./locales/en.json"; +import ko from "./locales/ko.json"; +import zhCN from "./locales/zh-CN.json"; +import zhTW from "./locales/zh-TW.json"; + +it("describes MCP approval grants as connection-scoped in every locale", () => { + expect(en.ai.externalMcpAllowSession).toBe("Allow for this connection"); + expect(zhCN.ai.externalMcpAllowSession).toBe("本次连接中允许"); + expect(zhTW.ai.externalMcpAllowSession).toBe("此連線期間允許"); + expect(ko.ai.externalMcpAllowSession).toBe("이 연결에서 허용"); +}); From 464eddeaa9df47d5e1b65d80fa65f0d961f79823 Mon Sep 17 00:00:00 2001 From: Kang Date: Sun, 30 Aug 2026 15:14:24 +0800 Subject: [PATCH 12/32] chore(i18n): update translations for external MCP session permissions - Modified the translation for "Allow for this MCP session" to "Allow for this connection" in English, Korean, Simplified Chinese, and Traditional Chinese locale files to enhance clarity and consistency in user messaging. --- src/i18n/locales/en.json | 2 +- src/i18n/locales/ko.json | 2 +- src/i18n/locales/zh-CN.json | 2 +- src/i18n/locales/zh-TW.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5d21fd14..20a03969 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -160,7 +160,7 @@ "externalMcp": "External MCP", "externalMcpAllSessions": "All sessions", "externalMcpAllowOnce": "Allow once", - "externalMcpAllowSession": "Allow for this MCP session", + "externalMcpAllowSession": "Allow for this connection", "externalMcpApprovalDesc": "An MCP client is requesting access to a NyaTerm session.", "externalMcpApprovalTitle": "External MCP approval", "externalMcpCapability": "Capability", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 6715ade1..890866e7 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -160,7 +160,7 @@ "externalMcp": "외부 MCP", "externalMcpAllSessions": "모든 세션", "externalMcpAllowOnce": "한 번 허용", - "externalMcpAllowSession": "이 MCP 세션에서 허용", + "externalMcpAllowSession": "이 연결에서 허용", "externalMcpApprovalDesc": "MCP 클라이언트가 NyaTerm 세션 접근을 요청합니다.", "externalMcpApprovalTitle": "외부 MCP 승인", "externalMcpCapability": "기능", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 9b408da1..a4a94bac 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -160,7 +160,7 @@ "externalMcp": "外部 MCP", "externalMcpAllSessions": "所有会话", "externalMcpAllowOnce": "允许一次", - "externalMcpAllowSession": "本次 MCP 会话内允许", + "externalMcpAllowSession": "本次连接中允许", "externalMcpApprovalDesc": "一个 MCP 客户端正在请求访问 NyaTerm 会话。", "externalMcpApprovalTitle": "外部 MCP 审批", "externalMcpCapability": "能力", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 69c8fd72..a5ccdf76 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -160,7 +160,7 @@ "externalMcp": "外部 MCP", "externalMcpAllSessions": "所有工作階段", "externalMcpAllowOnce": "允許一次", - "externalMcpAllowSession": "此 MCP 工作階段內允許", + "externalMcpAllowSession": "此連線期間允許", "externalMcpApprovalDesc": "MCP 用戶端正在要求存取 NyaTerm 工作階段。", "externalMcpApprovalTitle": "外部 MCP 核准", "externalMcpCapability": "功能", From 877a970e0358f395e62d1aca9b287b9ad578d0fc Mon Sep 17 00:00:00 2001 From: Kang Date: Sun, 30 Aug 2026 21:44:01 +0800 Subject: [PATCH 13/32] feat(interaction): implement terminal right-click action settings (#530) - Introduced a new setting for terminal right-click actions, allowing users to choose between "none", "menu", and "paste" options. - Updated the InteractionTab component to include a tabbed interface for selecting the right-click action. - Refactored the TerminalContextMenu to handle the new right-click action logic, ensuring appropriate behavior based on user selection. - Added tests for the terminal right-click action functionality to validate expected behaviors and interactions. - Migrated legacy right-click paste settings to the new terminal right-click action configuration. --- src-tauri/src/config/settings/interaction.rs | 89 ++++++++++- src-tauri/src/config/settings/mod.rs | 7 + src/components/settings/InteractionTab.tsx | 33 +++- .../terminal/TerminalContextMenu.test.tsx | 149 ++++++++++++++++++ .../terminal/TerminalContextMenu.tsx | 54 ++++--- src/context/AppProvider.tsx | 2 +- src/context/ChildAppProvider.tsx | 2 +- src/lib/interactionSettings.test.ts | 20 +++ src/lib/interactionSettings.ts | 20 +++ src/types/global.d.ts | 2 +- 10 files changed, 347 insertions(+), 31 deletions(-) create mode 100644 src/components/terminal/TerminalContextMenu.test.tsx create mode 100644 src/lib/interactionSettings.test.ts diff --git a/src-tauri/src/config/settings/interaction.rs b/src-tauri/src/config/settings/interaction.rs index c5b6b01e..d4cd9265 100644 --- a/src-tauri/src/config/settings/interaction.rs +++ b/src-tauri/src/config/settings/interaction.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Deserializer, Serialize}; pub struct InteractionSettings { pub copy_on_select: bool, pub allow_osc52_clipboard_write: bool, - pub right_click_paste: bool, + pub terminal_right_click_action: String, pub terminal_zoom_enabled: bool, pub command_suggestions_enabled: bool, pub command_suggestion_min_chars: usize, @@ -23,6 +23,7 @@ pub struct InteractionSettings { struct InteractionSettingsWire { copy_on_select: Option, allow_osc52_clipboard_write: Option, + terminal_right_click_action: Option, right_click_paste: Option, terminal_zoom_enabled: Option, command_suggestions_enabled: Option, @@ -58,6 +59,28 @@ fn default_encoding() -> String { "UTF-8".to_string() } +fn default_terminal_right_click_action() -> String { + "menu".to_string() +} + +fn normalize_terminal_right_click_action( + action: Option, + legacy_right_click_paste: Option, +) -> String { + if let Some(action) = action { + return match action.as_str() { + "none" | "menu" | "paste" => action, + _ => default_terminal_right_click_action(), + }; + } + + match legacy_right_click_paste { + Some(true) => "paste".to_string(), + Some(false) => "menu".to_string(), + None => "paste".to_string(), + } +} + fn default_tab_double_click_action() -> String { "disconnect_session".to_string() } @@ -79,7 +102,7 @@ impl Default for InteractionSettings { Self { copy_on_select: false, allow_osc52_clipboard_write: false, - right_click_paste: false, + terminal_right_click_action: default_terminal_right_click_action(), terminal_zoom_enabled: true, command_suggestions_enabled: true, command_suggestion_min_chars: default_command_suggestion_min_chars(), @@ -109,7 +132,10 @@ impl<'de> Deserialize<'de> for InteractionSettings { allow_osc52_clipboard_write: wire .allow_osc52_clipboard_write .unwrap_or(defaults.allow_osc52_clipboard_write), - right_click_paste: wire.right_click_paste.unwrap_or_else(default_true), + terminal_right_click_action: normalize_terminal_right_click_action( + wire.terminal_right_click_action, + wire.right_click_paste, + ), terminal_zoom_enabled: wire.terminal_zoom_enabled.unwrap_or_else(default_true), command_suggestions_enabled: wire .command_suggestions_enabled @@ -154,6 +180,7 @@ mod tests { assert_eq!(settings.tab_double_click_action, "disconnect_session"); assert_eq!(settings.tab_middle_click_action, "rename_tab"); assert_eq!(settings.tab_right_click_action, "none"); + assert_eq!(settings.terminal_right_click_action, "menu"); assert!(!settings.allow_osc52_clipboard_write); assert!(!settings.alt_as_meta); assert!(!settings.ime_compatibility); @@ -177,6 +204,7 @@ mod tests { assert_eq!(settings.tab_middle_click_action, "rename_tab"); assert_eq!(settings.tab_right_click_action, "none"); assert_eq!(settings.duplicate_session_command_delay_ms, 1000); + assert_eq!(settings.terminal_right_click_action, "menu"); assert!(!settings.allow_osc52_clipboard_write); assert!(!settings.alt_as_meta); assert!(!settings.ime_compatibility); @@ -188,12 +216,65 @@ mod tests { let settings: InteractionSettings = serde_json::from_value(serde_json::json!({})).unwrap(); assert!(settings.copy_on_select); - assert!(settings.right_click_paste); + assert_eq!(settings.terminal_right_click_action, "paste"); assert!(settings.terminal_zoom_enabled); assert!(settings.command_suggestions_enabled); assert!(!settings.ime_compatibility); } + #[test] + fn terminal_right_click_action_accepts_all_supported_values() { + for action in ["none", "menu", "paste"] { + let settings: InteractionSettings = serde_json::from_value(serde_json::json!({ + "terminal_right_click_action": action + })) + .unwrap(); + + assert_eq!(settings.terminal_right_click_action, action); + } + } + + #[test] + fn legacy_right_click_paste_migrates_to_terminal_right_click_action() { + let menu: InteractionSettings = serde_json::from_value(serde_json::json!({ + "right_click_paste": false + })) + .unwrap(); + let paste: InteractionSettings = serde_json::from_value(serde_json::json!({ + "right_click_paste": true + })) + .unwrap(); + + assert_eq!(menu.terminal_right_click_action, "menu"); + assert_eq!(paste.terminal_right_click_action, "paste"); + } + + #[test] + fn terminal_right_click_action_takes_precedence_and_normalizes_invalid_values() { + let explicit: InteractionSettings = serde_json::from_value(serde_json::json!({ + "terminal_right_click_action": "none", + "right_click_paste": true + })) + .unwrap(); + let invalid: InteractionSettings = serde_json::from_value(serde_json::json!({ + "terminal_right_click_action": "invalid", + "right_click_paste": true + })) + .unwrap(); + + assert_eq!(explicit.terminal_right_click_action, "none"); + assert_eq!(invalid.terminal_right_click_action, "menu"); + } + + #[test] + fn terminal_right_click_action_serialization_omits_legacy_field() { + let settings = InteractionSettings::default(); + let value = serde_json::to_value(settings).unwrap(); + + assert_eq!(value["terminal_right_click_action"], "menu"); + assert!(value.get("right_click_paste").is_none()); + } + #[test] fn legacy_mac_ime_compatibility_migrates_to_ime_compatibility() { let settings: InteractionSettings = serde_json::from_value(serde_json::json!({ diff --git a/src-tauri/src/config/settings/mod.rs b/src-tauri/src/config/settings/mod.rs index 05772102..748c46ba 100644 --- a/src-tauri/src/config/settings/mod.rs +++ b/src-tauri/src/config/settings/mod.rs @@ -95,6 +95,10 @@ pub fn load_app_settings(app: &AppHandle) -> AppResult { interaction.contains_key("mac_ime_compatibility") && !interaction.contains_key("ime_compatibility") }); + let has_legacy_terminal_right_click_action = raw_settings + .get("interaction") + .and_then(|interaction| interaction.as_object()) + .is_some_and(|interaction| !interaction.contains_key("terminal_right_click_action")); let mut migrated = false; let mut secrets_ready_for_persist = true; @@ -151,6 +155,9 @@ pub fn load_app_settings(app: &AppHandle) -> AppResult { if has_legacy_mac_ime_compatibility { migrated = true; } + if has_legacy_terminal_right_click_action { + migrated = true; + } for list in [ &mut settings.ui.activity_bar_layout.left_top, diff --git a/src/components/settings/InteractionTab.tsx b/src/components/settings/InteractionTab.tsx index e7caaa62..9d06e10f 100644 --- a/src/components/settings/InteractionTab.tsx +++ b/src/components/settings/InteractionTab.tsx @@ -1,5 +1,6 @@ import { useTranslation } from "react-i18next"; import { SelectItem } from "@/components/ui/select"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useApp } from "@/context/AppContext"; import { MAX_COMMAND_SUGGESTION_MAX_CHARS, @@ -9,6 +10,7 @@ import { normalizeCommandSuggestionMaxChars, normalizeCommandSuggestionMinChars, normalizeTabMouseAction, + normalizeTerminalRightClickAction, TAB_MOUSE_ACTION_LABEL_KEYS, TAB_MOUSE_ACTIONS, } from "@/lib/interactionSettings"; @@ -73,11 +75,32 @@ export function InteractionTab() { /> - - updateInteraction({ right_click_paste: v })} - /> + + + updateInteraction({ + terminal_right_click_action: normalizeTerminalRightClickAction(value), + }) + } + > + + + {t("settings.terminalRightClickNone")} + + + {t("settings.terminalRightClickMenu")} + + + {t("settings.terminalRightClickPaste")} + + + diff --git a/src/components/terminal/TerminalContextMenu.test.tsx b/src/components/terminal/TerminalContextMenu.test.tsx new file mode 100644 index 00000000..ba6a6fbc --- /dev/null +++ b/src/components/terminal/TerminalContextMenu.test.tsx @@ -0,0 +1,149 @@ +import { createEvent, fireEvent, render, waitFor } from "@testing-library/react"; +import type { Terminal } from "@xterm/xterm"; +import type React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { TerminalRightClickAction } from "@/lib/interactionSettings"; +import TerminalContextMenu from "./TerminalContextMenu"; + +let rightClickAction: TerminalRightClickAction; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn() })); +vi.mock("@/context/AppContext", () => ({ + useTerminalAppSettings: () => ({ + interaction: { terminal_right_click_action: rightClickAction }, + translation: {}, + search: { custom_engines: [] }, + ai: { enabled: false, terminal_ai_actions: [] }, + keybindings: {}, + }), +})); +vi.mock("@/hooks/useShortcutMap", () => ({ resolveDisplayKeys: () => "" })); +vi.mock("@/lib/aiEvents", () => ({ openAIAssistant: vi.fn() })); +vi.mock("@/lib/clipboard", () => ({ writeClipboardText: vi.fn() })); +vi.mock("@/lib/invoke", () => ({ invoke: vi.fn() })); +vi.mock("@/lib/terminalControlInput", () => ({ sendTerminalClearInput: vi.fn() })); +vi.mock("@/lib/windowManager", () => ({ openSettings: vi.fn() })); +vi.mock("../dialog/terminal/TranslationDialog", () => ({ default: () => null })); + +describe("TerminalContextMenu right-click behavior", () => { + beforeEach(() => { + rightClickAction = "menu"; + }); + + it("leaves the right-click event untouched when the action is off", () => { + rightClickAction = "none"; + const onAncestorContextMenu = vi.fn(); + const onPasteClipboard = vi.fn(); + const { getByTestId } = renderTerminalContextMenu({ + onAncestorContextMenu, + onPasteClipboard, + }); + const event = createEvent.contextMenu(getByTestId("terminal-child")); + + fireEvent(getByTestId("terminal-child"), event); + + expect(onAncestorContextMenu).toHaveBeenCalledOnce(); + expect(event.defaultPrevented).toBe(false); + expect(onPasteClipboard).not.toHaveBeenCalled(); + expect(document.querySelector('[data-slot="context-menu-content"]')).toBeNull(); + }); + + it("pastes directly and consumes the context-menu event in paste mode", async () => { + rightClickAction = "paste"; + const onAncestorContextMenu = vi.fn(); + const onPasteClipboard = vi.fn().mockResolvedValue(undefined); + const clearSelection = vi.fn(); + const focus = vi.fn(); + const { getByTestId } = renderTerminalContextMenu({ + onAncestorContextMenu, + onPasteClipboard, + clearSelection, + focus, + }); + const event = createEvent.contextMenu(getByTestId("terminal-child")); + + fireEvent(getByTestId("terminal-child"), event); + + expect(event.defaultPrevented).toBe(true); + expect(onAncestorContextMenu).not.toHaveBeenCalled(); + await waitFor(() => { + expect(onPasteClipboard).toHaveBeenCalledOnce(); + expect(clearSelection).toHaveBeenCalledOnce(); + expect(focus).toHaveBeenCalledOnce(); + }); + expect(document.querySelector('[data-slot="context-menu-content"]')).toBeNull(); + }); + + it("opens the application context menu without pasting in menu mode", async () => { + const onPasteClipboard = vi.fn(); + const { getByTestId } = renderTerminalContextMenu({ onPasteClipboard }); + + fireEvent.contextMenu(getByTestId("terminal-child")); + + await waitFor(() => { + expect(document.querySelector('[data-slot="context-menu-content"]')).not.toBeNull(); + }); + expect(onPasteClipboard).not.toHaveBeenCalled(); + }); + + it("preserves the terminal DOM node when switching between actions", () => { + const view = renderTerminalContextMenu(); + const terminalChild = view.getByTestId("terminal-child"); + + rightClickAction = "none"; + view.rerenderMenu(); + expect(view.getByTestId("terminal-child")).toBe(terminalChild); + + rightClickAction = "paste"; + view.rerenderMenu(); + expect(view.getByTestId("terminal-child")).toBe(terminalChild); + + rightClickAction = "menu"; + view.rerenderMenu(); + expect(view.getByTestId("terminal-child")).toBe(terminalChild); + }); +}); + +function renderTerminalContextMenu({ + onAncestorContextMenu = vi.fn(), + onPasteClipboard = vi.fn(), + clearSelection = vi.fn(), + focus = vi.fn(), +}: { + onAncestorContextMenu?: () => void; + onPasteClipboard?: () => Promise | void; + clearSelection?: () => void; + focus?: () => void; +} = {}) { + const terminal = { + clearSelection, + focus, + getSelection: () => "", + } as unknown as Terminal; + const terminalRef = { current: terminal } as React.RefObject; + + const element = () => ( +
+ +
+ +
+ ); + const view = render(element()); + + return { + ...view, + rerenderMenu: () => view.rerender(element()), + }; +} diff --git a/src/components/terminal/TerminalContextMenu.tsx b/src/components/terminal/TerminalContextMenu.tsx index 5ae8e8db..6f3bcf64 100644 --- a/src/components/terminal/TerminalContextMenu.tsx +++ b/src/components/terminal/TerminalContextMenu.tsx @@ -23,6 +23,7 @@ import { useTerminalAppSettings } from "@/context/AppContext"; import { resolveDisplayKeys } from "@/hooks/useShortcutMap"; import { openAIAssistant } from "@/lib/aiEvents"; import { writeClipboardText } from "@/lib/clipboard"; +import { normalizeTerminalRightClickAction } from "@/lib/interactionSettings"; import { invoke } from "@/lib/invoke"; import { sendTerminalClearInput } from "@/lib/terminalControlInput"; import { openSettings } from "@/lib/windowManager"; @@ -71,6 +72,9 @@ export default function TerminalContextMenu({ const { t } = useTranslation(); const termSettings = useTerminalAppSettings(); const { interaction, translation, search, ai, keybindings } = termSettings; + const rightClickAction = normalizeTerminalRightClickAction( + interaction.terminal_right_click_action, + ); const dk = (id: string) => resolveDisplayKeys(id, keybindings); const [ctxSelection, setCtxSelection] = useState({ @@ -116,31 +120,34 @@ export default function TerminalContextMenu({ ) : []; - // Right-click context menu: capture selection state - const handleContextMenu = (e: React.MouseEvent) => { + // Right-click context menu: capture selection state. + const handleContextMenu = () => { const terminal = terminalRef.current; if (!terminal) return; - if (interaction.right_click_paste) { - e.preventDefault(); - e.stopPropagation(); - (async () => { - try { - await onPasteClipboard(); - } catch { - /* clipboard access denied */ - } - terminal.clearSelection(); - terminal.focus(); - })(); - return; - } - const selection = terminal.getSelection(); const hasSelection = selection.length > 0; setCtxSelection({ text: selection, hasSelection }); }; + const handleDirectPasteContextMenu = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + const terminal = terminalRef.current; + if (!terminal) return; + + void (async () => { + try { + await onPasteClipboard(); + } catch { + /* clipboard access denied */ + } + terminal.clearSelection(); + terminal.focus(); + })(); + }; + const doPaste = useCallback(async () => { try { await onPasteClipboard(); @@ -226,8 +233,17 @@ export default function TerminalContextMenu({ return ( <> - -
+ +
{children}
diff --git a/src/context/AppProvider.tsx b/src/context/AppProvider.tsx index baf71534..640be6ed 100644 --- a/src/context/AppProvider.tsx +++ b/src/context/AppProvider.tsx @@ -155,7 +155,7 @@ const DEFAULT_APP_SETTINGS: AppSettings = { interaction: { copy_on_select: false, allow_osc52_clipboard_write: false, - right_click_paste: false, + terminal_right_click_action: "menu", terminal_zoom_enabled: true, command_suggestions_enabled: true, command_suggestion_min_chars: DEFAULT_COMMAND_SUGGESTION_MIN_CHARS, diff --git a/src/context/ChildAppProvider.tsx b/src/context/ChildAppProvider.tsx index 32470276..fdbd6322 100644 --- a/src/context/ChildAppProvider.tsx +++ b/src/context/ChildAppProvider.tsx @@ -115,7 +115,7 @@ const DEFAULT_APP_SETTINGS: AppSettings = { interaction: { copy_on_select: false, allow_osc52_clipboard_write: false, - right_click_paste: false, + terminal_right_click_action: "menu", terminal_zoom_enabled: true, command_suggestions_enabled: true, command_suggestion_min_chars: DEFAULT_COMMAND_SUGGESTION_MIN_CHARS, diff --git a/src/lib/interactionSettings.test.ts b/src/lib/interactionSettings.test.ts new file mode 100644 index 00000000..02fbedc8 --- /dev/null +++ b/src/lib/interactionSettings.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_TERMINAL_RIGHT_CLICK_ACTION, + normalizeTerminalRightClickAction, + TERMINAL_RIGHT_CLICK_ACTIONS, +} from "./interactionSettings"; + +describe("terminal right-click settings", () => { + it("accepts every supported action", () => { + for (const action of TERMINAL_RIGHT_CLICK_ACTIONS) { + expect(normalizeTerminalRightClickAction(action)).toBe(action); + } + }); + + it("falls back to the menu action for invalid values", () => { + expect(DEFAULT_TERMINAL_RIGHT_CLICK_ACTION).toBe("menu"); + expect(normalizeTerminalRightClickAction(undefined)).toBe("menu"); + expect(normalizeTerminalRightClickAction("invalid")).toBe("menu"); + }); +}); diff --git a/src/lib/interactionSettings.ts b/src/lib/interactionSettings.ts index 063bdee5..199fcbc5 100644 --- a/src/lib/interactionSettings.ts +++ b/src/lib/interactionSettings.ts @@ -17,6 +17,26 @@ export const DEFAULT_COMMAND_SUGGESTION_MAX_CHARS = 64; export const MIN_COMMAND_SUGGESTION_MAX_CHARS = 1; export const MAX_COMMAND_SUGGESTION_MAX_CHARS = 500; +export const TERMINAL_RIGHT_CLICK_ACTIONS = ["none", "menu", "paste"] as const; + +export type TerminalRightClickAction = (typeof TERMINAL_RIGHT_CLICK_ACTIONS)[number]; + +export const DEFAULT_TERMINAL_RIGHT_CLICK_ACTION: TerminalRightClickAction = "menu"; + +export function isTerminalRightClickAction(value: unknown): value is TerminalRightClickAction { + return ( + typeof value === "string" && + TERMINAL_RIGHT_CLICK_ACTIONS.includes(value as TerminalRightClickAction) + ); +} + +export function normalizeTerminalRightClickAction( + value: unknown, + fallback: TerminalRightClickAction = DEFAULT_TERMINAL_RIGHT_CLICK_ACTION, +): TerminalRightClickAction { + return isTerminalRightClickAction(value) ? value : fallback; +} + export const TAB_MOUSE_ACTIONS = [ "none", "rename_tab", diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 803c509a..79324c62 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -1700,7 +1700,7 @@ export interface TunnelRuntimeState { export interface InteractionSettings { copy_on_select: boolean; allow_osc52_clipboard_write: boolean; - right_click_paste: boolean; + terminal_right_click_action: "none" | "menu" | "paste"; terminal_zoom_enabled: boolean; command_suggestions_enabled: boolean; command_suggestion_min_chars: number; From 1c94d84bb92ea90c01c8f1b19e35b88e25482763 Mon Sep 17 00:00:00 2001 From: Kang Date: Sun, 30 Aug 2026 21:44:10 +0800 Subject: [PATCH 14/32] chore(i18n): update terminal right-click action translations - Removed outdated right-click paste translations and added new keys for terminal right-click action settings in English, Korean, Simplified Chinese, and Traditional Chinese locale files. - Updated descriptions to reflect the new options for right-click behavior in the terminal. --- src/i18n/locales/en.json | 7 +++++-- src/i18n/locales/ko.json | 7 +++++-- src/i18n/locales/zh-CN.json | 7 +++++-- src/i18n/locales/zh-TW.json | 7 +++++-- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 20a03969..dadbd06f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2254,8 +2254,6 @@ "resumeBrokenTransfer": "Resume Transfers", "resumeBrokenTransferDesc": "Resume incomplete transfers instead of restarting.", "revisionLabel": "Revision", - "rightClickPaste": "Right-click Paste", - "rightClickPasteDesc": "Paste clipboard text on terminal right-click.", "s3AccessKeyId": "Access Key ID", "s3Bucket": "Bucket", "s3BucketRequired": "S3 bucket is required.", @@ -2399,6 +2397,11 @@ "terminalFontWeightBold": "Bold Font Weight", "terminalFontWeightBoldDesc": "Weight used when terminal output requests bold text.", "terminalFontWeightDesc": "Weight used for normal terminal text.", + "terminalRightClickAction": "Right-click Behavior", + "terminalRightClickActionDesc": "Choose what happens when you right-click in the terminal.", + "terminalRightClickMenu": "Menu", + "terminalRightClickNone": "Off", + "terminalRightClickPaste": "Paste", "terminalShortcuts": "Terminal Hotkeys", "terminalShortcutsDesc": "Shortcuts inside the terminal.", "terminalTheme": "Terminal Theme", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 890866e7..b2935894 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -2253,8 +2253,6 @@ "resumeBrokenTransfer": "전송 재개", "resumeBrokenTransferDesc": "완료되지 않은 전송을 처음부터 다시 시작하는 대신 재개합니다.", "revisionLabel": "리비전", - "rightClickPaste": "우클릭 붙여넣기", - "rightClickPasteDesc": "터미널을 우클릭하면 클립보드 텍스트를 붙여넣습니다.", "s3AccessKeyId": "액세스 키 ID", "s3Bucket": "버킷", "s3BucketRequired": "S3 버킷이 필요합니다.", @@ -2398,6 +2396,11 @@ "terminalFontWeightBold": "굵은 글꼴 두께", "terminalFontWeightBoldDesc": "터미널 출력이 굵은 텍스트를 요청할 때 사용할 두께입니다.", "terminalFontWeightDesc": "일반 터미널 텍스트에 사용할 두께입니다.", + "terminalRightClickAction": "우클릭 동작", + "terminalRightClickActionDesc": "터미널을 우클릭할 때 실행할 동작을 선택합니다.", + "terminalRightClickMenu": "메뉴", + "terminalRightClickNone": "끄기", + "terminalRightClickPaste": "붙여넣기", "terminalShortcuts": "터미널 단축키", "terminalShortcutsDesc": "터미널 내부의 단축키입니다.", "terminalTheme": "터미널 테마", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index a4a94bac..eca02f68 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2253,8 +2253,6 @@ "resumeBrokenTransfer": "断点续传", "resumeBrokenTransferDesc": "尝试恢复未完成的文件传输而非重新开始。", "revisionLabel": "版本号", - "rightClickPaste": "右键粘贴", - "rightClickPasteDesc": "在终端中右键点击时从剪贴板粘贴文本。", "s3AccessKeyId": "Access Key ID", "s3Bucket": "Bucket", "s3BucketRequired": "必须填写 S3 Bucket。", @@ -2398,6 +2396,11 @@ "terminalFontWeightBold": "粗体字重", "terminalFontWeightBoldDesc": "终端输出要求粗体文本时使用的字重。", "terminalFontWeightDesc": "普通终端文本使用的字重。", + "terminalRightClickAction": "右键行为", + "terminalRightClickActionDesc": "选择在终端中点击鼠标右键时执行的操作。", + "terminalRightClickMenu": "菜单", + "terminalRightClickNone": "关闭", + "terminalRightClickPaste": "粘贴", "terminalShortcuts": "终端快捷键", "terminalShortcutsDesc": "在终端内使用的快捷操作。", "terminalTheme": "终端主题", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index a5ccdf76..85e3dac9 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -2248,8 +2248,6 @@ "resumeBrokenTransfer": "斷點續傳", "resumeBrokenTransferDesc": "嘗試恢復未完成的檔案傳輸而非重新開始。", "revisionLabel": "版本號", - "rightClickPaste": "右鍵貼上", - "rightClickPasteDesc": "在終端中右鍵點選時從剪貼簿貼上文字。", "s3AccessKeyId": "Access Key ID", "s3Bucket": "Bucket", "s3BucketRequired": "必須填寫 S3 Bucket。", @@ -2393,6 +2391,11 @@ "terminalFontWeightBold": "粗體字重", "terminalFontWeightBoldDesc": "終端輸出要求粗體文字時使用的字重。", "terminalFontWeightDesc": "一般終端文字使用的字重。", + "terminalRightClickAction": "右鍵行為", + "terminalRightClickActionDesc": "選擇在終端中按一下滑鼠右鍵時執行的操作。", + "terminalRightClickMenu": "選單", + "terminalRightClickNone": "關閉", + "terminalRightClickPaste": "貼上", "terminalShortcuts": "終端快捷鍵", "terminalShortcutsDesc": "在終端內使用的快捷操作。", "terminalTheme": "終端主題", From 011297daf31914984392cbdf9cfae4559cd354ca Mon Sep 17 00:00:00 2001 From: Kang Date: Mon, 31 Aug 2026 10:01:43 +0800 Subject: [PATCH 15/32] style(NetworkPanel): enhance layout and styling for TunnelRow and TunnelRuntimeBadge components (#532) - Updated the TunnelRow component to improve the layout by ensuring the name display is properly truncated and responsive. - Modified the TunnelRuntimeBadge component to include additional styling for better visual consistency and to prevent text overflow. --- src/components/panel/NetworkPanel.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/panel/NetworkPanel.tsx b/src/components/panel/NetworkPanel.tsx index 5c362ebe..1d849a70 100644 --- a/src/components/panel/NetworkPanel.tsx +++ b/src/components/panel/NetworkPanel.tsx @@ -251,7 +251,10 @@ function TunnelRow({
-
+
{tunnel.name || endpoint}
@@ -326,7 +329,12 @@ function TunnelRuntimeBadge({ state, enabled }: { state?: TunnelRuntimeState; en error: "bg-destructive/10 text-destructive", }[status] ?? "bg-muted text-muted-foreground"; const badge = ( - + {label} ); From 5f5a456284b269a8797c3151a6d5b58d686a9bdb Mon Sep 17 00:00:00 2001 From: lly Date: Mon, 31 Aug 2026 11:14:54 +0800 Subject: [PATCH 16/32] fix(editor): reuse terminal selection color in builtin file editor The builtin file editor rendered selections in a pale white that obscured file content, unlike the terminal. - Publish the terminal theme's selectionBackground as the --df-terminal-selection CSS variable and use it (with !important) for .cm-selectionBackground, so the editor selection matches the terminal selection color instead of CodeMirror's #d7d4f0 default. - Move the .cm-activeLine highlight onto a ::before pseudo-element with z-index -3 (below the selection layer at -2) and clear the element background, so the active line highlight no longer washes out the selection on the cursor line (visible when selecting upwards, where the top line holds the cursor). Add regression tests for the published CSS variable and the compiled selection/active-line rules. --- src/context/ThemeContext.test.tsx | 17 +++++++ src/context/ThemeContext.tsx | 1 + src/lib/codeMirrorFileView.test.ts | 74 ++++++++++++++++++++++++++++++ src/lib/codeMirrorFileView.ts | 12 ++++- 4 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 src/context/ThemeContext.test.tsx create mode 100644 src/lib/codeMirrorFileView.test.ts diff --git a/src/context/ThemeContext.test.tsx b/src/context/ThemeContext.test.tsx new file mode 100644 index 00000000..92abc410 --- /dev/null +++ b/src/context/ThemeContext.test.tsx @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import type { ThemeColors } from "@/lib/themes"; +import { applyTerminalThemeToDOM } from "./ThemeContext"; + +describe("applyTerminalThemeToDOM", () => { + it("publishes the terminal selection color for shared editor surfaces", () => { + const selectionBackground = "#264f78"; + + applyTerminalThemeToDOM({ + selectionBackground, + } as ThemeColors["terminal"]); + + expect(document.documentElement.style.getPropertyValue("--df-terminal-selection")).toBe( + selectionBackground, + ); + }); +}); diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx index 33bd5211..5917393f 100644 --- a/src/context/ThemeContext.tsx +++ b/src/context/ThemeContext.tsx @@ -68,6 +68,7 @@ export function applyTerminalThemeToDOM(colors: ThemeColors["terminal"]) { const root = document.documentElement.style; root.setProperty("--df-terminal-bg", colors.background); root.setProperty("--df-terminal-fg", colors.foreground); + root.setProperty("--df-terminal-selection", colors.selectionBackground); } /** Provides theme, themeName, setTheme. Syncs with appSettings.appearance.theme from backend. */ diff --git a/src/lib/codeMirrorFileView.test.ts b/src/lib/codeMirrorFileView.test.ts new file mode 100644 index 00000000..f9986f33 --- /dev/null +++ b/src/lib/codeMirrorFileView.test.ts @@ -0,0 +1,74 @@ +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { describe, expect, it } from "vitest"; +import { codeMirrorFileViewExtensions } from "./codeMirrorFileView"; + +function findRules(fragment: string): string[] { + const rules: string[] = []; + for (const sheet of Array.from(document.styleSheets)) { + const owner = sheet.ownerNode as HTMLStyleElement | null; + // jsdom's cssText drops !important inside var() values, so read the raw + // rule text from the mounted style tag instead. + const text = owner?.textContent ?? ""; + for (const line of text.split("\n")) { + if (line.includes(fragment)) { + rules.push(line); + } + } + } + return rules; +} + +function mountEditor(): HTMLDivElement { + const host = document.createElement("div"); + document.body.appendChild(host); + new EditorView({ + state: EditorState.create({ + doc: "hello", + extensions: codeMirrorFileViewExtensions("plaintext"), + }), + parent: host, + }); + return host; +} + +describe("codeMirrorFileView selection styling", () => { + it("renders the editor selection with the terminal selection color", () => { + const host = mountEditor(); + + try { + const rules = findRules("cm-selectionBackground").join("\n"); + expect(rules).toContain("var(--df-terminal-selection"); + expect(rules).toContain("!important"); + } finally { + host.remove(); + } + }); + + it("keeps the active line highlight below the selection layer", () => { + const host = mountEditor(); + + try { + // CodeMirror draws its selection layer below in-flow line backgrounds, + // so the highlight must live on a ::before with z-index under the + // selection layer (-2), not on the line element itself. + const activeLineRules = findRules("cm-activeLine").filter( + (rule) => !rule.includes("cm-activeLineGutter"), + ); + const beforeRule = activeLineRules.find((rule) => rule.includes(":before")); + + expect(beforeRule).toBeDefined(); + expect(beforeRule).toContain("z-index: -3"); + expect(beforeRule).toContain("color-mix(in srgb, var(--muted) 22%, transparent)"); + + const lineRules = activeLineRules.filter((rule) => !rule.includes(":before")); + // The CodeMirror base theme also styles .cm-activeLine (#cceeff44); + // our theme's rule is the one that clears the element background. + const ownLineRule = lineRules.find((rule) => rule.includes("background-color: transparent")); + expect(ownLineRule).toBeDefined(); + expect(ownLineRule).toContain("position: relative"); + } finally { + host.remove(); + } + }); +}); diff --git a/src/lib/codeMirrorFileView.ts b/src/lib/codeMirrorFileView.ts index 54d6db85..866d95e7 100644 --- a/src/lib/codeMirrorFileView.ts +++ b/src/lib/codeMirrorFileView.ts @@ -294,7 +294,7 @@ export function codeMirrorFileViewExtensions( borderLeftColor: "var(--foreground)", }, ".cm-selectionBackground, &.cm-focused .cm-selectionBackground": { - backgroundColor: "color-mix(in srgb, var(--primary) 28%, transparent)", + backgroundColor: "var(--df-terminal-selection, var(--df-primary)) !important", }, ".cm-scroller": { fontFamily: "var(--font-mono), 'JetBrains Mono', monospace", @@ -318,7 +318,15 @@ export function codeMirrorFileViewExtensions( opacity: "0.8", }, ".cm-activeLine": { - backgroundColor: "color-mix(in srgb, var(--muted) 22%, transparent)", + "&::before": { + content: '""', + position: "absolute", + inset: "0", + zIndex: "-3", + backgroundColor: "color-mix(in srgb, var(--muted) 22%, transparent)", + }, + position: "relative", + backgroundColor: "transparent", }, ".cm-activeLineGutter": { backgroundColor: "color-mix(in srgb, var(--muted) 32%, transparent)", From 93c5aeaa3e42c794d4a167ba38f2dbebff5af330 Mon Sep 17 00:00:00 2001 From: Kang Date: Mon, 31 Aug 2026 11:49:06 +0800 Subject: [PATCH 17/32] perf(terminal): add tests and enhancements for terminal refresh effects and keyword highlighting - Introduced unit tests for the `useTerminalRefreshEffects` hook to validate terminal repainting behavior and texture invalidation on DPI scale changes. - Updated the `useTerminalRefreshEffects` hook to prevent unnecessary texture atlas clearing during active terminal refreshes. - Added a new `shouldSuspendKeywordHighlighter` function to manage keyword highlighting based on terminal visibility and performance mode, with corresponding tests to ensure correct behavior. - Enhanced the `useKeywordHighlighter` hook to support session-specific keyword highlighting and cache management. - Refactored performance configurations for keyword highlighting to optimize refresh rates and decoration limits. --- src/components/terminal/XTerminal.tsx | 13 +- .../useTerminalRefreshEffects.test.ts | 84 +++ .../terminal/useTerminalRefreshEffects.ts | 1 - .../xterminalKeywordHighlighting.test.ts | 29 + .../terminal/xterminalKeywordHighlighting.ts | 21 + src/hooks/useKeywordHighlighter.test.ts | 76 +++ src/hooks/useKeywordHighlighter.ts | 17 +- src/hooks/useTerminalSettings.test.ts | 165 ++++++ src/hooks/useTerminalSettings.ts | 6 +- src/lib/keywordHighlighter.test.ts | 506 ++++++++++++++++ src/lib/keywordHighlighter.ts | 549 ++++++++++-------- src/lib/xtermPerformance.ts | 16 +- 12 files changed, 1220 insertions(+), 263 deletions(-) create mode 100644 src/components/terminal/useTerminalRefreshEffects.test.ts create mode 100644 src/components/terminal/xterminalKeywordHighlighting.test.ts create mode 100644 src/components/terminal/xterminalKeywordHighlighting.ts create mode 100644 src/hooks/useKeywordHighlighter.test.ts create mode 100644 src/hooks/useTerminalSettings.test.ts create mode 100644 src/lib/keywordHighlighter.test.ts diff --git a/src/components/terminal/XTerminal.tsx b/src/components/terminal/XTerminal.tsx index 2b941280..01ae136a 100644 --- a/src/components/terminal/XTerminal.tsx +++ b/src/components/terminal/XTerminal.tsx @@ -123,6 +123,7 @@ import { writeTextInFrames, } from "./xterminalOutputQueue"; import type { PerformanceMode, XTerminalProps } from "./xterminalTypes"; +import { shouldSuspendKeywordHighlighter } from "./xterminalKeywordHighlighting"; import { createZmodemEventHandler, type ZmodemEventPayload, @@ -1742,7 +1743,6 @@ export default function XTerminal({ if (!visibleRef.current || !isTerminalAlive()) return; requestAnimationFrame(() => { if (!visibleRef.current || !isTerminalAlive()) return; - terminal.clearTextureAtlas(); terminal.refresh(0, Math.max(0, terminal.rows - 1)); requestAnimationFrame(() => { if (!visibleRef.current || !isTerminalAlive()) return; @@ -2263,12 +2263,21 @@ export default function XTerminal({ // isDark is derived from the terminal theme background so built-in rule colors // switch automatically when the user changes themes. const isDark = hexLuminance(terminalTheme.colors.terminal.background) < 0.5; + const keywordHighlighterSuspended = shouldSuspendKeywordHighlighter({ + visible, + hibernated, + terminalReady, + performanceMode, + }); useKeywordHighlighter( terminalInstance, terminalSettings, sessionId, isDark, - performanceMode !== "normal" || !visible, + { + suspended: keywordHighlighterSuspended, + releaseCachesAfterDelay: !visible || hibernated, + }, ); const { tooltipState, menuState, closeMenu } = useActionLinks( diff --git a/src/components/terminal/useTerminalRefreshEffects.test.ts b/src/components/terminal/useTerminalRefreshEffects.test.ts new file mode 100644 index 00000000..1f2b305b --- /dev/null +++ b/src/components/terminal/useTerminalRefreshEffects.test.ts @@ -0,0 +1,84 @@ +import type { Terminal } from "@xterm/xterm"; +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { TerminalFitScheduler } from "./terminalFitScheduler"; +import { useTerminalRefreshEffects } from "./useTerminalRefreshEffects"; + +const windowMocks = vi.hoisted(() => ({ + scaleChanged: undefined as ((event: { payload: { scaleFactor: number } }) => void) | undefined, +})); + +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => ({ + onResized: async () => vi.fn(), + onMoved: async () => vi.fn(), + onFocusChanged: async () => vi.fn(), + onScaleChanged: async (callback: (event: { payload: { scaleFactor: number } }) => void) => { + windowMocks.scaleChanged = callback; + return vi.fn(); + }, + }), +})); + +describe("useTerminalRefreshEffects", () => { + beforeEach(() => { + windowMocks.scaleChanged = undefined; + }); + + it("repaints an active visible terminal without texture invalidation", () => { + const schedule = vi.fn(); + renderHook(() => + useTerminalRefreshEffects({ + terminalRef: { current: {} as Terminal }, + fitSchedulerRef: { + current: { schedule } as unknown as TerminalFitScheduler, + }, + active: true, + visible: true, + terminalReady: true, + performanceMode: "normal", + sessionId: "session-1", + showGutter: false, + showContentPadding: false, + }), + ); + + const activeRefresh = schedule.mock.calls + .map(([request]) => request) + .find((request) => request.reason === "active"); + expect(activeRefresh).toEqual( + expect.objectContaining({ force: true, refresh: true, focus: true }), + ); + expect(activeRefresh).not.toHaveProperty("clearTextureAtlas"); + }); + + it("still invalidates textures after a DPI scale change", async () => { + const schedule = vi.fn(); + renderHook(() => + useTerminalRefreshEffects({ + terminalRef: { current: {} as Terminal }, + fitSchedulerRef: { + current: { schedule } as unknown as TerminalFitScheduler, + }, + active: true, + visible: true, + terminalReady: true, + performanceMode: "normal", + sessionId: "session-1", + showGutter: false, + showContentPadding: false, + }), + ); + await waitFor(() => expect(windowMocks.scaleChanged).toBeTypeOf("function")); + windowMocks.scaleChanged?.({ payload: { scaleFactor: 2 } }); + + expect(schedule).toHaveBeenCalledWith( + expect.objectContaining({ + reason: "scale-factor", + force: true, + refresh: true, + clearTextureAtlas: true, + }), + ); + }); +}); diff --git a/src/components/terminal/useTerminalRefreshEffects.ts b/src/components/terminal/useTerminalRefreshEffects.ts index c9de1b54..12ec88c8 100644 --- a/src/components/terminal/useTerminalRefreshEffects.ts +++ b/src/components/terminal/useTerminalRefreshEffects.ts @@ -89,7 +89,6 @@ export function useTerminalRefreshEffects({ reason: "active", force: true, refresh: true, - clearTextureAtlas: true, focus: true, }); } diff --git a/src/components/terminal/xterminalKeywordHighlighting.test.ts b/src/components/terminal/xterminalKeywordHighlighting.test.ts new file mode 100644 index 00000000..c089b5f1 --- /dev/null +++ b/src/components/terminal/xterminalKeywordHighlighting.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { shouldSuspendKeywordHighlighter } from "./xterminalKeywordHighlighting"; + +describe("shouldSuspendKeywordHighlighter", () => { + const ready = { + visible: true, + hibernated: false, + terminalReady: true, + performanceMode: "normal" as const, + }; + + it("only resumes for a visible, ready terminal under normal pressure", () => { + expect(shouldSuspendKeywordHighlighter(ready)).toBe(false); + expect(shouldSuspendKeywordHighlighter({ ...ready, visible: false })).toBe(true); + expect(shouldSuspendKeywordHighlighter({ ...ready, hibernated: true })).toBe(true); + expect(shouldSuspendKeywordHighlighter({ ...ready, terminalReady: false })).toBe(true); + expect( + shouldSuspendKeywordHighlighter({ ...ready, performanceMode: "strained" }), + ).toBe(true); + expect( + shouldSuspendKeywordHighlighter({ ...ready, performanceMode: "overloaded" }), + ).toBe(true); + }); + + it("does not accept focus or active state as an input", () => { + expect(Object.keys(ready)).not.toContain("active"); + expect(shouldSuspendKeywordHighlighter(ready)).toBe(false); + }); +}); diff --git a/src/components/terminal/xterminalKeywordHighlighting.ts b/src/components/terminal/xterminalKeywordHighlighting.ts new file mode 100644 index 00000000..c872dea2 --- /dev/null +++ b/src/components/terminal/xterminalKeywordHighlighting.ts @@ -0,0 +1,21 @@ +import type { PerformanceMode } from "./xterminalTypes"; + +interface KeywordHighlightSuspensionState { + visible: boolean; + hibernated: boolean; + terminalReady: boolean; + performanceMode: PerformanceMode; +} + +/** + * Highlighting follows presentation readiness and output pressure. Focus/active + * state is deliberately absent so every visible split pane can stay highlighted. + */ +export function shouldSuspendKeywordHighlighter({ + visible, + hibernated, + terminalReady, + performanceMode, +}: KeywordHighlightSuspensionState): boolean { + return !visible || hibernated || !terminalReady || performanceMode !== "normal"; +} diff --git a/src/hooks/useKeywordHighlighter.test.ts b/src/hooks/useKeywordHighlighter.test.ts new file mode 100644 index 00000000..65efb432 --- /dev/null +++ b/src/hooks/useKeywordHighlighter.test.ts @@ -0,0 +1,76 @@ +import type { Terminal } from "@xterm/xterm"; +import { renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { XTERM_PERFORMANCE_CONFIG } from "@/lib/xtermPerformance"; +import type { AppSettings } from "@/types/global"; +import { useKeywordHighlighter } from "./useKeywordHighlighter"; + +const highlighterMocks = vi.hoisted(() => ({ + instances: [] as Array<{ + dispose: ReturnType; + releaseCaches: ReturnType; + setRules: ReturnType; + setSuspended: ReturnType; + }>, +})); + +vi.mock("../lib/keywordHighlighter", () => ({ + KeywordHighlighter: class { + dispose = vi.fn(); + releaseCaches = vi.fn(); + setRules = vi.fn(); + setSuspended = vi.fn(); + + constructor() { + highlighterMocks.instances.push(this); + } + }, +})); + +vi.mock("../lib/keywordHighlightPresets", () => ({ getBuiltinRules: () => [] })); + +const settings = { + keyword_highlights_enabled: true, + keyword_highlights: [], + keyword_highlight_builtin_rules: {}, + keyword_highlights_across_wrapped_lines: false, +} as unknown as AppSettings["terminal"]; + +describe("useKeywordHighlighter cache release policy", () => { + beforeEach(() => { + vi.useFakeTimers(); + highlighterMocks.instances.length = 0; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("suspends under pressure without scheduling a cache release", () => { + const terminal = {} as Terminal; + renderHook(() => + useKeywordHighlighter(terminal, settings, "session-1", true, { + suspended: true, + releaseCachesAfterDelay: false, + }), + ); + const highlighter = highlighterMocks.instances[0]; + vi.advanceTimersByTime(XTERM_PERFORMANCE_CONFIG.lifecycle.hiddenCacheReleaseDelayMs); + + expect(highlighter.setSuspended).toHaveBeenCalledWith(true); + expect(highlighter.releaseCaches).not.toHaveBeenCalled(); + }); + + it("releases caches after a terminal stays hidden", () => { + const terminal = {} as Terminal; + renderHook(() => + useKeywordHighlighter(terminal, settings, "session-1", true, { + suspended: true, + releaseCachesAfterDelay: true, + }), + ); + const highlighter = highlighterMocks.instances[0]; + vi.advanceTimersByTime(XTERM_PERFORMANCE_CONFIG.lifecycle.hiddenCacheReleaseDelayMs); + expect(highlighter.releaseCaches).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks/useKeywordHighlighter.ts b/src/hooks/useKeywordHighlighter.ts index 32327479..eaa1b692 100644 --- a/src/hooks/useKeywordHighlighter.ts +++ b/src/hooks/useKeywordHighlighter.ts @@ -18,14 +18,19 @@ import type { AppSettings, KeywordHighlightRule } from "../types/global"; export function useKeywordHighlighter( terminal: Terminal | null, terminalSettings: AppSettings["terminal"], - _sessionId: string, + sessionId: string, isDark: boolean, - suspended = false, + options: { + suspended?: boolean; + releaseCachesAfterDelay?: boolean; + } = {}, ): void { const highlighterRef = useRef(null); const cacheReleaseTimerRef = useRef(null); const [highlighterInstance, setHighlighterInstance] = useState(null); const enabled = terminalSettings.keyword_highlights_enabled ?? false; + const suspended = options.suspended ?? false; + const releaseCachesAfterDelay = options.releaseCachesAfterDelay ?? false; // Merge user rules (higher priority) + built-in rules (lower priority). // User rules carry two color fields; pick the right one for the current theme @@ -62,7 +67,7 @@ export function useKeywordHighlighter( if (!terminal) return; - const highlighter = new KeywordHighlighter(terminal); + const highlighter = new KeywordHighlighter(terminal, sessionId); highlighterRef.current = highlighter; setHighlighterInstance(highlighter); @@ -71,7 +76,7 @@ export function useKeywordHighlighter( highlighterRef.current = null; setHighlighterInstance((current) => (current === highlighter ? null : current)); }; - }, [terminal, enabled]); + }, [terminal, enabled, sessionId]); // Re-push rules whenever settings change or theme family switches. useEffect(() => { @@ -97,7 +102,7 @@ export function useKeywordHighlighter( cacheReleaseTimerRef.current = null; } - if (suspended) { + if (suspended && releaseCachesAfterDelay) { cacheReleaseTimerRef.current = window.setTimeout(() => { cacheReleaseTimerRef.current = null; highlighterInstance.releaseCaches(); @@ -110,5 +115,5 @@ export function useKeywordHighlighter( cacheReleaseTimerRef.current = null; } }; - }, [highlighterInstance, suspended]); + }, [highlighterInstance, releaseCachesAfterDelay, suspended]); } diff --git a/src/hooks/useTerminalSettings.test.ts b/src/hooks/useTerminalSettings.test.ts new file mode 100644 index 00000000..1d776474 --- /dev/null +++ b/src/hooks/useTerminalSettings.test.ts @@ -0,0 +1,165 @@ +import type { Terminal } from "@xterm/xterm"; +import { renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TerminalFitScheduler } from "@/components/terminal/terminalFitScheduler"; +import type { TerminalColors } from "@/lib/themes"; +import type { AppSettings } from "@/types/global"; +import { useTerminalSettings } from "./useTerminalSettings"; + +const webglMocks = vi.hoisted(() => ({ + instances: [] as Array<{ dispose: ReturnType; contextLoss?: () => void }>, +})); + +vi.mock("@xterm/addon-webgl", () => ({ + WebglAddon: class { + dispose = vi.fn(); + + constructor() { + webglMocks.instances.push(this); + } + + onContextLoss(callback: () => void) { + webglMocks.instances[webglMocks.instances.length - 1].contextLoss = callback; + return { dispose: vi.fn() }; + } + }, +})); + +vi.mock("@/lib/xtermImeCompatibility", () => ({ + installImeCompatibilityPatch: () => ({ dispose: vi.fn() }), +})); + +const theme = (background: string): TerminalColors => + ({ background, foreground: "#ffffff" }) as TerminalColors; + +const appearance = (fontSize: number): AppSettings["appearance"] => + ({ + font_family: "JetBrains Mono", + font_size: fontSize, + font_weight: "normal", + font_weight_bold: "bold", + cursor_blink: true, + cursor_style: "block", + minimum_contrast_ratio: 1, + }) as unknown as AppSettings["appearance"]; + +const terminalSettings = { + hardware_acceleration: true, + font_size_delta: 0, + scrollback_lines: 5_000, +} as AppSettings["terminal"]; + +const interaction = { + word_separators: " ()[]{}'\"", + alt_as_meta: false, + ime_compatibility: false, +} as AppSettings["interaction"]; + +describe("useTerminalSettings renderer refresh", () => { + let rafCallbacks: Map; + let rafRequests: ReturnType; + let nextRafId: number; + + beforeEach(() => { + vi.useFakeTimers(); + webglMocks.instances.length = 0; + rafCallbacks = new Map(); + nextRafId = 1; + rafRequests = vi.fn((callback: FrameRequestCallback) => { + const id = nextRafId++; + rafCallbacks.set(id, callback); + return id; + }); + vi.stubGlobal("requestAnimationFrame", rafRequests); + vi.stubGlobal("cancelAnimationFrame", (id: number) => rafCallbacks.delete(id)); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + const flushAnimationFrames = () => { + while (rafCallbacks.size > 0) { + const callbacks = [...rafCallbacks.values()]; + rafCallbacks.clear(); + for (const callback of callbacks) callback(performance.now()); + } + }; + + function createHookHarness(rendererVisible = true) { + const terminal = { + rows: 24, + options: {}, + clearTextureAtlas: vi.fn(), + refresh: vi.fn(), + loadAddon: vi.fn(), + } as unknown as Terminal; + const terminalRef = { current: terminal }; + const fitSchedulerRef = { + current: { schedule: vi.fn() } as unknown as TerminalFitScheduler, + }; + const hook = renderHook( + (props: { visible: boolean; colors: TerminalColors; ui: AppSettings["appearance"] }) => + useTerminalSettings( + terminalRef, + fitSchedulerRef, + props.colors, + props.ui, + terminalSettings, + interaction, + props.visible, + terminal, + "session-1", + ), + { + initialProps: { visible: rendererVisible, colors: theme("#000000"), ui: appearance(14) }, + }, + ); + return { ...hook, terminal, terminalRef, fitSchedulerRef }; + } + + it("installs WebGL and schedules only one reveal chain", () => { + const { terminal } = createHookHarness(); + flushAnimationFrames(); + + expect(terminal.loadAddon).toHaveBeenCalledTimes(1); + expect(terminal.refresh).toHaveBeenCalledTimes(3); + expect(terminal.clearTextureAtlas).toHaveBeenCalledTimes(1); + expect(rafRequests).toHaveBeenCalledTimes(3); + }); + + it("repaints hidden-to-visible WebGL without clearing the texture atlas", () => { + const harness = createHookHarness(); + const stableColors = theme("#000000"); + const stableAppearance = appearance(14); + flushAnimationFrames(); + vi.mocked(harness.terminal.refresh).mockClear(); + vi.mocked(harness.terminal.clearTextureAtlas).mockClear(); + + harness.rerender({ visible: false, colors: stableColors, ui: stableAppearance }); + flushAnimationFrames(); + vi.mocked(harness.terminal.refresh).mockClear(); + vi.mocked(harness.terminal.clearTextureAtlas).mockClear(); + harness.rerender({ visible: true, colors: stableColors, ui: stableAppearance }); + flushAnimationFrames(); + + expect(harness.terminal.refresh).toHaveBeenCalledTimes(2); + expect(harness.terminal.clearTextureAtlas).not.toHaveBeenCalled(); + }); + + it("still clears the texture atlas for theme and font changes", () => { + const harness = createHookHarness(); + flushAnimationFrames(); + vi.mocked(harness.terminal.clearTextureAtlas).mockClear(); + + harness.rerender({ visible: true, colors: theme("#101010"), ui: appearance(14) }); + flushAnimationFrames(); + expect(harness.terminal.clearTextureAtlas).toHaveBeenCalledTimes(1); + + vi.mocked(harness.terminal.clearTextureAtlas).mockClear(); + harness.rerender({ visible: true, colors: theme("#101010"), ui: appearance(16) }); + flushAnimationFrames(); + expect(harness.terminal.clearTextureAtlas).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks/useTerminalSettings.ts b/src/hooks/useTerminalSettings.ts index 2213f09f..5b49a46d 100644 --- a/src/hooks/useTerminalSettings.ts +++ b/src/hooks/useTerminalSettings.ts @@ -99,7 +99,6 @@ export function useTerminalSettings( revealRefreshFrameRef.current = requestAnimationFrame(() => { const terminal = terminalRef.current; if (terminal) { - terminal.clearTextureAtlas(); terminal.refresh(0, Math.max(0, terminal.rows - 1)); } @@ -172,7 +171,6 @@ export function useTerminalSettings( } clearHiddenWebglDisposeTimer(); - scheduleRevealRefresh(); const installWebgl = (targetTerminal: Terminal) => { try { @@ -214,7 +212,9 @@ export function useTerminalSettings( } }; - if (!webglAddonRef.current) { + if (webglAddonRef.current) { + scheduleRevealRefresh(); + } else { installWebgl(terminal); } }, [ diff --git a/src/lib/keywordHighlighter.test.ts b/src/lib/keywordHighlighter.test.ts new file mode 100644 index 00000000..d70123bc --- /dev/null +++ b/src/lib/keywordHighlighter.test.ts @@ -0,0 +1,506 @@ +import type { + IBufferCell, + IBufferLine, + IDecoration, + IDisposable, + IMarker, + Terminal, +} from "@xterm/xterm"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ResolvedHighlightRule } from "./keywordHighlightPresets"; +import { KeywordHighlighter } from "./keywordHighlighter"; +import { XTERM_PERFORMANCE_CONFIG } from "./xtermPerformance"; + +vi.mock("./logger", () => ({ logger: { debug: vi.fn() } })); + +class FakeDisposable implements IDisposable { + protected disposed = false; + + dispose(): void { + this.disposed = true; + } +} + +class FakeMarker extends FakeDisposable implements IMarker { + readonly id = 1; + private listeners = new Set<() => void>(); + + get isDisposed(): boolean { + return this.disposed; + } + + constructor(public line: number) { + super(); + } + + onDispose(listener: () => void): IDisposable { + this.listeners.add(listener); + return { dispose: () => this.listeners.delete(listener) }; + } + + override dispose(): void { + if (this.disposed) return; + super.dispose(); + for (const listener of [...this.listeners]) listener(); + this.listeners.clear(); + } +} + +class FakeDecoration extends FakeDisposable implements IDecoration { + readonly options = { overviewRulerOptions: undefined }; + readonly marker = new FakeMarker(0); + readonly element = undefined; + private listeners = new Set<() => void>(); + + get isDisposed(): boolean { + return this.disposed; + } + + onRender(): IDisposable { + return new FakeDisposable(); + } + + onDispose(listener: () => void): IDisposable { + this.listeners.add(listener); + return { dispose: () => this.listeners.delete(listener) }; + } + + override dispose(): void { + if (this.disposed) return; + super.dispose(); + for (const listener of [...this.listeners]) listener(); + this.listeners.clear(); + } +} + +interface CellSpec { + chars: string; + width: number; + fgDefault?: boolean; +} + +function createCell(spec: CellSpec = { chars: "", width: 1 }): IBufferCell { + return { + getChars: () => spec.chars, + getWidth: () => spec.width, + isFgDefault: () => spec.fgDefault ?? true, + } as unknown as IBufferCell; +} + +interface FakeLine extends IBufferLine { + translateSpy: ReturnType; +} + +function createLine( + text: string, + options: { wrapped?: boolean; cells?: Map; fgDefault?: boolean } = {}, +): FakeLine { + const translateSpy = vi.fn((trimRight: boolean) => + trimRight ? text.replace(/\s+$/u, "") : text, + ); + return { + isWrapped: options.wrapped ?? false, + length: Math.max(80, text.length), + translateSpy, + translateToString: translateSpy, + getCell: (index: number) => { + const explicit = options.cells?.get(index); + if (explicit) return createCell(explicit); + const char = text[index] ?? ""; + return createCell({ chars: char, width: 1, fgDefault: options.fgDefault }); + }, + } as unknown as FakeLine; +} + +interface DecorationRecord { + decoration: FakeDecoration; + marker: FakeMarker; + x?: number; + width?: number; + foregroundColor?: string; +} + +function createHarness(options: { + lines: FakeLine[]; + baseY: number; + viewportY: number; + rows?: number; + cols?: number; +}) { + const writeListeners = new Set<() => void>(); + const resizeListeners = new Set<() => void>(); + const renderListeners = new Set<() => void>(); + const markers: FakeMarker[] = []; + const decorations: DecorationRecord[] = []; + const active = { + type: "normal" as "normal" | "alternate", + baseY: options.baseY, + cursorY: 0, + viewportY: options.viewportY, + get length() { + return options.lines.length; + }, + getLine: (lineY: number) => options.lines[lineY], + getNullCell: () => createCell(), + }; + const subscribe = (listeners: Set<() => void>, listener: () => void): IDisposable => { + listeners.add(listener); + return { dispose: () => listeners.delete(listener) }; + }; + const terminal = { + rows: options.rows ?? 1, + cols: options.cols ?? 80, + buffer: { active }, + onWriteParsed: (listener: () => void) => subscribe(writeListeners, listener), + onResize: (listener: () => void) => subscribe(resizeListeners, listener), + onRender: (listener: () => void) => subscribe(renderListeners, listener), + registerMarker: (offset = 0) => { + const marker = new FakeMarker(active.baseY + active.cursorY + offset); + markers.push(marker); + return marker; + }, + registerDecoration: (decorationOptions: { + marker: FakeMarker; + x?: number; + width?: number; + foregroundColor?: string; + }) => { + const decoration = new FakeDecoration(); + decorations.push({ decoration, ...decorationOptions }); + return decoration; + }, + } as unknown as Terminal; + + return { + active, + decorations, + markers, + terminal, + render: () => { + for (const listener of renderListeners) listener(); + }, + resize: () => { + for (const listener of resizeListeners) listener(); + }, + write: () => { + for (const listener of writeListeners) listener(); + }, + }; +} + +const rule = (pattern = "ERROR", color = "#ff0000"): ResolvedHighlightRule => ({ + id: `rule-${pattern}-${color}`, + name: pattern, + patterns: [pattern], + color, + enabled: true, +}); + +function flushWriteRefresh() { + vi.advanceTimersByTime(XTERM_PERFORMANCE_CONFIG.highlighting.debounceMs); +} + +function flushScrollRefresh() { + vi.advanceTimersByTime(XTERM_PERFORMANCE_CONFIG.highlighting.scrollIdleDebounceMs); +} + +describe("KeywordHighlighter", () => { + let rafCallbacks: Map; + let nextRafId: number; + + beforeEach(() => { + vi.useFakeTimers(); + rafCallbacks = new Map(); + nextRafId = 1; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + const id = nextRafId++; + rafCallbacks.set(id, callback); + return id; + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => rafCallbacks.delete(id)); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("uses a pure trailing debounce for continuous scrolling", () => { + const lines = Array.from({ length: 240 }, (_, index) => + createLine(index === 140 ? "ERROR" : ""), + ); + const harness = createHarness({ lines, baseY: 220, viewportY: 100 }); + const highlighter = new KeywordHighlighter(harness.terminal, "session-1"); + highlighter.setRules([rule()], true); + flushWriteRefresh(); + + harness.active.viewportY = 120; + harness.render(); + vi.advanceTimersByTime(60); + harness.active.viewportY = 130; + harness.render(); + vi.advanceTimersByTime(119); + expect(lines[140].translateSpy).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(lines[140].translateSpy).toHaveBeenCalledTimes(1); + highlighter.dispose(); + }); + + it("lets scrolling cancel a pending continuation frame", () => { + const denseText = `${"ERROR ".repeat(20)}`; + const lines = Array.from({ length: 160 }, (_, index) => + createLine(index >= 20 && index <= 60 ? denseText : ""), + ); + const harness = createHarness({ lines, baseY: 140, viewportY: 40 }); + const highlighter = new KeywordHighlighter(harness.terminal); + highlighter.setRules([rule()], true); + flushWriteRefresh(); + expect(rafCallbacks).toHaveLength(1); + const decorationCount = harness.decorations.length; + + harness.active.viewportY = 41; + harness.render(); + expect(rafCallbacks).toHaveLength(0); + expect(harness.decorations).toHaveLength(decorationCount); + highlighter.dispose(); + }); + + it("rebuilds decorations from cached scrollback spans without rescanning", () => { + const lines = Array.from({ length: 240 }, (_, index) => + createLine(index === 100 ? "ERROR" : ""), + ); + const harness = createHarness({ lines, baseY: 220, viewportY: 100 }); + const highlighter = new KeywordHighlighter(harness.terminal); + highlighter.setRules([rule()], true); + flushWriteRefresh(); + expect(lines[100].translateSpy).toHaveBeenCalledTimes(1); + + harness.active.viewportY = 160; + harness.render(); + flushScrollRefresh(); + const firstDecoration = harness.decorations.find((entry) => entry.marker.line === 100); + expect(firstDecoration?.decoration).toHaveProperty("disposed", true); + + harness.active.viewportY = 100; + harness.render(); + flushScrollRefresh(); + expect(lines[100].translateSpy).toHaveBeenCalledTimes(1); + expect(harness.decorations.filter((entry) => entry.marker.line === 100)).toHaveLength(2); + highlighter.dispose(); + }); + + it("caches empty immutable line results", () => { + const lines = Array.from({ length: 240 }, () => createLine("nothing")); + const harness = createHarness({ lines, baseY: 220, viewportY: 100 }); + const highlighter = new KeywordHighlighter(harness.terminal); + highlighter.setRules([rule()], true); + flushWriteRefresh(); + + harness.active.viewportY = 160; + harness.render(); + flushScrollRefresh(); + harness.active.viewportY = 100; + harness.render(); + flushScrollRefresh(); + expect(lines[100].translateSpy).toHaveBeenCalledTimes(1); + highlighter.dispose(); + }); + + it("keeps the live screen dynamic instead of storing it in the match cache", () => { + const lines = Array.from({ length: 40 }, (_, index) => + createLine(index === 20 ? "ERROR" : ""), + ); + const harness = createHarness({ lines, baseY: 20, viewportY: 20 }); + const highlighter = new KeywordHighlighter(harness.terminal); + highlighter.setRules([rule()], true); + flushWriteRefresh(); + harness.write(); + flushWriteRefresh(); + + expect(lines[20].translateSpy).toHaveBeenCalledTimes(2); + highlighter.dispose(); + }); + + it("invalidates decorations and match state in the alternate screen", () => { + const lines = Array.from({ length: 80 }, (_, index) => + createLine(index === 20 ? "ERROR" : ""), + ); + const harness = createHarness({ lines, baseY: 60, viewportY: 20 }); + const highlighter = new KeywordHighlighter(harness.terminal); + highlighter.setRules([rule()], true); + flushWriteRefresh(); + const firstDecoration = harness.decorations.find((entry) => entry.marker.line === 20); + + harness.active.type = "alternate"; + harness.write(); + expect(firstDecoration?.decoration).toHaveProperty("disposed", true); + const internals = highlighter as unknown as { lineMatchCache: Map }; + expect(internals.lineMatchCache).toHaveLength(0); + + harness.active.type = "normal"; + harness.write(); + flushWriteRefresh(); + expect(lines[20].translateSpy).toHaveBeenCalledTimes(2); + highlighter.dispose(); + }); + + it("bounds the match cache with LRU eviction", () => { + const harness = createHarness({ lines: [createLine("")], baseY: 0, viewportY: 0 }); + const highlighter = new KeywordHighlighter(harness.terminal); + const internals = highlighter as unknown as { + setCachedMatches: (lineY: number, spans: []) => void; + lineMatchCache: Map; + }; + for (let lineY = 0; lineY <= XTERM_PERFORMANCE_CONFIG.highlighting.maxCachedMatchLines; lineY++) { + internals.setCachedMatches(lineY, []); + } + expect(internals.lineMatchCache).toHaveLength( + XTERM_PERFORMANCE_CONFIG.highlighting.maxCachedMatchLines, + ); + expect(internals.lineMatchCache.has(0)).toBe(false); + expect(internals.lineMatchCache.has(1)).toBe(true); + highlighter.dispose(); + }); + + it("invalidates match and decoration caches after scrollback trim and resize", () => { + const lines = Array.from({ length: 80 }, (_, index) => + createLine(index === 20 ? "ERROR" : ""), + ); + const harness = createHarness({ lines, baseY: 60, viewportY: 20 }); + const highlighter = new KeywordHighlighter(harness.terminal); + highlighter.setRules([rule()], true); + flushWriteRefresh(); + const firstSentinel = harness.markers[0]; + firstSentinel.dispose(); + harness.active.viewportY = 21; + harness.render(); + flushScrollRefresh(); + expect(lines[20].translateSpy).toHaveBeenCalledTimes(2); + + harness.resize(); + flushWriteRefresh(); + expect(lines[20].translateSpy).toHaveBeenCalledTimes(3); + highlighter.dispose(); + }); + + it("invalidates cached colors when rules change and preserves priority/overlap", () => { + const lines = Array.from({ length: 80 }, (_, index) => + createLine(index === 20 ? "ERROR" : ""), + ); + const harness = createHarness({ lines, baseY: 60, viewportY: 20 }); + const highlighter = new KeywordHighlighter(harness.terminal); + highlighter.setRules([rule("ERROR", "#111111"), rule("ERR", "#222222")], true); + flushWriteRefresh(); + expect( + harness.decorations.filter((entry) => entry.marker.line === 20).map((entry) => entry.foregroundColor), + ).toEqual(["#111111"]); + + highlighter.setRules([rule("ERROR", "#333333")], true); + flushWriteRefresh(); + expect(lines[20].translateSpy).toHaveBeenCalledTimes(2); + expect(harness.decorations[harness.decorations.length - 1]?.foregroundColor).toBe("#333333"); + highlighter.dispose(); + }); + + it("does not override an ANSI foreground", () => { + const lines = Array.from({ length: 80 }, (_, index) => + createLine(index === 20 ? "ERROR" : "", { fgDefault: index !== 20 }), + ); + const harness = createHarness({ lines, baseY: 60, viewportY: 20 }); + const highlighter = new KeywordHighlighter(harness.terminal); + highlighter.setRules([rule()], true); + flushWriteRefresh(); + expect(harness.decorations.filter((entry) => entry.marker.line === 20)).toHaveLength(0); + highlighter.dispose(); + }); + + it("maps CJK and surrogate-pair string offsets to xterm cells", () => { + const cjkCells = new Map(); + [..."错误:连接失败"].forEach((char, index) => { + cjkCells.set(index * 2, { chars: char, width: 2 }); + }); + const emojiCells = new Map([ + [0, { chars: "🙂", width: 2 }], + [2, { chars: "E", width: 1 }], + [3, { chars: "R", width: 1 }], + [4, { chars: "R", width: 1 }], + [5, { chars: "O", width: 1 }], + [6, { chars: "R", width: 1 }], + ]); + const nulBeforeWideCells = new Map([ + [0, { chars: "", width: 0 }], + [1, { chars: "错", width: 2 }], + [3, { chars: "误", width: 2 }], + ]); + const lines = Array.from({ length: 80 }, () => createLine("")); + lines[20] = createLine("错误:连接失败", { cells: cjkCells }); + lines[21] = createLine("🙂ERROR", { cells: emojiCells }); + lines[22] = createLine(" 错误", { cells: nulBeforeWideCells }); + const harness = createHarness({ lines, baseY: 60, viewportY: 20, rows: 3 }); + const highlighter = new KeywordHighlighter(harness.terminal); + highlighter.setRules( + [rule("连接失败"), rule("ERROR", "#00ff00"), rule("错误", "#0000ff")], + true, + ); + flushWriteRefresh(); + + expect(harness.decorations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ marker: expect.objectContaining({ line: 20 }), x: 6, width: 8 }), + expect.objectContaining({ marker: expect.objectContaining({ line: 21 }), x: 2, width: 5 }), + expect.objectContaining({ marker: expect.objectContaining({ line: 22 }), x: 1, width: 4 }), + ]), + ); + highlighter.dispose(); + }); + + it("caches complete wrapped logical lines and restores both physical rows", () => { + const lines = Array.from({ length: 240 }, () => createLine("")); + lines[100] = createLine("ERR"); + lines[101] = createLine("OR", { wrapped: true }); + const harness = createHarness({ lines, baseY: 220, viewportY: 100, rows: 2, cols: 3 }); + const highlighter = new KeywordHighlighter(harness.terminal); + highlighter.setRules([rule()], true, true); + flushWriteRefresh(); + expect(lines[100].translateSpy).toHaveBeenCalledTimes(1); + expect(lines[101].translateSpy).toHaveBeenCalledTimes(1); + expect(harness.decorations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ marker: expect.objectContaining({ line: 100 }), width: 3 }), + expect.objectContaining({ marker: expect.objectContaining({ line: 101 }), width: 2 }), + ]), + ); + + harness.active.viewportY = 160; + harness.render(); + flushScrollRefresh(); + harness.active.viewportY = 100; + harness.render(); + flushScrollRefresh(); + expect(lines[100].translateSpy).toHaveBeenCalledTimes(1); + expect(lines[101].translateSpy).toHaveBeenCalledTimes(1); + highlighter.dispose(); + }); + + it("delays resume and cancels it when suspension returns", () => { + const lines = Array.from({ length: 80 }, (_, index) => + createLine(index === 20 ? "ERROR" : ""), + ); + const harness = createHarness({ lines, baseY: 60, viewportY: 20 }); + const highlighter = new KeywordHighlighter(harness.terminal); + highlighter.setRules([rule()], true); + highlighter.setSuspended(true); + highlighter.setSuspended(false); + vi.advanceTimersByTime(XTERM_PERFORMANCE_CONFIG.highlighting.resumeIdleDelayMs - 1); + expect(rafCallbacks).toHaveLength(0); + vi.advanceTimersByTime(1); + expect(rafCallbacks).toHaveLength(1); + + highlighter.setSuspended(true); + expect(rafCallbacks).toHaveLength(0); + for (const callback of rafCallbacks.values()) callback(performance.now()); + expect(lines[20].translateSpy).not.toHaveBeenCalled(); + highlighter.dispose(); + }); +}); diff --git a/src/lib/keywordHighlighter.ts b/src/lib/keywordHighlighter.ts index cdff37a1..7efffe2b 100644 --- a/src/lib/keywordHighlighter.ts +++ b/src/lib/keywordHighlighter.ts @@ -7,6 +7,7 @@ import type { Terminal as XTerm, } from "@xterm/xterm"; import type { ResolvedHighlightRule } from "./keywordHighlightPresets"; +import { logger } from "./logger"; import { getKeywordHighlightPerformanceConfig, XTERM_PERFORMANCE_CONFIG } from "./xtermPerformance"; interface CompiledRule { @@ -17,6 +18,13 @@ interface CompiledRule { interface CachedDecoration { decoration: IDecoration; marker: IMarker; + lineY: number; +} + +interface HighlightSpan { + cellStartCol: number; + cellWidth: number; + color: string; } interface LogicalLineSegment { @@ -37,34 +45,52 @@ interface RefreshBudget { hitTotalDecorationLimit: boolean; } +interface SpanScanResult { + spans: HighlightSpan[]; + complete: boolean; +} + +interface WrappedSpanScanResult { + spansByLine: Map; + lineYs: number[]; + complete: boolean; + cacheable: boolean; +} + +type RefreshReason = "write" | "scroll_idle" | "resume" | "continuation" | "resize"; + +interface RefreshStats { + cacheHits: number; + cacheMisses: number; + scannedLines: number; + decorationsCreated: number; + decorationsDisposed: number; +} + /** * Manages terminal decorations for keyword highlighting. * * Optimizations over a naive implementation: - * - Overscan buffer: keeps decorations alive for configured rows above/below the - * viewport, eliminating highlight loss when scrolling back to recently-visited rows. - * - Scanned-line memoization: scrollback content is immutable once written, so each line - * or fully-scrollback wrapped logical line is regex-matched exactly once. Subsequent - * passes just copy existing keys into requiredKeys without re-running regex/cell scans. + * - Overscan buffer: keeps a small decoration zone around the viewport. + * - Match LRU: immutable scrollback rows retain regex results independently from + * short-lived xterm decorations, including rows with no matches. * - Fast ASCII path: skips building the wide-char cell map for lines with only ASCII chars. * - Deduplicates scroll/render events: onRender viewport-Y check replaces the redundant onScroll. - * - Auto-invalidation: each decoration subscribes to its own onDispose so the cache and the - * per-line index stay consistent when xterm evicts lines from the scrollback buffer. + * - Auto-invalidation: each decoration subscribes to its own onDispose without + * invalidating the independent match cache. * - Alternate buffer guard: clears decorations immediately when TUI apps (vim, htop) take over. */ export class KeywordHighlighter implements IDisposable { private term: XTerm; private compiledRules: CompiledRule[] = []; private decorationCache = new Map(); - /** Maps absolute buffer line index → decoration keys on that line. */ - private lineToKeys = new Map(); - /** Lines that have been fully scanned and whose results are memoized in lineToKeys. */ - private scannedLines = new Set(); + /** Immutable absolute buffer line index → resolved highlight spans (including []). */ + private lineMatchCache = new Map(); private writeDebounceTimer: ReturnType | null = null; - private scrollThrottleTimer: ReturnType | null = null; + private scrollDebounceTimer: ReturnType | null = null; + private resumeRefreshTimer: ReturnType | null = null; private resumeRefreshFrame: number | null = null; private continuationRefreshFrame: number | null = null; - private scrollThrottlePending = false; private enabled = false; private suspended = false; private highlightAcrossWrappedLines = false; @@ -78,15 +104,15 @@ export class KeywordHighlighter implements IDisposable { private static readonly MAX_LOGICAL_LINE_SCAN_CHARS = 16 * 1024; - constructor(term: XTerm) { + constructor(term: XTerm, private readonly sessionId?: string) { this.term = term; this.disposables.push( this.term.onWriteParsed(() => this.triggerWriteRefresh()), this.term.onResize(() => { - this.clearAllDecorations(); + this.invalidateAll(); this.lastViewportY = -1; - this.triggerWriteRefresh(); + this.triggerWriteRefresh("resize"); }), this.term.onRender(() => { const currentViewportY = this.term.buffer.active?.viewportY ?? 0; @@ -137,9 +163,9 @@ export class KeywordHighlighter implements IDisposable { } } - this.clearAllDecorations(); + this.invalidateAll(); if (this.enabled && this.compiledRules.length > 0) { - this.triggerWriteRefresh(); + this.triggerWriteRefresh("write"); } } @@ -159,12 +185,12 @@ export class KeywordHighlighter implements IDisposable { } public releaseCaches(): void { - this.clearAllDecorations(); + this.invalidateAll(); this.lastViewportY = -1; } public dispose(): void { - this.clearAllDecorations(); + this.invalidateAll(); this.disposables.forEach((d) => { d.dispose(); }); @@ -176,9 +202,13 @@ export class KeywordHighlighter implements IDisposable { clearTimeout(this.writeDebounceTimer); this.writeDebounceTimer = null; } - if (this.scrollThrottleTimer) { - clearTimeout(this.scrollThrottleTimer); - this.scrollThrottleTimer = null; + if (this.scrollDebounceTimer) { + clearTimeout(this.scrollDebounceTimer); + this.scrollDebounceTimer = null; + } + if (this.resumeRefreshTimer) { + clearTimeout(this.resumeRefreshTimer); + this.resumeRefreshTimer = null; } if (this.resumeRefreshFrame !== null) { cancelAnimationFrame(this.resumeRefreshFrame); @@ -188,7 +218,6 @@ export class KeywordHighlighter implements IDisposable { cancelAnimationFrame(this.continuationRefreshFrame); this.continuationRefreshFrame = null; } - this.scrollThrottlePending = false; } /** @@ -223,41 +252,43 @@ export class KeywordHighlighter implements IDisposable { private canRefresh(): boolean { if (!this.enabled || this.suspended || this.compiledRules.length === 0) return false; if (this.term.buffer.active.type === "alternate") { - this.clearAllDecorations(); + this.invalidateAll(); return false; } return true; } /** Debounced refresh for write/resize events (batches rapid output). */ - private triggerWriteRefresh(): void { + private triggerWriteRefresh(reason: "write" | "resize" = "write"): void { if (!this.canRefresh()) return; + if ( + this.scrollDebounceTimer !== null || + this.resumeRefreshTimer !== null || + this.resumeRefreshFrame !== null + ) { + return; + } if (this.writeDebounceTimer) clearTimeout(this.writeDebounceTimer); this.writeDebounceTimer = setTimeout(() => { this.writeDebounceTimer = null; - this.refreshViewport(); + this.refreshViewport(reason); }, XTERM_PERFORMANCE_CONFIG.highlighting.debounceMs); } - /** - * Leading+trailing throttle for scroll events. Fires immediately on the - * first scroll, then at most once per throttle interval during continuous - * scrolling, with a trailing call after scrolling stops. - */ + /** Pure trailing debounce: scrolling always preempts pending highlight work. */ private triggerScrollRefresh(): void { if (!this.canRefresh()) return; - if (this.scrollThrottleTimer !== null) { - this.scrollThrottlePending = true; - return; + if (this.writeDebounceTimer !== null) { + clearTimeout(this.writeDebounceTimer); + this.writeDebounceTimer = null; } - this.refreshViewport(); - this.scrollThrottleTimer = setTimeout(() => { - this.scrollThrottleTimer = null; - if (this.scrollThrottlePending) { - this.scrollThrottlePending = false; - this.triggerScrollRefresh(); - } - }, XTERM_PERFORMANCE_CONFIG.highlighting.throttleMs); + this.cancelContinuationRefresh(); + this.cancelResumeRefresh(); + if (this.scrollDebounceTimer !== null) clearTimeout(this.scrollDebounceTimer); + this.scrollDebounceTimer = setTimeout(() => { + this.scrollDebounceTimer = null; + this.refreshViewport("scroll_idle"); + }, XTERM_PERFORMANCE_CONFIG.highlighting.scrollIdleDebounceMs); } /** @@ -266,46 +297,86 @@ export class KeywordHighlighter implements IDisposable { */ private triggerResumeRefresh(): void { if (!this.canRefresh()) return; - if (this.resumeRefreshFrame !== null) return; + if (this.resumeRefreshTimer !== null || this.resumeRefreshFrame !== null) return; - this.resumeRefreshFrame = requestAnimationFrame(() => { + this.resumeRefreshTimer = setTimeout(() => { + this.resumeRefreshTimer = null; + if (!this.canRefresh() || this.scrollDebounceTimer !== null) return; this.resumeRefreshFrame = requestAnimationFrame(() => { this.resumeRefreshFrame = null; - this.refreshViewport(); + this.refreshViewport("resume"); }); - }); + }, XTERM_PERFORMANCE_CONFIG.highlighting.resumeIdleDelayMs); } private triggerContinuationRefresh(): void { if (!this.canRefresh()) return; - if (this.continuationRefreshFrame !== null) return; + if (this.continuationRefreshFrame !== null || this.scrollDebounceTimer !== null) return; this.continuationRefreshFrame = requestAnimationFrame(() => { this.continuationRefreshFrame = null; - this.refreshViewport(); + this.refreshViewport("continuation"); }); } - /** - * Clear map before disposing so the per-decoration onDispose callbacks find - * an empty map and become no-ops, avoiding re-entrant mutation. - * Also resets the scanned-line memoization so all lines are re-scanned after - * a rule change, and tears down the trim-detection sentinel. - */ - private clearAllDecorations(): void { - this.clearAllTimers(); + private cancelContinuationRefresh(): void { + if (this.continuationRefreshFrame === null) return; + cancelAnimationFrame(this.continuationRefreshFrame); + this.continuationRefreshFrame = null; + } + + private cancelResumeRefresh(): void { + if (this.resumeRefreshTimer !== null) { + clearTimeout(this.resumeRefreshTimer); + this.resumeRefreshTimer = null; + } + if (this.resumeRefreshFrame !== null) { + cancelAnimationFrame(this.resumeRefreshFrame); + this.resumeRefreshFrame = null; + } + } + + /** Clear the map before disposal so onDispose callbacks are no-ops. */ + private clearDecorations(): void { const entries = [...this.decorationCache.values()]; this.decorationCache.clear(); - this.lineToKeys.clear(); - this.scannedLines.clear(); - this.disposeSentinel(); - this.bufferTrimmed = false; for (const { decoration, marker } of entries) { decoration.dispose(); marker.dispose(); } } + private clearMatchCache(): void { + this.lineMatchCache.clear(); + } + + private invalidateAll(): void { + this.clearAllTimers(); + this.clearDecorations(); + this.clearMatchCache(); + this.disposeSentinel(); + this.bufferTrimmed = false; + } + + private getCachedMatches(lineY: number): HighlightSpan[] | undefined { + const spans = this.lineMatchCache.get(lineY); + if (spans === undefined) return undefined; + this.lineMatchCache.delete(lineY); + this.lineMatchCache.set(lineY, spans); + return spans; + } + + private setCachedMatches(lineY: number, spans: HighlightSpan[]): void { + this.lineMatchCache.delete(lineY); + this.lineMatchCache.set(lineY, spans); + const maxLines = XTERM_PERFORMANCE_CONFIG.highlighting.maxCachedMatchLines; + while (this.lineMatchCache.size > maxLines) { + const oldest = this.lineMatchCache.keys().next().value; + if (oldest === undefined) break; + this.lineMatchCache.delete(oldest); + } + } + private buildStringToCellMap( line: IBufferLine, stringLength: number, @@ -437,13 +508,6 @@ export class KeywordHighlighter implements IDisposable { return false; } - private getLineYFromDecorationKey(key: string): number | null { - const separatorIndex = key.indexOf(":"); - if (separatorIndex <= 0) return null; - const lineY = Number(key.slice(0, separatorIndex)); - return Number.isFinite(lineY) ? lineY : null; - } - private ensureDecoration( lineY: number, cellStartCol: number, @@ -479,39 +543,27 @@ export class KeywordHighlighter implements IDisposable { element.style.pointerEvents = "none"; }); - // Auto-remove from cache and line index when xterm evicts the line + // Decoration lifetime never invalidates immutable regex match results. deco.onDispose(() => { - this.decorationCache.delete(key); - // Remove from line index so the line gets re-scanned if it reappears - const keys = this.lineToKeys.get(lineY); - if (keys) { - const filtered = keys.filter((k) => k !== key); - if (filtered.length === 0) { - this.lineToKeys.delete(lineY); - this.scannedLines.delete(lineY); - } else { - this.lineToKeys.set(lineY, filtered); - } + if (this.decorationCache.get(key)?.decoration === deco) { + this.decorationCache.delete(key); } }); - this.decorationCache.set(key, { decoration: deco, marker }); + this.decorationCache.set(key, { decoration: deco, marker, lineY }); budget.createdDecorations++; return key; } private scanPhysicalLine( line: IBufferLine, - lineY: number, - cursorAbsoluteY: number, - requiredKeys: Set, scratchCell: IBufferCell, config: KeywordHighlightPerformanceConfig, budget: RefreshBudget, - ): string[] { + ): SpanScanResult { const maxCols = Math.min(line.length, this.term.cols); const lineText = line.translateToString(true, 0, maxCols); - if (!lineText) return []; + if (!lineText) return { spans: [], complete: true }; // Only build the wide-char map if actually needed (non-ASCII present) const hasMultibyte = /[^\u0000-\u00FF]/.test(lineText); @@ -522,14 +574,16 @@ export class KeywordHighlighter implements IDisposable { // Track occupied characters in the string to prevent multi-rule overlapping const occupied = new Uint8Array(lineText.length); - const lineKeys: string[] = []; + const spans: HighlightSpan[] = []; for (const { regex, color } of this.compiledRules) { - if (lineKeys.length >= config.maxMatchesPerLine || this.isBudgetExhausted(budget)) break; + if (spans.length >= config.maxMatchesPerLine) break; + if (this.isBudgetExhausted(budget)) return { spans, complete: false }; regex.lastIndex = 0; while (true) { - if (lineKeys.length >= config.maxMatchesPerLine || this.isBudgetExhausted(budget)) break; + if (spans.length >= config.maxMatchesPerLine) break; + if (this.isBudgetExhausted(budget)) return { spans, complete: false }; const match = regex.exec(lineText); if (match === null) break; @@ -557,49 +611,39 @@ export class KeywordHighlighter implements IDisposable { const cellStartCol = cellMap ? (cellMap[strStart] ?? strStart) : strStart; const cellEndCol = cellMap ? (cellMap[strEnd] ?? strEnd) : strEnd; - const key = this.ensureDecoration( - lineY, + const cellWidth = cellEndCol - cellStartCol; + if (cellWidth <= 0) continue; + spans.push({ cellStartCol, - cellEndCol - cellStartCol, + cellWidth, color, - cursorAbsoluteY, - config, - budget, - ); - if (!key) { - if (budget.hitLimit) break; - continue; - } + }); - // Mark as occupied only after the highlight has been accepted. + // Match priority is independent from whether a decoration can be created this frame. for (let k = strStart; k < strEnd; k++) { occupied[k] = 1; } - - requiredKeys.add(key); - lineKeys.push(key); } } - return lineKeys; + return { spans, complete: true }; } private scanWrappedLogicalLine( buffer: XTerm["buffer"]["active"], startY: number, endY: number, - scanStart: number, - scanEnd: number, - cursorAbsoluteY: number, - requiredKeys: Set, scratchCell: IBufferCell, config: KeywordHighlightPerformanceConfig, budget: RefreshBudget, - ): Map { + ): WrappedSpanScanResult { const segments: LogicalLineSegment[] = []; let logicalLength = 0; for (let currentY = startY; currentY <= endY; currentY++) { + if (this.isBudgetExhausted(budget)) { + return { spansByLine: new Map(), lineYs: [], complete: false, cacheable: false }; + } const line = buffer.getLine(currentY); if (!line) continue; @@ -621,23 +665,31 @@ export class KeywordHighlighter implements IDisposable { }); } - if (logicalLength === 0) return new Map(); + const lineYs = segments.map((segment) => segment.lineY); + const emptyByLine = new Map(lineYs.map((lineY) => [lineY, [] as HighlightSpan[]])); + if (logicalLength === 0) { + return { spansByLine: emptyByLine, lineYs, complete: true, cacheable: true }; + } const logicalText = segments.map((segment) => segment.text).join(""); if (logicalText.length > KeywordHighlighter.MAX_LOGICAL_LINE_SCAN_CHARS) { - return new Map(); + return { spansByLine: emptyByLine, lineYs, complete: true, cacheable: false }; } const occupied = new Uint8Array(logicalText.length); - const lineKeysByLine = new Map(); + const spansByLine = emptyByLine; const acceptedMatchesByLine = new Map(); for (const { regex, color } of this.compiledRules) { - if (this.isBudgetExhausted(budget)) break; + if (this.isBudgetExhausted(budget)) { + return { spansByLine, lineYs, complete: false, cacheable: false }; + } regex.lastIndex = 0; while (true) { - if (this.isBudgetExhausted(budget)) break; + if (this.isBudgetExhausted(budget)) { + return { spansByLine, lineYs, complete: false, cacheable: false }; + } const match = regex.exec(logicalText); if (match === null) break; @@ -660,11 +712,7 @@ export class KeywordHighlighter implements IDisposable { if (this.hasWrappedAnsiForegroundInRange(segments, strStart, strEnd, scratchCell)) continue; const matchedSegments = segments.filter( - (segment) => - segment.lineY >= scanStart && - segment.lineY <= scanEnd && - segment.endIndex > strStart && - segment.startIndex < strEnd, + (segment) => segment.endIndex > strStart && segment.startIndex < strEnd, ); if (matchedSegments.length === 0) continue; @@ -678,7 +726,7 @@ export class KeywordHighlighter implements IDisposable { } if (lineLimitReached) continue; - const createdKeys: Array<{ lineY: number; key: string }> = []; + const acceptedLineYs: number[] = []; for (const segment of matchedSegments) { const localStart = Math.max(strStart, segment.startIndex) - segment.startIndex; const localEnd = Math.min(strEnd, segment.endIndex) - segment.startIndex; @@ -688,69 +736,75 @@ export class KeywordHighlighter implements IDisposable { ? (segment.cellMap[localStart] ?? localStart) : localStart; const cellEndCol = segment.cellMap ? (segment.cellMap[localEnd] ?? localEnd) : localEnd; - const key = this.ensureDecoration( - segment.lineY, + const cellWidth = cellEndCol - cellStartCol; + if (cellWidth <= 0) continue; + spansByLine.get(segment.lineY)?.push({ cellStartCol, - cellEndCol - cellStartCol, + cellWidth, color, - cursorAbsoluteY, - config, - budget, - ); - if (!key) { - if (budget.hitLimit) break; - continue; - } - - requiredKeys.add(key); - createdKeys.push({ lineY: segment.lineY, key }); - - const lineKeys = lineKeysByLine.get(segment.lineY); - if (lineKeys) { - lineKeys.push(key); - } else { - lineKeysByLine.set(segment.lineY, [key]); - } + }); + acceptedLineYs.push(segment.lineY); } - if (createdKeys.length === 0) { - if (budget.hitLimit) break; - continue; - } + if (acceptedLineYs.length === 0) continue; for (let k = strStart; k < strEnd; k++) { occupied[k] = 1; } - for (const { lineY } of createdKeys) { + for (const lineY of acceptedLineYs) { acceptedMatchesByLine.set(lineY, (acceptedMatchesByLine.get(lineY) ?? 0) + 1); } } } - return lineKeysByLine; + return { spansByLine, lineYs, complete: true, cacheable: true }; } - private refreshViewport(): void { + private materializeSpans( + lineY: number, + spans: HighlightSpan[], + cursorAbsoluteY: number, + requiredKeys: Set, + config: KeywordHighlightPerformanceConfig, + budget: RefreshBudget, + ): void { + for (const span of spans) { + const key = this.ensureDecoration( + lineY, + span.cellStartCol, + span.cellWidth, + span.color, + cursorAbsoluteY, + config, + budget, + ); + if (key) requiredKeys.add(key); + if (budget.hitLimit) return; + } + } + + private refreshViewport(reason: RefreshReason): void { if (!this.enabled || this.suspended || this.compiledRules.length === 0) return; if (!this.term?.buffer?.active) return; if (this.term.buffer.active.type === "alternate") { - this.clearAllDecorations(); + this.invalidateAll(); return; } + const refreshStartedAt = performance.now(); + const stats: RefreshStats = { + cacheHits: 0, + cacheMisses: 0, + scannedLines: 0, + decorationsCreated: 0, + decorationsDisposed: 0, + }; + // When xterm trims the scrollback, all buffer indices shift and our caches // become stale. Detect this via the sentinel marker and wipe everything. if (this.bufferTrimmed) { - const entries = [...this.decorationCache.values()]; - this.decorationCache.clear(); - this.lineToKeys.clear(); - this.scannedLines.clear(); - this.bufferTrimmed = false; - for (const { decoration, marker } of entries) { - decoration.dispose(); - marker.dispose(); - } + this.invalidateAll(); } if (!this.sentinelMarker) { @@ -790,75 +844,38 @@ export class KeywordHighlighter implements IDisposable { processedLines.add(lineY); if (!this.highlightAcrossWrappedLines) { - if (lineY < screenStartY && this.scannedLines.has(lineY)) { - const cached = this.lineToKeys.get(lineY); - if (cached) { - let stale = false; - for (const k of cached) { - if (this.decorationCache.has(k)) { - requiredKeys.add(k); - } else { - stale = true; - } - } - if (!stale) continue; - this.scannedLines.delete(lineY); - this.lineToKeys.delete(lineY); + let spans: HighlightSpan[]; + if (lineY < screenStartY) { + const cached = this.getCachedMatches(lineY); + if (cached !== undefined) { + stats.cacheHits++; + spans = cached; } else { - continue; + stats.cacheMisses++; + stats.scannedLines++; + const result = this.scanPhysicalLine(line, scratchCell, config, budget); + spans = result.spans; + if (result.complete) this.setCachedMatches(lineY, spans); } + } else { + stats.scannedLines++; + spans = this.scanPhysicalLine(line, scratchCell, config, budget).spans; } - - const lineKeys = this.scanPhysicalLine( - line, + this.materializeSpans( lineY, + spans, cursorAbsoluteY, requiredKeys, - scratchCell, config, budget, ); - - // Only memoize scrollback lines — screen lines remain mutable - if (lineY < screenStartY) { - this.scannedLines.add(lineY); - if (lineKeys.length > 0) { - this.lineToKeys.set(lineY, lineKeys); - } else { - this.lineToKeys.delete(lineY); - } - } continue; } const { startY, endY } = this.getLogicalLineBounds(buffer, lineY, totalLines); const canMemoize = endY < screenStartY; - - if (canMemoize && this.scannedLines.has(lineY)) { - const cached = this.lineToKeys.get(lineY); - if (cached) { - let stale = false; - for (const k of cached) { - if (this.decorationCache.has(k)) { - requiredKeys.add(k); - } else { - stale = true; - } - } - if (!stale) continue; - this.scannedLines.delete(lineY); - this.lineToKeys.delete(lineY); - } else { - continue; - } - } - - // Wrapped-line mode can span multiple physical lines, so a logical line that - // touches the live screen cannot be memoized by individual scrollback rows. - if (!canMemoize && processedLogicalStarts.has(startY)) continue; - if (!canMemoize) { - processedLogicalStarts.add(startY); - } + if (processedLogicalStarts.has(startY)) continue; + processedLogicalStarts.add(startY); for ( let processedY = Math.max(startY, scanStart); processedY <= Math.min(endY, scanEnd); @@ -867,40 +884,69 @@ export class KeywordHighlighter implements IDisposable { processedLines.add(processedY); } - const lineKeysByLine = this.scanWrappedLogicalLine( - buffer, - startY, - endY, - scanStart, - scanEnd, - cursorAbsoluteY, - requiredKeys, - scratchCell, - config, - budget, - ); + const logicalLineYs = Array.from({ length: endY - startY + 1 }, (_, index) => startY + index); + const allRowsCached = + canMemoize && logicalLineYs.every((cachedLineY) => this.lineMatchCache.has(cachedLineY)); - if (canMemoize) { - for (let memoY = Math.max(startY, scanStart); memoY <= Math.min(endY, scanEnd); memoY++) { - this.scannedLines.add(memoY); - const lineKeys = lineKeysByLine.get(memoY); - if (lineKeys && lineKeys.length > 0) { - this.lineToKeys.set(memoY, lineKeys); - } else { - this.lineToKeys.delete(memoY); + let spansByLine: Map; + if (allRowsCached) { + spansByLine = new Map(); + for (const cachedLineY of logicalLineYs) { + const cached = this.getCachedMatches(cachedLineY); + if (cached !== undefined) { + stats.cacheHits++; + spansByLine.set(cachedLineY, cached); } } + } else { + if (canMemoize) stats.cacheMisses++; + stats.scannedLines += logicalLineYs.length; + const result = this.scanWrappedLogicalLine( + buffer, + startY, + endY, + scratchCell, + config, + budget, + ); + spansByLine = result.spansByLine; + if ( + canMemoize && + result.complete && + result.cacheable && + result.lineYs.length <= config.maxCachedMatchLines + ) { + for (const cachedLineY of result.lineYs) this.lineMatchCache.delete(cachedLineY); + for (const cachedLineY of result.lineYs) { + this.setCachedMatches(cachedLineY, result.spansByLine.get(cachedLineY) ?? []); + } + } + } + + for ( + let visibleLineY = Math.max(startY, scanStart); + visibleLineY <= Math.min(endY, scanEnd); + visibleLineY++ + ) { + this.materializeSpans( + visibleLineY, + spansByLine.get(visibleLineY) ?? [], + cursorAbsoluteY, + requiredKeys, + config, + budget, + ); + if (budget.hitLimit) break; } } // Evict decorations that have drifted outside the overscan zone. If the refresh // hit a time/count budget, keep unprocessed in-zone lines to avoid flicker and churn. const staleKeys: string[] = []; - for (const key of this.decorationCache.keys()) { + for (const [key, entry] of this.decorationCache) { if (requiredKeys.has(key)) continue; - const lineY = this.getLineYFromDecorationKey(key); - const isOutsideScanZone = lineY === null || lineY < scanStart || lineY > scanEnd; - if (isOutsideScanZone || !budget.hitLimit || processedLines.has(lineY)) { + const isOutsideScanZone = entry.lineY < scanStart || entry.lineY > scanEnd; + if (isOutsideScanZone || !budget.hitLimit || processedLines.has(entry.lineY)) { staleKeys.push(key); } } @@ -910,16 +956,29 @@ export class KeywordHighlighter implements IDisposable { this.decorationCache.delete(key); // remove before dispose to silence onDispose no-op entry.decoration.dispose(); entry.marker.dispose(); + stats.decorationsDisposed++; } } - // Also evict the line-index entries for lines now outside the zone so they - // are re-scanned if the user scrolls back to them later. - for (const lineY of this.scannedLines) { - if (lineY < scanStart || lineY > scanEnd) { - this.scannedLines.delete(lineY); - this.lineToKeys.delete(lineY); - } + stats.decorationsCreated = budget.createdDecorations; + + if (import.meta.env.DEV) { + logger.debug({ + domain: "terminal.input", + event: "terminal.keyword.refresh", + message: "Refreshed terminal keyword highlights", + ids: this.sessionId ? { session_id: this.sessionId } : undefined, + data: { + reason, + duration_ms: performance.now() - refreshStartedAt, + scanned_lines: stats.scannedLines, + cache_hits: stats.cacheHits, + cache_misses: stats.cacheMisses, + decorations_created: stats.decorationsCreated, + decorations_disposed: stats.decorationsDisposed, + match_cache_size: this.lineMatchCache.size, + }, + }); } if (budget.hitLimit && !budget.hitTotalDecorationLimit) { diff --git a/src/lib/xtermPerformance.ts b/src/lib/xtermPerformance.ts index ba6a3e89..4b00ad29 100644 --- a/src/lib/xtermPerformance.ts +++ b/src/lib/xtermPerformance.ts @@ -4,20 +4,24 @@ export const XTERM_PERFORMANCE_CONFIG = { highlighting: { /** Debounce delay in ms before re-scanning after new output is written. */ debounceMs: 80, - /** Throttle interval in ms for scroll-triggered viewport refreshes. */ - throttleMs: 80, + /** Idle delay before refreshing highlights after viewport scrolling stops. */ + scrollIdleDebounceMs: 120, + /** Idle delay before rebuilding highlights after a suspended terminal resumes. */ + resumeIdleDelayMs: 150, /** Lines above and below the viewport to keep decorated on most platforms. */ - overscanLines: 50, + overscanLines: 15, /** macOS WebView benefits from a slightly smaller highlighted active zone. */ - macosOverscanLines: 40, + macosOverscanLines: 10, /** Hard cap for total keyword highlight decorations held by one terminal. */ maxDecorations: 1_000, /** Hard cap for new keyword highlight decorations created by one refresh. */ - maxDecorationsPerRefresh: 500, + maxDecorationsPerRefresh: 100, /** Hard cap for accepted keyword highlight matches on one physical line. */ maxMatchesPerLine: 20, /** Main-thread time budget for one viewport refresh. */ - maxRefreshTimeMs: 12, + maxRefreshTimeMs: 3, + /** Maximum immutable scrollback rows retained in the regex match LRU. */ + maxCachedMatchLines: 3_000, }, output: { /** Backlog threshold where terminal side work should start yielding to rendering/input. */ From 3cfd6c4c386f1180ffdfab0a6c1b15b4ca7e7e6f Mon Sep 17 00:00:00 2001 From: Kang Date: Mon, 31 Aug 2026 15:27:25 +0800 Subject: [PATCH 18/32] chore(dependencies): update Tauri-related packages and various Rust dependencies - Updated Tauri packages in package.json and pnpm-lock.yaml to their latest versions: - @tauri-apps/api to ^2.11.1 - @tauri-apps/plugin-dialog to ^2.7.2 - @tauri-apps/plugin-opener to ^2.5.4 - @tauri-apps/cli to ^2.11.4 - Updated several Rust dependencies in Cargo.lock, including aes (0.9.3), aes-gcm (0.11.1), and others to improve security and performance. --- package.json | 8 +- pnpm-lock.yaml | 138 +-- src-tauri/Cargo.lock | 2697 +++++++++++++++++++----------------------- 3 files changed, 1275 insertions(+), 1568 deletions(-) diff --git a/package.json b/package.json index 298da2bd..9ed1797e 100644 --- a/package.json +++ b/package.json @@ -67,9 +67,9 @@ "@lezer/highlight": "^1.2.3", "@mdxeditor/editor": "^4.2.0", "@tanstack/react-virtual": "^3.14.6", - "@tauri-apps/api": "^2", - "@tauri-apps/plugin-dialog": "^2.6.0", - "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/api": "^2.11.1", + "@tauri-apps/plugin-dialog": "^2.7.2", + "@tauri-apps/plugin-opener": "^2.5.4", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "~2.10.1", "@types/papaparse": "^5.5.2", @@ -107,7 +107,7 @@ "devDependencies": { "@biomejs/biome": "^2.4.2", "@tailwindcss/vite": "^4.1.18", - "@tauri-apps/cli": "^2", + "@tauri-apps/cli": "^2.11.4", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^25.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a509c88..da4b7bcd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,14 +93,14 @@ importers: specifier: ^3.14.6 version: 3.14.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tauri-apps/api': - specifier: ^2 - version: 2.10.1 + specifier: ^2.11.1 + version: 2.11.1 '@tauri-apps/plugin-dialog': - specifier: ^2.6.0 - version: 2.6.0 + specifier: ^2.7.2 + version: 2.7.2 '@tauri-apps/plugin-opener': - specifier: ^2 - version: 2.5.3 + specifier: ^2.5.4 + version: 2.5.4 '@tauri-apps/plugin-process': specifier: ^2.3.1 version: 2.3.1 @@ -208,8 +208,8 @@ importers: specifier: ^4.1.18 version: 4.1.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)) '@tauri-apps/cli': - specifier: ^2 - version: 2.10.0 + specifier: ^2.11.4 + version: 2.11.4 '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -4222,90 +4222,90 @@ packages: '@tanstack/virtual-core@3.17.4': resolution: {integrity: sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw==} - '@tauri-apps/api@2.10.1': - resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} + '@tauri-apps/api@2.11.1': + resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} - '@tauri-apps/cli-darwin-arm64@2.10.0': - resolution: {integrity: sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==} + '@tauri-apps/cli-darwin-arm64@2.11.4': + resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@tauri-apps/cli-darwin-x64@2.10.0': - resolution: {integrity: sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==} + '@tauri-apps/cli-darwin-x64@2.11.4': + resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@tauri-apps/cli-linux-arm-gnueabihf@2.10.0': - resolution: {integrity: sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==} + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': + resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@tauri-apps/cli-linux-arm64-gnu@2.10.0': - resolution: {integrity: sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==} + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': + resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-arm64-musl@2.10.0': - resolution: {integrity: sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==} + '@tauri-apps/cli-linux-arm64-musl@2.11.4': + resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@tauri-apps/cli-linux-riscv64-gnu@2.10.0': - resolution: {integrity: sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==} + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': + resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-x64-gnu@2.10.0': - resolution: {integrity: sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==} + '@tauri-apps/cli-linux-x64-gnu@2.11.4': + resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-x64-musl@2.10.0': - resolution: {integrity: sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==} + '@tauri-apps/cli-linux-x64-musl@2.11.4': + resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@tauri-apps/cli-win32-arm64-msvc@2.10.0': - resolution: {integrity: sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==} + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': + resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@tauri-apps/cli-win32-ia32-msvc@2.10.0': - resolution: {integrity: sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==} + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': + resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] - '@tauri-apps/cli-win32-x64-msvc@2.10.0': - resolution: {integrity: sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==} + '@tauri-apps/cli-win32-x64-msvc@2.11.4': + resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@tauri-apps/cli@2.10.0': - resolution: {integrity: sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q==} + '@tauri-apps/cli@2.11.4': + resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==} engines: {node: '>= 10'} hasBin: true - '@tauri-apps/plugin-dialog@2.6.0': - resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==} + '@tauri-apps/plugin-dialog@2.7.2': + resolution: {integrity: sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==} - '@tauri-apps/plugin-opener@2.5.3': - resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==} + '@tauri-apps/plugin-opener@2.5.4': + resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==} '@tauri-apps/plugin-process@2.3.1': resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==} @@ -11490,7 +11490,7 @@ snapshots: '@types/react-router-config': 5.0.11 combine-promises: 1.2.0 fs-extra: 11.3.3 - js-yaml: 4.1.1 + js-yaml: 4.3.0 lodash: 4.18.1 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -14480,70 +14480,70 @@ snapshots: '@tanstack/virtual-core@3.17.4': {} - '@tauri-apps/api@2.10.1': {} + '@tauri-apps/api@2.11.1': {} - '@tauri-apps/cli-darwin-arm64@2.10.0': + '@tauri-apps/cli-darwin-arm64@2.11.4': optional: true - '@tauri-apps/cli-darwin-x64@2.10.0': + '@tauri-apps/cli-darwin-x64@2.11.4': optional: true - '@tauri-apps/cli-linux-arm-gnueabihf@2.10.0': + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': optional: true - '@tauri-apps/cli-linux-arm64-gnu@2.10.0': + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': optional: true - '@tauri-apps/cli-linux-arm64-musl@2.10.0': + '@tauri-apps/cli-linux-arm64-musl@2.11.4': optional: true - '@tauri-apps/cli-linux-riscv64-gnu@2.10.0': + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': optional: true - '@tauri-apps/cli-linux-x64-gnu@2.10.0': + '@tauri-apps/cli-linux-x64-gnu@2.11.4': optional: true - '@tauri-apps/cli-linux-x64-musl@2.10.0': + '@tauri-apps/cli-linux-x64-musl@2.11.4': optional: true - '@tauri-apps/cli-win32-arm64-msvc@2.10.0': + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': optional: true - '@tauri-apps/cli-win32-ia32-msvc@2.10.0': + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': optional: true - '@tauri-apps/cli-win32-x64-msvc@2.10.0': + '@tauri-apps/cli-win32-x64-msvc@2.11.4': optional: true - '@tauri-apps/cli@2.10.0': + '@tauri-apps/cli@2.11.4': optionalDependencies: - '@tauri-apps/cli-darwin-arm64': 2.10.0 - '@tauri-apps/cli-darwin-x64': 2.10.0 - '@tauri-apps/cli-linux-arm-gnueabihf': 2.10.0 - '@tauri-apps/cli-linux-arm64-gnu': 2.10.0 - '@tauri-apps/cli-linux-arm64-musl': 2.10.0 - '@tauri-apps/cli-linux-riscv64-gnu': 2.10.0 - '@tauri-apps/cli-linux-x64-gnu': 2.10.0 - '@tauri-apps/cli-linux-x64-musl': 2.10.0 - '@tauri-apps/cli-win32-arm64-msvc': 2.10.0 - '@tauri-apps/cli-win32-ia32-msvc': 2.10.0 - '@tauri-apps/cli-win32-x64-msvc': 2.10.0 + '@tauri-apps/cli-darwin-arm64': 2.11.4 + '@tauri-apps/cli-darwin-x64': 2.11.4 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.4 + '@tauri-apps/cli-linux-arm64-gnu': 2.11.4 + '@tauri-apps/cli-linux-arm64-musl': 2.11.4 + '@tauri-apps/cli-linux-riscv64-gnu': 2.11.4 + '@tauri-apps/cli-linux-x64-gnu': 2.11.4 + '@tauri-apps/cli-linux-x64-musl': 2.11.4 + '@tauri-apps/cli-win32-arm64-msvc': 2.11.4 + '@tauri-apps/cli-win32-ia32-msvc': 2.11.4 + '@tauri-apps/cli-win32-x64-msvc': 2.11.4 - '@tauri-apps/plugin-dialog@2.6.0': + '@tauri-apps/plugin-dialog@2.7.2': dependencies: - '@tauri-apps/api': 2.10.1 + '@tauri-apps/api': 2.11.1 - '@tauri-apps/plugin-opener@2.5.3': + '@tauri-apps/plugin-opener@2.5.4': dependencies: - '@tauri-apps/api': 2.10.1 + '@tauri-apps/api': 2.11.1 '@tauri-apps/plugin-process@2.3.1': dependencies: - '@tauri-apps/api': 2.10.1 + '@tauri-apps/api': 2.11.1 '@tauri-apps/plugin-updater@2.10.1': dependencies: - '@tauri-apps/api': 2.10.1 + '@tauri-apps/api': 2.11.1 '@testing-library/dom@10.4.1': dependencies: diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b4201de5..2be0dcb1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -52,13 +52,13 @@ dependencies = [ [[package]] name = "aes" -version = "0.9.1" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ "cipher 0.5.2", "cpubits", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "zeroize", ] @@ -78,16 +78,16 @@ dependencies = [ [[package]] name = "aes-gcm" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" dependencies = [ "aead 0.6.1", - "aes 0.9.1", + "aes 0.9.3", "cipher 0.5.2", "ctr 0.10.1", + "ctutils", "ghash 0.6.0", - "subtle", "zeroize", ] @@ -97,15 +97,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41ac571010bd60765c56085a4f1d412012a9be2663b1a2f2b19b49318653fd0d" dependencies = [ - "aes 0.9.1", + "aes 0.9.3", "const-oid 0.10.2", ] [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -118,9 +118,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -133,18 +133,18 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arbitrary" @@ -178,13 +178,13 @@ dependencies = [ [[package]] name = "argon2" -version = "0.6.0-rc.8" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7af50940b73bf4e16c15c448a2b121c63f2d68e3e54b6a8731673cb4aa0cdff5" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" dependencies = [ "base64ct", "blake2", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "password-hash", ] @@ -200,7 +200,7 @@ dependencies = [ "nom 7.1.3", "num-traits", "rusticata-macros", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -211,7 +211,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", "synstructure", ] @@ -223,7 +223,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -252,9 +252,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" dependencies = [ "compression-codecs", "compression-core", @@ -283,9 +283,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.13.3" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", @@ -362,14 +362,14 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "async-signal" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ "async-io", "async-lock", @@ -391,13 +391,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.4", ] [[package]] @@ -410,6 +410,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "asyncband" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a214ba60d6231afd0e805e3c27c45a1626d9debaa5a5061c45a1ea1b2f1ed0" +dependencies = [ + "hashbrown 0.17.1", + "slab", +] + [[package]] name = "atk" version = "0.18.2" @@ -450,15 +460,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.1" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", "zeroize", @@ -466,9 +476,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.42.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", @@ -518,6 +528,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -535,6 +551,21 @@ dependencies = [ "sha2 0.11.0", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bit_field" version = "0.10.3" @@ -549,9 +580,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -570,9 +601,9 @@ dependencies = [ [[package]] name = "blake2" -version = "0.11.0-rc.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "061f1a09225e328e1ffbb378d2d49923c0ca5fee19fb5ac1cc9c1e9d52b93690" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" dependencies = [ "digest 0.11.3", ] @@ -588,9 +619,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", "zeroize", @@ -625,9 +656,9 @@ dependencies = [ [[package]] name = "blocking" -version = "1.6.2" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ "async-channel", "async-task", @@ -648,9 +679,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -659,25 +690,34 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", ] [[package]] -name = "bumpalo" -version = "3.19.1" +name = "bs58" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -693,9 +733,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -715,7 +755,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cairo-sys-rs", "glib", "libc", @@ -736,9 +776,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.2" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" dependencies = [ "serde_core", ] @@ -763,7 +803,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -796,9 +836,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.55" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "jobserver", @@ -841,28 +881,28 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cipher 0.5.2", - "cpufeatures 0.3.0", - "rand_core 0.10.0", + "cpufeatures 0.3.1", + "rand_core 0.10.1", "zeroize", ] [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -889,7 +929,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "crypto-common 0.2.2", "inout 0.2.2", "zeroize", @@ -915,9 +955,9 @@ dependencies = [ [[package]] name = "cmov" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] name = "color_quant" @@ -927,9 +967,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -999,12 +1039,6 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - [[package]] name = "convert_case" version = "0.10.0" @@ -1016,9 +1050,9 @@ dependencies = [ [[package]] name = "cookie" -version = "0.18.1" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" dependencies = [ "time", "version_check", @@ -1065,11 +1099,11 @@ dependencies = [ [[package]] name = "core-graphics" -version = "0.24.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-graphics-types 0.2.0", "foreign-types 0.5.0", @@ -1093,7 +1127,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "libc", ] @@ -1112,9 +1146,9 @@ dependencies = [ [[package]] name = "cpubits" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef0c543070d296ea414df2dd7625d1b24866ce206709d8a4a424f28377f5861" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" [[package]] name = "cpufeatures" @@ -1127,9 +1161,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -1155,9 +1189,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] @@ -1170,18 +1204,18 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -1209,10 +1243,10 @@ checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" dependencies = [ "cpubits", "ctutils", - "getrandom 0.4.1", + "getrandom 0.4.3", "hybrid-array", "num-traits", - "rand_core 0.10.0", + "rand_core 0.10.1", "serdect", "subtle", "zeroize", @@ -1235,9 +1269,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "getrandom 0.4.1", + "getrandom 0.4.3", "hybrid-array", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] @@ -1252,13 +1286,12 @@ dependencies = [ [[package]] name = "crypto-primes" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21f41f23de7d24cdbda7f0c4d9c0351f99a4ceb258ef30e5c1927af8987ffe5a" +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" dependencies = [ "crypto-bigint 0.7.5", - "libm", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] @@ -1282,7 +1315,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff765b99fc49f3116c9a908484486a2b92fd73c48da45c3a69716471c6cc56c6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cryptoki-sys", "libloading 0.8.9", "log", @@ -1300,19 +1333,15 @@ dependencies = [ [[package]] name = "cssparser" -version = "0.29.6" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "matches", - "phf 0.10.1", - "proc-macro2", - "quote", + "phf", "smallvec", - "syn 1.0.109", ] [[package]] @@ -1322,19 +1351,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "ctor" -version = "0.2.9" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" dependencies = [ - "quote", - "syn 2.0.114", + "ctor-proc-macro", + "dtor", ] +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + [[package]] name = "ctr" version = "0.9.2" @@ -1385,7 +1420,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c906a87e53a36ff795d72e06e8162a83c5436e3ea89e942a9cb9fc083f0a384f" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "curve25519-dalek-derive", "digest 0.11.3", "fiat-crypto 0.3.0", @@ -1402,14 +1437,14 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "darling" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ "darling_core", "darling_macro", @@ -1417,27 +1452,26 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "darling_macro" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -1456,9 +1490,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "dbus" @@ -1491,9 +1525,9 @@ dependencies = [ [[package]] name = "deflate64" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" [[package]] name = "defmt" @@ -1514,7 +1548,7 @@ dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -1523,7 +1557,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -1534,7 +1568,7 @@ checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -1546,15 +1580,14 @@ dependencies = [ "const-oid 0.9.6", "der_derive", "flagset", - "pem-rfc7468 0.7.0", "zeroize", ] [[package]] name = "der" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ "const-oid 0.10.2", "pem-rfc7468 1.0.0", @@ -1582,16 +1615,15 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "deranged" -version = "0.5.6" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -1603,20 +1635,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", -] - -[[package]] -name = "derive_more" -version = "0.99.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "convert_case 0.4.0", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -1634,11 +1653,11 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case 0.10.0", + "convert_case", "proc-macro2", "quote", "rustc_version", - "syn 2.0.114", + "syn 2.0.119", "unicode-xid", ] @@ -1669,10 +1688,11 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", + "zeroize", ] [[package]] @@ -1696,19 +1716,13 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "dispatch" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" - [[package]] name = "dispatch2" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2", "libc", "objc2", @@ -1716,22 +1730,22 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.4", ] [[package]] name = "dlib" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "libloading 0.7.4", + "libloading 0.8.9", ] [[package]] @@ -1754,7 +1768,7 @@ checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -1766,6 +1780,21 @@ dependencies = [ "const-random", ] +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash 0.2.0", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -1790,7 +1819,7 @@ dependencies = [ "crypto-bigint 0.7.5", "crypto-common 0.2.2", "crypto-primes", - "der 0.8.0", + "der 0.8.1", "digest 0.11.3", "rfc6979 0.6.0", "sha2 0.11.0", @@ -1813,6 +1842,21 @@ dependencies = [ "dtoa", ] +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + [[package]] name = "dunce" version = "1.0.5" @@ -1857,7 +1901,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" dependencies = [ - "der 0.8.0", + "der 0.8.1", "digest 0.11.3", "elliptic-curve 0.14.1", "rfc6979 0.6.0", @@ -1905,7 +1949,7 @@ checksum = "1685663e23882cd8517dcbcb1c23a6ebff4433c22dfb681d760219b62cd1b849" dependencies = [ "curve25519-dalek 5.0.0-rc.1", "ed25519 3.0.0", - "rand_core 0.10.0", + "rand_core 0.10.1", "serde", "sha2 0.11.0", "signature 3.0.0", @@ -1915,9 +1959,9 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "elliptic-curve" @@ -1954,7 +1998,7 @@ dependencies = [ "hybrid-array", "pem-rfc7468 1.0.0", "pkcs8 0.11.0", - "rand_core 0.10.0", + "rand_core 0.10.1", "sec1 0.8.1", "subtle", "zeroize", @@ -1962,14 +2006,14 @@ dependencies = [ [[package]] name = "embed-resource" -version = "3.0.6" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" dependencies = [ "cc", "memchr", "rustc_version", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "vswhom", "winreg 0.55.0", ] @@ -2004,7 +2048,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -2025,7 +2069,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -2036,9 +2080,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "erased-serde" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" dependencies = [ "serde", "serde_core", @@ -2078,17 +2122,16 @@ dependencies = [ [[package]] name = "error-code" -version = "3.3.2" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -2116,29 +2159,15 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fax" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] - -[[package]] -name = "fax_derive" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" [[package]] name = "fdeflate" @@ -2165,7 +2194,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" dependencies = [ - "rand_core 0.10.0", + "rand_core 0.10.1", "subtle", ] @@ -2204,20 +2233,19 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", - "libredox", ] [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "fixedbitset" @@ -2233,13 +2261,13 @@ checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", "libz-sys", - "miniz_oxide", + "miniz_oxide 0.9.1", "zlib-rs", ] @@ -2261,13 +2289,19 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "font-kit" version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c7e611d49285d4c4b2e1727b72cf05353558885cc5252f93707b845dfcaf3d3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "byteorder", "core-foundation 0.9.4", "core-graphics 0.23.2", @@ -2307,13 +2341,13 @@ dependencies = [ [[package]] name = "foreign-types-macros" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.4", ] [[package]] @@ -2379,21 +2413,11 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -2406,9 +2430,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -2416,15 +2440,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -2433,9 +2457,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -2452,32 +2476,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.4", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -2490,15 +2514,6 @@ dependencies = [ "slab", ] -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - [[package]] name = "g2gen" version = "1.2.2" @@ -2508,7 +2523,7 @@ dependencies = [ "g2poly", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -2634,7 +2649,7 @@ checksum = "1d12aba7e9dc2c4d54654566dc3dc8383b5cb52e0cfc5754989afe0480d933e3" dependencies = [ "base64 0.22.1", "bytes", - "derive_more 2.1.1", + "derive_more", "eventsource-stream", "futures", "mime_guess", @@ -2665,9 +2680,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "1.3.5" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaf57c49a95fd1fe24b90b3033bee6dc7e8f1288d51494cb44e627c295e38542" +checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e" dependencies = [ "generic-array 0.14.7", "rustversion", @@ -2684,17 +2699,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -2704,7 +2708,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] @@ -2717,24 +2721,22 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.1" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", - "rand_core 0.10.0", - "wasip2", - "wasip3", + "r-efi 6.0.0", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -2754,7 +2756,8 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" dependencies = [ - "polyval 0.7.1", + "polyval 0.7.3", + "zeroize", ] [[package]] @@ -2805,7 +2808,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "futures-channel", "futures-core", "futures-executor", @@ -2833,7 +2836,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -2848,9 +2851,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "gloo-timers" @@ -2905,7 +2908,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" dependencies = [ "ff 0.14.0", - "rand_core 0.10.0", + "rand_core 0.10.1", "subtle", ] @@ -2958,14 +2961,14 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "h2" -version = "0.4.15" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -2973,7 +2976,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.13.0", + "indexmap 2.14.1", "slab", "tokio", "tokio-util", @@ -3020,14 +3023,14 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heapless" @@ -3038,7 +3041,7 @@ dependencies = [ "atomic-polyfill", "hash32", "rustc_version", - "spin 0.9.8", + "spin 0.9.9", "stable_deref_trait", ] @@ -3110,21 +3113,19 @@ dependencies = [ [[package]] name = "html5ever" -version = "0.29.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" dependencies = [ "log", - "mac", "markup5ever", - "match_token", ] [[package]] name = "http" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -3132,9 +3133,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -3142,9 +3143,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -3167,9 +3168,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "ctutils", "subtle", @@ -3179,9 +3180,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -3200,15 +3201,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -3291,12 +3291,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -3304,9 +3305,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -3317,9 +3318,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -3331,16 +3332,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -3351,15 +3353,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -3370,12 +3372,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -3395,9 +3391,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -3445,12 +3441,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -3466,20 +3462,20 @@ dependencies = [ [[package]] name = "inotify" -version = "0.11.0" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "inotify-sys", "libc", ] [[package]] name = "inotify-sys" -version = "0.1.5" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" dependencies = [ "libc", ] @@ -3518,8 +3514,8 @@ checksum = "ae8e22120c32fb4d19ec55fba35015f57095cd95a2e3b732e44457f5915b2ee8" dependencies = [ "num-integer", "num-traits", - "rand 0.10.0", - "rand_core 0.10.0", + "rand 0.10.2", + "rand_core 0.10.1", ] [[package]] @@ -3543,19 +3539,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" -dependencies = [ - "memchr", - "serde", -] +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "ironrdp" @@ -3638,7 +3624,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb9050999a1e032f4313788ac5a6d06e897a4f672f1de2ed98683001fc1abf56" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "ironrdp-core", "ironrdp-pdu", "ironrdp-svc", @@ -3733,7 +3719,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7493e426b6a8104cd497e518ba7781a9c7fc9a5f58a9cc0c1111e6d66f63bcc" dependencies = [ "bit_field", - "bitflags 2.11.1", + "bitflags 2.13.1", "bitvec", "byteorder", "ironrdp-core", @@ -3761,7 +3747,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19371274ea75ac1edb3431b08b823aad0dba48124c8fe7efbc4c3d8a30a22327" dependencies = [ "base64 0.22.1", - "bitflags 2.11.1", + "bitflags 2.13.1", "futures-util", "http-body-util", "hyper", @@ -3783,18 +3769,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ccd1179a4d106df1930347701388b5c79bc3725fa7dab4438d57db0d9d19347" dependencies = [ "bit_field", - "bitflags 2.11.1", + "bitflags 2.13.1", "byteorder", "der-parser", "ironrdp-core", "ironrdp-error", "md-5 0.10.6", - "num-bigint 0.4.6", + "num-bigint 0.4.8", "num-derive", "num-integer", "num-traits", "pkcs1 0.7.5", - "sha1 0.10.6", + "sha1 0.10.7", "tap", "x509-cert", ] @@ -3837,7 +3823,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24c36b82ab0f7fef2668fb7004008a0f3c100a3d9a7b18bd4495a71aba55b796" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "ironrdp-core", "ironrdp-pdu", ] @@ -3904,9 +3890,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "javascriptcore-rs" @@ -3933,11 +3919,12 @@ dependencies = [ [[package]] name = "jiff" -version = "0.2.32" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" dependencies = [ "defmt", + "jiff-core", "jiff-static", "jiff-tzdb-platform", "js-sys", @@ -3950,21 +3937,31 @@ dependencies = [ ] [[package]] -name = "jiff-static" -version = "0.2.32" +name = "jiff-core" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "jiff-tzdb" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" [[package]] name = "jiff-tzdb-platform" @@ -3984,7 +3981,7 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys 0.3.0", + "jni-sys 0.3.1", "log", "thiserror 1.0.69", "walkdir", @@ -4003,7 +4000,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.20", "walkdir", "windows-link 0.2.1", ] @@ -4018,14 +4015,17 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "jni-sys" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] [[package]] name = "jni-sys" @@ -4043,28 +4043,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -4101,12 +4100,12 @@ dependencies = [ [[package]] name = "keccak" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", ] [[package]] @@ -4116,7 +4115,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" dependencies = [ "crypto-common 0.2.2", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] @@ -4125,7 +4124,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "serde", "unicode-segmentation", ] @@ -4141,16 +4140,16 @@ dependencies = [ "log", "secret-service", "security-framework 2.11.1", - "security-framework 3.6.0", + "security-framework 3.7.0", "windows-sys 0.60.2", "zeroize", ] [[package]] name = "kqueue" -version = "1.1.1" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" dependencies = [ "kqueue-sys", "libc", @@ -4158,41 +4157,23 @@ dependencies = [ [[package]] name = "kqueue-sys" -version = "1.0.4" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.13.1", "libc", ] -[[package]] -name = "kuchikiki" -version = "0.8.8-speedreader" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" -dependencies = [ - "cssparser", - "html5ever", - "indexmap 2.13.0", - "selectors", -] - [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin 0.9.8", + "spin 0.9.9", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libappindicator" version = "0.9.0" @@ -4219,15 +4200,15 @@ dependencies = [ [[package]] name = "libbz2-rs-sys" -version = "0.2.2" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libdbus-sys" @@ -4266,13 +4247,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "libc", - "redox_syscall 0.7.4", + "plain", + "redox_syscall 0.9.3", ] [[package]] @@ -4308,15 +4290,15 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -4329,9 +4311,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru" @@ -4350,19 +4332,13 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "lzma-rust2" -version = "0.16.2" +version = "0.16.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47bb1e988e6fb779cf720ad431242d3f03167c1b3f2b1aae7f1a94b2495b36ae" +checksum = "ca93e534d1142d1d0dcca6d25fe302508a5dfb40b302802904577725ea0b695b" dependencies = [ - "sha2 0.10.9", + "sha2 0.11.0", ] -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - [[package]] name = "mach2" version = "0.4.3" @@ -4374,27 +4350,13 @@ dependencies = [ [[package]] name = "markup5ever" -version = "0.14.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" dependencies = [ "log", - "phf 0.11.3", - "phf_codegen 0.11.3", - "string_cache", - "string_cache_codegen", "tendril", -] - -[[package]] -name = "match_token" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", + "web_atoms", ] [[package]] @@ -4406,12 +4368,6 @@ dependencies = [ "regex-automata", ] -[[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" - [[package]] name = "md-5" version = "0.10.6" @@ -4443,24 +4399,25 @@ dependencies = [ [[package]] name = "md5" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" +checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" [[package]] name = "mea" -version = "0.6.4" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2640d335e7273dacdcf51044026139b2e269c3bb0dfc3f8cb3496b85e3f6a42c" +checksum = "c709842c4ce65cb91e2666ad5319dfc1efc3af0d34f02075eddca9000d9f8afb" dependencies = [ + "hashbrown 0.17.1", "slab", ] [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memoffset" @@ -4518,6 +4475,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.2" @@ -4526,7 +4493,7 @@ checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] @@ -4540,7 +4507,7 @@ dependencies = [ "kem", "module-lattice", "pkcs8 0.11.0", - "rand_core 0.10.0", + "rand_core 0.10.1", "sha3 0.11.0", ] @@ -4567,9 +4534,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.17.1" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" dependencies = [ "crossbeam-channel", "dpi", @@ -4580,10 +4547,10 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png 0.17.16", + "png 0.18.1", "serde", - "thiserror 2.0.18", - "windows-sys 0.60.2", + "thiserror 2.0.20", + "windows-sys 0.61.2", ] [[package]] @@ -4598,7 +4565,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework 3.6.0", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] @@ -4609,8 +4576,8 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.11.1", - "jni-sys 0.3.0", + "bitflags 2.13.1", + "jni-sys 0.3.1", "log", "ndk-sys", "num_enum", @@ -4618,19 +4585,13 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - [[package]] name = "ndk-sys" version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ - "jni-sys 0.3.0", + "jni-sys 0.3.1", ] [[package]] @@ -4670,7 +4631,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -4679,22 +4640,16 @@ dependencies = [ [[package]] name = "nix" -version = "0.31.2" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", ] -[[package]] -name = "nodrop" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" - [[package]] name = "nom" version = "7.1.3" @@ -4720,7 +4675,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "fsevent-sys", "inotify", "kqueue", @@ -4738,7 +4693,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "serde", ] @@ -4767,7 +4722,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" dependencies = [ - "num-bigint 0.4.6", + "num-bigint 0.4.8", "num-complex", "num-integer", "num-iter", @@ -4788,9 +4743,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -4807,7 +4762,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.8", "smallvec", "zeroize", ] @@ -4823,9 +4778,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -4835,25 +4790,24 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -4864,7 +4818,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ - "num-bigint 0.4.6", + "num-bigint 0.4.8", "num-integer", "num-traits", ] @@ -4881,9 +4835,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -4891,14 +4845,14 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -4944,7 +4898,7 @@ dependencies = [ "pbkdf2 0.12.2", "portable-pty", "quick-xml 0.40.1", - "rand 0.8.5", + "rand 0.8.8", "redb", "regex", "reqwest 0.12.28", @@ -4956,7 +4910,7 @@ dependencies = [ "serde", "serde_json", "serialport", - "sha1 0.10.6", + "sha1 0.10.7", "sha2 0.10.9", "sha3 0.10.9", "smallvec", @@ -4970,7 +4924,7 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-single-instance", "tauri-plugin-updater", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tokio", "tokio-socks", @@ -4988,7 +4942,7 @@ dependencies = [ "windows-sys 0.61.2", "x509-cert", "zeroize", - "zip 8.2.0", + "zip 8.6.0", "zmodem2", ] @@ -4996,7 +4950,7 @@ dependencies = [ name = "nyaterm-mcp-protocol" version = "0.1.0" dependencies = [ - "schemars 1.2.1", + "schemars 1.2.2", "serde", "serde_json", ] @@ -5007,9 +4961,9 @@ version = "0.1.0" [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", "objc2-exception-helper", @@ -5021,19 +4975,12 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2", - "libc", "objc2", - "objc2-cloud-kit", - "objc2-core-data", "objc2-core-foundation", "objc2-core-graphics", - "objc2-core-image", - "objc2-core-text", - "objc2-core-video", "objc2-foundation", - "objc2-quartz-core", ] [[package]] @@ -5042,7 +4989,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2", "objc2-foundation", ] @@ -5053,7 +5000,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "bitflags 2.11.1", "objc2", "objc2-foundation", ] @@ -5064,7 +5010,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "dispatch2", "objc2", ] @@ -5075,7 +5021,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "dispatch2", "objc2", "objc2-core-foundation", @@ -5092,31 +5038,28 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-text" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2", "objc2-core-foundation", "objc2-core-graphics", ] -[[package]] -name = "objc2-core-video" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" -dependencies = [ - "bitflags 2.11.1", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-io-surface", -] - [[package]] name = "objc2-encode" version = "4.1.0" @@ -5138,7 +5081,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2", "libc", "objc2", @@ -5151,17 +5094,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.11.1", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-javascript-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" -dependencies = [ + "bitflags 2.13.1", "objc2", "objc2-core-foundation", ] @@ -5172,7 +5105,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2", "objc2-app-kit", "objc2-foundation", @@ -5184,32 +5117,40 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2", "objc2-core-foundation", "objc2-foundation", ] -[[package]] -name = "objc2-security" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" -dependencies = [ - "bitflags 2.11.1", - "objc2", - "objc2-core-foundation", -] - [[package]] name = "objc2-ui-kit" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", + "block2", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", "objc2-foundation", ] @@ -5219,14 +5160,12 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2", "objc2", "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", - "objc2-javascript-core", - "objc2-security", ] [[package]] @@ -5252,21 +5191,20 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open" -version = "5.3.3" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +checksum = "ade3be4664bc1ef537ce133015f04c176b737815c2ba9fd60edf212d6e90dd55" dependencies = [ "dunce", "is-wsl", "libc", - "pathdiff", ] [[package]] name = "opendal" -version = "0.58.0" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77d02c6564e376d3670aaf66ad886cd34f83c0aca407b6364777c624a63e0e2d" +checksum = "33dbff14cc9bb085224256d6a81289d2f3202e85b06f408d42534b42162a4231" dependencies = [ "opendal-core", "opendal-http-transport-reqwest", @@ -5282,19 +5220,19 @@ dependencies = [ [[package]] name = "opendal-core" -version = "0.58.0" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8564bd76b75d2aea59178cb5b6e9f770be1da73b1bca062720ec151cc56215a" +checksum = "48dbcef97d3eb7591db2c18d5cae95c836bcce07359b98d98dd6f4e861eb77b7" dependencies = [ "anyhow", - "base64 0.22.1", + "asyncband", + "base64 0.23.1", "bytes", "futures", "http", "jiff", "log", "md-5 0.11.0", - "mea", "percent-encoding", "quick-xml 0.41.0", "reqsign-core", @@ -5308,9 +5246,9 @@ dependencies = [ [[package]] name = "opendal-http-transport-reqwest" -version = "0.58.0" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b7fd001b204df76be5d2b3f7a81c48b5cf25b28e2cfb3b8a819bb89cdc0ea3a" +checksum = "85663452ea32bbc17e8f79ab29788c846d116ec7de31451be9c787e462dcb36c" dependencies = [ "bytes", "futures", @@ -5322,9 +5260,9 @@ dependencies = [ [[package]] name = "opendal-layer-retry" -version = "0.58.0" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2df70875ab7fd6f80720d4787c49c70883cef0d81bfae947ecba88b8d1cd62e" +checksum = "e94db301964a25366090484d61e6da16d5979cc8faf02c3e12210dc74fafed38" dependencies = [ "backon", "log", @@ -5333,9 +5271,9 @@ dependencies = [ [[package]] name = "opendal-layer-timeout" -version = "0.58.0" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18f1bb30ab396a617ef884863b27ae91fbe966e312ce69876fa41584ab0fc5c6" +checksum = "08956ddda07465449bfd48825f4f0f25e0351278ac974eaa659895d9d74f2c80" dependencies = [ "opendal-core", "tokio", @@ -5343,9 +5281,9 @@ dependencies = [ [[package]] name = "opendal-layer-tracing" -version = "0.58.0" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04d866ce663c56a327ce30ee8fca1aa20250d6c7c06264cd4e2f4fb1d6a2e022" +checksum = "415e35e6137b02c80cdd69be9e7e72f9bb170fc9249dfb08eac9ca5f87e5d3d6" dependencies = [ "futures", "http", @@ -5355,14 +5293,14 @@ dependencies = [ [[package]] name = "opendal-service-aliyun-drive" -version = "0.58.0" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af933977128255600dc7161eef636e0f1f99c035c1d65ce825e578ba388d8805" +checksum = "c7c3e551e25d1c182be77a7c501eb3e0a9217fc409fca6f88125167083daa304" dependencies = [ + "asyncband", "bytes", "http", "log", - "mea", "opendal-core", "serde", "serde_json", @@ -5370,14 +5308,14 @@ dependencies = [ [[package]] name = "opendal-service-gdrive" -version = "0.58.0" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5ba5d3c6b19188ef0a229cc22d07f078dd0de388c3246e1370d7b2c7563a765" +checksum = "bf817a82a81423bdda3c980e5d5b076f07364a71398dbf038d6220bb4f55b529" dependencies = [ + "asyncband", "bytes", "http", "log", - "mea", "opendal-core", "serde", "serde_json", @@ -5385,14 +5323,14 @@ dependencies = [ [[package]] name = "opendal-service-onedrive" -version = "0.58.0" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e425f33e5eff2207ffd232fa1172531e1bb89b5968a57d9be903ec4e5678a5ff" +checksum = "d2d251202faa1d3d7e05a02f4fcb7e56a8770dfcd69a5e5fdebc567dabd5133c" dependencies = [ + "asyncband", "bytes", "http", "log", - "mea", "opendal-core", "serde", "serde_json", @@ -5401,11 +5339,11 @@ dependencies = [ [[package]] name = "opendal-service-s3" -version = "0.58.0" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "750d9cc8588c19b27c4c2c5d06d7eb6f2bff62549c48652f1f9a7603cd872377" +checksum = "c64335f9f24ccb62ac36f1d976342b48611a75ba61979813a4f78a4ebd94de42" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "crc-fast", "http", @@ -5422,15 +5360,15 @@ dependencies = [ [[package]] name = "opendal-service-webdav" -version = "0.58.0" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e7527c793412d4b129f334258af73689e144cb91610cabfbdb47d410248c08" +checksum = "1fa9aec39b39efa0367020732991260373492ffb0273259591ec362b664f455f" dependencies = [ "anyhow", + "asyncband", "bytes", "http", "log", - "mea", "opendal-core", "quick-xml 0.41.0", "serde", @@ -5438,15 +5376,14 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.75" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "foreign-types 0.3.2", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -5459,7 +5396,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -5470,9 +5407,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.111" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -5527,7 +5464,7 @@ dependencies = [ "objc2-osa-kit", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -5619,9 +5556,9 @@ dependencies = [ "delegate", "futures", "log", - "rand 0.10.0", + "rand 0.10.2", "sha2 0.11.0", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "windows 0.62.2", "windows-strings 0.5.1", @@ -5696,12 +5633,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - [[package]] name = "pathfinder_geometry" version = "0.5.1" @@ -5714,9 +5645,9 @@ dependencies = [ [[package]] name = "pathfinder_simd" -version = "0.5.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf9027960355bf3afff9841918474a81a5f972ac6d226d518060bba758b5ad57" +checksum = "4500030c302e4af1d423f36f3b958d1aecb6c04184356ed5a833bf6b60435777" dependencies = [ "rustc_version", ] @@ -5773,7 +5704,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset", "hashbrown 0.15.5", - "indexmap 2.13.0", + "indexmap 2.14.1", ] [[package]] @@ -5788,144 +5719,63 @@ dependencies = [ [[package]] name = "phf" -version = "0.8.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_shared 0.8.0", -] - -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_macros 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros 0.11.3", - "phf_shared 0.11.3", + "phf_macros", + "phf_shared", + "serde", ] [[package]] name = "phf_codegen" -version = "0.8.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", + "phf_generator", + "phf_shared", ] [[package]] name = "phf_generator" -version = "0.8.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ - "phf_shared 0.8.0", - "rand 0.7.3", -] - -[[package]] -name = "phf_generator" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" -dependencies = [ - "phf_shared 0.10.0", - "rand 0.8.5", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.5", + "fastrand", + "phf_shared", ] [[package]] name = "phf_macros" -version = "0.10.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", + "phf_generator", + "phf_shared", "proc-macro2", "quote", - "syn 1.0.109", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "phf_shared" -version = "0.8.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher 1.0.2", + "siphasher", ] [[package]] name = "picky" version = "7.0.0-rc.25" dependencies = [ - "aes 0.9.1", - "aes-gcm 0.11.0", + "aes 0.9.3", + "aes-gcm 0.11.1", "aes-kw", "base64 0.22.1", "cbc 0.2.1", @@ -5951,8 +5801,8 @@ dependencies = [ "picky-asn1-x509", "pkcs1 0.8.0-rc.4", "primeorder 0.14.0-rc.15", - "rand 0.10.0", - "rand_core 0.10.0", + "rand 0.10.2", + "rand_core 0.10.1", "rc2", "rsa 0.10.0-rc.18", "rustcrypto-ff", @@ -5963,7 +5813,7 @@ dependencies = [ "sha1 0.11.0", "sha2 0.11.0", "sha3 0.12.0", - "thiserror 2.0.18", + "thiserror 2.0.20", "x25519-dalek", "zeroize", ] @@ -6014,7 +5864,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d188f3192356068dbdba54bddbca6fd0f7a09565d3861eeb8efe1ab77ae8e97" dependencies = [ - "aes 0.9.1", + "aes 0.9.3", "block-padding 0.4.2", "byteorder", "cbc 0.2.1", @@ -6028,19 +5878,19 @@ dependencies = [ "picky-asn1", "picky-asn1-der", "picky-asn1-x509", - "rand 0.10.0", - "rand_core 0.10.0", + "rand 0.10.2", + "rand_core 0.10.1", "serde", "sha1 0.11.0", - "thiserror 2.0.18", + "thiserror 2.0.20", "uuid", ] [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -6050,9 +5900,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "piper" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", "fastrand", @@ -6076,37 +5926,23 @@ version = "0.8.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" dependencies = [ - "der 0.8.0", + "der 0.8.1", "spki 0.8.0", ] [[package]] name = "pkcs5" -version = "0.7.1" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" +checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca" dependencies = [ - "aes 0.8.4", - "cbc 0.1.2", - "der 0.7.10", - "pbkdf2 0.12.2", - "scrypt 0.11.0", - "sha2 0.10.9", - "spki 0.7.3", -] - -[[package]] -name = "pkcs5" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "279a91971a1d8eb1260a30938eae3be9cb67b472dffecb222fbbbe2fd2dc1453" -dependencies = [ - "aes 0.9.1", + "aes 0.9.3", + "aes-gcm 0.11.1", "cbc 0.2.1", - "der 0.8.0", + "der 0.8.1", "pbkdf2 0.13.0", - "rand_core 0.10.0", - "scrypt 0.12.0", + "rand_core 0.10.1", + "scrypt", "sha2 0.11.0", "spki 0.8.0", ] @@ -6118,8 +5954,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ "der 0.7.10", - "pkcs5 0.7.1", - "rand_core 0.6.4", "spki 0.7.3", ] @@ -6129,27 +5963,33 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der 0.8.0", - "pkcs5 0.8.0", - "rand_core 0.10.0", + "der 0.8.1", + "pkcs5", + "rand_core 0.10.1", "spki 0.8.0", ] [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "plist" -version = "1.8.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", - "indexmap 2.13.0", - "quick-xml 0.38.4", + "indexmap 2.14.1", + "quick-xml 0.41.0", "serde", "time", ] @@ -6164,7 +6004,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -6173,11 +6013,11 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -6207,11 +6047,11 @@ dependencies = [ [[package]] name = "poly1305" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00baa632505d05512f48a963e16051c54fda9a95cc9acea1a4e3c90991c4a2e" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" dependencies = [ - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "universal-hash 0.6.1", "zeroize", ] @@ -6230,20 +6070,21 @@ dependencies = [ [[package]] name = "polyval" -version = "0.7.1" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" dependencies = [ "cpubits", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "universal-hash 0.6.1", + "zeroize", ] [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" @@ -6277,9 +6118,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -6311,16 +6152,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.114", -] - [[package]] name = "primefield" version = "0.14.0" @@ -6330,7 +6161,7 @@ dependencies = [ "crypto-bigint 0.7.5", "crypto-common 0.2.2", "ff 0.14.0", - "rand_core 0.10.0", + "rand_core 0.10.1", "subtle", "zeroize", ] @@ -6379,11 +6210,11 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.23.10+spec-1.0.0", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] @@ -6410,26 +6241,20 @@ dependencies = [ "version_check", ] -[[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" - [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "pxfm" -version = "0.1.28" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] name = "quick-error" @@ -6437,24 +6262,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - -[[package]] -name = "quick-xml" -version = "0.39.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" -dependencies = [ - "memchr", -] - [[package]] name = "quick-xml" version = "0.40.1" @@ -6462,7 +6269,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2474bd2e5029e7ccb6abb2ba48cf2383a333851dedf495901544281590c7da7f" dependencies = [ "memchr", - "serde", ] [[package]] @@ -6489,7 +6295,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -6497,21 +6303,22 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.15" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.5", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -6533,9 +6340,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.44" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -6546,6 +6353,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radium" version = "0.7.0" @@ -6554,23 +6367,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.7.3" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", - "rand_pcg", -] - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -6589,23 +6388,13 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", - "getrandom 0.4.1", - "rand_core 0.10.0", -] - -[[package]] -name = "rand_chacha" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" -dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -6628,15 +6417,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -6657,26 +6437,17 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" - -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_pcg" -version = "0.2.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core 0.5.1", + "rand_core 0.10.1", ] [[package]] @@ -6696,9 +6467,9 @@ dependencies = [ [[package]] name = "redb" -version = "4.1.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e925444704b5f17d32bf42f5b6e2df050bceebc3dcd6e71cc73dafe8092e839" +checksum = "de6c3b63e007e90ce536ec2ae4690826136a20ec8dbbbb400daef1bb999d2e36" dependencies = [ "libc", ] @@ -6709,16 +6480,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] name = "redox_syscall" -version = "0.7.4" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -6729,34 +6500,34 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.4", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -6766,9 +6537,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -6777,24 +6548,23 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.9" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] -name = "reqsign-aws-v4" -version = "3.0.1" +name = "reqsign-aws-core" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b75624bd8a466e37ddc0a7b6c33ac859a85347c153a916e1dd9d0b68338f74a" +checksum = "bac4749b7dfa7bfaccd01eb03e9dc795ed37e3f20d6f0f38e2c67ee85ad6bc86" dependencies = [ - "anyhow", "bytes", "form_urlencoded", "hex", "http", "log", "percent-encoding", - "quick-xml 0.40.1", + "quick-xml 0.41.0", "reqsign-core", "rust-ini", "serde", @@ -6804,25 +6574,37 @@ dependencies = [ ] [[package]] -name = "reqsign-core" -version = "3.0.1" +name = "reqsign-aws-v4" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5fa5cb48808693614d1701fcd3db0b30fa292e0f18e122ae068b6d32eaeed3f" +checksum = "ff250f0fd0b913fbd565e405acc553da0f13bde30bfb5403178c9d0313cdc15f" +dependencies = [ + "bytes", + "http", + "log", + "quick-xml 0.41.0", + "reqsign-aws-core", + "reqsign-core", + "serde", +] + +[[package]] +name = "reqsign-core" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff052daffb0599681c50f85c59e7236438976efe991ab864edd9f3b235501a0f" dependencies = [ "anyhow", - "base64 0.22.1", + "base64 0.23.1", "bytes", - "form_urlencoded", "futures", "hex", "hmac 0.13.0", "http", "jiff", "log", + "mea", "percent-encoding", - "rsa 0.9.10", - "serde", - "serde_json", "sha1 0.11.0", "sha2 0.11.0", "windows-sys 0.61.2", @@ -6830,9 +6612,9 @@ dependencies = [ [[package]] name = "reqsign-file-read-tokio" -version = "3.0.1" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a4b6f3a3fd29ffcc99a90aec585a65217783badfd73acddf847b63ae683bda9" +checksum = "b3235df90a6bca681aa47dd86f2393d122a6d77042aa8a7c81e218cd45c5bfc0" dependencies = [ "anyhow", "reqsign-core", @@ -7026,7 +6808,7 @@ dependencies = [ "digest 0.11.3", "pkcs1 0.8.0-rc.4", "pkcs8 0.11.0", - "rand_core 0.10.0", + "rand_core 0.10.1", "sha2 0.11.0", "signature 3.0.0", "spki 0.8.0", @@ -7037,8 +6819,8 @@ dependencies = [ name = "russh" version = "0.62.1" dependencies = [ - "aes 0.9.1", - "bitflags 2.11.1", + "aes 0.9.3", + "bitflags 2.13.1", "block-padding 0.4.2", "byteorder", "bytes", @@ -7049,7 +6831,7 @@ dependencies = [ "curve25519-dalek 5.0.0-rc.1", "data-encoding", "delegate", - "der 0.8.0", + "der 0.8.1", "des", "digest 0.11.3", "ecdsa 0.17.0", @@ -7058,36 +6840,36 @@ dependencies = [ "enum_dispatch", "flate2", "futures", - "generic-array 1.3.5", - "getrandom 0.4.1", + "generic-array 1.4.5", + "getrandom 0.4.3", "ghash 0.6.0", "hex-literal", "hmac 0.13.0", "inout 0.2.2", "internal-russh-num-bigint", - "keccak 0.2.0", + "keccak 0.2.2", "log", "md5", "ml-kem", "module-lattice", - "num-bigint 0.4.6", + "num-bigint 0.4.8", "p256 0.14.0-rc.15", "p384 0.14.0-rc.15", "p521 0.14.0-rc.15", "pageant", "pbkdf2 0.13.0", "pkcs1 0.8.0-rc.4", - "pkcs5 0.8.0", + "pkcs5", "pkcs8 0.11.0", - "polyval 0.7.1", - "rand 0.10.0", - "rand_core 0.10.0", + "polyval 0.7.3", + "rand 0.10.2", + "rand_core 0.10.1", "ring", "rsa 0.10.0-rc.18", "russh-cryptovec", "russh-util", "salsa20 0.11.0", - "scrypt 0.12.0", + "scrypt", "sec1 0.8.1", "sha1 0.11.0", "sha2 0.11.0", @@ -7097,7 +6879,7 @@ dependencies = [ "ssh-encoding 0.3.0", "ssh-key 0.7.0-rc.11", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "typenum", "universal-hash 0.6.1", @@ -7109,7 +6891,7 @@ name = "russh-cryptovec" version = "0.62.0" dependencies = [ "log", - "nix 0.31.2", + "nix 0.31.3", "ssh-encoding 0.3.0", "windows-sys 0.61.2", ] @@ -7118,7 +6900,7 @@ dependencies = [ name = "russh-sftp" version = "2.3.0" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "bytes", "chrono", "dashmap", @@ -7127,7 +6909,7 @@ dependencies = [ "log", "serde", "serde_bytes", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-util", "wasm-bindgen-futures", @@ -7175,7 +6957,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd2a8adb347447693cd2ba0d218c4b66c62da9b0a5672b17b981e4291ec65ff6" dependencies = [ "bitvec", - "rand_core 0.10.0", + "rand_core 0.10.1", "rustcrypto-ff_derive", "subtle", ] @@ -7201,7 +6983,7 @@ version = "0.14.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "369f9b61aa45933c062c9f6b5c3c50ab710687eca83dd3802653b140b43f85ed" dependencies = [ - "rand_core 0.10.0", + "rand_core 0.10.1", "rustcrypto-ff", "subtle", ] @@ -7217,11 +6999,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "errno 0.3.14", "libc", "linux-raw-sys", @@ -7230,9 +7012,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", "log", @@ -7252,14 +7034,14 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.6.0", + "security-framework 3.7.0", ] [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -7280,7 +7062,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework 3.6.0", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -7294,9 +7076,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -7306,9 +7088,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rusty-leveldb" @@ -7321,7 +7103,7 @@ dependencies = [ "errno 0.2.8", "fs2", "integer-encoding", - "rand 0.8.5", + "rand 0.8.8", "snap", ] @@ -7361,9 +7143,9 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] @@ -7397,13 +7179,13 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", - "schemars_derive 1.2.1", + "schemars_derive 1.2.2", "serde", "serde_json", ] @@ -7416,20 +7198,20 @@ checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" dependencies = [ "proc-macro2", "quote", - "serde_derive_internals", - "syn 2.0.114", + "serde_derive_internals 0.29.1", + "syn 2.0.119", ] [[package]] name = "schemars_derive" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" dependencies = [ "proc-macro2", "quote", - "serde_derive_internals", - "syn 2.0.114", + "serde_derive_internals 0.30.0", + "syn 3.0.4", ] [[package]] @@ -7438,17 +7220,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "scrypt" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" -dependencies = [ - "pbkdf2 0.12.2", - "salsa20 0.10.2", - "sha2 0.10.9", -] - [[package]] name = "scrypt" version = "0.12.0" @@ -7483,7 +7254,7 @@ checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ "base16ct 1.0.0", "ctutils", - "der 0.8.0", + "der 0.8.1", "hybrid-array", "subtle", "zeroize", @@ -7511,7 +7282,7 @@ dependencies = [ "hkdf 0.12.4", "num", "once_cell", - "rand 0.8.5", + "rand 0.8.8", "serde", "sha2 0.10.9", "zbus 4.4.0", @@ -7523,7 +7294,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -7532,11 +7303,11 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.6.0" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -7555,27 +7326,28 @@ dependencies = [ [[package]] name = "selectors" -version = "0.24.0" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.13.1", "cssparser", - "derive_more 0.99.20", - "fxhash", + "derive_more", "log", - "phf 0.8.0", - "phf_codegen 0.8.0", + "new_debug_unreachable", + "phf", + "phf_codegen", "precomputed-hash", + "rustc-hash", "servo_arc", "smallvec", ] [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ "serde", "serde_core", @@ -7583,9 +7355,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -7615,22 +7387,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.4", ] [[package]] @@ -7641,14 +7413,25 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -7659,13 +7442,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.4", ] [[package]] @@ -7679,9 +7462,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -7700,17 +7483,19 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.16.1" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" dependencies = [ "base64 0.22.1", + "bs58", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.13.0", + "indexmap 2.14.1", + "jiff", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.2", "serde_core", "serde_json", "serde_with_macros", @@ -7719,21 +7504,21 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.16.1" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "serdect" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9af4a3e75ebd5599b30d4de5768e00b5095d518a79fefc3ecbaf77e665d1ec06" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" dependencies = [ "base16ct 1.0.0", "serde", @@ -7800,16 +7585,16 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "serialport" -version = "4.9.0" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3" +checksum = "6a2f4ac56b5d3af3c40fbbee17be96d532cba02fa5853926aacdb77d926272ab" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "core-foundation 0.10.1", "core-foundation-sys", @@ -7824,19 +7609,18 @@ dependencies = [ [[package]] name = "servo_arc" -version = "0.2.0" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" dependencies = [ - "nodrop", "stable_deref_trait", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -7850,7 +7634,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -7872,7 +7656,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -7893,7 +7677,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" dependencies = [ "digest 0.11.3", - "keccak 0.2.0", + "keccak 0.2.2", ] [[package]] @@ -7903,7 +7687,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" dependencies = [ "digest 0.11.3", - "keccak 0.2.0", + "keccak 0.2.2", "sponge-cursor", ] @@ -7934,9 +7718,9 @@ checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -7965,14 +7749,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ "digest 0.11.3", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" @@ -7992,15 +7776,9 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "0.3.11" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" - -[[package]] -name = "siphasher" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -8010,15 +7788,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "snap" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" +checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886" [[package]] name = "socket2" @@ -8080,9 +7858,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] @@ -8110,7 +7888,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der 0.8.0", + "der 0.8.1", ] [[package]] @@ -8136,13 +7914,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d801accda99469cde6d73da741422610fdf6508a72d9a69d1b55cb241c720597" dependencies = [ "aead 0.6.1", - "aes 0.9.1", - "aes-gcm 0.11.0", + "aes 0.9.3", + "aes-gcm 0.11.1", "chacha20", "cipher 0.5.2", "ctutils", "des", - "poly1305 0.9.0", + "poly1305 0.9.1", "ssh-encoding 0.3.0", "zeroize", ] @@ -8210,7 +7988,7 @@ dependencies = [ "p256 0.14.0-rc.15", "p384 0.14.0-rc.15", "p521 0.14.0-rc.15", - "rand_core 0.10.0", + "rand_core 0.10.1", "rsa 0.10.0-rc.18", "sec1 0.8.1", "sha1 0.11.0", @@ -8227,7 +8005,7 @@ version = "0.21.0" dependencies = [ "async-dnssd", "async-recursion", - "bitflags 2.11.1", + "bitflags 2.13.1", "bytemuck", "byteorder", "cfg-if", @@ -8258,8 +8036,8 @@ dependencies = [ "pkcs8 0.11.0", "primefield", "primeorder 0.14.0-rc.15", - "rand 0.10.0", - "rand_core 0.10.0", + "rand 0.10.2", + "rand_core 0.10.1", "rsa 0.10.0-rc.18", "rustcrypto-ff", "rustcrypto-ff_derive", @@ -8295,25 +8073,24 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "string_cache" -version = "0.8.9" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared 0.11.3", + "phf_shared", "precomputed-hash", - "serde", ] [[package]] name = "string_cache_codegen" -version = "0.5.4" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", + "phf_generator", + "phf_shared", "proc-macro2", "quote", ] @@ -8351,7 +8128,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -8362,15 +8139,21 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "swift-rs" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e" dependencies = [ "base64 0.21.7", "serde", "serde_json", ] +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -8384,9 +8167,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.114" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -8410,7 +8204,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -8419,7 +8213,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -8449,35 +8243,35 @@ dependencies = [ [[package]] name = "tao" -version = "0.34.5" +version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2", "core-foundation 0.10.1", - "core-graphics 0.24.0", + "core-graphics 0.25.0", "crossbeam-channel", - "dispatch", + "dbus", + "dispatch2", "dlopen2", "dpi", "gdkwayland-sys", "gdkx11-sys", "gtk", "jni 0.21.1", - "lazy_static", "libc", "log", "ndk", - "ndk-context", "ndk-sys", "objc2", "objc2-app-kit", "objc2-foundation", + "objc2-ui-kit", "once_cell", "parking_lot", + "percent-encoding", "raw-window-handle", - "scopeguard", "tao-macros", "unicode-segmentation", "url", @@ -8489,13 +8283,13 @@ dependencies = [ [[package]] name = "tao-macros" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -8506,9 +8300,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tar" -version = "0.4.45" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" dependencies = [ "filetime", "libc", @@ -8523,9 +8317,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.10.2" +version = "2.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "463ae8677aa6d0f063a900b9c41ecd4ac2b7ca82f0b058cc4491540e55b20129" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" dependencies = [ "anyhow", "bytes", @@ -8563,7 +8357,7 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tray-icon", "url", @@ -8575,9 +8369,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.5.5" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca7bd893329425df750813e95bd2b643d5369d929438da96d5bbb7cc2c918f74" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" dependencies = [ "anyhow", "cargo_toml", @@ -8591,15 +8385,14 @@ dependencies = [ "serde_json", "tauri-utils", "tauri-winres", - "toml 0.9.12+spec-1.1.0", "walkdir", ] [[package]] name = "tauri-codegen" -version = "2.5.4" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aac423e5859d9f9ccdd32e3cf6a5866a15bedbf25aa6630bcb2acde9468f6ae3" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" dependencies = [ "base64 0.22.1", "brotli", @@ -8613,9 +8406,9 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "syn 2.0.114", + "syn 2.0.119", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "url", "uuid", @@ -8624,23 +8417,23 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.5.4" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b6a1bd2861ff0c8766b1d38b32a6a410f6dc6532d4ef534c47cfb2236092f59" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", "tauri-codegen", "tauri-utils", ] [[package]] name = "tauri-plugin" -version = "2.5.3" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692a77abd8b8773e107a42ec0e05b767b8d2b7ece76ab36c6c3947e34df9f53f" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" dependencies = [ "anyhow", "glob", @@ -8649,7 +8442,6 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "toml 0.9.12+spec-1.1.0", "walkdir", ] @@ -8667,7 +8459,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "url", "windows-registry 0.5.3", @@ -8676,9 +8468,9 @@ dependencies = [ [[package]] name = "tauri-plugin-dialog" -version = "2.6.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" dependencies = [ "log", "raw-window-handle", @@ -8688,19 +8480,21 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-fs", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", ] [[package]] name = "tauri-plugin-fs" -version = "2.4.5" +version = "2.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" dependencies = [ "anyhow", "dunce", "glob", + "log", + "objc2-foundation", "percent-encoding", "schemars 0.8.22", "serde", @@ -8709,16 +8503,16 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.18", - "toml 0.9.12+spec-1.1.0", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", "url", ] [[package]] name = "tauri-plugin-opener" -version = "2.5.3" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" dependencies = [ "dunce", "glob", @@ -8730,10 +8524,10 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", "windows 0.61.3", - "zbus 5.13.2", + "zbus 5.19.0", ] [[package]] @@ -8748,18 +8542,19 @@ dependencies = [ [[package]] name = "tauri-plugin-single-instance" -version = "2.4.2" +version = "2.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c" dependencies = [ "serde", "serde_json", "tauri", "tauri-plugin-deep-link", - "thiserror 2.0.18", + "thiserror 2.0.20", + "tokio", "tracing", "windows-sys 0.60.2", - "zbus 5.13.2", + "zbus 5.19.0", ] [[package]] @@ -8786,7 +8581,7 @@ dependencies = [ "tauri", "tauri-plugin", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tokio", "url", @@ -8796,9 +8591,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.10.0" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b885ffeac82b00f1f6fd292b6e5aabfa7435d537cef57d11e38a489956535651" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" dependencies = [ "cookie", "dpi", @@ -8812,7 +8607,7 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", "webkit2gtk", "webview2-com", @@ -8821,9 +8616,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.10.0" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5204682391625e867d16584fedc83fc292fb998814c9f7918605c789cd876314" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", @@ -8831,7 +8626,6 @@ dependencies = [ "log", "objc2", "objc2-app-kit", - "objc2-foundation", "once_cell", "percent-encoding", "raw-window-handle", @@ -8848,24 +8642,24 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.8.2" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcd169fccdff05eff2c1033210b9b94acd07a47e6fa9a3431cf09cfd4f01c87e" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" dependencies = [ "anyhow", "brotli", "cargo_metadata", "ctor", + "dom_query", "dunce", "glob", - "html5ever", "http", "infer", "json-patch", - "kuchikiki", "log", "memchr", - "phf 0.11.3", + "phf", + "plist", "proc-macro2", "quote", "regex", @@ -8876,8 +8670,8 @@ dependencies = [ "serde_json", "serde_with", "swift-rs", - "thiserror 2.0.18", - "toml 0.9.12+spec-1.1.0", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", "url", "urlpattern", "uuid", @@ -8886,23 +8680,23 @@ dependencies = [ [[package]] name = "tauri-winres" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", ] [[package]] name = "tempfile" -version = "3.25.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.1", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -8910,13 +8704,11 @@ dependencies = [ [[package]] name = "tendril" -version = "0.4.3" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" dependencies = [ - "futf", - "mac", - "utf-8", + "new_debug_unreachable", ] [[package]] @@ -8939,11 +8731,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -8954,25 +8746,25 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.4", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -8993,12 +8785,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "js-sys", "libc", "num-conv", @@ -9011,15 +8802,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -9036,9 +8827,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -9077,14 +8868,14 @@ checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -9098,13 +8889,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.4", ] [[package]] @@ -9129,9 +8920,9 @@ dependencies = [ [[package]] name = "tokio-socks" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" dependencies = [ "either", "futures-util", @@ -9141,9 +8932,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -9170,15 +8961,16 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-io", "futures-sink", "futures-util", + "libc", "pin-project-lite", "tokio", ] @@ -9201,13 +8993,28 @@ version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.1", "serde_core", - "serde_spanned 1.0.4", + "serde_spanned 1.1.1", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 0.7.14", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap 2.14.1", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", ] [[package]] @@ -9228,13 +9035,22 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.1", "toml_datetime 0.6.3", "winnow 0.5.40", ] @@ -9245,7 +9061,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.1", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.3", @@ -9254,30 +9070,30 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ - "indexmap 2.13.0", - "toml_datetime 0.7.5+spec-1.1.0", + "indexmap 2.14.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 0.7.14", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.0.7+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "247eaa3197818b831697600aadf81514e577e0cba5eab10f7e064e78ae154df1" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 0.7.14", + "winnow 1.0.4", ] [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tower" @@ -9296,25 +9112,25 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", - "bitflags 2.11.1", + "bitflags 2.13.1", "bytes", "futures-core", "futures-util", "http", "http-body", "http-body-util", - "iri-string", "pin-project-lite", "tokio", "tokio-util", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -9343,12 +9159,13 @@ dependencies = [ [[package]] name = "tracing-appender" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", - "thiserror 2.0.18", + "symlink", + "thiserror 2.0.20", "time", "tracing-subscriber", ] @@ -9361,7 +9178,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -9397,9 +9214,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", @@ -9419,9 +9236,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.21.3" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" dependencies = [ "crossbeam-channel", "dirs", @@ -9433,10 +9250,10 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png 0.17.16", + "png 0.18.1", "serde", - "thiserror 2.0.18", - "windows-sys 0.60.2", + "thiserror 2.0.20", + "windows-sys 0.61.2", ] [[package]] @@ -9471,8 +9288,8 @@ dependencies = [ "rand 0.9.5", "rustls", "rustls-pki-types", - "sha1 0.10.6", - "thiserror 2.0.18", + "sha1 0.10.7", + "thiserror 2.0.20", ] [[package]] @@ -9495,22 +9312,22 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "uds_windows" -version = "1.1.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset 0.9.1", "tempfile", - "winapi", + "windows-sys 0.61.2", ] [[package]] name = "unescaper" -version = "0.1.8" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" +checksum = "7285e83a80ce76f5e7bce79fa41f68d78ba62d1003cf27bf748ab24413808cf4" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -9562,15 +9379,15 @@ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" -version = "1.0.23" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-xid" @@ -9635,12 +9452,6 @@ dependencies = [ "url", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -9649,11 +9460,11 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.1" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ - "getrandom 0.4.1", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -9667,11 +9478,11 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "value-ext" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05ebf9090a4eea10b1962958987cb54ee69f98b45eb918b73cb846bfb8c8c06f" +checksum = "ca945a9c7463ad3085da59b5dc4f8faf4dff6f3e7d835fa7ef08a3b36926512d" dependencies = [ - "derive_more 2.1.1", + "derive_more", "serde", "serde_json", ] @@ -9758,12 +9569,6 @@ dependencies = [ "try-lock", ] -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -9772,18 +9577,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] @@ -9796,9 +9592,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -9809,9 +9605,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -9819,9 +9615,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -9829,48 +9625,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.13.0", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.5.0" @@ -9884,23 +9658,11 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.15.5", - "indexmap 2.13.0", - "semver", -] - [[package]] name = "wayland-backend" -version = "0.3.15" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", @@ -9911,11 +9673,11 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.14" +version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "rustix", "wayland-backend", "wayland-scanner", @@ -9923,11 +9685,11 @@ dependencies = [ [[package]] name = "wayland-protocols" -version = "0.32.12" +version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-scanner", @@ -9939,7 +9701,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -9948,12 +9710,12 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.10" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml 0.39.4", + "quick-xml 0.41.0", "quote", ] @@ -9968,9 +9730,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -9986,6 +9748,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "webkit2gtk" version = "2.0.2" @@ -10032,9 +9806,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] @@ -10061,7 +9835,7 @@ checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -10070,7 +9844,7 @@ version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.20", "windows 0.61.3", "windows-core 0.61.2", ] @@ -10083,11 +9857,11 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "whoami" -version = "1.5.2" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "372d5b87f58ec45c384ba03563b03544dc5fadc3983e434b286913f5b4a9bb6d" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" dependencies = [ - "redox_syscall 0.5.18", + "libredox", "wasite", "web-sys", ] @@ -10243,7 +10017,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -10254,7 +10028,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -10616,9 +10390,15 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -10648,7 +10428,7 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12dafb3c1468d0a3f5440e21e51614b53d1fdc62c9f82cc861c447906d09c69a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crypto-bigint 0.7.5", "flate2", "iso7816", @@ -10676,91 +10456,9 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.51.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap 2.13.0", - "prettyplease", - "syn 2.0.114", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.114", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.1", - "indexmap 2.13.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.13.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "wl-clipboard-rs" @@ -10772,7 +10470,7 @@ dependencies = [ "log", "os_pipe", "rustix", - "thiserror 2.0.18", + "thiserror 2.0.20", "tree_magic_mini", "wayland-backend", "wayland-client", @@ -10793,30 +10491,29 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wry" -version = "0.54.1" +version = "0.55.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ed1a195b0375491dd15a7066a10251be217ce743cf4bbbbdcf5391d6473bee0" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" dependencies = [ "base64 0.22.1", "block2", "cookie", "crossbeam-channel", "dirs", + "dom_query", "dpi", "dunce", "gdkx11", "gtk", - "html5ever", "http", "javascriptcore-rs", "jni 0.21.1", - "kuchikiki", "libc", "ndk", "objc2", @@ -10831,7 +10528,7 @@ dependencies = [ "sha2 0.10.9", "soup3", "tao-macros", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", "webkit2gtk", "webkit2gtk-sys", @@ -10896,7 +10593,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eee64e8620caa64914d669b1f68f858aaff54e2d0f9ad3b30a613b58a1baa83e" dependencies = [ "curve25519-dalek 5.0.0-rc.1", - "rand_core 0.10.0", + "rand_core 0.10.1", "zeroize", ] @@ -10934,9 +10631,9 @@ dependencies = [ [[package]] name = "yeslogic-fontconfig-sys" -version = "6.0.0" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503a066b4c037c440169d995b869046827dbc71263f6e8f3be6d77d4f3229dbd" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" dependencies = [ "dlib", "once_cell", @@ -10945,9 +10642,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -10956,13 +10653,13 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", "synstructure", ] @@ -10993,10 +10690,10 @@ dependencies = [ "hex", "nix 0.29.0", "ordered-stream", - "rand 0.8.5", + "rand 0.8.8", "serde", "serde_repr", - "sha1 0.10.6", + "sha1 0.10.7", "static_assertions", "tracing", "uds_windows", @@ -11009,9 +10706,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.13.2" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ "async-broadcast", "async-executor", @@ -11036,10 +10733,10 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 0.7.14", - "zbus_macros 5.13.2", - "zbus_names 4.3.1", - "zvariant 5.9.2", + "winnow 1.0.4", + "zbus_macros 5.19.0", + "zbus_names 4.3.4", + "zvariant 5.15.0", ] [[package]] @@ -11048,26 +10745,26 @@ version = "4.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", "zvariant_utils 2.1.0", ] [[package]] name = "zbus_macros" -version = "5.13.2" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.114", - "zbus_names 4.3.1", - "zvariant 5.9.2", - "zvariant_utils 3.3.0", + "syn 3.0.4", + "zbus_names 4.3.4", + "zvariant 5.15.0", + "zvariant_utils 4.2.0", ] [[package]] @@ -11083,53 +10780,62 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.1" +version = "4.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant 5.15.0", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" dependencies = [ "serde", - "winnow 0.7.14", - "zvariant 5.9.2", ] [[package]] name = "zerocopy" -version = "0.8.39" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.39" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", "synstructure", ] @@ -11150,14 +10856,14 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -11166,9 +10872,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -11177,13 +10883,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.4", ] [[package]] @@ -11194,30 +10900,30 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" dependencies = [ "arbitrary", "crc32fast", - "indexmap 2.13.0", + "indexmap 2.14.1", "memchr", ] [[package]] name = "zip" -version = "8.2.0" +version = "8.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b680f2a0cd479b4cff6e1233c483fdead418106eae419dc60200ae9850f6d004" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" dependencies = [ - "aes 0.8.4", + "aes 0.9.3", "bzip2", "constant_time_eq", "crc32fast", "deflate64", "flate2", - "getrandom 0.4.1", - "hmac 0.12.1", - "indexmap 2.13.0", + "getrandom 0.4.3", + "hmac 0.13.0", + "indexmap 2.14.1", "lzma-rust2", "memchr", - "pbkdf2 0.12.2", + "pbkdf2 0.13.0", "ppmd-rust", - "sha1 0.10.6", + "sha1 0.11.0", "time", "typed-path", "zeroize", @@ -11227,21 +10933,21 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.3" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zmij" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zmodem2" version = "0.5.0" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crc32fast", "hex", "thiserror 1.0.69", @@ -11289,9 +10995,9 @@ dependencies = [ [[package]] name = "zune-core" -version = "0.5.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" [[package]] name = "zune-jpeg" @@ -11317,16 +11023,17 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.9.2" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" dependencies = [ "endi", "enumflags2", "serde", - "winnow 0.7.14", - "zvariant_derive 5.9.2", - "zvariant_utils 3.3.0", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive 5.15.0", + "zvariant_utils 4.2.0", ] [[package]] @@ -11335,24 +11042,24 @@ version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", "zvariant_utils 2.1.0", ] [[package]] name = "zvariant_derive" -version = "5.9.2" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.114", - "zvariant_utils 3.3.0", + "syn 3.0.4", + "zvariant_utils 4.2.0", ] [[package]] @@ -11363,18 +11070,18 @@ checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "zvariant_utils" -version = "3.3.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.114", - "winnow 0.7.14", + "syn 3.0.4", + "winnow 1.0.4", ] From 1c115b24632867c8afc1e6fc5b6e6dc9f7c2b27f Mon Sep 17 00:00:00 2001 From: agogo233 <276149387+agogo233@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:21:02 +0000 Subject: [PATCH 19/32] feat: add context menu action to save terminal selection as quick command --- src/components/terminal/TerminalContextMenu.tsx | 17 ++++++++++++++++- src/i18n/locales/en.json | 1 + src/i18n/locales/ko.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/i18n/locales/zh-TW.json | 1 + src/lib/windowManager.ts | 10 +++++++++- src/pages/QuickCommandPage.tsx | 2 +- 7 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/components/terminal/TerminalContextMenu.tsx b/src/components/terminal/TerminalContextMenu.tsx index 6f3bcf64..77d8ee60 100644 --- a/src/components/terminal/TerminalContextMenu.tsx +++ b/src/components/terminal/TerminalContextMenu.tsx @@ -3,6 +3,7 @@ import type { Terminal } from "@xterm/xterm"; import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { + MdAddCircleOutline, MdAutoAwesome, MdClearAll, MdContentCopy, @@ -26,7 +27,7 @@ import { writeClipboardText } from "@/lib/clipboard"; import { normalizeTerminalRightClickAction } from "@/lib/interactionSettings"; import { invoke } from "@/lib/invoke"; import { sendTerminalClearInput } from "@/lib/terminalControlInput"; -import { openSettings } from "@/lib/windowManager"; +import { openQuickCommand, openSettings } from "@/lib/windowManager"; import type { RecordingMode, RecordingStatus, SearchEngine } from "@/types/global"; import TranslationDialog from "../dialog/terminal/TranslationDialog"; import { type QuickIconDef, SEARCH_ICONS } from "../icons"; @@ -260,6 +261,20 @@ export default function TerminalContextMenu({ {t("terminalCtx.find")} {dk("terminal.find")} + {ctxSelection.text.trim().length > 0 && ( + + openQuickCommand( + JSON.stringify({ + command: ctxSelection.text.trim().slice(0, 10000), + }), + ) + } + > + + {t("terminalCtx.saveAsQuickCommand")} + + )} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index dadbd06f..e9ae74b5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2792,6 +2792,7 @@ "pasteSelectedText": "Paste Selected Text", "recordingLogs": "Recording Logs", "recordingSettings": "Settings...", + "saveAsQuickCommand": "Set as Quick Command", "searchCaseSensitive": "Case sensitive", "searchCurrentBuffer": "Current Buffer", "searchDeepHistory": "Deep History", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index b2935894..3d3e1fa6 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -2783,6 +2783,7 @@ "pasteSelectedText": "선택한 텍스트 붙여넣기", "recordingLogs": "녹화 로그", "recordingSettings": "설정...", + "saveAsQuickCommand": "빠른 명령으로 저장", "searchCaseSensitive": "대소문자 구분", "searchCurrentBuffer": "현재 버퍼", "searchDeepHistory": "전체 기록", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index eca02f68..3ece9711 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2791,6 +2791,7 @@ "pasteSelectedText": "粘贴选定的文本", "recordingLogs": "录制日志", "recordingSettings": "设置...", + "saveAsQuickCommand": "设为快捷命令", "searchCaseSensitive": "区分大小写", "searchCurrentBuffer": "当前缓冲区", "searchDeepHistory": "深度历史", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 85e3dac9..c8b710a4 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -2786,6 +2786,7 @@ "pasteSelectedText": "貼上選取的文字", "recordingLogs": "錄製日誌", "recordingSettings": "設定...", + "saveAsQuickCommand": "設為快捷命令", "searchCaseSensitive": "區分大小寫", "searchCurrentBuffer": "目前緩衝區", "searchDeepHistory": "深度歷史", diff --git a/src/lib/windowManager.ts b/src/lib/windowManager.ts index f2497f8f..a4fc19fa 100644 --- a/src/lib/windowManager.ts +++ b/src/lib/windowManager.ts @@ -875,10 +875,18 @@ export function openQuickCommand(editJson?: string, options?: { categoryId?: str } else if (options?.categoryId) { params.set("category_id", options.categoryId); } + let isEdit = false; + if (editJson) { + try { + isEdit = !!JSON.parse(editJson).id; + } catch { + isEdit = false; + } + } const url = `index.html?${params.toString()}`; return openChildWindow({ label: scopedModalLabel("quick-command"), - title: i18n.t(editJson ? "quickCommands.editCommand" : "quickCommands.addCommand"), + title: i18n.t(isEdit ? "quickCommands.editCommand" : "quickCommands.addCommand"), url, parentLabel: ownerMainWindowLabel, width: 540, diff --git a/src/pages/QuickCommandPage.tsx b/src/pages/QuickCommandPage.tsx index f20d5cb1..4c16a07f 100644 --- a/src/pages/QuickCommandPage.tsx +++ b/src/pages/QuickCommandPage.tsx @@ -383,7 +383,7 @@ export default function QuickCommandPage() {
Date: Mon, 31 Aug 2026 17:04:20 +0800 Subject: [PATCH 20/32] feat(mcp): refactor Windows ACL handling and add tests for discovery functionality - Moved Windows ACL logic from `discovery.rs` to a new `windows_acl.rs` module for better organization and maintainability. - Implemented `set_current_user_only` function to apply current user-only ACLs for files and directories. - Added unit tests to ensure that the discovery process preserves private ACLs and cleans up temporary files correctly. - Updated `mod.rs` to include the new `windows_acl` module conditionally for Windows builds. --- src-tauri/src/core/mcp/discovery.rs | 102 ++++----- src-tauri/src/core/mcp/mod.rs | 2 + src-tauri/src/core/mcp/windows_acl.rs | 315 ++++++++++++++++++++++++++ 3 files changed, 366 insertions(+), 53 deletions(-) create mode 100644 src-tauri/src/core/mcp/windows_acl.rs diff --git a/src-tauri/src/core/mcp/discovery.rs b/src-tauri/src/core/mcp/discovery.rs index 06af269e..30eadb16 100644 --- a/src-tauri/src/core/mcp/discovery.rs +++ b/src-tauri/src/core/mcp/discovery.rs @@ -117,59 +117,7 @@ fn set_private_file_permissions(path: &Path) -> AppResult<()> { #[cfg(windows)] fn set_windows_current_user_acl(path: &Path, directory: bool) -> AppResult<()> { - use std::os::windows::process::CommandExt; - - // Build a protected DACL from the current token SID instead of a user name. Starting - // from a fresh ACL also removes explicit grants that may have existed on a stale runtime - // directory, so another local account cannot inherit or retain access to the credential. - const SCRIPT: &str = r#" -$ErrorActionPreference = 'Stop' -$target = $args[0] -$isDirectory = $args[1] -eq '1' -$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User -$acl = if ($isDirectory) { - [System.Security.AccessControl.DirectorySecurity]::new() -} else { - [System.Security.AccessControl.FileSecurity]::new() -} -$acl.SetOwner($sid) -$acl.SetAccessRuleProtection($true, $false) -$inheritance = if ($isDirectory) { - [System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit -} else { - [System.Security.AccessControl.InheritanceFlags]::None -} -$rule = [System.Security.AccessControl.FileSystemAccessRule]::new( - $sid, - [System.Security.AccessControl.FileSystemRights]::FullControl, - $inheritance, - [System.Security.AccessControl.PropagationFlags]::None, - [System.Security.AccessControl.AccessControlType]::Allow -) -$acl.SetAccessRule($rule) -Set-Acl -LiteralPath $target -AclObject $acl -"#; - let output = std::process::Command::new("powershell.exe") - .args([ - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-Command", - SCRIPT, - ]) - .arg(path) - .arg(if directory { "1" } else { "0" }) - .creation_flags(0x0800_0000) - .output()?; - if output.status.success() { - Ok(()) - } else { - Err(AppError::Config( - "Failed to apply a current-user-only ACL to MCP discovery data.".into(), - )) - } + super::windows_acl::set_current_user_only(path, directory) } #[cfg(not(any(unix, windows)))] @@ -194,4 +142,52 @@ mod tests { store.remove().unwrap(); let _ = std::fs::remove_dir_all(root); } + + #[cfg(windows)] + #[test] + fn discovery_replacement_preserves_private_acl_and_removes_temporary_files() { + let root = std::env::temp_dir().join(format!( + "nyaterm-mcp-discovery-test-{}", + uuid::Uuid::new_v4() + )); + let store = DiscoveryStore::new(&root); + let first = discovery_document("first-token", "first-generation"); + let second = discovery_document("second-token", "second-generation"); + + store.write(&first).unwrap(); + store.write(&second).unwrap(); + + let actual: DiscoveryDocument = + serde_json::from_slice(&std::fs::read(&store.file).unwrap()).unwrap(); + assert_eq!(actual.token, second.token); + assert_eq!(actual.generation, second.generation); + super::super::windows_acl::assert_current_user_only(&store.directory, true); + super::super::windows_acl::assert_current_user_only(&store.file, false); + + let temporary_files = std::fs::read_dir(&store.directory) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| { + let name = entry.file_name(); + let name = name.to_string_lossy(); + name.starts_with(".discovery-") && name.ends_with(".tmp") + }) + .count(); + assert_eq!(temporary_files, 0); + + let _ = std::fs::remove_dir_all(root); + } + + #[cfg(windows)] + fn discovery_document(token: &str, generation: &str) -> DiscoveryDocument { + DiscoveryDocument { + version: 1, + pid: std::process::id(), + host: "127.0.0.1".into(), + port: 47_123, + token: token.into(), + generation: generation.into(), + permission_mode: "read-only".into(), + } + } } diff --git a/src-tauri/src/core/mcp/mod.rs b/src-tauri/src/core/mcp/mod.rs index 1ed63650..85f51103 100644 --- a/src-tauri/src/core/mcp/mod.rs +++ b/src-tauri/src/core/mcp/mod.rs @@ -1,6 +1,8 @@ mod approval; mod discovery; mod host; +#[cfg(windows)] +mod windows_acl; pub use approval::ApprovalDecision; pub use host::{EphemeralMcpCredential, McpClientConfigs, McpManager, McpRuntimeStatus}; diff --git a/src-tauri/src/core/mcp/windows_acl.rs b/src-tauri/src/core/mcp/windows_acl.rs new file mode 100644 index 00000000..e78cc521 --- /dev/null +++ b/src-tauri/src/core/mcp/windows_acl.rs @@ -0,0 +1,315 @@ +use std::ffi::c_void; +use std::mem::size_of; +use std::os::windows::ffi::OsStrExt; +use std::path::Path; +use std::ptr::{null, null_mut}; + +use windows_sys::Win32::Foundation::{ + CloseHandle, ERROR_INSUFFICIENT_BUFFER, ERROR_SUCCESS, GetLastError, HANDLE, LocalFree, +}; +use windows_sys::Win32::Security::Authorization::{ + EXPLICIT_ACCESS_W, NO_MULTIPLE_TRUSTEE, SE_FILE_OBJECT, SET_ACCESS, SetEntriesInAclW, + SetNamedSecurityInfoW, TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W, +}; +use windows_sys::Win32::Security::{ + ACL, CONTAINER_INHERIT_ACE, DACL_SECURITY_INFORMATION, GetTokenInformation, IsValidSid, + NO_INHERITANCE, OBJECT_INHERIT_ACE, OWNER_SECURITY_INFORMATION, + PROTECTED_DACL_SECURITY_INFORMATION, PSID, TOKEN_QUERY, TOKEN_USER, TokenUser, +}; +use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS; +use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + +use crate::error::{AppError, AppResult}; + +pub(super) fn set_current_user_only(path: &Path, directory: bool) -> AppResult<()> { + let user = current_user_sid(path)?; + let acl = create_private_acl(path, user.sid(), directory)?; + let path_wide = wide_path(path)?; + let security_information = OWNER_SECURITY_INFORMATION + | DACL_SECURITY_INFORMATION + | PROTECTED_DACL_SECURITY_INFORMATION; + let status = unsafe { + SetNamedSecurityInfoW( + path_wide.as_ptr(), + SE_FILE_OBJECT, + security_information, + user.sid(), + null_mut(), + acl.as_ptr(), + null(), + ) + }; + if status != ERROR_SUCCESS { + return Err(status_error("SetNamedSecurityInfoW", path, status)); + } + Ok(()) +} + +fn current_user_sid(path: &Path) -> AppResult { + let mut raw_token: HANDLE = null_mut(); + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut raw_token) } == 0 { + return Err(last_error("OpenProcessToken", path)); + } + let token = OwnedHandle(raw_token); + + let mut required_length = 0; + let initial_result = + unsafe { GetTokenInformation(token.0, TokenUser, null_mut(), 0, &mut required_length) }; + if initial_result != 0 { + return Err(AppError::Config(format!( + "Failed to query the current user SID for MCP discovery data during \ + GetTokenInformation(size) for '{}': unexpected result", + path.display() + ))); + } + let error = unsafe { GetLastError() }; + if error != ERROR_INSUFFICIENT_BUFFER || required_length == 0 { + return Err(status_error("GetTokenInformation(size)", path, error)); + } + + // TOKEN_USER contains pointer-sized fields. A usize buffer keeps the allocation aligned + // while still providing the variable-length storage required for the trailing SID. + let word_count = (required_length as usize).div_ceil(size_of::()); + let mut buffer = vec![0usize; word_count]; + let buffer_length = required_length; + if unsafe { + GetTokenInformation( + token.0, + TokenUser, + buffer.as_mut_ptr().cast(), + buffer_length, + &mut required_length, + ) + } == 0 + { + return Err(last_error("GetTokenInformation(TokenUser)", path)); + } + + let token_user = unsafe { &*buffer.as_ptr().cast::() }; + let sid = token_user.User.Sid; + if sid.is_null() || unsafe { IsValidSid(sid) } == 0 { + return Err(AppError::Config(format!( + "Failed to query the current user SID for MCP discovery data during \ + GetTokenInformation(TokenUser) for '{}': Windows returned an invalid SID", + path.display() + ))); + } + + Ok(CurrentUserSid { + _buffer: buffer, + sid, + }) +} + +fn create_private_acl(path: &Path, sid: PSID, directory: bool) -> AppResult> { + let inheritance = if directory { + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE + } else { + NO_INHERITANCE + }; + let trustee = TRUSTEE_W { + pMultipleTrustee: null_mut(), + MultipleTrusteeOperation: NO_MULTIPLE_TRUSTEE, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_USER, + ptstrName: sid.cast(), + }; + let access = EXPLICIT_ACCESS_W { + grfAccessPermissions: FILE_ALL_ACCESS, + grfAccessMode: SET_ACCESS, + grfInheritance: inheritance, + Trustee: trustee, + }; + let mut raw_acl: *mut ACL = null_mut(); + let status = unsafe { SetEntriesInAclW(1, &access, null(), &mut raw_acl) }; + if status != ERROR_SUCCESS { + if !raw_acl.is_null() { + drop(LocalAllocation(raw_acl)); + } + return Err(status_error("SetEntriesInAclW", path, status)); + } + if raw_acl.is_null() { + return Err(AppError::Config(format!( + "Failed to build a current-user-only ACL for MCP discovery data during \ + SetEntriesInAclW for '{}': Windows returned a null ACL", + path.display() + ))); + } + Ok(LocalAllocation(raw_acl)) +} + +fn wide_path(path: &Path) -> AppResult> { + let mut wide = path.as_os_str().encode_wide().collect::>(); + if wide.contains(&0) { + return Err(AppError::Config(format!( + "Failed to apply a current-user-only ACL to MCP discovery data: path contains an \ + embedded NUL: '{}'", + path.display() + ))); + } + wide.push(0); + Ok(wide) +} + +fn last_error(operation: &str, path: &Path) -> AppError { + status_error(operation, path, unsafe { GetLastError() }) +} + +fn status_error(operation: &str, path: &Path, status: u32) -> AppError { + let source = std::io::Error::from_raw_os_error(status as i32); + AppError::Config(format!( + "Failed to apply a current-user-only ACL to MCP discovery data during {operation} for \ + '{}': win32_error={status} ({source})", + path.display() + )) +} + +struct CurrentUserSid { + _buffer: Vec, + sid: PSID, +} + +impl CurrentUserSid { + const fn sid(&self) -> PSID { + self.sid + } +} + +struct OwnedHandle(HANDLE); + +impl Drop for OwnedHandle { + fn drop(&mut self) { + if !self.0.is_null() { + let _ = unsafe { CloseHandle(self.0) }; + } + } +} + +struct LocalAllocation(*mut T); + +impl LocalAllocation { + const fn as_ptr(&self) -> *mut T { + self.0 + } +} + +impl Drop for LocalAllocation { + fn drop(&mut self) { + if !self.0.is_null() { + let _ = unsafe { LocalFree(self.0.cast::()) }; + } + } +} + +#[cfg(test)] +pub(super) fn assert_current_user_only(path: &Path, directory: bool) { + use windows_sys::Win32::Security::Authorization::GetNamedSecurityInfoW; + use windows_sys::Win32::Security::{ + ACCESS_ALLOWED_ACE, ACL_SIZE_INFORMATION, AclSizeInformation, EqualSid, GetAce, + GetAclInformation, GetSecurityDescriptorControl, PSECURITY_DESCRIPTOR, SE_DACL_PROTECTED, + }; + + const ACCESS_ALLOWED_ACE_TYPE: u8 = 0; + + let user = current_user_sid(path).unwrap(); + let path_wide = wide_path(path).unwrap(); + let mut owner: PSID = null_mut(); + let mut dacl: *mut ACL = null_mut(); + let mut security_descriptor: PSECURITY_DESCRIPTOR = null_mut(); + let status = unsafe { + GetNamedSecurityInfoW( + path_wide.as_ptr(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &mut owner, + null_mut(), + &mut dacl, + null_mut(), + &mut security_descriptor, + ) + }; + assert_eq!( + status, + ERROR_SUCCESS, + "{}", + status_error("GetNamedSecurityInfoW", path, status) + ); + let _security_descriptor = LocalAllocation(security_descriptor); + assert!(!security_descriptor.is_null()); + assert!(!owner.is_null()); + assert!(!dacl.is_null()); + assert_ne!(unsafe { EqualSid(owner, user.sid()) }, 0); + + let mut control = 0; + let mut revision = 0; + assert_ne!( + unsafe { GetSecurityDescriptorControl(security_descriptor, &mut control, &mut revision) }, + 0 + ); + assert_ne!(control & SE_DACL_PROTECTED, 0); + + let mut acl_info = ACL_SIZE_INFORMATION::default(); + assert_ne!( + unsafe { + GetAclInformation( + dacl, + (&raw mut acl_info).cast(), + size_of::() as u32, + AclSizeInformation, + ) + }, + 0 + ); + assert_eq!(acl_info.AceCount, 1); + + let mut raw_ace: *mut c_void = null_mut(); + assert_ne!(unsafe { GetAce(dacl, 0, &mut raw_ace) }, 0); + let ace = raw_ace.cast::(); + assert_eq!(unsafe { (*ace).Header.AceType }, ACCESS_ALLOWED_ACE_TYPE); + assert_eq!(unsafe { (*ace).Mask }, FILE_ALL_ACCESS); + let expected_flags = if directory { + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE + } else { + NO_INHERITANCE + }; + assert_eq!(u32::from(unsafe { (*ace).Header.AceFlags }), expected_flags); + let ace_sid = unsafe { + std::ptr::addr_of!((*ace).SidStart) + .cast_mut() + .cast::() + }; + assert_ne!(unsafe { EqualSid(ace_sid, user.sid()) }, 0); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn applies_current_user_only_acl_to_directory_and_file() { + let root = + std::env::temp_dir().join(format!("nyaterm-windows-acl-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + set_current_user_only(&root, true).unwrap(); + + let file = root.join("discovery.json"); + std::fs::write(&file, b"{}").unwrap(); + set_current_user_only(&file, false).unwrap(); + + assert_current_user_only(&root, true); + assert_current_user_only(&file, false); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn missing_path_returns_contextual_win32_error() { + let path = std::env::temp_dir() + .join(format!("nyaterm-missing-acl-test-{}", uuid::Uuid::new_v4())) + .join("discovery.json"); + let error = set_current_user_only(&path, false).unwrap_err(); + let message = error.to_string(); + assert!(message.contains("SetNamedSecurityInfoW")); + assert!(message.contains("win32_error=")); + assert!(message.contains(&path.display().to_string())); + } +} From 4a88716a586667359c74f285a75df8095eb0eb2c Mon Sep 17 00:00:00 2001 From: Kang Date: Mon, 31 Aug 2026 17:04:32 +0800 Subject: [PATCH 21/32] chore(dependencies): add Windows security features to Cargo.toml - Included additional Windows features "Win32_Security" and "Win32_Security_Authorization" in the Cargo.toml file to enhance security capabilities for the application. --- src-tauri/Cargo.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b499d936..cac634a7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -211,6 +211,8 @@ webview2-com = "0.38.2" window-vibrancy = "0.6" windows-sys = { version = "0.61", features = [ "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_LibraryLoader", "Win32_System_Threading", From 576d1899d58b6d3c578f328013cb389f7e4cb933 Mon Sep 17 00:00:00 2001 From: Kang Date: Mon, 31 Aug 2026 21:12:30 +0800 Subject: [PATCH 22/32] feat(mcp): implement MCP session open request handling and permission modes - Added support for handling MCP session open requests, including the ability to respond to session creation with success or error messages. - Introduced new permission modes for AI interactions, including "full_access", and updated the permission handling logic to accommodate these changes. - Refactored the AiPermissionSelect component to manage permission modes more effectively and added corresponding tests to ensure functionality. - Updated translations for new permission modes and improved the UI to reflect changes in session handling. --- .../crates/nyaterm-mcp-protocol/src/lib.rs | 30 + src-tauri/crates/nyaterm-mcp/src/main.rs | 103 +++- src-tauri/src/cmd/mcp.rs | 34 +- src-tauri/src/cmd/settings.rs | 5 - src-tauri/src/config/mod.rs | 13 +- src-tauri/src/config/settings/ai.rs | 58 +- src-tauri/src/config/settings/mod.rs | 6 +- src-tauri/src/core/ai/agent.rs | 47 +- src-tauri/src/core/ai/external/claude_code.rs | 18 + src-tauri/src/core/capabilities/policy.rs | 71 +++ src-tauri/src/core/mcp/approval.rs | 2 + src-tauri/src/core/mcp/host.rs | 545 +++++++++++++----- src-tauri/src/lib.rs | 1 + src/App.tsx | 109 +++- src/components/dialog/app/McpApprovalHost.tsx | 8 +- .../settings/AiPermissionSelect.test.ts | 15 + .../settings/AiPermissionSelect.tsx | 99 ++++ src/components/settings/AiTab.tsx | 96 ++- src/i18n/mcpApprovalTranslations.test.ts | 34 ++ src/lib/aiSettings.test.ts | 12 +- src/lib/aiSettings.ts | 2 - src/types/global.d.ts | 18 +- 22 files changed, 1047 insertions(+), 279 deletions(-) create mode 100644 src/components/settings/AiPermissionSelect.test.ts create mode 100644 src/components/settings/AiPermissionSelect.tsx diff --git a/src-tauri/crates/nyaterm-mcp-protocol/src/lib.rs b/src-tauri/crates/nyaterm-mcp-protocol/src/lib.rs index b29d1b15..b5f4b630 100644 --- a/src-tauri/crates/nyaterm-mcp-protocol/src/lib.rs +++ b/src-tauri/crates/nyaterm-mcp-protocol/src/lib.rs @@ -10,6 +10,8 @@ pub const MAX_TEXT_WRITE_BYTES: usize = 1024 * 1024; pub mod capability { pub const ENVIRONMENT: &str = "session.environment"; + pub const CONNECTION_LIST: &str = "connection.list"; + pub const SESSION_OPEN: &str = "session.open"; pub const SESSION_GET: &str = "session.get"; pub const TERMINAL_EXECUTE: &str = "terminal.execute"; pub const TERMINAL_RECENT_OUTPUT: &str = "terminal.recent_output"; @@ -27,6 +29,8 @@ pub mod capability { pub mod tool { pub const GET_ENVIRONMENT: &str = "get_environment"; + pub const CONNECTION_LIST: &str = "connection_list"; + pub const SESSION_OPEN: &str = "session_open"; pub const SESSION_GET: &str = "session_get"; pub const TERMINAL_EXECUTE: &str = "terminal_execute"; pub const TERMINAL_RECENT_OUTPUT: &str = "terminal_recent_output"; @@ -73,6 +77,26 @@ pub const MCP_TOOL_REGISTRY: &[McpToolDefinition] = &[ destructive_hint: false, open_world_hint: false, }, + McpToolDefinition { + tool: tool::CONNECTION_LIST, + capability: capability::CONNECTION_LIST, + description: "List saved terminal connections using safe metadata only.", + access: CapabilityAccess::Read, + requires_session: false, + read_only_hint: true, + destructive_hint: false, + open_world_hint: false, + }, + McpToolDefinition { + tool: tool::SESSION_OPEN, + capability: capability::SESSION_OPEN, + description: "Open a new NyaTerm terminal session from a saved connection.", + access: CapabilityAccess::Write, + requires_session: false, + read_only_hint: false, + destructive_hint: false, + open_world_hint: true, + }, McpToolDefinition { tool: tool::SESSION_GET, capability: capability::SESSION_GET, @@ -287,6 +311,12 @@ pub struct SessionArgs { pub session_id: String, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SessionOpenArgs { + pub connection_id: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct PathArgs { diff --git a/src-tauri/crates/nyaterm-mcp/src/main.rs b/src-tauri/crates/nyaterm-mcp/src/main.rs index ff084d99..ac6a9420 100644 --- a/src-tauri/crates/nyaterm-mcp/src/main.rs +++ b/src-tauri/crates/nyaterm-mcp/src/main.rs @@ -6,7 +6,7 @@ use bridge::{BridgeClient, BridgeEndpoint, endpoint_from_environment_or_discover use nyaterm_mcp_protocol::{ EmptyArgs, MCP_TOOL_REGISTRY, McpToolDefinition, OutputReadArgs, PathArgs, SessionArgs, SftpChmodArgs, SftpMkdirArgs, SftpReadTextArgs, SftpRenameArgs, SftpWriteTextArgs, - TerminalExecuteArgs, TerminalRecentOutputArgs, tool, + SessionOpenArgs, TerminalExecuteArgs, TerminalRecentOutputArgs, tool, }; use rmcp::model::{ CallToolRequestParams, CallToolResponse, CallToolResult, Implementation, ListToolsResult, @@ -39,7 +39,7 @@ impl ServerHandler for NyaTermMcp { "nyaterm-mcp", env!("CARGO_PKG_VERSION"), )) - .with_instructions("Operate sessions already opened in NyaTerm. NyaTerm enforces session scope and approvals.") + .with_instructions("Discover saved terminal connections, open sessions in NyaTerm, and operate scoped sessions. NyaTerm enforces session scope and approvals.") } async fn call_tool( @@ -93,6 +93,8 @@ fn build_tools() -> Vec { .iter() .map(|definition| match definition.tool { tool::GET_ENVIRONMENT => tool_def::(definition), + tool::CONNECTION_LIST => tool_def::(definition), + tool::SESSION_OPEN => tool_def::(definition), tool::SESSION_GET | tool::SFTP_HOME => tool_def::(definition), tool::TERMINAL_EXECUTE => tool_def::(definition), tool::TERMINAL_RECENT_OUTPUT => tool_def::(definition), @@ -167,6 +169,29 @@ mod tests { definition.open_world_hint ); } + + let connection_list = tools + .iter() + .find(|item| item.name == tool::CONNECTION_LIST) + .expect("connection_list tool"); + let connection_list = serde_json::to_value(connection_list).unwrap(); + assert_eq!(connection_list["inputSchema"]["type"], "object"); + assert_eq!(connection_list["annotations"]["readOnlyHint"], true); + + let session_open = tools + .iter() + .find(|item| item.name == tool::SESSION_OPEN) + .expect("session_open tool"); + let session_open = serde_json::to_value(session_open).unwrap(); + assert_eq!( + session_open["inputSchema"]["required"], + json!(["connectionId"]) + ); + assert_eq!( + session_open["inputSchema"]["properties"]["connectionId"]["type"], + "string" + ); + assert_eq!(session_open["annotations"]["readOnlyHint"], false); } async fn send_client_message(writer: &mut WriteHalf, raw: &str) { @@ -215,10 +240,33 @@ mod tests { assert_eq!(request.params["name"], "integration-test"); json!({ "identified": true }) } - "capability.execute" => { - assert_eq!(request.params["tool"], tool::GET_ENVIRONMENT); - json!({ "defaultSessionId": "session-1", "sessions": [] }) - } + "capability.execute" => match request.params["tool"].as_str().unwrap() { + tool::GET_ENVIRONMENT => { + json!({ "defaultSessionId": "session-1", "sessions": [] }) + } + tool::CONNECTION_LIST => json!({ + "connections": [{ + "id": "connection-1", + "name": "Local shell", + "type": "local_terminal", + "groupPath": [] + }] + }), + tool::SESSION_OPEN => { + assert_eq!( + request.params["arguments"]["connectionId"], + "connection-1" + ); + json!({ + "sessionId": "session-2", + "connectionId": "connection-1", + "name": "Local shell", + "type": "local", + "connected": true + }) + } + other => panic!("unexpected tool: {other}"), + }, other => panic!("unexpected bridge method: {other}"), }; let response = RpcResponse { @@ -271,7 +319,7 @@ mod tests { ) .await; let listed = receive_response(&mut client_lines, 2).await; - assert_eq!(listed["result"]["tools"].as_array().unwrap().len(), 14); + assert_eq!(listed["result"]["tools"].as_array().unwrap().len(), 16); send_client_message( &mut client_writer, @@ -289,6 +337,47 @@ mod tests { "session-1" ); + send_client_message( + &mut client_writer, + r#"{ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { "name": "connection_list", "arguments": {} } + }"#, + ) + .await; + let connections = receive_response(&mut client_lines, 4).await; + assert_eq!( + connections["result"]["structuredContent"]["connections"][0]["id"], + "connection-1" + ); + + send_client_message( + &mut client_writer, + r#"{ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "session_open", + "arguments": { "connectionId": "connection-1" } + } + }"#, + ) + .await; + let opened = receive_response(&mut client_lines, 5).await; + assert_eq!( + opened["result"]["structuredContent"], + json!({ + "sessionId": "session-2", + "connectionId": "connection-1", + "name": "Local shell", + "type": "local", + "connected": true + }) + ); + drop(client_writer); drop(client_lines); server_task.abort(); diff --git a/src-tauri/src/cmd/mcp.rs b/src-tauri/src/cmd/mcp.rs index 53a32579..841f7574 100644 --- a/src-tauri/src/cmd/mcp.rs +++ b/src-tauri/src/cmd/mcp.rs @@ -39,17 +39,27 @@ pub async fn set_external_mcp_enabled( .inner() .configure_external(settings, &owner_window_label) .await?; - crate::storage::update_settings_doc( + if let Err(error) = crate::storage::update_settings_doc( crate::storage::SettingsDocKey::AppSettings, |stored: &mut crate::config::AppSettings| { stored.ai.external_mcp.enabled = true; Ok(()) }, - )?; + ) { + let _ = manager.disable_external(false).await; + return Err(error); + } let _ = app.emit("settings-changed", ()); Ok(status) } else { - manager.disable_external(true).await?; + if let Err(error) = manager.disable_external(true).await { + settings.enabled = true; + let _ = manager + .inner() + .configure_external(settings, &owner_window_label) + .await; + return Err(error); + } Ok(manager.status().await) } } @@ -83,6 +93,24 @@ pub async fn report_mcp_active_session( manager.set_active_session(window.label(), session_id).await } +#[tauri::command] +pub async fn respond_mcp_session_open( + window: tauri::WebviewWindow, + manager: tauri::State<'_, Arc>, + request_id: String, + session_id: Option, + error: Option, +) -> AppResult<()> { + if !crate::window_state::is_main_window_label(window.label()) { + return Err(AppError::Config( + "Only a NyaTerm main window can complete an MCP session-open request.".into(), + )); + } + manager + .respond_session_open(window.label(), &request_id, session_id, error) + .await +} + #[tauri::command] pub fn get_external_mcp_client_configs( manager: tauri::State<'_, Arc>, diff --git a/src-tauri/src/cmd/settings.rs b/src-tauri/src/cmd/settings.rs index 10f549c6..7180dccd 100644 --- a/src-tauri/src/cmd/settings.rs +++ b/src-tauri/src/cmd/settings.rs @@ -74,11 +74,6 @@ pub async fn save_app_settings( allow_master_password_change: Option, owner_window_label: Option, ) -> AppResult<()> { - if !(1..=120).contains(&settings.ai.external_mcp.idle_timeout_minutes) { - return Err(AppError::Config( - "External MCP idle timeout must be between 1 and 120 minutes.".into(), - )); - } let previous_mcp = config::load_app_settings(&app)?.ai.external_mcp; let next_mcp = settings.ai.external_mcp.clone(); let external_owner = if previous_mcp != next_mcp && next_mcp.enabled { diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs index 9127ece4..66c0ff93 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -82,13 +82,12 @@ pub use settings::{ AiModelSource, AiPermissionMode, AiProviderCredential, AiProviderKind, AiProviderProfile, AiReasoningEffort, AiSettings, AppSettings, AppearanceSettings, ClaudeCodeIntegrationSettings, CodexIntegrationSettings, CodexThreadMode, DiagnosticsLogLevel, DiagnosticsSettings, - ExternalMcpServerMode, ExternalMcpSessionScope, ExternalMcpSettings, GeneralSettings, - InteractionSettings, KeywordHighlightRule, ProxySettings, RecordingSettings, RiskLevel, - SearchEngine, SearchSettings, SecuritySettings, TerminalColorsConfig, TerminalSettings, - ThemeColorsConfig, ThemeConfig, TransferSettings, TranslationSettings, - ai_model_id_for_credential, ai_model_id_for_provider, decrypt_ai_settings, encrypt_ai_settings, - load_app_settings, mask_ai_settings, merge_masked_ai_settings, normalize_ai_settings, - save_app_settings, + ExternalMcpSessionScope, ExternalMcpSettings, GeneralSettings, InteractionSettings, + KeywordHighlightRule, ProxySettings, RecordingSettings, RiskLevel, SearchEngine, + SearchSettings, SecuritySettings, TerminalColorsConfig, TerminalSettings, ThemeColorsConfig, + ThemeConfig, TransferSettings, TranslationSettings, ai_model_id_for_credential, + ai_model_id_for_provider, decrypt_ai_settings, encrypt_ai_settings, load_app_settings, + mask_ai_settings, merge_masked_ai_settings, normalize_ai_settings, save_app_settings, }; #[allow(unused_imports)] pub use tunnel::{ diff --git a/src-tauri/src/config/settings/ai.rs b/src-tauri/src/config/settings/ai.rs index c6a692b8..449fcf57 100644 --- a/src-tauri/src/config/settings/ai.rs +++ b/src-tauri/src/config/settings/ai.rs @@ -72,6 +72,7 @@ pub enum AiPermissionMode { Observer, Confirm, Auto, + FullAccess, } impl Default for AiPermissionMode { @@ -93,19 +94,6 @@ impl Default for ExternalMcpSessionScope { } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ExternalMcpServerMode { - Temporary, - Persistent, -} - -impl Default for ExternalMcpServerMode { - fn default() -> Self { - Self::Temporary - } -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ExternalMcpSettings { #[serde(default)] @@ -114,10 +102,6 @@ pub struct ExternalMcpSettings { pub permission_mode: AiPermissionMode, #[serde(default)] pub session_scope: ExternalMcpSessionScope, - #[serde(default)] - pub server_mode: ExternalMcpServerMode, - #[serde(default = "default_external_mcp_idle_timeout_minutes")] - pub idle_timeout_minutes: u16, } impl Default for ExternalMcpSettings { @@ -126,16 +110,10 @@ impl Default for ExternalMcpSettings { enabled: false, permission_mode: AiPermissionMode::Confirm, session_scope: ExternalMcpSessionScope::CurrentWindow, - server_mode: ExternalMcpServerMode::Temporary, - idle_timeout_minutes: default_external_mcp_idle_timeout_minutes(), } } } -fn default_external_mcp_idle_timeout_minutes() -> u16 { - 10 -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum AiMode { @@ -738,8 +716,6 @@ pub fn normalize_ai_settings(settings: &mut AiSettings) -> bool { let original = serde_json::to_string(settings).unwrap_or_default(); settings.schema_version = 6; - settings.external_mcp.idle_timeout_minutes = - settings.external_mcp.idle_timeout_minutes.clamp(1, 120); if settings.request_user_agent.trim().is_empty() { settings.request_user_agent = default_request_user_agent(); } @@ -879,6 +855,38 @@ fn migrate_legacy_ollama_base_url(base_url: &mut Option) { mod tests { use super::*; + #[test] + fn legacy_external_mcp_mode_fields_are_read_but_not_written() { + let settings: ExternalMcpSettings = serde_json::from_value(serde_json::json!({ + "enabled": true, + "permission_mode": "confirm", + "session_scope": "current_window", + "server_mode": "temporary", + "idle_timeout_minutes": 10 + })) + .expect("legacy External MCP settings"); + + assert!(settings.enabled); + assert_eq!(settings.permission_mode, AiPermissionMode::Confirm); + assert_eq!( + settings.session_scope, + ExternalMcpSessionScope::CurrentWindow + ); + let serialized = serde_json::to_value(settings).expect("serialized External MCP settings"); + assert!(serialized.get("server_mode").is_none()); + assert!(serialized.get("idle_timeout_minutes").is_none()); + } + + #[test] + fn full_access_permission_mode_roundtrips_as_snake_case() { + let serialized = serde_json::to_string(&AiPermissionMode::FullAccess) + .expect("serialized permission mode"); + assert_eq!(serialized, "\"full_access\""); + let parsed: AiPermissionMode = + serde_json::from_str(&serialized).expect("parsed permission mode"); + assert_eq!(parsed, AiPermissionMode::FullAccess); + } + fn ollama_profile(settings: &AiSettings) -> &AiProviderProfile { settings .provider_profiles diff --git a/src-tauri/src/config/settings/mod.rs b/src-tauri/src/config/settings/mod.rs index 748c46ba..e9f5461e 100644 --- a/src-tauri/src/config/settings/mod.rs +++ b/src-tauri/src/config/settings/mod.rs @@ -16,9 +16,9 @@ pub use ai::{ AiBackendKind, AiCustomActionConfig, AiMode, AiModelConfigItem, AiModelSource, AiPermissionMode, AiProviderCredential, AiProviderKind, AiProviderProfile, AiReasoningEffort, AiSettings, ClaudeCodeIntegrationSettings, CodexIntegrationSettings, CodexThreadMode, - ExternalMcpServerMode, ExternalMcpSessionScope, ExternalMcpSettings, RiskLevel, - ai_model_id_for_credential, ai_model_id_for_provider, decrypt_ai_settings, encrypt_ai_settings, - mask_ai_settings, merge_masked_ai_settings, normalize_ai_settings, + ExternalMcpSessionScope, ExternalMcpSettings, RiskLevel, ai_model_id_for_credential, + ai_model_id_for_provider, decrypt_ai_settings, encrypt_ai_settings, mask_ai_settings, + merge_masked_ai_settings, normalize_ai_settings, }; pub use appearance::{AppearanceSettings, TerminalColorsConfig, ThemeColorsConfig, ThemeConfig}; pub use diagnostics::{DiagnosticsLogLevel, DiagnosticsSettings}; diff --git a/src-tauri/src/core/ai/agent.rs b/src-tauri/src/core/ai/agent.rs index 2d45a8f2..4d7dd2e3 100644 --- a/src-tauri/src/core/ai/agent.rs +++ b/src-tauri/src/core/ai/agent.rs @@ -368,6 +368,7 @@ fn safe_command_preview(command: &str) -> String { struct RiskAssessment { model_risk: RiskLevel, local_risk: RiskLevel, + local_auto_executable: bool, effective_risk: RiskLevel, risk_reason: Option, } @@ -574,14 +575,14 @@ fn risk_label(risk: &RiskLevel) -> &'static str { } } -fn assess_local_command_risk(command: &str) -> (RiskLevel, String) { +fn assess_local_command_risk(command: &str) -> (RiskLevel, String, bool) { let risk = crate::core::capabilities::assess_command_risk(command); - (risk.level, risk.reason) + (risk.level, risk.reason, risk.auto_executable) } fn assess_agent_command_risk(parsed: &AgentLlmResponse, command: &str) -> RiskAssessment { let model_risk = parsed.risk_level.clone().unwrap_or(RiskLevel::Medium); - let (local_risk, local_reason) = assess_local_command_risk(command); + let (local_risk, local_reason, local_auto_executable) = assess_local_command_risk(command); let effective_risk = max_risk(model_risk.clone(), local_risk.clone()); let risk_reason = parsed .risk_reason @@ -593,6 +594,7 @@ fn assess_agent_command_risk(parsed: &AgentLlmResponse, command: &str) -> RiskAs RiskAssessment { model_risk, local_risk, + local_auto_executable, effective_risk, risk_reason, } @@ -636,13 +638,23 @@ fn decide_agent_command_execution( fn decide_external_agent_command_execution( mode: &AiPermissionMode, + assessment: &RiskAssessment, ) -> (ApprovalDecision, Option) { match mode { AiPermissionMode::Observer | AiPermissionMode::Confirm => ( ApprovalDecision::NeedsApproval, Some("external agent permission mode requires confirmation".to_string()), ), - AiPermissionMode::Auto => (ApprovalDecision::Auto, None), + AiPermissionMode::Auto + if assessment.local_auto_executable && assessment.effective_risk < RiskLevel::High => + { + (ApprovalDecision::Auto, None) + } + AiPermissionMode::Auto => ( + ApprovalDecision::NeedsApproval, + Some("safe auto requires confirmation for unknown or high-risk commands".to_string()), + ), + AiPermissionMode::FullAccess => (ApprovalDecision::Auto, None), } } @@ -743,7 +755,8 @@ fn append_agent_command_audit( client: None, capability: None, session_id: None, - permission_mode: None, + permission_mode: (request.agent_kind != AiAgentKind::Nyaterm) + .then(|| request.permission_mode.clone()), approval_decision: None, success: None, duration_ms: None, @@ -782,7 +795,7 @@ pub(super) async fn run_external_agent_command_step( let (decision, approval_reason) = if request.agent_kind == AiAgentKind::Nyaterm { decide_agent_command_execution(settings, &assessment) } else { - decide_external_agent_command_execution(&request.permission_mode) + decide_external_agent_command_execution(&request.permission_mode, &assessment) }; if request.agent_kind != AiAgentKind::Nyaterm @@ -1412,16 +1425,32 @@ mod tests { #[test] fn external_agent_permission_modes_are_explicit() { + let safe = assess_agent_command_risk(&parsed_response(None), "ls -la"); + let high = assess_agent_command_risk(&parsed_response(None), "sudo reboot"); + let unknown = assess_agent_command_risk(&parsed_response(None), "custom-deploy production"); + assert_eq!( - decide_external_agent_command_execution(&AiPermissionMode::Observer).0, + decide_external_agent_command_execution(&AiPermissionMode::Observer, &safe).0, ApprovalDecision::NeedsApproval ); assert_eq!( - decide_external_agent_command_execution(&AiPermissionMode::Confirm).0, + decide_external_agent_command_execution(&AiPermissionMode::Confirm, &safe).0, ApprovalDecision::NeedsApproval ); assert_eq!( - decide_external_agent_command_execution(&AiPermissionMode::Auto).0, + decide_external_agent_command_execution(&AiPermissionMode::Auto, &safe).0, + ApprovalDecision::Auto + ); + assert_eq!( + decide_external_agent_command_execution(&AiPermissionMode::Auto, &high).0, + ApprovalDecision::NeedsApproval + ); + assert_eq!( + decide_external_agent_command_execution(&AiPermissionMode::Auto, &unknown).0, + ApprovalDecision::NeedsApproval + ); + assert_eq!( + decide_external_agent_command_execution(&AiPermissionMode::FullAccess, &high).0, ApprovalDecision::Auto ); } diff --git a/src-tauri/src/core/ai/external/claude_code.rs b/src-tauri/src/core/ai/external/claude_code.rs index 7b2a4973..1ebbe15f 100644 --- a/src-tauri/src/core/ai/external/claude_code.rs +++ b/src-tauri/src/core/ai/external/claude_code.rs @@ -419,6 +419,9 @@ fn claude_permission_mode(mode: &AiPermissionMode) -> &'static str { AiPermissionMode::Observer => "plan", AiPermissionMode::Confirm => "manual", AiPermissionMode::Auto => "auto", + // Full access only bypasses NyaTerm's own capability approvals. Do not + // widen Claude Code's native local-tool permissions. + AiPermissionMode::FullAccess => "auto", } } @@ -895,6 +898,21 @@ mod tests { ); } + #[test] + fn full_access_does_not_enable_claude_native_permission_bypass() { + let mut request = test_request(); + request.permission_mode = AiPermissionMode::FullAccess; + + let invocation = + build_claude_invocation(&request, &AiSettings::default(), "prompt".to_string()); + + assert_eq!( + arg_value(&invocation.args, "--permission-mode"), + Some("auto") + ); + assert!(!invocation.args.iter().any(|arg| arg == "bypassPermissions")); + } + #[test] fn extracts_delta_from_partial_message() { let mut last = String::new(); diff --git a/src-tauri/src/core/capabilities/policy.rs b/src-tauri/src/core/capabilities/policy.rs index 4865a4b0..787c8246 100644 --- a/src-tauri/src/core/capabilities/policy.rs +++ b/src-tauri/src/core/capabilities/policy.rs @@ -21,6 +21,9 @@ pub fn decide_policy( access: CapabilityAccess, assessment: Option<&RiskAssessment>, ) -> PolicyDecision { + if *mode == AiPermissionMode::FullAccess { + return PolicyDecision::Allow; + } if matches!( access, CapabilityAccess::Write | CapabilityAccess::DestructiveWrite @@ -38,6 +41,7 @@ pub fn decide_policy( return PolicyDecision::RequireApproval; } match (mode, access) { + (AiPermissionMode::FullAccess, _) => PolicyDecision::Allow, (_, CapabilityAccess::Read) => PolicyDecision::Allow, (AiPermissionMode::Auto, CapabilityAccess::SensitiveRead | CapabilityAccess::Write) => { PolicyDecision::Allow @@ -562,6 +566,17 @@ mod tests { let safe = risk(RiskLevel::Medium, "known write", true); let unknown = risk(RiskLevel::Medium, "unknown", false); let high = risk(RiskLevel::High, "high", false); + for mode in [ + AiPermissionMode::Observer, + AiPermissionMode::Confirm, + AiPermissionMode::Auto, + AiPermissionMode::FullAccess, + ] { + assert_eq!( + decide_policy(&mode, CapabilityAccess::Read, None), + PolicyDecision::Allow + ); + } assert_eq!( decide_policy(&AiPermissionMode::Observer, CapabilityAccess::Write, None), PolicyDecision::Deny @@ -574,6 +589,30 @@ mod tests { ), PolicyDecision::RequireApproval ); + assert_eq!( + decide_policy( + &AiPermissionMode::Confirm, + CapabilityAccess::SensitiveRead, + None + ), + PolicyDecision::RequireApproval + ); + assert_eq!( + decide_policy( + &AiPermissionMode::Auto, + CapabilityAccess::SensitiveRead, + None + ), + PolicyDecision::Allow + ); + assert_eq!( + decide_policy( + &AiPermissionMode::Confirm, + CapabilityAccess::Write, + Some(&safe) + ), + PolicyDecision::RequireApproval + ); assert_eq!( decide_policy( &AiPermissionMode::Auto, @@ -606,6 +645,38 @@ mod tests { ), PolicyDecision::RequireApproval ); + assert_eq!( + decide_policy( + &AiPermissionMode::Observer, + CapabilityAccess::DestructiveWrite, + None + ), + PolicyDecision::Deny + ); + assert_eq!( + decide_policy( + &AiPermissionMode::Confirm, + CapabilityAccess::DestructiveWrite, + None + ), + PolicyDecision::RequireApproval + ); + assert_eq!( + decide_policy( + &AiPermissionMode::FullAccess, + CapabilityAccess::DestructiveWrite, + Some(&high) + ), + PolicyDecision::Allow + ); + assert_eq!( + decide_policy( + &AiPermissionMode::FullAccess, + CapabilityAccess::Write, + Some(&unknown) + ), + PolicyDecision::Allow + ); } #[test] diff --git a/src-tauri/src/core/mcp/approval.rs b/src-tauri/src/core/mcp/approval.rs index 7b659e36..cef12cad 100644 --- a/src-tauri/src/core/mcp/approval.rs +++ b/src-tauri/src/core/mcp/approval.rs @@ -36,6 +36,8 @@ pub struct ApprovalRequestEvent { pub capability: String, pub session_id: Option, pub session_name: Option, + pub connection_id: Option, + pub connection_name: Option, pub parameter_summary: String, pub risk: RiskLevel, } diff --git a/src-tauri/src/core/mcp/host.rs b/src-tauri/src/core/mcp/host.rs index cf5398eb..978d6f90 100644 --- a/src-tauri/src/core/mcp/host.rs +++ b/src-tauri/src/core/mcp/host.rs @@ -8,9 +8,9 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use nyaterm_mcp_protocol::{ AuthParams, CapabilityExecuteParams, ClientIdentifyParams, DiscoveryDocument, MAX_INLINE_OUTPUT_BYTES, MAX_RPC_LINE_BYTES, MAX_TEXT_READ_BYTES, MAX_TEXT_WRITE_BYTES, - PROTOCOL_VERSION, PathArgs, RpcError, RpcRequest, RpcResponse, SessionArgs, SftpChmodArgs, - SftpMkdirArgs, SftpReadTextArgs, SftpRenameArgs, SftpWriteTextArgs, TerminalExecuteArgs, - TerminalRecentOutputArgs, tool, + PROTOCOL_VERSION, PathArgs, RpcError, RpcRequest, RpcResponse, SessionArgs, SessionOpenArgs, + SftpChmodArgs, SftpMkdirArgs, SftpReadTextArgs, SftpRenameArgs, SftpWriteTextArgs, + TerminalExecuteArgs, TerminalRecentOutputArgs, tool, }; use rand::RngCore; use serde::Serialize; @@ -19,13 +19,13 @@ use sha2::{Digest, Sha256}; use tauri::{AppHandle, Emitter, Manager}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{TcpListener, TcpStream}; -use tokio::sync::{Mutex, RwLock}; +use tokio::sync::{Mutex, RwLock, oneshot}; use tokio_util::sync::CancellationToken; use super::approval::{ApprovalDecision, ApprovalRequestEvent, McpApprovalManager}; use super::discovery::DiscoveryStore; use crate::config::{ - AiExecutionProfile, AiPermissionMode, ExternalMcpServerMode, ExternalMcpSessionScope, + AiExecutionProfile, AiPermissionMode, ConnectionType, ExternalMcpSessionScope, ExternalMcpSettings, RiskLevel, }; use crate::core::SessionManager; @@ -64,6 +64,30 @@ pub struct McpClientConfigs { pub cursor: Value, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct McpConnectionSummary { + id: String, + name: String, + r#type: String, + group_path: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct McpSessionOpenRequestEvent { + request_id: String, + connection_id: String, + target_window_label: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct McpSessionOpenCancelEvent { + request_id: String, + target_window_label: String, +} + #[derive(Clone)] pub struct EphemeralMcpCredential { pub host: String, @@ -111,6 +135,7 @@ struct Credential { source: String, owner_window_label: Option, cancellation: CancellationToken, + opened_session_ids: RwLock>, } struct ExternalRuntime { @@ -119,8 +144,11 @@ struct ExternalRuntime { generation: String, scope: Arc, cancellation: CancellationToken, - last_activity: Arc>, - approval_waiters: Arc, +} + +struct PendingSessionOpen { + owner_window_label: String, + responder: oneshot::Sender>, } struct ConnectionContext { @@ -143,11 +171,12 @@ pub struct McpManager { shutdown: CancellationToken, credentials: RwLock>>, external: Mutex>, - persistent_startup: Mutex>, + startup_settings: Mutex>, approvals: Arc, request_cancellations: Mutex>, active_sessions: RwLock>, external_connections: AtomicUsize, + pending_session_opens: Mutex>, last_error: StdMutex>, } @@ -167,11 +196,12 @@ impl McpManager { shutdown: CancellationToken::new(), credentials: RwLock::new(HashMap::new()), external: Mutex::new(None), - persistent_startup: Mutex::new(None), + startup_settings: Mutex::new(None), approvals: Arc::new(McpApprovalManager::default()), request_cancellations: Mutex::new(HashMap::new()), active_sessions: RwLock::new(HashMap::new()), external_connections: AtomicUsize::new(0), + pending_session_opens: Mutex::new(HashMap::new()), last_error: StdMutex::new(None), }) } @@ -189,16 +219,8 @@ impl McpManager { tauri::async_runtime::spawn(async move { manager.accept_loop(listener).await }); let settings = crate::config::load_app_settings(&app)?.ai.external_mcp; - if settings.enabled && settings.server_mode == ExternalMcpServerMode::Persistent { - *self.persistent_startup.lock().await = Some(settings); - } else if settings.enabled { - let _ = crate::storage::update_settings_doc( - crate::storage::SettingsDocKey::AppSettings, - |stored: &mut crate::config::AppSettings| { - stored.ai.external_mcp.enabled = false; - Ok(()) - }, - ); + if settings.enabled { + *self.startup_settings.lock().await = Some(settings); } Ok(()) } @@ -207,7 +229,7 @@ impl McpManager { self: &Arc, owner_window_label: &str, ) -> AppResult { - let settings = self.persistent_startup.lock().await.take(); + let settings = self.startup_settings.lock().await.take(); if let Some(settings) = settings { self.configure_external(settings, owner_window_label).await } else { @@ -225,11 +247,6 @@ impl McpManager { settings: ExternalMcpSettings, owner_window_label: &str, ) -> AppResult { - if !(1..=120).contains(&settings.idle_timeout_minutes) { - return Err(AppError::Config( - "External MCP idle timeout must be between 1 and 120 minutes.".into(), - )); - } if !settings.enabled { self.disable_external(false).await?; return Ok(self.status().await); @@ -268,21 +285,18 @@ impl McpManager { source: EXTERNAL_SOURCE.into(), owner_window_label: Some(owner_window_label.to_string()), cancellation: cancellation.clone(), + opened_session_ids: RwLock::new(HashSet::new()), }); self.credentials .write() .await .insert(generation.clone(), credential); - let last_activity = Arc::new(StdMutex::new(Instant::now())); - let approval_waiters = Arc::new(AtomicUsize::new(0)); *self.external.lock().await = Some(ExternalRuntime { settings: settings.clone(), owner_window_label: owner_window_label.to_string(), generation: generation.clone(), scope, cancellation: cancellation.clone(), - last_activity: last_activity.clone(), - approval_waiters: approval_waiters.clone(), }); let document = DiscoveryDocument { version: PROTOCOL_VERSION, @@ -299,20 +313,6 @@ impl McpManager { return Err(error); } self.set_error(None); - if settings.server_mode == ExternalMcpServerMode::Temporary { - let manager = self.clone(); - tauri::async_runtime::spawn(async move { - manager - .temporary_idle_worker( - generation, - settings.idle_timeout_minutes, - last_activity, - approval_waiters, - cancellation, - ) - .await; - }); - } self.emit_status(); Ok(self.status().await) } @@ -339,16 +339,31 @@ impl McpManager { Ok(()) } - pub async fn owner_window_closed(&self, label: &str) { + pub async fn owner_window_closed(self: &Arc, label: &str) { self.active_sessions.write().await.remove(label); - let matches = self + let settings = self .external .lock() .await .as_ref() - .is_some_and(|state| state.owner_window_label == label); - if matches { - let _ = self.disable_external(true).await; + .filter(|state| state.owner_window_label == label) + .map(|state| state.settings.clone()); + let Some(settings) = settings else { return }; + + let replacement = self.app.get().and_then(|app| { + let mut windows = crate::app::main_windows(app) + .into_iter() + .filter(|window| window.label() != label) + .collect::>(); + windows.sort_by(|left, right| left.label().cmp(right.label())); + windows.first().map(|window| window.label().to_string()) + }); + let _ = self.disable_external(false).await; + if let Some(replacement) = replacement { + if let Err(error) = self.configure_external(settings, &replacement).await { + self.set_error(Some(error.to_string())); + self.emit_status(); + } } } @@ -405,6 +420,157 @@ impl McpManager { }) } + fn connection_summaries(&self) -> AppResult> { + let app = self + .app + .get() + .ok_or_else(|| AppError::Config("NyaTerm is not ready.".into()))?; + let config = crate::config::load_config(app)?; + Ok(connection_summaries_from_config(&config)) + } + + fn connection_summary(&self, connection_id: &str) -> AppResult { + if connection_id.trim().is_empty() { + return Err(AppError::Config("connectionId is required.".into())); + } + self.connection_summaries()? + .into_iter() + .find(|connection| connection.id == connection_id) + .ok_or_else(|| { + AppError::Config( + "The saved connection does not exist or is not a supported terminal connection." + .into(), + ) + }) + } + + pub async fn respond_session_open( + &self, + owner_window_label: &str, + request_id: &str, + session_id: Option, + error: Option, + ) -> AppResult<()> { + let pending = self + .pending_session_opens + .lock() + .await + .remove(request_id) + .ok_or_else(|| { + AppError::Config("The MCP session-open request is no longer pending.".into()) + })?; + if pending.owner_window_label != owner_window_label { + self.pending_session_opens + .lock() + .await + .insert(request_id.to_string(), pending); + return Err(AppError::Config( + "Only the target main window can complete this MCP session-open request.".into(), + )); + } + let result = match (session_id, error) { + (Some(session_id), None) if !session_id.trim().is_empty() => Ok(session_id), + (None, Some(error)) if !error.trim().is_empty() => Err(error), + _ => Err("Invalid MCP session-open response.".into()), + }; + pending + .responder + .send(result) + .map_err(|_| AppError::Cancelled("The MCP session-open request was cancelled.".into())) + } + + async fn request_session_open( + &self, + context: &ConnectionContext, + connection: &McpConnectionSummary, + cancellation: &CancellationToken, + ) -> AppResult { + let owner = context + .credential + .owner_window_label + .as_deref() + .ok_or_else(|| AppError::Config("The MCP owner window is unavailable.".into()))?; + let app = self + .app + .get() + .ok_or_else(|| AppError::Config("NyaTerm is not ready.".into()))?; + let window = app + .get_webview_window(owner) + .ok_or_else(|| AppError::Config("The MCP owner window is unavailable.".into()))?; + let request_id = uuid::Uuid::new_v4().to_string(); + let (tx, rx) = oneshot::channel(); + self.pending_session_opens.lock().await.insert( + request_id.clone(), + PendingSessionOpen { + owner_window_label: owner.to_string(), + responder: tx, + }, + ); + let event = McpSessionOpenRequestEvent { + request_id: request_id.clone(), + connection_id: connection.id.clone(), + target_window_label: owner.to_string(), + }; + if let Err(error) = window.emit("mcp-session-open-request", event) { + self.pending_session_opens.lock().await.remove(&request_id); + return Err(AppError::Config(format!( + "Failed to send the MCP session-open request: {error}" + ))); + } + + let result = tokio::select! { + _ = cancellation.cancelled() => { + self.pending_session_opens.lock().await.remove(&request_id); + let _ = window.emit("mcp-session-open-cancel", McpSessionOpenCancelEvent { + request_id: request_id.clone(), + target_window_label: owner.to_string(), + }); + return Err(AppError::Cancelled("The MCP session-open request was cancelled.".into())); + } + result = rx => result.map_err(|_| AppError::Cancelled("The MCP session-open request was cancelled.".into()))?, + }; + let session_id = result.map_err(AppError::Config)?; + let info = self.sessions.session_info(&session_id).await?; + if info.owner_window_label.as_deref() != Some(owner) + || info.connection_id.as_deref() != Some(connection.id.as_str()) + || !info.connected + || !matches!( + info.session_type, + SessionType::SSH | SessionType::Local | SessionType::Telnet | SessionType::Serial + ) + { + return Err(AppError::Config( + "The opened session does not match the requested saved connection.".into(), + )); + } + context + .credential + .opened_session_ids + .write() + .await + .insert(session_id.clone()); + Ok(session_id) + } + + async fn resolve_credential_scope(&self, credential: &Credential) -> McpScopeSnapshot { + let sessions = self.sessions.list_sessions().await; + let live_ids = sessions + .iter() + .map(|session| session.id.as_str()) + .collect::>(); + let mut scope = credential.scope.resolve(&sessions); + scope.session_ids.extend( + credential + .opened_session_ids + .read() + .await + .iter() + .filter(|id| live_ids.contains(id.as_str())) + .cloned(), + ); + scope + } + pub async fn create_ephemeral_credential( self: &Arc, source: &str, @@ -432,6 +598,7 @@ impl McpManager { source: source.to_string(), owner_window_label, cancellation: cancellation.clone(), + opened_session_ids: RwLock::new(HashSet::new()), }), ); Ok(EphemeralMcpCredential { @@ -581,7 +748,6 @@ impl McpManager { } if is_external { self.external_connections.fetch_add(1, Ordering::SeqCst); - self.touch_external(&context.generation).await; self.emit_status(); } loop { @@ -593,9 +759,6 @@ impl McpManager { Ok(value) => value, Err(_) => break, }; - if is_external { - self.touch_external(&context.generation).await; - } let response = self.handle_request(&context, request).await; if write_response(&mut write, response).await.is_err() { break; @@ -664,9 +827,6 @@ impl McpManager { let result = self .execute_tool(context, ¶ms.tool, params.arguments, token) .await; - if context.credential.source == EXTERNAL_SOURCE { - self.touch_external(&context.generation).await; - } if let Some(id) = params.request_id.as_ref() { self.request_cancellations.lock().await.remove(id); } @@ -742,10 +902,16 @@ impl McpManager { } } } - let scope = context - .credential - .scope - .resolve(&self.sessions.list_sessions().await); + let scope = self.resolve_credential_scope(&context.credential).await; + let connection_target = if tool_name == tool::SESSION_OPEN { + let args = parse::(arguments.clone())?; + Some( + self.connection_summary(&args.connection_id) + .map_err(map_error)?, + ) + } else { + None + }; let session_id = match Self::resolve_session(&scope, tool_name, &arguments) { Ok(session_id) => session_id, Err(error) => { @@ -833,9 +999,19 @@ impl McpManager { assessment.as_ref(), ); let risk = assessment.as_ref().map(|value| value.level.clone()); - let grant_key = session_id - .clone() - .map(|id| (id, definition.capability.to_string())); + let grant_key = connection_target + .as_ref() + .map(|connection| { + ( + format!("connection:{}", connection.id), + definition.capability.to_string(), + ) + }) + .or_else(|| { + session_id + .clone() + .map(|id| (id, definition.capability.to_string())) + }); let grantable = definition.access != CapabilityAccess::DestructiveWrite && assessment .as_ref() @@ -865,37 +1041,34 @@ impl McpManager { )); } if policy == PolicyDecision::RequireApproval && !granted { - let target = session_id.as_deref().ok_or_else(|| { - failure( - "approval_denied", - "A target session is required for approval.", + let info = if let Some(target) = session_id.as_deref() { + Some( + self.sessions + .session_info(target) + .await + .map_err(map_error)?, ) - })?; - let info = self - .sessions - .session_info(target) - .await - .map_err(map_error)?; + } else { + None + }; let owner = info - .owner_window_label - .as_deref() + .as_ref() + .and_then(|info| info.owner_window_label.as_deref()) .or(context.credential.owner_window_label.as_deref()) .ok_or_else(|| { failure( "approval_denied", - "The session owner window is unavailable for approval.", + "The MCP owner window is unavailable for approval.", ) })?; - let waiter = self.external_waiter(&context.generation).await; - if let Some(waiter) = waiter.as_ref() { - waiter.fetch_add(1, Ordering::SeqCst); - } let event = ApprovalRequestEvent { request_id: uuid::Uuid::new_v4().to_string(), client: context.client.lock().unwrap().clone(), capability: definition.capability.to_string(), - session_id: Some(target.to_string()), - session_name: Some(info.name), + session_id: session_id.clone(), + session_name: info.as_ref().map(|info| info.name.clone()), + connection_id: connection_target.as_ref().map(|value| value.id.clone()), + connection_name: connection_target.as_ref().map(|value| value.name.clone()), parameter_summary: summarize(tool_name, &arguments), risk: risk .clone() @@ -913,16 +1086,13 @@ impl McpManager { &cancellation, ) .await; - if let Some(waiter) = waiter.as_ref() { - waiter.fetch_sub(1, Ordering::SeqCst); - } let decision = match result { Ok(decision) => decision, Err(error) => { self.audit( context, definition.capability, - Some(target), + session_id.as_deref(), risk.clone(), Some("approval_unavailable"), false, @@ -939,7 +1109,7 @@ impl McpManager { self.audit( context, definition.capability, - Some(target), + session_id.as_deref(), risk, approval, false, @@ -960,16 +1130,28 @@ impl McpManager { context.grants.lock().await.insert(key); } } - let result = tokio::select! { - _ = cancellation.cancelled() => Err(failure("cancelled", "The MCP request was cancelled.")), - value = self.dispatch( + let result = if tool_name == tool::SESSION_OPEN { + self.dispatch( context, &scope, tool_name, arguments.clone(), session_id.as_deref(), cancellation.clone(), - ) => value, + ) + .await + } else { + tokio::select! { + _ = cancellation.cancelled() => Err(failure("cancelled", "The MCP request was cancelled.")), + value = self.dispatch( + context, + &scope, + tool_name, + arguments.clone(), + session_id.as_deref(), + cancellation.clone(), + ) => value, + } }; let elapsed = started.elapsed(); match result { @@ -1011,7 +1193,10 @@ impl McpManager { tool_name: &str, arguments: &Value, ) -> AppResult> { - if tool_name == tool::GET_ENVIRONMENT { + if matches!( + tool_name, + tool::GET_ENVIRONMENT | tool::CONNECTION_LIST | tool::SESSION_OPEN + ) { return Ok(None); } if tool_name == tool::TERMINAL_EXECUTE { @@ -1059,6 +1244,31 @@ impl McpManager { "sessions": sessions, })) } + tool::CONNECTION_LIST => Ok(json!({ + "connections": self.connection_summaries().map_err(map_error)?, + })), + tool::SESSION_OPEN => { + let args: SessionOpenArgs = parse(arguments)?; + let connection = self + .connection_summary(&args.connection_id) + .map_err(map_error)?; + let session_id = self + .request_session_open(context, &connection, &cancellation) + .await + .map_err(map_error)?; + let info = self + .sessions + .session_info(&session_id) + .await + .map_err(map_error)?; + Ok(json!({ + "sessionId": session_id, + "connectionId": connection.id, + "name": info.name, + "type": session_type_name(&info.session_type), + "connected": true, + })) + } tool::SESSION_GET => { let args: SessionArgs = parse(arguments)?; let info = self @@ -1245,52 +1455,6 @@ impl McpManager { .then_some(credential) } - async fn temporary_idle_worker( - self: Arc, - generation: String, - minutes: u16, - last_activity: Arc>, - approval_waiters: Arc, - cancellation: CancellationToken, - ) { - let timeout = Duration::from_secs(u64::from(minutes) * 60); - loop { - tokio::select! { _ = cancellation.cancelled() => return, _ = tokio::time::sleep(Duration::from_secs(5)) => {} } - if approval_waiters.load(Ordering::SeqCst) > 0 { - continue; - } - if last_activity.lock().unwrap().elapsed() >= timeout { - if self - .external - .lock() - .await - .as_ref() - .is_some_and(|state| state.generation == generation) - { - let _ = self.disable_external(true).await; - } - return; - } - } - } - - async fn touch_external(&self, generation: &str) { - if let Some(state) = self.external.lock().await.as_ref() { - if state.generation == generation { - *state.last_activity.lock().unwrap() = Instant::now(); - } - } - } - - async fn external_waiter(&self, generation: &str) -> Option> { - self.external - .lock() - .await - .as_ref() - .filter(|state| state.generation == generation) - .map(|state| state.approval_waiters.clone()) - } - #[allow(clippy::too_many_arguments)] fn audit( &self, @@ -1378,6 +1542,7 @@ fn permission_mode_name(value: &AiPermissionMode) -> &'static str { AiPermissionMode::Observer => "observer", AiPermissionMode::Confirm => "confirm", AiPermissionMode::Auto => "auto", + AiPermissionMode::FullAccess => "full_access", } } fn session_type_name(value: &SessionType) -> &'static str { @@ -1388,6 +1553,61 @@ fn session_type_name(value: &SessionType) -> &'static str { SessionType::Serial => "serial", } } +fn terminal_connection_type(value: &ConnectionType) -> Option<&'static str> { + match value { + ConnectionType::Ssh { .. } => Some("ssh"), + ConnectionType::LocalTerminal { .. } => Some("local_terminal"), + ConnectionType::Telnet { .. } => Some("telnet"), + ConnectionType::Serial { .. } => Some("serial"), + ConnectionType::Rdp { .. } | ConnectionType::Vnc { .. } => None, + } +} +fn connection_summaries_from_config( + config: &crate::config::AppConfig, +) -> Vec { + let groups = config + .groups + .iter() + .map(|group| (group.id.as_str(), group)) + .collect::>(); + let mut connections = config + .connections + .iter() + .filter_map(|connection| { + terminal_connection_type(&connection.config).map(|kind| McpConnectionSummary { + id: connection.id.clone(), + name: connection.name.clone(), + r#type: kind.to_string(), + group_path: connection_group_path(connection.group_id.as_deref(), &groups), + }) + }) + .collect::>(); + connections.sort_by(|left, right| { + left.group_path + .cmp(&right.group_path) + .then_with(|| left.name.cmp(&right.name)) + .then_with(|| left.id.cmp(&right.id)) + }); + connections +} +fn connection_group_path( + group_id: Option<&str>, + groups: &HashMap<&str, &crate::config::Group>, +) -> Vec { + let mut path = Vec::new(); + let mut visited = HashSet::new(); + let mut current = group_id; + while let Some(id) = current { + if !visited.insert(id.to_string()) { + break; + } + let Some(group) = groups.get(id) else { break }; + path.push(group.name.clone()); + current = group.parent_id.as_deref(); + } + path.reverse(); + path +} fn execution_profile_name(value: AiExecutionProfile) -> &'static str { match value { AiExecutionProfile::Disabled => "disabled", @@ -1418,6 +1638,12 @@ fn access_risk(value: CapabilityAccess) -> RiskLevel { } fn summarize(name: &str, args: &Value) -> String { let value = match name { + tool::SESSION_OPEN => format!( + "connectionId={}", + args.get("connectionId") + .and_then(Value::as_str) + .unwrap_or_default() + ), tool::TERMINAL_EXECUTE => args .get("command") .and_then(Value::as_str) @@ -1541,6 +1767,51 @@ mod tests { assert_eq!(URL_SAFE_NO_PAD.decode(random_token()).unwrap().len(), 32); } + #[test] + fn full_access_permission_name_is_written_to_metadata() { + assert_eq!( + permission_mode_name(&AiPermissionMode::FullAccess), + "full_access" + ); + } + + #[test] + fn connection_summaries_filter_graphical_connections_and_secrets() { + let config: crate::config::AppConfig = serde_json::from_value(json!({ + "groups": [ + { "id": "root", "name": "Production" }, + { "id": "child", "name": "Linux", "parent_id": "root" } + ], + "connections": [ + { + "id": "ssh-1", + "name": "Web server", + "type": "ssh", + "host": "secret.example.com", + "username": "root", + "group_id": "child" + }, + { + "id": "rdp-1", + "name": "Desktop", + "type": "rdp", + "host": "desktop.example.com" + } + ] + })) + .expect("saved connections"); + + let summaries = connection_summaries_from_config(&config); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].id, "ssh-1"); + assert_eq!(summaries[0].r#type, "ssh"); + assert_eq!(summaries[0].group_path, ["Production", "Linux"]); + let serialized = serde_json::to_string(&summaries).unwrap(); + assert!(!serialized.contains("secret.example.com")); + assert!(!serialized.contains("username")); + assert!(!serialized.contains("rdp-1")); + } + #[test] fn active_session_must_be_live_and_scoped() { let scope = McpScopeSnapshot { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0205cfb8..a026d06b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -168,6 +168,7 @@ pub fn run() { cmd::mcp::set_external_mcp_enabled, cmd::mcp::respond_external_mcp_approval, cmd::mcp::report_mcp_active_session, + cmd::mcp::respond_mcp_session_open, cmd::mcp::get_external_mcp_client_configs, cmd::ai::detect_claude_code_cli, cmd::ai::get_claude_code_account_status, diff --git a/src/App.tsx b/src/App.tsx index 02f312fa..313ed4ca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,8 +6,8 @@ import { toast } from "sonner"; import AppLayout from "./components/app/AppLayout"; import AppPanelContent from "./components/app/AppPanelContent"; import ActivityBarResetDialog from "./components/dialog/app/ActivityBarResetDialog"; -import { McpApprovalHost } from "./components/dialog/app/McpApprovalHost"; import AppOverlayDialogs from "./components/dialog/app/AppOverlayDialogs"; +import { McpApprovalHost } from "./components/dialog/app/McpApprovalHost"; import type { HostKeyVerifyRequest } from "./components/dialog/connections/HostKeyVerifyDialog"; import type { OtpRequest } from "./components/dialog/connections/OtpDialog"; import type { RdpCertificateVerifyRequest } from "./components/dialog/connections/RdpCertificateVerifyDialog"; @@ -58,15 +58,15 @@ import { } from "./lib/appSessionFactory"; import { buildPanelOpenUpdate, - canUseFloatingPanel, canCreateSessionFromPane, + canUseFloatingPanel, clearUnavailableFloatingPanels, collectActiveNonSerialSessionIds, EXCLUSIVE_PANEL_IDS, type FloatingPanelsState, + getItemSide, getSideOpenPanels, getSideOverlayPanel, - getItemSide, getVisibleActivityIds, hasLiveSession, isActivityItemAvailable, @@ -151,6 +151,8 @@ import type { AppSettings, AssetMetadata, CloudConflictPreview, + McpSessionOpenCancel, + McpSessionOpenRequest, PaneSplitDirection, RecordingMode, SavedConnection, @@ -956,6 +958,9 @@ function App() { options?: { failureContext?: string; runtimeModeOverride?: SshRuntimeMode; + propagateError?: boolean; + onPending?: (pending: { tabId: string; createRequestId: string }) => void; + onSuccess?: (sessionId: string) => void; }, ) => { const pending = addPendingTab( @@ -967,6 +972,7 @@ function App() { { display: getRemoteDesktopPaneDisplay(connection) }, ); const { tabId, createRequestId } = pending; + options?.onPending?.({ tabId, createRequestId }); try { const sessionId = await createSessionForConnection( @@ -983,8 +989,10 @@ function App() { focusTerminalSession(sessionId); recordRecentConnection(connection.id); updateAutoIconForSessionStart(connection.id, sessionId); + options?.onSuccess?.(sessionId); } catch (error) { if (isSessionCreationCancelled(error) || !hasTab(tabId)) { + if (options?.propagateError) throw error; return; } const errorMessage = getErrorMessage(error); @@ -1000,6 +1008,7 @@ function App() { sourceTabId: tabId, }); toast.error(t("savedConnections.connectionFailed", { error: errorMessage })); + if (options?.propagateError) throw error; } }, [ @@ -1014,6 +1023,89 @@ function App() { ], ); + const mcpSessionOpenRequestsRef = useRef( + new Map(), + ); + const cancelledMcpSessionOpenRequestsRef = useRef(new Set()); + useEffect(() => { + let disposed = false; + let unlistenOpen: (() => void) | undefined; + let unlistenCancel: (() => void) | undefined; + + void listen("mcp-session-open-request", ({ payload }) => { + if (disposed || !eventTargetsCurrentWindow(payload.targetWindowLabel)) return; + void (async () => { + const connections = savedConnections.some((item) => item.id === payload.connectionId) + ? savedConnections + : await invoke("get_saved_connections"); + if (cancelledMcpSessionOpenRequestsRef.current.delete(payload.requestId)) return; + const connection = connections.find((item) => item.id === payload.connectionId); + if (!connection || connection.type === "rdp" || connection.type === "vnc") { + await invoke("respond_mcp_session_open", { + requestId: payload.requestId, + sessionId: null, + error: "The saved connection does not exist or is not a supported terminal connection.", + }); + return; + } + + let openedSessionId: string | null = null; + await connectSavedConnection(connection, { + failureContext: "MCP session open failed", + propagateError: true, + onPending: (pending) => { + mcpSessionOpenRequestsRef.current.set(payload.requestId, pending); + }, + onSuccess: (sessionId) => { + openedSessionId = sessionId; + }, + }); + await invoke("respond_mcp_session_open", { + requestId: payload.requestId, + sessionId: openedSessionId, + error: openedSessionId ? null : "The MCP session-open request did not create a session.", + }); + })() + .catch((error) => { + void invoke("respond_mcp_session_open", { + requestId: payload.requestId, + sessionId: null, + error: getErrorMessage(error), + }).catch(() => {}); + }) + .finally(() => { + mcpSessionOpenRequestsRef.current.delete(payload.requestId); + cancelledMcpSessionOpenRequestsRef.current.delete(payload.requestId); + }); + }).then((dispose) => { + if (disposed) dispose(); + else unlistenOpen = dispose; + }); + + void listen("mcp-session-open-cancel", ({ payload }) => { + if (disposed || !eventTargetsCurrentWindow(payload.targetWindowLabel)) return; + const pending = mcpSessionOpenRequestsRef.current.get(payload.requestId); + if (!pending) { + cancelledMcpSessionOpenRequestsRef.current.add(payload.requestId); + return; + } + mcpSessionOpenRequestsRef.current.delete(payload.requestId); + closeTabs([pending.tabId]); + void invoke("cancel_session_creation", { + createRequestId: pending.createRequestId, + }).catch(() => {}); + }).then((dispose) => { + if (disposed) dispose(); + else unlistenCancel = dispose; + }); + + return () => { + disposed = true; + unlistenOpen?.(); + unlistenCancel?.(); + }; + }, [closeTabs, connectSavedConnection, savedConnections]); + const connectTemporaryConnection = useCallback( async (config: TemporaryLinkConfig) => { const pending = addPendingTab( @@ -1054,18 +1146,11 @@ function App() { const connectExternalLocalSession = useCallback( async (workingDir: string | null) => { - const pending = addPendingTab( - t("menu.newLocalTerminal"), - "Local", - undefined, - ); + const pending = addPendingTab(t("menu.newLocalTerminal"), "Local", undefined); const { tabId, createRequestId } = pending; try { - const sessionId = await createExternalLocalSession( - workingDir, - createRequestId, - ); + const sessionId = await createExternalLocalSession(workingDir, createRequestId); if (!hasTab(tabId)) { await closeStaleCreatedSession(sessionId); return; diff --git a/src/components/dialog/app/McpApprovalHost.tsx b/src/components/dialog/app/McpApprovalHost.tsx index ba88128f..e7149249 100644 --- a/src/components/dialog/app/McpApprovalHost.tsx +++ b/src/components/dialog/app/McpApprovalHost.tsx @@ -92,9 +92,13 @@ export function McpApprovalHost() {
- {t("ai.externalMcpSession")}: + {t("ai.externalMcpTarget")}: {" "} - {current.sessionName ?? current.sessionId ?? "-"} + {current.connectionName ?? + current.connectionId ?? + current.sessionName ?? + current.sessionId ?? + "-"}
diff --git a/src/components/settings/AiPermissionSelect.test.ts b/src/components/settings/AiPermissionSelect.test.ts new file mode 100644 index 00000000..8db59072 --- /dev/null +++ b/src/components/settings/AiPermissionSelect.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { AI_PERMISSION_MODES, requiresFullAccessConfirmation } from "./AiPermissionSelect"; + +describe("AI permission modes", () => { + it("exposes the four permission levels in increasing order", () => { + expect(AI_PERMISSION_MODES).toEqual(["observer", "confirm", "auto", "full_access"]); + }); + + it("only requires confirmation when entering full access", () => { + expect(requiresFullAccessConfirmation("confirm", "full_access")).toBe(true); + expect(requiresFullAccessConfirmation("auto", "full_access")).toBe(true); + expect(requiresFullAccessConfirmation("full_access", "full_access")).toBe(false); + expect(requiresFullAccessConfirmation("full_access", "confirm")).toBe(false); + }); +}); diff --git a/src/components/settings/AiPermissionSelect.tsx b/src/components/settings/AiPermissionSelect.tsx new file mode 100644 index 00000000..6fec69b6 --- /dev/null +++ b/src/components/settings/AiPermissionSelect.tsx @@ -0,0 +1,99 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { SelectItem } from "@/components/ui/select"; +import type { AIPermissionMode } from "@/types/global"; +import { SettingSelect } from "./SettingFormItems"; + +export const AI_PERMISSION_MODES = [ + "observer", + "confirm", + "auto", + "full_access", +] as const satisfies readonly AIPermissionMode[]; + +const PERMISSION_MODE_LABEL_KEYS: Record = { + observer: "ai.permissionObserver", + confirm: "ai.permissionConfirm", + auto: "ai.permissionAuto", + full_access: "ai.permissionFullAccess", +}; + +const PERMISSION_MODE_DESCRIPTION_KEYS: Record = { + observer: "ai.permissionObserverDesc", + confirm: "ai.permissionConfirmDesc", + auto: "ai.permissionAutoDesc", + full_access: "ai.permissionFullAccessDesc", +}; + +export function requiresFullAccessConfirmation(current: AIPermissionMode, next: AIPermissionMode) { + return current !== "full_access" && next === "full_access"; +} + +interface AiPermissionSelectProps { + value: AIPermissionMode; + targetLabel: string; + onValueChange: (value: AIPermissionMode) => void; +} + +export function AiPermissionSelect({ value, targetLabel, onValueChange }: AiPermissionSelectProps) { + const { t } = useTranslation(); + const [confirmingFullAccess, setConfirmingFullAccess] = useState(false); + + const handleValueChange = (nextValue: string) => { + const next = nextValue as AIPermissionMode; + if (requiresFullAccessConfirmation(value, next)) { + setConfirmingFullAccess(true); + return; + } + onValueChange(next); + }; + + const enableFullAccess = () => { + setConfirmingFullAccess(false); + onValueChange("full_access"); + }; + + return ( + <> + + {AI_PERMISSION_MODES.map((mode) => ( + + {t(PERMISSION_MODE_LABEL_KEYS[mode])} + + ))} + + + + + + {t("ai.fullAccessConfirmTitle")} + + {t("ai.fullAccessConfirmDesc", { target: targetLabel })} + + + + {t("common.cancel")} + + {t("ai.enableFullAccess")} + + + + + + ); +} diff --git a/src/components/settings/AiTab.tsx b/src/components/settings/AiTab.tsx index e41ce186..6824f7e8 100644 --- a/src/components/settings/AiTab.tsx +++ b/src/components/settings/AiTab.tsx @@ -1,5 +1,5 @@ -import { openUrl } from "@tauri-apps/plugin-opener"; import { listen } from "@tauri-apps/api/event"; +import { openUrl } from "@tauri-apps/plugin-opener"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { @@ -32,14 +32,14 @@ import { supportsApiFormatSelection, supportsCustomModelDiscovery, } from "@/lib/aiSettings"; +import { writeClipboardText } from "@/lib/clipboard"; import { getErrorMessage } from "@/lib/errors"; import { invoke } from "@/lib/invoke"; -import { writeClipboardText } from "@/lib/clipboard"; +import { getOwnerMainWindowLabel } from "@/lib/windowManager"; import type { - AICustomActionConfig, AIApiFormat, + AICustomActionConfig, AIModelConfigItem, - AIPermissionMode, AIProviderCredential, AIProviderKind, AISettings, @@ -48,6 +48,7 @@ import type { ExternalMcpSettings, McpRuntimeStatus, } from "@/types/global"; +import { AiPermissionSelect } from "./AiPermissionSelect"; import { SettingFieldGrid, SettingInput, @@ -307,8 +308,6 @@ export function AiAgentsTab() { enabled: false, permission_mode: "confirm", session_scope: "current_window", - server_mode: "temporary", - idle_timeout_minutes: 10, }; const [mcpStatus, setMcpStatus] = useState(null); const [cliStatus, setCliStatus] = useState(null); @@ -341,12 +340,32 @@ export function AiAgentsTab() { const updateExternalMcp = useCallback( (patch: Partial) => - updateAppSettings({ ai: { ...ai, external_mcp: { ...externalMcp, ...patch } } }), + updateAppSettings({ + ai: { ...ai, external_mcp: { ...externalMcp, ...patch } }, + }), [ai, externalMcp, updateAppSettings], ); + const setExternalMcpEnabled = useCallback( + async (enabled: boolean) => { + try { + const status = await invoke("set_external_mcp_enabled", { + enabled, + ownerWindowLabel: getOwnerMainWindowLabel(), + }); + setMcpStatus(status); + updateExternalMcp({ enabled }); + } catch (error) { + toast.error(getErrorMessage(error)); + } + }, + [updateExternalMcp], + ); + useEffect(() => { - void invoke("get_external_mcp_status").then(setMcpStatus).catch(() => {}); + void invoke("get_external_mcp_status") + .then(setMcpStatus) + .catch(() => {}); let disposed = false; let unlisten: (() => void) | undefined; void listen("mcp-status-changed", (event) => { @@ -597,19 +616,15 @@ export function AiAgentsTab() { ))} - updateCodex({ - permission_mode: permission_mode as AIPermissionMode, + permission_mode, }) } - > - {t("ai.permissionObserver")} - {t("ai.permissionConfirm")} - {t("ai.permissionAuto")} - + />
@@ -722,19 +737,15 @@ export function AiAgentsTab() { }) } /> - updateClaudeCode({ - permission_mode: permission_mode as AIPermissionMode, + permission_mode, }) } - > - {t("ai.permissionObserver")} - {t("ai.permissionConfirm")} - {t("ai.permissionAuto")} - + />
@@ -782,7 +793,7 @@ export function AiAgentsTab() { updateExternalMcp({ enabled })} + onChange={(enabled) => void setExternalMcpEnabled(enabled)} />
@@ -790,17 +801,15 @@ export function AiAgentsTab() {
{mcpStatus.error}
) : null} - - updateExternalMcp({ permission_mode: permission_mode as AIPermissionMode }) + updateExternalMcp({ + permission_mode, + }) } - > - {t("ai.permissionObserver")} - {t("ai.permissionConfirm")} - {t("ai.permissionAuto")} - + /> {t("ai.externalMcpCurrentWindow")} {t("ai.externalMcpAllSessions")} - - updateExternalMcp({ - server_mode: server_mode as ExternalMcpSettings["server_mode"], - }) - } - > - {t("ai.externalMcpTemporary")} - {t("ai.externalMcpPersistent")} - - updateExternalMcp({ idle_timeout_minutes })} - />
{t("ai.externalMcpRuntimeSummary", { diff --git a/src/i18n/mcpApprovalTranslations.test.ts b/src/i18n/mcpApprovalTranslations.test.ts index 690b40ae..a4da8bfa 100644 --- a/src/i18n/mcpApprovalTranslations.test.ts +++ b/src/i18n/mcpApprovalTranslations.test.ts @@ -10,3 +10,37 @@ it("describes MCP approval grants as connection-scoped in every locale", () => { expect(zhTW.ai.externalMcpAllowSession).toBe("此連線期間允許"); expect(ko.ai.externalMcpAllowSession).toBe("이 연결에서 허용"); }); + +it("labels both saved connections and sessions as MCP approval targets", () => { + expect(en.ai.externalMcpTarget).toBe("Target"); + expect(zhCN.ai.externalMcpTarget).toBe("目标"); + expect(zhTW.ai.externalMcpTarget).toBe("目標"); + expect(ko.ai.externalMcpTarget).toBe("대상"); +}); + +it("uses explicit names for all AI permission modes in every locale", () => { + expect([ + en.ai.permissionObserver, + en.ai.permissionConfirm, + en.ai.permissionAuto, + en.ai.permissionFullAccess, + ]).toEqual(["Read-only", "Always confirm", "Safe auto", "Full access"]); + expect([ + zhCN.ai.permissionObserver, + zhCN.ai.permissionConfirm, + zhCN.ai.permissionAuto, + zhCN.ai.permissionFullAccess, + ]).toEqual(["只读", "每次确认", "安全自动", "完全权限"]); + expect([ + zhTW.ai.permissionObserver, + zhTW.ai.permissionConfirm, + zhTW.ai.permissionAuto, + zhTW.ai.permissionFullAccess, + ]).toEqual(["唯讀", "每次確認", "安全自動", "完全權限"]); + expect([ + ko.ai.permissionObserver, + ko.ai.permissionConfirm, + ko.ai.permissionAuto, + ko.ai.permissionFullAccess, + ]).toEqual(["읽기 전용", "매번 확인", "안전 자동", "전체 권한"]); +}); diff --git a/src/lib/aiSettings.test.ts b/src/lib/aiSettings.test.ts index a93f92f0..67dfb8dd 100644 --- a/src/lib/aiSettings.test.ts +++ b/src/lib/aiSettings.test.ts @@ -3,13 +3,9 @@ import { BUILTIN_PROVIDERS, DEFAULT_AI_SETTINGS } from "./aiSettings"; describe("aiSettings Ollama defaults", () => { it("uses the Ollama native API root instead of the OpenAI-compatible v1 path", () => { - expect(BUILTIN_PROVIDERS.ollama?.defaultBaseUrl).toBe( - "http://localhost:11434/", - ); + expect(BUILTIN_PROVIDERS.ollama?.defaultBaseUrl).toBe("http://localhost:11434/"); - const profile = DEFAULT_AI_SETTINGS.provider_profiles.find( - (item) => item.id === "ollama", - ); + const profile = DEFAULT_AI_SETTINGS.provider_profiles.find((item) => item.id === "ollama"); const credential = DEFAULT_AI_SETTINGS.provider_credentials.find( (item) => item.id === "ollama", ); @@ -20,13 +16,11 @@ describe("aiSettings Ollama defaults", () => { }); describe("External MCP defaults", () => { - it("starts disabled with a temporary confirm-scoped server", () => { + it("starts disabled with confirm-scoped persistent storage", () => { expect(DEFAULT_AI_SETTINGS.external_mcp).toEqual({ enabled: false, permission_mode: "confirm", session_scope: "current_window", - server_mode: "temporary", - idle_timeout_minutes: 10, }); }); }); diff --git a/src/lib/aiSettings.ts b/src/lib/aiSettings.ts index 55a835ba..c59b160a 100644 --- a/src/lib/aiSettings.ts +++ b/src/lib/aiSettings.ts @@ -647,8 +647,6 @@ export const DEFAULT_AI_SETTINGS: AISettings = { enabled: false, permission_mode: "confirm", session_scope: "current_window", - server_mode: "temporary", - idle_timeout_minutes: 10, }, }; diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 79324c62..52935da4 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -1362,15 +1362,12 @@ export type RiskLevel = "low" | "medium" | "high" | "critical"; export type AIMode = "ask" | "agent"; export type AIAgentCommandExecutionMode = "confirm_each" | "smart" | "auto"; export type AIAgentKind = "nyaterm" | "codex" | "claude_code"; -export type AIPermissionMode = "observer" | "confirm" | "auto"; +export type AIPermissionMode = "observer" | "confirm" | "auto" | "full_access"; export type ExternalMcpSessionScope = "current_window" | "all_sessions"; -export type ExternalMcpServerMode = "temporary" | "persistent"; export interface ExternalMcpSettings { enabled: boolean; permission_mode: AIPermissionMode; session_scope: ExternalMcpSessionScope; - server_mode: ExternalMcpServerMode; - idle_timeout_minutes: number; } export type AIReasoningEffort = | "auto" @@ -1506,10 +1503,23 @@ export interface McpApprovalRequest { capability: string; sessionId?: string | null; sessionName?: string | null; + connectionId?: string | null; + connectionName?: string | null; parameterSummary: string; risk: RiskLevel; } +export interface McpSessionOpenRequest { + requestId: string; + connectionId: string; + targetWindowLabel: string; +} + +export interface McpSessionOpenCancel { + requestId: string; + targetWindowLabel: string; +} + export interface AIContext { connectionName?: string | null; host?: string | null; From 39a2576d15244eb9f2b7a3eb102530f5eb417e8e Mon Sep 17 00:00:00 2001 From: Kang Date: Mon, 31 Aug 2026 21:12:42 +0800 Subject: [PATCH 23/32] chore(i18n): update translations for new permission modes and full access features - Added new translation keys for enabling full access and updated descriptions for permission modes in English, Korean, Simplified Chinese, and Traditional Chinese locale files. - Enhanced existing translations for external MCP descriptions and permission confirmations to improve clarity and user understanding. --- src/i18n/locales/en.json | 24 ++++++++++++++---------- src/i18n/locales/ko.json | 24 ++++++++++++++---------- src/i18n/locales/zh-CN.json | 24 ++++++++++++++---------- src/i18n/locales/zh-TW.json | 24 ++++++++++++++---------- 4 files changed, 56 insertions(+), 40 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index dadbd06f..b85634ee 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -141,6 +141,7 @@ "disabled": "AI is disabled", "empty": "Ask AI to explain output or generate commands.", "enableAutoExecution": "Enable auto", + "enableFullAccess": "Enable full access", "enableOneModelHint": "Enable at least one model before using the AI panel.", "enabled": "Enable AI assistant", "errorDetected": "Terminal error detected", @@ -161,7 +162,7 @@ "externalMcpAllSessions": "All sessions", "externalMcpAllowOnce": "Allow once", "externalMcpAllowSession": "Allow for this connection", - "externalMcpApprovalDesc": "An MCP client is requesting access to a NyaTerm session.", + "externalMcpApprovalDesc": "An MCP client is requesting access to a NyaTerm capability.", "externalMcpApprovalTitle": "External MCP approval", "externalMcpCapability": "Capability", "externalMcpClient": "Client", @@ -169,22 +170,20 @@ "externalMcpCopyConfig": "Copy config", "externalMcpCurrentWindow": "Current window", "externalMcpDeny": "Deny", - "externalMcpDesc": "Allow external MCP clients to use a snapshot of the selected NyaTerm sessions.", + "externalMcpDesc": "Persistently allow external MCP clients to discover connections and use scoped NyaTerm sessions.", "externalMcpDisabled": "Disabled", "externalMcpEnabled": "Enable External MCP", "externalMcpError": "Error", - "externalMcpIdleTimeout": "Idle timeout (minutes)", - "externalMcpPersistent": "Persistent", "externalMcpRisk": "Risk", "externalMcpRunning": "Running", "externalMcpRuntimeSummary": "Window: {{window}} · Sessions: {{sessions}} · Connections: {{connections}}", "externalMcpScope": "Session scope", - "externalMcpServerMode": "Server mode", - "externalMcpSession": "Session", - "externalMcpTemporary": "Temporary", + "externalMcpTarget": "Target", "fileActions": "File AI actions", "fileUnsupported": "File not supported for AI", "formattingResponse": "Formatting", + "fullAccessConfirmDesc": "{{target}} will be able to execute high-risk commands, writes, overwrites, and deletions through NyaTerm without further approval. Session scope, validation, and auditing remain enabled.", + "fullAccessConfirmTitle": "Enable full access?", "general": "General", "generate": "Generate", "generateCommand": "Generate Command", @@ -240,10 +239,15 @@ "notConfigured": "Not set", "notInstalled": "Not installed", "panelMetaMultiTarget": "{{target}} + {{count}} sessions", - "permissionAuto": "Auto", - "permissionConfirm": "Confirm", + "permissionAuto": "Safe auto", + "permissionAutoDesc": "Automatically allows locally recognized safe actions; unknown, high-risk, and destructive actions still require confirmation.", + "permissionConfirm": "Always confirm", + "permissionConfirmDesc": "Sensitive reads and all write operations require confirmation.", + "permissionFullAccess": "Full access", + "permissionFullAccessDesc": "All scoped NyaTerm capabilities run without approval, including high-risk and destructive actions.", "permissionMode": "Permission mode", - "permissionObserver": "Observer", + "permissionObserver": "Read-only", + "permissionObserverDesc": "Allows basic reads, asks before sensitive reads, and blocks write operations.", "placeholder": "Ask anything... Type @ for sessions", "profileName": "Name", "providerKind": "Provider", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index b2935894..2cf04de4 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -141,6 +141,7 @@ "disabled": "AI가 비활성화되어 있습니다", "empty": "AI에게 출력 설명이나 명령 생성을 요청하세요.", "enableAutoExecution": "자동 실행 켜기", + "enableFullAccess": "전체 권한 사용", "enableOneModelHint": "AI 패널을 사용하려면 모델을 하나 이상 활성화하세요.", "enabled": "AI 어시스턴트 사용", "errorDetected": "터미널 오류가 감지되었습니다", @@ -161,7 +162,7 @@ "externalMcpAllSessions": "모든 세션", "externalMcpAllowOnce": "한 번 허용", "externalMcpAllowSession": "이 연결에서 허용", - "externalMcpApprovalDesc": "MCP 클라이언트가 NyaTerm 세션 접근을 요청합니다.", + "externalMcpApprovalDesc": "MCP 클라이언트가 NyaTerm 기능 사용을 요청합니다.", "externalMcpApprovalTitle": "외부 MCP 승인", "externalMcpCapability": "기능", "externalMcpClient": "클라이언트", @@ -169,22 +170,20 @@ "externalMcpCopyConfig": "설정 복사", "externalMcpCurrentWindow": "현재 창", "externalMcpDeny": "거부", - "externalMcpDesc": "외부 MCP 클라이언트가 선택된 NyaTerm 세션의 활성화 시점 스냅샷을 사용하도록 허용합니다.", + "externalMcpDesc": "외부 MCP 클라이언트가 연결을 검색하고 범위 내 NyaTerm 세션을 사용하도록 영구적으로 허용합니다.", "externalMcpDisabled": "비활성화됨", "externalMcpEnabled": "외부 MCP 활성화", "externalMcpError": "오류", - "externalMcpIdleTimeout": "유휴 시간 제한(분)", - "externalMcpPersistent": "영구", "externalMcpRisk": "위험", "externalMcpRunning": "실행 중", "externalMcpRuntimeSummary": "창: {{window}} · 세션: {{sessions}} · 연결: {{connections}}", "externalMcpScope": "세션 범위", - "externalMcpServerMode": "서버 모드", - "externalMcpSession": "세션", - "externalMcpTemporary": "임시", + "externalMcpTarget": "대상", "fileActions": "파일 AI 작업", "fileUnsupported": "AI가 지원하지 않는 파일입니다", "formattingResponse": "서식 지정 중", + "fullAccessConfirmDesc": "{{target}}에서 NyaTerm을 통해 고위험 명령, 쓰기, 덮어쓰기 및 삭제 작업을 추가 승인 없이 실행할 수 있습니다. 세션 범위, 매개변수 검증 및 감사는 계속 적용됩니다.", + "fullAccessConfirmTitle": "전체 권한을 사용할까요?", "general": "일반", "generate": "생성", "generateCommand": "명령 생성", @@ -240,10 +239,15 @@ "notConfigured": "설정되지 않음", "notInstalled": "설치되지 않음", "panelMetaMultiTarget": "{{target}} + 세션 {{count}}개", - "permissionAuto": "자동", - "permissionConfirm": "확인", + "permissionAuto": "안전 자동", + "permissionAutoDesc": "로컬에서 안전하다고 확인된 작업은 자동 허용하며, 알 수 없거나 고위험 또는 파괴적인 작업은 계속 확인합니다.", + "permissionConfirm": "매번 확인", + "permissionConfirmDesc": "민감한 읽기와 모든 쓰기 작업에 확인이 필요합니다.", + "permissionFullAccess": "전체 권한", + "permissionFullAccessDesc": "범위 내 모든 NyaTerm 기능을 승인 없이 실행하며, 고위험 및 파괴적인 작업도 포함합니다.", "permissionMode": "권한 모드", - "permissionObserver": "관찰", + "permissionObserver": "읽기 전용", + "permissionObserverDesc": "일반 읽기는 허용하고 민감한 읽기는 확인하며 쓰기 작업은 차단합니다.", "placeholder": "무엇이든 물어보세요... @를 입력하면 세션을 선택할 수 있습니다", "profileName": "이름", "providerKind": "공급자", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index eca02f68..a6291b6d 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -141,6 +141,7 @@ "disabled": "AI 助手未启用", "empty": "让 AI 帮你解释终端输出或生成命令。", "enableAutoExecution": "开启全自动", + "enableFullAccess": "启用完全权限", "enableOneModelHint": "至少启用一个模型才能在 AI 面板使用。", "enabled": "启用 AI 助手", "errorDetected": "检测到终端错误输出", @@ -161,7 +162,7 @@ "externalMcpAllSessions": "所有会话", "externalMcpAllowOnce": "允许一次", "externalMcpAllowSession": "本次连接中允许", - "externalMcpApprovalDesc": "一个 MCP 客户端正在请求访问 NyaTerm 会话。", + "externalMcpApprovalDesc": "一个 MCP 客户端正在请求使用 NyaTerm 能力。", "externalMcpApprovalTitle": "外部 MCP 审批", "externalMcpCapability": "能力", "externalMcpClient": "客户端", @@ -169,22 +170,20 @@ "externalMcpCopyConfig": "复制配置", "externalMcpCurrentWindow": "当前窗口", "externalMcpDeny": "拒绝", - "externalMcpDesc": "允许外部 MCP 客户端使用所选 NyaTerm 会话的启用时快照。", + "externalMcpDesc": "持久允许外部 MCP 客户端发现连接并使用作用域内的 NyaTerm 会话。", "externalMcpDisabled": "已禁用", "externalMcpEnabled": "启用外部 MCP", "externalMcpError": "错误", - "externalMcpIdleTimeout": "空闲超时(分钟)", - "externalMcpPersistent": "持久", "externalMcpRisk": "风险", "externalMcpRunning": "运行中", "externalMcpRuntimeSummary": "窗口:{{window}} · 会话:{{sessions}} · 连接:{{connections}}", "externalMcpScope": "会话范围", - "externalMcpServerMode": "服务模式", - "externalMcpSession": "会话", - "externalMcpTemporary": "临时", + "externalMcpTarget": "目标", "fileActions": "文件右键 AI 功能", "fileUnsupported": "该文件暂不支持 AI 分析", "formattingResponse": "整理中", + "fullAccessConfirmDesc": "{{target}} 将可以通过 NyaTerm 直接执行高风险命令、写入、覆盖和删除操作,不再请求确认。会话范围、参数校验和审计仍然生效。", + "fullAccessConfirmTitle": "启用完全权限?", "general": "常规", "generate": "生成", "generateCommand": "AI 生成命令", @@ -240,10 +239,15 @@ "notConfigured": "未配置", "notInstalled": "未安装", "panelMetaMultiTarget": "{{target}} + {{count}} 个会话", - "permissionAuto": "自动", - "permissionConfirm": "确认", + "permissionAuto": "安全自动", + "permissionAutoDesc": "自动放行本地识别为安全的操作;未知、高风险和破坏性操作仍需确认。", + "permissionConfirm": "每次确认", + "permissionConfirmDesc": "敏感读取和所有写入操作都需要确认。", + "permissionFullAccess": "完全权限", + "permissionFullAccessDesc": "作用域内的所有 NyaTerm 能力均不再审批,包括高风险和破坏性操作。", "permissionMode": "权限模式", - "permissionObserver": "观察", + "permissionObserver": "只读", + "permissionObserverDesc": "允许普通读取;敏感读取需要确认;禁止写入操作。", "placeholder": "描述你的需求… 输入 @ 选择目标会话", "profileName": "名称", "providerKind": "供应商", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 85e3dac9..5eadf16a 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -141,6 +141,7 @@ "disabled": "AI 助手未啟用", "empty": "讓 AI 幫你解釋終端輸出或產生命令。", "enableAutoExecution": "開啟全自動", + "enableFullAccess": "啟用完全權限", "enableOneModelHint": "至少啟用一個模型才能在 AI 面板使用。", "enabled": "啟用 AI 助手", "errorDetected": "偵測到終端錯誤輸出", @@ -161,7 +162,7 @@ "externalMcpAllSessions": "所有工作階段", "externalMcpAllowOnce": "允許一次", "externalMcpAllowSession": "此連線期間允許", - "externalMcpApprovalDesc": "MCP 用戶端正在要求存取 NyaTerm 工作階段。", + "externalMcpApprovalDesc": "MCP 用戶端正在要求使用 NyaTerm 功能。", "externalMcpApprovalTitle": "外部 MCP 核准", "externalMcpCapability": "功能", "externalMcpClient": "用戶端", @@ -169,22 +170,20 @@ "externalMcpCopyConfig": "複製設定", "externalMcpCurrentWindow": "目前視窗", "externalMcpDeny": "拒絕", - "externalMcpDesc": "允許外部 MCP 用戶端使用所選 NyaTerm 工作階段的啟用時快照。", + "externalMcpDesc": "持久允許外部 MCP 用戶端探索連線並使用範圍內的 NyaTerm 工作階段。", "externalMcpDisabled": "已停用", "externalMcpEnabled": "啟用外部 MCP", "externalMcpError": "錯誤", - "externalMcpIdleTimeout": "閒置逾時(分鐘)", - "externalMcpPersistent": "持久", "externalMcpRisk": "風險", "externalMcpRunning": "執行中", "externalMcpRuntimeSummary": "視窗:{{window}} · 工作階段:{{sessions}} · 連線:{{connections}}", "externalMcpScope": "工作階段範圍", - "externalMcpServerMode": "伺服器模式", - "externalMcpSession": "工作階段", - "externalMcpTemporary": "暫時", + "externalMcpTarget": "目標", "fileActions": "檔案右鍵 AI 功能", "fileUnsupported": "該檔案暫不支援 AI 分析", "formattingResponse": "整理中", + "fullAccessConfirmDesc": "{{target}} 將可以透過 NyaTerm 直接執行高風險命令、寫入、覆寫和刪除操作,不再要求確認。工作階段範圍、參數驗證和稽核仍然有效。", + "fullAccessConfirmTitle": "啟用完全權限?", "general": "一般", "generate": "產生", "generateCommand": "AI 產生命令", @@ -240,10 +239,15 @@ "notConfigured": "未設定", "notInstalled": "未安裝", "panelMetaMultiTarget": "{{target}} + {{count}} 個工作階段", - "permissionAuto": "自動", - "permissionConfirm": "確認", + "permissionAuto": "安全自動", + "permissionAutoDesc": "自動允許本機識別為安全的操作;未知、高風險和破壞性操作仍需確認。", + "permissionConfirm": "每次確認", + "permissionConfirmDesc": "敏感讀取和所有寫入操作都需要確認。", + "permissionFullAccess": "完全權限", + "permissionFullAccessDesc": "範圍內的所有 NyaTerm 功能均不再核准,包括高風險和破壞性操作。", "permissionMode": "權限模式", - "permissionObserver": "觀察", + "permissionObserver": "唯讀", + "permissionObserverDesc": "允許一般讀取;敏感讀取需要確認;禁止寫入操作。", "placeholder": "描述你的需求… 輸入 @ 選擇目標工作階段", "profileName": "名稱", "providerKind": "供應商", From 2c74efb7e35add1903de05937d9b023eb87f8fe1 Mon Sep 17 00:00:00 2001 From: Kang Date: Mon, 31 Aug 2026 22:35:09 +0800 Subject: [PATCH 24/32] perf(terminal): implement snapshot restore functionality and enhance terminal refresh behavior - Added support for snapshot restoration in the terminal, allowing for smoother recovery of terminal state during hibernation and reconnection. - Updated the `useTerminalRefreshEffects` hook to suppress refreshes while a snapshot is being restored, improving performance and user experience. - Introduced a new `createXTerminalSnapshotRestoreController` to manage the snapshot restoration process, including phases for replaying and finalizing the restoration. - Enhanced tests for terminal refresh effects and added new tests for snapshot restoration to ensure correct behavior during terminal state transitions. --- src/components/terminal/XTerminal.tsx | 169 ++++++++++++------ .../useTerminalRefreshEffects.test.ts | 44 ++++- .../terminal/useTerminalRefreshEffects.ts | 23 ++- .../xterminalHibernationController.test.ts | 34 +++- .../xterminalHibernationController.ts | 6 +- .../terminal/xterminalSessionEvents.test.ts | 37 ++++ .../terminal/xterminalSessionEvents.ts | 19 +- ...xterminalSnapshotRestoreController.test.ts | 106 +++++++++++ .../xterminalSnapshotRestoreController.ts | 71 ++++++++ src/hooks/useTerminalSettings.test.ts | 82 +++++++-- src/hooks/useTerminalSettings.ts | 31 +++- 11 files changed, 528 insertions(+), 94 deletions(-) create mode 100644 src/components/terminal/xterminalSessionEvents.test.ts create mode 100644 src/components/terminal/xterminalSnapshotRestoreController.test.ts create mode 100644 src/components/terminal/xterminalSnapshotRestoreController.ts diff --git a/src/components/terminal/XTerminal.tsx b/src/components/terminal/XTerminal.tsx index 01ae136a..bbac1972 100644 --- a/src/components/terminal/XTerminal.tsx +++ b/src/components/terminal/XTerminal.tsx @@ -111,6 +111,7 @@ import { installXTerminalKeyboardController } from "./xterminalKeyboardControlle import { createXTerminalOutputController } from "./xterminalOutputController"; import { installXTerminalSelectionController } from "./xterminalSelectionController"; import { createXTerminalSessionEvents } from "./xterminalSessionEvents"; +import { createXTerminalSnapshotRestoreController } from "./xterminalSnapshotRestoreController"; import type { HibernationLogEvent, HibernationPhase, @@ -165,6 +166,15 @@ export default function XTerminal({ null, ); const [terminalReady, setTerminalReady] = useState(false); + const [restoringSnapshot, setRestoringSnapshot] = useState(false); + const restoringSnapshotRef = useRef(false); + const [snapshotRestoreController] = useState(() => + createXTerminalSnapshotRestoreController({ + restoringRef: restoringSnapshotRef, + setRestoring: setRestoringSnapshot, + setTerminalReady, + }), + ); const [performanceMode, setPerformanceMode] = useState("normal"); const [terminalGeneration, setTerminalGeneration] = useState(0); @@ -723,6 +733,16 @@ export default function XTerminal({ setPerformanceMode("normal"); let disposed = false; + const preservedReconnectSnapshot = + hibernationSnapshotRef.current ?? + preservedReconnectContentRef.current ?? + consumePreservedTerminalReconnectContent(sessionId); + const restoringInitialSnapshot = snapshotRestoreController.begin( + preservedReconnectSnapshot, + ); + hibernationSnapshotRef.current = null; + preservedReconnectContentRef.current = null; + const terminal = new Terminal({ scrollback: terminalSettings.scrollback_lines, cursorBlink: appearance.cursor_blink, @@ -888,12 +908,6 @@ export default function XTerminal({ } }; - const preservedReconnectSnapshot = - hibernationSnapshotRef.current ?? - preservedReconnectContentRef.current ?? - consumePreservedTerminalReconnectContent(sessionId); - hibernationSnapshotRef.current = null; - preservedReconnectContentRef.current = null; const initialReplayPromise = preservedReconnectSnapshot?.content ? writeTextInFrames(terminal, preservedReconnectSnapshot.content).then( () => { @@ -1577,6 +1591,7 @@ export default function XTerminal({ if (!isTerminalAlive()) return; sendBackendResize(terminal.cols, terminal.rows, result.reason); refreshGutter(); + snapshotRestoreController.completeAfterFinalFit(); }; const fitScheduler = createTerminalFitScheduler({ @@ -1740,12 +1755,27 @@ export default function XTerminal({ }; const repaintVisibleTerminal = () => { - if (!visibleRef.current || !isTerminalAlive()) return; + if ( + restoringSnapshotRef.current || + !visibleRef.current || + !isTerminalAlive() + ) + return; requestAnimationFrame(() => { - if (!visibleRef.current || !isTerminalAlive()) return; + if ( + restoringSnapshotRef.current || + !visibleRef.current || + !isTerminalAlive() + ) + return; terminal.refresh(0, Math.max(0, terminal.rows - 1)); requestAnimationFrame(() => { - if (!visibleRef.current || !isTerminalAlive()) return; + if ( + restoringSnapshotRef.current || + !visibleRef.current || + !isTerminalAlive() + ) + return; terminal.refresh(0, Math.max(0, terminal.rows - 1)); }); }); @@ -1813,42 +1843,45 @@ export default function XTerminal({ const { applyVisibilityPolicy, noteOutputActivity } = createXTerminalHibernationController({ - sessionId, - terminal, - outputDrain, - visibleRef, - sessionTypeRef, - aiCapturingRef, - zmodemActiveRef, - syncPeerSessionIdsRef, - outputDrainRef, - disconnectedRef, - reconnectingRef, - hibernateTimerRef, - hibernationEpochRef, - hibernationPendingRef, - hibernationPhaseRef, - detachedHibernateEpochRef, - hibernationSnapshotRef, - hibernationCleanupRef, - hibernatedRef, - lastOutputActivityAtRef, - showSearchBar, - activeMode, - isTerminalAlive, - logHibernation, - clearHibernateTimer, - enterDisconnectedStateIfAttachSessionMissing, - updateOutputDrainMode, - flushFrameGateAndDrain, - captureReconnectSnapshot, - setTerminalReady, - setHibernated, - setTerminalGeneration, - maybeRecoverPerformanceMode, - refreshOutputPressureMode, - repaintVisibleTerminal, - }); + sessionId, + terminal, + outputDrain, + visibleRef, + sessionTypeRef, + aiCapturingRef, + zmodemActiveRef, + syncPeerSessionIdsRef, + outputDrainRef, + disconnectedRef, + reconnectingRef, + hibernateTimerRef, + hibernationEpochRef, + hibernationPendingRef, + hibernationPhaseRef, + detachedHibernateEpochRef, + hibernationSnapshotRef, + hibernationCleanupRef, + hibernatedRef, + lastOutputActivityAtRef, + showSearchBar, + activeMode, + isTerminalAlive, + logHibernation, + clearHibernateTimer, + enterDisconnectedStateIfAttachSessionMissing, + updateOutputDrainMode, + flushFrameGateAndDrain, + captureReconnectSnapshot, + beginSnapshotRestore: (snapshot) => { + snapshotRestoreController.begin(snapshot); + }, + setTerminalReady, + setHibernated, + setTerminalGeneration, + maybeRecoverPerformanceMode, + refreshOutputPressureMode, + repaintVisibleTerminal, + }); handleVisibilityChangeRef.current = applyVisibilityPolicy; applyVisibilityPolicy(); @@ -1888,7 +1921,7 @@ export default function XTerminal({ zmodemHandler, replayPendingWakeEvents, }); - void sessionEvents.setup(); + const sessionSetupPromise = sessionEvents.setup(); const removePreviewListener = listenSessionInputPreview( sessionId, @@ -1914,7 +1947,9 @@ export default function XTerminal({ `\r\n\x1b[36m[${tRef.current("terminal.reconnecting")}]\x1b[0m\r\n`, ); const newSessionId = await createReconnectedSession(); - preservedReconnectContentRef.current = captureReconnectSnapshot(); + const reconnectSnapshot = captureReconnectSnapshot(); + preservedReconnectContentRef.current = reconnectSnapshot; + snapshotRestoreController.begin(reconnectSnapshot); const oldSessionId = sessionIdRef.current; disconnectedRef.current = false; disconnectedNoticeShownRef.current = false; @@ -2098,6 +2133,7 @@ export default function XTerminal({ }); const observer = new ResizeObserver((entries) => { + if (restoringSnapshotRef.current) return; const entry = entries[0]; if (!entry) return; fitScheduler.observeResize( @@ -2133,16 +2169,24 @@ export default function XTerminal({ pasteClipboard, }); - fitScheduler.schedule({ - reason: "initial", - force: true, - refresh: true, - onComplete: () => { + if (restoringInitialSnapshot) { + void sessionSetupPromise.then(() => { if (!isTerminalAlive()) return; - setTerminalReady(true); - refreshGutter(); - }, - }); + snapshotRestoreController.markReplayAndAttachComplete(); + }); + } else { + void sessionSetupPromise; + fitScheduler.schedule({ + reason: "initial", + force: true, + refresh: true, + onComplete: () => { + if (!isTerminalAlive()) return; + setTerminalReady(true); + refreshGutter(); + }, + }); + } return () => { disposed = true; @@ -2233,7 +2277,9 @@ export default function XTerminal({ latestLifecycleState.terminalTransparencyEnabled !== terminalTransparencyEnabled ) { - preservedReconnectContentRef.current = captureReconnectSnapshot(); + const reconnectSnapshot = captureReconnectSnapshot(); + preservedReconnectContentRef.current = reconnectSnapshot; + snapshotRestoreController.begin(reconnectSnapshot); } terminal.dispose(); terminalRef.current = null; @@ -2258,6 +2304,7 @@ export default function XTerminal({ visible && active, terminalInstance, sessionId, + restoringSnapshotRef, ); // isDark is derived from the terminal theme background so built-in rule colors @@ -2299,6 +2346,7 @@ export default function XTerminal({ showGutter, showContentPadding, workspacePaddingSetting: terminalSettings.show_workspace_padding, + snapshotRestoringRef: restoringSnapshotRef, }); const searchInputRef = useRef(null); @@ -2407,7 +2455,7 @@ export default function XTerminal({ backgroundColor: terminalBackground, }} > - {showGutter && terminalReady && ( + {showGutter && terminalReady && !restoringSnapshot && ( ({ - scaleChanged: undefined as ((event: { payload: { scaleFactor: number } }) => void) | undefined, + scaleChanged: undefined as + | ((event: { payload: { scaleFactor: number } }) => void) + | undefined, })); vi.mock("@tauri-apps/api/window", () => ({ @@ -13,7 +15,9 @@ vi.mock("@tauri-apps/api/window", () => ({ onResized: async () => vi.fn(), onMoved: async () => vi.fn(), onFocusChanged: async () => vi.fn(), - onScaleChanged: async (callback: (event: { payload: { scaleFactor: number } }) => void) => { + onScaleChanged: async ( + callback: (event: { payload: { scaleFactor: number } }) => void, + ) => { windowMocks.scaleChanged = callback; return vi.fn(); }, @@ -69,7 +73,9 @@ describe("useTerminalRefreshEffects", () => { showContentPadding: false, }), ); - await waitFor(() => expect(windowMocks.scaleChanged).toBeTypeOf("function")); + await waitFor(() => + expect(windowMocks.scaleChanged).toBeTypeOf("function"), + ); windowMocks.scaleChanged?.({ payload: { scaleFactor: 2 } }); expect(schedule).toHaveBeenCalledWith( @@ -81,4 +87,34 @@ describe("useTerminalRefreshEffects", () => { }), ); }); + + it("suppresses incidental refreshes while a snapshot restore is finalizing", async () => { + const schedule = vi.fn(); + const snapshotRestoringRef = { current: true }; + renderHook(() => + useTerminalRefreshEffects({ + terminalRef: { current: {} as Terminal }, + fitSchedulerRef: { + current: { schedule } as unknown as TerminalFitScheduler, + }, + active: true, + visible: true, + terminalReady: true, + performanceMode: "normal", + sessionId: "session-1", + showGutter: false, + showContentPadding: false, + snapshotRestoringRef, + }), + ); + await waitFor(() => + expect(windowMocks.scaleChanged).toBeTypeOf("function"), + ); + schedule.mockClear(); + + window.dispatchEvent(new Event("nyaterm:refresh-terminals")); + windowMocks.scaleChanged?.({ payload: { scaleFactor: 2 } }); + + expect(schedule).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/terminal/useTerminalRefreshEffects.ts b/src/components/terminal/useTerminalRefreshEffects.ts index 12ec88c8..fb3a6181 100644 --- a/src/components/terminal/useTerminalRefreshEffects.ts +++ b/src/components/terminal/useTerminalRefreshEffects.ts @@ -17,6 +17,7 @@ interface UseTerminalRefreshEffectsParams { showGutter: boolean; showContentPadding: boolean; workspacePaddingSetting?: boolean; + snapshotRestoringRef?: RefObject; } export function useTerminalRefreshEffects({ @@ -30,6 +31,7 @@ export function useTerminalRefreshEffects({ showGutter, showContentPadding, workspacePaddingSetting, + snapshotRestoringRef, }: UseTerminalRefreshEffectsParams) { useEffect(() => { if (terminalReady && fitSchedulerRef.current && terminalRef.current) { @@ -96,7 +98,13 @@ export function useTerminalRefreshEffects({ useEffect(() => { const handleRefresh = () => { - if (!visible || !fitSchedulerRef.current || !terminalRef.current) return; + if ( + snapshotRestoringRef?.current || + !visible || + !fitSchedulerRef.current || + !terminalRef.current + ) + return; fitSchedulerRef.current.schedule({ reason: "global-refresh", @@ -110,7 +118,7 @@ export function useTerminalRefreshEffects({ return () => { window.removeEventListener("nyaterm:refresh-terminals", handleRefresh); }; - }, [active, fitSchedulerRef, terminalRef, visible]); + }, [active, fitSchedulerRef, snapshotRestoringRef, terminalRef, visible]); useEffect(() => { if (!terminalReady) return; @@ -128,6 +136,7 @@ export function useTerminalRefreshEffects({ force = false, scaleFactor?: number, ) => { + if (snapshotRestoringRef?.current) return; const nextDevicePixelRatio = window.devicePixelRatio || 1; const dprChanged = Math.abs(nextDevicePixelRatio - lastDevicePixelRatio) > 0.001; if (dprChanged) { @@ -216,7 +225,15 @@ export function useTerminalRefreshEffects({ unlistenFocused?.(); unlistenScale?.(); }; - }, [active, fitSchedulerRef, sessionId, terminalReady, terminalRef, visible]); + }, [ + active, + fitSchedulerRef, + sessionId, + snapshotRestoringRef, + terminalReady, + terminalRef, + visible, + ]); useEffect(() => { const handleClear = () => { diff --git a/src/components/terminal/xterminalHibernationController.test.ts b/src/components/terminal/xterminalHibernationController.test.ts index 649e52c1..81f28e14 100644 --- a/src/components/terminal/xterminalHibernationController.test.ts +++ b/src/components/terminal/xterminalHibernationController.test.ts @@ -36,6 +36,7 @@ function createHarness( sessionType?: SessionType; lastOutputActivityAt?: number; flushFrameGateAndDrain?: (reason: string) => Promise; + reconnectSnapshot?: TerminalReconnectSnapshot | null; } = {}, ) { let now = 0; @@ -65,6 +66,7 @@ function createHarness( const setTerminalGeneration = vi.fn(); const updateOutputDrainMode = vi.fn(); const repaintVisibleTerminal = vi.fn(); + const beginSnapshotRestore = vi.fn(); const flushFrameGateAndDrain = options.flushFrameGateAndDrain ?? vi.fn(async () => true); @@ -112,7 +114,8 @@ function createHarness( enterDisconnectedStateIfAttachSessionMissing: () => false, updateOutputDrainMode, flushFrameGateAndDrain, - captureReconnectSnapshot: () => null, + captureReconnectSnapshot: () => options.reconnectSnapshot ?? null, + beginSnapshotRestore, setTerminalReady, setHibernated, setTerminalGeneration, @@ -148,6 +151,7 @@ function createHarness( return { advance, + beginSnapshotRestore, controller, flushFrameGateAndDrain, hibernatedRef, @@ -184,6 +188,26 @@ describe("createXTerminalHibernationController", () => { expect(setHibernated).toHaveBeenCalledWith(true); }); + it("starts snapshot restore before disposing a hibernated renderer", async () => { + const snapshot: TerminalReconnectSnapshot = { + content: "preserved terminal history", + lineTimestamps: [], + captureStartLine: 0, + captureEndLine: 0, + }; + const { advance, beginSnapshotRestore, setHibernated } = createHarness({ + reconnectSnapshot: snapshot, + }); + + advance(XTERM_PERFORMANCE_CONFIG.lifecycle.deepHibernateDelayMs); + await settle(); + + expect(beginSnapshotRestore).toHaveBeenCalledWith(snapshot); + expect(beginSnapshotRestore.mock.invocationCallOrder[0]).toBeLessThan( + setHibernated.mock.invocationCallOrder[0], + ); + }); + it("does not hibernate while hidden output activity continues", async () => { const { advance, controller } = createHarness(); @@ -243,7 +267,9 @@ describe("createXTerminalHibernationController", () => { it("never hibernates a visible terminal from output idleness", async () => { const { advance, controller, repaintVisibleTerminal, timers } = - createHarness({ visible: true }); + createHarness({ + visible: true, + }); expect(timers.size).toBe(0); expect(repaintVisibleTerminal).toHaveBeenCalled(); @@ -286,7 +312,9 @@ describe("createXTerminalHibernationController", () => { .mockResolvedValueOnce(true) .mockReturnValueOnce(afterDetachDrain.promise); const { advance, controller, hibernatedRef, hibernationPhaseRef } = - createHarness({ flushFrameGateAndDrain }); + createHarness({ + flushFrameGateAndDrain, + }); advance(XTERM_PERFORMANCE_CONFIG.lifecycle.deepHibernateDelayMs); await settle(); diff --git a/src/components/terminal/xterminalHibernationController.ts b/src/components/terminal/xterminalHibernationController.ts index 0efda2b6..71ac6212 100644 --- a/src/components/terminal/xterminalHibernationController.ts +++ b/src/components/terminal/xterminalHibernationController.ts @@ -57,6 +57,7 @@ interface CreateXTerminalHibernationControllerParams { updateOutputDrainMode: () => void; flushFrameGateAndDrain: (reason: string) => Promise; captureReconnectSnapshot: () => TerminalReconnectSnapshot | null; + beginSnapshotRestore: (snapshot: TerminalReconnectSnapshot | null) => void; setTerminalReady: (ready: boolean) => void; setHibernated: (hibernated: boolean) => void; setTerminalGeneration: (updater: (generation: number) => number) => void; @@ -104,6 +105,7 @@ export function createXTerminalHibernationController({ updateOutputDrainMode, flushFrameGateAndDrain, captureReconnectSnapshot, + beginSnapshotRestore, setTerminalReady, setHibernated, setTerminalGeneration, @@ -339,7 +341,9 @@ export function createXTerminalHibernationController({ return; } - hibernationSnapshotRef.current = captureReconnectSnapshot(); + const hibernationSnapshot = captureReconnectSnapshot(); + hibernationSnapshotRef.current = hibernationSnapshot; + beginSnapshotRestore(hibernationSnapshot); hibernationCleanupRef.current = true; hibernationPhaseRef.current = "hibernated"; outputDrain.setMode("hibernated"); diff --git a/src/components/terminal/xterminalSessionEvents.test.ts b/src/components/terminal/xterminalSessionEvents.test.ts new file mode 100644 index 00000000..9045bdb5 --- /dev/null +++ b/src/components/terminal/xterminalSessionEvents.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import { replaySnapshotBeforeAttach } from "./xterminalSessionEvents"; + +function createDeferred() { + let resolve!: () => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +describe("replaySnapshotBeforeAttach", () => { + it("replays the snapshot before pending wake events and backend attach", async () => { + const replay = createDeferred(); + const order: string[] = []; + const attachSession = vi.fn(async () => { + order.push("attach"); + }); + const restore = replaySnapshotBeforeAttach({ + initialReplayPromise: replay.promise.then(() => { + order.push("replay"); + }), + replayPendingWakeEvents: () => order.push("pending-wake"), + attachSession, + }); + + await Promise.resolve(); + expect(attachSession).not.toHaveBeenCalled(); + expect(order).toEqual([]); + + replay.resolve(); + await restore; + + expect(order).toEqual(["replay", "pending-wake", "attach"]); + expect(attachSession).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/terminal/xterminalSessionEvents.ts b/src/components/terminal/xterminalSessionEvents.ts index 9257404c..19217c58 100644 --- a/src/components/terminal/xterminalSessionEvents.ts +++ b/src/components/terminal/xterminalSessionEvents.ts @@ -27,6 +27,16 @@ interface ZmodemHandler { handle: (payload: ZmodemEventPayload) => void; } +export async function replaySnapshotBeforeAttach(options: { + initialReplayPromise: Promise; + replayPendingWakeEvents: () => void; + attachSession: () => Promise; +}) { + await options.initialReplayPromise.catch(() => {}); + options.replayPendingWakeEvents(); + await options.attachSession(); +} + interface CreateXTerminalSessionEventsParams { sessionId: string; terminal: Terminal; @@ -278,11 +288,12 @@ export function createXTerminalSessionEvents({ ); if (!addUnlistener(nextZmodemUnlisten)) return; - replayPendingWakeEvents(); - try { - await initialReplayPromise.catch(() => {}); - await invoke("attach_session", { sessionId }); + await replaySnapshotBeforeAttach({ + initialReplayPromise, + replayPendingWakeEvents, + attachSession: () => invoke("attach_session", { sessionId }), + }); detachedHibernateEpochRef.current = null; if ( hibernationPhaseRef.current === "waking" || diff --git a/src/components/terminal/xterminalSnapshotRestoreController.test.ts b/src/components/terminal/xterminalSnapshotRestoreController.test.ts new file mode 100644 index 00000000..9f666e4e --- /dev/null +++ b/src/components/terminal/xterminalSnapshotRestoreController.test.ts @@ -0,0 +1,106 @@ +import type { Terminal } from "@xterm/xterm"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TerminalReconnectSnapshot } from "@/lib/terminalReconnectHistory"; +import { writeTextInFrames } from "./xterminalOutputQueue"; +import { createXTerminalSnapshotRestoreController } from "./xterminalSnapshotRestoreController"; + +const snapshot = (content: string): TerminalReconnectSnapshot => ({ + content, + lineTimestamps: [], + captureStartLine: 0, + captureEndLine: 0, +}); + +describe("createXTerminalSnapshotRestoreController", () => { + let animationFrames: FrameRequestCallback[]; + + beforeEach(() => { + animationFrames = []; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + animationFrames.push(callback); + return animationFrames.length; + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const createHarness = () => { + let rendererVisible = true; + const readyStates: boolean[] = []; + const restoringRef = { current: false }; + const controller = createXTerminalSnapshotRestoreController({ + restoringRef, + setRestoring: (restoring) => { + rendererVisible = !restoring; + }, + setTerminalReady: (ready) => readyStates.push(ready), + }); + return { + controller, + readyStates, + rendererVisible: () => rendererVisible, + }; + }; + + const runNextFrame = () => { + const callback = animationFrames.shift(); + expect(callback).toBeTypeOf("function"); + callback?.(performance.now()); + }; + + it("keeps the renderer hidden through every replay frame until final fit", async () => { + const harness = createHarness(); + const terminal = { + write: vi.fn((_data: string, callback: () => void) => callback()), + } as unknown as Terminal; + const replay = writeTextInFrames(terminal, "x".repeat(96 * 1024)); + + expect(harness.controller.begin(snapshot("large snapshot"))).toBe(true); + expect(harness.rendererVisible()).toBe(false); + + runNextFrame(); + expect(terminal.write).toHaveBeenCalledTimes(1); + expect(harness.rendererVisible()).toBe(false); + + runNextFrame(); + expect(terminal.write).toHaveBeenCalledTimes(2); + expect(harness.rendererVisible()).toBe(false); + + while (animationFrames.length > 0) runNextFrame(); + await replay; + expect(harness.controller.getPhase()).toBe("replaying"); + expect(harness.rendererVisible()).toBe(false); + + expect(harness.controller.markReplayAndAttachComplete()).toBe(true); + expect(harness.controller.getPhase()).toBe("awaiting-final-fit"); + expect(harness.rendererVisible()).toBe(false); + expect(harness.readyStates[harness.readyStates.length - 1]).toBe(true); + + expect(harness.controller.completeAfterFinalFit()).toBe(true); + expect(harness.rendererVisible()).toBe(true); + expect(harness.controller.completeAfterFinalFit()).toBe(false); + }); + + it("does not hide a terminal when there is no snapshot content", () => { + const harness = createHarness(); + + expect(harness.controller.begin(null)).toBe(false); + expect(harness.controller.begin(snapshot(""))).toBe(false); + expect(harness.rendererVisible()).toBe(true); + expect(harness.controller.getPhase()).toBe("idle"); + expect(harness.readyStates).toEqual([]); + }); + + it("clears a stale restore barrier when the replacement has no snapshot", () => { + const harness = createHarness(); + + harness.controller.begin(snapshot("old session")); + expect(harness.rendererVisible()).toBe(false); + + expect(harness.controller.begin(null)).toBe(false); + expect(harness.rendererVisible()).toBe(true); + expect(harness.controller.getPhase()).toBe("idle"); + }); +}); diff --git a/src/components/terminal/xterminalSnapshotRestoreController.ts b/src/components/terminal/xterminalSnapshotRestoreController.ts new file mode 100644 index 00000000..9799d921 --- /dev/null +++ b/src/components/terminal/xterminalSnapshotRestoreController.ts @@ -0,0 +1,71 @@ +import type { TerminalReconnectSnapshot } from "@/lib/terminalReconnectHistory"; + +interface MutableRef { + current: T; +} + +export type SnapshotRestorePhase = + | "idle" + | "replaying" + | "awaiting-final-fit" + | "revealed"; + +interface CreateXTerminalSnapshotRestoreControllerParams { + restoringRef: MutableRef; + setRestoring: (restoring: boolean) => void; + setTerminalReady: (ready: boolean) => void; +} + +export function createXTerminalSnapshotRestoreController({ + restoringRef, + setRestoring, + setTerminalReady, +}: CreateXTerminalSnapshotRestoreControllerParams) { + let phase: SnapshotRestorePhase = "idle"; + + const begin = (snapshot: TerminalReconnectSnapshot | null | undefined) => { + if (!snapshot?.content) { + if (restoringRef.current) { + phase = "idle"; + restoringRef.current = false; + setRestoring(false); + } + return false; + } + + phase = "replaying"; + restoringRef.current = true; + setRestoring(true); + setTerminalReady(false); + return true; + }; + + const markReplayAndAttachComplete = () => { + if (phase !== "replaying") return false; + + phase = "awaiting-final-fit"; + setTerminalReady(true); + return true; + }; + + const completeAfterFinalFit = () => { + if (phase !== "awaiting-final-fit") return false; + + phase = "revealed"; + restoringRef.current = false; + setRestoring(false); + return true; + }; + + return { + begin, + markReplayAndAttachComplete, + completeAfterFinalFit, + isRestoring: () => restoringRef.current, + getPhase: () => phase, + }; +} + +export type XTerminalSnapshotRestoreController = ReturnType< + typeof createXTerminalSnapshotRestoreController +>; diff --git a/src/hooks/useTerminalSettings.test.ts b/src/hooks/useTerminalSettings.test.ts index 1d776474..a3430519 100644 --- a/src/hooks/useTerminalSettings.test.ts +++ b/src/hooks/useTerminalSettings.test.ts @@ -1,5 +1,5 @@ -import type { Terminal } from "@xterm/xterm"; import { renderHook } from "@testing-library/react"; +import type { Terminal } from "@xterm/xterm"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { TerminalFitScheduler } from "@/components/terminal/terminalFitScheduler"; import type { TerminalColors } from "@/lib/themes"; @@ -7,7 +7,10 @@ import type { AppSettings } from "@/types/global"; import { useTerminalSettings } from "./useTerminalSettings"; const webglMocks = vi.hoisted(() => ({ - instances: [] as Array<{ dispose: ReturnType; contextLoss?: () => void }>, + instances: [] as Array<{ + dispose: ReturnType; + contextLoss?: () => void; + }>, })); vi.mock("@xterm/addon-webgl", () => ({ @@ -19,7 +22,8 @@ vi.mock("@xterm/addon-webgl", () => ({ } onContextLoss(callback: () => void) { - webglMocks.instances[webglMocks.instances.length - 1].contextLoss = callback; + webglMocks.instances[webglMocks.instances.length - 1].contextLoss = + callback; return { dispose: vi.fn() }; } }, @@ -71,7 +75,9 @@ describe("useTerminalSettings renderer refresh", () => { return id; }); vi.stubGlobal("requestAnimationFrame", rafRequests); - vi.stubGlobal("cancelAnimationFrame", (id: number) => rafCallbacks.delete(id)); + vi.stubGlobal("cancelAnimationFrame", (id: number) => + rafCallbacks.delete(id), + ); }); afterEach(() => { @@ -87,7 +93,10 @@ describe("useTerminalSettings renderer refresh", () => { } }; - function createHookHarness(rendererVisible = true) { + function createHookHarness( + rendererVisible = true, + snapshotRestoring = false, + ) { const terminal = { rows: 24, options: {}, @@ -99,8 +108,18 @@ describe("useTerminalSettings renderer refresh", () => { const fitSchedulerRef = { current: { schedule: vi.fn() } as unknown as TerminalFitScheduler, }; + const snapshotRestoringRef = { current: snapshotRestoring }; + const initialProps = { + visible: rendererVisible, + colors: theme("#000000"), + ui: appearance(14), + }; const hook = renderHook( - (props: { visible: boolean; colors: TerminalColors; ui: AppSettings["appearance"] }) => + (props: { + visible: boolean; + colors: TerminalColors; + ui: AppSettings["appearance"]; + }) => useTerminalSettings( terminalRef, fitSchedulerRef, @@ -111,12 +130,20 @@ describe("useTerminalSettings renderer refresh", () => { props.visible, terminal, "session-1", + snapshotRestoringRef, ), { - initialProps: { visible: rendererVisible, colors: theme("#000000"), ui: appearance(14) }, + initialProps, }, ); - return { ...hook, terminal, terminalRef, fitSchedulerRef }; + return { + ...hook, + terminal, + terminalRef, + fitSchedulerRef, + initialProps, + snapshotRestoringRef, + }; } it("installs WebGL and schedules only one reveal chain", () => { @@ -137,28 +164,59 @@ describe("useTerminalSettings renderer refresh", () => { vi.mocked(harness.terminal.refresh).mockClear(); vi.mocked(harness.terminal.clearTextureAtlas).mockClear(); - harness.rerender({ visible: false, colors: stableColors, ui: stableAppearance }); + harness.rerender({ + visible: false, + colors: stableColors, + ui: stableAppearance, + }); flushAnimationFrames(); vi.mocked(harness.terminal.refresh).mockClear(); vi.mocked(harness.terminal.clearTextureAtlas).mockClear(); - harness.rerender({ visible: true, colors: stableColors, ui: stableAppearance }); + harness.rerender({ + visible: true, + colors: stableColors, + ui: stableAppearance, + }); flushAnimationFrames(); expect(harness.terminal.refresh).toHaveBeenCalledTimes(2); expect(harness.terminal.clearTextureAtlas).not.toHaveBeenCalled(); }); + it("does not add WebGL or settings refreshes during snapshot restore", () => { + const harness = createHookHarness(true, true); + flushAnimationFrames(); + + expect(harness.terminal.loadAddon).toHaveBeenCalledTimes(1); + expect(harness.terminal.refresh).not.toHaveBeenCalled(); + expect(harness.fitSchedulerRef.current?.schedule).not.toHaveBeenCalled(); + + harness.snapshotRestoringRef.current = false; + harness.rerender(harness.initialProps); + flushAnimationFrames(); + + expect(harness.terminal.refresh).not.toHaveBeenCalled(); + }); + it("still clears the texture atlas for theme and font changes", () => { const harness = createHookHarness(); flushAnimationFrames(); vi.mocked(harness.terminal.clearTextureAtlas).mockClear(); - harness.rerender({ visible: true, colors: theme("#101010"), ui: appearance(14) }); + harness.rerender({ + visible: true, + colors: theme("#101010"), + ui: appearance(14), + }); flushAnimationFrames(); expect(harness.terminal.clearTextureAtlas).toHaveBeenCalledTimes(1); vi.mocked(harness.terminal.clearTextureAtlas).mockClear(); - harness.rerender({ visible: true, colors: theme("#101010"), ui: appearance(16) }); + harness.rerender({ + visible: true, + colors: theme("#101010"), + ui: appearance(16), + }); flushAnimationFrames(); expect(harness.terminal.clearTextureAtlas).toHaveBeenCalledTimes(1); }); diff --git a/src/hooks/useTerminalSettings.ts b/src/hooks/useTerminalSettings.ts index 5b49a46d..39263165 100644 --- a/src/hooks/useTerminalSettings.ts +++ b/src/hooks/useTerminalSettings.ts @@ -12,6 +12,10 @@ import type { AppSettings } from "@/types/global"; type TerminalRendererPreference = "dom" | "webgl" | "auto"; type ResolvedTerminalRendererMode = "dom" | "webgl"; +function isSnapshotRestoreActive(ref?: RefObject) { + return ref?.current === true; +} + function resolveTerminalRendererMode(options: { preference: TerminalRendererPreference; transparencyEnabled: boolean; @@ -34,6 +38,7 @@ export function useTerminalSettings( rendererVisible = true, terminalInstance: Terminal | null = null, sessionId?: string, + snapshotRestoringRef?: RefObject, ) { const webglAddonRef = useRef(null); const webglTerminalRef = useRef(null); @@ -82,21 +87,28 @@ export function useTerminalSettings( }, []); const scheduleTextureRefresh = useCallback(() => { + if (isSnapshotRestoreActive(snapshotRestoringRef)) return; if (textureRefreshFrameRef.current !== null) return; textureRefreshFrameRef.current = requestAnimationFrame(() => { textureRefreshFrameRef.current = null; + if (isSnapshotRestoreActive(snapshotRestoringRef)) return; const terminal = terminalRef.current; if (!terminal) return; terminal.clearTextureAtlas(); terminal.refresh(0, Math.max(0, terminal.rows - 1)); }); - }, [terminalRef]); + }, [snapshotRestoringRef, terminalRef]); const scheduleRevealRefresh = useCallback(() => { cancelRevealRefresh(); + if (isSnapshotRestoreActive(snapshotRestoringRef)) return; let remainingFrames = XTERM_PERFORMANCE_CONFIG.webgl.revealRefreshFrames; const refreshNextFrame = () => { revealRefreshFrameRef.current = requestAnimationFrame(() => { + if (isSnapshotRestoreActive(snapshotRestoringRef)) { + revealRefreshFrameRef.current = null; + return; + } const terminal = terminalRef.current; if (terminal) { terminal.refresh(0, Math.max(0, terminal.rows - 1)); @@ -111,7 +123,7 @@ export function useTerminalSettings( }); }; refreshNextFrame(); - }, [cancelRevealRefresh, terminalRef]); + }, [cancelRevealRefresh, snapshotRestoringRef, terminalRef]); useEffect(() => { return () => { @@ -253,12 +265,14 @@ export function useTerminalSettings( scheduleTextureRefresh(); // Auto-fit on font size change - fitSchedulerRef.current?.schedule({ - reason: "appearance", - force: true, - refresh: true, - clearTextureAtlas: true, - }); + if (!isSnapshotRestoreActive(snapshotRestoringRef)) { + fitSchedulerRef.current?.schedule({ + reason: "appearance", + force: true, + refresh: true, + clearTextureAtlas: true, + }); + } } }, [ appearance, @@ -266,6 +280,7 @@ export function useTerminalSettings( terminalRef, fitSchedulerRef, scheduleTextureRefresh, + snapshotRestoringRef, ]); // React to terminal core settings changes: scrollback From df6f54490ac03d87a45230520c6ae57ece74ab7e Mon Sep 17 00:00:00 2001 From: Kang Date: Mon, 31 Aug 2026 23:40:24 +0800 Subject: [PATCH 25/32] chore: bump version to v1.2.6 --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/crates/nyaterm-mcp/Cargo.lock | 2 +- src-tauri/crates/nyaterm-mcp/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 9ed1797e..03576f0e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nyaterm", "private": true, - "version": "1.2.5", + "version": "1.2.6", "description": "A modern, high-performance SSH client built with Tauri and React.", "author": "NyaKang", "homepage": "https://nyaterm.app", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2be0dcb1..32f25ba4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4866,7 +4866,7 @@ dependencies = [ [[package]] name = "nyaterm" -version = "1.2.5" +version = "1.2.6" dependencies = [ "aes 0.8.4", "aes-gcm 0.10.3", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cac634a7..68f11e94 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nyaterm" -version = "1.2.5" +version = "1.2.6" description = "A modern remote terminal workspace built with Tauri, React, and Rust." authors = ["Kang"] edition = "2024" diff --git a/src-tauri/crates/nyaterm-mcp/Cargo.lock b/src-tauri/crates/nyaterm-mcp/Cargo.lock index 49db9199..54e2c1bb 100644 --- a/src-tauri/crates/nyaterm-mcp/Cargo.lock +++ b/src-tauri/crates/nyaterm-mcp/Cargo.lock @@ -366,7 +366,7 @@ dependencies = [ [[package]] name = "nyaterm-mcp" -version = "1.2.5" +version = "1.2.6" dependencies = [ "dirs", "nyaterm-mcp-protocol", diff --git a/src-tauri/crates/nyaterm-mcp/Cargo.toml b/src-tauri/crates/nyaterm-mcp/Cargo.toml index 5e30af82..009cf523 100644 --- a/src-tauri/crates/nyaterm-mcp/Cargo.toml +++ b/src-tauri/crates/nyaterm-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nyaterm-mcp" -version = "1.2.5" +version = "1.2.6" edition = "2024" publish = false diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 941b851d..7f4934b9 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "NyaTerm", - "version": "1.2.5", + "version": "1.2.6", "identifier": "com.kang.nyaterm", "build": { "beforeDevCommand": "pnpm build:mcp-sidecar && pnpm dev", From ffc16c28d7a81a90b923e1d8ede3d7cfd062c9ee Mon Sep 17 00:00:00 2001 From: Kang Date: Tue, 1 Sep 2026 00:31:58 +0800 Subject: [PATCH 26/32] ci(build): move Win7 Cargo patch enabling logic to a separate PowerShell script - Replaced inline script in the build action with a dedicated PowerShell script (`enable-win7-cargo-patches.ps1`) for better organization and maintainability. - The new script handles the enabling of Win7-specific Cargo patches, including validation of the `ctor` package and updating the Cargo configuration. - This change improves readability and separation of concerns in the build process. --- .github/actions/build-win7/action.yml | 14 +-- .github/scripts/enable-win7-cargo-patches.ps1 | 89 +++++++++++++++++++ 2 files changed, 90 insertions(+), 13 deletions(-) create mode 100644 .github/scripts/enable-win7-cargo-patches.ps1 diff --git a/.github/actions/build-win7/action.yml b/.github/actions/build-win7/action.yml index ef3cffdb..ec6fca66 100644 --- a/.github/actions/build-win7/action.yml +++ b/.github/actions/build-win7/action.yml @@ -87,19 +87,7 @@ runs: - name: Enable Win7-only Cargo patches shell: pwsh - run: | - $ErrorActionPreference = "Stop" - - New-Item -ItemType Directory -Force -Path ".cargo" | Out-Null - @' - [patch.crates-io] - webview2-com-sys = { path = "src-tauri/vendor/webview2-com-sys" } - windows-core = { path = "src-tauri/vendor/windows-core" } - '@ | Set-Content -LiteralPath ".cargo/config.toml" -Encoding UTF8 - - cargo metadata --manifest-path src-tauri/Cargo.toml --format-version 1 | Out-Null - Write-Host "Enabled Win7-only Cargo patches:" - Get-Content -LiteralPath ".cargo/config.toml" + run: ./.github/scripts/enable-win7-cargo-patches.ps1 - name: Print build metadata shell: pwsh diff --git a/.github/scripts/enable-win7-cargo-patches.ps1 b/.github/scripts/enable-win7-cargo-patches.ps1 new file mode 100644 index 00000000..8e17f5fb --- /dev/null +++ b/.github/scripts/enable-win7-cargo-patches.ps1 @@ -0,0 +1,89 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$knownCtorVersion = "0.8.0" +$knownCtorMacrosSha256 = "86ec55f4670e68dbd0fb6f400be0374ba14ac9b621ba041561de7dc629e22fcc" + +$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$tauriManifest = Join-Path $repositoryRoot "src-tauri\Cargo.toml" +$cargoConfigDirectory = Join-Path $repositoryRoot ".cargo" +$cargoConfigPath = Join-Path $cargoConfigDirectory "config.toml" +$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) "nyaterm-win7-cargo-patches-$([Guid]::NewGuid())" +$patchedCtorRoot = Join-Path $temporaryRoot "ctor-$knownCtorVersion" + +Push-Location $repositoryRoot +try { + $metadataJson = & cargo metadata --manifest-path $tauriManifest --locked --format-version 1 + if ($LASTEXITCODE -ne 0) { + throw "cargo metadata failed while locating ctor $knownCtorVersion." + } + + $metadata = $metadataJson | ConvertFrom-Json + $ctorPackages = @($metadata.packages | Where-Object { + $_.name -eq "ctor" -and $_.version -eq $knownCtorVersion + }) + if ($ctorPackages.Count -ne 1) { + throw "Expected exactly one ctor $knownCtorVersion package, found $($ctorPackages.Count)." + } + + $ctorRoot = Split-Path -Parent $ctorPackages[0].manifest_path + $ctorMacrosPath = Join-Path $ctorRoot "src\macros\mod.rs" + if (!(Test-Path -LiteralPath $ctorMacrosPath -PathType Leaf)) { + throw "ctor macros file does not exist: $ctorMacrosPath" + } + + $actualCtorMacrosSha256 = (Get-FileHash -LiteralPath $ctorMacrosPath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualCtorMacrosSha256 -ne $knownCtorMacrosSha256) { + throw "Unexpected ctor $knownCtorVersion macros SHA256. Expected $knownCtorMacrosSha256, got $actualCtorMacrosSha256." + } + + New-Item -ItemType Directory -Path $temporaryRoot -Force | Out-Null + Copy-Item -LiteralPath $ctorRoot -Destination $patchedCtorRoot -Recurse -Force + + $patchedMacrosPath = Join-Path $patchedCtorRoot "src\macros\mod.rs" + $patchedMacros = Get-Content -LiteralPath $patchedMacrosPath -Raw + $unsupportedVendorCondition = 'target_vendor = "pc"' + $replacementCount = ([regex]::Matches($patchedMacros, [regex]::Escape($unsupportedVendorCondition))).Count + if ($replacementCount -ne 3) { + throw "Expected three Windows vendor checks in ctor $knownCtorVersion, found $replacementCount." + } + + # The built-in Win7 targets use target_vendor="win7" but retain the normal + # Windows MSVC CRT constructor sections. This is the same compatibility fix + # released upstream in ctor 1.0.4, kept on 0.8.0 for Tauri's version range. + $patchedMacros = $patchedMacros.Replace($unsupportedVendorCondition, 'target_os = "windows"') + Set-Content -LiteralPath $patchedMacrosPath -Value $patchedMacros -Encoding UTF8 -NoNewline + + $cargoCtorPath = $patchedCtorRoot.Replace("\", "/").Replace("'", "''") + New-Item -ItemType Directory -Path $cargoConfigDirectory -Force | Out-Null + @" +[patch.crates-io] +ctor = { path = '$cargoCtorPath' } +webview2-com-sys = { path = "src-tauri/vendor/webview2-com-sys" } +windows-core = { path = "src-tauri/vendor/windows-core" } +"@ | Set-Content -LiteralPath $cargoConfigPath -Encoding UTF8 + + $patchedMetadataJson = & cargo metadata --manifest-path $tauriManifest --format-version 1 + if ($LASTEXITCODE -ne 0) { + throw "cargo metadata failed after enabling the Windows 7 Cargo patches." + } + + $patchedMetadata = $patchedMetadataJson | ConvertFrom-Json + $activeCtorPackages = @($patchedMetadata.packages | Where-Object { + $_.name -eq "ctor" -and $_.version -eq $knownCtorVersion -and + (Split-Path -Parent $_.manifest_path) -eq $patchedCtorRoot + }) + if ($activeCtorPackages.Count -ne 1) { + throw "The patched ctor $knownCtorVersion package was not selected by Cargo." + } + + Write-Host "Enabled Win7-only Cargo patches:" + Get-Content -LiteralPath $cargoConfigPath + Write-Host "Patched ctor source: $patchedCtorRoot" +} +finally { + Pop-Location +} From 5da78e483ff5a0326cb75b986b58b1f66fc410e6 Mon Sep 17 00:00:00 2001 From: victorwon2001 <192616110+victorwon2001@users.noreply.github.com> Date: Tue, 1 Sep 2026 06:08:45 +0900 Subject: [PATCH 27/32] fix: restore RDP jump host on edit --- src/pages/NewSessionPage.test.tsx | 150 ++++++++++++++++++++++++++++++ src/pages/NewSessionPage.tsx | 2 + 2 files changed, 152 insertions(+) create mode 100644 src/pages/NewSessionPage.test.tsx diff --git a/src/pages/NewSessionPage.test.tsx b/src/pages/NewSessionPage.test.tsx new file mode 100644 index 00000000..af743543 --- /dev/null +++ b/src/pages/NewSessionPage.test.tsx @@ -0,0 +1,150 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SavedConnection } from "@/types/global"; +import NewSessionPage from "./NewSessionPage"; + +const { closeMock, emitMock, invokeMock, rdpFormMock, translateMock } = vi.hoisted(() => ({ + closeMock: vi.fn(), + emitMock: vi.fn(), + invokeMock: vi.fn(), + rdpFormMock: vi.fn(), + translateMock: (key: string, fallback?: unknown) => + typeof fallback === "string" ? fallback : key, +})); + +vi.mock("@/context/AppContext", () => ({ + useApp: () => ({ + appSettings: { + recording: { + auto_start: false, + default_mode: "transcript", + }, + ui: { + show_remote_stats: true, + }, + }, + }), +})); + +vi.mock("@/lib/invoke", () => ({ invoke: invokeMock })); +vi.mock("@tauri-apps/api/event", () => ({ emit: emitMock })); +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => ({ close: closeMock }), +})); +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn() })); + +vi.mock("@/components/sessions/LocalTerminal", () => ({ LocalTerminal: () => null })); +vi.mock("@/components/sessions/SerialForm", () => ({ SerialForm: () => null })); +vi.mock("@/components/sessions/SshForm", () => ({ SshForm: () => null })); +vi.mock("@/components/sessions/TelnetForm", () => ({ TelnetForm: () => null })); +vi.mock("@/components/sessions/VncForm", () => ({ VncForm: () => null })); +vi.mock("@/components/sessions/RdpForm", () => ({ + RdpForm: (props: Record) => { + rdpFormMock(props); + return null; + }, +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: translateMock, + }), +})); + +const jumpHost: SavedConnection = { + id: "ssh-jump-1", + name: "SSH jump host", + type: "ssh", + host: "jump.example.com", + port: 22, + username: "jump-user", +}; + +const rdpConnection: SavedConnection = { + id: "rdp-1", + name: "RDP desktop", + type: "rdp", + host: "rdp.example.com", + port: 3389, + username: "Administrator", + auth: { mode: "password" }, + network: { + proxy_id: "proxy-1", + proxy_jump_id: jumpHost.id, + }, + security: { + use_nla: true, + certificate_policy: "prompt", + }, + display: { + mode: "fit-window", + width: 1920, + height: 1080, + color_depth: 32, + }, + clipboard: { mode: "text-only" }, + reconnect: { enabled: true, max_attempts: 5 }, +}; + +describe("NewSessionPage", () => { + beforeEach(() => { + window.history.replaceState({}, "", `/?edit=${rdpConnection.id}`); + closeMock.mockReset(); + closeMock.mockResolvedValue(undefined); + emitMock.mockReset(); + emitMock.mockResolvedValue(undefined); + invokeMock.mockReset(); + invokeMock.mockImplementation((command: string) => { + switch (command) { + case "get_groups": + case "get_proxies": + case "get_otp_entries": + case "get_connection_custom_icons": + return Promise.resolve([]); + case "get_saved_connections": + return Promise.resolve([rdpConnection, jumpHost]); + case "save_connection": + return Promise.resolve(rdpConnection.id); + default: + return Promise.reject(new Error(`Unexpected command: ${command}`)); + } + }); + rdpFormMock.mockReset(); + }); + + it("restores an RDP jump host and keeps it when saving without changes", async () => { + render(); + + await waitFor(() => { + expect(rdpFormMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + proxyId: "proxy-1", + jumpHostId: jumpHost.id, + jumpHostOptions: expect.arrayContaining([ + expect.objectContaining({ + connection: expect.objectContaining({ id: jumpHost.id }), + }), + ]), + }), + ); + }); + + const saveButton = screen.getByRole("button", { name: "dialog.save" }); + expect((saveButton as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(invokeMock).toHaveBeenCalledWith( + "save_connection", + expect.objectContaining({ + connection: expect.objectContaining({ + type: "rdp", + network: { + proxy_id: "proxy-1", + proxy_jump_id: jumpHost.id, + }, + }), + }), + ); + }); + }); +}); diff --git a/src/pages/NewSessionPage.tsx b/src/pages/NewSessionPage.tsx index ecaadd69..bb4de19c 100644 --- a/src/pages/NewSessionPage.tsx +++ b/src/pages/NewSessionPage.tsx @@ -354,6 +354,8 @@ export default function NewSessionPage() { setRdpDomain(found.domain || ""); setPasswordId(found.auth?.password_id || ""); setHasPassword(found.auth?.has_password || false); + setProxyId(found.network?.proxy_id || ""); + setJumpHostId(found.network?.proxy_jump_id || ""); setRdpUseNla(found.security?.use_nla ?? true); setRdpCertificatePolicy(found.security?.certificate_policy ?? "prompt"); setRdpDisplayMode( From f0267a6538edea7186fa6fe4bf3d453121baeab3 Mon Sep 17 00:00:00 2001 From: Kang Date: Tue, 1 Sep 2026 14:23:59 +0800 Subject: [PATCH 28/32] feat(sftp): introduce helper functions for directory listing and stat commands - Added `list_dir_command` and `stat_command` functions to streamline the creation of shell commands for listing directories and retrieving file statistics with numeric owner and group information. - Updated `stat_remote_properties` and `list_dir` methods to utilize the new command functions, improving code readability and maintainability. - Enhanced unit tests to validate the new command functions and ensure correct parsing of directory entries. --- src-tauri/src/core/sftp/scp_normal.rs | 77 ++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/core/sftp/scp_normal.rs b/src-tauri/src/core/sftp/scp_normal.rs index eaf9a475..9d8c339e 100644 --- a/src-tauri/src/core/sftp/scp_normal.rs +++ b/src-tauri/src/core/sftp/scp_normal.rs @@ -323,6 +323,14 @@ fn split_ls_fields(line: &str) -> Option<(Vec<&str>, &str)> { (cursor < bytes.len()).then(|| (fields, &line[cursor..])) } +fn list_dir_command(path: &str) -> String { + format!("LC_ALL=C ls -la -n -- {}", sh_quote(path)) +} + +fn stat_command(path: &str) -> String { + format!("LC_ALL=C ls -lad -n -- {}", sh_quote(path)) +} + fn parse_ls_line(line: &str) -> Option { let line = line.trim_start(); if line.trim_end().is_empty() || line.starts_with("total ") { @@ -442,11 +450,7 @@ async fn stat_remote_properties( ssh_handle: &Arc, path: &str, ) -> AppResult { - let result = exec_command( - ssh_handle, - &format!("LC_ALL=C ls -lad -- {}", sh_quote(path)), - ) - .await?; + let result = exec_command(ssh_handle, &stat_command(path)).await?; if result.exit_code != Some(0) { let stderr_text = String::from_utf8_lossy(&result.stderr); return Err(AppError::Channel(format!( @@ -888,9 +892,7 @@ impl RemoteFs for ScpNormalBackend { async fn list_dir(&self, path: &str) -> AppResult> { let listing_path = remote_dir_listing_path(path); - let output = self - .exec_ok(&format!("LC_ALL=C ls -la -- {}", sh_quote(&listing_path))) - .await?; + let output = self.exec_ok(&list_dir_command(&listing_path)).await?; let mut entries = Vec::new(); for line in output.lines() { if let Some(mut entry) = parse_ls_line(line) { @@ -908,9 +910,7 @@ impl RemoteFs for ScpNormalBackend { } async fn stat(&self, path: &str) -> AppResult { - let output = self - .exec_ok(&format!("LC_ALL=C ls -lad -- {}", sh_quote(path))) - .await?; + let output = self.exec_ok(&stat_command(path)).await?; let line = output .lines() .find(|l| !l.trim().is_empty() && !l.starts_with("total ")) @@ -1558,7 +1558,60 @@ impl RemoteFs for ScpNormalBackend { #[cfg(test)] mod tests { - use super::parse_ls_line_to_properties; + use super::{list_dir_command, parse_ls_line, parse_ls_line_to_properties, stat_command}; + + #[test] + fn ls_parser_handles_numeric_owner_and_group() { + let entry = parse_ls_line("-rw-r--r-- 1 1000 100513 203 Aug 3 16:37 .zshrc").unwrap(); + assert_eq!(entry.name, ".zshrc"); + assert_eq!(entry.owner, "1000"); + assert_eq!(entry.group, "100513"); + assert_eq!(entry.size, 203); + assert!(!entry.is_dir); + } + + #[test] + fn ls_parser_keeps_hidden_directory_name() { + let entry = parse_ls_line("drwx------ 4 1000 100513 4096 Sep 1 09:31 .copilot").unwrap(); + assert_eq!(entry.name, ".copilot"); + assert!(entry.is_dir); + } + + #[test] + fn ls_parser_keeps_spaces_in_file_name() { + let entry = + parse_ls_line("-rw-r--r-- 1 1000 100513 123 Sep 1 09:31 hello world.txt").unwrap(); + assert_eq!(entry.name, "hello world.txt"); + assert!(!entry.is_dir); + } + + #[test] + fn ls_parser_keeps_spaces_in_directory_name() { + let entry = parse_ls_line("drwxr-xr-x 2 1000 100513 4096 Sep 1 09:31 My Folder").unwrap(); + assert_eq!(entry.name, "My Folder"); + assert!(entry.is_dir); + } + + #[test] + fn ls_parser_strips_symlink_target_from_name() { + let entry = + parse_ls_line("lrwxrwxrwx 1 1000 100513 11 Aug 29 12:00 current -> releases/v2") + .unwrap(); + assert_eq!(entry.name, "current"); + assert!(entry.is_symlink); + } + + #[test] + fn ls_commands_request_numeric_owner_and_group() { + assert_eq!( + list_dir_command("/home/user/My Folder"), + "LC_ALL=C ls -la -n -- '/home/user/My Folder'" + ); + assert_eq!( + stat_command("/home/user/My Folder"), + "LC_ALL=C ls -lad -n -- '/home/user/My Folder'" + ); + } #[test] fn ls_properties_parser_keeps_relative_symlink_target() { From 7d1d4073e7de843f7d5a62eb1298d84bc74b39d4 Mon Sep 17 00:00:00 2001 From: Kang Date: Tue, 1 Sep 2026 16:49:11 +0800 Subject: [PATCH 29/32] fix(terminal): focus on open tab terminal after tab change (#547) - Added a call to `focusOpenTabTerminal(tab)` in the `onTabChange` handler to ensure the terminal focuses on the active tab when changed, enhancing user experience. --- src/components/terminal/TabBar.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/terminal/TabBar.tsx b/src/components/terminal/TabBar.tsx index e0c4efca..931dd507 100644 --- a/src/components/terminal/TabBar.tsx +++ b/src/components/terminal/TabBar.tsx @@ -1314,6 +1314,7 @@ function TabBar({ return; } onTabChange(tab.id); + focusOpenTabTerminal(tab); }} onDoubleClick={(event) => { if ( From 5c379d61b8ab027effeb077507a679b25106bb5b Mon Sep 17 00:00:00 2001 From: lly Date: Tue, 1 Sep 2026 19:10:32 +0800 Subject: [PATCH 30/32] feat(editor): support Ctrl+wheel font zoom in builtin file editor --- src-tauri/src/config/settings/transfer.rs | 7 ++ src/App.tsx | 3 + .../file-explorer/FileDocumentEditor.test.tsx | 10 +++ .../file-explorer/FileDocumentEditor.tsx | 13 ++- .../file-explorer/FilePreviewContent.tsx | 8 +- src/context/AppProvider.tsx | 1 + src/context/ChildAppProvider.tsx | 1 + src/hooks/useFileEditorZoom.ts | 83 +++++++++++++++++++ src/lib/codeMirrorFileView.ts | 1 - src/lib/fileEditorFontSize.test.ts | 48 +++++++++++ src/lib/fileEditorFontSize.ts | 20 +++++ src/pages/RemoteFileEditorPage.tsx | 19 ++++- src/types/global.d.ts | 1 + 13 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 src/hooks/useFileEditorZoom.ts create mode 100644 src/lib/fileEditorFontSize.test.ts create mode 100644 src/lib/fileEditorFontSize.ts diff --git a/src-tauri/src/config/settings/transfer.rs b/src-tauri/src/config/settings/transfer.rs index 6dd223ca..b854708b 100644 --- a/src-tauri/src/config/settings/transfer.rs +++ b/src-tauri/src/config/settings/transfer.rs @@ -9,6 +9,8 @@ pub struct TransferSettings { pub editor_type: String, #[serde(default = "default_internal_editor_display")] pub internal_editor_display: String, + #[serde(default = "default_internal_editor_font_size")] + pub internal_editor_font_size: u32, #[serde(default = "default_transfer_threads")] pub download_threads: u32, #[serde(default = "default_transfer_threads")] @@ -54,6 +56,9 @@ fn default_editor_type() -> String { fn default_internal_editor_display() -> String { "workspace".to_string() } +fn default_internal_editor_font_size() -> u32 { + 13 +} fn default_duplicate_strategy() -> String { "ask".to_string() } @@ -75,6 +80,7 @@ impl Default for TransferSettings { Self { editor_type: default_editor_type(), internal_editor_display: default_internal_editor_display(), + internal_editor_font_size: default_internal_editor_font_size(), download_threads: default_transfer_threads(), upload_threads: default_transfer_threads(), duplicate_strategy: default_duplicate_strategy(), @@ -113,5 +119,6 @@ mod tests { assert!(!settings.recording_auto_start); assert_eq!(settings.editor_type, "external"); assert_eq!(settings.internal_editor_display, "workspace"); + assert_eq!(settings.internal_editor_font_size, 13); } } diff --git a/src/App.tsx b/src/App.tsx index 313ed4ca..77df3f0a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,6 +31,7 @@ import { useRemoteStats } from "./hooks/useRemoteStats"; import { useSecurityPromptQueue } from "./hooks/useSecurityPromptQueue"; import { useSessionRuntimeState } from "./hooks/useSessionRuntimeState"; import { resolveDisplayKeys } from "./hooks/useShortcutMap"; +import { useFileEditorZoom } from "./hooks/useFileEditorZoom"; import { useTerminalZoom } from "./hooks/useTerminalZoom"; import { useTabStatusIndicators } from "./hooks/useUnreadTabs"; import { AI_OPEN_EVENT, type AIOpenIntent } from "./lib/aiEvents"; @@ -2036,6 +2037,8 @@ function App() { appSettings.interaction.terminal_zoom_enabled, ); + useFileEditorZoom(updateAppSettings); + const handleOpenSettings = useCallback(() => { openSettings(); }, []); diff --git a/src/components/panel/file-explorer/FileDocumentEditor.test.tsx b/src/components/panel/file-explorer/FileDocumentEditor.test.tsx index 917f13c0..2a455e51 100644 --- a/src/components/panel/file-explorer/FileDocumentEditor.test.tsx +++ b/src/components/panel/file-explorer/FileDocumentEditor.test.tsx @@ -37,6 +37,16 @@ vi.mock("@/lib/codeMirrorFileView", () => ({ vi.mock("@/lib/invoke", () => ({ invoke: vi.fn() })); vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } })); +vi.mock("@/context/AppContext", () => ({ + useApp: () => ({ + appSettings: { + transfer: { + internal_editor_font_size: 13, + }, + }, + }), +})); + function pane(): FileDocumentPane { return { id: "pane-file", diff --git a/src/components/panel/file-explorer/FileDocumentEditor.tsx b/src/components/panel/file-explorer/FileDocumentEditor.tsx index 10058e06..c9b7ea80 100644 --- a/src/components/panel/file-explorer/FileDocumentEditor.tsx +++ b/src/components/panel/file-explorer/FileDocumentEditor.tsx @@ -15,7 +15,9 @@ import { registerFileDocument, updateFileDocumentState, } from "@/lib/fileDocumentRegistry"; +import { useApp } from "@/context/AppContext"; import { invoke } from "@/lib/invoke"; +import { clampFileEditorFontSize } from "@/lib/fileEditorFontSize"; import { formatSize } from "@/lib/utils"; import type { FileDocumentPane } from "@/types/global"; import { languageFromFilename, type TextFileOpenResult } from "./model"; @@ -35,6 +37,10 @@ interface FileDocumentEditorProps { export default function FileDocumentEditor({ pane, active }: FileDocumentEditorProps) { const { t } = useTranslation(); + const { appSettings } = useApp(); + const editorFontSize = clampFileEditorFontSize( + appSettings.transfer.internal_editor_font_size, + ); const editorParentRef = useRef(null); const viewRef = useRef(null); const suppressUpdateRef = useRef(false); @@ -239,6 +245,7 @@ export default function FileDocumentEditor({ pane, active }: FileDocumentEditorP
{ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") { event.preventDefault(); @@ -285,7 +292,11 @@ export default function FileDocumentEditor({ pane, active }: FileDocumentEditorP {error}
) : null} -
+
{languageFromFilename(pane.name || pane.file.path).toLocaleUpperCase()} diff --git a/src/components/panel/file-explorer/FilePreviewContent.tsx b/src/components/panel/file-explorer/FilePreviewContent.tsx index 62057150..3c2f015a 100644 --- a/src/components/panel/file-explorer/FilePreviewContent.tsx +++ b/src/components/panel/file-explorer/FilePreviewContent.tsx @@ -777,7 +777,13 @@ function ReadOnlyCodeMirror({ return () => view.destroy(); }, [content, language]); - return
; + return ( +
+ ); } function PdfPreview({ file }: { file: RemoteBinaryFile }) { diff --git a/src/context/AppProvider.tsx b/src/context/AppProvider.tsx index 640be6ed..316c94e0 100644 --- a/src/context/AppProvider.tsx +++ b/src/context/AppProvider.tsx @@ -185,6 +185,7 @@ const DEFAULT_APP_SETTINGS: AppSettings = { transfer: { editor_type: "external", internal_editor_display: "workspace", + internal_editor_font_size: 13, download_threads: 3, upload_threads: 3, duplicate_strategy: "ask", diff --git a/src/context/ChildAppProvider.tsx b/src/context/ChildAppProvider.tsx index fdbd6322..c44c9572 100644 --- a/src/context/ChildAppProvider.tsx +++ b/src/context/ChildAppProvider.tsx @@ -146,6 +146,7 @@ const DEFAULT_APP_SETTINGS: AppSettings = { transfer: { editor_type: "external", internal_editor_display: "workspace", + internal_editor_font_size: 13, download_threads: 3, upload_threads: 3, duplicate_strategy: "ask", diff --git a/src/hooks/useFileEditorZoom.ts b/src/hooks/useFileEditorZoom.ts new file mode 100644 index 00000000..ff9241c8 --- /dev/null +++ b/src/hooks/useFileEditorZoom.ts @@ -0,0 +1,83 @@ +import { useCallback, useEffect, useRef } from "react"; +import { + decreaseFileEditorFontSize, + increaseFileEditorFontSize, +} from "@/lib/fileEditorFontSize"; +import type { AppSettings } from "@/types/global"; + +type UpdateAppSettings = ( + updates: Partial | ((prev: AppSettings) => Partial), +) => void; + +const CTRL_WHEEL_ZOOM_THROTTLE_MS = 50; +const FILE_EDITOR_ROOT_SELECTOR = '[data-file-editor-root="true"]'; + +function isElement(value: EventTarget | null): value is Element { + return value instanceof Element; +} + +function eventTargetIsInsideFileEditorRoot(event: WheelEvent) { + const pathContainsFileEditorRoot = event.composedPath().some((target) => { + if (!isElement(target)) return false; + return target.matches(FILE_EDITOR_ROOT_SELECTOR); + }); + if (pathContainsFileEditorRoot) return true; + + const target = event.target; + return isElement(target) && target.closest(FILE_EDITOR_ROOT_SELECTOR) !== null; +} + +export function useFileEditorZoom(updateAppSettings: UpdateAppSettings) { + const lastCtrlWheelZoomAtRef = useRef(0); + + const handleZoomIn = useCallback(() => { + updateAppSettings((prev) => ({ + transfer: { + ...prev.transfer, + internal_editor_font_size: increaseFileEditorFontSize( + prev.transfer.internal_editor_font_size, + ), + }, + })); + }, [updateAppSettings]); + + const handleZoomOut = useCallback(() => { + updateAppSettings((prev) => ({ + transfer: { + ...prev.transfer, + internal_editor_font_size: decreaseFileEditorFontSize( + prev.transfer.internal_editor_font_size, + ), + }, + })); + }, [updateAppSettings]); + + useEffect(() => { + const handleCtrlWheelZoom = (event: WheelEvent) => { + if (!event.ctrlKey && !event.metaKey) return; + if (event.deltaY === 0) return; + if (!eventTargetIsInsideFileEditorRoot(event)) return; + + event.preventDefault(); + const now = Date.now(); + if (now - lastCtrlWheelZoomAtRef.current < CTRL_WHEEL_ZOOM_THROTTLE_MS) return; + lastCtrlWheelZoomAtRef.current = now; + + if (event.deltaY < 0) { + handleZoomIn(); + } else { + handleZoomOut(); + } + }; + + window.addEventListener("wheel", handleCtrlWheelZoom, { passive: false, capture: true }); + return () => { + window.removeEventListener("wheel", handleCtrlWheelZoom, true); + }; + }, [handleZoomIn, handleZoomOut]); + + return { + handleZoomIn, + handleZoomOut, + }; +} diff --git a/src/lib/codeMirrorFileView.ts b/src/lib/codeMirrorFileView.ts index 866d95e7..6fe6ba40 100644 --- a/src/lib/codeMirrorFileView.ts +++ b/src/lib/codeMirrorFileView.ts @@ -276,7 +276,6 @@ export function codeMirrorFileViewExtensions( height: "100%", backgroundColor: "var(--background)", color: "var(--foreground)", - fontSize: "13px", }, "&.cm-focused": { outline: "none", diff --git a/src/lib/fileEditorFontSize.test.ts b/src/lib/fileEditorFontSize.test.ts new file mode 100644 index 00000000..f5bf0411 --- /dev/null +++ b/src/lib/fileEditorFontSize.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { + clampFileEditorFontSize, + DEFAULT_FILE_EDITOR_FONT_SIZE, + decreaseFileEditorFontSize, + increaseFileEditorFontSize, +} from "./fileEditorFontSize"; + +describe("clampFileEditorFontSize", () => { + it("clamps below the minimum", () => { + expect(clampFileEditorFontSize(1)).toBe(8); + }); + + it("clamps above the maximum", () => { + expect(clampFileEditorFontSize(100)).toBe(72); + }); + + it("rounds fractional sizes", () => { + expect(clampFileEditorFontSize(13.6)).toBe(14); + }); + + it("falls back to the default for non-finite values", () => { + expect(clampFileEditorFontSize(Number.NaN)).toBe(DEFAULT_FILE_EDITOR_FONT_SIZE); + expect(clampFileEditorFontSize(Number.POSITIVE_INFINITY)).toBe( + DEFAULT_FILE_EDITOR_FONT_SIZE, + ); + }); +}); + +describe("increaseFileEditorFontSize", () => { + it("steps up by one", () => { + expect(increaseFileEditorFontSize(13)).toBe(14); + }); + + it("stops at the maximum", () => { + expect(increaseFileEditorFontSize(72)).toBe(72); + }); +}); + +describe("decreaseFileEditorFontSize", () => { + it("steps down by one", () => { + expect(decreaseFileEditorFontSize(13)).toBe(12); + }); + + it("stops at the minimum", () => { + expect(decreaseFileEditorFontSize(8)).toBe(8); + }); +}); diff --git a/src/lib/fileEditorFontSize.ts b/src/lib/fileEditorFontSize.ts new file mode 100644 index 00000000..e78e69e0 --- /dev/null +++ b/src/lib/fileEditorFontSize.ts @@ -0,0 +1,20 @@ +export const DEFAULT_FILE_EDITOR_FONT_SIZE = 13; +export const MIN_FILE_EDITOR_FONT_SIZE = 8; +export const MAX_FILE_EDITOR_FONT_SIZE = 72; +export const FILE_EDITOR_FONT_SIZE_STEP = 1; + +export function clampFileEditorFontSize(fontSize: number): number { + if (!Number.isFinite(fontSize)) return DEFAULT_FILE_EDITOR_FONT_SIZE; + return Math.max( + MIN_FILE_EDITOR_FONT_SIZE, + Math.min(MAX_FILE_EDITOR_FONT_SIZE, Math.round(fontSize)), + ); +} + +export function increaseFileEditorFontSize(fontSize: number): number { + return clampFileEditorFontSize(fontSize + FILE_EDITOR_FONT_SIZE_STEP); +} + +export function decreaseFileEditorFontSize(fontSize: number): number { + return clampFileEditorFontSize(fontSize - FILE_EDITOR_FONT_SIZE_STEP); +} diff --git a/src/pages/RemoteFileEditorPage.tsx b/src/pages/RemoteFileEditorPage.tsx index a3728001..01e05b7b 100644 --- a/src/pages/RemoteFileEditorPage.tsx +++ b/src/pages/RemoteFileEditorPage.tsx @@ -36,6 +36,7 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useApp } from "@/context/AppContext"; import { useChildWindowCommand } from "@/hooks/useChildWindowCommand"; +import { useFileEditorZoom } from "@/hooks/useFileEditorZoom"; import { CHILD_WINDOW_COMMANDS } from "@/lib/childWindowProtocol"; import { type CursorPosition, @@ -44,6 +45,7 @@ import { getDisplayLanguage, } from "@/lib/codeMirrorFileView"; import { getErrorMessage } from "@/lib/errors"; +import { clampFileEditorFontSize } from "@/lib/fileEditorFontSize"; import { MAX_EDITOR_FILE_BYTES } from "@/lib/fileEditorLimits"; import { invoke } from "@/lib/invoke"; import { cn, formatSize, parseJsonSearchParam } from "@/lib/utils"; @@ -162,7 +164,11 @@ function formatTargetLabel(target?: FileWindowTarget) { export default function RemoteFileEditorPage() { const { t } = useTranslation(); - const { appSettings } = useApp(); + const { appSettings, updateAppSettings } = useApp(); + useFileEditorZoom(updateAppSettings); + const editorFontSize = clampFileEditorFontSize( + appSettings.transfer.internal_editor_font_size, + ); const initialData = useMemo(() => { const params = new URLSearchParams(window.location.search); return parseJsonSearchParam(params.get("data")); @@ -659,7 +665,10 @@ export default function RemoteFileEditorPage() { : t("fileEditor.title"); return ( -
+
} @@ -856,7 +865,11 @@ export default function RemoteFileEditorPage() { {t("common.loading")}
)} -
+
diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 52935da4..d7ddcaf7 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -1333,6 +1333,7 @@ export interface TerminalSettings { export interface TransferSettings { editor_type: "external" | "internal"; internal_editor_display: "workspace" | "window"; + internal_editor_font_size: number; download_threads: number; upload_threads: number; duplicate_strategy: string; From 718db66b13804418528a60e13ccc07317909036f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=B5=A9=E7=94=9F?= Date: Tue, 1 Sep 2026 20:40:51 +0800 Subject: [PATCH 31/32] fix: scroll terminal to cursor when typing with a selection When the terminal viewport is scrolled up and the user has a selection over scrollback output, key presses were sent via terminal.input(data, false) to preserve the selection (by design, it is only cleared on mouse click). However, xterm.js gates both selection clearing and scrollOnUserInput behind the same wasUserInput flag, so the viewport never scrolled back to the cursor and the user could not see what they typed. Keep the selection-preservation behavior but explicitly scroll to the bottom when the viewport is not at the cursor, replicating xterm's scrollOnUserInput for every key path in the selection branch (printable input, backspace/delete, enter, arrows, ctrl/alt combos). Fixes #518 --- .../terminal/xterminalKeyboardController.ts | 49 ++++++++++++------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/src/components/terminal/xterminalKeyboardController.ts b/src/components/terminal/xterminalKeyboardController.ts index 45fcf3a6..36562a23 100644 --- a/src/components/terminal/xterminalKeyboardController.ts +++ b/src/components/terminal/xterminalKeyboardController.ts @@ -262,9 +262,20 @@ export function installXTerminalKeyboardController({ } if (terminal.hasSelection() && !getSmartCursorSelectedInputRange()) { + // The selection is preserved by design while typing (it is only cleared + // on mouse click), so input is sent with wasUserInput=false to skip + // xterm's selection clearing. That also skips xterm's scrollOnUserInput, + // so scroll back to the cursor explicitly to keep the prompt visible. + const inputPreservingSelection = (data: string) => { + terminal.input(data, false); + const buffer = terminal.buffer.active; + if (buffer.baseY !== buffer.viewportY) { + terminal.scrollToBottom(); + } + }; if (directInputData) { e.preventDefault(); - terminal.input(directInputData, false); + inputPreservingSelection(directInputData); return false; } if ( @@ -274,12 +285,12 @@ export function installXTerminalKeyboardController({ !e.altKey ) { e.preventDefault(); - terminal.input("\x7f", false); + inputPreservingSelection("\x7f"); return false; } if (e.key === "Enter" && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); - terminal.input("\r", false); + inputPreservingSelection("\r"); return false; } if ( @@ -290,7 +301,7 @@ export function installXTerminalKeyboardController({ !e.shiftKey ) { e.preventDefault(); - terminal.input("\x1b[D", false); + inputPreservingSelection("\x1b[D"); return false; } if ( @@ -301,7 +312,7 @@ export function installXTerminalKeyboardController({ !e.shiftKey ) { e.preventDefault(); - terminal.input("\x1b[C", false); + inputPreservingSelection("\x1b[C"); return false; } if ( @@ -312,7 +323,7 @@ export function installXTerminalKeyboardController({ !e.shiftKey ) { e.preventDefault(); - terminal.input("\x1b[A", false); + inputPreservingSelection("\x1b[A"); return false; } if ( @@ -323,7 +334,7 @@ export function installXTerminalKeyboardController({ !e.shiftKey ) { e.preventDefault(); - terminal.input("\x1b[B", false); + inputPreservingSelection("\x1b[B"); return false; } if (e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { @@ -358,65 +369,65 @@ export function installXTerminalKeyboardController({ const keyLower = e.key.toLowerCase(); if (ctrlCharMap[keyLower]) { e.preventDefault(); - terminal.input(ctrlCharMap[keyLower], false); + inputPreservingSelection(ctrlCharMap[keyLower]); return false; } if (e.key === "ArrowLeft") { e.preventDefault(); - terminal.input("\x1b[1;5D", false); + inputPreservingSelection("\x1b[1;5D"); return false; } if (e.key === "ArrowRight") { e.preventDefault(); - terminal.input("\x1b[1;5C", false); + inputPreservingSelection("\x1b[1;5C"); return false; } if (e.key === "ArrowUp") { e.preventDefault(); - terminal.input("\x1b[1;5A", false); + inputPreservingSelection("\x1b[1;5A"); return false; } if (e.key === "ArrowDown") { e.preventDefault(); - terminal.input("\x1b[1;5B", false); + inputPreservingSelection("\x1b[1;5B"); return false; } } if ((e.altKey || e.metaKey) && !e.ctrlKey && !e.shiftKey) { if (e.key === "ArrowLeft") { e.preventDefault(); - terminal.input("\x1b[1;3D", false); + inputPreservingSelection("\x1b[1;3D"); return false; } if (e.key === "ArrowRight") { e.preventDefault(); - terminal.input("\x1b[1;3C", false); + inputPreservingSelection("\x1b[1;3C"); return false; } if (e.key === "ArrowUp") { e.preventDefault(); - terminal.input("\x1b[1;3A", false); + inputPreservingSelection("\x1b[1;3A"); return false; } if (e.key === "ArrowDown") { e.preventDefault(); - terminal.input("\x1b[1;3B", false); + inputPreservingSelection("\x1b[1;3B"); return false; } const keyLower = e.key.toLowerCase(); if (keyLower === "b") { e.preventDefault(); - terminal.input("\x1bb", false); + inputPreservingSelection("\x1bb"); return false; } if (keyLower === "f") { e.preventDefault(); - terminal.input("\x1bf", false); + inputPreservingSelection("\x1bf"); return false; } if (keyLower === "d") { e.preventDefault(); - terminal.input("\x1bd", false); + inputPreservingSelection("\x1bd"); return false; } } From 40d306eaadeb12188e178ad0f38c52da3209a759 Mon Sep 17 00:00:00 2001 From: Kang Date: Wed, 2 Sep 2026 00:09:05 +0800 Subject: [PATCH 32/32] fix: preserve terminal selection when saving quick command --- src/components/terminal/TerminalContextMenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/terminal/TerminalContextMenu.tsx b/src/components/terminal/TerminalContextMenu.tsx index 77d8ee60..b792a5dd 100644 --- a/src/components/terminal/TerminalContextMenu.tsx +++ b/src/components/terminal/TerminalContextMenu.tsx @@ -266,7 +266,7 @@ export default function TerminalContextMenu({ onClick={() => openQuickCommand( JSON.stringify({ - command: ctxSelection.text.trim().slice(0, 10000), + command: ctxSelection.text, }), ) }