diff --git a/rustup-init.exe b/rustup-init.exe new file mode 100644 index 00000000..cfefdb21 Binary files /dev/null and b/rustup-init.exe differ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 422be209..aeae15bb 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -9193,8 +9193,6 @@ checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" [[package]] name = "zmodem2" version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "515c519057f07982ebb6eed12d5316a52ee563b004cf08961019166e33a7cc9d" dependencies = [ "bitflags 2.11.1", "hex", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 42dc0902..60e8d83e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -157,3 +157,6 @@ windows = { version = "0.61", features = [ "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", ] } + +[patch.crates-io] +zmodem2 = { path = "vendor/zmodem2" } diff --git a/src-tauri/src/cmd/sftp.rs b/src-tauri/src/cmd/sftp.rs index d137a99f..36e3ebed 100644 --- a/src-tauri/src/cmd/sftp.rs +++ b/src-tauri/src/cmd/sftp.rs @@ -1,6 +1,7 @@ use crate::core::SessionManager; -use crate::core::sftp; -use crate::error::AppResult; +use crate::core::sftp::{self, TransferDuplicateManager}; +use crate::core::sftp::duplicate::DuplicateChoice; +use crate::error::{AppError, AppResult}; use std::sync::Arc; #[tauri::command] @@ -67,6 +68,7 @@ pub async fn upload_local_file( local_path: String, remote_path: String, transfer_id: Option, + duplicate_strategy_override: Option, ) -> AppResult<()> { sftp::upload_local_file( app, @@ -75,6 +77,7 @@ pub async fn upload_local_file( &local_path, &remote_path, transfer_id, + duplicate_strategy_override, ) .await } @@ -176,6 +179,7 @@ pub async fn upload_local_directory( local_path: String, remote_path: String, transfer_id: Option, + duplicate_strategy_override: Option, ) -> AppResult<()> { sftp::upload_local_directory( app, @@ -184,6 +188,7 @@ pub async fn upload_local_directory( &local_path, &remote_path, transfer_id, + duplicate_strategy_override, ) .await } @@ -202,3 +207,20 @@ pub async fn resume_transfer(app: tauri::AppHandle, transfer_id: String) -> AppR pub async fn cancel_transfer(app: tauri::AppHandle, transfer_id: String) -> AppResult<()> { sftp::cancel_transfer(app, &transfer_id).await } + +#[tauri::command] +pub async fn respond_transfer_duplicate( + state: tauri::State<'_, Arc>, + request_id: String, + action: String, +) -> AppResult<()> { + let choice = DuplicateChoice::from_action(&action) + .ok_or_else(|| AppError::Config(format!("Invalid duplicate action: {action}")))?; + if state.respond(&request_id, choice).await { + Ok(()) + } else { + Err(AppError::Config(format!( + "No pending duplicate prompt with id '{request_id}'" + ))) + } +} diff --git a/src-tauri/src/config/settings/transfer.rs b/src-tauri/src/config/settings/transfer.rs index f359fc28..6d67cc5d 100644 --- a/src-tauri/src/config/settings/transfer.rs +++ b/src-tauri/src/config/settings/transfer.rs @@ -45,7 +45,7 @@ fn default_transfer_threads() -> u32 { 3 } fn default_duplicate_strategy() -> String { - "overwrite".to_string() + "ask".to_string() } fn default_file_permissions() -> String { "644".to_string() diff --git a/src-tauri/src/core/pty.rs b/src-tauri/src/core/pty.rs index 8430316e..7695a3fa 100644 --- a/src-tauri/src/core/pty.rs +++ b/src-tauri/src/core/pty.rs @@ -8,7 +8,8 @@ use super::session::{ }; use super::update_cwd_if_changed; use super::zmodem::{ - ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemEvent, ZmodemTransfer, + ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemDirection, ZmodemEvent, + ZmodemTransfer, start_zmodem_transfer, }; use crate::config::AiExecutionProfile; use crate::core::SessionOutputCoalescer; @@ -587,7 +588,30 @@ fn pty_session_thread( output_reader.push_owned(pre); } } - let transfer = ZmodemTransfer::new(direction, &initial_bytes); + let prepared_upload = if direction == ZmodemDirection::Upload { + rt_for_reader.block_on(async { + manager_reader + .take_pending_zmodem_upload(&sid_read) + .await + }) + } else { + None + }; + let (transfer, bootstrap_actions) = start_zmodem_transfer( + direction, + &initial_bytes, + prepared_upload, + ); + for action in bootstrap_actions { + match action { + ZmodemAction::SendToRemote(data) => { + let _ = zmodem_out_tx.send(data); + } + ZmodemAction::EmitEvent(event) => { + let _ = app_read.emit(&zmodem_event_reader, &event); + } + } + } *zmodem_state_reader.lock().unwrap() = Some(transfer); let _ = app_read.emit( &zmodem_event_reader, @@ -761,6 +785,9 @@ fn pty_session_thread( } } SessionCommand::ZmodemCancel => { + rt_handle.block_on(async { + manager.clear_pending_zmodem_upload(&session_id).await; + }); let mut zm = zmodem_state.lock().unwrap(); if let Some(ref mut transfer) = *zm { let actions = transfer.cancel(); diff --git a/src-tauri/src/core/serial.rs b/src-tauri/src/core/serial.rs index 282070a8..0440c295 100644 --- a/src-tauri/src/core/serial.rs +++ b/src-tauri/src/core/serial.rs @@ -4,7 +4,8 @@ use super::session::{ SessionCommand, SessionHandle, SessionInfo, SessionManager, SessionType, SharedCwd, }; use super::zmodem::{ - ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemEvent, ZmodemTransfer, + ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemDirection, ZmodemEvent, + ZmodemTransfer, start_zmodem_transfer, }; use crate::config::AiExecutionProfile; use crate::core::capture::OutputCaptureProcessor; @@ -226,6 +227,8 @@ fn serial_session_thread( // Reader thread let app_reader = app.clone(); let sid_reader = session_id.clone(); + let manager_reader = manager.clone(); + let rt_handle_reader = rt_handle.clone(); let port_writer_reader = port_writer.clone(); let output_reader = output.clone(); let recording_mgr_reader = recording_mgr.clone(); @@ -284,7 +287,32 @@ fn serial_session_thread( output_reader.push_owned(pre); } } - let transfer = ZmodemTransfer::new(direction, &initial_bytes); + let prepared_upload = if direction == ZmodemDirection::Upload { + rt_handle_reader.block_on(async { + manager_reader + .take_pending_zmodem_upload(&sid_reader) + .await + }) + } else { + None + }; + let (transfer, bootstrap_actions) = start_zmodem_transfer( + direction, + &initial_bytes, + prepared_upload, + ); + for action in bootstrap_actions { + match action { + ZmodemAction::SendToRemote(data) => { + let mut p = port_writer_reader.lock().unwrap(); + let _ = p.write_all(&data); + let _ = p.flush(); + } + ZmodemAction::EmitEvent(event) => { + let _ = app_reader.emit(&zmodem_event_reader, &event); + } + } + } *zmodem_state_reader.lock().unwrap() = Some(transfer); let _ = app_reader .emit(&zmodem_event_reader, &ZmodemEvent::Detected { direction }); @@ -419,6 +447,9 @@ fn serial_session_thread( } } SessionCommand::ZmodemCancel => { + rt_handle.block_on(async { + manager.clear_pending_zmodem_upload(&session_id).await; + }); let mut zm = zmodem_state.lock().unwrap(); if let Some(ref mut transfer) = *zm { let actions = transfer.cancel(); diff --git a/src-tauri/src/core/session.rs b/src-tauri/src/core/session.rs index 6060b3a5..abf81f93 100644 --- a/src-tauri/src/core/session.rs +++ b/src-tauri/src/core/session.rs @@ -153,6 +153,7 @@ pub struct SessionManager { pub sessions: Arc>>, pub history_store: Arc>, command_submissions: Arc>>, + pending_zmodem_uploads: Arc>>>, history_save_notify: Arc, history_save_worker_started: AtomicBool, history_event_notify: Arc, @@ -168,6 +169,7 @@ impl SessionManager { sessions: Arc::new(Mutex::new(HashMap::new())), history_store: Arc::new(Mutex::new(CommandHistoryStore::new())), command_submissions: Arc::new(Mutex::new(HashMap::new())), + pending_zmodem_uploads: Arc::new(Mutex::new(HashMap::new())), history_save_notify: Arc::new(Notify::new()), history_save_worker_started: AtomicBool::new(false), history_event_notify: Arc::new(Notify::new()), @@ -261,6 +263,7 @@ impl SessionManager { if removed { self.flush_pending_submission(id).await; self.command_submissions.lock().await.remove(id); + self.pending_zmodem_uploads.lock().await.remove(id); } if removed { if let Some(app) = self.app_handle.get() { @@ -271,6 +274,25 @@ impl SessionManager { removed } + /// Takes and clears any prepared ZMODEM upload paths for a session. + pub async fn take_pending_zmodem_upload( + &self, + session_id: &str, + ) -> Option> { + self.pending_zmodem_uploads + .lock() + .await + .remove(session_id) + } + + /// Clears prepared ZMODEM upload paths without starting a transfer. + pub async fn clear_pending_zmodem_upload(&self, session_id: &str) { + self.pending_zmodem_uploads + .lock() + .await + .remove(session_id); + } + /// Sends a command to a session's I/O loop; errors if session not found. pub async fn send_command(&self, id: &str, cmd: SessionCommand) -> AppResult<()> { let sessions = self.sessions.lock().await; diff --git a/src-tauri/src/core/sftp/duplicate.rs b/src-tauri/src/core/sftp/duplicate.rs new file mode 100644 index 00000000..ace88cff --- /dev/null +++ b/src-tauri/src/core/sftp/duplicate.rs @@ -0,0 +1,103 @@ +use crate::core::SessionManager; +use crate::error::{AppError, AppResult}; +use serde::Serialize; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use tauri::{AppHandle, Emitter, Manager}; +use tokio::sync::{Mutex, oneshot}; + +const DUPLICATE_PROMPT_TIMEOUT: Duration = Duration::from_secs(120); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DuplicateChoice { + Skip, + Overwrite, +} + +impl DuplicateChoice { + pub fn from_action(action: &str) -> Option { + match action { + "skip" => Some(Self::Skip), + "overwrite" => Some(Self::Overwrite), + _ => None, + } + } +} + +/// Manages pending duplicate-file prompts awaiting user input from the frontend. +pub struct TransferDuplicateManager { + pending: Mutex>>, +} + +impl TransferDuplicateManager { + pub fn new() -> Self { + Self { + pending: Mutex::new(HashMap::new()), + } + } + + pub async fn register(&self, request_id: String) -> oneshot::Receiver { + let (tx, rx) = oneshot::channel(); + self.pending.lock().await.insert(request_id, tx); + rx + } + + pub async fn respond(&self, request_id: &str, choice: DuplicateChoice) -> bool { + if let Some(tx) = self.pending.lock().await.remove(request_id) { + tx.send(choice).is_ok() + } else { + false + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TransferDuplicateRequestPayload { + pub request_id: String, + pub session_id: String, + pub remote_path: String, + pub file_name: String, + pub is_directory: bool, + pub target_window_label: Option, +} + +pub async fn prompt_duplicate_choice( + app: &AppHandle, + session_manager: &SessionManager, + session_id: &str, + remote_path: &str, + file_name: &str, + is_directory: bool, +) -> AppResult { + let manager = app + .try_state::>() + .ok_or_else(|| AppError::Config("TransferDuplicateManager not available".to_string()))?; + + let target_window_label = session_manager + .session_info(session_id) + .await + .ok() + .and_then(|info| info.owner_window_label); + + let request_id = uuid::Uuid::new_v4().to_string(); + let rx = manager.register(request_id.clone()).await; + + let _ = app.emit( + "transfer-duplicate-request", + TransferDuplicateRequestPayload { + request_id, + session_id: session_id.to_string(), + remote_path: remote_path.to_string(), + file_name: file_name.to_string(), + is_directory, + target_window_label, + }, + ); + + match tokio::time::timeout(DUPLICATE_PROMPT_TIMEOUT, rx).await { + Ok(Ok(choice)) => Ok(choice), + _ => Ok(DuplicateChoice::Skip), + } +} diff --git a/src-tauri/src/core/sftp/mod.rs b/src-tauri/src/core/sftp/mod.rs index a34b2489..7ab5f14e 100644 --- a/src-tauri/src/core/sftp/mod.rs +++ b/src-tauri/src/core/sftp/mod.rs @@ -5,6 +5,7 @@ //! The upper layers and the frontend never need to know which protocol is in use. mod cache; +pub(crate) mod duplicate; mod scp_enhanced; mod scp_normal; mod sftp_backend; @@ -24,6 +25,7 @@ use crate::error::{AppError, AppResult}; use std::sync::Arc; use tokio::sync::RwLock; +pub(crate) use duplicate::TransferDuplicateManager; pub(crate) use transfer::{active_transfer_count, transfer_target_directory}; pub use transfer::{cancel_transfer, pause_transfer, resume_transfer}; pub use util::{FileEntry, FileProperties, RemoteFileAttributeUpdate, RemoteTextFile}; @@ -338,11 +340,15 @@ pub async fn upload_local_file( local_path: &str, remote_path: &str, transfer_id: Option, + duplicate_strategy_override: Option, ) -> AppResult<()> { let auto_fs = get_or_create_auto_fs(&manager, session_id).await?; - let transfer_settings = crate::config::load_app_settings(&app) + let mut transfer_settings = crate::config::load_app_settings(&app) .map(|s| s.transfer) .unwrap_or_default(); + if let Some(strategy) = duplicate_strategy_override { + transfer_settings.duplicate_strategy = strategy; + } let guard = auto_fs.backend().await?; let fs = guard.as_ref().unwrap(); fs.upload_file( @@ -531,10 +537,24 @@ pub async fn upload_local_directory( local_path: &str, remote_path: &str, transfer_id: Option, + duplicate_strategy_override: Option, ) -> AppResult<()> { let auto_fs = get_or_create_auto_fs(&manager, session_id).await?; + let mut transfer_settings = crate::config::load_app_settings(&app) + .map(|s| s.transfer) + .unwrap_or_default(); + if let Some(strategy) = duplicate_strategy_override { + transfer_settings.duplicate_strategy = strategy; + } let guard = auto_fs.backend().await?; let fs = guard.as_ref().unwrap(); - fs.upload_directory(&app, session_id, local_path, remote_path, transfer_id) - .await + fs.upload_directory( + &app, + session_id, + local_path, + remote_path, + &transfer_settings, + transfer_id, + ) + .await } diff --git a/src-tauri/src/core/sftp/scp_enhanced.rs b/src-tauri/src/core/sftp/scp_enhanced.rs index d9a31b61..93caeb8a 100644 --- a/src-tauri/src/core/sftp/scp_enhanced.rs +++ b/src-tauri/src/core/sftp/scp_enhanced.rs @@ -1069,6 +1069,7 @@ impl RemoteFs for ScpEnhancedBackend { session_id: &str, local_path: &str, remote_path: &str, + _transfer_settings: &crate::config::TransferSettings, transfer_id: Option, ) -> AppResult<()> { let local_stats = collect_local_directory_stats(local_path).await?; diff --git a/src-tauri/src/core/sftp/scp_normal.rs b/src-tauri/src/core/sftp/scp_normal.rs index a2c8fa26..46fe5762 100644 --- a/src-tauri/src/core/sftp/scp_normal.rs +++ b/src-tauri/src/core/sftp/scp_normal.rs @@ -12,7 +12,7 @@ use crate::error::{AppError, AppResult}; use crate::observability::{StructuredLog, StructuredLogLevel, log_event}; use russh::ChannelMsg; use std::sync::Arc; -use tauri::Emitter; +use tauri::{Emitter, Manager}; pub(crate) struct ScpNormalBackend { ssh_handle: Arc, @@ -694,15 +694,30 @@ async fn resolve_remote_exists(ssh_handle: &Arc, remote_pa .unwrap_or(false) } -async fn resolve_remote_path( +async fn resolve_remote_is_directory( ssh_handle: &Arc, remote_path: &str, +) -> bool { + exec_command(ssh_handle, &format!("test -d {}", sh_quote(remote_path))) + .await + .map(|r| r.exit_code == Some(0)) + .unwrap_or(false) +} + +async fn resolve_remote_path( + app: &tauri::AppHandle, + session_manager: &crate::core::SessionManager, + ssh_handle: &Arc, + session_id: &str, + remote_path: &str, strategy: &str, ) -> Option { let exists = resolve_remote_exists(ssh_handle, remote_path).await; if !exists { return Some(remote_path.to_string()); } + let file_name = file_name_from_path(remote_path); + let is_directory = resolve_remote_is_directory(ssh_handle, remote_path).await; match strategy { "skip" => None, "rename" => { @@ -728,10 +743,62 @@ async fn resolve_remote_path( } Some(remote_path.to_string()) } + "ask" => { + match super::duplicate::prompt_duplicate_choice( + app, + session_manager, + session_id, + remote_path, + &file_name, + is_directory, + ) + .await + { + Ok(super::duplicate::DuplicateChoice::Skip) => None, + Ok(super::duplicate::DuplicateChoice::Overwrite) => Some(remote_path.to_string()), + Err(_) => None, + } + } _ => Some(remote_path.to_string()), } } +async fn ensure_remote_upload_target_allowed( + app: &tauri::AppHandle, + session_manager: &crate::core::SessionManager, + ssh_handle: &Arc, + session_id: &str, + remote_path: &str, + strategy: &str, +) -> bool { + if !resolve_remote_exists(ssh_handle, remote_path).await { + return true; + } + + let file_name = file_name_from_path(remote_path); + let is_directory = resolve_remote_is_directory(ssh_handle, remote_path).await; + + match strategy { + "skip" => false, + "rename" => true, + "ask" => { + matches!( + super::duplicate::prompt_duplicate_choice( + app, + session_manager, + session_id, + remote_path, + &file_name, + is_directory, + ) + .await, + Ok(super::duplicate::DuplicateChoice::Overwrite) + ) + } + _ => true, + } +} + #[async_trait::async_trait] impl RemoteFs for ScpNormalBackend { fn backend_name(&self) -> &'static str { @@ -1020,41 +1087,19 @@ impl RemoteFs for ScpNormalBackend { transfer_id: Option, ) -> AppResult<()> { let max_retries = transfer_settings.max_transfer_retries; + let session_manager = app.state::>(); let actual_remote_path = match resolve_remote_path( + app, + session_manager.inner(), &self.ssh_handle, + session_id, remote_path, &transfer_settings.duplicate_strategy, ) .await { Some(path) => path, - None => { - let file_name = file_name_from_path(remote_path); - let transfer_id = transfer_id - .clone() - .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); - let _ = app.emit( - "transfer-event", - &TransferEvent { - id: transfer_id, - session_id: session_id.to_string(), - file_name, - remote_path: remote_path.to_string(), - local_path: local_path.to_string(), - direction: "upload".to_string(), - kind: "file".to_string(), - status: "completed".to_string(), - size: 0, - bytes_transferred: 0, - total_size: 0, - parent_id: None, - item_count_total: None, - item_count_completed: None, - error_msg: None, - }, - ); - return Ok(()); - } + None => return Ok(()), }; let mut last_err = None; @@ -1178,8 +1223,23 @@ impl RemoteFs for ScpNormalBackend { session_id: &str, local_path: &str, remote_path: &str, + transfer_settings: &crate::config::TransferSettings, transfer_id: Option, ) -> AppResult<()> { + let session_manager = app.state::>(); + if !ensure_remote_upload_target_allowed( + app, + session_manager.inner(), + &self.ssh_handle, + session_id, + remote_path, + &transfer_settings.duplicate_strategy, + ) + .await + { + return Ok(()); + } + let local_stats = collect_local_directory_stats(local_path).await?; let directory_controller = create_directory_transfer_controller( transfer_id, diff --git a/src-tauri/src/core/sftp/sftp_backend.rs b/src-tauri/src/core/sftp/sftp_backend.rs index 542b3aae..60e19dd1 100644 --- a/src-tauri/src/core/sftp/sftp_backend.rs +++ b/src-tauri/src/core/sftp/sftp_backend.rs @@ -15,7 +15,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; -use tauri::Emitter; +use tauri::{Emitter, Manager}; use tokio::sync::RwLock; const SFTP_MIN_REQUEST_KIB: usize = 64; @@ -464,7 +464,10 @@ async fn remove_dir_recursive(sftp: &SftpSession, path: &str) -> AppResult<()> { } async fn resolve_remote_path( + app: &tauri::AppHandle, + session_manager: &crate::core::SessionManager, sftp: &SftpSession, + session_id: &str, remote_path: &str, strategy: &str, ) -> Option { @@ -472,6 +475,12 @@ async fn resolve_remote_path( if !exists { return Some(remote_path.to_string()); } + let file_name = remote_path.split('/').last().unwrap_or(remote_path); + let is_directory = sftp + .metadata(remote_path) + .await + .map(|attrs| sftp_attrs_is_dir(&attrs)) + .unwrap_or(false); match strategy { "skip" => None, "rename" => { @@ -497,10 +506,67 @@ async fn resolve_remote_path( } Some(remote_path.to_string()) } + "ask" => { + match super::duplicate::prompt_duplicate_choice( + app, + session_manager, + session_id, + remote_path, + file_name, + is_directory, + ) + .await + { + Ok(super::duplicate::DuplicateChoice::Skip) => None, + Ok(super::duplicate::DuplicateChoice::Overwrite) => Some(remote_path.to_string()), + Err(_) => None, + } + } _ => Some(remote_path.to_string()), } } +async fn ensure_remote_upload_target_allowed( + app: &tauri::AppHandle, + session_manager: &crate::core::SessionManager, + sftp: &SftpSession, + session_id: &str, + remote_path: &str, + strategy: &str, +) -> bool { + let exists = sftp.metadata(remote_path).await.is_ok(); + if !exists { + return true; + } + + let file_name = file_name_from_path(remote_path); + let is_directory = sftp + .metadata(remote_path) + .await + .map(|attrs| sftp_attrs_is_dir(&attrs)) + .unwrap_or(false); + + match strategy { + "skip" => false, + "rename" => true, + "ask" => { + matches!( + super::duplicate::prompt_duplicate_choice( + app, + session_manager, + session_id, + remote_path, + &file_name, + is_directory, + ) + .await, + Ok(super::duplicate::DuplicateChoice::Overwrite) + ) + } + _ => true, + } +} + async fn apply_remote_mode(sftp: &SftpSession, path: &str, requested_mode: u32) -> AppResult<()> { let original_attrs = sftp.metadata(path).await?; let original_permissions = original_attrs.permissions; @@ -1455,9 +1521,13 @@ impl RemoteFs for SftpBackend { transfer_id: Option, ) -> AppResult<()> { let max_retries = transfer_settings.max_transfer_retries; + let session_manager = app.state::>(); let sftp_for_resolve = self.open_sftp().await?; let actual_remote_path = match resolve_remote_path( + app, + session_manager.inner(), &sftp_for_resolve, + session_id, remote_path, &transfer_settings.duplicate_strategy, ) @@ -1465,30 +1535,6 @@ impl RemoteFs for SftpBackend { { Some(path) => path, None => { - let file_name = remote_path.split('/').last().unwrap_or(remote_path); - let transfer_id = transfer_id - .clone() - .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); - let _ = app.emit( - "transfer-event", - &TransferEvent { - id: transfer_id, - session_id: session_id.to_string(), - file_name: file_name.to_string(), - remote_path: remote_path.to_string(), - local_path: local_path.to_string(), - direction: "upload".to_string(), - kind: "file".to_string(), - status: "completed".to_string(), - size: 0, - bytes_transferred: 0, - total_size: 0, - parent_id: None, - item_count_total: None, - item_count_completed: None, - error_msg: None, - }, - ); let _ = sftp_for_resolve.close().await; return Ok(()); } @@ -1643,13 +1689,28 @@ impl RemoteFs for SftpBackend { session_id: &str, local_path: &str, remote_path: &str, + transfer_settings: &crate::config::TransferSettings, transfer_id: Option, ) -> AppResult<()> { - let transfer_settings = crate::config::load_app_settings(app) - .map(|s| s.transfer) - .unwrap_or_default(); + let session_manager = app.state::>(); + let sftp_for_check = self.open_sftp().await?; + if !ensure_remote_upload_target_allowed( + app, + session_manager.inner(), + &sftp_for_check, + session_id, + remote_path, + &transfer_settings.duplicate_strategy, + ) + .await + { + let _ = sftp_for_check.close().await; + return Ok(()); + } + let _ = sftp_for_check.close().await; + let (request_kib, pipeline_depth, max_concurrent_writes) = - sftp_pipeline_config(&transfer_settings); + sftp_pipeline_config(transfer_settings); let concurrent_tasks = background_transfer_task_concurrency(transfer_settings.upload_threads); let transfer_started = Instant::now(); diff --git a/src-tauri/src/core/sftp/traits.rs b/src-tauri/src/core/sftp/traits.rs index 92078329..f950e904 100644 --- a/src-tauri/src/core/sftp/traits.rs +++ b/src-tauri/src/core/sftp/traits.rs @@ -57,6 +57,7 @@ pub(crate) trait RemoteFs: Send + Sync { session_id: &str, local_path: &str, remote_path: &str, + transfer_settings: &crate::config::TransferSettings, transfer_id: Option, ) -> AppResult<()>; } diff --git a/src-tauri/src/core/ssh/io.rs b/src-tauri/src/core/ssh/io.rs index b3c86e27..5c9201f4 100644 --- a/src-tauri/src/core/ssh/io.rs +++ b/src-tauri/src/core/ssh/io.rs @@ -2,7 +2,8 @@ use super::client::{SshHandle, SshHandler, SshPostLoginConfig}; use crate::core::capture::OutputCaptureProcessor; use crate::core::ssh::osc::{self, OscStripper, ShellKind}; use crate::core::zmodem::{ - ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemEvent, ZmodemTransfer, + ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemDirection, ZmodemEvent, + ZmodemTransfer, start_zmodem_transfer, }; use crate::core::{ RecordingManager, SessionCommand, SessionManager, SessionOutputCoalescer, SharedCwd, @@ -376,9 +377,15 @@ pub(super) async fn ssh_io_loop( if transfer.is_done() { zmodem_transfer = None; } + } else { + tracing::warn!( + session_id = %session_id, + "Received ZmodemAcceptUpload without an active transfer" + ); } } Some(SessionCommand::ZmodemCancel) => { + manager.clear_pending_zmodem_upload(&session_id).await; if let Some(ref mut transfer) = zmodem_transfer { let actions = transfer.cancel(); handle_zmodem_actions(&app, &zmodem_event_name, &mut channel, actions).await; @@ -406,8 +413,9 @@ pub(super) async fn ssh_io_loop( } // ZMODEM: detect header in raw bytes before lossy UTF-8 conversion. - if phase == IoPhase::Normal { - match zmodem_detector.feed(data) { + // Detection must run in every phase so an early `rz` is not missed + // while shell-integration output is still being suppressed. + match zmodem_detector.feed(data) { ZmodemDetectResult::Detected { direction, passthrough, initial_bytes } => { // Forward any pre-header bytes to the terminal. if !passthrough.is_empty() { @@ -416,8 +424,21 @@ pub(super) async fn ssh_io_loop( output.push_owned(pre); } } - let transfer = ZmodemTransfer::new(direction, &initial_bytes); + let prepared_upload = if direction == ZmodemDirection::Upload { + manager.take_pending_zmodem_upload(&session_id).await + } else { + None + }; + let (transfer, bootstrap_actions) = + start_zmodem_transfer(direction, &initial_bytes, prepared_upload); zmodem_transfer = Some(transfer); + handle_zmodem_actions( + &app, + &zmodem_event_name, + &mut channel, + bootstrap_actions, + ) + .await; let _ = app.emit(&zmodem_event_name, &ZmodemEvent::Detected { direction }); tracing::info!( session_id = %session_id, @@ -454,29 +475,6 @@ pub(super) async fn ssh_io_loop( continue; } } - } - - let text = String::from_utf8_lossy(data).to_string(); - let mut result = stripper.push(&text); - - if capture_processor.has_active() { - result.visible = capture_processor.process(&result.visible); - } - - handle_osc_result( - &app, - &output, - &cwd_event, - &cwd, - &recording_mgr, - &session_id, - &manager, - &mut channel, - &mut pending_script, - &mut inject_deadline, - &mut phase, - &result, - ).await; } Some(ChannelMsg::ExtendedData { ref data, .. }) => { let text = String::from_utf8_lossy(data).to_string(); diff --git a/src-tauri/src/core/telnet.rs b/src-tauri/src/core/telnet.rs index 89961e88..2a8e12cb 100644 --- a/src-tauri/src/core/telnet.rs +++ b/src-tauri/src/core/telnet.rs @@ -4,7 +4,8 @@ use super::session::{ SessionCommand, SessionHandle, SessionInfo, SessionManager, SessionType, SharedCwd, }; use super::zmodem::{ - ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemEvent, ZmodemTransfer, + ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemDirection, ZmodemEvent, + ZmodemTransfer, start_zmodem_transfer, }; use crate::config::AiExecutionProfile; use crate::core::capture::OutputCaptureProcessor; @@ -251,6 +252,7 @@ async fn telnet_session_task( let app_reader = app.clone(); let sid_reader = session_id.clone(); + let manager_reader = manager.clone(); let output_reader = output.clone(); let reader_connection_id = connection_id.clone(); let recording_mgr_reader = recording_mgr.clone(); @@ -314,7 +316,28 @@ async fn telnet_session_task( output_reader.push_owned(pre); } } - let transfer = ZmodemTransfer::new(direction, &initial_bytes); + let prepared_upload = if direction == ZmodemDirection::Upload { + manager_reader + .take_pending_zmodem_upload(&sid_reader) + .await + } else { + None + }; + let (transfer, bootstrap_actions) = start_zmodem_transfer( + direction, + &initial_bytes, + prepared_upload, + ); + for action in bootstrap_actions { + match action { + ZmodemAction::SendToRemote(data) => { + let _ = zmodem_out_tx.send(data); + } + ZmodemAction::EmitEvent(event) => { + let _ = app_reader.emit(&zmodem_event_reader, &event); + } + } + } *zmodem_state_reader.lock().await = Some(transfer); let _ = app_reader .emit(&zmodem_event_reader, &ZmodemEvent::Detected { direction }); @@ -465,6 +488,7 @@ async fn telnet_session_task( } } Some(SessionCommand::ZmodemCancel) => { + manager.clear_pending_zmodem_upload(&session_id).await; let mut zm = zmodem_state.lock().await; if let Some(ref mut transfer) = *zm { let actions = transfer.cancel(); diff --git a/src-tauri/src/core/zmodem.rs b/src-tauri/src/core/zmodem.rs index 628ac26c..cbd70550 100644 --- a/src-tauri/src/core/zmodem.rs +++ b/src-tauri/src/core/zmodem.rs @@ -63,6 +63,20 @@ pub enum ZmodemEvent { Failed { reason: String }, } +/// Creates a transfer and optionally auto-accepts a prepared upload. +pub fn start_zmodem_transfer( + direction: ZmodemDirection, + initial_bytes: &[u8], + prepared_upload_files: Option>, +) -> (ZmodemTransfer, Vec) { + let mut transfer = ZmodemTransfer::new(direction, initial_bytes); + let bootstrap_actions = match (direction, prepared_upload_files) { + (ZmodemDirection::Upload, Some(files)) => transfer.accept_upload(files), + _ => Vec::new(), + }; + (transfer, bootstrap_actions) +} + /// Actions returned to the I/O loop after feeding bytes. pub enum ZmodemAction { /// Send these bytes back to the remote (protocol responses). @@ -115,13 +129,18 @@ impl ZmodemDetector { /// /// `passthrough` contains bytes that can be shown in the terminal. Split /// header prefixes are retained internally until enough bytes arrive. + /// When an upload is detected, the "rz -y" shell echo is stripped from + /// passthrough so the user doesn't see the command. pub fn feed(&mut self, data: &[u8]) -> ZmodemDetectResult { self.pending.extend_from_slice(data); if let Some((direction, header_start)) = detect_zmodem_start(&self.pending) { - let passthrough = self.pending[..header_start].to_vec(); + let mut passthrough = self.pending[..header_start].to_vec(); let initial_bytes = self.pending[header_start..].to_vec(); self.reset(); + if direction == ZmodemDirection::Upload { + strip_rz_echo(&mut passthrough); + } return ZmodemDetectResult::Detected { direction, passthrough, @@ -221,6 +240,22 @@ fn has_rz_receive_prompt_before(data: &[u8], start: usize) -> bool { .any(|window| window == RZ_RECEIVE_PROMPT) } +/// Strip the "rz" shell echo from the end of the passthrough data +/// so that the user doesn't see the upload command in the terminal. +/// Handles common echo variants: "rz\r\n", "rz\r", "rz". +fn strip_rz_echo(data: &mut Vec) { + // Try to strip from the end: \r\n, \r, or just the command text. + // The command is always at the end of the passthrough because the + // ZMODEM header follows immediately. + let patterns: &[&[u8]] = &[b"rz\r\n", b"rz\r", b"rz"]; + for &pat in patterns { + if data.ends_with(pat) { + data.truncate(data.len() - pat.len()); + return; + } + } +} + fn is_possible_zmodem_prefix(data: &[u8]) -> bool { match data { [] => true, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2a5d2c7d..920b7f03 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -17,6 +17,7 @@ use std::sync::Arc; use crate::core::ai::AgentApprovalManager; use crate::core::ssh::{HostKeyVerifyManager, PendingAuthManager, TunnelManager}; +use crate::core::sftp::TransferDuplicateManager; use crate::core::{CloudSyncManager, QuickCommandsStore, RecordingManager, SessionManager}; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -32,6 +33,7 @@ pub fn run() { let quick_commands_store = Arc::new(QuickCommandsStore::new()); let cloud_sync_manager = Arc::new(CloudSyncManager::new()); let agent_approval_manager = Arc::new(AgentApprovalManager::new()); + let transfer_duplicate_manager = Arc::new(TransferDuplicateManager::new()); let builder = tauri::Builder::default(); #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -64,6 +66,7 @@ pub fn run() { .manage(quick_commands_store.clone()) .manage(cloud_sync_manager.clone()) .manage(agent_approval_manager.clone()) + .manage(transfer_duplicate_manager.clone()) .setup(move |a| { app::setup( a, @@ -155,6 +158,7 @@ pub fn run() { cmd::sftp::pause_transfer, cmd::sftp::resume_transfer, cmd::sftp::cancel_transfer, + cmd::sftp::respond_transfer_duplicate, cmd::connection::get_saved_connections, cmd::connection::save_connection, cmd::connection::delete_connection, diff --git a/src-tauri/vendor/zmodem2/.cargo-ok b/src-tauri/vendor/zmodem2/.cargo-ok new file mode 100644 index 00000000..5f8b7958 --- /dev/null +++ b/src-tauri/vendor/zmodem2/.cargo-ok @@ -0,0 +1 @@ +{"v":1} \ No newline at end of file diff --git a/src-tauri/vendor/zmodem2/.cargo_vcs_info.json b/src-tauri/vendor/zmodem2/.cargo_vcs_info.json new file mode 100644 index 00000000..7dfae52a --- /dev/null +++ b/src-tauri/vendor/zmodem2/.cargo_vcs_info.json @@ -0,0 +1,6 @@ +{ + "git": { + "sha1": "9635055dbc74652765e4066d18e1cfaac880e58c" + }, + "path_in_vcs": "" +} \ No newline at end of file diff --git a/src-tauri/vendor/zmodem2/.gitattributes b/src-tauri/vendor/zmodem2/.gitattributes new file mode 100644 index 00000000..6313b56c --- /dev/null +++ b/src-tauri/vendor/zmodem2/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/src-tauri/vendor/zmodem2/.gitignore b/src-tauri/vendor/zmodem2/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/src-tauri/vendor/zmodem2/.gitignore @@ -0,0 +1 @@ +/target diff --git a/src-tauri/vendor/zmodem2/Cargo.lock b/src-tauri/vendor/zmodem2/Cargo.lock new file mode 100644 index 00000000..53763d0f --- /dev/null +++ b/src-tauri/vendor/zmodem2/Cargo.lock @@ -0,0 +1,433 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "indexmap" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "proc-macro-crate" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "rstest" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fc39292f8613e913f7df8fa892b8944ceb47c247b78e1b1ae2f09e019be789d" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f168d99749d307be9de54d23fd226628d99768225ef08f6ffb52e0182a27746" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "syn" +version = "2.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "zmodem2" +version = "0.5.0" +dependencies = [ + "bitflags", + "hex", + "nix", + "rstest", + "tempfile", + "thiserror", +] diff --git a/src-tauri/vendor/zmodem2/Cargo.toml b/src-tauri/vendor/zmodem2/Cargo.toml new file mode 100644 index 00000000..4b749674 --- /dev/null +++ b/src-tauri/vendor/zmodem2/Cargo.toml @@ -0,0 +1,65 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2021" +rust-version = "1.81" +name = "zmodem2" +version = "0.5.0" +authors = ["Jarkko Sakkinen "] +build = "build.rs" +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "ZMODEM file transfer protocol" +homepage = "https://codeberg.org/jarkko/zmodem2" +readme = "README.md" +license = "MIT OR Apache-2.0" +repository = "https://codeberg.org/jarkko/zmodem2.git" + +[features] +default = ["std"] +std = [] + +[lib] +name = "zmodem2" +path = "src/lib.rs" + +[[test]] +name = "integration" +path = "tests/integration.rs" +harness = true + +[[test]] +name = "protocol" +path = "tests/protocol.rs" +harness = true + +[dependencies.bitflags] +version = "2.4" + +[dependencies.hex] +version = "0.4.3" + +[dependencies.thiserror] +version = "1.0" + +[dev-dependencies.nix] +version = "0.29" +features = ["fs"] + +[dev-dependencies.rstest] +version = "0.25" + +[dev-dependencies.tempfile] +version = "3.10" diff --git a/src-tauri/vendor/zmodem2/Cargo.toml.orig b/src-tauri/vendor/zmodem2/Cargo.toml.orig new file mode 100644 index 00000000..6ff4c0f2 --- /dev/null +++ b/src-tauri/vendor/zmodem2/Cargo.toml.orig @@ -0,0 +1,35 @@ +[package] +authors = ["Jarkko Sakkinen "] +description = "ZMODEM file transfer protocol" +edition = "2021" +homepage = "https://codeberg.org/jarkko/zmodem2" +license = "MIT OR Apache-2.0" +name = "zmodem2" +repository = "https://codeberg.org/jarkko/zmodem2.git" +rust-version = "1.81" +version = "0.5.0" +build = "build.rs" + +[features] +default = ["std"] +std = [] + +[dependencies] +bitflags = "2.4" +hex = "0.4.3" +thiserror = "1.0" + +[dev-dependencies] +nix = { version = "0.29", features = ["fs"] } +rstest = "0.25" +tempfile = "3.10" + +[[test]] +name = "protocol" +path = "tests/protocol.rs" +harness = true + +[[test]] +name = "integration" +path = "tests/integration.rs" +harness = true diff --git a/src-tauri/vendor/zmodem2/LICENSE-APACHE b/src-tauri/vendor/zmodem2/LICENSE-APACHE new file mode 100644 index 00000000..e062dfe0 --- /dev/null +++ b/src-tauri/vendor/zmodem2/LICENSE-APACHE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright (c) 2017-2020 Alexey Arbuzov +Copyright (c) 2023-2025 Jarkko Sakkinen + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/src-tauri/vendor/zmodem2/LICENSE-MIT b/src-tauri/vendor/zmodem2/LICENSE-MIT new file mode 100644 index 00000000..b2b97d50 --- /dev/null +++ b/src-tauri/vendor/zmodem2/LICENSE-MIT @@ -0,0 +1,26 @@ +Copyright (c) 2017-2020 Alexey Arbuzov +Copyright (c) 2023-2025 Jarkko Sakkinen + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/src-tauri/vendor/zmodem2/README.md b/src-tauri/vendor/zmodem2/README.md new file mode 100644 index 00000000..ab2f3253 --- /dev/null +++ b/src-tauri/vendor/zmodem2/README.md @@ -0,0 +1,16 @@ +# zmodem2 + +`zmodem2` provides a ZMODEM file tranmission protocol implementation with step +executed state by the caller. It is based on prior work of Aleksei Arbuzov in +the [`zmodem`](https://github.com/lexxvir/zmodem) crate. + +This crate is fully `no_std` compatible and *heapless*. + +## Development + +* Git: https://codeberg.org/jarkko/zmodem2.git +* Commits follow the + [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) + specification. +* New commits must include a `Signed-off-by` trailer. +* Versioning scheme uses [Semantic Versioning](https://semver.org/). diff --git a/src-tauri/vendor/zmodem2/build.rs b/src-tauri/vendor/zmodem2/build.rs new file mode 100644 index 00000000..ed6fec98 --- /dev/null +++ b/src-tauri/vendor/zmodem2/build.rs @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// Copyright (c) 2017-2020 Alexey Arbuzov +// Copyright (c) 2023-2025 Jarkko Sakkinen + +use std::env; +use std::path::PathBuf; + +fn main() { + println!("cargo::rustc-check-cfg=cfg(has_lrzsz)"); + + let rz = find_prog("ZMODEM_RZ_BIN", &["rz", "lrzsz-rz"]); + let sz = find_prog("ZMODEM_SZ_BIN", &["sz", "lrzsz-sz"]); + + match (rz, sz) { + (Some(rz_path), Some(sz_path)) => { + println!("cargo:rustc-cfg=has_lrzsz"); + println!("cargo:rustc-env=ZMODEM_RZ_BIN={}", rz_path.display()); + println!("cargo:rustc-env=ZMODEM_SZ_BIN={}", sz_path.display()); + } + _ => { + println!("cargo:warning=lrzsz not found"); + } + } +} + +fn find_prog(env_var: &str, candidates: &[&str]) -> Option { + if let Ok(path) = env::var(env_var) { + let p = PathBuf::from(path); + if p.is_file() { + return Some(p); + } + } + + let path = env::var_os("PATH")?; + for dir in env::split_paths(&path) { + for &name in candidates { + let full = dir.join(name); + if full.is_file() { + return Some(full); + } + } + } + + None +} diff --git a/src-tauri/vendor/zmodem2/docs/zmodem.txt b/src-tauri/vendor/zmodem2/docs/zmodem.txt new file mode 100644 index 00000000..da1a6c39 --- /dev/null +++ b/src-tauri/vendor/zmodem2/docs/zmodem.txt @@ -0,0 +1,3488 @@ + + + + The ZMODEM Inter Application File Transfer Protocol + + Chuck Forsberg + + Omen Technology Inc + + + A overview of this document is available as ZMODEM.OV + (in ZMDMOV.ARC) + + + + + +This file may be redistributed without restriction provided the text is +not altered. + +Please distribute as widely as possible. + + + + + + + + Omen Technology Incorporated + The High Reliability Software + + 17505-V Northwest Sauvie Island Road + Portland Oregon 97231 + VOICE: 503-621-3406 :VOICE + Modem: 503-621-3746 Speed 1200,2400,19200(Telebit PEP) + Compuserve:70007,2304 GEnie:CAF + UUCP: ...!tektronix!reed!omen!caf + + + + + + + + + + + + + + + + + + + + + + + + +Chapter 0 Rev Oct-14-88 Typeset 10-14-88 1 + + + + + + + +Chapter 0 ZMODEM Protocol 2 + + + +1. INTENDED AUDIENCE + +This document is intended for telecommunications managers, systems +programmers, and others who choose and implement asynchronous file +transfer protocols over dial-up networks and related environments. + + +2. WHY DEVELOP ZMODEM? + +Since its development half a decade ago, the Ward Christensen MODEM +protocol has enabled a wide variety of computer systems to interchange +data. There is hardly a communications program that doesn't at least +claim to support this protocol, now called XMODEM. + +Advances in computing, modems and networking have spread the XMODEM +protocol far beyond the micro to micro environment for which it was +designed. These application have exposed some weaknesses: + + + The awkward user interface is suitable for computer hobbyists. + Multiple commands must be keyboarded to transfer each file. + + + Since commands must be given to both programs, simple menu selections + are not possible. + + + The short block length causes throughput to suffer when used with + timesharing systems, packet switched networks, satellite circuits, + and buffered (error correcting) modems. + + + The 8 bit checksum and unprotected supervison allow undetected errors + and disrupted file transfers. + + + Only one file can be sent per command. The file name has to be given + twice, first to the sending program and then again to the receiving + program. + + + The transmitted file accumulates as many as 127 bytes of garbage. + + + The modification date and other file attributes are lost. + + + XMODEM requires complete 8 bit transparency, all 256 codes. XMODEM + will not operate over some networks that use ASCII flow control or + escape codes. Setting network transparency disables important + control functions for the duration of the call. + +A number of other protocols have been developed over the years, but none +have proven satisfactory. + + + Lack of public domain documentation and example programs have kept + proprietary protocols such as Relay, Blast, DART, and others tightly + bound to the fortunes of their suppliers. These protocols have not + benefited from public scrutiny of their design features. + + + +Chapter 2 Rev Oct-14-88 Typeset 10-14-88 2 + + + + + + + +Chapter 2 ZMODEM Protocol 3 + + + + + Link level protocols such as X.25, X.PC, and MNP do not manage + application to application file transfers. + + + Link Level protocols do not eliminate end-to-end errors. Interfaces + between error-free networks are not necessarily error-free. + Sometimes, error-free networks aren't. + + + The Kermit protocol was developed to allow file transfers in + environments hostile to XMODEM. The performance compromises + necessary to accommodate traditional mainframe environments limit + Kermit's efficiency. Even with completely transparent channels, + Kermit control character quoting limits the efficiency of binary file + transfers to about 75 per cent.[1] + + A number of submodes are used in various Kermit programs, including + different methods of transferring binary files. Two Kermit programs + will mysteriously fail to operate with each other if the user has not + correctly specified these submodes. + + Kermit Sliding Windows ("SuperKermit") improves throughput over + networks at the cost of increased complexity. SuperKermit requires + full duplex communications and the ability to check for the presence + of characters in the input queue, precluding its implementation on + some operating systems. + + SuperKermit state transitions are encoded in a special language + "wart" which requires a C compiler. + + SuperKermit sends an ACK packet for each data packet of 96 bytes + (fewer if control characters are present). This reduces throughput + on high speed modems, from 1350 to 177 characters per second in one + test. + +A number of extensions to the XMODEM protocol have been made to improve +performance and (in some cases) the user interface. They provide useful +improvements in some applications but not in others. XMODEM's unprotected +control messages compromise their reliability. Complex proprietary +techniques such as Cybernetic Data Recovery(TM)[2] improve reliability, +but are not universally available. Some of the XMODEM mutant protocols +have significant design flaws of their own. + + + XMODEM-k uses 1024 byte blocks to reduce the overhead from transmission + delays by 87 per cent compared to XMODEM, but network delays still + + +__________ + + 1. Some Kermit programs support run length encoding. + + 2. Unique to DSZ, ZCOMM, Professional-YAM and PowerCom + + + + +Chapter 2 Rev Oct-14-88 Typeset 10-14-88 3 + + + + + + + +Chapter 2 ZMODEM Protocol 4 + + + + degrade performance. Some networks cannot transmit 1024 byte packets + without flow control, which is difficult to apply without impairing the + perfect transparency required by XMODEM. XMODEM-k adds garbage to + received files. + + + YMODEM sends the file name, file length, and creation date at the + beginning of each file, and allows optional 1024 byte blocks for + improved throughput. The handling of files that are not a multiple of + 1024 or 128 bytes is awkward, especially if the file length is not + known in advance, or changes during transmission. The large number of + non conforming and substandard programs claiming to support YMODEM + further complicates its use. + + + YMODEM-g provides efficient batch file transfers, preserving exact file + length and file modification date. YMODEM-g is a modification to + YMODEM wherein ACKs for data blocks are not used. YMODEM-g is + essentially insensitive to network delays. Because it does not support + error recovery, YMODEM-g must be used hard wired or with a reliable + link level protocol. Successful application at high speed requires + cafeful attention to transparent flow control. When YMODEM-g detects a + CRC error, data transfers are aborted. YMODEM-g is easy to implement + because it closely resembles standard YMODEM-1k. + + + WXMODEM, SEAlink, and MEGAlink have applied a subset of ZMODEM's + techniques to "Classic XMODEM" to improve upon their suppliers' + previous offerings. They provide good performance under ideal + conditions. + +Another XMODEM "extension" is protocol cheating, such as Omen Technology's +OverThruster(TM) and OverThruster II(TM). These improve XMODEM throughput +under some conditions by compromising error recovery. + +The ZMODEM Protocol corrects the weaknesses described above while +maintaining as much of XMODEM/CRC's simplicity and prior art as possible. + + + +3. ZMODEM Protocol Design Criteria + +The design of a file transfer protocol is an engineering compromise +between conflicting requirements: + +3.1 Ease of Use + + + ZMODEM allows either program to initiate file transfers. + + + The sender can pass commands and/or modifiers to the receiving program. + + + File names need be entered only once. + + + + + +Chapter 3 Rev Oct-14-88 Typeset 10-14-88 4 + + + + + + + +Chapter 3 ZMODEM Protocol 5 + + + + + Menu selections are supported. + + + Wild Card names may be used with batch transfers. + + + Minimum keystrokes required to initiate transfers. + + + ZRQINIT frame sent by sending program can trigger automatic downloads. + + + ZMODEM can optionally step down to YMODEM if the other end does not + support ZMODEM.[1] + +3.2 Throughput + +All file transfer protocols make tradeoffs between throughput, +reliability, universality, and complexity according to the technology and +knowledge base available to their designers. + +In the design of ZMODEM, three applications deserve special attention. + + + Network applications with significant delays (relative to character + transmission time) and low error rate + + + Timesharing and buffered modem applications with significant delays + and throughput that is quickly degraded by reverse channel traffic. + ZMODEM's economy of reverse channel bandwidth allows modems that + dynamically partition bandwidth between the two directions to operate + at optimal speeds. Special ZMODEM features allow simple, efficient + implementation on a wide variety of timesharing hosts. + + + Traditional direct modem to modem communications with high error rate + +Unlike Sliding Windows Kermit, ZMODEM is not optimized for optimum +throughput when error rate and delays are both high. This tradeoff +markedly reduces code complexity and memory requirements. ZMODEM +generally provides faster error recovery than network compatible XMODEM +implementations. + +In the absence of network delays, rapid error recovery is possible, much +faster than MEGAlink and network compatible versions of YMODEM and XMODEM. + +File transfers begin immediately regardless of which program is started +first, without the 10 second delay associated with XMODEM. + + + + + +__________ + + 1. Provided the transmission medium accommodates X/YMODEM. + + + + +Chapter 3 Rev Oct-14-88 Typeset 10-14-88 5 + + + + + + + +Chapter 3 ZMODEM Protocol 6 + + + +3.3 Integrity and Robustness + +Once a ZMODEM session is begun, all transactions are protected with 16 or +32 bit CRC.[2] Complex proprietary techniques such as Omen Technology's +Cybernetic Data Recovery(TM)[3] are not needed for reliable transfers. +This complete protection of data and supervisory information accounts for +most of ZMODEM's high reliability compared to XMODEM derived protocols +including WXMODEM, SEAlink, MEGAlink, etc. + +An optional 32-bit CRC used as the frame check sequence in ADCCP (ANSI +X3.66, also known as FIPS PUB 71 and FED-STD-1003, the U.S. versions of +CCITT's X.25) is available. The 32 bit CRC reduces undetected errors by +at least five orders of magnitude when properly applied (-1 preset, +inversion). + +A security challenge mechanism guards against "Trojan Horse" messages +written to mimic legitimate command or file downloads. + +3.4 Ease of Implementation + +ZMODEM accommodates a wide variety of systems: + + + Microcomputers that cannot overlap disk and serial i/o + + + Microcomputers that cannot overlap serial send and receive + + + Computers and/or networks requiring XON/XOFF flow control + + + Computers that cannot check the serial input queue for the presence of + data without having to wait for the data to arrive. + +Although ZMODEM provides "hooks" for multiple "threads", ZMODEM is not +intended to replace link level protocols such as X.25. + +ZMODEM accommodates network and timesharing system delays by continuously +transmitting data unless the receiver interrupts the sender to request +retransmission of garbled data. ZMODEM in effect uses the entire file as +a window.[4] Using the entire file as a window simplifies buffer +management, avoiding the window overrun failure modes that affect +MEGAlink, SuperKermit, and others. + + +__________ + + 2. Except for the CAN-CAN-CAN-CAN-CAN abort sequence which requires five + successive CAN characters. + + 3. Unique to Professional-YAM, ZCOMM, and PowerCom + + 4. Streaming strategies are discussed in coming chapters. + + + + +Chapter 3 Rev Oct-14-88 Typeset 10-14-88 6 + + + + + + + +Chapter 3 ZMODEM Protocol 7 + + + +ZMODEM provides a general purpose application to application file transfer +protocol which may be used directly or with with reliable link level +protocols such as X.25, MNP, Fastlink, etc. When used with X.25, MNP, +Fastlink, etc., ZMODEM detects and corrects errors in the interfaces +between error controlled media and the remainder of the communications +link. + +ZMODEM was developed for the public domain under a Telenet contract. The +ZMODEM protocol descriptions and the Unix rz/sz program source code are +public domain. No licensing, trademark, or copyright restrictions apply +to the use of the protocol, the Unix rz/sz source code and the ZMODEM +name. + + +4. EVOLUTION OF ZMODEM + +In early 1986, Telenet funded a project to develop an improved public +domain application to application file transfer protocol. This protocol +would alleviate the throughput problems network customers were +experiencing with XMODEM and Kermit file transfers. + +In the beginning, we thought a few modifications to XMODEM would allow +high performance over packet switched networks while preserving XMODEM's +simplicity. + +The initial concept would add a block number to the ACK and NAK characters +used by XMODEM. The resultant protocol would allow the sender to send +more than one block before waiting for a response. + +But how to add the block number to XMODEM's ACK and NAK? WXMODEM, +SEAlink, MEGAlink and some other protocols add binary byte(s) to indicate +the block number. + +Pure binary was unsuitable for ZMODEM because binary code combinations +won't pass bidirectionally through some modems, networks and operating +systems. Other operating systems may not be able to recognize something +coming back[1] unless a break signal or a system dependent code or +sequence is present. By the time all this and other problems with the +simple ACK/NAK sequences mentioned above were corrected, XMODEM's simple +ACK and NACK characters had evolved into a real packet. The Frog was +riveting. + +Managing the window[2] was another problem. Experience gained in + + +__________ + + 1. Without stopping for a response + + 2. The WINDOW is the data in transit between sender and receiver. + + + + +Chapter 4 Rev Oct-14-88 Typeset 10-14-88 7 + + + + + + + +Chapter 4 ZMODEM Protocol 8 + + + +debugging The Source's SuperKermit protocol indicated a window size of +about 1000 characters was needed at 1200 bps. High speed modems require a +window of 20000 or more characters for full throughput. Much of the +SuperKermit's inefficiency, complexity and debugging time centered around +its ring buffering and window management. There had to be a better way to +get the job done. + +A sore point with XMODEM and its progeny is error recovery. More to the +point, how can the receiver determine whether the sender has responded, or +is ready to respond, to a retransmission request? XMODEM attacks the +problem by throwing away characters until a certain period of silence. +Too short a time allows a spurious pause in output (network or timesharing +congestion) to masquerade as error recovery. Too long a timeout +devastates throughput, and allows a noisy line to lock up the protocol. +SuperKermit solves the problem with a distinct start of packet character +(SOH). WXMODEM and ZMODEM use unique character sequences to delineate the +start of frames. SEAlink and MEGAlink do not address this problem. + +A further error recovery problem arises in streaming protocols. How does +the receiver know when (or if) the sender has recognized its error signal? +Is the next packet the correct response to the error signal? Is it +something left over "in the queue"? Or is this new subpacket one of many +that will have to be discarded because the sender did not receive the +error signal? How long should this continue before sending another error +signal? How can the protocol prevent this from degenerating into an +argument about mixed signals? + +SuperKermit uses selective retransmission, so it can accept any good +packet it receives. Each time the SuperKermit receiver gets a data +packet, it must decide which outstanding packet (if any) it "wants most" +to receive, and asks for that one. In practice, complex software "hacks" +are needed to attain acceptable robustness.[3] + +For ZMODEM, we decided to forgo the complexity of SuperKermit's packet +assembly scheme and its associated buffer management logic and memory +requirements. + +Another sore point with XMODEM and WXMODEM is the garbage added to files. +This was acceptable with the old CP/M files which had no exact length, but +not with newer systems such as PC-DOS and Unix. YMODEM uses file length +information transmitted in the header block to trim the output file, but +this causes data loss when transferring files that grow during a transfer. + + +__________ + + 3. For example, when SuperKermit encounters certain errors, the wndesr + function is called to determine the next block to request. A burst of + errors generates several wasteful requests to retransmit the same + block. + + + + +Chapter 4 Rev Oct-14-88 Typeset 10-14-88 8 + + + + + + + +Chapter 4 ZMODEM Protocol 9 + + + +In some cases, the file length may be unknown, as when data is obtained +from a process. Variable length data subpackets solve both of these +problems. + +Since some characters had to be escaped anyway, there wasn't any point +wasting bytes to fill out a fixed packet length or to specify a variable +packet length. In ZMODEM, the length of data subpackets is denoted by +ending each subpacket with an escape sequence similar to BISYNC and HDLC. + +The end result is a ZMOEM header containing a "frame type", four bytes of +supervisory information, and its own CRC. Data frames consist of a header +followed by 1 or more data subpackets. In the absence of transmission +errors, an entire file can be sent in one data frame. + +Since the sending system may be sensitive to numerous control characters +or strip parity in the reverse data path, all of the headers sent by the +receiver are sent in hex. A common lower level routine receives all +headers, allowing the main program logic to deal with headers and data +subpackets as objects. + +With equivalent binary (efficient) and hex (application friendly) frames, +the sending program can send an "invitation to receive" sequence to +activate the receiver without crashing the remote application with +unexpected control characters. + +Going "back to scratch" in the protocol design presents an opportunity to +steal good ideas from many sources and to add a few new ones. + +From Kermit and UUCP comes the concept of an initial dialog to exchange +system parameters. + +ZMODEM generalizes Compuserve B Protocol's host controlled transfers to +single command AutoDownload and command downloading. A Security Challenge +discourages password hackers and Trojan Horse authors from abusing +ZMODEM's power. + +We were also keen to the pain and $uffering of legions of +telecommunicators whose file transfers have been ruined by communications +and timesharing faults. ZMODEM's file transfer recovery and advanced file +management are dedicated to these kindred comrades. + +After ZMODEM had been operational a short time, Earl Hall pointed out the +obvious: ZMODEM's user friendly AutoDownload was almost useless if the +user must assign transfer options to each of the sending and receiving +programs. Now, transfer options may be specified to/by the sending +program, which passes them to the receiving program in the ZFILE header. + + + + + + + + +Chapter 5 Rev Oct-14-88 Typeset 10-14-88 9 + + + + + + + +Chapter 5 ZMODEM Protocol 10 + + + +5. ROSETTA STONE + +Here are some definitions which reflect current vernacular in the computer +media. The attempt here is identify the file transfer protocol rather +than specific programs. + +FRAME A ZMODEM frame consists of a header and 0 or more data subpackets. + +XMODEM refers to the original 1977 file transfer etiquette introduced by + Ward Christensen's MODEM2 program. It's also called the MODEM or + MODEM2 protocol. Some who are unaware of MODEM7's unusual batch + file mode call it MODEM7. Other aliases include "CP/M Users's + Group" and "TERM II FTP 3". This protocol is supported by most + communications programs because it is easy to implement. + +XMODEM/CRC replaces XMODEM's 1 byte checksum with a two byte Cyclical + Redundancy Check (CRC-16), improving error detection. + +XMODEM-1k Refers to XMODEM-CRC with optional 1024 byte blocks. + +YMODEM refers to the XMODEM/CRC protocol with batch transmission and + optional 1024 byte blocks as described in YMODEM.DOC.[1] + + +6. ZMODEM REQUIREMENTS + +ZMODEM requires an 8 bit transfer medium.[1] ZMODEM escapes network +control characters to allow operation with packet switched networks. In +general, ZMODEM operates over any path that supports XMODEM, and over many +that don't. + +To support full streaming,[2] the transmission path should either assert +flow control or pass full speed transmission without loss of data. +Otherwise the ZMODEM sender must manage the window size. + +6.1 File Contents + +6.1.1 Binary Files +ZMODEM places no constraints on the information content of binary files, +except that the number of bits in the file must be a multiple of 8. + + + +__________ + + 1. Available on TeleGodzilla as part of YZMODEM.ZOO + + 1. The ZMODEM design allows encoded packets for less transparent media. + + 2. With XOFF and XON, or out of band flow control such as X.25 or CTS + + + + +Chapter 6 Rev Oct-14-88 Typeset 10-14-88 10 + + + + + + + +Chapter 6 ZMODEM Protocol 11 + + + +6.1.2 Text Files +Since ZMODEM is used to transfer files between different types of computer +systems, text files must meet minimum requirements if they are to be +readable on a wide variety of systems and environments. + +Text lines consist of printing ASCII characters, spaces, tabs, and +backspaces. + +6.1.2.1 ASCII End of Line +The ASCII code definition allows text lines terminated by a CR/LF (015, +012) sequence, or by a NL (012) character. Lines logically terminated by +a lone CR (013) are not ASCII text. + +A CR (013) without a linefeed implies overprinting, and is not acceptable +as a logical line separator. Overprinted lines should print all important +characters in the last pass to allow CRT displays to display meaningful +text. Overstruck characters may be generated by backspacing or by +overprinting the line with CR (015) not followed by LF. + +Overstruck characters generated with backspaces should be sent with the +most important character last to accommodate CRT displays that cannot +overstrike. The sending program may use the ZCNL bit to force the +receiving program to convert the received end of line to its local end of +line convention.[3] + + + + + + + + + + + + + + + + + + + + + + +__________ + + 3. Files that have been translated in such a way as to modify their + length cannot be updated with the ZCRECOV Conversion Option. + + + + +Chapter 6 Rev Oct-14-88 Typeset 10-14-88 11 + + + + + + + +Chapter 6 ZMODEM Protocol 12 + + + +7. ZMODEM BASICS + +7.1 Packetization + +ZMODEM frames differ somewhat from XMODEM blocks. XMODEM blocks are not +used for the following reasons: + + + Block numbers are limited to 256 + + + No provision for variable length blocks + + + Line hits corrupt protocol signals, causing failed file transfers. In + particular, modem errors sometimes generate false block numbers, false + EOTs and false ACKs. False ACKs are the most troublesome as they cause + the sender to lose synchronization with the receiver. + + State of the art programs such as Professional-YAM and ZCOMM overcome + some of these weaknesses with clever proprietary code, but a stronger + protocol is desired. + + + It is difficult to determine the beginning and ends of XMODEM blocks + when line hits cause a loss of synchronization. This precludes rapid + error recovery. + +7.2 Link Escape Encoding + +ZMODEM achieves data transparency by extending the 8 bit character set +(256 codes) with escape sequences based on the ZMODEM data link escape +character ZDLE.[1] + +Link Escape coding permits variable length data subpackets without the +overhead of a separate byte count. It allows the beginning of frames to +be detected without special timing techniques, facilitating rapid error +recovery. + +Link Escape coding does add some overhead. The worst case, a file +consisting entirely of escaped characters, would incur a 50% overhead. + +The ZDLE character is special. ZDLE represents a control sequence of some +sort. If a ZDLE character appears in binary data, it is prefixed with +ZDLE, then sent as ZDLEE. + +The value for ZDLE is octal 030 (ASCII CAN). This particular value was +chosen to allow a string of 5 consecutive CAN characters to abort a ZMODEM + + +__________ + + 1. This and other constants are defined in the zmodem.h include file. + Please note that constants with a leading 0 are octal constants in C. + + + + +Chapter 7 Rev Oct-14-88 Typeset 10-14-88 12 + + + + + + + +Chapter 7 ZMODEM Protocol 13 + + + +session, compatible with YMODEM session abort. + +Since CAN is not used in normal terminal operations, interactive +applications and communications programs can monitor the data flow for +ZDLE. The following characters can be scanned to detect the ZRQINIT +header, the invitation to automatically download commands or files. + +Receipt of five successive CAN characters will abort a ZMODEM session. +Eight CAN characters are sent. + +The receiving program decodes any sequence of ZDLE followed by a byte with +bit 6 set and bit 5 reset (upper case letter, either parity) to the +equivalent control character by inverting bit 6. This allows the +transmitter to escape any control character that cannot be sent by the +communications medium. In addition, the receiver recognizes escapes for +0177 and 0377 should these characters need to be escaped. + +ZMODEM software escapes ZDLE, 020, 0220, 021, 0221, 023, and 0223. If +preceded by 0100 or 0300 (@), 015 and 0215 are also escaped to protect the +Telenet command escape CR-@-CR. The receiver ignores 021, 0221, 023, and +0223 characters in the data stream. + +The ZMODEM routines in zm.c accept an option to escape all control +characters, to allow operation with less transparent networks. This +option can be given to either the sending or receiving program. + +7.3 Header + +All ZMODEM frames begin with a header which may be sent in binary or HEX +form. ZMODEM uses a single routine to recognize binary and hex headers. +Either form of the header contains the same raw information: + + + A type byte[2] [3] + + + Four bytes of data indicating flags and/or numeric quantities depending + on the frame type + + + + + + + +__________ + + 2. The frame types are cardinal numbers beginning with 0 to minimize + state transition table memory requirements. + + 3. Future extensions to ZMODEM may use the high order bits of the type + byte to indicate thread selection. + + + + +Chapter 7 Rev Oct-14-88 Typeset 10-14-88 13 + + + + + + + +Chapter 7 ZMODEM Protocol 14 + + + + Figure 1. Order of Bytes in Header + + TYPE: frame type + F0: Flags least significant byte + P0: file Position least significant + P3: file Position most significant + + TYPE F3 F2 F1 F0 + ------------------- + TYPE P0 P1 P2 P3 + +7.3.1 16 Bit CRC Binary Header +A binary header is sent by the sending program to the receiving program. +ZDLE encoding accommodates XON/XOFF flow control. + +A binary header begins with the sequence ZPAD, ZDLE, ZBIN. + +The frame type byte is ZDLE encoded. + +The four position/flags bytes are ZDLE encoded. + +A two byte CRC of the frame type and position/flag bytes is ZDLE encoded. + +0 or more binary data subpackets with 16 bit CRC will follow depending on +the frame type. + +The function zsbhdr transmits a binary header. The function zgethdr +receives a binary or hex header. + + Figure 2. 16 Bit CRC Binary Header + * ZDLE A TYPE F3/P0 F2/P1 F1/P2 F0/P3 CRC-1 CRC-2 + + +7.3.2 32 Bit CRC Binary Header +A "32 bit CRC" Binary header is similar to a Binary Header, except the +ZBIN (A) character is replaced by a ZBIN32 (C) character, and four +characters of CRC are sent. 0 or more binary data subpackets with 32 bit +CRC will follow depending on the frame type. + +The common variable Txfcs32 may be set TRUE for 32 bit CRC iff the +receiver indicates the capability with the CANFC32 bit. The zgethdr, +zsdata and zrdata functions automatically adjust to the type of Frame +Check Sequence being used. + Figure 3. 32 Bit CRC Binary Header + * ZDLE C TYPE F3/P0 F2/P1 F1/P2 F0/P3 CRC-1 CRC-2 CRC-3 CRC-4 + + +7.3.3 HEX Header +The receiver sends responses in hex headers. The sender also uses hex +headers when they are not followed by binary data subpackets. + + + + +Chapter 7 Rev Oct-14-88 Typeset 10-14-88 14 + + + + + + + +Chapter 7 ZMODEM Protocol 15 + + + +Hex encoding protects the reverse channel from random control characters. +The hex header receiving routine ignores parity. + +Use of Kermit style encoding for control and paritied characters was +considered and rejected because of increased possibility of interacting +with some timesharing systems' line edit functions. Use of HEX headers +from the receiving program allows control characters to be used to +interrupt the sender when errors are detected. A HEX header may be used +in place of a binary header wherever convenient. If a data packet follows +a HEX header, it is protected with CRC-16. + +A hex header begins with the sequence ZPAD, ZPAD, ZDLE, ZHEX. The zgethdr +routine synchronizes with the ZPAD-ZDLE sequence. The extra ZPAD +character allows the sending program to detect an asynchronous header +(indicating an error condition) and then call zgethdr to receive the +header. + +The type byte, the four position/flag bytes, and the 16 bit CRC thereof +are sent in hex using the character set 01234567890abcdef. Upper case hex +digits are not allowed; they false trigger XMODEM and YMODEM programs. +Since this form of hex encoding detects many patterns of errors, +especially missing characters, a hex header with 32 bit CRC has not been +defined. + +A carriage return and line feed are sent with HEX headers. The receive +routine expects to see at least one of these characters, two if the first +is CR. The CR/LF aids debugging from printouts, and helps overcome +certain operating system related problems. + +An XON character is appended to all HEX packets except ZACK and ZFIN. The +XON releases the sender from spurious XOFF flow control characters +generated by line noise, a common occurrence. XON is not sent after ZACK +headers to protect flow control in streaming situations. XON is not sent +after a ZFIN header to allow clean session cleanup. + +0 or more data subpackets will follow depending on the frame type. + +The function zshhdr sends a hex header. + + Figure 4. HEX Header + + * * ZDLE B TYPE F3/P0 F2/P1 F1/P2 F0/P3 CRC-1 CRC-2 CR LF XON + +(TYPE, F3...F0, CRC-1, and CRC-2 are each sent as two hex digits.) + + + + + + + + + + +Chapter 7 Rev Oct-14-88 Typeset 10-14-88 15 + + + + + + + +Chapter 7 ZMODEM Protocol 16 + + + +7.4 Binary Data Subpackets + +Binary data subpackets immediately follow the associated binary header +packet. A binary data packet contains 0 to 1024 bytes of data. +Recommended length values are 256 bytes below 2400 bps, 512 at 2400 bps, +and 1024 above 4800 bps or when the data link is known to be relatively +error free.[4] + +No padding is used with binary data subpackets. The data bytes are ZDLE +encoded and transmitted. A ZDLE and frameend are then sent, followed by +two or four ZDLE encoded CRC bytes. The CRC accumulates the data bytes +and frameend. + +The function zsdata sends a data subpacket. The function zrdata receives +a data subpacket. + +7.5 ASCII Encoded Data Subpacket + +The format of ASCII Encoded data subpackets is not currently specified. +These could be used for server commands, or main transfers in 7 bit +environments. + + +8. PROTOCOL TRANSACTION OVERVIEW + +As with the XMODEM recommendation, ZMODEM timing is receiver driven. The +transmitter should not time out at all, except to abort the program if no +headers are received for an extended period of time, say one minute.[1] + + +8.1 Session Startup + +To start a ZMODEM file transfer session, the sending program is called +with the names of the desired file(s) and option(s). + +The sending program may send the string "rz\r" to invoke the receiving +program from a possible command mode. The "rz" followed by carriage +return activates a ZMODEM receive program or command if it were not +already active. + +The sender may then display a message intended for human consumption, such + + +__________ + + 4. Strategies for adjusting the subpacket length for optimal results + based on real time error rates are still evolving. Shorter subpackets + speed error detection but increase protocol overhead slightly. + + 1. Special considerations apply when sending commands. + + + + +Chapter 8 Rev Oct-14-88 Typeset 10-14-88 16 + + + + + + + +Chapter 8 ZMODEM Protocol 17 + + + +as a list of the files requested, etc. + +Then the sender may send a ZRQINIT header. The ZRQINIT header causes a +previously started receive program to send its ZRINIT header without +delay. + +In an interactive or conversational mode, the receiving application may +monitor the data stream for ZDLE. The following characters may be scanned +for B00 indicating a ZRQINIT header, a command to download a command or +data. + +The sending program awaits a command from the receiving program to start +file transfers. If a "C", "G", or NAK is received, an XMODEM or YMODEM +file transfer is indicated, and file transfer(s) use the YMODEM protocol. +Note: With ZMODEM and YMODEM, the sending program provides the file name, +but not with XMODEM. + +In case of garbled data, the sending program can repeat the invitation to +receive a number of times until a session starts. + +When the ZMODEM receive program starts, it immediately sends a ZRINIT +header to initiate ZMODEM file transfers, or a ZCHALLENGE header to verify +the sending program. The receive program resends its header at response +time (default 10 second) intervals for a suitable period of time (40 +seconds total) before falling back to YMODEM protocol. + +If the receiving program receives a ZRQINIT header, it resends the ZRINIT +header. If the sending program receives the ZCHALLENGE header, it places +the data in ZP0...ZP3 in an answering ZACK header. + +If the receiving program receives a ZRINIT header, it is an echo +indicating that the sending program is not operational. + +Eventually the sending program correctly receives the ZRINIT header. + +The sender may then send an optional ZSINIT frame to define the receiving +program's Attn sequence, or to specify complete control character +escaping.[2] + +If the ZSINIT header specifies ESCCTL or ESC8, a HEX header is used, and +the receiver activates the specified ESC modes before reading the +following data subpacket. + +The receiver sends a ZACK header in response, containing either the serial + + +__________ + + 2. If the receiver specifies the same or higher level of escaping, the + ZSINIT frame need not be sent unless an Attn sequence is needed. + + + + +Chapter 8 Rev Oct-14-88 Typeset 10-14-88 17 + + + + + + + +Chapter 8 ZMODEM Protocol 18 + + + +number of the receiving program, or 0. + +8.2 File Transmission + +The sender then sends a ZFILE header with ZMODEM Conversion, Management, +and Transport options[3] followed by a ZCRCW data subpacket containing the +file name, file length, modification date, and other information identical +to that used by YMODEM Batch. + +The receiver examines the file name, length, and date information provided +by the sender in the context of the specified transfer options, the +current state of its file system(s), and local security requirements. The +receiving program should insure the pathname and options are compatible +with its operating environment and local security requirements. + +The receiver may respond with a ZSKIP header, which makes the sender +proceed to the next file (if any) in the batch. + + The receiver has a file with the same name and length, may + respond with a ZCRC header with a byte count, which + requires the sender to perform a 32 bit CRC on the + specified number of bytes in the file and transmit the + complement of the CRC in an answering ZCRC header.[4] The + receiver uses this information to determine whether to + accept the file or skip it. This sequence may be triggered + by the ZMCRC Management Option. + +A ZRPOS header from the receiver initiates transmission of the file data +starting at the offset in the file specified in the ZRPOS header. +Normally the receiver specifies the data transfer to begin begin at +offset 0 in the file. + The receiver may start the transfer further down in the + file. This allows a file transfer interrupted by a loss + or carrier or system crash to be completed on the next + connection without requiring the entire file to be + retransmitted.[5] If downloading a file from a timesharing + system that becomes sluggish, the transfer can be + interrupted and resumed later with no loss of data. + +The sender sends a ZDATA binary header (with file position) followed by + + +__________ + + 3. See below, under ZFILE header type. + + 4. The crc is initialized to 0xFFFFFFFF. A byte count of 0 implies the + entire file. + + 5. This does not apply to files that have been translated. + + + + +Chapter 8 Rev Oct-14-88 Typeset 10-14-88 18 + + + + + + + +Chapter 8 ZMODEM Protocol 19 + + + +one or more data subpackets. + +The receiver compares the file position in the ZDATA header with the +number of characters successfully received to the file. If they do not +agree, a ZRPOS error response is generated to force the sender to the +right position within the file.[6] + +A data subpacket terminated by ZCRCG and CRC does not elicit a response +unless an error is detected; more data subpacket(s) follow immediately. + + ZCRCQ data subpackets expect a ZACK response with the + receiver's file offset if no error, otherwise a ZRPOS + response with the last good file offset. Another data + subpacket continues immediately. ZCRCQ subpackets are + not used if the receiver does not indicate FDX ability + with the CANFDX bit. + +ZCRCW data subpackets expect a response before the next frame is sent. +If the receiver does not indicate overlapped I/O capability with the +CANOVIO bit, or sets a buffer size, the sender uses the ZCRCW to allow +the receiver to write its buffer before sending more data. + + A zero length data frame may be used as an idle + subpacket to prevent the receiver from timing out in + case data is not immediately available to the sender. + +In the absence of fatal error, the sender eventually encounters end of +file. If the end of file is encountered within a frame, the frame is +closed with a ZCRCE data subpacket which does not elicit a response +except in case of error. + +The sender sends a ZEOF header with the file ending offset equal to +the number of characters in the file. The receiver compares this +number with the number of characters received. If the receiver has +received all of the file, it closes the file. If the file close was +satisfactory, the receiver responds with ZRINIT. If the receiver has +not received all the bytes of the file, the receiver ignores the ZEOF +because a new ZDATA is coming. If the receiver cannot properly close +the file, a ZFERR header is sent. + + + + + + + +__________ + + 6. If the ZMSPARS option is used, the receiver instead seeks to the + position given in the ZDATA header. + + + + +Chapter 8 Rev Oct-14-88 Typeset 10-14-88 19 + + + + + + + +Chapter 8 ZMODEM Protocol 20 + + + + After all files are processed, any further protocol + errors should not prevent the sending program from + returning with a success status. + + +8.3 Session Cleanup + +The sender closes the session with a ZFIN header. The receiver +acknowledges this with its own ZFIN header. + +When the sender receives the acknowledging header, it sends two +characters, "OO" (Over and Out) and exits to the operating system or +application that invoked it. The receiver waits briefly for the "O" +characters, then exits whether they were received or not. + +8.4 Session Abort Sequence + +If the receiver is receiving data in streaming mode, the Attn +sequence is executed to interrupt data transmission before the Cancel +sequence is sent. The Cancel sequence consists of eight CAN +characters and ten backspace characters. ZMODEM only requires five +Cancel characters, the other three are "insurance". + +The trailing backspace characters attempt to erase the effects of the +CAN characters if they are received by a command interpreter. + + static char canistr[] = { + 24,24,24,24,24,24,24,24,8,8,8,8,8,8,8,8,8,8,0 + }; + + + + + + + + + + + + + + + + + + + + + + + + + +Chapter 8 Rev Oct-14-88 Typeset 10-14-88 20 + + + + + + + +Chapter 8 ZMODEM Protocol 21 + + + +9. STREAMING TECHNIQUES / ERROR RECOVERY + +It is a fact of life that no single method of streaming is applicable +to a majority of today's computing and telecommunications +environments. ZMODEM provides several data streaming methods +selected according to the limitations of the sending environment, +receiving environment, and transmission channel(s). + + +9.1 Full Streaming with Sampling + +If the receiver can overlap serial I/O with disk I/O, and if the +sender can sample the reverse channel for the presence of data +without having to wait, full streaming can be used with no Attn +sequence required. The sender begins data transmission with a ZDATA +header and continuous ZCRCG data subpackets. When the receiver +detects an error, it executes the Attn sequence and then sends a +ZRPOS header with the correct position within the file. + +At the end of each transmitted data subpacket, the sender checks for +the presence of an error header from the receiver. To do this, the +sender samples the reverse data stream for the presence of either a +ZPAD or CAN character.[1] Flow control characters (if present) are +acted upon. + +Other characters (indicating line noise) increment a counter which is +reset whenever the sender waits for a header from the receiver. If +the counter overflows, the sender sends the next data subpacket as +ZCRCW, and waits for a response. + +ZPAD indicates some sort of error header from the receiver. A CAN +suggests the user is attempting to "stop the bubble machine" by +keyboarding CAN characters. If one of these characters is seen, an +empty ZCRCE data subpacket is sent. Normally, the receiver will have +sent an ZRPOS or other error header, which will force the sender to +resume transmission at a different address, or take other action. In +the unlikely event the ZPAD or CAN character was spurious, the +receiver will time out and send a ZRPOS header.[2] + +Then the receiver's response header is read and acted upon.[3] + + +__________ + + 1. The call to rdchk() in sz.c performs this function. + + 2. The obvious choice of ZCRCW packet, which would trigger an ZACK from + the receiver, is not used because multiple in transit frames could + result if the channel has a long propagation delay. + + 3. The call to getinsync() in sz.c performs this function. + + + +Chapter 9 Rev Oct-14-88 Typeset 10-14-88 21 + + + + + + + +Chapter 9 ZMODEM Protocol 22 + + + +A ZRPOS header resets the sender's file offset to the correct +position. If possible, the sender should purge its output buffers +and/or networks of all unprocessed output data, to minimize the +amount of unwanted data the receiver must discard before receiving +data starting at the correct file offset. The next transmitted data +frame should be a ZCRCW frame followed by a wait to guarantee +complete flushing of the network's memory. + +If the receiver gets a ZACK header with an address that disagrees +with the sender address, it is ignored, and the sender waits for +another header. A ZFIN, ZABORT, or TIMEOUT terminates the session; a +ZSKIP terminates the processing of this file. + +The reverse channel is then sampled for the presence of another +header from the receiver.[4] if one is detected, the getinsync() +function is again called to read another error header. Otherwise, +transmission resumes at the (possibly reset) file offset with a ZDATA +header followed by data subpackets. + + +9.1.1 Window Management +When sending data through a network, some nodes of the network store +data while it is transferred to the receiver. 7000 bytes and more of +transient storage have been observed. Such a large amount of storage +causes the transmitter to "get ahead" of the reciever. This can be +fatal with MEGAlink and other protocols that depend on timely +notification of errors from the receiver. This condition is not +fatal with ZMODEM, but it does slow error recovery. + +To manage the window size, the sending program uses ZCRCQ data +subpackets to trigger ZACK headers from the receiver. The returning +ZACK headers inform the sender of the receiver's progress. When the +window size (current transmitter file offset - last reported receiver +file offset) exceeds a specified value, the sender waits for a +ZACK[5] packet with a receiver file offset that reduces the window +size. + +Unix sz versions beginning with May 9 1987 control the window size +with the "-w N" option, where N is the maximum window size. Pro-YAM, +ZCOMM and DSZ versions beginning with May 9 1987 control the window +size with "zmodem pwN". This is compatible with previous versions of +these programs.[6] + + +__________ + + 4. If sampling is possible. + + 5. ZRPOS and other error packets are handled normally. + + 6. When used with modems or networks that simultaneously assert flow + + + +Chapter 9 Rev Oct-14-88 Typeset 10-14-88 22 + + + + + + + +Chapter 9 ZMODEM Protocol 23 + + + +9.2 Full Streaming with Reverse Interrupt + +The above method cannot be used if the reverse data stream cannot be +sampled without entering an I/O wait. An alternate method is to +instruct the receiver to interrupt the sending program when an error +is detected. + +The receiver can interrupt the sender with a control character, break +signal, or combination thereof, as specified in the Attn sequence. +After executing the Attn sequence, the receiver sends a hex ZRPOS +header to force the sender to resend the lost data. + +When the sending program responds to this interrupt, it reads a HEX +header (normally ZRPOS) from the receiver and takes the action +described in the previous section. The Unix sz.c program uses a +setjmp/longjmp call to catch the interrupt generated by the Attn +sequence. Catching the interrupt activates the getinsync() function +to read the receiver's error header and take appropriate action. + +When compiled for standard SYSTEM III/V Unix, sz.c uses an Attn +sequence of Ctrl-C followed by a 1 second pause to interrupt the +sender, then give the sender (Unix) time to prepare for the +receiver's error header. + + +9.3 Full Streaming with Sliding Window + +If none of the above methods is applicable, hope is not yet lost. If +the sender can buffer responses from the receiver, the sender can use +ZCRCQ data subpackets to get ACKs from the receiver without +interrupting the transmission of data. After a sufficient number of +ZCRCQ data subpackets have been sent, the sender can read one of the +headers that should have arrived in its receive interrupt buffer. + +A problem with this method is the possibility of wasting an excessive +amount of time responding to the receiver's error header. It may be +possible to program the receiver's Attn sequence to flush the +sender's interrupt buffer before sending the ZRPOS header. + + + + + + + +__________________________________________________________________________ + + control with XON and XOFF characters and pass XON characters that + violate flow control, the receiving program should have a revision + date of May 9 or later. + + + + +Chapter 9 Rev Oct-14-88 Typeset 10-14-88 23 + + + + + + + +Chapter 9 ZMODEM Protocol 24 + + + +9.4 Full Streaming over Error Free Channels + +File transfer protocols predicated on the existence of an error free +end to end communications channel have been proposed from time to +time. Such channels have proven to be more readily available in +theory than in actuality. The frequency of undetected errors +increases when modem scramblers have more bits than the error +detecting CRC. + +A ZMODEM sender assuming an error free channel with end to end flow +control can send the entire file in one frame without any checking of +the reverse stream. If this channel is completely transparent, only +ZDLE need be escaped. The resulting protocol overhead for average +long files is less than one per cent.[7] + +9.5 Segmented Streaming + +If the receiver cannot overlap serial and disk I/O, it uses the +ZRINIT frame to specify a buffer length which the sender will not +overflow. The sending program sends a ZCRCW data subpacket and waits +for a ZACK header before sending the next segment of the file. + +If the sending program supports reverse data stream sampling or +interrupt, error recovery will be faster (on average) than a protocol +(such as YMODEM) that sends large blocks. + +A sufficiently large receiving buffer allows throughput to closely +approach that of full streaming. For example, 16kb segmented +streaming adds about 3 per cent to full streaming ZMODEM file +transfer times when the round trip delay is five seconds. + + +10. ATTENTION SEQUENCE + +The receiving program sends the Attn sequence whenever it detects an +error and needs to interrupt the sending program. + +The default Attn string value is empty (no Attn sequence). The +receiving program resets Attn to the empty default before each +transfer session. + +The sender specifies the Attn sequence in its optional ZSINIT frame. +The Attn string is terminated with a null. + + + +__________ + + 7. One in 256 for escaping ZDLE, about two (four if 32 bit CRC is used) + in 1024 for data subpacket CRC's + + + + +Chapter 10 Rev Oct-14-88 Typeset 10-14-88 24 + + + + + + + +Chapter 10 ZMODEM Protocol 25 + + + +Two meta-characters perform special functions: + + + \335 (octal) Send a break signal + + + \336 (octal) Pause one second + + +11. FRAME TYPES + +The numeric values for the values shown in boldface are given in +zmodem.h. Unused bits and unused bytes in the header (ZP0...ZP3) are +set to 0. + +11.1 ZRQINIT + +Sent by the sending program, to trigger the receiving program to send +its ZRINIT header. This avoids the aggravating startup delay +associated with XMODEM and Kermit transfers. The sending program may +repeat the receive invitation (including ZRQINIT) if a response is +not obtained at first. + +ZF0 contains ZCOMMAND if the program is attempting to send a command, +0 otherwise. + +11.2 ZRINIT + +Sent by the receiving program. ZF0 and ZF1 contain the bitwise or +of the receiver capability flags: +#define CANCRY 8 /* Receiver can decrypt */ +#define CANFDX 01 /* Rx can send and receive true FDX */ +#define CANOVIO 02 /* Rx can receive data during disk I/O */ +#define CANBRK 04 /* Rx can send a break signal */ +#define CANCRY 010 /* Receiver can decrypt */ +#define CANLZW 020 /* Receiver can uncompress */ +#define CANFC32 040 /* Receiver can use 32 bit Frame Check */ +#define ESCCTL 0100 /* Receiver expects ctl chars to be escaped +*/ +#define ESC8 0200 /* Receiver expects 8th bit to be escaped */ + +ZP0 and ZP1 contain the size of the receiver's buffer in bytes, or 0 +if nonstop I/O is allowed. + +11.3 ZSINIT + +The Sender sends flags followed by a binary data subpacket terminated +with ZCRCW. + +/* Bit Masks for ZSINIT flags byte ZF0 */ +#define TESCCTL 0100 /* Transmitter expects ctl chars to be escaped +*/ +#define TESC8 0200 /* Transmitter expects 8th bit to be escaped + + + +Chapter 11 Rev Oct-14-88 Typeset 10-14-88 25 + + + + + + + +Chapter 11 ZMODEM Protocol 26 + + + +*/ + +The data subpacket contains the null terminated Attn sequence, +maximum length 32 bytes including the terminating null. + +11.4 ZACK + +Acknowledgment to a ZSINIT frame, ZCHALLENGE header, ZCRCQ or ZCRCW +data subpacket. ZP0 to ZP3 contain file offset. The response to +ZCHALLENGE contains the same 32 bit number received in the ZCHALLENGE +header. + +11.5 ZFILE + +This frame denotes the beginning of a file transmission attempt. +ZF0, ZF1, and ZF2 may contain options. A value of 0 in each of these +bytes implies no special treatment. Options specified to the +receiver override options specified to the sender with the exception +of ZCBIN. A ZCBIN from the sender overrides any other Conversion +Option given to the receiver except ZCRESUM. A ZCBIN from the +receiver overrides any other Conversion Option sent by the sender. + + +11.5.1 ZF0: Conversion Option +If the receiver does not recognize the Conversion Option, an +application dependent default conversion may apply. + +ZCBIN "Binary" transfer - inhibit conversion unconditionally + +ZCNL Convert received end of line to local end of line + convention. The supported end of line conventions are + CR/LF (most ASCII based operating systems except Unix + and Macintosh), and NL (Unix). Either of these two end + of line conventions meet the permissible ASCII + definitions for Carriage Return and Line Feed/New Line. + Neither the ASCII code nor ZMODEM ZCNL encompass lines + separated only by carriage returns. Other processing + appropriate to ASCII text files and the local operating + system may also be applied by the receiver.[1] + +ZCRECOV Recover/Resume interrupted file transfer. ZCREVOV is + also useful for updating a remote copy of a file that + grows without resending of old data. If the destination + file exists and is no longer than the source, append to + the destination file and start transfer at the offset + + +__________ + + 1. Filtering RUBOUT, NULL, Ctrl-Z, etc. + + + + +Chapter 11 Rev Oct-14-88 Typeset 10-14-88 26 + + + + + + + +Chapter 11 ZMODEM Protocol 27 + + + + corresponding to the receiver's end of file. This + option does not apply if the source file is shorter. + Files that have been converted (e.g., ZCNL) or subject + to a single ended Transport Option cannot have their + transfers recovered. + +11.5.2 ZF1: Management Option +If the receiver does not recognize the Management Option, the +file should be transferred normally. + +The ZMSKNOLOC bit instructs the receiver to bypass the +current file if the receiver does not have a file with the +same name. + +Five bits (defined by ZMMASK) define the following set of +mutually exclusive management options. + +ZMNEWL Transfer file if destination file absent. Otherwise, + transfer file overwriting destination if the source file + is newer or longer. + +ZMCRC Compare the source and destination files. Transfer if + file lengths or file polynomials differ. + +ZMAPND Append source file contents to the end of the existing + destination file (if any). + +ZMCLOB Replace existing destination file (if any). + +ZMDIFF Transfer file if destination file absent. Otherwise, + transfer file overwriting destination if files have + different lengths or dates. + +ZMPROT Protect destination file by transferring file only if + the destination file is absent. + +ZMNEW Transfer file if destination file absent. Otherwise, + transfer file overwriting destination if the source file + is newer. + +11.5.3 ZF2: Transport Option +If the receiver does not implement the particular transport +option, the file is copied without conversion for later +processing. + +ZTLZW Lempel-Ziv compression. Transmitted data will be + identical to that produced by compress 4.0 operating on + a computer with VAX byte ordering, using 12 bit + encoding. + + + + + +Chapter 11 Rev Oct-14-88 Typeset 10-14-88 27 + + + + + + + +Chapter 11 ZMODEM Protocol 28 + + + +ZTCRYPT Encryption. An initial null terminated string + identifies the key. Details to be determined. + +ZTRLE Run Length encoding, Details to be determined. + +A ZCRCW data subpacket follows with file name, file length, +modification date, and other information described in a later +chapter. + +11.5.4 ZF3: Extended Options +The Extended Options are bit encoded. + +ZTSPARS Special processing for sparse files, or sender managed + selective retransmission. Each file segment is transmitted as + a separate frame, where the frames are not necessarily + contiguous. The sender should end each segment with a ZCRCW + data subpacket and process the expected ZACK to insure no data + is lost. ZTSPARS cannot be used with ZCNL. + +11.6 ZSKIP + +Sent by the receiver in response to ZFILE, makes the sender skip to +the next file. + +11.7 ZNAK + +Indicates last header was garbled. (See also ZRPOS). + +11.8 ZABORT + +Sent by receiver to terminate batch file transfers when requested by +the user. Sender responds with a ZFIN sequence.[2] + +11.9 ZFIN + +Sent by sending program to terminate a ZMODEM session. Receiver +responds with its own ZFIN. + +11.10 ZRPOS + +Sent by receiver to force file transfer to resume at file offset +given in ZP0...ZP3. + + + + + +__________ + + 2. Or ZCOMPL in case of server mode. + + + + +Chapter 11 Rev Oct-14-88 Typeset 10-14-88 28 + + + + + + + +Chapter 11 ZMODEM Protocol 29 + + + +11.11 ZDATA + +ZP0...ZP3 contain file offset. One or more data subpackets follow. + +11.12 ZEOF + +Sender reports End of File. ZP0...ZP3 contain the ending file +offset. + +11.13 ZFERR + +Error in reading or writing file, protocol equivalent to ZABORT. + +11.14 ZCRC + +Request (receiver) and response (sender) for file polynomial. +ZP0...ZP3 contain file polynomial. + +11.15 ZCHALLENGE + +Request sender to echo a random number in ZP0...ZP3 in a ZACK frame. +Sent by the receiving program to the sending program to verify that +it is connected to an operating program, and was not activated by +spurious data or a Trojan Horse message. + +11.16 ZCOMPL + +Request now completed. + +11.17 ZCAN + +This is a pseudo frame type returned by gethdr() in response to a +Session Abort sequence. + +11.18 ZFREECNT + +Sending program requests a ZACK frame with ZP0...ZP3 containing the +number of free bytes on the current file system. A value of 0 +represents an indefinite amount of free space. + +11.19 ZCOMMAND + +ZCOMMAND is sent in a binary frame. ZF0 contains 0 or ZCACK1 (see +below). + +A ZCRCW data subpacket follows, with the ASCII text command string +terminated with a NULL character. If the command is intended to be +executed by the operating system hosting the receiving program +(e.g., "shell escape"), it must have "!" as the first character. +Otherwise the command is meant to be executed by the application +program which receives the command. + + + +Chapter 11 Rev Oct-14-88 Typeset 10-14-88 29 + + + + + + + +Chapter 11 ZMODEM Protocol 30 + + + +If the receiver detects an illegal or badly formed command, the +receiver immediately responds with a ZCOMPL header with an error +code in ZP0...ZP3. + +If ZF0 contained ZCACK1, the receiver immediately responds with a +ZCOMPL header with 0 status. + +Otherwise, the receiver responds with a ZCOMPL header when the +operation is completed. The exit status of the completed command is +stored in ZP0...ZP3. A 0 exit status implies nominal completion of +the command. + +If the command causes a file to be transmitted, the command sender +will see a ZRQINIT frame from the other computer attempting to send +data. + +The sender examines ZF0 of the received ZRQINIT header to verify it +is not an echo of its own ZRQINIT header. It is illegal for the +sending program to command the receiving program to send a command. + +If the receiver program does not implement command downloading, it +may display the command to the standard error output, then return a +ZCOMPL header. + + + +12. SESSION TRANSACTION EXAMPLES + +12.1 A simple file transfer + +A simple transaction, one file, no errors, no CHALLENGE, overlapped +I/O: + +Sender Receiver + +"rz\r" +ZRQINIT(0) + ZRINIT +ZFILE + ZRPOS +ZDATA data ... +ZEOF + ZRINIT +ZFIN + ZFIN +OO + + + + + + + + +Chapter 12 Rev Oct-14-88 Typeset 10-14-88 30 + + + + + + + +Chapter 12 ZMODEM Protocol 31 + + + +12.2 Challenge and Command Download + + +Sender Receiver + +"rz\r" +ZRQINIT(ZCOMMAND) + ZCHALLENGE(random-number) +ZACK(same-number) + ZRINIT +ZCOMMAND, ZDATA + (Performs Command) + ZCOMPL +ZFIN + ZFIN +OO + + +13. ZFILE FRAME FILE INFORMATION + +ZMODEM sends the same file information with the ZFILE frame data +that YMODEM Batch sends in its block 0. + +N.B.: The pathname (file name) field is mandatory. + +Pathname The pathname (conventionally, the file name) is sent as a + null terminated ASCII string. This is the filename format used + by the handle oriented MSDOS(TM) functions and C library fopen + functions. An assembly language example follows: + DB 'foo.bar',0 + No spaces are included in the pathname. Normally only the file + name stem (no directory prefix) is transmitted unless the + sender has selected YAM's f option to send the full absolute or + relative pathname. The source drive designator (A:, B:, etc.) + usually is not sent. + + Filename Considerations + + + File names should be translated to lower case unless the + sending system supports upper/lower case file names. This + is a convenience for users of systems (such as Unix) which + store filenames in upper and lower case. + + + The receiver should accommodate file names in lower and + upper case. + + + When transmitting files between different operating + systems, file names must be acceptable to both the sender + and receiving operating systems. If not, transformations + should be applied to make the file names acceptable. If + the transformations are unsuccessful, a new file name may + + + +Chapter 13 Rev Oct-14-88 Typeset 10-14-88 31 + + + + + + + +Chapter 13 ZMODEM Protocol 32 + + + + be invented be the receiving program. + + If directories are included, they are delimited by /; i.e., + "subdir/foo" is acceptable, "subdir\foo" is not. + +Length The file length and each of the succeeding fields are + optional.[1] The length field is stored as a decimal string + counting the number of data bytes in the file. + + The ZMODEM receiver uses the file length as an estimate only. + It may be used to display an estimate of the transmission time, + and may be compared with the amount of free disk space. The + actual length of the received file is determined by the data + transfer. A file may grow after transmission commences, and + all the data will be sent. + +Modification Date A single space separates the modification date + from the file length. + + The mod date is optional, and the filename and length may be + sent without requiring the mod date to be sent. + + The mod date is sent as an octal number giving the time the + contents of the file were last changed measured in seconds from + Jan 1 1970 Universal Coordinated Time (GMT). A date of 0 + implies the modification date is unknown and should be left as + the date the file is received. + + This standard format was chosen to eliminate ambiguities + arising from transfers between different time zones. + + +File Mode A single space separates the file mode from the + modification date. The file mode is stored as an octal string. + Unless the file originated from a Unix system, the file mode is + set to 0. rz(1) checks the file mode for the 0x8000 bit which + indicates a Unix type regular file. Files with the 0x8000 bit + set are assumed to have been sent from another Unix (or + similar) system which uses the same file conventions. Such + files are not translated in any way. + + +Serial Number A single space separates the serial number from the + file mode. The serial number of the transmitting program is + stored as an octal string. Programs which do not have a serial + + +__________ + + 1. Fields may not be skipped. + + + + +Chapter 13 Rev Oct-14-88 Typeset 10-14-88 32 + + + + + + + +Chapter 13 ZMODEM Protocol 33 + + + + number should omit this field, or set it to 0. The receiver's + use of this field is optional. + + +Number of Files Remaining Iff the number of files remaining is sent, + a single space separates this field from the previous field. + This field is coded as a decimal number, and includes the + current file. This field is an estimate, and incorrect values + must not be allowed to cause loss of data. The receiver's use + of this field is optional. + + +Number of Bytes Remaining Iff the number of bytes remaining is sent, + a single space separates this field from the previous field. + This field is coded as a decimal number, and includes the + current file. This field is an estimate, and incorrect values + must not be allowed to cause loss of data. The receiver's use + of this field is optional. + + +File Type Iff the file type is sent, a single space separates this + field from the previous field. This field is coded as a + decimal number. Currently defined values are: + + 0 Sequential file - no special type + + 1 Other types to be defined. + The receiver's use of this field is optional. + + +The file information is terminated by a null. If only the pathname +is sent, the pathname is terminated with two nulls. The length of +the file information subpacket, including the trailing null, must +not exceed 1024 bytes; a typical length is less than 64 bytes. + + + + +14. PERFORMANCE RESULTS + +14.1 Compatibility + +Extensive testing has demonstrated ZMODEM to be compatible with +satellite links, packet switched networks, microcomputers, +minicomputers, regular and error correcting buffered modems at 75 to +19200 bps. ZMODEM's economy of reverse channel bandwidth allows +modems that dynamically partition bandwidth between the two +directions to operate at optimal speeds. + + + + + + +Chapter 14 Rev Oct-14-88 Typeset 10-14-88 33 + + + + + + + +Chapter 14 ZMODEM Protocol 34 + + + +14.2 Throughput + +Between two single task PC-XT computers sending a program image on +an in house Telenet link, SuperKermit provided 72 ch/sec throughput +at 1200 baud. YMODEM-k yielded 85 chars/sec, and ZMODEM provided +113 chars/sec. XMODEM was not measured, but would have been much +slower based on observed network propagation delays. + +Recent tests downloading large binary files to an IBM PC (4.7 mHz +V20) running YAMK 16.30 with table driven 32 bit CRC calculation +yielded a throughput of 1870 cps on a 19200 bps direct connection. + +Tests with TELEBIT TrailBlazer modems have shown transfer rates +approaching 1400 characters per second for long files. When files +are compressed, effective transfer rates of 2000 characters per +second are possible. + + +14.3 Error Recovery + +Some tests of ZMODEM protocol error recovery performance have been +made. A PC-AT with SCO SYS V Xenix or DOS 3.1 was connected to a PC +with DOS 2.1 either directly at 9600 bps or with unbuffered dial-up +1200 bps modems. The ZMODEM software was configured to use 1024 +byte data subpacket lengths above 2400 bps, 256 otherwise. + +Because no time delays are necessary in normal file transfers, per +file negotiations are much faster than with YMODEM, the only +observed delay being the time required by the program(s) to update +logging files. + +During a file transfer, a short line hit seen by the receiver +usually induces a CRC error. The interrupt sequence is usually seen +by the sender before the next data subpacket is completely sent, and +the resultant loss of data throughput averages about half a data +subpacket per line hit. At 1200 bps this is would be about .75 +second lost per hit. At 10-5 error rate, this would degrade +throughput by about 9 per cent. + +The throughput degradation increases with increasing channel delay, +as more data subpackets in transit through the channel are discarded +when an error is detected. + +A longer noise burst that affects both the receiver and the sender's +reception of the interrupt sequence usually causes the sender to +remain silent until the receiver times out in 10 seconds. If the +round trip channel delay exceeds the receiver's 10 second timeout, +recovery from this type of error may become difficult. + +Noise affecting only the sender is usually ignored, with one common +exception. Spurious XOFF characters generated by noise stop the + + + +Chapter 14 Rev Oct-14-88 Typeset 10-14-88 34 + + + + + + + +Chapter 14 ZMODEM Protocol 35 + + + +sender until the receiver times out and sends an interrupt sequence +which concludes with an XON. + +In summation, ZMODEM performance in the presence of errors resembles +that of X.PC and SuperKermit. Short bursts cause minimal data +retransmission. Long bursts (such as pulse dialing noises) often +require a timeout error to restore the flow of data. + + +15. PACKET SWITCHED NETWORK CONSIDERATIONS + +Flow control is necessary for printing messages and directories, and +for streaming file transfer protocols. A non transparent flow +control is incompatible with XMODEM and YMODEM transfers. XMODEM +and YMODEM protocols require complete transparency of all 256 8 bit +codes to operate properly. + +The "best" flow control (when X.25 or hardware CTS is unavailable) +would not "eat" any characters at all. When the PAD's buffer almost +fills up, an XOFF should be emitted. When the buffer is no longer +nearly full, send an XON. Otherwise, the network should neither +generate nor eat XON or XOFF control characters. + +On Telenet, this can be met by setting CCIT X3 5:1 and 12:0 at both +ends of the network. For best throughput, parameter 64 (advance +ACK) should be set to something like 4. Packets should be forwarded +when the packet is a full 128 bytes, or after a moderate delay +(3:0,4:10,6:0). + +With PC-Pursuit, it is sufficient to set parameter 5 to 1 at both +ends after one is connected to the remote modem. + + @ + set 5:1 + rst? 5:1 + cont + +Unfortunately, many PADs do not accept the "rst?" command. + +For YMODEM, PAD buffering should guarantee that a minimum of 1040 +characters can be sent in a burst without loss of data or generation +of flow control characters. Failure to provide this buffering will +generate excessive retries with YMODEM. + + + + + + + + + + + +Chapter 15 Rev Oct-14-88 Typeset 10-14-88 35 + + + + + + + +Chapter 15 ZMODEM Protocol 36 + + + + TABLE 1. Network and Flow Control Compatibility + +______________________________________________________________________________ +| Connectivity | Interactive| XMODEM| WXMODEM| SUPERKERMIT| ZMODEM | +______________________________________________________________________________ +|___________________|_____________|________|_________|_____________|_________| +|Direct Connect | YES | YES | YES | YES | YES | +|___________________|_____________|________|_________|_____________|_________| +|Network, no FC | no | YES | (4) | (6) | YES (1)| +|___________________|_____________|________|_________|_____________|_________| +|Net, transparent FC| YES | YES | YES | YES | YES | +|___________________|_____________|________|_________|_____________|_________| +|Net, non-trans. FC | YES | no | no (5) | YES | YES | +|___________________|_____________|________|_________|_____________|_________| +|Network, 7 bit | YES | no | no | YES (2) | YES (3)| +|___________________|_____________|________|_________|_____________|_________| + +(1) ZMODEM can optimize window size or burst length for fastest +transfers. +(2) Parity bits must be encoded, slowing binary transfers. +(3) Natural protocol extension possible for encoding data to 7 bits. +(4) Small WXMODEM window size may may allow operation. +(5) Some flow control codes are not escaped in WXMODEM. +(6) Kermit window size must be reduced to avoid buffer overrun. + + +16. PERFORMANCE COMPARISON TABLES + + +"Round Trip Delay Time" includes the time for the last byte in a +packet to propagate through the operating systems and network to the +receiver, plus the time for the receiver's response to that packet +to propagate back to the sender. + +The figures shown below are calculated for round trip delay times of +40 milliseconds and 5 seconds. Shift registers in the two computers +and a pair of 212 modems generate a round trip delay time on the +order of 40 milliseconds. Operation with busy timesharing computers +and networks can easily generate round trip delays of five seconds. +Because the round trip delays cause visible interruptions of data +transfer when using XMODEM protocol, the subjective effect of these +delays is greatly exaggerated, especially when the user is paying +for connect time. + +A 102400 byte binary file with randomly distributed codes is sent at +1200 bps 8 data bits, 1 stop bit. The calculations assume no +transmission errors. For each of the protocols, only the per file +functions are considered. Processor and I/O overhead are not +included. YM-k refers to YMODEM with 1024 byte data packets. YM-g +refers to the YMODEM "g" option. ZMODEM uses 256 byte data +subpackets for this example. SuperKermit uses maximum standard + + + +Chapter 16 Rev Oct-14-88 Typeset 10-14-88 36 + + + + + + + +Chapter 16 ZMODEM Protocol 37 + + + +packet size, 8 bit transparent transmission, no run length +compression. The 4 block WXMODEM window is too small to span the 5 +second delay in this example; the resulting thoughput degradation is +ignored. + +For comparison, a straight "dump" of the file contents with no file +management or error checking takes 853 seconds. + + TABLE 2. Protocol Overhead Information + (102400 byte binary file, 5 Second Round Trip) + +____________________________________________________________________________ +| Protocol | XMODEM| YM-k | YM-g| ZMODEM| SKermit| WXMODEM| +____________________________________________________________________________ +|_____________________|________|_______|______|________|_________|_________| +|Protocol Round Trips | 804 | 104 | 5 | 5 | 5 | 4 | +|_____________________|________|_______|______|________|_________|_________| +|Trip Time at 40ms | 32s | 4s | 0 | 0 | 0 | 0 | +|_____________________|________|_______|______|________|_________|_________| +|Trip Time at 5s | 4020s | 520s | 25s | 25s | 25 | 20 | +____________________________________________________________________________ +|_____________________|________|_______|______|________|_________|_________| +|Overhead Characters | 4803 | 603 | 503 | 3600 | 38280 | 8000 | +____________________________________________________________________________ +|_____________________|________|_______|______|________|_________|_________| +|Line Turnarounds | 1602 | 204 | 5 | 5 | 2560 | 1602 | +____________________________________________________________________________ +|_____________________|________|_______|______|________|_________|_________| +|Transfer Time at 0s | 893s | 858s | 857s| 883s | 1172s | 916s | +|_____________________|________|_______|______|________|_________|_________| +|Transfer Time at 40ms| 925s | 862s | 857s| 883s | 1172s | 916s | +|_____________________|________|_______|______|________|_________|_________| +|Transfer Time at 5s | 5766s | 1378s| 882s| 918s | 1197s | 936s | +|_____________________|________|_______|______|________|_________|_________| + + + Figure 5. Transmission Time Comparison + (102400 byte binary file, 5 Second Round Trip) + +************************************************** XMODEM +************ YMODEM-K +********** SuperKermit (Sliding Windows) +******* ZMODEM 16kb Segmented Streaming +******* ZMODEM Full Streaming +******* YMODEM-G + + + + + + + + + +Chapter 16 Rev Oct-14-88 Typeset 10-14-88 37 + + + + + + + +Chapter 16 ZMODEM Protocol 38 + + + + TABLE 3. Local Timesharing Computer Download Performance + +__________________________________________________________________________ +| Command | Protocol| Time/HD| Time/FD| Throughput| Efficiency| +__________________________________________________________________________ +|_______________|__________|_________|_________|____________|____________| +|kermit -x | Kermit | 1:49 | 2:03 | 327 | 34% | +|_______________|__________|_________|_________|____________|____________| +|sz -Xa phones.t| XMODEM | 1:20 | 1:44 | 343 | 36% | +|_______________|__________|_________|_________|____________|____________| +|sz -a phones.t | ZMODEM | :39 | :48 | 915 | 95% | +|_______________|__________|_________|_________|____________|____________| + + +Times were measured downloading a 35721 character text file at 9600 +bps, from Santa Cruz SysV 2.1.2 Xenix on a 9 mHz IBM PC-AT to DOS +2.1 on an IBM PC. Xenix was in multiuser mode but otherwise idle. +Transfer times to PC hard disk and floppy disk destinations are +shown. + +C-Kermit 4.2(030) used server mode and file compression, sending to +Pro-YAM 15.52 using 0 delay and a "get phones.t" command. + +Crosstalk XVI 3.6 used XMODEM 8 bit checksum (CRC not available) and +an "ESC rx phones.t" command. The Crosstalk time does not include +the time needed to enter the extra commands not needed by Kermit and +ZMODEM. + +Professional-YAM used ZMODEM AutoDownload. ZMODEM times included a +security challenge to the sending program. + + + + + + + + + + + + + + + + + + + + + + + + +Chapter 16 Rev Oct-14-88 Typeset 10-14-88 38 + + + + + + + +Chapter 16 ZMODEM Protocol 39 + + + + TABLE 4. File Transfer Speeds + +______________________________________________________________________________ +| Prot file bytes bps ch/sec Notes | +______________________________________________________________________________ +|X jancol.c 18237 2400 53 Tymnet PTL 5/3/87 | +|X source.xxx 6143 2400 56 Source PTL 5/29/87 | +|X jancol.c 18237 2400 64 Tymnet PTL | +|XN tsrmaker.arc 25088 1200 94 GEnie PTL | +|B/ovth emaibm.arc 51200 1200 101 CIS PTL MNP | +|UUCP 74 files, each >7000 1200 102 (Average) | +|ZM jancol.c 18237 1200 112 DataPac (604-687-7144)| +|X/ovth emaibm.arc 51200 1200 114 CIS PTL MNP | +|ZM emaibm.arc 51200 1200 114 CIS PTL MNP | +|ZM frombyte87.txt 62506 1200 117 BIX | +|SK source.xxx 6143 2400 170 Source PTL 5/29/87 | +|ZM jancol.c 18237 2400 221 Tymnet PTL upl/dl | +|B/ovth destro.gif 33613 2400 223 CIS/PTL 9-12-87 | +|ZM jancol.c 18237 2400 224 Tymnet PTL | +|ZM tp40kerm.arc 112640 2400 224 BIX 6/88 | +|ZM readme.lis 9466 2400 231 BIX 6/88 | +|ZM jancol.c 18237 2400 226/218 TeleGodzilla upl | +|ZM jancol.c 18237 2400 226 Tymnet PTL 5/3/87 | +|ZM zmodem.ov 35855 2400 227 CIS PTL node | +|C jancol.c 18237 2400 229 Tymnet PTL 5/3/87 | +|ZM jancol.c 18237 2400 229/221 TeleGodzilla | +|ZM zmodem.ov 35855 2400 229 CIS PTL node upl | +|ZM jancol.c 18237 2400 232 CIS PTL node | +|QB gifeof.arc 32187 2400 232 CIS PTL node | +|ZM pcpbbs.txt 38423 2400 534 MNP Level 5 | +|ZM mbox 473104 9600 948/942 TeleGodzilla upl | +|ZM zmodem.arc 318826 14k 1357/1345 TeleGodzilla | +|ZM mbox 473104 14k 1367/1356 TeleGodzilla upl | +|ZM c2.doc 218823 38k 3473 Xenix 386 TK upl | +|ZM mbox -a 511893 38k 3860 386 Xenix 2.2 Beta | +|ZM c.doc 218823 57k 5611 AT Clone & 386 | +|____________________________________________________________________________| + +Times are for downloads unless noted. Where two speeds are noted, +the faster speed is reported by the receiver because its transfer +time calculation excludes the security check and transaction log +file processing. The TeleGodzilla computer is a 4.77 mHz IBM PC +with a 10 MB hard disk. The 386 computer uses an Intel motherboard +at 18 mHz 1ws. The AT Clone (QIC) runs at 8 mHz 0ws. +Abbreviations: + B Compuserve B Protocol + QB Compuserve Quick-B/B+ Protocol + B/ovth CIS B with Omen Technology OverThruster(TM) + C Capture DC2/DC4 (no protocol) + K Kermit + MNP Microcom MNP error correcting SX/1200 modem + + + +Chapter 16 Rev Oct-14-88 Typeset 10-14-88 39 + + + + + + + +Chapter 16 ZMODEM Protocol 40 + + + + PTL Portland Oregon network node + SK Sliding Window Kermit (SuperKermit) w=15 + X XMODEM + XN XMODEM protocol implemented in network modes + X/ovth XMODEM, Omen Technology OverThruster(TM) + ZM ZMODEM + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Chapter 16 Rev Oct-14-88 Typeset 10-14-88 40 + + + + + + + +Chapter 16 ZMODEM Protocol 41 + + + + TABLE 5. Protocol Checklist +Etc: Relay, BLAST, DART + +_________________________________________________________________________ +|Item XMODEM YMDM-k YMDM-g ZMODEM SK Etc.| +_________________________________________________________________________ +|IN SERVICE | 1977 | 1982 | 1985 | 1986 | 1985 | ? | +|VENDORS | ?? | ?? | >20 | >20 | ?? | 1 | +_________________________________________________________________________ +|HOST AVAILABILITY | | | | | | | +|Compuserve | YES | - | - | YES | - | - | +|BIX | YES | - | - | YES | YES | - | +|Portal | | | YES | - | - | SOON| +|The Source | YES | - | - | - | YES | - | +|_______________________|________|________|_______|_______|_______|_____| +|USER FEATURES | | | | | | | +|User Friendly | - | - | - | YES | (10) | - | +|Commands/batch | 2*N | 2 | 2 | 1 | 1(1) | | +|Commands/file | 2 | 0 | 0 | 0 | 0 | | +|Command Download | - | - | - | YES | YES(6)| - | +|Menu Compatible | - | - | - | YES | - | - | +|Crash Recovery | - | - | - | YES | - | ?? | +|File Management | - | - | - | YES | - | some| +|Security Check | - | - | - | YES | - | - | +|_______________________|________|________|_______|_______|_______|_____| +|COMPATIBILITY | | | | | | | +|Dynamic Files | YES | - | - | YES | YES | ? | +|Packet SW NETS | - | - | - | YES | YES | ? | +|7 bit PS NETS | - | - | - | (3) | YES | ? | +|Old Mainframes | - | - | - | (3) | YES | ? | +|_______________________|________|________|_______|_______|_______|_____| +|ATTRIBUTES | | | | | | | +|Reliability(5) | fair | fair(5)| none | BEST | good | ? | +|Streaming | - | - | YES | YES | YES | | +|Overhead(2) | 7% | 1% | 1% | 4%(8) | 30% | | +|Faithful Xfers | - | YES(7) | YES(7)| YES | YES | ? | +|Preserve Date | - | YES | YES | YES | - | ? | +|_______________________|________|________|_______|_______|_______|_____| +|COMPLEXITY | | | | | | | +|No-Wait Sample | - | - | - | opt | REQD | REQD| +|Ring Buffers | - | - | - | opt | REQD | REQD| +|Complexity | LOW(5)| LOW(5) | LOW | MED | HIGH | ? | +|_______________________|________|________|_______|_______|_______|_____| +|EXTENSIONS | | | | | | | +|Server Operation | - | - | - | YES(4)| YES | ? | +|Multiple Threads | - | - | - | future| - | - | +_________________________________________________________________________ + +NOTES: +(1) Server mode or Omen Technology Kermit AutoDownload +(2) Character count, binary file, transparent channel + + + +Chapter 16 Rev Oct-14-88 Typeset 10-14-88 41 + + + + + + + +Chapter 16 ZMODEM Protocol 42 + + + +(3) Future enhancement provided for +(4) AutoDownload operation +(5) Omen Technology's Cybernetic Data Recovery(TM) improves XMODEM +and YMODEM reliability with complex proprietary logic. +(6) Server commands only +(7) Only with True YMODEM(TM) +(8) More then 3% from protected network control characters +(9) With Segmented Streaming +(10) With Pro-YAM extensions + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Chapter 16 Rev Oct-14-88 Typeset 10-14-88 42 + + + + + + + +Chapter 16 ZMODEM Protocol 43 + + + +17. FUTURE EXTENSIONS + +Future extensions include: + + + Compatibility with 7 bit networks + + + Server/Link Level operation: An END-TO-END error corrected + program to program session is required for financial and other + sensitive applications. + + + Multiple independent threads + + + Bidirectional transfers (STEREO ZMODEM) + + + Encryption + + + Compression + + + File Comparison + + + Selective transfer within a file (e.g., modified segments of a + database file) + + + Selective Retransmission for error correction + + +18. REVISIONS + +10-14-88 Pascal source code now available in Phil Burn's PibTerm +v4.2. 6-24-88 An exception to the previously unconditional ZCBIN +override: a ZCRESUM specified by the receiver need not be overridden +by the sender's ZCBIN. + +11-18-87 Editorial improvements + +10-27-87 Optional fields added for number of files remaining to be +sent and total number of bytes remaining to be sent. + +07-31-1987 The receiver should ignore a ZEOF with an offset that +does not match the current file length. The previous action of +responding with ZRPOS caused transfers to fail if a CRC error +occurred immediately before end of file, because two retransmission +requests were being sent for each error. This has been observed +under exceptional conditions, such as data transmission at speeds +greater than the receiving computer's interrupt response capabilitiy +or gross misapplication of flow control. + +Discussion of the Tx backchannel garbage count and ZCRCW after error +ZRPOS was added. Many revisions for clarity. + +07-09-87 Corrected XMODEM's development date, incorrectly stated as + + + +Chapter 18 Rev Oct-14-88 Typeset 10-14-88 43 + + + + + + + +Chapter 18 ZMODEM Protocol 44 + + + +1979 instead of the actual August 1977. More performance data was +added. + +05-30-87 Added ZMNEW and ZMSKNOLOC + +05-14-87 Window management, ZACK zshhdr XON removed, control +character escaping, ZMSPARS changed to ZXPARS, editorial changes. + +04-13-87 The ZMODEM file transfer protocol's public domain status +is emphasized. + +04-04-87: minor editorial changes, added conditionals for overview +version. + +03-15-87: 32 bit CRC added. + +12-19-86: 0 Length ZCRCW data subpacket sent in response to ZPAD or +ZDELE detected on reverse channel has been changed to ZCRCE. The +reverse channel is now checked for activity before sending each +ZDATA header. + +11-08-86: Minor changes for clarity. + +10-2-86: ZCNL definition expanded. + +9-11-86: ZMPROT file management option added. + +8-20-86: More performance data included. + +8-4-86: ASCII DLE (Ctrl-P, 020) now escaped; compatible with +previous versions. More document revisions for clarity. + +7-15-86: This document was extensively edited to improve clarity and +correct small errors. The definition of the ZMNEW management option +was modified, and the ZMDIFF management option was added. The +cancel sequence was changed from two to five CAN characters after +spurious two character cancel sequences were detected. + + +19. MORE INFORMATION + +Please contact Omen Technology for troff source files and typeset +copies of this document. + + +19.1 TeleGodzilla Bulletin Board + +More information may be obtained by calling the TeleGodzilla +bulletin board at 503-621-3746. TeleGodzilla supports 19200 +(Telebit PEP), 2400 and 1200 bps callers with automatic speed +recognition. + + + +Chapter 19 Rev Oct-14-88 Typeset 10-14-88 44 + + + + + + + +Chapter 19 ZMODEM Protocol 45 + + + +Relevant files include YZMODEM.ZOO, YAMDEMO.ZOO, YAMHELP.ZOO, +ZCOMMEXE.ARC, ZCOMMDOC.ARC, ZCOMMHLP.ARC. + +Useful commands for TeleGodzilla include "menu", "dir", "sx file +(XMODEM)", "kermit sb file ...", and "sz file ...". + +19.2 Unix UUCP Access + +UUCP sites can obtain the current version of this file with + uucp omen!/u/caf/public/zmodem.doc /tmp +A continually updated list of available files is stored in +/usr/spool/uucppublic/FILES. + uucp omen!~uucp/FILES /usr/spool/uucppublic + +The following L.sys line allows UUCP to call site "omen" via Omen's +bulletin board system "TeleGodzilla". TeleGodzilla is an instance +of Omen Technology's Professional-YAM in host operation, acting as a +bulletin board and front ending a Xenix system. + +In response to TeleGodzilla's "Name Please:" (e:--e:), uucico gives +the Pro-YAM "link" command as a user name. Telegodzilla then asks +for a link password (d:). The password (Giznoid) controls access to +the Xenix system connected to the IBM PC's other serial port. +Communications between Pro-YAM and Xenix use 9600 bps; YAM converts +this to the caller's speed. + +Finally, the calling uucico sees the Xenix "Login:" message (n:-- +n:), and logs in as "uucp". No password is used for the uucp +account. + +omen Any ACU 2400 1-503-621-3746 e:--e: link d: Giznoid n:--n: uucp + + + +20. ZMODEM PROGRAMS + +A copy of this document, a demonstration version of +Professional-YAM, a flash-up tree structured help file and +processor, are available in YZMODEM.ZOO on TeleGodzilla and other +bulletin boards. This file must be unpacked with LOOZ.EXE, also +available on TeleGodzilla. YZMODEM.ZOO may be distributed provided +none of the files are deleted or modified without the written +consent of Omen Technology. + +TeleGodzilla and other bulletin boards also feature ZCOMM, a +shareware communications program. ZCOMM includes Omen Technology's +TurboLearn(TM) Script Writer, ZMODEM, Omen's highly acclaimed XMODEM +and YMODEM protocol support, Sliding Windows Kermit, several +traditional protocols, a powerful script language, and the most +accurate VT100/102 emulation available in a usr supported program. +The ZCOMM files include: + + + +Chapter 20 Rev Oct-14-88 Typeset 10-14-88 45 + + + + + + + +Chapter 20 ZMODEM Protocol 46 + + + + + ZCOMMEXE.ARC Executable files and beginner's telephone directory + + + ZCOMMDOC.ARC "Universal Line Printer Edition" Manual + + + ZCOMMHLP.ARC Tree structured Flash-UP help processor and + database + +C source code and manual pages for the Unix/Xenix rz and sz programs +are available on TeleGodzilla in RZSZ.ZOO. This ZOO archive may be +unpacked with LOOZ.EXE, also available on TeleGodzilla. Most Unix +like systems are supported, including V7, Sys III, 4.x BSD, SYS V, +Idris, Coherent, and Regulus. + +RZSZ.ZOO includes a ZCOMM/Pro-YAM/PowerCom script ZUPL.T to upload +the small (178 lines) YMODEM bootstrap program MINIRB.C without a +file transfer protocol. MINIRB uses the Unix stty(1) program to set +the required raw tty modes, and compiles without special flags on +virtually all Unix and Xenix systems. ZUPL.T directs the Unix +system to compile MINIRB, then uses it as a bootstrap to upload the +rz/sz source and manual files. + +Pascal source code for ZMODEM support is available in PibTerm v4.2 +written by Phil Burns. + +The PC-DOS EXEC-PC, QuickBBS, Opus and Nochange bulletin boards +support ZMODEM. Integrated ZMODEM support for the Collie bulletin +board program is planned. Most of the PC-DOS bulletin board +programs that lack integrated ZMODEM support ZMODEM with external +modules (DSZ, etc.). + +The BinkleyTerm, Dutchie and D'Bridge email systems support ZMODEM +as their primary protocol. + +The IN-SYNCH PC-DOS Teleconferencing system uses ZMODEM. + +The LAN modem sharing program Line Plus has announced ZMODEM +support. + +Many programs have added direct ZMODEM support, including Crosstalk +Mark IV, and Telix 3. + +Most other PC-DOS communications programs support external ZMODEM +via Omen Technology's DSZ, including PibTerm, Qmodem SST and BOYAN. + +The ZMDM communications program by Jwahar Bammi runs on Atari ST +machines. + +The Online! and A-Talk Gold programs for the Amiga support ZMODEM. + +The Byte Information eXchange supports ZMODEM. The Compuserve +Information Service has ported the Unix rz/sz ZMODEM programs to + + + +Chapter 20 Rev Oct-14-88 Typeset 10-14-88 46 + + + + + + + +Chapter 20 ZMODEM Protocol 47 + + + +DECSYSTEM 20 assembler, and has announced future support for ZMODEM. + +20.1 Adding ZMODEM to DOS Programs + +DSZ is a small shareware program that supports XMODEM, YMODEM, and +ZMODEM file transfers. DSZ is designed to be called from a bulletin +board program or another communications program. It may be called +as + dsz port 2 sz file1 file2 +to send files, or as + dsz port 2 rz +to receive zero or more file(s), or as + dsz port 2 rz filea fileb +to receive two files, the first to filea and the second (if sent) to +fileb. This form of dsz may be used to control the pathname of +incoming file(s). In this example, if the sending program attempted +to send a third file, the transfer would be terminated. + +Dsz uses DOS stdout for messages (no direct CRT access), acquires +the COMM port vectors with standard DOS calls, and restores the COMM +port's interrupt vector and registers upon exit. + +Further information on dsz may be found in dsz.doc and the ZCOMM or +Pro-YAM user manuals. + + +21. YMODEM PROGRAMS + +The Unix rz/sz programs support YMODEM as well as ZMODEM. Most Unix +like systems are supported, including V7, Sys III, 4.2 BSD, SYS V, +Idris, Coherent, and Regulus. + +A version for VAX-VMS is available in VRBSB.SHQ, in the same +directory. + +Irv Hoff has added 1k packets and YMODEM transfers to the KMD and +IMP series programs, which replace the XMODEM and MODEM7/MDM7xx +series respectively. Overlays are available for a wide variety of +CP/M systems. + +Many other programs, including MEX-PLUS and Crosstalk Mark IV also +support some of YMODEM's features. + + + + + + + + + + + + +Chapter 21 Rev Oct-14-88 Typeset 10-14-88 47 + + + + + + + +Chapter 21 ZMODEM Protocol 48 + + + +Questions about YMODEM, the Professional-YAM communications program, +and requests for evaluation copies may be directed to: + + Chuck Forsberg + Omen Technology Inc + 17505-V Sauvie Island Road + Portland Oregon 97231 + VOICE: 503-621-3406 :VOICE + Modem (TeleGodzilla): 503-621-3746 + Usenet: ...!tektronix!reed!omen!caf + Compuserve: 70007,2304 + Source: TCE022 + + +22. ACKNOWLEDGMENTS + +The High Reliability Software(TM), TurboLearn Script Writer(TM), +Cybernetic Data Recovery(TM), AutoDownload(TM), Intelligent Crash +Recovery(TM), Error Containment(TM), Full Time Capture(TM), True +YMODEM(TM), OverThruster(TM), Password Guardian(TM), +CryptoScript(TM), and TurboDial(TM) are Omen Technology trademarks. + +ZMODEM was developed for the public domain under a Telenet contract. +The ZMODEM protocol descriptions and the Unix rz/sz program source +code are public domain. No licensing, trademark, or copyright +restrictions apply to the use of the protocol, the Unix rz/sz source +code and the ZMODEM name. + +Encouragement and suggestions by Thomas Buck, Ward Christensen, Earl +Hall, Irv Hoff, Stuart Mathison, and John Wales, are gratefully +acknowledged. 32 bit CRC code courtesy Gary S. Brown. + + +23. RELATED FILES + +The following files may be useful while studying this document: + +YMODEM.DOC Describes the XMODEM, XMODEM-1k, and YMODEM batch file + transfer protocols. This file is available on TeleGodzilla + as YMODEM.DQC. + +zmodem.h Definitions for ZMODEM manifest constants + +rz.c, sz.c, rbsb.c Unix source code for operating ZMODEM programs. + +rz.1, sz.1 Manual pages for rz and sz (Troff sources). + +zm.c Operating system independent low level ZMODEM subroutines. + +minirb.c A YMODEM bootstrap program, 178 lines. + + + + +Chapter 23 Rev Oct-14-88 Typeset 10-14-88 48 + + + + + + + +Chapter 23 ZMODEM Protocol 49 + + + +RZSZ.ZOO,rzsz.arc Contain the C source code and manual pages listed + above, plus a ZCOMM script to upload minirb.c to a Unix or + Xenix system, compile it, and use the program to upload the + ZMODEM source files with error checking. + +DSZ.ZOO,dsz.arc Contains DSZ.COM, a shareware X/Y/ZMODEM subprogram, + DESQview "pif" files for background operation in minimum + memory, and DSZ.DOC. + +ZCOMM*.ARC Archive files for ZCOMM, a powerful shareware + communications program. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Chapter 23 Rev Oct-14-88 Typeset 10-14-88 49 + + + + + + + + + + + + CONTENTS + + + 1. INTENDED AUDIENCE................................................ 2 + + 2. WHY DEVELOP ZMODEM?.............................................. 2 + + 3. ZMODEM Protocol Design Criteria.................................. 4 + 3.1 Ease of Use............................................... 4 + 3.2 Throughput................................................ 5 + 3.3 Integrity and Robustness.................................. 6 + 3.4 Ease of Implementation.................................... 6 + + 4. EVOLUTION OF ZMODEM.............................................. 7 + + 5. ROSETTA STONE.................................................... 10 + + 6. ZMODEM REQUIREMENTS.............................................. 10 + 6.1 File Contents............................................. 10 + + 7. ZMODEM BASICS.................................................... 12 + 7.1 Packetization............................................. 12 + 7.2 Link Escape Encoding...................................... 12 + 7.3 Header.................................................... 13 + 7.4 Binary Data Subpackets.................................... 16 + 7.5 ASCII Encoded Data Subpacket.............................. 16 + + 8. PROTOCOL TRANSACTION OVERVIEW.................................... 16 + 8.1 Session Startup........................................... 16 + 8.2 File Transmission......................................... 18 + 8.3 Session Cleanup........................................... 20 + 8.4 Session Abort Sequence.................................... 20 + + 9. STREAMING TECHNIQUES / ERROR RECOVERY............................ 21 + 9.1 Full Streaming with Sampling.............................. 21 + 9.2 Full Streaming with Reverse Interrupt..................... 23 + 9.3 Full Streaming with Sliding Window........................ 23 + 9.4 Full Streaming over Error Free Channels................... 24 + 9.5 Segmented Streaming....................................... 24 + +10. ATTENTION SEQUENCE............................................... 24 + +11. FRAME TYPES...................................................... 25 + 11.1 ZRQINIT................................................... 25 + 11.2 ZRINIT.................................................... 25 + 11.3 ZSINIT.................................................... 25 + 11.4 ZACK...................................................... 26 + 11.5 ZFILE..................................................... 26 + 11.6 ZSKIP..................................................... 28 + 11.7 ZNAK...................................................... 28 + 11.8 ZABORT.................................................... 28 + + + + - i - + + + + + + + + + + + + 11.9 ZFIN...................................................... 28 + 11.10 ZRPOS..................................................... 28 + 11.11 ZDATA..................................................... 29 + 11.12 ZEOF...................................................... 29 + 11.13 ZFERR..................................................... 29 + 11.14 ZCRC...................................................... 29 + 11.15 ZCHALLENGE................................................ 29 + 11.16 ZCOMPL.................................................... 29 + 11.17 ZCAN...................................................... 29 + 11.18 ZFREECNT.................................................. 29 + 11.19 ZCOMMAND.................................................. 29 + +12. SESSION TRANSACTION EXAMPLES..................................... 30 + 12.1 A simple file transfer.................................... 30 + 12.2 Challenge and Command Download............................ 31 + +13. ZFILE FRAME FILE INFORMATION..................................... 31 + +14. PERFORMANCE RESULTS.............................................. 33 + 14.1 Compatibility............................................. 33 + 14.2 Throughput................................................ 34 + 14.3 Error Recovery............................................ 34 + +15. PACKET SWITCHED NETWORK CONSIDERATIONS........................... 35 + +16. PERFORMANCE COMPARISON TABLES.................................... 36 + +17. FUTURE EXTENSIONS................................................ 43 + +18. REVISIONS........................................................ 43 + +19. MORE INFORMATION................................................. 44 + 19.1 TeleGodzilla Bulletin Board............................... 44 + 19.2 Unix UUCP Access.......................................... 45 + +20. ZMODEM PROGRAMS.................................................. 45 + 20.1 Adding ZMODEM to DOS Programs............................. 47 + +21. YMODEM PROGRAMS.................................................. 47 + +22. ACKNOWLEDGMENTS.................................................. 48 + +23. RELATED FILES.................................................... 48 + + +LIST OF FIGURES + + +Figure 1. Order of Bytes in Header................................... 14 + +Figure 2. 16 Bit CRC Binary Header................................... 14 + + + + - ii - + + + + + + + + + + + +Figure 3. 32 Bit CRC Binary Header................................... 14 + +Figure 4. HEX Header................................................. 15 + +Figure 5. Transmission Time Comparison............................... 37 + + +LIST OF TABLES + + +TABLE 1. Network and Flow Control Compatibility...................... 36 + +TABLE 2. Protocol Overhead Information............................... 37 + +TABLE 3. Local Timesharing Computer Download Performance............. 38 + +TABLE 4. File Transfer Speeds........................................ 39 + +TABLE 5. Protocol Checklist.......................................... 41 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - iii - + + + + + + + + + + The ZMODEM Inter Application File Transfer Protocol + + Chuck Forsberg + + Omen Technology Inc + + + ABSTRACT + + + +The ZMODEM file transfer protocol provides reliable file and command +transfers with complete END-TO-END data integrity between application +programs. ZMODEM's 32 bit CRC protects against errors that continue to +sneak into even the most advanced networks. + +Unlike traditional and many recently introduced protocols, ZMODEM +safeguards all data and supervisory information with effective error +detection. + +ZMODEM rapidly transfers files, particularly with buffered (error +correcting) modems, timesharing systems, satellite relays, and wide area +packet switched networks. + +User Friendliness is an important ZMODEM feature. ZMODEM AutoDownload +(Automatic file Download initiated without user intervention) greatly +simplifies file transfers compared to the traditional protocols. + +ZMODEM provides advanced file management features including Crash +Recovery, flexible control of selective file transfers, and security +verified command downloading. + +ZMODEM protocol features allow implementation on a wide variety of systems +operating in a wide variety of environments. A choice of buffering and +windowing modes allows ZMODEM to operate on systems that cannot support +other streaming protocols. Finely tuned control character escaping allows +operation with real world networks without Kermit's high overhead. + +ZMODEM is the only high performance high reliability public protocol that +does not require large buffer allocations for normal file transfers. + +Although ZMODEM software is more complex than unreliable XMODEM routines, +a comphrensive protocol description and actual C source code to production +programs have allowed dozens of developers to upgrade their applications +with efficient, reliable ZMODEM file transfers with a minimum of effort. + +ZMODEM was developed for the public domain under a Telenet contract. The +ZMODEM protocol descriptions and the Unix rz/sz program source code are +public domain. No licensing, trademark, or copyright restrictions apply +to the use of the protocol, the Unix rz/sz source code and the ZMODEM +name. diff --git a/src-tauri/vendor/zmodem2/src/buffer.rs b/src-tauri/vendor/zmodem2/src/buffer.rs new file mode 100644 index 00000000..4fe10399 --- /dev/null +++ b/src-tauri/vendor/zmodem2/src/buffer.rs @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// Copyright (c) 2023-2025 Jarkko Sakkinen + +//! A simple stack-allocated buffer with a fixed capacity for staging data coming +//! from the serial link. + +use core::fmt; +use core::ops::{Deref, DerefMut}; + +/// An error indicating a buffer's capacity was exceeded. +#[derive(Debug, PartialEq)] +pub struct CapacityError; + +/// A buffer type for incoming and outgoing and other flyaway data. +pub struct Buffer { + bytes: [u8; CAP], + len: usize, +} + +impl Default for Buffer { + fn default() -> Self { + Self::new() + } +} + +impl Buffer { + /// Creates a new instance. + #[must_use] + pub const fn new() -> Self { + Self { + bytes: [0; CAP], + len: 0, + } + } + + /// Returns the number of bytes stored in the buffer. + #[must_use] + pub const fn len(&self) -> usize { + self.len + } + + /// Returns the capacity of the buffer in bytes. + #[must_use] + pub const fn capacity(&self) -> usize { + CAP + } + + /// Returns `true` if the buffer is empty. + #[must_use] + pub const fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Resets buffer length back to zero. + pub fn clear(&mut self) { + self.len = 0; + } + + /// Appends a byte to the buffer. + /// + /// # Errors + /// + /// Returns `Err(CapacityError)`, if the capacity would be exceeded. + pub fn push(&mut self, value: u8) -> Result<(), CapacityError> { + if self.len < CAP { + self.bytes[self.len] = value; + self.len += 1; + Ok(()) + } else { + Err(CapacityError) + } + } + + /// Removes the last byte and returns it to the caller. If the buffer is + /// empty, returns `None`. + pub fn pop(&mut self) -> Option { + if self.is_empty() { + None + } else { + self.len -= 1; + Some(self.bytes[self.len]) + } + } + + /// Copies bytes from a slice to the end of the buffer. + /// + /// # Errors + /// + /// Returns `Err(CapacityError)`, if the capacity would be exceeded. + pub fn extend_from_slice(&mut self, slice: &[u8]) -> Result<(), CapacityError> { + if self.len + slice.len() > CAP { + return Err(CapacityError); + } + let end = self.len + slice.len(); + self.bytes[self.len..end].copy_from_slice(slice); + self.len = end; + Ok(()) + } +} + +impl fmt::Write for Buffer { + fn write_str(&mut self, s: &str) -> fmt::Result { + self.extend_from_slice(s.as_bytes()).map_err(|_| fmt::Error) + } +} + +impl Deref for Buffer { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + &self.bytes[0..self.len] + } +} + +impl DerefMut for Buffer { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.bytes[0..self.len] + } +} + +impl AsRef<[u8]> for Buffer { + fn as_ref(&self) -> &[u8] { + &self.bytes[0..self.len] + } +} + +impl PartialEq for Buffer +where + T: AsRef<[u8]>, +{ + fn eq(&self, other: &T) -> bool { + self.as_ref() == other.as_ref() + } +} + +impl Eq for Buffer {} diff --git a/src-tauri/vendor/zmodem2/src/crc.rs b/src-tauri/vendor/zmodem2/src/crc.rs new file mode 100644 index 00000000..c6e8ec15 --- /dev/null +++ b/src-tauri/vendor/zmodem2/src/crc.rs @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// Copyright (c) 2023-2025 Jarkko Sakkinen + +/// Computes the CRC-16-XMODEM checksum. +#[must_use] +pub const fn crc16_xmodem(data: &[u8]) -> u16 { + let mut crc: u16 = 0x0000; + let mut i = 0; + while i < data.len() { + crc = crc16_update(crc, data[i]); + i += 1; + } + crc +} + +/// Computes the CRC-32-ISO-HDLC checksum. +#[must_use] +pub const fn crc32_iso_hdlc(data: &[u8]) -> u32 { + let mut crc: u32 = 0xFFFF_FFFF; + let mut i = 0; + while i < data.len() { + crc = crc32_update(crc, data[i]); + i += 1; + } + !crc +} + +/// A stateful, iterative CRC-16-XMODEM calculator. +pub struct Crc16 { + crc: u16, +} + +impl Crc16 { + /// Creates a new CRC-16 calculator. + #[must_use] + pub const fn new() -> Self { + Self { crc: 0x0000 } + } + + /// Updates the CRC state with a slice of bytes. + pub fn update(&mut self, data: &[u8]) { + for &byte in data { + self.update_byte(byte); + } + } + + /// Updates the CRC state with a single byte. + pub fn update_byte(&mut self, byte: u8) { + self.crc = crc16_update(self.crc, byte); + } + + /// Finalizes the CRC calculation and returns the checksum. + #[must_use] + pub const fn finalize(&self) -> u16 { + self.crc + } +} + +/// A stateful, iterative CRC-32-ISO-HDLC calculator. +pub struct Crc32 { + crc: u32, +} + +impl Crc32 { + /// Creates a new CRC-32 calculator. + #[must_use] + pub const fn new() -> Self { + Self { crc: 0xFFFF_FFFF } + } + + /// Updates the CRC state with a slice of bytes. + pub fn update(&mut self, data: &[u8]) { + for &byte in data { + self.update_byte(byte); + } + } + + /// Updates the CRC state with a single byte. + pub fn update_byte(&mut self, byte: u8) { + self.crc = crc32_update(self.crc, byte); + } + + /// Finalizes the CRC calculation and returns the checksum. + #[must_use] + pub const fn finalize(&self) -> u32 { + !self.crc + } +} + +/// Performs a single byte update for CRC-16-XMODEM. +const fn crc16_update(mut crc: u16, byte: u8) -> u16 { + crc ^= (byte as u16) << 8; + let mut i = 0; + while i < 8 { + if (crc & 0x8000) != 0 { + crc = (crc << 1) ^ 0x1021; + } else { + crc <<= 1; + } + i += 1; + } + crc +} + +/// Performs a single byte update for CRC-32-ISO-HDLC. +const fn crc32_update(mut crc: u32, byte: u8) -> u32 { + crc ^= byte as u32; + let mut i = 0; + while i < 8 { + if (crc & 1) != 0 { + crc = (crc >> 1) ^ 0xEDB8_8320; + } else { + crc >>= 1; + } + i += 1; + } + crc +} diff --git a/src-tauri/vendor/zmodem2/src/error.rs b/src-tauri/vendor/zmodem2/src/error.rs new file mode 100644 index 00000000..7adfb9a8 --- /dev/null +++ b/src-tauri/vendor/zmodem2/src/error.rs @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// Copyright (c) 2023-2025 Jarkko Sakkinen + +use crate::String; +use thiserror::Error; + +/// Top-level error type. +#[derive(Error, Debug, PartialEq)] +pub enum Error { + #[error("malformed encoding type: {0:#02x}")] + MalformedEncoding(u8), + #[error("malformed file size")] + MalformedFileSize, + #[error("malformed filename")] + MalformedFileName, + #[error("malformed frame type: {0:#02x}")] + MalformedFrame(u8), + #[error("malformed header")] + MalformedHeader, + #[error("malformed packet type: {0:#02x}")] + MalformedPacket(u8), + #[error("not connected")] + NotConnected, + #[error("read: {0}")] + Read(String), + #[error("out of memory")] + OutOfMemory, + #[error("unexpected CRC-16")] + UnexpectedCrc16, + #[error("unexpected CRC-32")] + UnexpectedCrc32, + #[error("unexpected EOF")] + UnexpectedEof, + #[error("unsupported operation")] + Unsupported, + #[error("write: {0}")] + Write(String), +} diff --git a/src-tauri/vendor/zmodem2/src/header.rs b/src-tauri/vendor/zmodem2/src/header.rs new file mode 100644 index 00000000..f2c15ffa --- /dev/null +++ b/src-tauri/vendor/zmodem2/src/header.rs @@ -0,0 +1,390 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// Copyright (c) 2017-2020 Alexey Arbuzov +// Copyright (c) 2023-2025 Jarkko Sakkinen + +//! ZMODEM protocol header, encoding, and frame definitions. + +use crate::buffer::Buffer; +use crate::crc::{crc16_xmodem, crc32_iso_hdlc}; +use crate::error::Error; +use crate::io::{Read, Write}; +use crate::zdle; +use crate::{XON, ZDLE, ZPAD}; +use bitflags::bitflags; + +/// Buffer size with enough capacity for an escaped header +pub(crate) const HEADER_SIZE: usize = 32; +/// The size of the header payload (frame type + flags). +pub(crate) const HEADER_PAYLOAD_SIZE: usize = 5; + +pub(crate) const ZACK_HEADER: Header = Header::new(Encoding::ZHEX, Frame::ZACK, &[0; 4]); +pub(crate) const ZDATA_HEADER: Header = Header::new(Encoding::ZBIN32, Frame::ZDATA, &[0; 4]); +pub(crate) const ZEOF_HEADER: Header = Header::new(Encoding::ZBIN32, Frame::ZEOF, &[0; 4]); +pub(crate) const ZFIN_HEADER: Header = Header::new(Encoding::ZHEX, Frame::ZFIN, &[0; 4]); +pub(crate) const ZNAK_HEADER: Header = Header::new(Encoding::ZHEX, Frame::ZNAK, &[0; 4]); +pub(crate) const ZRPOS_HEADER: Header = Header::new(Encoding::ZHEX, Frame::ZRPOS, &[0; 4]); +pub(crate) const ZRQINIT_HEADER: Header = Header::new(Encoding::ZHEX, Frame::ZRQINIT, &[0; 4]); + +/// Data structure for holding a ZMODEM protocol header, which begins a frame, +/// and is followed optionally by a variable number of subpackets. +#[repr(C)] +#[derive(Clone, Copy, PartialEq)] +pub struct Header { + encoding: Encoding, + frame: Frame, + flags: [u8; 4], +} + +impl Header { + /// Creates a new instance + #[must_use] + pub const fn new(encoding: Encoding, frame: Frame, flags: &[u8; 4]) -> Self { + Self { + encoding, + frame, + flags: *flags, + } + } + + /// Returns `Encoding` of the frame + #[must_use] + pub const fn encoding(&self) -> Encoding { + self.encoding + } + + /// Returns `Frame`, containing the frame type + #[must_use] + pub const fn frame(&self) -> Frame { + self.frame + } + + /// Returns count for the frame types using this field + #[must_use] + pub const fn count(&self) -> u32 { + u32::from_le_bytes(self.flags) + } + + /// Encodes and writes the header to the serial port + /// + /// # Errors + /// + /// * [`Read`](crate::Error::Read) when the read I/O fails with the serial port + /// * [`Write`](crate::Error::Write) when the write I/O fails with the serial port + pub fn write

(&self, port: &mut P) -> Result, Error> + where + P: Write + ?Sized, + { + if write_header_start(port, self.encoding)?.is_none() { + return Ok(None); + } + + let mut out: Buffer = Buffer::new(); + out.push(self.frame as u8).map_err(|_| Error::OutOfMemory)?; + out.extend_from_slice(&self.flags) + .map_err(|_| Error::OutOfMemory)?; + + let mut crc = [0u8; 4]; + let crc_len = make_crc(&out, &mut crc, self.encoding); + out.extend_from_slice(&crc[..crc_len]) + .map_err(|_| Error::OutOfMemory)?; + + if self.encoding == Encoding::ZHEX { + let mut hex_buf = [0u8; HEADER_SIZE]; + let len = out.len() * 2; + let hex = &mut hex_buf.get_mut(..len).ok_or(Error::UnexpectedEof)?; + hex::encode_to_slice(&out, hex).map_err(|_| Error::OutOfMemory)?; + if write_slice_escaped(port, hex)?.is_none() { + return Ok(None); + } + + if write_header_end_hex(port, self.frame)?.is_none() { + return Ok(None); + } + } else if write_slice_escaped(port, &out)?.is_none() { + return Ok(None); + } + + Ok(Some(())) + } + + /// Reads and decodes a header from the serial port, and returns a new + /// instance + /// + /// # Errors + /// + /// * [`Read`](crate::Error::Read) when the read I/O fails with the serial port + /// * [`Write`](crate::Error::Write) when the write I/O fails with the serial port + /// * [`UnexpectedCrc16`](crate::Error::UnexpectedCrc16) or + /// [`UnexpectedCrc32`](crate::Error::UnexpectedCrc32) when corrupted data has been detected + pub fn read

(port: &mut P) -> Result, Error> + where + P: Read + ?Sized, + { + let Some(encoding_byte) = port.read_byte()? else { + return Ok(None); + }; + let encoding = Encoding::try_from(encoding_byte)?; + + let mut out_hex: Buffer = Buffer::new(); + for _ in 0..Header::read_size(encoding) { + let Some(byte) = read_byte_unescaped(port)? else { + return Ok(None); + }; + out_hex.push(byte).map_err(|_| Error::OutOfMemory)?; + } + + let mut out: Buffer = Buffer::new(); + if encoding == Encoding::ZHEX { + let mut out_bytes = [0u8; HEADER_SIZE / 2]; + let out_len = out_hex.len() / 2; + hex::decode_to_slice(&out_hex, &mut out_bytes[..out_len]) + .map_err(|_| Error::MalformedHeader)?; + out.extend_from_slice(&out_bytes[..out_len]) + .map_err(|_| Error::OutOfMemory)?; + } else { + out.extend_from_slice(&out_hex) + .map_err(|_| Error::OutOfMemory)?; + } + check_crc(&out[..5], &out[5..], encoding)?; + let frame = Frame::try_from(out[0])?; + let mut header = Header::new(encoding, frame, &[0; 4]); + header.flags.copy_from_slice(&out[1..=4]); + Ok(Some(header)) + } + + /// Returns a new instance with the flags substitude with a count + /// for the frame types using this field. + #[must_use] + pub const fn with_count(&self, count: u32) -> Self { + Header::new(self.encoding, self.frame, &count.to_le_bytes()) + } + + /// Returns the serialized size of the header payload (payload + CRC) + pub(crate) const fn read_size(encoding: Encoding) -> usize { + match encoding { + Encoding::ZBIN => HEADER_PAYLOAD_SIZE + 2, + Encoding::ZBIN32 => HEADER_PAYLOAD_SIZE + 4, + Encoding::ZHEX => (HEADER_PAYLOAD_SIZE + 2) * 2, + } + } +} + +/// The ZMODEM protocol frame encoding +#[repr(u8)] +#[allow(clippy::upper_case_acronyms)] +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Encoding { + ZBIN = 0x41, + ZHEX = 0x42, + ZBIN32 = 0x43, +} + +impl TryFrom for Encoding { + type Error = Error; + + fn try_from(value: u8) -> Result { + match value { + 0x41 => Ok(Encoding::ZBIN), + 0x42 => Ok(Encoding::ZHEX), + 0x43 => Ok(Encoding::ZBIN32), + _ => Err(Error::MalformedEncoding(value)), + } + } +} + +#[repr(u8)] +#[allow(clippy::upper_case_acronyms)] +#[derive(Clone, Copy, Debug, PartialEq)] +/// Frame types +pub enum Frame { + /// Request receive init + ZRQINIT = 0, + /// Receiver capabilities and packet size + ZRINIT = 1, + /// Send init sequence (optional) + ZSINIT = 2, + /// ACK to above + ZACK = 3, + /// File name from sender + ZFILE = 4, + /// To sender: skip this file + ZSKIP = 5, + /// Last packet was garbled + ZNAK = 6, + /// Abort batch transfers + ZABORT = 7, + /// Finish session + ZFIN = 8, + /// Resume data trans at this position + ZRPOS = 9, + /// Data packet(s) follow + ZDATA = 10, + /// End of file + ZEOF = 11, + /// Fatal Read or Write error Detected + ZFERR = 12, + /// Request for file CRC and response + ZCRC = 13, + /// Receiver's Challenge + ZCHALLENGE = 14, + /// Request is complete + ZCOMPL = 15, + /// Other end canned session with CAN*5 + ZCAN = 16, + /// Request for free bytes on filesystem + ZFREECNT = 17, + /// Command from sending program + ZCOMMAND = 18, + /// Output to standard error, data follows + ZSTDERR = 19, +} + +impl TryFrom for Frame { + type Error = Error; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(Frame::ZRQINIT), + 1 => Ok(Frame::ZRINIT), + 2 => Ok(Frame::ZSINIT), + 3 => Ok(Frame::ZACK), + 4 => Ok(Frame::ZFILE), + 5 => Ok(Frame::ZSKIP), + 6 => Ok(Frame::ZNAK), + 7 => Ok(Frame::ZABORT), + 8 => Ok(Frame::ZFIN), + 9 => Ok(Frame::ZRPOS), + 10 => Ok(Frame::ZDATA), + 11 => Ok(Frame::ZEOF), + 12 => Ok(Frame::ZFERR), + 13 => Ok(Frame::ZCRC), + 14 => Ok(Frame::ZCHALLENGE), + 15 => Ok(Frame::ZCOMPL), + 16 => Ok(Frame::ZCAN), + 17 => Ok(Frame::ZFREECNT), + 18 => Ok(Frame::ZCOMMAND), + 19 => Ok(Frame::ZSTDERR), + _ => Err(Error::MalformedFrame(value)), + } + } +} + +bitflags! { + /// `ZRINIT` flags + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct Zrinit: u8 { + /// Can send and receive in full-duplex + const CANFDX = 0x01; + /// Can receive data in parallel with disk I/O + const CANOVIO = 0x02; + /// Can send a break signal + const CANBRK = 0x04; + /// Can decrypt + const CANCRY = 0x08; + /// Can uncompress + const CANLZW = 0x10; + /// Can use 32-bit frame check + const CANFC32 = 0x20; + /// Expects control character to be escaped + const ESCCTL = 0x40; + /// Expects 8th bit to be escaped + const ESC8 = 0x80; + } +} + +fn write_header_start

(port: &mut P, encoding: Encoding) -> Result, Error> +where + P: Write + ?Sized, +{ + if port.write_byte(ZPAD)?.is_none() { + return Ok(None); + } + if encoding == Encoding::ZHEX && port.write_byte(ZPAD)?.is_none() { + return Ok(None); + } + if port.write_byte(ZDLE)?.is_none() { + return Ok(None); + } + port.write_byte(encoding as u8) +} + +fn write_header_end_hex

(port: &mut P, frame: Frame) -> Result, Error> +where + P: Write + ?Sized, +{ + if port.write_byte(b'\r')?.is_none() { + return Ok(None); + } + if port.write_byte(b'\n')?.is_none() { + return Ok(None); + } + if frame != Frame::ZACK && frame != Frame::ZFIN && port.write_byte(XON)?.is_none() { + return Ok(None); + } + Ok(Some(())) +} + +fn make_crc(data: &[u8], out: &mut [u8], encoding: Encoding) -> usize { + if encoding == Encoding::ZBIN32 { + let crc = crc32_iso_hdlc(data).to_le_bytes(); + out[..4].copy_from_slice(&crc[..4]); + 4 + } else { + let crc = crc16_xmodem(data).to_be_bytes(); + out[..2].copy_from_slice(&crc[..2]); + 2 + } +} + +fn check_crc(data: &[u8], crc: &[u8], encoding: Encoding) -> Result<(), Error> { + let mut crc2 = [0u8; 4]; + let crc2_len = make_crc(data, &mut crc2, encoding); + if *crc == crc2[..crc2_len] { + Ok(()) + } else if encoding == Encoding::ZBIN32 { + Err(Error::UnexpectedCrc32) + } else { + Err(Error::UnexpectedCrc16) + } +} + +pub(crate) fn write_slice_escaped

(port: &mut P, buf: &[u8]) -> Result, Error> +where + P: Write + ?Sized, +{ + for value in buf { + if write_byte_escaped(port, *value)?.is_none() { + return Ok(None); + } + } + + Ok(Some(())) +} + +pub(crate) fn write_byte_escaped

(port: &mut P, value: u8) -> Result, Error> +where + P: Write + ?Sized, +{ + let escaped = zdle::ZDLE_TABLE[value as usize]; + if escaped != value && port.write_byte(ZDLE)?.is_none() { + return Ok(None); + } + port.write_byte(escaped) +} + +pub(crate) fn read_byte_unescaped

(port: &mut P) -> Result, Error> +where + P: Read + ?Sized, +{ + let Some(b) = port.read_byte()? else { + return Ok(None); + }; + Ok(Some(if b == ZDLE { + let Some(b) = port.read_byte()? else { + return Ok(None); + }; + zdle::UNZDLE_TABLE[b as usize] + } else { + b + })) +} diff --git a/src-tauri/vendor/zmodem2/src/io.rs b/src-tauri/vendor/zmodem2/src/io.rs new file mode 100644 index 00000000..fc508d76 --- /dev/null +++ b/src-tauri/vendor/zmodem2/src/io.rs @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// Copyright (c) 2023-2025 Jarkko Sakkinen + +//! Traits for I/O non-blocking operations. + +use crate::Error; + +/// Write operations. +pub trait Write { + /// Writes bytes from a buffer to the I/O port. + /// + /// # Errors + /// + /// [`Read`](crate::Error::Read) when the read I/O fails with the serial + /// port. + /// [`Write`](crate::Error::Write) when the write I/O fails with the + /// serial port. + fn write(&mut self, buf: &[u8]) -> Result, Error> { + if self.write_all(buf)?.is_none() { + return Ok(None); + } + u32::try_from(buf.len()) + .map(Some) + .map_err(|_| Error::OutOfMemory) + } + + /// Writes the entire buffer to the I/O port. + /// + /// Returns `Ok(None)` only if no bytes were written. + /// + /// # Errors + /// + /// [`Read`](crate::Error::Read) when the read I/O fails with the serial + /// port. + /// [`Write`](crate::Error::Write) when the write I/O fails with the + /// serial port. + fn write_all(&mut self, buf: &[u8]) -> Result, Error>; + + /// Writes a single byte to the I/O port. + /// + /// Returns `Ok(None)` only if no bytes were written. + /// + /// # Errors + /// + /// [`Read`](crate::Error::Read) when the read I/O fails with the serial + /// port. + /// [`Write`](crate::Error::Write) when the write I/O fails with the + /// serial port. + fn write_byte(&mut self, value: u8) -> Result, Error> { + self.write_all(&[value]) + } +} + +/// Read operations. +pub trait Read { + /// Read bytes from the I/O port. + /// + /// Returns `Ok(None)` only if no bytes were written. + /// + /// # Errors + /// + /// * [`Read`](crate::Error::Read) when the read I/O fails with the serial port. + /// * [`Write`](crate::Error::Write) when the write I/O fails with the serial port. + fn read(&mut self, buf: &mut [u8]) -> Result, Error>; + + /// Read a byte from the I/O port. + /// + /// Returns `Ok(None)` only if no bytes were written. + /// + /// # Errors + /// + /// [`Read`](crate::Error::Read) when the read I/O fails with the serial port. + /// [`Write`](crate::Error::Write) when the write I/O fails with the serial port. + fn read_byte(&mut self) -> Result, Error>; +} + +/// Seek operations +pub trait Seek { + /// Seeks I/O port to an offset. + /// + /// # Errors + /// + /// [`Read`](crate::Error::Read) when the read I/O fails with the serial port. + /// [`Write`](crate::Error::Write) when the write I/O fails with the serial port. + fn seek(&mut self, offset: u32) -> Result, Error>; +} diff --git a/src-tauri/vendor/zmodem2/src/lib.rs b/src-tauri/vendor/zmodem2/src/lib.rs new file mode 100644 index 00000000..973388e3 --- /dev/null +++ b/src-tauri/vendor/zmodem2/src/lib.rs @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// Copyright (c) 2017-2020 Alexey Arbuzov +// Copyright (c) 2023-2026 Jarkko Sakkinen + +//! ZMODEM file transfer protocol library. `zmodem2::Sender` and +//! `zmodem2::Receiver` provide stream alike state machines for sending and +//! receiving files with the ZMODEM protocol. +//! +//! The usage can be described in the high-level with the following flow: +//! 1. Create `zmodem2::Sender` or `zmodem2::Receiver`. +//! 2. Drain `drain_outgoing()` returned bytes into the wire and call +//! `advance_outgoing()` after writing. Then, feed incoming bytes with +//! `feed_incoming()`. +//! 3. In the sender, complete `poll_file()` with `feed_file()` if required +//! and handle events via `poll_event()`. +//! 4. In the receiver, write `drain_file()` returned bytes into storage, and +//! call `advance_file()` after writing. Handle events via `poll_event()`. + +#![deny(clippy::all)] +#![deny(clippy::pedantic)] +#![allow(clippy::result_large_err)] +#![cfg_attr(not(feature = "std"), no_std)] + +mod buffer; +mod crc; +mod error; +mod header; +mod io; +#[cfg(feature = "std")] +mod std; +mod string; +mod transmission; +mod zdle; + +pub use buffer::*; +pub use error::*; +pub use header::*; +pub use io::*; +pub use string::*; +pub use transmission::*; + +pub const ZPAD: u8 = b'*'; +pub const ZDLE: u8 = 0x18; +pub const XON: u8 = 0x11; diff --git a/src-tauri/vendor/zmodem2/src/std.rs b/src-tauri/vendor/zmodem2/src/std.rs new file mode 100644 index 00000000..9c60f396 --- /dev/null +++ b/src-tauri/vendor/zmodem2/src/std.rs @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// Copyright (c) 2017-2020 Alexey Arbuzov +// Copyright (c) 2023-2025 Jarkko Sakkinen + +use super::{Encoding, Error, Frame, Header, Read, Seek, String, SubpacketType, Write}; +use std::{fmt, io::SeekFrom}; + +impl Write for W +where + W: std::io::Write, +{ + fn write(&mut self, buf: &[u8]) -> Result, Error> { + match std::io::Write::write(self, buf) { + Ok(bytes_written) => u32::try_from(bytes_written) + .map(Some) + .map_err(|_| Error::OutOfMemory), + Err(e) => { + if e.kind() == std::io::ErrorKind::WouldBlock { + Ok(None) + } else { + Err(Error::Write(String::from(e.to_string().as_str()))) + } + } + } + } + + fn write_all(&mut self, buf: &[u8]) -> Result, Error> { + match std::io::Write::write_all(self, buf) { + Ok(()) => Ok(Some(())), + Err(e) => { + if e.kind() == std::io::ErrorKind::WouldBlock { + Ok(None) + } else { + Err(Error::Write(String::from(e.to_string().as_str()))) + } + } + } + } +} + +impl Read for R +where + R: std::io::Read, +{ + fn read(&mut self, buf: &mut [u8]) -> Result, Error> { + match std::io::Read::read(self, buf) { + Ok(bytes_read) => u32::try_from(bytes_read) + .map(Some) + .map_err(|_| Error::OutOfMemory), + Err(e) => { + if e.kind() == std::io::ErrorKind::WouldBlock { + Ok(None) + } else { + Err(Error::Read(String::from(e.to_string().as_str()))) + } + } + } + } + + fn read_byte(&mut self) -> Result, Error> { + let mut buf = [0; 1]; + match std::io::Read::read(self, &mut buf) { + Ok(1) => Ok(Some(buf[0])), + Ok(0) => Err(Error::UnexpectedEof), + Err(e) => { + if e.kind() == std::io::ErrorKind::WouldBlock { + Ok(None) + } else { + Err(Error::Read(String::from(e.to_string().as_str()))) + } + } + Ok(_) => Err(Error::Read(String::from("unknown read error"))), + } + } +} + +impl Seek for S +where + S: std::io::Seek, +{ + fn seek(&mut self, offset: u32) -> Result, Error> { + let new_offset = u64::from(offset); + match std::io::Seek::seek(self, SeekFrom::Start(new_offset)) { + Ok(final_offset) => u32::try_from(final_offset) + .map(Some) + .map_err(|_| Error::UnexpectedEof), + Err(e) => { + if e.kind() == std::io::ErrorKind::WouldBlock { + Ok(None) + } else { + Err(Error::Read(String::from(e.to_string().as_str()))) + } + } + } + } +} + +impl fmt::Display for Header { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{:8} {}", self.encoding(), self.frame()) + } +} + +impl fmt::Display for Encoding { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{:#02x}", *self as u8) + } +} + +impl fmt::Display for Frame { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{:#02x}", *self as u8) + } +} + +impl fmt::Display for SubpacketType { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{:#0x}", *self as u8) + } +} + +impl std::fmt::Debug for String { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match core::str::from_utf8(self) { + Ok(s) => f.write_str(s), + Err(_) => f.debug_list().entries(self.iter()).finish(), + } + } +} + +impl std::fmt::Display for String { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(core::str::from_utf8(self).unwrap_or("")) + } +} diff --git a/src-tauri/vendor/zmodem2/src/string.rs b/src-tauri/vendor/zmodem2/src/string.rs new file mode 100644 index 00000000..2e4a7172 --- /dev/null +++ b/src-tauri/vendor/zmodem2/src/string.rs @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// Copyright (c) 2023-2025 Jarkko Sakkinen + +use super::{Buffer, CapacityError}; +use core::{ + cmp::min, + ops::{Deref, DerefMut}, +}; + +/// The capacity of the fixed-size `String` type. +const STRING_CAP: usize = 256; + +/// A stack-allocated, fixed-capacity string. +/// +/// This is a newtype wrapper around `Buffer<256>` to provide type safety and +/// string-specific operations. +#[derive(Eq)] +pub struct String(Buffer); + +impl Default for String { + fn default() -> Self { + Self::new() + } +} + +impl From<&str> for String { + /// Creates a new `String` from a `&str`, truncating if necessary. + fn from(s: &str) -> Self { + let mut string = Self::new(); + let bytes = s.as_bytes(); + let len = min(bytes.len(), STRING_CAP); + let mut end = len; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + + let truncated_bytes = &bytes[..end]; + string + .extend_from_slice(truncated_bytes) + .unwrap_or_default(); + string + } +} + +impl String { + /// Creates a new, empty string. + #[must_use] + pub const fn new() -> Self { + Self(Buffer::::new()) + } + + /// Resets buffer length back to zero. + pub fn clear(&mut self) { + self.0.clear(); + } + + /// Returns the capacity of the buffer in bytes. + #[must_use] + pub const fn capacity(&self) -> usize { + self.0.capacity() + } + + /// Copies bytes from a slice to the end of the buffer. + /// + /// # Errors + /// + /// Returns `Err(CapacityError)`, if the capacity would be exceeded. + pub fn extend_from_slice(&mut self, slice: &[u8]) -> Result<(), CapacityError> { + self.0.extend_from_slice(slice) + } +} + +impl Deref for String { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for String { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl AsRef<[u8]> for String { + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } +} + +impl PartialEq for String +where + T: AsRef<[u8]>, +{ + fn eq(&self, other: &T) -> bool { + self.as_ref() == other.as_ref() + } +} diff --git a/src-tauri/vendor/zmodem2/src/transmission.rs b/src-tauri/vendor/zmodem2/src/transmission.rs new file mode 100644 index 00000000..1cee80f2 --- /dev/null +++ b/src-tauri/vendor/zmodem2/src/transmission.rs @@ -0,0 +1,1450 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// Copyright (c) 2017-2020 Alexey Arbuzov +// Copyright (c) 2023-2026 Jarkko Sakkinen + +//! ZMODEM transmission state and logic. + +use crate::buffer::Buffer; +use crate::crc; +use crate::error::Error; +use crate::header::{ + write_slice_escaped, Encoding, Frame, Header, Zrinit, HEADER_PAYLOAD_SIZE, HEADER_SIZE, + ZACK_HEADER, ZDATA_HEADER, ZEOF_HEADER, ZFIN_HEADER, ZNAK_HEADER, ZRPOS_HEADER, ZRQINIT_HEADER, +}; +use crate::io::{Read, Write}; +use crate::string::String; +use crate::zdle; +use crate::{ZDLE, ZPAD}; +use core::cmp::min; +use core::fmt::Write as _; + +/// Size of the unescaped subpacket payload. The size is picked from the +/// original ZMODEM specification. +const SUBPACKET_MAX_SIZE: usize = 1024; +const SUBPACKET_PER_ACK: usize = 10; +const MAX_HEADER_ESCAPED: usize = 128; +const MAX_SUBPACKET_ESCAPED: usize = SUBPACKET_MAX_SIZE * 2 + 2 + 8; +const WIRE_BUF_SIZE: usize = MAX_HEADER_ESCAPED + MAX_SUBPACKET_ESCAPED; +const RECEIVER_EVENT_QUEUE_CAP: usize = 4; + +/// The ZMODEM protocol subpacket type +#[repr(u8)] +#[allow(clippy::upper_case_acronyms)] +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum SubpacketType { + ZCRCE = 0x68, + ZCRCG = 0x69, + ZCRCQ = 0x6a, + ZCRCW = 0x6b, +} + +impl TryFrom for SubpacketType { + type Error = Error; + + fn try_from(value: u8) -> Result { + match value { + 0x68 => Ok(SubpacketType::ZCRCE), + 0x69 => Ok(SubpacketType::ZCRCG), + 0x6a => Ok(SubpacketType::ZCRCQ), + 0x6b => Ok(SubpacketType::ZCRCW), + _ => Err(Error::MalformedPacket(value)), + } + } +} + +/// A request for file data from the sender. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FileRequest { + pub offset: u32, + pub len: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum SenderEvent { + FileComplete, + SessionComplete, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ReceiverEvent { + FileStart, + FileComplete, + SessionComplete, +} + +/// Internal state for reading a subpacket byte-by-byte +#[derive(Clone, Copy, Debug, PartialEq)] +enum SubpacketState { + Idle, + Reading, + Writing(SubpacketType), + Crc(SubpacketType), +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum ZpadState { + Idle, + Zpad, + ZpadZpad, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum HeaderReadState { + SeekingZpad, + ReadingEncoding, + ReadingData, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum SendState { + WaitReceiverInit, + ReadyForFile, + WaitFilePos, + NeedFileData, + WaitFileAck, + WaitFileDone, + WaitFinish, + Done, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum RecvState { + SessionBegin, + FileBegin, + FileReadingMetadata, + FileReadingSubpacket, + FileWaitingSubpacket, + SessionEnd, +} + +struct HeaderReader { + state: HeaderReadState, + zpad_state: ZpadState, + buf: Buffer, + encoding: Option, + expected_len: usize, + escape_pending: bool, +} + +impl HeaderReader { + const fn new() -> Self { + Self { + state: HeaderReadState::SeekingZpad, + zpad_state: ZpadState::Idle, + buf: Buffer::::new(), + encoding: None, + expected_len: 0, + escape_pending: false, + } + } + + fn reset(&mut self) { + self.state = HeaderReadState::SeekingZpad; + self.zpad_state = ZpadState::Idle; + self.encoding = None; + self.expected_len = 0; + self.escape_pending = false; + self.buf.clear(); + } + + fn advance_zpad_state(&mut self, byte: u8) -> bool { + match self.zpad_state { + ZpadState::Idle => { + if byte == ZPAD { + self.zpad_state = ZpadState::Zpad; + } + } + ZpadState::Zpad | ZpadState::ZpadZpad => { + if byte == ZDLE { + self.zpad_state = ZpadState::Idle; + return true; + } + if byte == ZPAD { + self.zpad_state = ZpadState::ZpadZpad; + } else { + self.zpad_state = ZpadState::Idle; + } + } + } + false + } + + fn read

(&mut self, port: &mut P) -> Result, Error> + where + P: Read + ?Sized, + { + loop { + match self.state { + HeaderReadState::SeekingZpad => { + let Some(byte) = port.read_byte()? else { + return Ok(None); + }; + if self.advance_zpad_state(byte) { + self.state = HeaderReadState::ReadingEncoding; + } + } + HeaderReadState::ReadingEncoding => { + let Some(byte) = port.read_byte()? else { + return Ok(None); + }; + let encoding = match Encoding::try_from(byte) { + Ok(encoding) => encoding, + Err(e) => { + self.reset(); + return Err(e); + } + }; + self.expected_len = Header::read_size(encoding); + self.encoding = Some(encoding); + self.escape_pending = false; + self.buf.clear(); + self.state = HeaderReadState::ReadingData; + } + HeaderReadState::ReadingData => { + while self.buf.len() < self.expected_len { + let Some(byte) = + read_byte_unescaped_stateful(port, &mut self.escape_pending)? + else { + return Ok(None); + }; + self.buf.push(byte).map_err(|_| Error::OutOfMemory)?; + } + + let Some(encoding) = self.encoding else { + self.reset(); + return Err(Error::MalformedHeader); + }; + + let header = match decode_header(encoding, &self.buf) { + Ok(header) => header, + Err(e) => { + self.reset(); + return Err(e); + } + }; + self.reset(); + return Ok(Some(header)); + } + } + } + } +} + +struct SliceReader<'a> { + buf: &'a [u8], + pos: usize, +} + +impl<'a> SliceReader<'a> { + fn new(buf: &'a [u8]) -> Self { + Self { buf, pos: 0 } + } + + fn consumed(&self) -> usize { + self.pos + } +} + +impl Read for SliceReader<'_> { + fn read(&mut self, buf: &mut [u8]) -> Result, Error> { + if self.pos >= self.buf.len() { + return Ok(None); + } + let n = min(buf.len(), self.buf.len() - self.pos); + buf[..n].copy_from_slice(&self.buf[self.pos..self.pos + n]); + self.pos += n; + u32::try_from(n).map(Some).map_err(|_| Error::OutOfMemory) + } + + fn read_byte(&mut self) -> Result, Error> { + if let Some(byte) = self.buf.get(self.pos) { + self.pos += 1; + Ok(Some(*byte)) + } else { + Ok(None) + } + } +} + +struct BufferWriter<'a, const N: usize> { + buf: &'a mut Buffer, +} + +impl<'a, const N: usize> BufferWriter<'a, N> { + fn new(buf: &'a mut Buffer) -> Self { + Self { buf } + } +} + +impl Write for BufferWriter<'_, N> { + fn write_all(&mut self, buf: &[u8]) -> Result, Error> { + if self.buf.len() + buf.len() > self.buf.capacity() { + return Ok(None); + } + self.buf + .extend_from_slice(buf) + .map_err(|_| Error::OutOfMemory)?; + Ok(Some(())) + } + + fn write_byte(&mut self, value: u8) -> Result, Error> { + if self.buf.len() == self.buf.capacity() { + return Ok(None); + } + self.buf.push(value).map_err(|_| Error::OutOfMemory)?; + Ok(Some(())) + } +} + +struct RxCrc { + calc16: crc::Crc16, + calc32: crc::Crc32, + buf: [u8; 4], + bytes_read: u8, + escape_pending: bool, +} + +impl RxCrc { + fn new() -> Self { + Self { + calc16: crc::Crc16::new(), + calc32: crc::Crc32::new(), + buf: [0; 4], + bytes_read: 0, + escape_pending: false, + } + } + + fn reset(&mut self) { + self.calc16 = crc::Crc16::new(); + self.calc32 = crc::Crc32::new(); + self.bytes_read = 0; + self.escape_pending = false; + } + + fn update(&mut self, byte: u8, encoding: Encoding) { + if encoding == Encoding::ZBIN32 { + self.calc32.update_byte(byte); + } else { + self.calc16.update_byte(byte); + } + } + + fn process( + &mut self, + port: &mut P, + encoding: Encoding, + ) -> Result, Error> { + let crc_len = if encoding == Encoding::ZBIN32 { 4 } else { 2 }; + let Some(byte) = read_byte_unescaped_stateful(port, &mut self.escape_pending)? else { + return Ok(None); + }; + self.buf[self.bytes_read as usize] = byte; + self.bytes_read += 1; + + if self.bytes_read < crc_len { + return Ok(None); + } + + if encoding == Encoding::ZBIN32 { + let expected = self.calc32.finalize().to_le_bytes(); + if expected != self.buf { + return Err(Error::UnexpectedCrc32); + } + } else { + let expected = self.calc16.finalize().to_be_bytes(); + if expected != [self.buf[0], self.buf[1]] { + return Err(Error::UnexpectedCrc16); + } + } + Ok(Some(())) + } +} + +use bitflags::bitflags; + +bitflags! { + /// ZFILE subpacket management options (ZF1 byte). + /// These tell the remote receiver how to handle the file. + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct ZfileManagementOption: u8 { + /// Clobber (overwrite) existing file. + const ZMCLOB = 0x01; + /// Append to existing file. + const ZMAPND = 0x02; + /// Resume interrupted transfer. + const ZMRESUM = 0x04; + /// Skip if file exists on destination. + const ZMSKNOLOC = 0x08; + /// Newer / longer / CRC match — skip if destination exists. + const ZMNEWL = 0x10; + /// Newer / longer / CRC match — overwrite. + const ZMNEWER = 0x20; + /// Compare CRC; skip if same. + const ZMCRC = 0x40; + /// Protect existing files (prompt on conflict). + const ZMPROT = 0x80; + } +} + +/// ZMODEM sender state machine. +pub struct Sender { + state: SendState, + file_name: String, + file_size: u32, + has_file: bool, + /// ZF0-ZF3 management / conversion options for ZFILE subpackets. + /// Bytes: [conversion, management, transport, extended_len]. + file_options: [u8; 4], + pending_request: Option, + frame_remaining: usize, + frame_needs_header: bool, + max_subpacket_size: usize, + max_subpackets_per_ack: usize, + buf: Buffer, + outgoing: Buffer, + outgoing_offset: usize, + header_reader: HeaderReader, + pending_event: Option, + finish_requested: bool, +} + +impl Sender { + /// Create a new sender instance. + /// + /// # Errors + /// + /// * [`Write`](crate::Error::Write) when the write I/O fails with the serial port + pub fn new() -> Result { + let mut sender = Self { + state: SendState::WaitReceiverInit, + file_name: String::new(), + file_size: 0, + has_file: false, + file_options: [1, 0, 0, 0], // ZF0=ZCBIN (binary mode) + pending_request: None, + frame_remaining: 0, + frame_needs_header: false, + max_subpacket_size: SUBPACKET_MAX_SIZE, + max_subpackets_per_ack: SUBPACKET_PER_ACK, + buf: Buffer::::new(), + outgoing: Buffer::::new(), + outgoing_offset: 0, + header_reader: HeaderReader::new(), + pending_event: None, + finish_requested: false, + }; + sender.queue_zrqinit()?; + Ok(sender) + } + + /// Starts sending a file with the provided metadata. + /// + /// # Errors + /// + /// * [`Write`](crate::Error::Write) when the write I/O fails with the serial port + pub fn start_file(&mut self, file_name: &[u8], file_size: u32) -> Result<(), Error> { + if matches!(self.state, SendState::Done | SendState::WaitFinish) + || (!matches!( + self.state, + SendState::WaitReceiverInit | SendState::ReadyForFile + )) + { + return Err(Error::Unsupported); + } + + self.file_name.clear(); + self.file_name + .extend_from_slice(file_name) + .map_err(|_| Error::OutOfMemory)?; + self.file_size = file_size; + self.has_file = true; + self.pending_request = None; + self.frame_remaining = 0; + self.frame_needs_header = false; + + if self.state == SendState::ReadyForFile { + if self.outgoing() { + return Err(Error::Unsupported); + } + self.queue_zfile()?; + self.state = SendState::WaitFilePos; + } + Ok(()) + } + + /// Set ZFILE subpacket management options (ZF1 byte). + /// + /// Call before `start_file()` to control how the remote receiver + /// handles the file. Common values: + /// - `ZfileManagementOption::ZMCLOB` — overwrite without prompting (default) + /// - `ZfileManagementOption::ZMSKNOLOC` — skip if file exists + /// - `ZfileManagementOption::ZMAPND` — append to existing file + pub fn set_file_options(&mut self, options: impl Into>) { + self.file_options[1] = options + .into() + .map(|o| o.bits()) + .unwrap_or(ZfileManagementOption::ZMCLOB.bits()); + } + + /// Requests to finish the session after the current file completes. + /// + /// # Errors + /// + /// * [`Write`](crate::Error::Write) when the write I/O fails with the serial port + pub fn finish_session(&mut self) -> Result<(), Error> { + self.finish_requested = true; + if self.state == SendState::ReadyForFile { + if self.outgoing() { + return Err(Error::Unsupported); + } + self.queue_zfin()?; + self.state = SendState::WaitFinish; + } + Ok(()) + } + + /// Returns a pending file data request, if any. + #[must_use] + pub fn poll_file(&self) -> Option { + self.pending_request + } + + /// Feeds a chunk of file data for the current request. + /// + /// # Errors + /// + /// * [`Write`](crate::Error::Write) when the write I/O fails with the serial port + pub fn feed_file(&mut self, data: &[u8]) -> Result<(), Error> { + if self.state != SendState::NeedFileData { + return Err(Error::Unsupported); + } + let Some(request) = self.pending_request else { + return Err(Error::Unsupported); + }; + + if data.is_empty() { + return Err(Error::UnexpectedEof); + } + if data.len() > request.len { + return Err(Error::UnexpectedEof); + } + let remaining = self.file_size.saturating_sub(request.offset) as usize; + if data.len() > remaining { + return Err(Error::UnexpectedEof); + } + if self.outgoing() { + return Err(Error::Unsupported); + } + + let offset = request.offset; + let next_offset = offset + .checked_add(u32::try_from(data.len()).map_err(|_| Error::OutOfMemory)?) + .ok_or(Error::OutOfMemory)?; + let remaining_after = self.file_size.saturating_sub(next_offset); + let max_len = min(self.max_subpacket_size, remaining_after as usize); + let is_last_in_frame = + self.frame_remaining <= 1 || data.len() < request.len || remaining_after == 0; + let kind = if is_last_in_frame { + SubpacketType::ZCRCW + } else { + SubpacketType::ZCRCG + }; + + self.queue_zdata(offset, data, kind, self.frame_needs_header)?; + self.frame_needs_header = false; + + if self.frame_remaining > 0 { + self.frame_remaining -= 1; + } + + if is_last_in_frame { + self.pending_request = None; + self.state = SendState::WaitFileAck; + self.frame_remaining = 0; + } else { + self.pending_request = Some(FileRequest { + offset: next_offset, + len: max_len, + }); + } + Ok(()) + } + + /// Feeds incoming wire data into the state machine. + /// + /// Returns the number of bytes consumed. + /// + /// # Errors + /// + /// * [`Read`](crate::Error::Read) when the read I/O fails with the serial port + /// * [`Write`](crate::Error::Write) when the write I/O fails with the serial port + /// * [`UnexpectedCrc16`](crate::Error::UnexpectedCrc16) or + /// [`UnexpectedCrc32`](crate::Error::UnexpectedCrc32) when corrupted data has been detected + pub fn feed_incoming(&mut self, input: &[u8]) -> Result { + let mut reader = SliceReader::new(input); + + loop { + if self.outgoing() || self.state == SendState::Done || self.pending_request.is_some() { + break; + } + + let before = reader.consumed(); + let header = match self.header_reader.read(&mut reader) { + Ok(Some(header)) => header, + Ok(None) => break, + Err(e) => { + let _ = self.queue_nak(); + return Err(e); + } + }; + + self.handle_header(header)?; + + if reader.consumed() == before || reader.consumed() == input.len() { + break; + } + } + + Ok(reader.consumed()) + } + + /// Returns pending outgoing bytes. + #[must_use] + pub fn drain_outgoing(&self) -> &[u8] { + &self.outgoing[self.outgoing_offset..] + } + + /// Advances the outgoing cursor by `n` bytes. + pub fn advance_outgoing(&mut self, n: usize) { + let remaining = self.outgoing.len().saturating_sub(self.outgoing_offset); + let n = min(n, remaining); + self.outgoing_offset += n; + if self.outgoing_offset >= self.outgoing.len() { + self.outgoing.clear(); + self.outgoing_offset = 0; + } + } + + /// Returns the next pending sender event. + pub fn poll_event(&mut self) -> Option { + self.pending_event.take() + } + + fn outgoing(&self) -> bool { + self.outgoing_offset < self.outgoing.len() + } + + fn queue_writer(&mut self) -> Result, Error> { + if self.outgoing() { + return Err(Error::Unsupported); + } + Ok(BufferWriter::new(&mut self.outgoing)) + } + + fn queue_zrqinit(&mut self) -> Result<(), Error> { + let mut writer = self.queue_writer()?; + if ZRQINIT_HEADER.write(&mut writer)?.is_none() { + return Err(Error::OutOfMemory); + } + Ok(()) + } + + fn queue_zfile(&mut self) -> Result<(), Error> { + let file_size = self.file_size; + let file_name = &self.file_name; + let mut writer = BufferWriter::new(&mut self.outgoing); + if write_zfile(&mut writer, &mut self.buf, file_name, file_size)?.is_none() { + return Err(Error::OutOfMemory); + } + Ok(()) + } + + fn queue_zdata( + &mut self, + offset: u32, + data: &[u8], + kind: SubpacketType, + include_header: bool, + ) -> Result<(), Error> { + let mut writer = self.queue_writer()?; + if include_header + && ZDATA_HEADER + .with_count(offset) + .write(&mut writer)? + .is_none() + { + return Err(Error::OutOfMemory); + } + if write_subpacket(&mut writer, Encoding::ZBIN32, kind, data)?.is_none() { + return Err(Error::OutOfMemory); + } + Ok(()) + } + + fn queue_zeof(&mut self, offset: u32) -> Result<(), Error> { + let mut writer = self.queue_writer()?; + if ZEOF_HEADER.with_count(offset).write(&mut writer)?.is_none() { + return Err(Error::OutOfMemory); + } + Ok(()) + } + + fn queue_zfin(&mut self) -> Result<(), Error> { + let mut writer = self.queue_writer()?; + if ZFIN_HEADER.write(&mut writer)?.is_none() { + return Err(Error::OutOfMemory); + } + Ok(()) + } + + fn queue_nak(&mut self) -> Result<(), Error> { + let mut writer = self.queue_writer()?; + if ZNAK_HEADER.write(&mut writer)?.is_none() { + return Err(Error::OutOfMemory); + } + Ok(()) + } + + fn queue_oo(&mut self) -> Result<(), Error> { + let mut writer = self.queue_writer()?; + if writer.write_byte(b'O')?.is_none() { + return Err(Error::OutOfMemory); + } + if writer.write_byte(b'O')?.is_none() { + return Err(Error::OutOfMemory); + } + Ok(()) + } + + fn handle_header(&mut self, header: Header) -> Result<(), Error> { + match header.frame() { + Frame::ZRINIT => self.on_zrinit(header), + Frame::ZRPOS | Frame::ZACK => self.on_zrpos(header.count()), + Frame::ZFIN => self.on_zfin(), + _ => { + if self.state == SendState::WaitReceiverInit { + self.queue_zrqinit()?; + } + Ok(()) + } + } + } + + fn on_zrinit(&mut self, header: Header) -> Result<(), Error> { + self.update_receiver_caps(header); + match self.state { + SendState::WaitReceiverInit => { + if self.has_file { + self.queue_zfile()?; + self.state = SendState::WaitFilePos; + } else { + self.state = SendState::ReadyForFile; + if self.finish_requested { + self.queue_zfin()?; + self.state = SendState::WaitFinish; + } + } + } + SendState::WaitFileDone => { + self.pending_event = Some(SenderEvent::FileComplete); + self.has_file = false; + if self.finish_requested { + self.queue_zfin()?; + self.state = SendState::WaitFinish; + } else { + self.state = SendState::ReadyForFile; + } + } + SendState::WaitFinish => { + self.queue_oo()?; + self.state = SendState::Done; + self.pending_event = Some(SenderEvent::SessionComplete); + } + _ => {} + } + Ok(()) + } + + fn update_receiver_caps(&mut self, header: Header) { + let flags = header.count().to_le_bytes(); + let rx_buf_size = u16::from_le_bytes([flags[0], flags[1]]) as usize; + let caps = flags[2] | flags[3]; + let can_ovio = (caps & Zrinit::CANOVIO.bits()) != 0; + + if rx_buf_size == 0 { + self.max_subpacket_size = SUBPACKET_MAX_SIZE; + self.max_subpackets_per_ack = if can_ovio { SUBPACKET_PER_ACK } else { 1 }; + return; + } + + self.max_subpacket_size = min(SUBPACKET_MAX_SIZE, rx_buf_size); + if !can_ovio { + self.max_subpackets_per_ack = 1; + return; + } + + let subpackets = rx_buf_size / self.max_subpacket_size; + self.max_subpackets_per_ack = if subpackets == 0 { 1 } else { subpackets }; + } + + fn on_zrpos(&mut self, offset: u32) -> Result<(), Error> { + match self.state { + SendState::WaitReceiverInit => { + self.queue_zrqinit()?; + } + SendState::WaitFilePos | SendState::WaitFileAck | SendState::NeedFileData => { + if offset >= self.file_size { + self.queue_zeof(offset)?; + self.state = SendState::WaitFileDone; + self.pending_request = None; + } else { + let remaining = (self.file_size - offset) as usize; + let max_subpackets = remaining.div_ceil(self.max_subpacket_size); + self.frame_remaining = min(self.max_subpackets_per_ack, max_subpackets); + self.frame_needs_header = true; + let len = min(self.max_subpacket_size, remaining); + self.pending_request = Some(FileRequest { offset, len }); + self.state = SendState::NeedFileData; + } + } + _ => {} + } + Ok(()) + } + + fn on_zfin(&mut self) -> Result<(), Error> { + if self.state == SendState::WaitFinish { + self.queue_oo()?; + self.state = SendState::Done; + self.pending_event = Some(SenderEvent::SessionComplete); + } + Ok(()) + } +} + +/// ZMODEM receiver state machine. +pub struct Receiver { + state: RecvState, + count: u32, + file_name: String, + file_size: u32, + buf: Buffer, + buf_write_offset: usize, + data_encoding: Encoding, + header_reader: HeaderReader, + subpacket_state: SubpacketState, + subpacket_escape_pending: bool, + crc: RxCrc, + outgoing: Buffer, + outgoing_offset: usize, + pending_events: [Option; RECEIVER_EVENT_QUEUE_CAP], + pending_event_head: usize, + pending_event_len: usize, +} + +impl Receiver { + /// Create a new receiver instance. + /// + /// # Errors + /// + /// * [`Write`](crate::Error::Write) when the write I/O fails with the serial port + pub fn new() -> Result { + let mut receiver = Self { + state: RecvState::SessionBegin, + count: 0, + file_name: String::new(), + file_size: 0, + buf: Buffer::::new(), + buf_write_offset: 0, + data_encoding: Encoding::ZBIN, + header_reader: HeaderReader::new(), + subpacket_state: SubpacketState::Idle, + subpacket_escape_pending: false, + crc: RxCrc::new(), + outgoing: Buffer::::new(), + outgoing_offset: 0, + pending_events: [None; RECEIVER_EVENT_QUEUE_CAP], + pending_event_head: 0, + pending_event_len: 0, + }; + receiver.queue_zrinit()?; + Ok(receiver) + } + + /// Feeds incoming wire data into the state machine. + /// + /// Returns the number of bytes consumed. + /// + /// # Errors + /// + /// * [`Read`](crate::Error::Read) when the read I/O fails with the serial port + /// * [`Write`](crate::Error::Write) when the write I/O fails with the serial port + /// * [`UnexpectedCrc16`](crate::Error::UnexpectedCrc16) or + /// [`UnexpectedCrc32`](crate::Error::UnexpectedCrc32) when corrupted data has been detected + pub fn feed_incoming(&mut self, input: &[u8]) -> Result { + let mut reader = SliceReader::new(input); + + loop { + if self.outgoing() || !self.drain_file().is_empty() || self.pending_events_full() { + break; + } + + let before = reader.consumed(); + + if matches!( + self.state, + RecvState::FileReadingSubpacket | RecvState::FileReadingMetadata + ) { + match self.process_subpacket(&mut reader) { + Ok(Some(())) => { + if self.outgoing() + || !self.drain_file().is_empty() + || self.pending_events_full() + { + break; + } + if reader.consumed() == before { + break; + } + continue; + } + Ok(None) => break, + Err(e) => return Err(e), + } + } + + let header = match self.header_reader.read(&mut reader) { + Ok(Some(header)) => header, + Ok(None) => break, + Err(e) => { + let _ = self.queue_nak(); + return Err(e); + } + }; + + self.handle_header(header)?; + + if self.pending_events_full() { + break; + } + + if reader.consumed() == before || reader.consumed() == input.len() { + break; + } + } + + Ok(reader.consumed()) + } + + /// Returns pending outgoing bytes. + #[must_use] + pub fn drain_outgoing(&self) -> &[u8] { + &self.outgoing[self.outgoing_offset..] + } + + /// Advances the outgoing cursor by `n` bytes. + pub fn advance_outgoing(&mut self, n: usize) { + let remaining = self.outgoing.len().saturating_sub(self.outgoing_offset); + let n = min(n, remaining); + self.outgoing_offset += n; + if self.outgoing_offset >= self.outgoing.len() { + self.outgoing.clear(); + self.outgoing_offset = 0; + } + } + + /// Returns pending file data bytes. + #[must_use] + pub fn drain_file(&self) -> &[u8] { + match self.subpacket_state { + SubpacketState::Writing(_) => &self.buf[self.buf_write_offset..], + _ => &[], + } + } + + /// Advances the file output cursor by `n` bytes. + /// + /// # Errors + /// + /// * [`Write`](crate::Error::Write) when the write I/O fails with the serial port + pub fn advance_file(&mut self, n: usize) -> Result<(), Error> { + let SubpacketState::Writing(packet) = self.subpacket_state else { + return Ok(()); + }; + + let remaining = self.buf.len().saturating_sub(self.buf_write_offset); + let n = min(n, remaining); + self.buf_write_offset = self + .buf_write_offset + .checked_add(n) + .ok_or(Error::OutOfMemory)?; + + if self.buf_write_offset < self.buf.len() { + return Ok(()); + } + + self.finish_subpacket(packet) + } + + /// Returns the next pending receiver event. + pub fn poll_event(&mut self) -> Option { + self.pop_event() + } + + #[must_use] + pub fn file_name(&self) -> &[u8] { + &self.file_name + } + + #[must_use] + pub fn file_size(&self) -> u32 { + self.file_size + } + + fn outgoing(&self) -> bool { + self.outgoing_offset < self.outgoing.len() + } + + fn pending_events_full(&self) -> bool { + self.pending_event_len >= RECEIVER_EVENT_QUEUE_CAP + } + + fn push_event(&mut self, event: ReceiverEvent) -> Result<(), Error> { + if self.pending_events_full() { + return Err(Error::OutOfMemory); + } + let index = (self.pending_event_head + self.pending_event_len) % RECEIVER_EVENT_QUEUE_CAP; + self.pending_events[index] = Some(event); + self.pending_event_len += 1; + Ok(()) + } + + fn pop_event(&mut self) -> Option { + if self.pending_event_len == 0 { + return None; + } + let event = self.pending_events[self.pending_event_head].take(); + self.pending_event_head = (self.pending_event_head + 1) % RECEIVER_EVENT_QUEUE_CAP; + self.pending_event_len -= 1; + event + } + + fn queue_writer(&mut self) -> Result, Error> { + if self.outgoing() { + return Err(Error::Unsupported); + } + Ok(BufferWriter::new(&mut self.outgoing)) + } + + fn queue_zrinit(&mut self) -> Result<(), Error> { + let mut writer = self.queue_writer()?; + if write_zrinit(&mut writer)?.is_none() { + return Err(Error::OutOfMemory); + } + Ok(()) + } + + fn queue_zrpos(&mut self, count: u32) -> Result<(), Error> { + let mut writer = self.queue_writer()?; + if ZRPOS_HEADER.with_count(count).write(&mut writer)?.is_none() { + return Err(Error::OutOfMemory); + } + Ok(()) + } + + fn queue_zack(&mut self) -> Result<(), Error> { + let count = self.count; + let mut writer = self.queue_writer()?; + if ZACK_HEADER.with_count(count).write(&mut writer)?.is_none() { + return Err(Error::OutOfMemory); + } + Ok(()) + } + + fn queue_zfin(&mut self) -> Result<(), Error> { + let mut writer = self.queue_writer()?; + if ZFIN_HEADER.write(&mut writer)?.is_none() { + return Err(Error::OutOfMemory); + } + Ok(()) + } + + fn queue_nak(&mut self) -> Result<(), Error> { + let mut writer = self.queue_writer()?; + if ZNAK_HEADER.write(&mut writer)?.is_none() { + return Err(Error::OutOfMemory); + } + Ok(()) + } + + fn handle_header(&mut self, header: Header) -> Result<(), Error> { + match header.frame() { + Frame::ZRQINIT => { + if self.state == RecvState::SessionBegin { + self.queue_zrinit()?; + } + } + Frame::ZFILE => { + if self.state == RecvState::SessionBegin || self.state == RecvState::FileBegin { + self.data_encoding = header.encoding(); + self.state = RecvState::FileReadingMetadata; + self.subpacket_state = SubpacketState::Reading; + self.subpacket_escape_pending = false; + self.crc.reset(); + self.buf.clear(); + self.buf_write_offset = 0; + } + } + Frame::ZDATA => { + if self.state == RecvState::SessionBegin { + self.queue_zrinit()?; + } else if self.state == RecvState::FileBegin + || self.state == RecvState::FileWaitingSubpacket + { + if header.count() != self.count { + self.queue_zrpos(self.count)?; + return Ok(()); + } + self.data_encoding = header.encoding(); + self.state = RecvState::FileReadingSubpacket; + self.subpacket_state = SubpacketState::Reading; + self.subpacket_escape_pending = false; + self.crc.reset(); + self.buf.clear(); + self.buf_write_offset = 0; + } + } + Frame::ZEOF => { + if self.state == RecvState::FileWaitingSubpacket && header.count() == self.count { + self.queue_zrinit()?; + self.state = RecvState::FileBegin; + self.push_event(ReceiverEvent::FileComplete)?; + } + } + Frame::ZFIN => { + if self.state == RecvState::FileWaitingSubpacket + || self.state == RecvState::FileBegin + { + self.queue_zfin()?; + self.state = RecvState::SessionEnd; + self.push_event(ReceiverEvent::SessionComplete)?; + } + } + _ => {} + } + Ok(()) + } + + /// Parses the file info buffer after a ZFILE subpacket is received. + fn parse_zfile_buf(&mut self) -> Result<(), Error> { + let payload = &self.buf; + let mut fields = payload.split(|&b| b == b'\0'); + + let file_name_bytes = fields.next().ok_or(Error::MalformedFileName)?; + if file_name_bytes.is_empty() { + return Err(Error::MalformedFileName); + } + + self.file_name.clear(); + self.file_name + .extend_from_slice(file_name_bytes) + .map_err(|_| Error::OutOfMemory)?; + + if let Some(size_str_bytes) = fields.next() { + let size_field_bytes = size_str_bytes + .split(|&b| b == b' ') + .next() + .unwrap_or_default(); + + self.file_size = parse_file_size(size_field_bytes)?; + } else { + self.file_size = 0; + } + + self.count = 0; + Ok(()) + } + + /// Handles reading a single byte for the `SubpacketState::Reading` state. + fn receive_subpacket_data_byte

(&mut self, port: &mut P) -> Result, Error> + where + P: Read + ?Sized, + { + let handle_followup = |this: &mut Self, byte: u8| -> Result, Error> { + if let Ok(packet) = SubpacketType::try_from(byte) { + this.crc.update(packet as u8, this.data_encoding); + this.subpacket_state = SubpacketState::Crc(packet); + } else { + let unescaped = zdle::UNZDLE_TABLE[byte as usize]; + this.buf.push(unescaped).map_err(|_| Error::OutOfMemory)?; + this.crc.update(unescaped, this.data_encoding); + } + Ok(Some(())) + }; + + if self.subpacket_escape_pending { + let Some(byte) = port.read_byte()? else { + return Ok(None); + }; + self.subpacket_escape_pending = false; + return handle_followup(self, byte); + } + + let Some(byte) = port.read_byte()? else { + return Ok(None); + }; + if byte == ZDLE { + let Some(next) = port.read_byte()? else { + self.subpacket_escape_pending = true; + return Ok(None); + }; + return handle_followup(self, next); + } + + self.buf.push(byte).map_err(|_| Error::OutOfMemory)?; + self.crc.update(byte, self.data_encoding); + Ok(Some(())) + } + + fn process_subpacket

(&mut self, port: &mut P) -> Result, Error> + where + P: Read + ?Sized, + { + match self.subpacket_state { + SubpacketState::Reading => self.receive_subpacket_data_byte(port), + SubpacketState::Crc(packet) => { + if self.crc.process(port, self.data_encoding)?.is_none() { + return Ok(None); + } + + if self.state == RecvState::FileReadingMetadata { + self.parse_zfile_buf()?; + self.buf.clear(); + self.buf_write_offset = 0; + self.crc.reset(); + self.subpacket_escape_pending = false; + + self.queue_zrpos(0)?; + + self.state = RecvState::FileBegin; + self.subpacket_state = SubpacketState::Idle; + self.push_event(ReceiverEvent::FileStart)?; + } else { + self.subpacket_state = SubpacketState::Writing(packet); + self.buf_write_offset = 0; + if self.buf.is_empty() { + self.finish_subpacket(packet)?; + } + } + Ok(Some(())) + } + SubpacketState::Writing(_) => Ok(Some(())), + SubpacketState::Idle => Err(Error::Unsupported), + } + } + + fn finish_subpacket(&mut self, packet: SubpacketType) -> Result<(), Error> { + self.count += u32::try_from(self.buf.len()).map_err(|_| Error::OutOfMemory)?; + self.buf.clear(); + self.buf_write_offset = 0; + self.crc.reset(); + + match packet { + SubpacketType::ZCRCW => { + self.queue_zack()?; + self.state = RecvState::FileWaitingSubpacket; + self.subpacket_state = SubpacketState::Idle; + self.subpacket_escape_pending = false; + } + SubpacketType::ZCRCQ => { + self.queue_zack()?; + self.subpacket_state = SubpacketState::Reading; + self.subpacket_escape_pending = false; + } + SubpacketType::ZCRCG => { + self.subpacket_state = SubpacketState::Reading; + self.subpacket_escape_pending = false; + } + SubpacketType::ZCRCE => { + self.state = RecvState::FileWaitingSubpacket; + self.subpacket_state = SubpacketState::Idle; + self.subpacket_escape_pending = false; + } + } + Ok(()) + } +} + +fn read_byte_unescaped_stateful

(port: &mut P, pending: &mut bool) -> Result, Error> +where + P: Read + ?Sized, +{ + if *pending { + let Some(b) = port.read_byte()? else { + return Ok(None); + }; + *pending = false; + return Ok(Some(zdle::UNZDLE_TABLE[b as usize])); + } + + let Some(b) = port.read_byte()? else { + return Ok(None); + }; + if b == ZDLE { + let Some(next) = port.read_byte()? else { + *pending = true; + return Ok(None); + }; + return Ok(Some(zdle::UNZDLE_TABLE[next as usize])); + } + + Ok(Some(b)) +} + +fn decode_header(encoding: Encoding, data: &[u8]) -> Result { + let mut out: Buffer = Buffer::new(); + if encoding == Encoding::ZHEX { + if data.len() % 2 != 0 { + return Err(Error::MalformedHeader); + } + let mut out_bytes = [0u8; HEADER_SIZE / 2]; + let out_len = data.len() / 2; + let out_buf = out_bytes.get_mut(..out_len).ok_or(Error::UnexpectedEof)?; + hex::decode_to_slice(data, out_buf).map_err(|_| Error::MalformedHeader)?; + out.extend_from_slice(out_buf) + .map_err(|_| Error::OutOfMemory)?; + } else { + out.extend_from_slice(data) + .map_err(|_| Error::OutOfMemory)?; + } + + let crc_len = if encoding == Encoding::ZBIN32 { 4 } else { 2 }; + if out.len() < HEADER_PAYLOAD_SIZE + crc_len { + return Err(Error::MalformedHeader); + } + let (payload, crc_bytes) = out.split_at(HEADER_PAYLOAD_SIZE); + if encoding == Encoding::ZBIN32 { + let expected_crc = crc::crc32_iso_hdlc(payload).to_le_bytes(); + if crc_bytes != &expected_crc[..crc_len] { + return Err(Error::UnexpectedCrc32); + } + } else { + let expected_crc = crc::crc16_xmodem(payload).to_be_bytes(); + if crc_bytes != &expected_crc[..crc_len] { + return Err(Error::UnexpectedCrc16); + } + } + + let frame = Frame::try_from(payload[0])?; + let mut flags = [0u8; 4]; + flags.copy_from_slice(&payload[1..=4]); + Ok(Header::new(encoding, frame, &flags)) +} + +/// Writes ZRINIT +fn write_zrinit

(port: &mut P) -> Result, Error> +where + P: Write + ?Sized, +{ + let zrinit = Zrinit::CANFDX | Zrinit::CANFC32; + let buffer_size = u16::try_from(SUBPACKET_MAX_SIZE) + .map_err(|_| Error::Unsupported)? + .to_le_bytes(); + Header::new( + Encoding::ZHEX, + Frame::ZRINIT, + &[buffer_size[0], buffer_size[1], 0, zrinit.bits()], + ) + .write(port) +} + +/// Parses a u32 from a slice of ASCII decimal bytes. +fn parse_file_size(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Ok(0); + } + + let mut result: u32 = 0; + for &byte in bytes { + let digit = match byte { + b'0'..=b'9' => u32::from(byte - b'0'), + _ => return Err(Error::MalformedFileSize), + }; + result = result + .checked_mul(10) + .and_then(|r| r.checked_add(digit)) + .ok_or(Error::MalformedFileSize)?; + } + Ok(result) +} + +/// Write ZRFILE +fn write_zfile

( + port: &mut P, + buf: &mut Buffer, + name: &[u8], + size: u32, +) -> Result, Error> +where + P: Write + ?Sized, +{ + buf.clear(); + buf.extend_from_slice(name) + .map_err(|_| Error::OutOfMemory)?; + buf.push(b'\0').map_err(|_| Error::OutOfMemory)?; + + write!(buf, "{size}\0").map_err(|_| Error::OutOfMemory)?; + + if Header::new(Encoding::ZBIN32, Frame::ZFILE, &[0; 4]) + .write(port)? + .is_none() + { + return Ok(None); + } + write_subpacket(port, Encoding::ZBIN32, SubpacketType::ZCRCW, buf) +} + +/// Writes a subpacket. +/// +/// # Errors +/// +/// This function returns `Error::Read` or `Error::Write` on an I/O error, or +/// `Error::Unsupported` if `ZHEX` encoding is requested. +fn write_subpacket

( + port: &mut P, + encoding: Encoding, + kind: SubpacketType, + data: &[u8], +) -> Result, Error> +where + P: Write + ?Sized, +{ + let kind = kind as u8; + if write_slice_escaped(port, data)?.is_none() { + return Ok(None); + } + if port.write_byte(ZDLE)?.is_none() { + return Ok(None); + } + if port.write_byte(kind)?.is_none() { + return Ok(None); + } + match encoding { + Encoding::ZBIN32 => { + let mut crc = crc::Crc32::new(); + crc.update(data); + crc.update_byte(kind); + let buf = crc.finalize().to_le_bytes(); + write_slice_escaped(port, &buf) + } + Encoding::ZBIN => { + let mut crc = crc::Crc16::new(); + crc.update(data); + crc.update_byte(kind); + let buf = crc.finalize().to_be_bytes(); + write_slice_escaped(port, &buf) + } + Encoding::ZHEX => Err(Error::Unsupported), + } +} diff --git a/src-tauri/vendor/zmodem2/src/zdle.rs b/src-tauri/vendor/zmodem2/src/zdle.rs new file mode 100644 index 00000000..1013ba09 --- /dev/null +++ b/src-tauri/vendor/zmodem2/src/zdle.rs @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// Copyright (c) 2017-2020 Alexey Arbuzov +// Copyright (c) 2023-2025 Jarkko Sakkinen + +/// ZMODEM escape table, a constant array for escaping characters. +pub const ZDLE_TABLE: [u8; 0x100] = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x4d, 0x0e, 0x0f, + 0x50, 0x51, 0x12, 0x53, 0x14, 0x15, 0x16, 0x17, 0x58, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, + 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, + 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, + 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, + 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f, + 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, + 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7c, 0x7d, 0x7e, 0x6c, + 0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x8b, 0x8c, 0xcd, 0x8e, 0x8f, + 0xd0, 0xd1, 0x92, 0xd3, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0x9b, 0x9c, 0x9d, 0x9e, 0x9f, + 0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae, 0xaf, + 0xb0, 0xb1, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xbb, 0xbc, 0xbd, 0xbe, 0xbf, + 0xc0, 0xc1, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xcb, 0xcc, 0xcd, 0xce, 0xcf, + 0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xdb, 0xdc, 0xdd, 0xde, 0xdf, + 0xe0, 0xe1, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xeb, 0xec, 0xed, 0xee, 0xef, + 0xf0, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0xfb, 0xfc, 0xfd, 0xfe, 0x6d, +]; + +/// ZMODEM unescape table, a constant array for converting escaped characters back. +pub const UNZDLE_TABLE: [u8; 0x100] = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, + 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, + 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, + 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x6b, 0x7f, 0xff, 0x6e, 0x6f, + 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7c, 0x7d, 0x7e, 0x7f, + 0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x8b, 0x8c, 0x8d, 0x8e, 0x8f, + 0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0x9b, 0x9c, 0x9d, 0x9e, 0x9f, + 0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae, 0xaf, + 0xb0, 0xb1, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xbb, 0xbc, 0xbd, 0xbe, 0xbf, + 0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x8b, 0x8c, 0x8d, 0x8e, 0x8f, + 0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0x9b, 0x9c, 0x9d, 0x9e, 0x9f, + 0xe0, 0xe1, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xeb, 0xec, 0xed, 0xee, 0xef, + 0xf0, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0xfb, 0xfc, 0xfd, 0xfe, 0xff, +]; diff --git a/src-tauri/vendor/zmodem2/tests/integration.rs b/src-tauri/vendor/zmodem2/tests/integration.rs new file mode 100644 index 00000000..39552675 --- /dev/null +++ b/src-tauri/vendor/zmodem2/tests/integration.rs @@ -0,0 +1,468 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// Copyright (c) 2017-2020 Alexey Arbuzov +// Copyright (c) 2023-2026 Jarkko Sakkinen + +use nix::fcntl::{self, OFlag}; +use std::cmp::{max, min}; +use std::collections::HashMap; +use std::fs::File; +use std::io::{BufRead, BufReader, Read, Result, Seek, Write}; +use std::os::unix::io::{AsRawFd, RawFd}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::thread::sleep; +use std::time::{Duration, Instant}; +use tempfile::TempDir; + +const FILE_COUNT: usize = 10; +const FILE_SIZE: usize = 50 * 1024; +const RATE_BPS: u32 = 115200; + +const NAME_PREFIX: &[&str] = &[ + "Laser", + "Neon", + "Chrome", + "Cosmic", + "Turbo", + "Starlight", + "Future", +]; +const NAME_POSTFIX: &[&str] = &[ + "Rider", "Funk", "Dream", "Grid", "System", "Dancer", "Midnight", +]; +const EXTENSIONS: &[&str] = &["dat", "BIN", "log", "TMP", "txt"]; + +struct MockPort { + r: R, + w: W, + bits_per_second: u32, + next_byte_due: Instant, +} + +impl MockPort { + pub fn new(r: R, w: W, bits_per_second: u32) -> Self { + MockPort { + r, + w, + bits_per_second, + next_byte_due: Instant::now(), + } + } + + fn throttle(&mut self, bytes_transferred: usize) { + if self.bits_per_second == 0 { + return; + } + let bits_transferred = (bytes_transferred * 10) as f64; + let duration_needed = + Duration::from_secs_f64(bits_transferred / f64::from(self.bits_per_second)); + let now = Instant::now(); + if self.next_byte_due > now { + sleep(self.next_byte_due - now); + } + self.next_byte_due = max(now, self.next_byte_due) + duration_needed; + } +} + +impl Read for MockPort { + fn read(&mut self, buf: &mut [u8]) -> Result { + let bytes_read = self.r.read(buf)?; + if bytes_read > 0 { + self.throttle(bytes_read); + } + Ok(bytes_read) + } +} + +impl Write for MockPort { + fn write(&mut self, buf: &[u8]) -> Result { + let bytes_written = self.w.write(buf)?; + if bytes_written > 0 { + self.throttle(bytes_written); + } + Ok(bytes_written) + } + + fn flush(&mut self) -> Result<()> { + self.w.flush() + } +} + +/// Creates a temporary file with a predictable, patterned content. +fn create_test_file(path: &Path, size_bytes: usize) { + let mut file = File::create(path).unwrap(); + let mut buffer = [0u8; 1024]; + for (i, byte) in buffer.iter_mut().enumerate() { + *byte = (i % 256) as u8; + } + + let mut bytes_written = 0; + while bytes_written < size_bytes { + let to_write = min(buffer.len(), size_bytes - bytes_written); + file.write_all(&buffer[..to_write]).unwrap(); + bytes_written += to_write; + } +} + +/// Asserts that two files have the same size and content. +fn assert_files_equal(path1: &Path, path2: &Path) { + let meta1 = path1.metadata().unwrap(); + let meta2 = path2.metadata().unwrap(); + assert_eq!(meta1.len(), meta2.len(), "File sizes do not match"); + + let mut f1 = BufReader::new(File::open(path1).unwrap()); + let mut f2 = BufReader::new(File::open(path2).unwrap()); + + loop { + let buf1 = f1.fill_buf().unwrap(); + let buf2 = f2.fill_buf().unwrap(); + + if buf1.is_empty() && buf2.is_empty() { + break; + } + + assert_eq!(buf1, buf2, "File contents do not match"); + + let len1 = buf1.len(); + let len2 = buf2.len(); + f1.consume(len1); + f2.consume(len2); + } +} + +/// Manages a set of temporary files for a test run. +struct TestFiles { + #[allow(dead_code)] + dir: TempDir, + paths: Vec, +} + +impl TestFiles { + fn new() -> Self { + let dir = tempfile::Builder::new() + .prefix("zmodem_test_src_") + .tempdir() + .unwrap(); + let mut paths = Vec::new(); + for i in 0..FILE_COUNT { + let prefix = NAME_PREFIX[i % NAME_PREFIX.len()]; + let postfix = NAME_POSTFIX[i % NAME_POSTFIX.len()]; + let ext = EXTENSIONS[i % EXTENSIONS.len()]; + let filename = format!("{prefix}{postfix}_{i}.{ext}"); + let path = dir.path().join(filename); + + create_test_file(&path, FILE_SIZE); + paths.push(path); + } + Self { dir, paths } + } +} + +/// Sets the O_NONBLOCK flag on a raw file descriptor. +fn set_nonblocking(fd: RawFd) { + let flags = fcntl::fcntl(fd, fcntl::FcntlArg::F_GETFL).unwrap(); + let mut nonblocking_flags = OFlag::from_bits_truncate(flags); + nonblocking_flags.insert(OFlag::O_NONBLOCK); + fcntl::fcntl(fd, fcntl::FcntlArg::F_SETFL(nonblocking_flags)).unwrap(); +} + +/// Helper to set up a non-blocking `sz` process. +fn setup_sz(test_files: &TestFiles) -> (Child, MockPort) { + let mut sz_process = Command::new(env!("ZMODEM_SZ_BIN")) + .args(&test_files.paths) + .stdout(Stdio::piped()) + .stdin(Stdio::piped()) + .spawn() + .unwrap(); + + let stdin = sz_process.stdin.take().unwrap(); + let stdout = sz_process.stdout.take().unwrap(); + + set_nonblocking(stdin.as_raw_fd()); + set_nonblocking(stdout.as_raw_fd()); + + let port = MockPort::new(stdout, stdin, RATE_BPS); + (sz_process, port) +} + +/// Helper to set up a non-blocking `rz` process. +fn setup_rz(dest_dir: &TempDir) -> (Child, MockPort) { + let mut rz_process: Child = Command::new(env!("ZMODEM_RZ_BIN")) + .stdout(Stdio::piped()) + .stdin(Stdio::piped()) + .current_dir(dest_dir.path()) + .spawn() + .unwrap(); + + let stdin = rz_process.stdin.take().unwrap(); + let stdout = rz_process.stdout.take().unwrap(); + + set_nonblocking(stdin.as_raw_fd()); + set_nonblocking(stdout.as_raw_fd()); + + let port = MockPort::new(stdout, stdin, RATE_BPS); + (rz_process, port) +} + +struct MockBlockingFile { + file: File, + counter: usize, +} + +impl Write for MockBlockingFile { + fn write(&mut self, buf: &[u8]) -> Result { + self.counter += 1; + if self.counter % 10 == 0 { + return Err(std::io::Error::from(std::io::ErrorKind::WouldBlock)); + } + self.file.write(buf) + } + + fn flush(&mut self) -> Result<()> { + self.file.flush() + } +} + +#[test] +#[cfg(has_lrzsz)] +fn test_batch_from_sz() { + let test_files = TestFiles::new(); + let dest_dir = tempfile::Builder::new() + .prefix("zmodem_test_dest_") + .tempdir() + .unwrap(); + + let (mut sz_process, mut port) = setup_sz(&test_files); + let mut receiver = zmodem2::Receiver::new().unwrap(); + let mut open_files: HashMap, MockBlockingFile> = HashMap::new(); + let mut sink = std::io::sink(); + let mut current_file_name_bytes: Vec = Vec::new(); + let mut wire_buf = [0u8; 4096]; + let mut input_buf: Vec = Vec::new(); + let mut input_offset: usize = 0; + let mut session_done = false; + + while !session_done || !receiver.drain_outgoing().is_empty() { + let mut progressed = false; + + if !receiver.drain_outgoing().is_empty() { + match port.write(receiver.drain_outgoing()) { + Ok(0) => {} + Ok(n) => { + receiver.advance_outgoing(n); + progressed = true; + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {} + Err(e) => panic!("wire write failed: {e}"), + } + } + + if !receiver.drain_file().is_empty() { + let file_writer: &mut dyn Write = open_files + .get_mut(¤t_file_name_bytes) + .map(|f| f as &mut dyn Write) + .unwrap_or(&mut sink); + + match file_writer.write(receiver.drain_file()) { + Ok(0) => {} + Ok(n) => { + receiver.advance_file(n).unwrap(); + progressed = true; + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {} + Err(e) => panic!("file write failed: {e}"), + } + } + + match port.read(&mut wire_buf) { + Ok(0) => {} + Ok(n) => { + input_buf.extend_from_slice(&wire_buf[..n]); + progressed = true; + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {} + Err(e) => panic!("wire read failed: {e}"), + } + + if receiver.drain_outgoing().is_empty() + && receiver.drain_file().is_empty() + && input_offset < input_buf.len() + { + let consumed = receiver.feed_incoming(&input_buf[input_offset..]).unwrap(); + if consumed > 0 { + input_offset += consumed; + progressed = true; + if input_offset == input_buf.len() { + input_buf.clear(); + input_offset = 0; + } else if input_offset > 4096 { + input_buf.drain(..input_offset); + input_offset = 0; + } + } + } + + while let Some(event) = receiver.poll_event() { + match event { + zmodem2::ReceiverEvent::FileStart => { + let name = receiver.file_name(); + if name != current_file_name_bytes.as_slice() { + let filename_str = std::str::from_utf8(name).unwrap(); + let filename = Path::new(filename_str) + .file_name() + .unwrap() + .to_str() + .unwrap(); + let file_path = dest_dir.path().join(filename); + let file = File::create(file_path).unwrap(); + open_files.insert(name.to_vec(), MockBlockingFile { file, counter: 0 }); + current_file_name_bytes = name.to_vec(); + } + } + zmodem2::ReceiverEvent::FileComplete => {} + zmodem2::ReceiverEvent::SessionComplete => { + session_done = true; + } + } + } + + if !progressed { + sleep(Duration::from_millis(5)); + } + } + + sz_process.wait().unwrap(); + for path in &test_files.paths { + let filename = path.file_name().unwrap(); + let received_path = dest_dir.path().join(filename); + assert!( + received_path.exists(), + "File '{}' was not received", + received_path.display() + ); + assert_files_equal(path, &received_path); + } +} + +#[test] +#[cfg(has_lrzsz)] +fn test_batch_to_rz() { + let test_files = TestFiles::new(); + let dest_dir = tempfile::Builder::new() + .prefix("zmodem_test_dest_") + .tempdir() + .unwrap(); + + let (mut rz_process, mut port) = setup_rz(&dest_dir); + + let mut open_files: HashMap = HashMap::new(); + for path in &test_files.paths { + let filename = path.file_name().unwrap().to_str().unwrap().to_string(); + let file = File::open(path).unwrap(); + open_files.insert(filename, file); + } + + let mut file_iter = test_files.paths.iter(); + + let first_path = file_iter.next().expect("No test files found"); + let first_filename = first_path.file_name().unwrap().to_str().unwrap(); + let first_size = first_path.metadata().unwrap().len() as u32; + let mut sender = zmodem2::Sender::new().unwrap(); + sender + .start_file(first_filename.as_bytes(), first_size) + .unwrap(); + let mut current_filename = first_filename.to_string(); + let mut wire_buf = [0u8; 4096]; + let mut input_buf: Vec = Vec::new(); + let mut input_offset: usize = 0; + let mut file_buf = [0u8; 1024]; + let mut session_done = false; + + while !session_done || !sender.drain_outgoing().is_empty() { + let mut progressed = false; + + if !sender.drain_outgoing().is_empty() { + match port.write(sender.drain_outgoing()) { + Ok(0) => {} + Ok(n) => { + sender.advance_outgoing(n); + progressed = true; + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {} + Err(e) => panic!("wire write failed: {e}"), + } + } + + if let Some(request) = sender.poll_file() { + let file = open_files + .get_mut(¤t_filename) + .expect("File not found in map"); + file.seek(std::io::SeekFrom::Start(u64::from(request.offset))) + .unwrap(); + let n = file.read(&mut file_buf[..request.len]).unwrap(); + sender.feed_file(&file_buf[..n]).unwrap(); + progressed = true; + } + + match port.read(&mut wire_buf) { + Ok(0) => {} + Ok(n) => { + input_buf.extend_from_slice(&wire_buf[..n]); + progressed = true; + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {} + Err(e) => panic!("wire read failed: {e}"), + } + + if sender.drain_outgoing().is_empty() && input_offset < input_buf.len() { + let consumed = sender.feed_incoming(&input_buf[input_offset..]).unwrap(); + if consumed > 0 { + input_offset += consumed; + progressed = true; + if input_offset == input_buf.len() { + input_buf.clear(); + input_offset = 0; + } else if input_offset > 4096 { + input_buf.drain(..input_offset); + input_offset = 0; + } + } + } + + if let Some(event) = sender.poll_event() { + match event { + zmodem2::SenderEvent::FileComplete => { + if let Some(next_path) = file_iter.next() { + let next_filename = next_path.file_name().unwrap().to_str().unwrap(); + let next_size = next_path.metadata().unwrap().len() as u32; + sender + .start_file(next_filename.as_bytes(), next_size) + .unwrap(); + current_filename = next_filename.to_string(); + } else { + sender.finish_session().unwrap(); + } + } + zmodem2::SenderEvent::SessionComplete => { + session_done = true; + } + } + } + + if !progressed { + sleep(Duration::from_millis(5)); + } + } + + rz_process.wait().unwrap(); + for path in &test_files.paths { + let filename = path.file_name().unwrap(); + let received_path = dest_dir.path().join(filename); + assert!( + received_path.exists(), + "File '{}' was not sent", + received_path.display() + ); + assert_files_equal(path, &received_path); + } +} diff --git a/src-tauri/vendor/zmodem2/tests/protocol.rs b/src-tauri/vendor/zmodem2/tests/protocol.rs new file mode 100644 index 00000000..fdb6b84f --- /dev/null +++ b/src-tauri/vendor/zmodem2/tests/protocol.rs @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// Copyright (c) 2017-2020 Alexey Arbuzov +// Copyright (c) 2023-2026 Jarkko Sakkinen + +use rstest::rstest; +use zmodem2::{Encoding, Frame, Header, Receiver, ReceiverEvent, Sender, XON, ZDLE, ZPAD}; + +#[rstest] +#[case(Encoding::ZBIN, Frame::ZRQINIT, &[0; 4], &[ZPAD, ZDLE, Encoding::ZBIN as u8, 0, 0, 0, 0, 0, 0, 0])] +#[case(Encoding::ZBIN32, Frame::ZRQINIT, &[0; 4], &[ZPAD, ZDLE, Encoding::ZBIN32 as u8, 0, 0, 0, 0, 0, 29, 247, 34, 198])] +#[case(Encoding::ZBIN, Frame::ZRQINIT, &[1; 4], &[ZPAD, ZDLE, Encoding::ZBIN as u8, 0, 1, 1, 1, 1, 98, 148])] +#[case(Encoding::ZHEX, Frame::ZRQINIT, &[1; 4], &[ZPAD, ZPAD, ZDLE, Encoding::ZHEX as u8, b'0', b'0', b'0', b'1', b'0', b'1', b'0', b'1', b'0', b'1', 54, 50, 57, 52, b'\r', b'\n', XON])] +pub fn test_header_write( + #[case] encoding: Encoding, + #[case] frame: Frame, + #[case] flags: &[u8; 4], + #[case] expected: &[u8], +) { + let header = Header::new(encoding, frame, flags); + let mut port = vec![]; + assert!(header.write(&mut port) == Ok(Some(()))); + assert_eq!(port, expected); +} + +#[rstest] +#[case(&[Encoding::ZHEX as u8, b'0', b'1', b'0', b'1', b'0', b'2', b'0', b'3', b'0', b'4', b'a', b'7', b'5', b'2'], Encoding::ZHEX, Frame::ZRINIT, &[0x1, 0x2, 0x3, 0x4])] +#[case(&[Encoding::ZBIN as u8, Frame::ZRINIT as u8, 0xa, 0xb, 0xc, 0xd, 0xa6, 0xcb], Encoding::ZBIN, Frame::ZRINIT, &[0xa, 0xb, 0xc, 0xd])] +#[case(&[Encoding::ZBIN32 as u8, Frame::ZRINIT as u8, 0xa, 0xb, 0xc, 0xd, 0x99, 0xe2, 0xae, 0x4a], Encoding::ZBIN32, Frame::ZRINIT, &[0xa, 0xb, 0xc, 0xd])] +#[case(&[Encoding::ZBIN as u8, Frame::ZRINIT as u8, 0xa, ZDLE, b'l', 0xd, ZDLE, b'm', 0x5e, 0x6f], Encoding::ZBIN, Frame::ZRINIT, &[0xa, 0x7f, 0xd, 0xff])] +pub fn test_header_read( + #[case] port: &[u8], + #[case] encoding: Encoding, + #[case] frame: Frame, + #[case] flags: &[u8; 4], +) { + let port = &mut port.to_vec(); + let port = &mut port.as_slice(); + assert!(Header::read(port) == Ok(Some(Header::new(encoding, frame, flags)))); +} + +#[test] +fn test_receive_malformed_header() { + let mut receiver = Receiver::new().unwrap(); + receiver.advance_outgoing(receiver.drain_outgoing().len()); + + let input = b"malformed data"; + let consumed = receiver.feed_incoming(input).unwrap(); + + assert_eq!(consumed, input.len()); + assert!(receiver.drain_outgoing().is_empty()); + assert!(receiver.drain_file().is_empty()); + assert!(receiver.poll_event().is_none()); +} + +#[test] +fn test_receive_zfile_with_non_utf8_name() { + let file_name = b"bad\x80name"; + let file_size = 123; + + let mut sender = Sender::new().unwrap(); + sender.start_file(file_name, file_size).unwrap(); + sender.advance_outgoing(sender.drain_outgoing().len()); + + let zrinit = Header::new(Encoding::ZHEX, Frame::ZRINIT, &[0; 4]); + let mut zrinit_bytes = Vec::new(); + zrinit.write(&mut zrinit_bytes).unwrap(); + let consumed = sender.feed_incoming(&zrinit_bytes).unwrap(); + assert!(consumed > 0 && consumed <= zrinit_bytes.len()); + + let wire = sender.drain_outgoing().to_vec(); + sender.advance_outgoing(wire.len()); + + let mut receiver = Receiver::new().unwrap(); + receiver.advance_outgoing(receiver.drain_outgoing().len()); + + let mut input = wire.as_slice(); + let mut got_start = false; + for _ in 0..(wire.len() * 4) { + if input.is_empty() { + break; + } + let consumed = receiver.feed_incoming(input).unwrap(); + if consumed == 0 { + if !receiver.drain_outgoing().is_empty() { + receiver.advance_outgoing(receiver.drain_outgoing().len()); + } + } else { + input = &input[consumed..]; + } + + if let Some(ReceiverEvent::FileStart) = receiver.poll_event() { + got_start = true; + break; + } + } + + assert!(got_start); + assert_eq!(receiver.file_name(), file_name); + assert_eq!(receiver.file_size(), file_size); +} diff --git a/src/App.tsx b/src/App.tsx index 660edfad..1f4628fd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -60,6 +60,7 @@ import { type TerminalWindowNode, updateTerminalWindowSplitRatio, } from "./lib/tabWindows"; +import { setBackendTransferDuplicatePrompt } from "./lib/transferDuplicatePrompt"; import { checkForUpdate, type UpdateInfo } from "./lib/updater"; import { getOwnerMainWindowLabel, @@ -388,6 +389,28 @@ function App() { }), ); + unsubs.push( + listen<{ + requestId: string; + sessionId: string; + remotePath: string; + fileName: string; + isDirectory: boolean; + targetWindowLabel?: string | null; + }>("transfer-duplicate-request", (event) => { + if (!eventTargetsCurrentWindow(event.payload.targetWindowLabel)) return; + setBackendTransferDuplicatePrompt({ + requestId: event.payload.requestId, + sessionId: event.payload.sessionId, + remotePath: event.payload.remotePath, + fileName: event.payload.fileName, + isDirectory: event.payload.isDirectory, + targetWindowLabel: event.payload.targetWindowLabel, + respondViaBackend: true, + }); + }), + ); + unsubs.push( listen<{ connectionId: string; diff --git a/src/components/ExternalFileDropOverlay.tsx b/src/components/ExternalFileDropOverlay.tsx new file mode 100644 index 00000000..b6e87528 --- /dev/null +++ b/src/components/ExternalFileDropOverlay.tsx @@ -0,0 +1,41 @@ +interface ExternalFileDropOverlayProps { + title: string; + hint: string; + insetClassName?: string; +} + +export default function ExternalFileDropOverlay({ + title, + hint, + insetClassName = "inset-2", +}: ExternalFileDropOverlayProps) { + return ( +

+
+
+
{title}
+
+ {hint} +
+
+
+
+ ); +} diff --git a/src/components/app/AppLayout.tsx b/src/components/app/AppLayout.tsx index e196a1ec..8082975b 100644 --- a/src/components/app/AppLayout.tsx +++ b/src/components/app/AppLayout.tsx @@ -10,6 +10,7 @@ import type { HostKeyVerifyRequest } from "@/components/dialog/connections/HostK import { HostKeyVerifyDialog } from "@/components/dialog/connections/HostKeyVerifyDialog"; import type { OtpRequest } from "@/components/dialog/connections/OtpDialog"; import { OtpDialog } from "@/components/dialog/connections/OtpDialog"; +import { TransferDuplicateDialog } from "@/components/dialog/file-explorer/TransferDuplicateDialog"; import SyncGroupDialog from "@/components/dialog/terminal/SyncGroupDialog"; import ActivityBar from "@/components/layout/ActivityBar"; import Header from "@/components/layout/Header"; @@ -461,6 +462,7 @@ export default function AppLayout({ request={dialogs.hostKeyVerifyRequest} onDone={dialogs.onHostKeyVerifyDone} /> + {dialogs.modalChildWindowCount > 0 && (
!v && !isSubmitting && onClose()}> - - - + { + if (!open && !isSubmitting) { + onClose(); + } + }} + > + { + if (event.key === "Enter" && !isSubmitting && data.items.length > 0) { + event.preventDefault(); + void handleDeleteSubmit(); + } + }} + > + + {data.items.length === 1 ? t("fileExplorer.sureDelete", { name: data.items[0]?.name ?? "" }) : t("fileExplorer.sureDeleteMultiple", { count: data.items.length })} - - {t("fileExplorer.cmDelete")} - + + {t("fileExplorer.deleteConfirmHint")} + {data.items.length > 1 && (
)} - - - - - - + + + + ); } diff --git a/src/components/dialog/file-explorer/DeleteTransferDialog.tsx b/src/components/dialog/file-explorer/DeleteTransferDialog.tsx new file mode 100644 index 00000000..7d913528 --- /dev/null +++ b/src/components/dialog/file-explorer/DeleteTransferDialog.tsx @@ -0,0 +1,58 @@ +import { useTranslation } from "react-i18next"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import type { TransferItem } from "@/context/TransferContext"; + +interface DeleteTransferDialogProps { + transfer: TransferItem | null; + onCancel: () => void; + onConfirm: () => void; +} + +export default function DeleteTransferDialog({ + transfer, + onCancel, + onConfirm, +}: DeleteTransferDialogProps) { + const { t } = useTranslation(); + + return ( + { + if (!open) onCancel(); + }} + > + { + if (event.key === "Enter" && transfer) { + event.preventDefault(); + onConfirm(); + } + }} + > + + {t("fileTransfer.deleteConfirmTitle")} + + {t("fileTransfer.deleteConfirmDesc", { name: transfer?.fileName ?? "" })} + + + + {t("dialog.cancel")} + + {t("fileTransfer.delete")} + + + + + ); +} diff --git a/src/components/dialog/file-explorer/TransferDuplicateDialog.tsx b/src/components/dialog/file-explorer/TransferDuplicateDialog.tsx new file mode 100644 index 00000000..d8bc5968 --- /dev/null +++ b/src/components/dialog/file-explorer/TransferDuplicateDialog.tsx @@ -0,0 +1,141 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { invoke } from "@/lib/invoke"; +import { logger } from "@/lib/logger"; +import { + getActiveTransferDuplicatePrompt, + resolveTransferDuplicatePrompt, + setBackendTransferDuplicatePrompt, + subscribeTransferDuplicatePrompt, + type TransferDuplicateChoice, + type TransferDuplicateRequest, +} from "@/lib/transferDuplicatePrompt"; + +export function TransferDuplicateDialog() { + const { t } = useTranslation(); + const [request, setRequest] = useState(() => + getActiveTransferDuplicatePrompt(), + ); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => subscribeTransferDuplicatePrompt(setRequest), []); + + const handleChoice = async (choice: TransferDuplicateChoice) => { + if (!request || submitting) return; + setSubmitting(true); + + try { + if (request.respondViaBackend) { + await invoke("respond_transfer_duplicate", { + requestId: request.requestId, + action: choice, + }); + } else { + resolveTransferDuplicatePrompt(choice); + setSubmitting(false); + return; + } + } catch (error) { + logger.error({ + domain: "transfer.lifecycle", + event: "duplicate.response_failed", + message: "Failed to send duplicate resolution to backend", + ids: { request_id: request.requestId }, + error, + }); + if (request.respondViaBackend) { + await invoke("respond_transfer_duplicate", { + requestId: request.requestId, + action: "skip", + }).catch(() => {}); + } else { + resolveTransferDuplicatePrompt("skip"); + } + } + + setSubmitting(false); + if (request.respondViaBackend) { + setBackendTransferDuplicatePrompt(null); + } + }; + + const kindLabel = request?.isDirectory + ? t("fileTransfer.duplicateKindFolder") + : t("fileTransfer.duplicateKindFile"); + + return ( + { + if (!open && request && !submitting) { + void handleChoice("skip"); + } + }} + > + { + if (event.key === "Enter" && !submitting && request) { + event.preventDefault(); + void handleChoice("overwrite"); + } + }} + > + + + {t("fileTransfer.duplicateTitle")} + + + {t("fileTransfer.duplicateDescription", { + kind: kindLabel, + name: request?.fileName ?? "", + })} + + + + {request?.remotePath && ( +
+ {request.remotePath} +
+ )} + + + { + event.preventDefault(); + void handleChoice("skip"); + }} + > + {t("fileTransfer.duplicateSkip")} + + { + event.preventDefault(); + void handleChoice("overwrite"); + }} + > + {t("fileTransfer.duplicateOverwrite")} + + +
+
+ ); +} diff --git a/src/components/panel/file-explorer/FileExplorerView.tsx b/src/components/panel/file-explorer/FileExplorerView.tsx index 72e21950..e31a5e92 100644 --- a/src/components/panel/file-explorer/FileExplorerView.tsx +++ b/src/components/panel/file-explorer/FileExplorerView.tsx @@ -39,6 +39,7 @@ import type { MoveDialogData } from "@/components/dialog/file-explorer/MoveDialo import type { NewItemDialogData } from "@/components/dialog/file-explorer/NewItemDialog"; import type { NewSymlinkDialogData } from "@/components/dialog/file-explorer/NewSymlinkDialog"; import type { PropertiesDialogData } from "@/components/dialog/file-explorer/PropertiesDialog"; +import ExternalFileDropOverlay from "@/components/ExternalFileDropOverlay"; import PanelHeader from "@/components/layout/PanelHeader"; import { Button } from "@/components/ui/button"; import { @@ -192,8 +193,9 @@ function FileExplorer({ const autoSyncCwd = !!activeConnectionId && autoSyncConnectionIds.includes(activeConnectionId); const favoriteDirectoriesByConnection = appSettings.ui.file_explorer_favorite_dirs_by_connection_id ?? {}; - const favoriteDirectories = - activeConnectionId ? (favoriteDirectoriesByConnection[activeConnectionId] ?? []) : []; + const favoriteDirectories = activeConnectionId + ? (favoriteDirectoriesByConnection[activeConnectionId] ?? []) + : []; const listScrollResetKey = `${activeSessionId ?? ""}:${currentPath}`; const listFilterResetKey = `${fileSearchQuery}:${fileSortMode.column}:${fileSortMode.direction}`; @@ -1702,16 +1704,11 @@ function FileExplorer({
{isExternalDropActive && canBrowseFiles && ( -
- {t("fileExplorer.externalDropOverlay")} -
+ )}
void; onPause: (id: string) => void; onResume: (id: string) => void; onRetry: (item: TransferItem) => void; onCancel: (id: string) => void; - onDelete: (id: string) => void; + onDelete: (item: TransferItem) => void; onOpenTargetDirectory: (id: string) => void; }) { const { t } = useTranslation(); @@ -156,9 +169,21 @@ function TransferRow({
(e.currentTarget.style.backgroundColor = "var(--df-bg-hover)")} - onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "var(--df-bg-panel)")} + style={{ + backgroundColor: selected ? "var(--df-bg-hover)" : "var(--df-bg-panel)", + outline: selected ? "1px solid var(--df-primary)" : undefined, + }} + onMouseDown={() => onSelect(item.id)} + onMouseEnter={(e) => { + if (!selected) { + e.currentTarget.style.backgroundColor = "var(--df-bg-hover)"; + } + }} + onMouseLeave={(e) => { + if (!selected) { + e.currentTarget.style.backgroundColor = "var(--df-bg-panel)"; + } + }} title={item.error || `${item.fileName} — ${statusText}`} >
@@ -276,11 +301,7 @@ function TransferRow({ )} - onDelete(item.id)} - disabled={!canDelete} - > + onDelete(item)} disabled={!canDelete}> {t("fileTransfer.delete")} @@ -302,6 +323,9 @@ export default function FileTransfer({ activeSessionId }: FileTransferProps) { } = useTransfer(); const { appSettings } = useApp(); const [resolvedDownloadDir, setResolvedDownloadDir] = useState(""); + const [selectedTransferId, setSelectedTransferId] = useState(null); + const [pendingDeleteTransfer, setPendingDeleteTransfer] = useState(null); + const listContainerRef = useRef(null); useEffect(() => { downloadDir() @@ -329,6 +353,92 @@ export default function FileTransfer({ activeSessionId }: FileTransferProps) { }); }, [activeSessionId, transfers]); + useEffect(() => { + setSelectedTransferId(null); + setPendingDeleteTransfer(null); + }, [activeSessionId]); + + useEffect(() => { + if ( + selectedTransferId && + !visibleTransfers.some((transfer) => transfer.id === selectedTransferId) + ) { + setSelectedTransferId(null); + } + }, [selectedTransferId, visibleTransfers]); + + const selectedTransfer = useMemo( + () => visibleTransfers.find((transfer) => transfer.id === selectedTransferId) ?? null, + [selectedTransferId, visibleTransfers], + ); + + const canDeleteTransfer = useCallback((transfer: TransferItem) => { + const canCancel = + transfer.status === "queued" || + transfer.status === "transferring" || + transfer.status === "paused"; + return !canCancel || transfer.status === "queued" || transfer.queueState === "pending"; + }, []); + + const requestDeleteTransfer = useCallback( + (transfer: TransferItem) => { + if (!canDeleteTransfer(transfer)) return; + setSelectedTransferId(transfer.id); + setPendingDeleteTransfer(transfer); + }, + [canDeleteTransfer], + ); + + const handleConfirmDeleteTransfer = useCallback(() => { + if (!pendingDeleteTransfer) return; + removeTransfer(pendingDeleteTransfer.id); + setPendingDeleteTransfer(null); + setSelectedTransferId(null); + }, [pendingDeleteTransfer, removeTransfer]); + + const handleListKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (pendingDeleteTransfer) return; + + if ( + event.key !== "Delete" || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey + ) { + return; + } + + const target = event.target; + if ( + target instanceof HTMLElement && + (target.isContentEditable || + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.tagName === "SELECT") + ) { + return; + } + + const transfer = selectedTransfer ?? visibleTransfers[0]; + if (!transfer || !canDeleteTransfer(transfer)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + requestDeleteTransfer(transfer); + }, + [ + canDeleteTransfer, + pendingDeleteTransfer, + requestDeleteTransfer, + selectedTransfer, + visibleTransfers, + ], + ); + const hasRunning = visibleTransfers.some( (transfer) => transfer.status === "transferring" || transfer.status === "queued", ); @@ -449,7 +559,17 @@ export default function FileTransfer({ activeSessionId }: FileTransferProps) { } /> -
+
{ + if (activeSessionId) { + listContainerRef.current?.focus(); + } + }} + onKeyDown={handleListKeyDown} + > {!activeSessionId ? (
@@ -466,11 +586,13 @@ export default function FileTransfer({ activeSessionId }: FileTransferProps) { void pauseTransfer(id)} onResume={(id) => void resumeTransfer(id)} onRetry={(transfer) => void retryTransfer(transfer)} onCancel={(id) => void cancelTransfer(id)} - onDelete={removeTransfer} + onDelete={requestDeleteTransfer} onOpenTargetDirectory={(id) => void handleOpenTargetDirectory(id)} /> ))} @@ -478,6 +600,12 @@ export default function FileTransfer({ activeSessionId }: FileTransferProps) { )}
+ setPendingDeleteTransfer(null)} + onConfirm={handleConfirmDeleteTransfer} + /> + {displayPath && ( diff --git a/src/components/panel/file-explorer/useExternalFileDrop.ts b/src/components/panel/file-explorer/useExternalFileDrop.ts index 8885a468..65b7336c 100644 --- a/src/components/panel/file-explorer/useExternalFileDrop.ts +++ b/src/components/panel/file-explorer/useExternalFileDrop.ts @@ -3,130 +3,22 @@ import { getCurrentWebview } from "@tauri-apps/api/webview"; import { useEffect } from "react"; import { toast } from "sonner"; import { logger } from "@/lib/logger"; +import { + collectExternalDropAdditionalObjects, + createExternalFileDropBridgeMessage, + DRAG_EVENT_CAPTURE_OPTIONS, + getDragEventPosition, + getExternalFileDropBridge, + isDropPositionInsideElement, + isExternalFileDragEvent, + type NativeFileDropEventPayload, +} from "@/lib/nativeFileDrop"; import { normalizeDirectoryPath } from "./model"; -interface ExternalFileDropEventPayload { - kind: "enter" | "over" | "leave" | "drop"; - paths: string[]; - position: { - x: number; - y: number; - }; -} - -const EXTERNAL_FILE_DROP_MESSAGE_KIND = "external-file-drop"; -const DRAG_EVENT_CAPTURE_OPTIONS = true; - -type WebView2Bridge = { - postMessageWithAdditionalObjects: ( - message: unknown, - additionalObjects: ArrayLike, - ) => void; -}; - -type DataTransferItemWithFileSystemHandle = DataTransferItem & { - getAsFileSystemHandle?: () => Promise; -}; - type CurrentRef = { current: T; }; -declare global { - interface Window { - chrome?: { - webview?: WebView2Bridge; - }; - } -} - -function isDropPositionInsideElement( - position: { x: number; y: number }, - element: HTMLElement | null, -) { - if (!element) { - return false; - } - - const rect = element.getBoundingClientRect(); - const scale = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1; - const candidates = - scale === 1 ? [position] : [position, { x: position.x / scale, y: position.y / scale }]; - - return candidates.some( - ({ x, y }) => x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom, - ); -} - -function isExternalFileDragEvent(event: DragEvent) { - const dataTransfer = event.dataTransfer; - if (!dataTransfer) { - return false; - } - - if (Array.from(dataTransfer.types ?? []).includes("Files")) { - return true; - } - - if (dataTransfer.files.length > 0) { - return true; - } - - return Array.from(dataTransfer.items ?? []).some((item) => item.kind === "file"); -} - -function getDragEventPosition(event: DragEvent) { - return { - x: event.clientX, - y: event.clientY, - }; -} - -function getExternalFileDropBridge() { - return window.chrome?.webview; -} - -function createExternalFileDropBridgeMessage(position: { x: number; y: number }) { - return JSON.stringify({ - kind: EXTERNAL_FILE_DROP_MESSAGE_KIND, - position, - }); -} - -async function collectExternalDropAdditionalObjects(dataTransfer: DataTransfer | null) { - if (!dataTransfer) { - return []; - } - - const fileItems = Array.from(dataTransfer.items ?? []).filter((item) => item.kind === "file"); - if (fileItems.length === 0 && dataTransfer.files.length > 0) { - return Array.from(dataTransfer.files); - } - - const additionalObjects: unknown[] = []; - for (const item of fileItems) { - const file = item.getAsFile(); - if (file) { - additionalObjects.push(file); - continue; - } - - const itemWithHandle = item as DataTransferItemWithFileSystemHandle; - if (typeof itemWithHandle.getAsFileSystemHandle === "function") { - try { - const handle = await itemWithHandle.getAsFileSystemHandle(); - if (handle) { - additionalObjects.push(handle); - } - } catch { - // Fall back to File objects if the runtime cannot expose FileSystemHandle. - } - } - } - - return additionalObjects; -} - interface UseExternalFileDropParams { activeSessionIdRef: CurrentRef; canBrowseFilesRef: CurrentRef; @@ -164,17 +56,23 @@ export function useExternalFileDrop({ return; } - event.preventDefault(); const isOverDropTarget = isDropPositionInsideElement( getDragEventPosition(event), listContainerRef.current, ); + + if (!isOverDropTarget) { + resetExternalDropHover(); + return; + } + + event.preventDefault(); const isActive = canBrowseFilesRef.current && !!activeSessionIdRef.current && isOverDropTarget; setIsExternalDropActive(isActive); if (event.dataTransfer) { - event.dataTransfer.dropEffect = event.type === "dragenter" || isActive ? "copy" : "none"; + event.dataTransfer.dropEffect = "copy"; } }; @@ -204,7 +102,6 @@ export function useExternalFileDrop({ return; } - event.preventDefault(); const dropPosition = getDragEventPosition(event); const isOverDropTarget = isDropPositionInsideElement(dropPosition, listContainerRef.current); resetExternalDropHover(); @@ -214,6 +111,8 @@ export function useExternalFileDrop({ return; } + event.preventDefault(); + const dataTransfer = event.dataTransfer; if (dataTransfer?.files && dataTransfer.files.length > 0) { try { @@ -318,7 +217,7 @@ export function useExternalFileDrop({ let cancelled = false; - const unlistenPromise = listen("external-file-drop", (event) => { + const unlistenPromise = listen("external-file-drop", (event) => { if (cancelled) { return; } diff --git a/src/components/terminal/TabBar.tsx b/src/components/terminal/TabBar.tsx index 1e510f19..c1442903 100644 --- a/src/components/terminal/TabBar.tsx +++ b/src/components/terminal/TabBar.tsx @@ -6,19 +6,23 @@ import { useMemo, useRef, useState, + type WheelEvent, } from "react"; import { useTranslation } from "react-i18next"; import { MdAdd, MdCellTower, + MdChevronLeft, + MdChevronRight, MdClose, + MdContentCopy, MdDns, MdErrorOutline, MdFolder, MdHistory, MdTerminal, } from "react-icons/md"; -import { RiExpandDiagonalLine } from "react-icons/ri"; +import { toast } from "sonner"; import { getActiveGroupForSession, isSessionPausedInGroup } from "@/lib/syncInputGroups"; import { getActivePane, getTabDisplayName } from "@/lib/workspaceTabs"; import type { Group, PaneSplitDirection, SavedConnection, Tab } from "@/types/global"; @@ -70,64 +74,49 @@ interface ConnectionGroupNode { totalCount: number; } -const TAB_WIDTH_FALLBACK = 220; -const TAB_OVERFLOW_TRIGGER_WIDTH = 36; +const TAB_STRIP_SCROLL_DURATION_MS = 180; +const TAB_STRIP_SCROLL_PADDING = 12; -function areStringArraysEqual(left: string[], right: string[]) { - return left.length === right.length && left.every((value, index) => value === right[index]); +function easeOutCubic(progress: number): number { + return 1 - (1 - progress) ** 3; } -function computeVisibleTabIds( - tabs: Tab[], - tabWidths: Map, - availableWidth: number, - activeTabId: string | null, -) { - if (tabs.length === 0) return []; - if (availableWidth <= 0) { - return [tabs.find((tab) => tab.id === activeTabId)?.id ?? tabs[0].id]; +function clampTabStripScrollLeft(strip: HTMLElement, value: number): number { + const maxScrollLeft = Math.max(0, strip.scrollWidth - strip.clientWidth); + return Math.max(0, Math.min(maxScrollLeft, value)); +} + +function animateTabStripScroll( + strip: HTMLElement, + targetScrollLeft: number, + onComplete?: () => void, +): () => void { + const startScrollLeft = strip.scrollLeft; + const clampedTarget = clampTabStripScrollLeft(strip, targetScrollLeft); + const distance = clampedTarget - startScrollLeft; + + if (Math.abs(distance) < 0.5) { + strip.scrollLeft = clampedTarget; + onComplete?.(); + return () => {}; } - const widths = tabs.map((tab) => Math.ceil(tabWidths.get(tab.id) ?? TAB_WIDTH_FALLBACK)); - const totalWidth = widths.reduce((sum, width) => sum + width, 0); - if (totalWidth <= availableWidth) return tabs.map((tab) => tab.id); + const startTime = performance.now(); + let rafId = 0; - const selectedIndexes: number[] = []; - let usedWidth = 0; - - for (let index = tabs.length - 1; index >= 0; index -= 1) { - const width = widths[index]; - if (usedWidth + width > availableWidth) break; - selectedIndexes.push(index); - usedWidth += width; - } - - const activeIndex = activeTabId ? tabs.findIndex((tab) => tab.id === activeTabId) : -1; - const activeWidth = activeIndex >= 0 ? widths[activeIndex] : 0; - if (activeIndex >= 0 && !selectedIndexes.includes(activeIndex)) { - while (selectedIndexes.length > 0 && usedWidth + activeWidth > availableWidth) { - const leftmostSelectedIndex = Math.min(...selectedIndexes); - selectedIndexes.splice(selectedIndexes.indexOf(leftmostSelectedIndex), 1); - usedWidth -= widths[leftmostSelectedIndex]; + const step = (now: number) => { + const progress = Math.min(1, (now - startTime) / TAB_STRIP_SCROLL_DURATION_MS); + strip.scrollLeft = startScrollLeft + distance * easeOutCubic(progress); + if (progress < 1) { + rafId = requestAnimationFrame(step); + return; } + strip.scrollLeft = clampedTarget; + onComplete?.(); + }; - if (activeWidth <= availableWidth || selectedIndexes.length === 0) { - selectedIndexes.push(activeIndex); - usedWidth += activeWidth; - } - } - - for (let index = tabs.length - 1; index >= 0; index -= 1) { - if (selectedIndexes.includes(index)) continue; - const width = widths[index]; - if (usedWidth + width <= availableWidth) { - selectedIndexes.push(index); - usedWidth += width; - } - } - - const selectedSet = new Set(selectedIndexes); - return tabs.filter((_, index) => selectedSet.has(index)).map((tab) => tab.id); + rafId = requestAnimationFrame(step); + return () => cancelAnimationFrame(rafId); } function compareSortOrder(left: { sort_order?: number }, right: { sort_order?: number }) { @@ -197,14 +186,16 @@ function TabBar({ const { appSettings, savedConnections, savedGroups, syncGroups, broadcastToAll } = useApp(); const [draggedTabId, setDraggedTabId] = useState(null); const [dropIndex, setDropIndex] = useState(null); - const [overflowMenuOpen, setOverflowMenuOpen] = useState(false); - const [overflowTooltipOpen, setOverflowTooltipOpen] = useState(false); - const [suppressOverflowTooltip, setSuppressOverflowTooltip] = useState(false); const tabStripRef = useRef(null); - const tabMeasureRefs = useRef(new Map()); const tabButtonRefs = useRef(new Map()); const draggedTabIdRef = useRef(null); - const [visibleTabIds, setVisibleTabIds] = useState(() => tabs.map((tab) => tab.id)); + const tabStripAnimatingRef = useRef(false); + const tabStripScrollAnimationRef = useRef<(() => void) | null>(null); + const [tabStripScroll, setTabStripScroll] = useState({ + canScrollLeft: false, + canScrollRight: false, + hasOverflow: false, + }); const groupsById = useMemo( () => new Map(savedGroups.map((group) => [group.id, group])), @@ -283,55 +274,162 @@ function TabBar({ .slice(0, 10); }, [appSettings.ui.recent_connection_ids, savedConnections]); - const updateVisibleTabs = useCallback(() => { + const updateTabStripScrollState = useCallback(() => { const strip = tabStripRef.current; if (!strip) return; - const tabWidths = new Map(); - for (const tab of tabs) { - const measuredWidth = tabMeasureRefs.current.get(tab.id)?.offsetWidth; - if (measuredWidth) tabWidths.set(tab.id, measuredWidth); + const maxScrollLeft = Math.max(0, strip.scrollWidth - strip.clientWidth); + const nextState = { + canScrollLeft: strip.scrollLeft > 1, + canScrollRight: strip.scrollLeft < maxScrollLeft - 1, + hasOverflow: maxScrollLeft > 1, + }; + + setTabStripScroll((current) => + current.canScrollLeft === nextState.canScrollLeft && + current.canScrollRight === nextState.canScrollRight && + current.hasOverflow === nextState.hasOverflow + ? current + : nextState, + ); + }, []); + + const runTabStripScrollAnimation = useCallback( + (targetScrollLeft: number) => { + const strip = tabStripRef.current; + if (!strip) return; + + tabStripScrollAnimationRef.current?.(); + tabStripAnimatingRef.current = true; + tabStripScrollAnimationRef.current = animateTabStripScroll(strip, targetScrollLeft, () => { + tabStripScrollAnimationRef.current = null; + tabStripAnimatingRef.current = false; + updateTabStripScrollState(); + }); + }, + [updateTabStripScrollState], + ); + + const getTabStripStepTarget = useCallback((direction: -1 | 1): number | null => { + const strip = tabStripRef.current; + if (!strip) return null; + + const pageWidth = strip.clientWidth; + const targetScrollLeft = + direction === 1 ? strip.scrollLeft + pageWidth : strip.scrollLeft - pageWidth; + const clampedTarget = clampTabStripScrollLeft(strip, targetScrollLeft); + + if (Math.abs(clampedTarget - strip.scrollLeft) < 1) return null; + return clampedTarget; + }, []); + + const scrollTabStripPage = useCallback( + (direction: -1 | 1) => { + if (tabStripAnimatingRef.current) return; + const targetScrollLeft = getTabStripStepTarget(direction); + if (targetScrollLeft === null) return; + runTabStripScrollAnimation(targetScrollLeft); + }, + [getTabStripStepTarget, runTabStripScrollAnimation], + ); + + const getActiveTabScrollTarget = useCallback((): number | null => { + if (!activeTabId) return null; + + const strip = tabStripRef.current; + const tabElement = tabButtonRefs.current.get(activeTabId); + if (!strip || !tabElement) return null; + + const scrollLeft = strip.scrollLeft; + const viewportRight = scrollLeft + strip.clientWidth; + const padding = TAB_STRIP_SCROLL_PADDING; + const tabLeft = tabElement.offsetLeft; + const tabRight = tabLeft + tabElement.offsetWidth; + + if (tabLeft >= scrollLeft + padding && tabRight <= viewportRight - padding) { + return null; } - const totalTabWidth = tabs.reduce( - (sum, tab) => sum + Math.ceil(tabWidths.get(tab.id) ?? TAB_WIDTH_FALLBACK), - 0, - ); - const hasOverflowTrigger = visibleTabIds.length < tabs.length; - const availableWithoutOverflowTrigger = - strip.clientWidth + (hasOverflowTrigger ? TAB_OVERFLOW_TRIGGER_WIDTH : 0); - const availableWidth = - totalTabWidth <= availableWithoutOverflowTrigger - ? availableWithoutOverflowTrigger - : Math.max(0, availableWithoutOverflowTrigger - TAB_OVERFLOW_TRIGGER_WIDTH); + if (tabLeft < scrollLeft + padding) { + return clampTabStripScrollLeft(strip, tabLeft - padding); + } - const nextVisibleTabIds = computeVisibleTabIds(tabs, tabWidths, availableWidth, activeTabId); + return clampTabStripScrollLeft(strip, tabRight - strip.clientWidth + padding); + }, [activeTabId]); - setVisibleTabIds((current) => - areStringArraysEqual(current, nextVisibleTabIds) ? current : nextVisibleTabIds, - ); - }, [activeTabId, tabs, visibleTabIds.length]); + const scrollActiveTabIntoView = useCallback( + (smooth: boolean) => { + const targetScrollLeft = getActiveTabScrollTarget(); + if (targetScrollLeft === null) return; + + if (smooth) { + runTabStripScrollAnimation(targetScrollLeft); + return; + } + + const strip = tabStripRef.current; + if (!strip) return; + strip.scrollLeft = targetScrollLeft; + updateTabStripScrollState(); + }, + [getActiveTabScrollTarget, runTabStripScrollAnimation, updateTabStripScrollState], + ); + + const handleTabStripScroll = useCallback(() => { + if (tabStripAnimatingRef.current) return; + updateTabStripScrollState(); + }, [updateTabStripScrollState]); useLayoutEffect(() => { - updateVisibleTabs(); + updateTabStripScrollState(); + }, [tabs, updateTabStripScrollState]); + useLayoutEffect(() => { const strip = tabStripRef.current; if (!strip) return; - const observer = new ResizeObserver(() => updateVisibleTabs()); + const observer = new ResizeObserver(() => { + updateTabStripScrollState(); + scrollActiveTabIntoView(false); + }); observer.observe(strip); return () => observer.disconnect(); - }, [updateVisibleTabs]); + }, [scrollActiveTabIntoView, tabs, updateTabStripScrollState]); - const visibleTabIdSet = useMemo(() => new Set(visibleTabIds), [visibleTabIds]); - const visibleTabs = useMemo( - () => tabs.filter((tab) => visibleTabIdSet.has(tab.id)), - [tabs, visibleTabIdSet], + useLayoutEffect(() => { + scrollActiveTabIntoView(true); + }, [activeTabId, scrollActiveTabIntoView]); + + useLayoutEffect( + () => () => { + tabStripScrollAnimationRef.current?.(); + tabStripScrollAnimationRef.current = null; + tabStripAnimatingRef.current = false; + }, + [], ); - const overflowTabs = useMemo( - () => tabs.filter((tab) => !visibleTabIdSet.has(tab.id)), - [tabs, visibleTabIdSet], + + const handleTabStripWheel = useCallback( + (event: WheelEvent) => { + const strip = tabStripRef.current; + if (!strip || strip.scrollWidth <= strip.clientWidth + 1) return; + + event.preventDefault(); + event.stopPropagation(); + + // Wheel: continuous proportional scroll (natural for trackpad / mouse wheel). + tabStripScrollAnimationRef.current?.(); + tabStripScrollAnimationRef.current = null; + tabStripAnimatingRef.current = false; + + const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; + if (Math.abs(delta) < 0.5) return; + + strip.scrollLeft = clampTabStripScrollLeft(strip, strip.scrollLeft + delta); + updateTabStripScrollState(); + }, + [updateTabStripScrollState], ); const getInsertionIndex = useCallback((event: DragEvent, index: number) => { @@ -345,14 +443,6 @@ function TabBar({ setDropIndex(null); }, []); - const setMeasureTabRef = useCallback((tabId: string, element: HTMLDivElement | null) => { - if (element) { - tabMeasureRefs.current.set(tabId, element); - } else { - tabMeasureRefs.current.delete(tabId); - } - }, []); - const setVisibleTabRef = useCallback((tabId: string, element: HTMLDivElement | null) => { if (element) { tabButtonRefs.current.set(tabId, element); @@ -363,7 +453,7 @@ function TabBar({ const getInsertionIndexFromClientX = useCallback( (clientX: number) => { - for (const tab of visibleTabs) { + for (const tab of tabs) { const element = tabButtonRefs.current.get(tab.id); if (!element) continue; const rect = element.getBoundingClientRect(); @@ -374,7 +464,7 @@ function TabBar({ } return tabs.length; }, - [tabs, visibleTabs], + [tabs], ); const handleDropAtIndex = useCallback( @@ -535,7 +625,7 @@ function TabBar({ return ; }; - const renderTabItem = (tab: Tab, index: number, measureOnly = false) => { + const renderTabItem = (tab: Tab, index: number) => { const isActive = activeTabId === tab.id; const isFocused = focusedTabId === tab.id; const showUnreadIndicator = !isFocused && unreadTabIds?.has(tab.id); @@ -544,12 +634,7 @@ function TabBar({ const tabButton = (
setMeasureTabRef(tab.id, element) - : (element) => setVisibleTabRef(tab.id, element) - } - draggable={!measureOnly} + draggable className={`group relative flex items-center gap-2 border-r pl-3 pr-2 text-xs transition-[color,background-color,opacity] duration-200 ${ isActive ? "font-semibold" : "font-medium df-hover" } ${draggedTabId === tab.id ? "opacity-60" : ""}`} @@ -564,34 +649,22 @@ function TabBar({ : "transparent", color: isActive ? "var(--df-text)" : "var(--df-text-muted)", }} - onClick={measureOnly ? undefined : () => onTabChange(tab.id)} - onContextMenu={measureOnly ? undefined : () => onTabChange(tab.id)} - onDragStart={measureOnly ? undefined : (event) => handleDragStart(event, tab.id)} - onDragEnd={ - measureOnly - ? undefined - : (event) => { - handleDragEnd(event); - } - } - onDragOver={ - measureOnly - ? undefined - : (event) => { - if (!draggedTabId && !event.dataTransfer.types.includes("application/nyaterm-tab")) - return; - event.preventDefault(); - setDropIndex(getInsertionIndex(event, index)); - } - } - onDrop={ - measureOnly - ? undefined - : (event) => { - event.preventDefault(); - handleDropAtIndex(getInsertionIndex(event, index), event); - } - } + onClick={() => onTabChange(tab.id)} + onContextMenu={() => onTabChange(tab.id)} + onDragStart={(event) => handleDragStart(event, tab.id)} + onDragEnd={(event) => { + handleDragEnd(event); + }} + onDragOver={(event) => { + if (!draggedTabId && !event.dataTransfer.types.includes("application/nyaterm-tab")) + return; + event.preventDefault(); + setDropIndex(getInsertionIndex(event, index)); + }} + onDrop={(event) => { + event.preventDefault(); + handleDropAtIndex(getInsertionIndex(event, index), event); + }} > {isActive && (
{displayName} - ) : ( - - - {displayName} - - - {displayName} - - - )} + + + + + {displayName} + + + {index + 1}. {displayName} + + + {(() => { + const pane = getActivePane(tab); + const conn = pane?.connectionId + ? savedConnections.find((c) => c.id === pane.connectionId) + : undefined; + const host = conn?.host; + if (host) { + return ( +
{ + event.stopPropagation(); + navigator.clipboard.writeText(host).catch(() => {}); + toast.success(t("tabCtx.ipCopied")); + }} + title={host} + > + +
+ ); + } + return null; + })()} +
{showUnreadIndicator ? ( @@ -637,14 +738,10 @@ function TabBar({ ? "text-[var(--df-text-muted)]" : "text-[var(--df-text-dimmed)] opacity-0 group-hover:opacity-100" } hover:!bg-red-500/10 hover:!text-red-500 active:scale-90 active:!bg-red-500/20`} - onClick={ - measureOnly - ? undefined - : (event) => { - event.stopPropagation(); - void onTabClose(tab); - } - } + onClick={(event) => { + event.stopPropagation(); + void onTabClose(tab); + }} >
@@ -653,16 +750,12 @@ function TabBar({
); - if (measureOnly) { - return ( -
- {tabButton} -
- ); - } - return ( -
+
setVisibleTabRef(tab.id, element)} + className="relative flex shrink-0" + > {dropIndex === index && (draggedTabId || dropIndex !== null) && (
{ - const isActive = activeTabId === tab.id; - const displayName = getTabDisplayName(tab); - - return ( - { - event.preventDefault(); - onTabChange(tab.id); - setOverflowMenuOpen(false); - }} - > - {renderTabIcon(tab)} - - {displayName} - - - - ); - }; - const renderConnectionMenuItem = (connection: SavedConnection, label = connection.name) => ( -
- + + + )} - {visibleTabs.map((tab) => +
+ {tabs.map((tab) => renderTabItem( tab, tabs.findIndex((item) => item.id === tab.id), @@ -814,60 +873,17 @@ function TabBar({
- {overflowTabs.length > 0 && ( - { - setOverflowMenuOpen(open); - if (open) { - setOverflowTooltipOpen(false); - setSuppressOverflowTooltip(true); - } else { - setSuppressOverflowTooltip(true); - } - }} + {tabStripScroll.hasOverflow && ( + - - - - {t("terminal.showHiddenSessions")} - - - - - {t("terminal.hiddenSessions")} - - {overflowTabs.map(renderOverflowTabItem)} - - + + )} diff --git a/src/components/terminal/TabContextMenu.tsx b/src/components/terminal/TabContextMenu.tsx index db80de02..f0996d89 100644 --- a/src/components/terminal/TabContextMenu.tsx +++ b/src/components/terminal/TabContextMenu.tsx @@ -96,7 +96,7 @@ export default function TabContextMenu({ onActivateTab, }: TabContextMenuProps) { const { t } = useTranslation(); - const { updateTab } = useApp(); + const { updateTab, savedConnections } = useApp(); const [renameOpen, setRenameOpen] = useState(false); const [renameValue, setRenameValue] = useState(""); @@ -163,6 +163,22 @@ export default function TabContextMenu({ } }, [displayName, t]); + const conn = activePane?.connectionId + ? savedConnections.find((c) => c.id === activePane.connectionId) + : undefined; + const host = conn?.host; + const canCopyIp = !!host; + + const handleCopyIp = useCallback(async () => { + if (!host) return; + try { + await navigator.clipboard.writeText(host); + toast.success(t("tabCtx.ipCopied")); + } catch { + toast.error(t("tabCtx.copyFailed")); + } + }, [host, t]); + const handleOpenAI = useCallback( (action: "explain_output" | "analyze_error") => { onActivateTab(tab.id); @@ -232,6 +248,11 @@ export default function TabContextMenu({ {t("tabCtx.copyName")} + void handleCopyIp()}> + + {t("tabCtx.copyIp")} + + void onDuplicateSession(tab)}> @@ -252,10 +273,7 @@ export default function TabContextMenu({ {t("tabCtx.reconnect")} - void onDisconnectSession(tab)} - > + void onDisconnectSession(tab)}> {t("tabCtx.disconnect")} diff --git a/src/components/terminal/XTerminal.tsx b/src/components/terminal/XTerminal.tsx index 74fcae70..a2ee57b9 100644 --- a/src/components/terminal/XTerminal.tsx +++ b/src/components/terminal/XTerminal.tsx @@ -4,7 +4,10 @@ import { SearchAddon } from "@xterm/addon-search"; import { Terminal } from "@xterm/xterm"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; import MultiLinePasteDialog from "@/components/dialog/terminal/MultiLinePasteDialog"; +import ExternalFileDropOverlay from "@/components/ExternalFileDropOverlay"; +import type { ResolvedLocalDropPathEntry } from "@/components/panel/file-explorer/model"; import { useTerminalAppSettings } from "@/context/AppContext"; import { useTheme } from "@/context/ThemeContext"; import { useActionLinks } from "@/hooks/useActionLinks"; @@ -13,6 +16,7 @@ import { useCredentialAutofill } from "@/hooks/useCredentialAutofill"; import { useKeywordHighlighter } from "@/hooks/useKeywordHighlighter"; import { useShellIntegration } from "@/hooks/useShellIntegration"; import { resolveShortcutKeys } from "@/hooks/useShortcutMap"; +import { useTerminalFileDrop } from "@/hooks/useTerminalFileDrop"; import { useTerminalSearch } from "@/hooks/useTerminalSearch"; import { useTerminalSettings } from "@/hooks/useTerminalSettings"; import { emitAIErrorDetected } from "@/lib/aiEvents"; @@ -22,6 +26,7 @@ import { readClipboardText } from "@/lib/clipboard"; import { detectCredentialPromptKind } from "@/lib/credentialAutofill"; import { invoke } from "@/lib/invoke"; import { hexLuminance } from "@/lib/keywordHighlightPresets"; +import { logger } from "@/lib/logger"; import { openSendCommandPanel } from "@/lib/sendCommandPanelEvents"; import { buildTerminalCommandInput, @@ -33,6 +38,7 @@ import { } from "@/lib/sessionInput"; import { matchesKeyEvent, resolveIndexedKeys } from "@/lib/shortcutRegistry"; import { registerTerminalContextProvider } from "@/lib/terminalContext"; +import { getTerminalDropOverlayCopy, handleTerminalFileDrop } from "@/lib/terminalFileDrop"; import { applyTerminalInputData, applyTerminalInputPreview, @@ -115,6 +121,7 @@ export default function XTerminal({ const [performanceOverlay, setPerformanceOverlay] = useState(null); const [skippedOutputChars, setSkippedOutputChars] = useState(0); const [multiLinePasteText, setMultiLinePasteText] = useState(null); + const [isExternalDropActive, setIsExternalDropActive] = useState(false); const aiCapturingRef = useRef(false); const { terminalTheme } = useTheme(); @@ -366,7 +373,12 @@ export default function XTerminal({ removePopup: removeLinkPopup, } = createTerminalLinkHandlers(terminal, tRef); const searchAddon = new SearchAddon(); - const zmodemHandler = createZmodemEventHandler(terminal, sessionId, () => tRef.current); + const zmodemHandler = createZmodemEventHandler( + terminal, + sessionId, + () => tRef.current, + () => terminalAppSettingsRef.current?.transfer.duplicate_strategy ?? "ask", + ); terminal.options.linkHandler = oscLinkHandler; terminal.loadAddon(fitAddon); @@ -711,6 +723,7 @@ export default function XTerminal({ return false; } + // Smart cursor selection: preserve editing behavior for command-line input if ( (e.key === "ArrowLeft" || e.key === "ArrowRight") && !e.ctrlKey && @@ -745,6 +758,155 @@ export default function XTerminal({ } } + // Normal selection: preserve selection for ALL key presses - only clear on mouse click + // If there's a selection that's NOT a smart cursor selection, + // send the key input without triggering xterm's clearSelection + if (terminal.hasSelection() && !getSmartCursorSelectedInputRange()) { + if (directInputData) { + e.preventDefault(); + terminal.input(directInputData, false); + return false; + } + // Handle Backspace/Delete - send control char without clearing selection + if ( + (e.key === "Backspace" || e.key === "Delete") && + !e.ctrlKey && + !e.metaKey && + !e.altKey + ) { + e.preventDefault(); + terminal.input("\x7f", false); + return false; + } + // Handle Enter - send newline without clearing selection + if (e.key === "Enter" && !e.ctrlKey && !e.metaKey && !e.altKey) { + e.preventDefault(); + terminal.input("\r", false); + return false; + } + // Handle arrow keys - send ANSI escape sequences without clearing selection + if (e.key === "ArrowLeft" && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { + e.preventDefault(); + terminal.input("\x1b[D", false); + return false; + } + if (e.key === "ArrowRight" && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { + e.preventDefault(); + terminal.input("\x1b[C", false); + return false; + } + if (e.key === "ArrowUp" && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { + e.preventDefault(); + terminal.input("\x1b[A", false); + return false; + } + if (e.key === "ArrowDown" && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { + e.preventDefault(); + terminal.input("\x1b[B", false); + return false; + } + // Handle Ctrl key combinations - send control characters without clearing selection + if (e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { + const ctrlCharMap: Record = { + a: "\x01", + b: "\x02", + c: "\x03", + d: "\x04", + e: "\x05", + f: "\x06", + g: "\x07", + h: "\x08", + i: "\x09", + j: "\x0a", + k: "\x0b", + l: "\x0c", + m: "\x0d", + n: "\x0e", + o: "\x0f", + p: "\x10", + q: "\x11", + r: "\x12", + s: "\x13", + t: "\x14", + u: "\x15", + v: "\x16", + w: "\x17", + x: "\x18", + y: "\x19", + z: "\x1a", + }; + const keyLower = e.key.toLowerCase(); + if (ctrlCharMap[keyLower]) { + e.preventDefault(); + terminal.input(ctrlCharMap[keyLower], false); + return false; + } + // Ctrl+Arrow keys (word navigation) + if (e.key === "ArrowLeft") { + e.preventDefault(); + terminal.input("\x1b[1;5D", false); + return false; + } + if (e.key === "ArrowRight") { + e.preventDefault(); + terminal.input("\x1b[1;5C", false); + return false; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + terminal.input("\x1b[1;5A", false); + return false; + } + if (e.key === "ArrowDown") { + e.preventDefault(); + terminal.input("\x1b[1;5B", false); + return false; + } + } + // Handle Alt/Meta key combinations + if ((e.altKey || e.metaKey) && !e.ctrlKey && !e.shiftKey) { + // Alt+Arrow keys (word navigation on some shells) + if (e.key === "ArrowLeft") { + e.preventDefault(); + terminal.input("\x1b[1;3D", false); + return false; + } + if (e.key === "ArrowRight") { + e.preventDefault(); + terminal.input("\x1b[1;3C", false); + return false; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + terminal.input("\x1b[1;3A", false); + return false; + } + if (e.key === "ArrowDown") { + e.preventDefault(); + terminal.input("\x1b[1;3B", false); + return false; + } + // Alt+b, Alt+f (word navigation shortcuts in bash) + const keyLower = e.key.toLowerCase(); + if (keyLower === "b") { + e.preventDefault(); + terminal.input("\x1bb", false); + return false; + } + if (keyLower === "f") { + e.preventDefault(); + terminal.input("\x1bf", false); + return false; + } + // Alt+d (delete word forward) + if (keyLower === "d") { + e.preventDefault(); + terminal.input("\x1bd", false); + return false; + } + } + } + if (matchesKeyEvent(resolveShortcutKeys("terminal.copy", kb), e)) { e.preventDefault(); const sel = terminal.getSelection(); @@ -1618,25 +1780,105 @@ export default function XTerminal({ }); }, []); - const handleSendMultiLinePasteByLine = useCallback((text: string) => { - if (!text) return; - openSendCommandPanel({ - text, - sourceSessionId: sessionId, - sourceSessionType: sessionType, - dataType: "text", - sendMode: "line", - count: 1, - intervalSeconds: 1, - target: "current", - }); - setMultiLinePasteText(null); - }, [sessionId, sessionType]); + const handleSendMultiLinePasteByLine = useCallback( + (text: string) => { + if (!text) return; + openSendCommandPanel({ + text, + sourceSessionId: sessionId, + sourceSessionType: sessionType, + dataType: "text", + sendMode: "line", + count: 1, + intervalSeconds: 1, + target: "current", + }); + setMultiLinePasteText(null); + }, + [sessionId, sessionType], + ); useEffect(() => { doFindRef.current = doFind; }, [doFind]); + const resetExternalDropHover = useCallback(() => { + setIsExternalDropActive(false); + }, []); + + const resolveLocalDropPaths = useCallback(async (paths: string[]) => { + const uniquePaths = Array.from( + new Set(paths.map((path) => path.trim()).filter((path) => !!path)), + ); + if (uniquePaths.length === 0) { + return []; + } + + return invoke("resolve_local_drop_paths", { + paths: uniquePaths, + }); + }, []); + + const processTerminalDropPaths = useCallback( + async (dropPaths: string[]) => { + try { + const resolvedLocalEntries = await resolveLocalDropPaths(dropPaths); + if (resolvedLocalEntries.length === 0) { + logger.warn({ + domain: "ui.error", + event: "terminal.external_drop_paths_unresolved", + message: "Native terminal drop did not resolve to usable local paths", + ids: { session_id: sessionId }, + data: { path_count: dropPaths.length }, + }); + toast.error(t("terminal.dropPathsRequired")); + return; + } + + await handleTerminalFileDrop({ + sessionId, + sessionType, + entries: resolvedLocalEntries, + t, + duplicateStrategy: terminalAppSettings.transfer.duplicate_strategy, + }); + } catch (error) { + logger.error({ + domain: "ui.error", + event: "terminal.external_drop_failed", + message: "Failed to process terminal file drop", + ids: { session_id: sessionId }, + data: { path_count: dropPaths.length }, + error, + }); + toast.error(String(error)); + } + }, + [ + resolveLocalDropPaths, + sessionId, + sessionType, + t, + terminalAppSettings.transfer.duplicate_strategy, + ], + ); + + useTerminalFileDrop({ + sessionId, + sessionType, + enabled: visible, + containerRef, + resetExternalDropHover, + setIsExternalDropActive, + processDropPaths: processTerminalDropPaths, + externalDropPathsRequiredMessage: t("terminal.dropPathsRequired"), + }); + + const dropOverlayCopy = useMemo( + () => getTerminalDropOverlayCopy(sessionType, t), + [sessionType, t], + ); + return (
{showGutter && terminalReady && ( @@ -1659,6 +1901,10 @@ export default function XTerminal({
+ {isExternalDropActive && ( + + )} + {performanceOverlay && (
Translate, + getDuplicateStrategy: () => string = () => "ask", ): ZmodemEventHandler { let pendingProgress: Extract | null = null; let progressRaf: number | null = null; let progressTimer: number | null = null; let lastProgressWriteAt = 0; + let uploadStarted = false; + let uploadFileName = ""; + let uploadToastId: string | number | null = null; let disposed = false; const clearProgressTimer = () => { @@ -59,16 +73,26 @@ export function createZmodemEventHandler( pendingProgress = null; lastProgressWriteAt = Date.now(); + if (payload.direction === "upload") { + const fileName = payload.fileName ?? payload.file_name ?? uploadFileName; + if (fileName) { + uploadFileName = fileName; + } + if (!uploadStarted) { + uploadStarted = true; + uploadToastId = toast.message( + getT()("fileTransfer.uploadStarted", { name: uploadFileName || fileName }), + ); + } + return; + } + const fileName = payload.fileName ?? payload.file_name ?? ""; const bytesTransferred = payload.bytesTransferred ?? payload.bytes_transferred ?? 0; const totalSize = payload.totalSize ?? payload.total_size ?? 0; const percent = totalSize > 0 ? Math.round((bytesTransferred / totalSize) * 100) : 0; const t = getT(); - const msg = - payload.direction === "download" - ? t("zmodem.downloading", { fileName, percent }) - : t("zmodem.uploading", { fileName, percent }); - terminal.write(`\r\x1b[36m[ZMODEM] ${msg}\x1b[K`); + terminal.write(`\r\x1b[36m[ZMODEM] ${t("zmodem.downloading", { fileName, percent })}\x1b[K`); }; const scheduleProgressRender = () => { @@ -93,6 +117,20 @@ export function createZmodemEventHandler( renderProgress(); }; + const dismissUploadToast = () => { + if (uploadToastId !== null) { + toast.dismiss(uploadToastId); + uploadToastId = null; + } + }; + + const showUploadCompletedToast = () => { + dismissUploadToast(); + const t = getT(); + const description = uploadFileName; + toast.success(t("fileTransfer.uploadCompleted"), { description }); + }; + const handleDetected = async (payload: Extract) => { const t = getT(); const { open: openDialog } = await import("@tauri-apps/plugin-dialog"); @@ -114,14 +152,58 @@ export function createZmodemEventHandler( return; } - terminal.write(`\r\n\x1b[36m[ZMODEM] ${t("zmodem.selectFiles")}\x1b[0m\r\n`); - const files = await openDialog({ directory: false, multiple: true }); - if (disposed) return; - if (files && files.length > 0) { - const filePaths = Array.isArray(files) ? files.map(String) : [String(files)]; + uploadStarted = false; + uploadFileName = ""; + uploadToastId = null; + + const pendingPaths = getPendingZmodemUploadPaths(sessionId); + if (pendingPaths) { + markPendingZmodemUploadActive(sessionId); + // Files were already resolved in uploadFilesViaZmodem before rz was sent — + // accept immediately so the remote rz won't time out. + if (pendingPaths.length === 1) { + uploadFileName = getLocalPathName(pendingPaths[0] ?? "", "uploaded_file"); + uploadToastId = toast.message( + t("fileTransfer.uploadStarted", { name: uploadFileName }), + ); + uploadStarted = true; + } await invoke("zmodem_accept_upload", { + sessionId, + filePaths: pendingPaths, + }); + return; + } + + const selected = await openDialog({ multiple: true }); + if (disposed) return; + if (selected && selected.length > 0) { + const filePaths = selected.map(String); + + // Probe remote directory and delete conflicting files so plain rz + // never sees an existing file to prompt about. + const { paths: resolvedPaths } = await probeAndResolveRemoteConflicts( sessionId, filePaths, + getDuplicateStrategy(), + ); + + if (resolvedPaths.length === 0) { + await invoke("zmodem_cancel", { sessionId }); + terminal.write(`\r\n\x1b[33m[ZMODEM] ${t("zmodem.cancelled")}\x1b[0m\r\n`); + return; + } + + if (resolvedPaths.length === 1) { + uploadFileName = getLocalPathName(resolvedPaths[0] ?? "", "uploaded_file"); + uploadToastId = toast.message( + t("fileTransfer.uploadStarted", { name: uploadFileName }), + ); + uploadStarted = true; + } + await invoke("zmodem_accept_upload", { + sessionId, + filePaths: resolvedPaths, }); } else { await invoke("zmodem_cancel", { sessionId }); @@ -135,30 +217,67 @@ export function createZmodemEventHandler( switch (payload.type) { case "detected": + if (payload.direction === "upload" && hasPendingZmodemUpload(sessionId)) { + markPendingZmodemUploadActive(sessionId); + } void handleDetected(payload); break; case "progress": + if (payload.direction === "upload") { + markPendingZmodemUploadActive(sessionId); + } pendingProgress = payload; scheduleProgressRender(); break; case "complete": { flushProgress(); - terminal.write(`\r\n\x1b[32m[ZMODEM] ${getT()("zmodem.complete")}\x1b[0m\r\n`); + if (payload.direction === "upload") { + showUploadCompletedToast(); + completePendingZmodemUpload(sessionId); + } else { + terminal.write(`\r\n\x1b[32m[ZMODEM] ${getT()("zmodem.complete")}\x1b[0m\r\n`); + } + uploadStarted = false; + uploadFileName = ""; + uploadToastId = null; break; } - case "failed": + case "failed": { flushProgress(); - terminal.write( - `\r\n\x1b[31m[ZMODEM] ${getT()("zmodem.failed", { - reason: payload.reason, - })}\x1b[0m\r\n`, - ); + const isUpload = hasPendingZmodemUpload(sessionId) || uploadStarted; + if (isUpload) { + dismissUploadToast(); + if (payload.reason !== "cancelled") { + toast.error( + getT()("fileTransfer.uploadFailed", { + name: uploadFileName || "file", + }), + { + description: payload.reason, + }, + ); + } + failPendingZmodemUpload(sessionId, payload.reason); + } else { + terminal.write( + `\r\n\x1b[31m[ZMODEM] ${getT()("zmodem.failed", { + reason: payload.reason, + })}\x1b[0m\r\n`, + ); + } + uploadStarted = false; + uploadFileName = ""; + uploadToastId = null; break; + } } }, dispose() { disposed = true; pendingProgress = null; + uploadStarted = false; + uploadFileName = ""; + uploadToastId = null; clearProgressRaf(); clearProgressTimer(); }, diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx index d339e8a1..8b6f7598 100644 --- a/src/components/ui/alert-dialog.tsx +++ b/src/components/ui/alert-dialog.tsx @@ -139,14 +139,16 @@ function AlertDialogAction({ className, variant = "default", size = "default", + autoFocus, ...props }: React.ComponentProps & - Pick, "variant" | "size">) { + Pick, "variant" | "size"> & { autoFocus?: boolean }) { return ( diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx index fd837191..dc6d6300 100644 --- a/src/context/AppContext.tsx +++ b/src/context/AppContext.tsx @@ -160,7 +160,14 @@ function createSessionRequestId() { export type TerminalAppSettings = Pick< AppSettings, - "appearance" | "interaction" | "terminal" | "translation" | "search" | "ai" | "keybindings" + | "appearance" + | "interaction" + | "terminal" + | "translation" + | "search" + | "ai" + | "keybindings" + | "transfer" >; /** @@ -253,7 +260,7 @@ const DEFAULT_APP_SETTINGS: AppSettings = { transfer: { download_threads: 3, upload_threads: 3, - duplicate_strategy: "overwrite", + duplicate_strategy: "ask", preserve_timestamps: true, resume_broken_transfer: true, default_file_permissions: "644", @@ -1255,6 +1262,7 @@ export function AppProvider({ children }: { children: ReactNode }) { search: appSettings.search, ai: appSettings.ai, keybindings: appSettings.keybindings, + transfer: appSettings.transfer, }), [ appSettings.appearance, @@ -1264,6 +1272,7 @@ export function AppProvider({ children }: { children: ReactNode }) { appSettings.search, appSettings.ai, appSettings.keybindings, + appSettings.transfer, ], ); diff --git a/src/context/ChildAppProvider.tsx b/src/context/ChildAppProvider.tsx index e300ae5a..7e3abf8c 100644 --- a/src/context/ChildAppProvider.tsx +++ b/src/context/ChildAppProvider.tsx @@ -86,7 +86,7 @@ const DEFAULT_APP_SETTINGS: AppSettings = { transfer: { download_threads: 3, upload_threads: 3, - duplicate_strategy: "overwrite", + duplicate_strategy: "ask", preserve_timestamps: true, resume_broken_transfer: true, default_file_permissions: "644", diff --git a/src/context/TransferContext.tsx b/src/context/TransferContext.tsx index e5b03d2a..2ea6f973 100644 --- a/src/context/TransferContext.tsx +++ b/src/context/TransferContext.tsx @@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { useApp } from "@/context/AppContext"; import { invoke } from "@/lib/invoke"; +import { filterEnqueueUploadRequests } from "@/lib/transferDuplicateResolution"; export type TransferDirection = "upload" | "download"; export type TransferKind = "file" | "directory"; @@ -30,6 +31,7 @@ export interface EnqueueUploadRequest { localPath: string; remotePath: string; kind: TransferKind; + duplicateStrategyOverride?: string; } export interface EnqueueDownloadRequest { @@ -47,6 +49,7 @@ interface QueuedTransferRequest { remotePath: string; kind: TransferKind; direction: TransferDirection; + duplicateStrategyOverride?: string; } export interface TransferItem { @@ -127,6 +130,7 @@ export function TransferProvider({ children }: { children: ReactNode }) { const transferMapRef = useRef(transferMap); const queuedTransfersRef = useRef>(new Map()); const parkedTransferIdsRef = useRef>(new Set()); + const uploadFolderToastIdsRef = useRef>(new Map()); const [queueRevision, setQueueRevision] = useState(0); const transfers = useMemo(() => Array.from(transferMap.values()), [transferMap]); @@ -249,13 +253,62 @@ export function TransferProvider({ children }: { children: ReactNode }) { setQueueRevision((revision) => revision + 1); } - if (p.status === "completed" && p.direction === "download" && !p.parent_id) { - toast.success( + if (p.status === "completed" && !p.parent_id) { + if (p.direction === "download") { + toast.success( + kind === "directory" + ? t("fileTransfer.downloadFolderCompleted") + : t("fileTransfer.downloadCompleted"), + { + description: p.local_path, + }, + ); + return; + } + + if (p.direction === "upload") { + const folderToastId = uploadFolderToastIdsRef.current.get(p.id); + if (folderToastId !== undefined) { + toast.dismiss(folderToastId); + uploadFolderToastIdsRef.current.delete(p.id); + } + toast.success( + kind === "directory" + ? t("fileTransfer.uploadFolderCompleted") + : t("fileTransfer.uploadCompleted"), + { + description: p.remote_path, + }, + ); + } + return; + } + + if ( + p.status === "started" && + p.direction === "upload" && + !p.parent_id && + kind === "directory" + ) { + const toastId = toast.message( + t("fileTransfer.uploadFolderStarted", { name: p.file_name }), + ); + uploadFolderToastIdsRef.current.set(p.id, toastId); + return; + } + + if (p.status === "error" && p.direction === "upload" && !p.parent_id) { + const folderToastId = uploadFolderToastIdsRef.current.get(p.id); + if (folderToastId !== undefined) { + toast.dismiss(folderToastId); + uploadFolderToastIdsRef.current.delete(p.id); + } + toast.error( kind === "directory" - ? t("fileTransfer.downloadFolderCompleted") - : t("fileTransfer.downloadCompleted"), + ? t("fileTransfer.uploadFolderFailed", { name: p.file_name }) + : t("fileTransfer.uploadFailed", { name: p.file_name }), { - description: p.local_path, + description: p.error_msg, }, ); } @@ -366,6 +419,7 @@ export function TransferProvider({ children }: { children: ReactNode }) { localPath: request.localPath, remotePath: request.remotePath, transferId: nextQueued.id, + duplicateStrategyOverride: request.duplicateStrategyOverride, }); } else if (request.direction === "upload") { await invoke("upload_local_file", { @@ -373,6 +427,7 @@ export function TransferProvider({ children }: { children: ReactNode }) { localPath: request.localPath, remotePath: request.remotePath, transferId: nextQueued.id, + duplicateStrategyOverride: request.duplicateStrategyOverride, }); } else if (request.kind === "directory") { await invoke("download_remote_directory", { @@ -652,9 +707,20 @@ export function TransferProvider({ children }: { children: ReactNode }) { }, []); const enqueueUploads = useCallback( - (uploads: EnqueueUploadRequest[]) => - enqueueTransfers(uploads.map((upload) => ({ ...upload, direction: "upload" as const }))), - [enqueueTransfers], + (uploads: EnqueueUploadRequest[]) => { + void (async () => { + const filtered = await filterEnqueueUploadRequests( + uploads, + appSettings.transfer.duplicate_strategy, + ); + if (filtered.length === 0) { + return; + } + enqueueTransfers(filtered.map((upload) => ({ ...upload, direction: "upload" as const }))); + })(); + return []; + }, + [appSettings.transfer.duplicate_strategy, enqueueTransfers], ); const enqueueDownloads = useCallback( diff --git a/src/hooks/useTerminalFileDrop.ts b/src/hooks/useTerminalFileDrop.ts new file mode 100644 index 00000000..2b011880 --- /dev/null +++ b/src/hooks/useTerminalFileDrop.ts @@ -0,0 +1,289 @@ +import { listen } from "@tauri-apps/api/event"; +import { getCurrentWebview } from "@tauri-apps/api/webview"; +import { useEffect, useRef } from "react"; +import { toast } from "sonner"; +import { + collectExternalDropAdditionalObjects, + createExternalFileDropBridgeMessage, + DRAG_EVENT_CAPTURE_OPTIONS, + getDragEventPosition, + getExternalFileDropBridge, + isDropPositionInsideElement, + isExternalFileDragEvent, + logExternalDropBridgeFailure, + type NativeFileDropEventPayload, +} from "@/lib/nativeFileDrop"; +import type { SessionType } from "@/types/global"; + +interface UseTerminalFileDropParams { + sessionId: string; + sessionType: SessionType; + enabled: boolean; + containerRef: React.RefObject; + resetExternalDropHover: () => void; + setIsExternalDropActive: (active: boolean) => void; + processDropPaths: (dropPaths: string[]) => void | Promise; + externalDropPathsRequiredMessage: string; +} + +export function useTerminalFileDrop({ + sessionId, + sessionType, + enabled, + containerRef, + resetExternalDropHover, + setIsExternalDropActive, + processDropPaths, + externalDropPathsRequiredMessage, +}: UseTerminalFileDropParams) { + const enabledRef = useRef(enabled); + enabledRef.current = enabled; + + const sessionIdRef = useRef(sessionId); + sessionIdRef.current = sessionId; + + const sessionTypeRef = useRef(sessionType); + sessionTypeRef.current = sessionType; + + useEffect(() => { + if (!enabled) { + resetExternalDropHover(); + } + }, [enabled, resetExternalDropHover]); + + useEffect(() => { + const bridge = getExternalFileDropBridge(); + if (!bridge?.postMessageWithAdditionalObjects) { + return; + } + + const updateExternalDropState = (event: DragEvent) => { + if (!enabledRef.current || !isExternalFileDragEvent(event)) { + return; + } + + const isOverDropTarget = isDropPositionInsideElement( + getDragEventPosition(event), + containerRef.current, + ); + + if (!isOverDropTarget) { + resetExternalDropHover(); + return; + } + + event.preventDefault(); + setIsExternalDropActive(true); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = "copy"; + } + }; + + const handleWindowDragLeave = (event: DragEvent) => { + if (!enabledRef.current || !isExternalFileDragEvent(event)) { + return; + } + + event.preventDefault(); + + const leftWindow = + event.clientX <= 0 || + event.clientY <= 0 || + event.clientX >= window.innerWidth || + event.clientY >= window.innerHeight; + + if ( + leftWindow || + !isDropPositionInsideElement(getDragEventPosition(event), containerRef.current) + ) { + resetExternalDropHover(); + } + }; + + const handleWindowDrop = (event: DragEvent) => { + if (!enabledRef.current || !isExternalFileDragEvent(event)) { + return; + } + + const dropPosition = getDragEventPosition(event); + const isOverDropTarget = isDropPositionInsideElement(dropPosition, containerRef.current); + resetExternalDropHover(); + + if (!isOverDropTarget) { + return; + } + + event.preventDefault(); + + const dataTransfer = event.dataTransfer; + if (dataTransfer?.files && dataTransfer.files.length > 0) { + try { + bridge.postMessageWithAdditionalObjects( + createExternalFileDropBridgeMessage(dropPosition), + dataTransfer.files, + ); + } catch (error) { + logExternalDropBridgeFailure( + "terminal.external_drop_filelist_bridge_failed", + "Failed to bridge terminal file drop FileList through WebView2 additional objects", + error, + { + session_id: sessionIdRef.current, + session_type: sessionTypeRef.current, + file_count: dataTransfer.files.length, + }, + ); + toast.error(String(error)); + } + return; + } + + void (async () => { + try { + const additionalObjects = await collectExternalDropAdditionalObjects(dataTransfer); + if (additionalObjects.length === 0) { + toast.error(externalDropPathsRequiredMessage); + return; + } + + bridge.postMessageWithAdditionalObjects( + createExternalFileDropBridgeMessage(dropPosition), + additionalObjects, + ); + } catch (error) { + logExternalDropBridgeFailure( + "terminal.external_drop_bridge_failed", + "Failed to bridge terminal file drop through WebView2 additional objects", + error, + { + session_id: sessionIdRef.current, + session_type: sessionTypeRef.current, + }, + ); + toast.error(String(error)); + } + })(); + }; + + const handleWindowBlur = () => { + resetExternalDropHover(); + }; + + window.addEventListener("dragenter", updateExternalDropState, DRAG_EVENT_CAPTURE_OPTIONS); + window.addEventListener("dragover", updateExternalDropState, DRAG_EVENT_CAPTURE_OPTIONS); + window.addEventListener("dragleave", handleWindowDragLeave, DRAG_EVENT_CAPTURE_OPTIONS); + window.addEventListener("drop", handleWindowDrop, DRAG_EVENT_CAPTURE_OPTIONS); + window.addEventListener("blur", handleWindowBlur); + + return () => { + resetExternalDropHover(); + window.removeEventListener("dragenter", updateExternalDropState, DRAG_EVENT_CAPTURE_OPTIONS); + window.removeEventListener("dragover", updateExternalDropState, DRAG_EVENT_CAPTURE_OPTIONS); + window.removeEventListener("dragleave", handleWindowDragLeave, DRAG_EVENT_CAPTURE_OPTIONS); + window.removeEventListener("drop", handleWindowDrop, DRAG_EVENT_CAPTURE_OPTIONS); + window.removeEventListener("blur", handleWindowBlur); + }; + }, [ + containerRef, + externalDropPathsRequiredMessage, + resetExternalDropHover, + setIsExternalDropActive, + ]); + + useEffect(() => { + const bridge = getExternalFileDropBridge(); + if (!bridge?.postMessageWithAdditionalObjects) { + return; + } + + let cancelled = false; + + const unlistenPromise = listen("external-file-drop", (event) => { + if (cancelled) { + return; + } + + const payload = event.payload; + if (payload.kind === "leave") { + resetExternalDropHover(); + return; + } + + const isOverDropTarget = isDropPositionInsideElement(payload.position, containerRef.current); + const isActive = enabledRef.current && isOverDropTarget; + + if (payload.kind === "enter" || payload.kind === "over") { + setIsExternalDropActive(isActive); + return; + } + + if (payload.kind !== "drop") { + return; + } + + resetExternalDropHover(); + + if (!isActive) { + return; + } + + void processDropPaths(payload.paths); + }); + + return () => { + cancelled = true; + resetExternalDropHover(); + unlistenPromise.then((unlisten) => unlisten()); + }; + }, [containerRef, processDropPaths, resetExternalDropHover, setIsExternalDropActive]); + + useEffect(() => { + const bridge = getExternalFileDropBridge(); + if (bridge?.postMessageWithAdditionalObjects) { + return; + } + + let cancelled = false; + + const handleWindowBlur = () => { + resetExternalDropHover(); + }; + + window.addEventListener("blur", handleWindowBlur); + + const unlistenPromise = getCurrentWebview().onDragDropEvent((event) => { + if (cancelled) { + return; + } + + const payload = event.payload; + if (payload.type === "leave") { + resetExternalDropHover(); + return; + } + + const isOverDropTarget = isDropPositionInsideElement(payload.position, containerRef.current); + const isActive = enabledRef.current && isOverDropTarget; + + if (payload.type === "enter" || payload.type === "over") { + setIsExternalDropActive(isActive); + return; + } + + resetExternalDropHover(); + + if (!isActive) { + return; + } + + void processDropPaths(payload.paths); + }); + + return () => { + cancelled = true; + resetExternalDropHover(); + window.removeEventListener("blur", handleWindowBlur); + unlistenPromise.then((unlisten) => unlisten()); + }; + }, [containerRef, processDropPaths, resetExternalDropHover, setIsExternalDropActive]); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f90bbeb7..cc8dee2e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -395,7 +395,8 @@ "downloadSelected": "Download", "editPath": "Click to edit path", "emptyDirectory": "Empty", - "externalDropOverlay": "Drop to upload", + "externalDropOverlayHint": "Files and folders will be uploaded via SFTP", + "externalDropOverlayTitle": "Drop to upload", "externalDropPathsRequired": "No local paths from this drag source. Use Upload Files or Upload Folder.", "externalDropTimeout": "Windows did not provide local paths. Use Upload Files or Upload Folder.", "favoriteAdded": "Added to favorites: {{path}}", @@ -457,6 +458,7 @@ "special": "Special", "sureDelete": "Delete '{{name}}'?", "sureDeleteMultiple": "Delete {{count}} selected items?", + "deleteConfirmHint": "Press Enter to confirm, or click Delete.", "symlinkName": "Symlink Name", "symlinkTarget": "Symlink Target", "syncFailed": "Path sync failed", @@ -479,6 +481,8 @@ "clearCompleted": "Clear Done", "completed": "Completed", "delete": "Delete", + "deleteConfirmDesc": "Remove \"{{name}}\" from the transfer list?", + "deleteConfirmTitle": "Delete transfer record?", "directoryProgress": "{{completed}} / {{total}} files", "downloadCompleted": "Download done", "downloadFolderCompleted": "Folder done", @@ -495,7 +499,19 @@ "resumeAll": "Resume All", "retry": "Retry", "savePath": "Save to: {{path}}", - "transferring": "Transferring" + "transferring": "Transferring", + "uploadCompleted": "Upload complete", + "uploadFailed": "Upload failed: {{name}}", + "uploadFolderCompleted": "Folder upload complete", + "uploadFolderFailed": "Folder upload failed: {{name}}", + "uploadFolderStarted": "Uploading folder \"{{name}}\"...", + "uploadStarted": "Uploading file \"{{name}}\"...", + "duplicateTitle": "Target already exists", + "duplicateDescription": "A {{kind}} named \"{{name}}\" already exists at the destination. Choose how to proceed.", + "duplicateKindFile": "file", + "duplicateKindFolder": "folder", + "duplicateSkip": "Skip", + "duplicateOverwrite": "Overwrite" }, "lockScreen": { "message": "Locked due to inactivity.", @@ -1466,6 +1482,7 @@ "closeRight": "Close Right", "colorFailed": "Failed to set tab color", "copyFailed": "Copy failed, please retry", + "copyIp": "Copy IP Address", "copyName": "Copy Tab Name", "disconnect": "Disconnect", "disconnectFailed": "Disconnect failed", @@ -1475,6 +1492,7 @@ "multiplexSsh": "Reuse SSH Channel", "multiplexSshFailed": "Failed to reuse SSH channel", "nameCopied": "Tab name copied", + "ipCopied": "IP address copied", "reconnect": "Reconnect Session", "reconnectFailed": "Reconnect failed", "reconnectSuccess": "Reconnected", @@ -1505,6 +1523,16 @@ "allSessions": "All Sessions (A)", "click": "Click", "connectionFailed": "Connection failed", + "dropFoldersNotSupportedLocal": "Folders cannot be inserted as paths in a local terminal.", + "dropFoldersZmodemOnly": "Folders cannot be uploaded via ZMODEM. Use the file explorer for SFTP uploads.", + "dropOverlayHintLocal": "File paths will be inserted into the command line", + "dropOverlayHintSsh": "Single files prefer ZMODEM; folders or failures use SFTP", + "dropOverlayHintZmodem": "Files only, uploaded via ZMODEM", + "dropOverlayTitleLocal": "Drop to insert file paths", + "dropOverlayTitleSsh": "Drop files or folders to upload", + "dropOverlayTitleUpload": "Drop files to upload", + "dropPathsRequired": "No local paths from this drag source.", + "dropUploadFailed": "File upload failed.", "hiddenSessions": "Hidden Sessions", "largeOutputProtectionActive": "Large-output protection active", "largeOutputProtectionActiveDetail": "Rendering is prioritizing responsiveness. Skipped {{skipped}} queued characters.", @@ -1524,6 +1552,8 @@ "recentSessions": "Recent Sessions", "reconnectFailed": "Reconnect failed", "reconnecting": "Reconnecting...", + "scrollTabsLeft": "Scroll tabs left", + "scrollTabsRight": "Scroll tabs right", "sessionDisconnected": "Disconnected", "shellSessions": "Shell Sessions", "showHiddenSessions": "Show Hidden Sessions", @@ -1584,8 +1614,11 @@ "complete": "ZMODEM transfer complete", "downloading": "Receiving: {{fileName}} ({{percent}}%)", "failed": "ZMODEM transfer failed: {{reason}}", + "preparingUpload": "Preparing to upload {{count}} file(s)...", "selectFiles": "Select files to upload via ZMODEM", "selectSaveDir": "Select folder to save ZMODEM downloads", - "uploading": "Sending: {{fileName}} ({{percent}}%)" + "dragFilesHint": "Alternatively, drag files onto the terminal to upload", + "uploading": "Sending: {{fileName}} ({{percent}}%)", + "sftpProbeSkipped": "SFTP channel busy, skipping conflict detection; uploading via ZMODEM" } } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index f7932679..d70d9e20 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -395,7 +395,8 @@ "downloadSelected": "下载选中", "editPath": "点击编辑路径", "emptyDirectory": "空目录", - "externalDropOverlay": "将文件或文件夹拖放到此处上传", + "externalDropOverlayHint": "文件或文件夹将以 SFTP 方式上传", + "externalDropOverlayTitle": "拖动以上传", "externalDropPathsRequired": "此次拖拽没有暴露本机文件路径,请改用“上传文件”或“上传文件夹”。", "externalDropTimeout": "Windows 没有提供此次拖拽的本机文件路径,请改用“上传文件”或“上传文件夹”。", "favoriteAdded": "已添加到收藏夹:{{path}}", @@ -457,6 +458,7 @@ "special": "特殊", "sureDelete": "确定要删除“{{name}}”吗?", "sureDeleteMultiple": "确定要删除这 {{count}} 个已选项目吗?", + "deleteConfirmHint": "按 Enter 确认,或点击删除按钮。", "symlinkName": "符号链接名称", "symlinkTarget": "符号链接目标", "syncFailed": "同步终端路径失败", @@ -479,6 +481,8 @@ "clearCompleted": "清除已完成", "completed": "已完成", "delete": "删除", + "deleteConfirmDesc": "确定从传输列表中删除「{{name}}」吗?", + "deleteConfirmTitle": "删除传输记录", "directoryProgress": "{{completed}} / {{total}} 个文件", "downloadCompleted": "下载完成", "downloadFolderCompleted": "文件夹下载完成", @@ -495,7 +499,19 @@ "resumeAll": "全部继续", "retry": "重试", "savePath": "保存至:{{path}}", - "transferring": "传输中" + "transferring": "传输中", + "uploadCompleted": "文件上传完成", + "uploadFailed": "文件上传失败:{{name}}", + "uploadFolderCompleted": "文件夹上传完成", + "uploadFolderFailed": "文件夹上传失败:{{name}}", + "uploadFolderStarted": "正在上传文件夹「{{name}}」…", + "uploadStarted": "正在上传文件「{{name}}」…", + "duplicateTitle": "目标已存在", + "duplicateDescription": "远程路径已有同名{{kind}}「{{name}}」,请选择处理方式。", + "duplicateKindFile": "文件", + "duplicateKindFolder": "文件夹", + "duplicateSkip": "跳过", + "duplicateOverwrite": "覆盖" }, "lockScreen": { "message": "由于长时间无操作,应用程序已自动锁定。", @@ -1466,6 +1482,7 @@ "closeRight": "关闭右侧所有会话", "colorFailed": "标签颜色设置失败", "copyFailed": "复制失败,请重试", + "copyIp": "复制服务器 IP", "copyName": "复制标签名称", "disconnect": "断开连接", "disconnectFailed": "断开连接失败", @@ -1475,6 +1492,7 @@ "multiplexSsh": "复用 SSH 通道", "multiplexSshFailed": "SSH 通道复用失败", "nameCopied": "标签名称已复制", + "ipCopied": "服务器 IP 已复制", "reconnect": "重新连接会话", "reconnectFailed": "重新连接失败", "reconnectSuccess": "重新连接成功", @@ -1505,6 +1523,16 @@ "allSessions": "所有会话(A)", "click": "单击", "connectionFailed": "连接失败", + "dropFoldersNotSupportedLocal": "本地终端不支持拖入文件夹,请只拖入文件。", + "dropFoldersZmodemOnly": "文件夹无法通过 ZMODEM 上传,请使用文件浏览器进行 SFTP 上传。", + "dropOverlayHintLocal": "文件路径将插入到命令行", + "dropOverlayHintSsh": "单文件优先 ZMODEM,文件夹或失败时使用 SFTP", + "dropOverlayHintZmodem": "仅支持文件,将以 ZMODEM 方式上传", + "dropOverlayTitleLocal": "拖动以插入文件路径", + "dropOverlayTitleSsh": "拖动以上传文件或文件夹", + "dropOverlayTitleUpload": "拖动以上传文件", + "dropPathsRequired": "此次拖拽没有暴露本机文件路径。", + "dropUploadFailed": "文件上传失败。", "hiddenSessions": "被隐藏的会话", "largeOutputProtectionActive": "大流量输出保护已启用", "largeOutputProtectionActiveDetail": "终端正在优先保证响应性,已跳过 {{skipped}} 个排队字符。", @@ -1524,6 +1552,8 @@ "recentSessions": "最近的会话", "reconnectFailed": "重新连接失败", "reconnecting": "正在重新连接...", + "scrollTabsLeft": "向左滚动标签", + "scrollTabsRight": "向右滚动标签", "sessionDisconnected": "会话已断开", "shellSessions": "Shell会话", "showHiddenSessions": "显示被隐藏的会话", @@ -1584,8 +1614,11 @@ "complete": "ZMODEM 传输完成", "downloading": "接收中: {{fileName}} ({{percent}}%)", "failed": "ZMODEM 传输失败: {{reason}}", + "preparingUpload": "正在准备上传 {{count}} 个文件…", "selectFiles": "选择要通过 ZMODEM 上传的文件", "selectSaveDir": "选择 ZMODEM 下载保存文件夹", - "uploading": "发送中: {{fileName}} ({{percent}}%)" + "dragFilesHint": "或拖拽文件到终端以通过 ZMODEM 上传", + "uploading": "发送中: {{fileName}} ({{percent}}%)", + "sftpProbeSkipped": "SFTP 通道繁忙,跳过同名文件检测,直接上传" } } diff --git a/src/index.css b/src/index.css index 8dcb48a9..7b49f29b 100644 --- a/src/index.css +++ b/src/index.css @@ -132,6 +132,19 @@ } } +.tab-strip-scroll { + scrollbar-width: none; + -ms-overflow-style: none; + scroll-behavior: auto; + overscroll-behavior-x: contain; +} + +.tab-strip-scroll::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} + .terminal-scroll::-webkit-scrollbar, .custom-scrollbar::-webkit-scrollbar { width: 6px; diff --git a/src/lib/nativeFileDrop.ts b/src/lib/nativeFileDrop.ts new file mode 100644 index 00000000..8f8224bb --- /dev/null +++ b/src/lib/nativeFileDrop.ts @@ -0,0 +1,134 @@ +import { logger } from "@/lib/logger"; + +export interface NativeFileDropEventPayload { + kind: "enter" | "over" | "leave" | "drop"; + paths: string[]; + position: { + x: number; + y: number; + }; +} + +export const EXTERNAL_FILE_DROP_MESSAGE_KIND = "external-file-drop"; +export const DRAG_EVENT_CAPTURE_OPTIONS = true; + +type WebView2Bridge = { + postMessageWithAdditionalObjects: ( + message: unknown, + additionalObjects: ArrayLike, + ) => void; +}; + +type DataTransferItemWithFileSystemHandle = DataTransferItem & { + getAsFileSystemHandle?: () => Promise; +}; + +declare global { + interface Window { + chrome?: { + webview?: WebView2Bridge; + }; + } +} + +export function isDropPositionInsideElement( + position: { x: number; y: number }, + element: HTMLElement | null, +) { + if (!element) { + return false; + } + + const rect = element.getBoundingClientRect(); + const scale = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1; + const candidates = + scale === 1 ? [position] : [position, { x: position.x / scale, y: position.y / scale }]; + + return candidates.some( + ({ x, y }) => x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom, + ); +} + +export function isExternalFileDragEvent(event: DragEvent) { + const dataTransfer = event.dataTransfer; + if (!dataTransfer) { + return false; + } + + if (Array.from(dataTransfer.types ?? []).includes("Files")) { + return true; + } + + if (dataTransfer.files.length > 0) { + return true; + } + + return Array.from(dataTransfer.items ?? []).some((item) => item.kind === "file"); +} + +export function getDragEventPosition(event: DragEvent) { + return { + x: event.clientX, + y: event.clientY, + }; +} + +export function getExternalFileDropBridge() { + return window.chrome?.webview; +} + +export function createExternalFileDropBridgeMessage(position: { x: number; y: number }) { + return JSON.stringify({ + kind: EXTERNAL_FILE_DROP_MESSAGE_KIND, + position, + }); +} + +export async function collectExternalDropAdditionalObjects(dataTransfer: DataTransfer | null) { + if (!dataTransfer) { + return []; + } + + const fileItems = Array.from(dataTransfer.items ?? []).filter((item) => item.kind === "file"); + if (fileItems.length === 0 && dataTransfer.files.length > 0) { + return Array.from(dataTransfer.files); + } + + const additionalObjects: unknown[] = []; + for (const item of fileItems) { + const file = item.getAsFile(); + if (file) { + additionalObjects.push(file); + continue; + } + + const itemWithHandle = item as DataTransferItemWithFileSystemHandle; + if (typeof itemWithHandle.getAsFileSystemHandle === "function") { + try { + const handle = await itemWithHandle.getAsFileSystemHandle(); + if (handle) { + additionalObjects.push(handle); + } + } catch { + // Fall back to File objects if the runtime cannot expose FileSystemHandle. + } + } + } + + return additionalObjects; +} + +export function logExternalDropBridgeFailure( + event: string, + message: string, + error: unknown, + data?: Record, +) { + logger.error({ + domain: "ui.error", + event, + message, + data, + error, + }); +} diff --git a/src/lib/terminalFileDrop.ts b/src/lib/terminalFileDrop.ts new file mode 100644 index 00000000..2d777e10 --- /dev/null +++ b/src/lib/terminalFileDrop.ts @@ -0,0 +1,86 @@ +import { toast } from "sonner"; +import type { ResolvedLocalDropPathEntry } from "@/components/panel/file-explorer/model"; +import { sendSessionInput } from "@/lib/sessionInput"; +import { uploadFilesViaZmodem } from "@/lib/terminalZmodemUpload"; +import type { SessionType } from "@/types/global"; + +function quoteLocalPath(path: string): string { + if (!/[\s'"\\]/.test(path)) { + return path; + } + if (path.includes("\\")) { + return `"${path.replace(/"/g, '\\"')}"`; + } + return `'${path.replace(/'/g, `'\\''`)}'`; +} + +export function formatLocalTerminalDropInput(paths: string[]): string { + return paths.map(quoteLocalPath).join(" "); +} + +export function getTerminalDropOverlayCopy( + sessionType: SessionType, + t: (key: string) => string, +): { title: string; hint: string } { + switch (sessionType) { + case "Local": + return { + title: t("terminal.dropOverlayTitleLocal"), + hint: t("terminal.dropOverlayHintLocal"), + }; + case "SSH": + case "Telnet": + case "Serial": + return { + title: t("terminal.dropOverlayTitleUpload"), + hint: t("terminal.dropOverlayHintZmodem"), + }; + } +} + +export async function handleTerminalFileDrop(params: { + sessionId: string; + sessionType: SessionType; + entries: ResolvedLocalDropPathEntry[]; + t: (key: string) => string; + duplicateStrategy?: string; +}): Promise { + const { sessionId, sessionType, entries, t, duplicateStrategy = "ask" } = params; + if (entries.length === 0) { + return; + } + + const fileEntries = entries.filter((entry) => !entry.isDir); + const hasDirectories = entries.some((entry) => entry.isDir); + + if (sessionType === "Local") { + if (fileEntries.length === 0) { + toast.message(t("terminal.dropFoldersNotSupportedLocal")); + return; + } + await sendSessionInput( + sessionId, + formatLocalTerminalDropInput(fileEntries.map((entry) => entry.path)), + ); + return; + } + + // SSH / Telnet / Serial: ZMODEM only, no folder support. + if (hasDirectories) { + toast.error(t("terminal.dropFoldersZmodemOnly")); + return; + } + + if (fileEntries.length === 0) { + return; + } + + await uploadFilesViaZmodem( + sessionId, + fileEntries.map((entry) => entry.path), + duplicateStrategy, + ).catch((error) => { + toast.error(t("terminal.dropUploadFailed")); + throw error; + }); +} diff --git a/src/lib/terminalZmodemUpload.ts b/src/lib/terminalZmodemUpload.ts new file mode 100644 index 00000000..fe21a970 --- /dev/null +++ b/src/lib/terminalZmodemUpload.ts @@ -0,0 +1,287 @@ +import { invoke } from "@/lib/invoke"; +import { buildTerminalCommandInput, sendSessionInput } from "@/lib/sessionInput"; +import { showTransferDuplicatePrompt } from "@/lib/transferDuplicatePrompt"; +import { getLocalPathName } from "@/components/panel/file-explorer/model"; +import { toast } from "sonner"; +import i18n from "@/i18n"; + +const ZMODEM_UPLOAD_TIMEOUT_MS = 60_000; + +type PendingUploadPhase = "waiting" | "active"; + +type PendingUpload = { + sessionId: string; + filePaths: string[]; + resolve: () => void; + reject: (error: Error) => void; + timeoutId: number | null; + phase: PendingUploadPhase; +}; + +let pendingUpload: PendingUpload | null = null; +let preparingUploadToastId: string | number | null = null; + +function clearWaitingTimeout() { + if (pendingUpload?.timeoutId !== null && pendingUpload?.timeoutId !== undefined) { + window.clearTimeout(pendingUpload.timeoutId); + pendingUpload.timeoutId = null; + } +} + +function clearPendingUpload() { + clearWaitingTimeout(); + pendingUpload = null; + if (preparingUploadToastId !== null) { + toast.dismiss(preparingUploadToastId); + preparingUploadToastId = null; + } +} + +export function getPendingZmodemUploadPaths(sessionId: string): string[] | null { + if (!pendingUpload || pendingUpload.sessionId !== sessionId) { + return null; + } + return pendingUpload.filePaths; +} + +export function hasPendingZmodemUpload(sessionId: string): boolean { + return pendingUpload?.sessionId === sessionId; +} + +export function markPendingZmodemUploadActive(sessionId: string) { + if (!pendingUpload || pendingUpload.sessionId !== sessionId) { + return; + } + clearWaitingTimeout(); + pendingUpload.phase = "active"; + if (preparingUploadToastId !== null) { + toast.dismiss(preparingUploadToastId); + preparingUploadToastId = null; + } +} + +export function completePendingZmodemUpload(sessionId: string) { + if (!pendingUpload || pendingUpload.sessionId !== sessionId) { + return; + } + const { resolve } = pendingUpload; + clearPendingUpload(); + resolve(); +} + +export function failPendingZmodemUpload(sessionId: string, reason: string) { + if (!pendingUpload || pendingUpload.sessionId !== sessionId) { + return; + } + const { reject } = pendingUpload; + clearPendingUpload(); + reject(new Error(reason)); +} + +export function cancelPendingZmodemUpload(sessionId?: string) { + if (!pendingUpload) return; + if (sessionId && pendingUpload.sessionId !== sessionId) return; + failPendingZmodemUpload(pendingUpload.sessionId, "cancelled"); +} + +const SFTP_PROBE_TIMEOUT_MS = 3_000; + +function withTimeout(promise: Promise, ms: number, fallback: T): Promise { + return Promise.race([ + promise, + new Promise((resolve) => setTimeout(() => resolve(fallback), ms)), + ]); +} + +export interface ConflictProbeResult { + paths: string[]; + probeSkipped: boolean; +} + +/** + * Probe the remote working directory for files that match the local file + * names. Resolves conflicts according to the configured duplicate strategy. + * + * - "overwrite" / "rename": delete remote files silently, include all local paths + * - "skip": silently exclude conflicting local paths + * - "ask" (default): prompt the user per conflict + * + * SFTP operations are time-boxed — if the SFTP channel is busy (e.g. the + * file explorer panel is open), the probe is skipped, all files are uploaded + * without conflict detection, and `probeSkipped` is set to true. + */ +export async function probeAndResolveRemoteConflicts( + sessionId: string, + filePaths: string[], + duplicateStrategy = "ask", +): Promise { + if (filePaths.length === 0) return { paths: [], probeSkipped: false }; + + // Resolve remote upload directory (terminal CWD is a shell operation, not SFTP). + const cwd = await withTimeout( + invoke("get_terminal_cwd", { sessionId }).catch(() => null), + SFTP_PROBE_TIMEOUT_MS, + null, + ); + + // If CWD fails (non-SSH or timeout), try home dir via SFTP with timeout. + const home = !cwd + ? await withTimeout( + invoke("get_home_dir", { sessionId }).catch(() => "/"), + SFTP_PROBE_TIMEOUT_MS, + null as string | null, + ) + : null; + const remoteDir = (cwd || home || "/").replace(/\/+$/, "") || "/"; + + // List remote directory (SFTP, time-boxed). + const entries = await withTimeout( + invoke<{ name: string; is_dir: boolean }[] | null>( + "list_remote_dir", + { sessionId, path: remoteDir }, + ).catch(() => null), + SFTP_PROBE_TIMEOUT_MS, + null as { name: string; is_dir: boolean }[] | null, + ); + + // If the SFTP probe timed out, skip conflict detection entirely. + if (entries === null) { + toast.message(i18n.t("zmodem.sftpProbeSkipped")); + return { paths: filePaths, probeSkipped: true }; + } + + const existingNames = new Set(entries.map((e) => e.name)); + + // Separate conflicting and clean files. + const conflicts: string[] = []; + const clean: string[] = []; + for (const fp of filePaths) { + const name = getLocalPathName(fp, "file"); + if (existingNames.has(name)) { + conflicts.push(fp); + } else { + clean.push(fp); + } + } + + if (conflicts.length === 0) return { paths: filePaths, probeSkipped: false }; + + // Auto-resolve based on configured strategy. + if (duplicateStrategy === "skip") { + return { paths: clean, probeSkipped: false }; + } + + if (duplicateStrategy === "overwrite" || duplicateStrategy === "rename") { + for (const fp of conflicts) { + const name = getLocalPathName(fp, "file"); + const remotePath = + remoteDir === "/" ? `/${name}` : `${remoteDir}/${name}`; + await withTimeout( + invoke("delete_remote_file", { sessionId, path: remotePath }).catch(() => {}), + SFTP_PROBE_TIMEOUT_MS, + undefined, + ); + } + return { paths: filePaths, probeSkipped: false }; + } + + // "ask" — prompt the user for each conflict. + const resolved: string[] = [...clean]; + + for (const fp of conflicts) { + const name = getLocalPathName(fp, "file"); + const remotePath = + remoteDir === "/" ? `/${name}` : `${remoteDir}/${name}`; + + const choice = await showTransferDuplicatePrompt({ + requestId: crypto.randomUUID(), + sessionId, + remotePath, + fileName: name, + isDirectory: false, + }); + + if (choice === "overwrite") { + await withTimeout( + invoke("delete_remote_file", { sessionId, path: remotePath }).catch(() => {}), + SFTP_PROBE_TIMEOUT_MS, + undefined, + ); + resolved.push(fp); + } + // "skip" → don't add. + } + + return { paths: resolved, probeSkipped: false }; +} + +export async function uploadFilesViaZmodem( + sessionId: string, + filePaths: string[], + duplicateStrategy = "ask", +): Promise { + if (filePaths.length === 0) { + return Promise.resolve(); + } + + // Cancel any previous pending upload. + if (pendingUpload?.sessionId === sessionId && pendingUpload.phase === "waiting") { + clearPendingUpload(); + await invoke("zmodem_cancel", { sessionId }).catch(() => {}); + } else { + cancelPendingZmodemUpload(sessionId); + } + + // Show immediate feedback so the user knows the drop was received, + // even while the SFTP probe may be slow (e.g. file explorer panel open). + if (preparingUploadToastId !== null) { + toast.dismiss(preparingUploadToastId); + } + preparingUploadToastId = toast.message( + i18n.t("zmodem.preparingUpload", { count: filePaths.length }), + ); + + // Netcatty approach: probe remote directory and delete conflicting files + // BEFORE sending `rz`, so the remote rz never sees an existing file to + // prompt about. If the SFTP channel is busy (e.g. file explorer panel is + // open), the probe is time-boxed and we fall through. + const conflict = await probeAndResolveRemoteConflicts( + sessionId, + filePaths, + duplicateStrategy, + ); + + if (conflict.paths.length === 0) { + if (preparingUploadToastId !== null) { + toast.dismiss(preparingUploadToastId); + preparingUploadToastId = null; + } + throw new Error("All files were skipped or probe failed"); + } + + return new Promise((resolve, reject) => { + const timeoutId = window.setTimeout(() => { + if (pendingUpload?.sessionId !== sessionId || pendingUpload.phase !== "waiting") { + return; + } + clearPendingUpload(); + void invoke("zmodem_cancel", { sessionId }).catch(() => {}); + reject(new Error("zmodem timeout")); + }, ZMODEM_UPLOAD_TIMEOUT_MS); + + pendingUpload = { + sessionId, + filePaths: conflict.paths, + resolve, + reject, + timeoutId, + phase: "waiting", + }; + + sendSessionInput(sessionId, buildTerminalCommandInput("rz", true)).catch((error) => { + if (pendingUpload?.sessionId !== sessionId) return; + clearPendingUpload(); + reject(error instanceof Error ? error : new Error(String(error))); + }); + }); +} diff --git a/src/lib/transferDuplicatePrompt.ts b/src/lib/transferDuplicatePrompt.ts new file mode 100644 index 00000000..69fd77d7 --- /dev/null +++ b/src/lib/transferDuplicatePrompt.ts @@ -0,0 +1,70 @@ +export type TransferDuplicateChoice = "skip" | "overwrite"; + +export interface TransferDuplicateRequest { + requestId: string; + sessionId: string; + remotePath: string; + fileName: string; + isDirectory: boolean; + targetWindowLabel?: string | null; + respondViaBackend?: boolean; +} + +type TransferDuplicateListener = (request: TransferDuplicateRequest | null) => void; + +let activeRequest: TransferDuplicateRequest | null = null; +let localResolver: ((choice: TransferDuplicateChoice) => void) | null = null; +const listeners = new Set(); + +function notifyListeners() { + for (const listener of listeners) { + listener(activeRequest); + } +} + +export function subscribeTransferDuplicatePrompt(listener: TransferDuplicateListener) { + listeners.add(listener); + listener(activeRequest); + return () => { + listeners.delete(listener); + }; +} + +export function showTransferDuplicatePrompt( + request: TransferDuplicateRequest, +): Promise { + if (localResolver) { + localResolver("skip"); + } + + return new Promise((resolve) => { + localResolver = resolve; + activeRequest = { ...request, respondViaBackend: request.respondViaBackend ?? false }; + notifyListeners(); + }); +} + +export function resolveTransferDuplicatePrompt(choice: TransferDuplicateChoice) { + const resolver = localResolver; + localResolver = null; + activeRequest = null; + notifyListeners(); + resolver?.(choice); +} + +export function setBackendTransferDuplicatePrompt(request: TransferDuplicateRequest | null) { + if (request) { + activeRequest = { ...request, respondViaBackend: true }; + notifyListeners(); + return; + } + + if (activeRequest?.respondViaBackend) { + activeRequest = null; + notifyListeners(); + } +} + +export function getActiveTransferDuplicatePrompt() { + return activeRequest; +} diff --git a/src/lib/transferDuplicateResolution.ts b/src/lib/transferDuplicateResolution.ts new file mode 100644 index 00000000..ada5541e --- /dev/null +++ b/src/lib/transferDuplicateResolution.ts @@ -0,0 +1,130 @@ +import { getRemoteParentDirectory } from "@/components/panel/file-explorer/model"; +import type { EnqueueUploadRequest } from "@/context/TransferContext"; +import { invoke } from "@/lib/invoke"; +import { + showTransferDuplicatePrompt, + type TransferDuplicateChoice, +} from "@/lib/transferDuplicatePrompt"; +import type { FileEntry, FileProperties } from "@/types/global"; + +async function remotePathExists( + sessionId: string, + path: string, +): Promise<{ exists: boolean; isDirectory: boolean }> { + try { + const props = await invoke("get_file_properties", { + sessionId, + path, + }); + return { exists: true, isDirectory: props.is_dir }; + } catch { + const parentDir = getRemoteParentDirectory(path); + const fileName = path.split("/").filter(Boolean).pop() ?? ""; + if (!fileName) { + return { exists: false, isDirectory: false }; + } + + try { + const entries = await invoke("list_remote_dir", { + sessionId, + path: parentDir, + }); + const entry = entries.find((item) => item.name === fileName); + if (entry) { + return { exists: true, isDirectory: entry.is_dir }; + } + } catch { + // Fall through to "does not exist". + } + + return { exists: false, isDirectory: false }; + } +} + +async function resolveDuplicateChoice(params: { + sessionId: string; + remotePath: string; + fileName: string; + isDirectory: boolean; + duplicateStrategy: string; +}): Promise { + const { sessionId, remotePath, fileName, isDirectory, duplicateStrategy } = params; + + switch (duplicateStrategy) { + case "skip": + return "skip"; + case "overwrite": + case "rename": + return "proceed"; + case "ask": + return showTransferDuplicatePrompt({ + requestId: crypto.randomUUID(), + sessionId, + remotePath, + fileName, + isDirectory, + }); + default: + return "proceed"; + } +} + +async function resolveRemoteUploadConflict(params: { + sessionId: string; + remotePath: string; + fileName: string; + isDirectory: boolean; + duplicateStrategy: string; +}): Promise<"include" | "skip"> { + const { exists, isDirectory } = await remotePathExists(params.sessionId, params.remotePath); + if (!exists) { + return "include"; + } + + const choice = await resolveDuplicateChoice({ + sessionId: params.sessionId, + remotePath: params.remotePath, + fileName: params.fileName, + isDirectory: exists ? isDirectory : params.isDirectory, + duplicateStrategy: params.duplicateStrategy, + }); + + if (choice === "skip") { + return "skip"; + } + + return "include"; +} + +export async function filterEnqueueUploadRequests( + requests: EnqueueUploadRequest[], + duplicateStrategy: string, +): Promise { + if (duplicateStrategy !== "ask" && duplicateStrategy !== "skip") { + return requests; + } + + const filtered: EnqueueUploadRequest[] = []; + + for (const request of requests) { + const decision = await resolveRemoteUploadConflict({ + sessionId: request.sessionId, + remotePath: request.remotePath, + fileName: request.fileName, + isDirectory: request.kind === "directory", + duplicateStrategy, + }); + + if (decision === "skip") { + continue; + } + + filtered.push( + duplicateStrategy === "ask" + ? { ...request, duplicateStrategyOverride: "overwrite" } + : request, + ); + } + + return filtered; +}