fix(update): apply staged Windows replacement (#85)

Co-authored-by: NianJiuZst <3235467914@qq.com>
This commit is contained in:
BB-fat
2026-08-12 19:45:39 +08:00
committed by GitHub
co-authored by NianJiuZst
parent 08212497f1
commit 0ce497f017
+96 -18
View File
@@ -5,6 +5,11 @@ use std::io::{Cursor, Read, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
#[cfg(windows)]
use std::os::windows::process::CommandExt;
#[cfg(windows)]
use std::process::{Command, Stdio};
use anyhow::{Context, Result, bail};
use clap::Args;
use flate2::read::GzDecoder;
@@ -265,20 +270,33 @@ fn run(args: UpdateArgs, format: Format) -> Result<()> {
InstallAction::Replaced => "replaced",
InstallAction::Staged => "staged",
};
let (status, message) = match action {
InstallAction::Replaced => (
"updated",
format!(
"updated bsk from {} to {}",
candidate.current, candidate.latest
),
),
InstallAction::Staged => (
"staged",
format!(
"staged bsk {} for replacement after this command exits",
candidate.latest
),
),
};
render_report(
format,
&UpdateReport {
status: "updated",
status,
current_version: candidate.current.to_string(),
latest_version: Some(candidate.latest.to_string()),
release_url: candidate.release_url,
asset_url: Some(candidate.asset.url),
install_action: Some(action_label),
message: format!(
"updated bsk from {} to {}",
candidate.current, candidate.latest
),
message,
},
)
}
@@ -345,7 +363,7 @@ fn install_candidate_with_client(
crate::daemon::start::run_stop().context("stop bsk daemon before update")?;
}
let action = replace_binary_at_path(&target, &binary)?;
let action = replace_binary_for_update(&target, &binary, daemon_was_running)?;
if daemon_was_running && matches!(action, InstallAction::Replaced) {
crate::daemon::start::run_start(StartArgs::default())
@@ -649,9 +667,7 @@ fn render_report(format: Format, report: &UpdateReport) -> Result<()> {
println!("release: {release_url}");
}
if matches!(report.install_action, Some("staged")) {
println!(
"restart your terminal and run `bsk --version` to verify the staged update"
);
println!("the detached update helper will apply the replacement after exit");
}
}
Format::Json => {
@@ -672,13 +688,22 @@ pub fn extract_bsk_binary(archive_bytes: &[u8], kind: ArchiveKind) -> Result<Vec
}
pub fn replace_binary_at_path(target: &Path, binary: &[u8]) -> Result<InstallAction> {
replace_binary_for_update(target, binary, false)
}
fn replace_binary_for_update(
target: &Path,
binary: &[u8],
restart_daemon: bool,
) -> Result<InstallAction> {
#[cfg(windows)]
{
stage_windows_replacement(target, binary)
stage_windows_replacement(target, binary, restart_daemon)
}
#[cfg(not(windows))]
{
let _ = restart_daemon;
replace_binary_atomically(target, binary)
}
}
@@ -721,15 +746,35 @@ fn replace_binary_atomically(target: &Path, binary: &[u8]) -> Result<InstallActi
}
#[cfg(windows)]
fn stage_windows_replacement(target: &Path, binary: &[u8]) -> Result<InstallAction> {
fn stage_windows_replacement(
target: &Path,
binary: &[u8],
restart_daemon: bool,
) -> Result<InstallAction> {
let paths = staged_replacement_paths(target, std::process::id())?;
std::fs::write(&paths.binary_path, binary)
.with_context(|| format!("write {}", paths.binary_path.display()))?;
std::fs::write(
&paths.script_path,
windows_replacement_script(target, &paths.binary_path),
windows_replacement_script(target, &paths.binary_path, restart_daemon),
)
.with_context(|| format!("write {}", paths.script_path.display()))?;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let command = format!("\"{}\"", paths.script_path.display());
if let Err(err) = Command::new("cmd.exe")
.args(["/D", "/S", "/C"])
.arg(command)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.creation_flags(CREATE_NO_WINDOW)
.spawn()
{
let _ = std::fs::remove_file(&paths.binary_path);
let _ = std::fs::remove_file(&paths.script_path);
return Err(err).context("launch detached Windows update helper");
}
Ok(InstallAction::Staged)
}
@@ -746,19 +791,26 @@ pub fn staged_replacement_paths(target: &Path, pid: u32) -> Result<StagedReplace
})
}
#[cfg(windows)]
fn windows_replacement_script(target: &Path, staged_binary: &Path) -> String {
#[cfg(any(windows, test))]
fn windows_replacement_script(target: &Path, staged_binary: &Path, restart_daemon: bool) -> String {
let target = target.display().to_string().replace('%', "%%");
let staged_binary = staged_binary.display().to_string().replace('%', "%%");
let restart = if restart_daemon {
format!("start \"\" /B \"{target}\" daemon start >nul 2>nul\r\n")
} else {
String::new()
};
format!(
"@echo off\r\n\
setlocal\r\n\
:retry\r\n\
move /Y \"{}\" \"{}\" >nul 2>nul\r\n\
move /Y \"{staged_binary}\" \"{target}\" >nul 2>nul\r\n\
if errorlevel 1 (\r\n\
timeout /t 1 /nobreak >nul\r\n\
goto retry\r\n\
)\r\n",
staged_binary.display(),
target.display()
)\r\n\
{restart}\
del /F /Q \"%~f0\" >nul 2>nul\r\n"
)
}
@@ -945,6 +997,32 @@ mod tests {
);
}
#[test]
fn windows_replacement_script_retries_restarts_and_cleans_itself() {
let script = windows_replacement_script(
Path::new(r"C:\Program Files\bsk.exe"),
Path::new(r"C:\Program Files\bsk.exe.update-42"),
true,
);
assert!(script.contains(":retry"));
assert!(script.contains("move /Y"));
assert!(script.contains(r#"start "" /B "C:\Program Files\bsk.exe" daemon start"#));
assert!(script.contains(r#"del /F /Q "%~f0""#));
}
#[test]
fn windows_replacement_script_omits_restart_when_daemon_was_not_running() {
let script = windows_replacement_script(
Path::new(r"C:\bsk.exe"),
Path::new(r"C:\bsk.exe.update-42"),
false,
);
assert!(!script.contains("daemon start"));
assert!(script.contains(r#"del /F /Q "%~f0""#));
}
#[test]
fn cache_freshness_uses_epoch_seconds() {
let cache = UpdateCheckCache {