From caa14ec24367e476f037b05667bc714eda47d1c1 Mon Sep 17 00:00:00 2001 From: Kang Date: Thu, 17 Sep 2026 12:57:16 +0800 Subject: [PATCH] fix(updater): include MCP sidecar in portable builds --- .github/workflows/build-main-snapshot.yml | 6 + .github/workflows/build-release.yml | 6 + src-tauri/src/portable_updater.rs | 236 +++++++++++++++++++--- 3 files changed, 220 insertions(+), 28 deletions(-) diff --git a/.github/workflows/build-main-snapshot.yml b/.github/workflows/build-main-snapshot.yml index 25a1d8a1..22e8fae5 100644 --- a/.github/workflows/build-main-snapshot.yml +++ b/.github/workflows/build-main-snapshot.yml @@ -303,6 +303,11 @@ jobs: throw "Release executable not found in $releaseDir" } + $sidecar = Join-Path "src-tauri/binaries" "nyaterm-mcp-$target.exe" + if (-not (Test-Path $sidecar)) { + throw "MCP sidecar not found at $sidecar" + } + $portableRoot = Join-Path $env:RUNNER_TEMP "NyaTerm-portable-package" $portableDir = Join-Path $portableRoot "NyaTerm-portable" $dataDir = Join-Path $portableDir "data" @@ -311,6 +316,7 @@ jobs: Remove-Item -LiteralPath $portableRoot -Recurse -Force -ErrorAction SilentlyContinue New-Item -ItemType Directory -Path $dataDir -Force | Out-Null Copy-Item -LiteralPath $exe -Destination (Join-Path $portableDir "NyaTerm.exe") + Copy-Item -LiteralPath $sidecar -Destination (Join-Path $portableDir "nyaterm-mcp.exe") New-Item -ItemType File -Path (Join-Path $portableDir "portable.flag") -Force | Out-Null New-Item -ItemType File -Path (Join-Path $dataDir ".keep") -Force | Out-Null diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 33e37f64..9334287f 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -254,6 +254,11 @@ jobs: throw "Release executable not found in $releaseDir" } + $sidecar = Join-Path "src-tauri/binaries" "nyaterm-mcp-$target.exe" + if (-not (Test-Path $sidecar)) { + throw "MCP sidecar not found at $sidecar" + } + $assetName = "NyaTerm_${version}_windows_${arch}_portable.zip" $portableRoot = Join-Path $env:RUNNER_TEMP "NyaTerm-portable-package" $portableDir = Join-Path $portableRoot "NyaTerm-portable" @@ -263,6 +268,7 @@ jobs: Remove-Item -LiteralPath $portableRoot -Recurse -Force -ErrorAction SilentlyContinue New-Item -ItemType Directory -Path $dataDir -Force | Out-Null Copy-Item -LiteralPath $exe -Destination (Join-Path $portableDir "NyaTerm.exe") + Copy-Item -LiteralPath $sidecar -Destination (Join-Path $portableDir "nyaterm-mcp.exe") New-Item -ItemType File -Path (Join-Path $portableDir "portable.flag") -Force | Out-Null New-Item -ItemType File -Path (Join-Path $dataDir ".keep") -Force | Out-Null diff --git a/src-tauri/src/portable_updater.rs b/src-tauri/src/portable_updater.rs index afc78efa..88ba9860 100644 --- a/src-tauri/src/portable_updater.rs +++ b/src-tauri/src/portable_updater.rs @@ -19,6 +19,7 @@ use crate::runtime::AppRuntime; const PORTABLE_ROOT: &str = "NyaTerm-portable"; const PORTABLE_EXE: &str = "NyaTerm.exe"; +const PORTABLE_MCP: &str = "nyaterm-mcp.exe"; const PORTABLE_MARKER: &str = "portable.flag"; const HELPER_FLAG: &str = "--nyaterm-portable-update-helper"; const CLEANUP_ENV: &str = "NYATERM_PORTABLE_UPDATE_CLEANUP"; @@ -47,6 +48,7 @@ struct StagedPortableUpdate { work_dir: PathBuf, helper_exe: PathBuf, payload_exe: PathBuf, + payload_mcp: PathBuf, payload_marker: PathBuf, } @@ -156,6 +158,7 @@ pub fn apply( if !staged.helper_exe.is_file() || !staged.payload_exe.is_file() + || !staged.payload_mcp.is_file() || !staged.payload_marker.is_file() { let _ = fs::remove_dir_all(&staged.work_dir); @@ -171,6 +174,7 @@ pub fn apply( .arg(&staged.payload_exe) .arg(&target_exe) .arg(&staged.work_dir) + .arg(&staged.payload_mcp) .spawn(); if let Err(error) = spawn_result { @@ -245,6 +249,7 @@ fn stage_verified_archive(bytes: &[u8]) -> AppResult { Ok(StagedPortableUpdate { payload_exe: payload_dir.join(PORTABLE_EXE), + payload_mcp: payload_dir.join(PORTABLE_MCP), payload_marker: payload_dir.join(PORTABLE_MARKER), helper_exe, work_dir: work_dir.clone(), @@ -268,6 +273,7 @@ fn extract_portable_payload(bytes: &[u8], destination: &Path) -> AppResult<()> { } let mut found_exe = false; + let mut found_mcp = false; let mut found_marker = false; let mut payload_bytes = 0_u64; @@ -314,6 +320,14 @@ fn extract_portable_payload(bytes: &[u8], destination: &Path) -> AppResult<()> { } found_exe = true; destination.join(PORTABLE_EXE) + } else if relative == Path::new(PORTABLE_MCP) { + if found_mcp { + return Err(AppError::Config( + "Portable update archive contains duplicate nyaterm-mcp.exe entries".into(), + )); + } + found_mcp = true; + destination.join(PORTABLE_MCP) } else if relative == Path::new(PORTABLE_MARKER) { if found_marker { return Err(AppError::Config( @@ -345,9 +359,10 @@ fn extract_portable_payload(bytes: &[u8], destination: &Path) -> AppResult<()> { } } - if !found_exe || !found_marker { + if !found_exe || !found_mcp || !found_marker { return Err(AppError::Config( - "Portable update archive is missing NyaTerm.exe or portable.flag".into(), + "Portable update archive is missing NyaTerm.exe, nyaterm-mcp.exe, or portable.flag" + .into(), )); } Ok(()) @@ -374,7 +389,7 @@ pub fn run_helper_if_requested() -> bool { } fn run_helper(args: &[OsString]) -> AppResult<()> { - if args.len() != 6 { + if args.len() != 7 { return Err(AppError::Config( "Invalid portable update helper arguments".into(), )); @@ -386,46 +401,120 @@ fn run_helper(args: &[OsString]) -> AppResult<()> { let source_exe = PathBuf::from(&args[3]); let target_exe = PathBuf::from(&args[4]); let work_dir = PathBuf::from(&args[5]); + let source_mcp = PathBuf::from(&args[6]); wait_for_process_exit(parent_pid)?; - replace_executable(&source_exe, &target_exe)?; + replace_portable_files(&source_exe, &target_exe, &source_mcp)?; Command::new(&target_exe) .env(CLEANUP_ENV, &work_dir) .spawn()?; Ok(()) } -fn replace_executable(source_exe: &Path, target_exe: &Path) -> AppResult<()> { +fn replace_portable_files( + source_exe: &Path, + target_exe: &Path, + source_mcp: &Path, +) -> AppResult<()> { let target_dir = target_exe .parent() .ok_or_else(|| AppError::Config("Portable executable has no parent directory".into()))?; + let target_mcp = target_dir.join(PORTABLE_MCP); let new_exe = target_dir.join(".nyaterm-update-new.exe"); let backup_exe = target_dir.join(".nyaterm-update-backup.exe"); + let new_mcp = target_dir.join(".nyaterm-mcp-update-new.exe"); + let backup_mcp = target_dir.join(".nyaterm-mcp-update-backup.exe"); let _ = fs::remove_file(&new_exe); let _ = fs::remove_file(&backup_exe); + let _ = fs::remove_file(&new_mcp); + let _ = fs::remove_file(&backup_mcp); fs::copy(source_exe, &new_exe)?; + if let Err(error) = fs::copy(source_mcp, &new_mcp) { + let _ = fs::remove_file(&new_exe); + return Err(error.into()); + } - commit_executable(&new_exe, target_exe, &backup_exe, |from, to| { - fs::rename(from, to) - })?; - let _ = fs::remove_file(backup_exe); - Ok(()) + let result = commit_portable_files( + &new_exe, + target_exe, + &backup_exe, + &new_mcp, + &target_mcp, + &backup_mcp, + |from, to| fs::rename(from, to), + ); + let _ = fs::remove_file(&new_exe); + let _ = fs::remove_file(&new_mcp); + if result.is_ok() { + let _ = fs::remove_file(&backup_exe); + let _ = fs::remove_file(&backup_mcp); + } + result } -fn commit_executable( +fn commit_portable_files( new_exe: &Path, target_exe: &Path, backup_exe: &Path, - move_new: F, + new_mcp: &Path, + target_mcp: &Path, + backup_mcp: &Path, + move_file: F, ) -> AppResult<()> where - F: FnOnce(&Path, &Path) -> std::io::Result<()>, + F: Fn(&Path, &Path) -> std::io::Result<()>, { - fs::rename(target_exe, backup_exe)?; - if let Err(error) = move_new(new_exe, target_exe) { + move_file(target_exe, backup_exe)?; + let had_mcp = target_mcp.is_file(); + if target_mcp.exists() && !had_mcp { + let _ = fs::rename(backup_exe, target_exe); + return Err(AppError::Config(format!( + "Portable MCP target is not a file: {}", + target_mcp.display() + ))); + } + + if had_mcp && let Err(error) = move_file(target_mcp, backup_mcp) { if let Err(rollback_error) = fs::rename(backup_exe, target_exe) { return Err(AppError::Config(format!( - "Failed to install the portable update ({error}) and restore the previous executable ({rollback_error})" + "Failed to prepare nyaterm-mcp.exe for update ({error}) and restore NyaTerm.exe ({rollback_error})" + ))); + } + return Err(error.into()); + } + + if let Err(error) = move_file(new_exe, target_exe) { + let mut rollback_errors = Vec::new(); + if let Err(rollback_error) = fs::rename(backup_exe, target_exe) { + rollback_errors.push(format!("NyaTerm.exe: {rollback_error}")); + } + if had_mcp && let Err(rollback_error) = fs::rename(backup_mcp, target_mcp) { + rollback_errors.push(format!("nyaterm-mcp.exe: {rollback_error}")); + } + if !rollback_errors.is_empty() { + return Err(AppError::Config(format!( + "Failed to install NyaTerm.exe ({error}) and restore the previous files ({})", + rollback_errors.join(", ") + ))); + } + return Err(error.into()); + } + + if let Err(error) = move_file(new_mcp, target_mcp) { + let mut rollback_errors = Vec::new(); + if let Err(rollback_error) = fs::remove_file(target_exe) { + rollback_errors.push(format!("remove new NyaTerm.exe: {rollback_error}")); + } + if let Err(rollback_error) = fs::rename(backup_exe, target_exe) { + rollback_errors.push(format!("restore NyaTerm.exe: {rollback_error}")); + } + if had_mcp && let Err(rollback_error) = fs::rename(backup_mcp, target_mcp) { + rollback_errors.push(format!("restore nyaterm-mcp.exe: {rollback_error}")); + } + if !rollback_errors.is_empty() { + return Err(AppError::Config(format!( + "Failed to install nyaterm-mcp.exe ({error}) and restore the previous files ({})", + rollback_errors.join(", ") ))); } return Err(error.into()); @@ -566,6 +655,7 @@ mod tests { let destination = test_dir("portable-extract"); let bytes = archive(&[ ("NyaTerm-portable/NyaTerm.exe", b"new-exe"), + ("NyaTerm-portable/nyaterm-mcp.exe", b"new-mcp"), ("NyaTerm-portable/portable.flag", b""), ("NyaTerm-portable/data/.keep", b"package-data"), ]); @@ -576,6 +666,10 @@ mod tests { fs::read(destination.join(PORTABLE_EXE)).unwrap(), b"new-exe" ); + assert_eq!( + fs::read(destination.join(PORTABLE_MCP)).unwrap(), + b"new-mcp" + ); assert!(destination.join(PORTABLE_MARKER).is_file()); assert!(!destination.join("data").exists()); fs::remove_dir_all(destination).unwrap(); @@ -586,37 +680,123 @@ mod tests { let destination = test_dir("portable-invalid"); let unsafe_archive = archive(&[ ("../NyaTerm.exe", b"bad"), + ("NyaTerm-portable/nyaterm-mcp.exe", b"new-mcp"), ("NyaTerm-portable/portable.flag", b""), ]); assert!(extract_portable_payload(&unsafe_archive, &destination).is_err()); let nested_escape = archive(&[ ("NyaTerm-portable/NyaTerm.exe", b"new-exe"), + ("NyaTerm-portable/nyaterm-mcp.exe", b"new-mcp"), ("NyaTerm-portable/portable.flag", b""), ("NyaTerm-portable/data/../../escape", b"bad"), ]); assert!(extract_portable_payload(&nested_escape, &destination).is_err()); - let missing_marker = archive(&[("NyaTerm-portable/NyaTerm.exe", b"new-exe")]); + let missing_marker = archive(&[ + ("NyaTerm-portable/NyaTerm.exe", b"new-exe"), + ("NyaTerm-portable/nyaterm-mcp.exe", b"new-mcp"), + ]); assert!(extract_portable_payload(&missing_marker, &destination).is_err()); + + let missing_mcp = archive(&[ + ("NyaTerm-portable/NyaTerm.exe", b"new-exe"), + ("NyaTerm-portable/portable.flag", b""), + ]); + assert!(extract_portable_payload(&missing_mcp, &destination).is_err()); fs::remove_dir_all(destination).unwrap(); } #[test] - fn executable_commit_rolls_back_when_final_move_fails() { - let directory = test_dir("portable-rollback"); - let target = directory.join(PORTABLE_EXE); - let new_exe = directory.join("new.exe"); - let backup = directory.join("backup.exe"); - fs::write(&target, b"old").unwrap(); - fs::write(&new_exe, b"new").unwrap(); + fn portable_replace_installs_mcp_when_missing() { + let directory = test_dir("portable-replace"); + let source_dir = directory.join("source"); + fs::create_dir(&source_dir).unwrap(); + let source_exe = source_dir.join(PORTABLE_EXE); + let source_mcp = source_dir.join(PORTABLE_MCP); + let target_exe = directory.join(PORTABLE_EXE); + let target_mcp = directory.join(PORTABLE_MCP); + fs::write(&source_exe, b"new-exe").unwrap(); + fs::write(&source_mcp, b"new-mcp").unwrap(); + fs::write(&target_exe, b"old-exe").unwrap(); - let result = commit_executable(&new_exe, &target, &backup, |_, _| { - Err(std::io::Error::other("simulated failure")) - }); + replace_portable_files(&source_exe, &target_exe, &source_mcp).unwrap(); + + assert_eq!(fs::read(&target_exe).unwrap(), b"new-exe"); + assert_eq!(fs::read(&target_mcp).unwrap(), b"new-mcp"); + assert!(!directory.join(".nyaterm-update-backup.exe").exists()); + assert!(!directory.join(".nyaterm-mcp-update-backup.exe").exists()); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn portable_commit_rolls_back_both_files_when_mcp_install_fails() { + let directory = test_dir("portable-rollback"); + let target_exe = directory.join(PORTABLE_EXE); + let target_mcp = directory.join(PORTABLE_MCP); + let new_exe = directory.join("new.exe"); + let new_mcp = directory.join("new-mcp.exe"); + let backup_exe = directory.join("backup.exe"); + let backup_mcp = directory.join("backup-mcp.exe"); + fs::write(&target_exe, b"old-exe").unwrap(); + fs::write(&target_mcp, b"old-mcp").unwrap(); + fs::write(&new_exe, b"new-exe").unwrap(); + fs::write(&new_mcp, b"new-mcp").unwrap(); + + let result = commit_portable_files( + &new_exe, + &target_exe, + &backup_exe, + &new_mcp, + &target_mcp, + &backup_mcp, + |from, to| { + if from == new_mcp { + Err(std::io::Error::other("simulated MCP install failure")) + } else { + fs::rename(from, to) + } + }, + ); assert!(result.is_err()); - assert_eq!(fs::read(&target).unwrap(), b"old"); + assert_eq!(fs::read(&target_exe).unwrap(), b"old-exe"); + assert_eq!(fs::read(&target_mcp).unwrap(), b"old-mcp"); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn portable_commit_restores_exe_when_first_mcp_install_fails() { + let directory = test_dir("portable-first-mcp"); + let target_exe = directory.join(PORTABLE_EXE); + let target_mcp = directory.join(PORTABLE_MCP); + let new_exe = directory.join("new.exe"); + let new_mcp = directory.join("new-mcp.exe"); + let backup_exe = directory.join("backup.exe"); + let backup_mcp = directory.join("backup-mcp.exe"); + fs::write(&target_exe, b"old-exe").unwrap(); + fs::write(&new_exe, b"new-exe").unwrap(); + fs::write(&new_mcp, b"new-mcp").unwrap(); + + let result = commit_portable_files( + &new_exe, + &target_exe, + &backup_exe, + &new_mcp, + &target_mcp, + &backup_mcp, + |from, to| { + if from == new_mcp { + Err(std::io::Error::other("simulated MCP install failure")) + } else { + fs::rename(from, to) + } + }, + ); + + assert!(result.is_err()); + assert_eq!(fs::read(&target_exe).unwrap(), b"old-exe"); + assert!(!target_mcp.exists()); fs::remove_dir_all(directory).unwrap(); } }