mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
feat(update): auto-upgrade bsk when daemon finds a newer version (#77)
The daemon's periodic update check now installs a newer release instead of only refreshing the hint cache: it reuses the update.rs install path (download, sha256 verify, extract, replace), skips the round while any agent session is live, and otherwise restarts itself into the new binary (spawns a detached replacement that waits for the old pid, then exits). On by default; BSK_AUTO_UPDATE=off keeps the old cache/hint-only behaviour. Windows can only stage the replacement, so it just logs that a restart is needed. The CLI hint wording reflects the switch.
This commit is contained in:
@@ -35,6 +35,11 @@ pub(crate) const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60);
|
||||
pub(crate) const DAEMON_REFRESH_WINDOW: Duration =
|
||||
Duration::from_secs(UPDATE_CHECK_INTERVAL.as_secs() * 5 / 6);
|
||||
|
||||
/// Environment variable that switches daemon-side auto-upgrade off.
|
||||
/// Unset, or any value other than `off` (compared case-insensitively,
|
||||
/// surrounding whitespace ignored), keeps auto-upgrade on.
|
||||
pub(crate) const AUTO_UPDATE_ENV: &str = "BSK_AUTO_UPDATE";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpdateManifest {
|
||||
pub version: Version,
|
||||
@@ -332,16 +337,7 @@ fn install_candidate_with_client(
|
||||
restart_daemon: bool,
|
||||
client: &reqwest::blocking::Client,
|
||||
) -> Result<InstallAction> {
|
||||
let expected_sha = candidate.asset.sha256.as_deref().with_context(|| {
|
||||
format!(
|
||||
"release {} does not include a sha256 checksum; cannot safely auto-update",
|
||||
candidate.tag
|
||||
)
|
||||
})?;
|
||||
let archive = fetch_bytes_with_client(client, &candidate.asset.url)?;
|
||||
verify_sha256(&archive, expected_sha)?;
|
||||
let kind = ArchiveKind::from_url(&candidate.asset.url)?;
|
||||
let binary = extract_bsk_binary(&archive, kind)?;
|
||||
let binary = download_candidate_binary(candidate, client)?;
|
||||
let target = std::env::current_exe().context("locate current bsk executable")?;
|
||||
|
||||
let daemon_was_running = restart_daemon && crate::daemon::info::read_valid()?.is_some();
|
||||
@@ -359,6 +355,90 @@ fn install_candidate_with_client(
|
||||
Ok(action)
|
||||
}
|
||||
|
||||
/// Download the candidate's release archive, verify its sha256 checksum,
|
||||
/// and extract the `bsk` binary. Archives without a checksum are
|
||||
/// refused — auto-update never installs unverifiable bytes.
|
||||
pub(crate) fn download_candidate_binary(
|
||||
candidate: &UpdateCandidate,
|
||||
client: &reqwest::blocking::Client,
|
||||
) -> Result<Vec<u8>> {
|
||||
let expected_sha = candidate.asset.sha256.as_deref().with_context(|| {
|
||||
format!(
|
||||
"release {} does not include a sha256 checksum; cannot safely auto-update",
|
||||
candidate.tag
|
||||
)
|
||||
})?;
|
||||
let archive = fetch_bytes_with_client(client, &candidate.asset.url)?;
|
||||
verify_sha256(&archive, expected_sha)?;
|
||||
let kind = ArchiveKind::from_url(&candidate.asset.url)?;
|
||||
extract_bsk_binary(&archive, kind)
|
||||
}
|
||||
|
||||
/// Daemon-side install: download, verify, and replace the executable at
|
||||
/// `target` (on Windows: stage the replacement next to it). Unlike the
|
||||
/// CLI path this never stops or starts the daemon — the daemon drives
|
||||
/// its own restart once the binary is replaced.
|
||||
///
|
||||
/// `target` must be captured *before* any replacement happens: on Linux
|
||||
/// `std::env::current_exe` starts returning a ` (deleted)`-suffixed
|
||||
/// path once the running binary has been replaced on disk.
|
||||
pub(crate) fn self_install_candidate(
|
||||
candidate: &UpdateCandidate,
|
||||
target: &Path,
|
||||
) -> Result<InstallAction> {
|
||||
let client = update_http_client(ARCHIVE_FETCH_TIMEOUT)?;
|
||||
let binary = download_candidate_binary(candidate, &client)?;
|
||||
replace_binary_at_path(target, &binary)
|
||||
}
|
||||
|
||||
/// Outcome of one daemon auto-update step (see [`auto_update_step`]).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum AutoUpdateOutcome {
|
||||
/// The manifest names no newer version.
|
||||
UpToDate,
|
||||
/// A newer version exists but auto-update is switched off — the
|
||||
/// refreshed cache still feeds the CLI hint.
|
||||
Disabled { latest: String },
|
||||
/// Live agent sessions block replacing the binary; the next tick
|
||||
/// retries.
|
||||
PostponedSessions { latest: String, sessions: usize },
|
||||
/// The new binary replaced the old one; the daemon should now
|
||||
/// restart into it.
|
||||
Replaced { latest: String },
|
||||
/// Windows staged the replacement next to the running binary; a
|
||||
/// daemon/terminal restart finishes it.
|
||||
Staged { latest: String },
|
||||
}
|
||||
|
||||
/// The daemon's auto-update step for one tick: decide whether the
|
||||
/// fetched `candidate` may be installed (auto-update on, no live agent
|
||||
/// sessions) and, only then, run `install`. The installer is injectable
|
||||
/// so tests never touch a real binary.
|
||||
pub(crate) fn auto_update_step(
|
||||
candidate: Option<&UpdateCandidate>,
|
||||
auto_update_enabled: bool,
|
||||
active_sessions: usize,
|
||||
install: impl FnOnce(&UpdateCandidate) -> Result<InstallAction>,
|
||||
) -> Result<AutoUpdateOutcome> {
|
||||
let Some(candidate) = candidate else {
|
||||
return Ok(AutoUpdateOutcome::UpToDate);
|
||||
};
|
||||
let latest = candidate.latest.to_string();
|
||||
if !auto_update_enabled {
|
||||
return Ok(AutoUpdateOutcome::Disabled { latest });
|
||||
}
|
||||
if active_sessions > 0 {
|
||||
return Ok(AutoUpdateOutcome::PostponedSessions {
|
||||
latest,
|
||||
sessions: active_sessions,
|
||||
});
|
||||
}
|
||||
Ok(match install(candidate)? {
|
||||
InstallAction::Replaced => AutoUpdateOutcome::Replaced { latest },
|
||||
InstallAction::Staged => AutoUpdateOutcome::Staged { latest },
|
||||
})
|
||||
}
|
||||
|
||||
pub fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> {
|
||||
let actual = hex_sha256(bytes);
|
||||
let expected = expected_hex.trim().to_ascii_lowercase();
|
||||
@@ -372,28 +452,51 @@ fn manifest_url() -> String {
|
||||
std::env::var("BSK_UPDATE_MANIFEST_URL").unwrap_or_else(|_| DEFAULT_MANIFEST_URL.to_string())
|
||||
}
|
||||
|
||||
/// Whether daemon-side auto-upgrade is enabled. On by default; only
|
||||
/// [`AUTO_UPDATE_ENV`]`=off` disables it. This is the single place the
|
||||
/// switch is interpreted — the daemon periodic task and the CLI hint
|
||||
/// both go through it so they always agree.
|
||||
pub(crate) fn auto_update_enabled() -> bool {
|
||||
auto_update_enabled_from(std::env::var(AUTO_UPDATE_ENV).ok().as_deref())
|
||||
}
|
||||
|
||||
fn auto_update_enabled_from(value: Option<&str>) -> bool {
|
||||
!matches!(value, Some(value) if value.trim().eq_ignore_ascii_case("off"))
|
||||
}
|
||||
|
||||
pub fn update_hint_for_manifest(
|
||||
manifest: &UpdateManifest,
|
||||
current_version: &str,
|
||||
platform_key: &str,
|
||||
auto_update: bool,
|
||||
) -> Result<Option<String>> {
|
||||
Ok(manifest
|
||||
.update_candidate(current_version, platform_key)?
|
||||
.map(|candidate| {
|
||||
format!(
|
||||
"A new bsk version is available: {} -> {}. Run `bsk update`.",
|
||||
candidate.current, candidate.latest
|
||||
)
|
||||
}))
|
||||
.map(|candidate| update_hint_text(&candidate.current, &candidate.latest, auto_update)))
|
||||
}
|
||||
|
||||
fn update_hint_for_cache(cache: &UpdateCheckCache, current_version: &str) -> Option<String> {
|
||||
fn update_hint_for_cache(
|
||||
cache: &UpdateCheckCache,
|
||||
current_version: &str,
|
||||
auto_update: bool,
|
||||
) -> Option<String> {
|
||||
let latest = cache.latest_version.as_str();
|
||||
let current = Version::parse(current_version.trim_start_matches('v')).ok()?;
|
||||
let latest_version = Version::parse(latest.trim_start_matches('v')).ok()?;
|
||||
(latest_version > current).then(|| {
|
||||
format!("A new bsk version is available: {current} -> {latest_version}. Run `bsk update`.")
|
||||
})
|
||||
(latest_version > current).then(|| update_hint_text(¤t, &latest_version, auto_update))
|
||||
}
|
||||
|
||||
/// CLI hint wording. With auto-update on, the daemon upgrades bsk
|
||||
/// itself so the hint only announces that; with it off, the hint keeps
|
||||
/// pointing at the manual `bsk update`.
|
||||
fn update_hint_text(current: &Version, latest: &Version, auto_update: bool) -> String {
|
||||
if auto_update {
|
||||
format!(
|
||||
"A new bsk version is available: {current} -> {latest}. The daemon will upgrade bsk automatically."
|
||||
)
|
||||
} else {
|
||||
format!("A new bsk version is available: {current} -> {latest}. Run `bsk update`.")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_update_cache(path: &Path) -> Result<Option<UpdateCheckCache>> {
|
||||
@@ -458,12 +561,12 @@ pub(crate) fn cache_needs_refresh(
|
||||
}
|
||||
|
||||
/// Fetch the update manifest and rewrite the cache. Returns the update
|
||||
/// hint when a newer version exists. Blocking: call from a blocking
|
||||
/// context (the daemon wraps it in `spawn_blocking`).
|
||||
pub(crate) fn refresh_update_cache(cache_path: &Path) -> Result<Option<String>> {
|
||||
/// candidate when the manifest names a newer version. Blocking: call
|
||||
/// from a blocking context (the daemon wraps it in `spawn_blocking`).
|
||||
pub(crate) fn refresh_update_cache(cache_path: &Path) -> Result<Option<UpdateCandidate>> {
|
||||
let manifest = fetch_manifest(&manifest_url())?;
|
||||
let platform = current_platform_key()?;
|
||||
let hint = update_hint_for_manifest(&manifest, env!("CARGO_PKG_VERSION"), platform)?;
|
||||
let candidate = manifest.update_candidate(env!("CARGO_PKG_VERSION"), platform)?;
|
||||
write_update_cache(
|
||||
cache_path,
|
||||
&UpdateCheckCache {
|
||||
@@ -471,7 +574,7 @@ pub(crate) fn refresh_update_cache(cache_path: &Path) -> Result<Option<String>>
|
||||
latest_version: manifest.version.to_string(),
|
||||
},
|
||||
)?;
|
||||
Ok(hint)
|
||||
Ok(candidate)
|
||||
}
|
||||
|
||||
/// Print the cached "new version available" hint, if there is one.
|
||||
@@ -496,7 +599,12 @@ pub fn print_update_hint_from_cache(flags: &super::GlobalFlags, command: &super:
|
||||
let Ok(cache_path) = crate::daemon::paths::update_check_path() else {
|
||||
return;
|
||||
};
|
||||
match cached_update_hint(&cache_path, env!("CARGO_PKG_VERSION"), now_epoch_secs()) {
|
||||
match cached_update_hint(
|
||||
&cache_path,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
now_epoch_secs(),
|
||||
auto_update_enabled(),
|
||||
) {
|
||||
Ok(Some(hint)) => eprintln!("{hint}"),
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
@@ -511,6 +619,7 @@ fn cached_update_hint(
|
||||
cache_path: &Path,
|
||||
current_version: &str,
|
||||
now_epoch_secs: u64,
|
||||
auto_update: bool,
|
||||
) -> Result<Option<String>> {
|
||||
let Some(cache) = read_update_cache(cache_path)? else {
|
||||
return Ok(None);
|
||||
@@ -518,7 +627,7 @@ fn cached_update_hint(
|
||||
if !cache.is_fresh(now_epoch_secs, UPDATE_CHECK_INTERVAL) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(update_hint_for_cache(&cache, current_version))
|
||||
Ok(update_hint_for_cache(&cache, current_version, auto_update))
|
||||
}
|
||||
|
||||
fn confirm_update(candidate: &UpdateCandidate) -> Result<bool> {
|
||||
@@ -864,13 +973,22 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let hint = update_hint_for_manifest(&manifest, "0.1.7", "linux-x64").unwrap();
|
||||
// Auto-update off: the hint keeps pointing at `bsk update`.
|
||||
let hint = update_hint_for_manifest(&manifest, "0.1.7", "linux-x64", false).unwrap();
|
||||
assert_eq!(
|
||||
hint.as_deref(),
|
||||
Some("A new bsk version is available: 0.1.7 -> 0.2.0. Run `bsk update`.")
|
||||
);
|
||||
// Auto-update on: the daemon upgrades bsk itself.
|
||||
let hint = update_hint_for_manifest(&manifest, "0.1.7", "linux-x64", true).unwrap();
|
||||
assert_eq!(
|
||||
hint.as_deref(),
|
||||
Some(
|
||||
"A new bsk version is available: 0.1.7 -> 0.2.0. The daemon will upgrade bsk automatically."
|
||||
)
|
||||
);
|
||||
assert!(
|
||||
update_hint_for_manifest(&manifest, "0.2.0", "linux-x64")
|
||||
update_hint_for_manifest(&manifest, "0.2.0", "linux-x64", true)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
@@ -958,18 +1076,28 @@ mod tests {
|
||||
let now = now_epoch_secs();
|
||||
|
||||
// Missing cache -> no hint.
|
||||
assert_eq!(cached_update_hint(&path, "0.1.7", now).unwrap(), None);
|
||||
assert_eq!(
|
||||
cached_update_hint(&path, "0.1.7", now, false).unwrap(),
|
||||
None
|
||||
);
|
||||
|
||||
// Fresh cache with a newer version -> hint.
|
||||
// Fresh cache with a newer version -> hint, worded by the
|
||||
// auto-update switch.
|
||||
let fresh_newer = UpdateCheckCache {
|
||||
checked_at_epoch_secs: now,
|
||||
latest_version: "0.2.0".to_string(),
|
||||
};
|
||||
write_update_cache(&path, &fresh_newer).unwrap();
|
||||
assert_eq!(
|
||||
cached_update_hint(&path, "0.1.7", now).unwrap(),
|
||||
cached_update_hint(&path, "0.1.7", now, false).unwrap(),
|
||||
Some("A new bsk version is available: 0.1.7 -> 0.2.0. Run `bsk update`.".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
cached_update_hint(&path, "0.1.7", now, true).unwrap(),
|
||||
Some(
|
||||
"A new bsk version is available: 0.1.7 -> 0.2.0. The daemon will upgrade bsk automatically.".to_string()
|
||||
)
|
||||
);
|
||||
|
||||
// Fresh cache without a newer version -> no hint.
|
||||
let fresh_current = UpdateCheckCache {
|
||||
@@ -977,7 +1105,7 @@ mod tests {
|
||||
latest_version: "0.1.7".to_string(),
|
||||
};
|
||||
write_update_cache(&path, &fresh_current).unwrap();
|
||||
assert_eq!(cached_update_hint(&path, "0.1.7", now).unwrap(), None);
|
||||
assert_eq!(cached_update_hint(&path, "0.1.7", now, true).unwrap(), None);
|
||||
|
||||
// Stale cache, even with a newer version -> no hint.
|
||||
let stale_newer = UpdateCheckCache {
|
||||
@@ -985,11 +1113,114 @@ mod tests {
|
||||
latest_version: "0.2.0".to_string(),
|
||||
};
|
||||
write_update_cache(&path, &stale_newer).unwrap();
|
||||
assert_eq!(cached_update_hint(&path, "0.1.7", now).unwrap(), None);
|
||||
assert_eq!(cached_update_hint(&path, "0.1.7", now, true).unwrap(), None);
|
||||
|
||||
// Corrupt cache file -> error surfaced to the caller, no panic.
|
||||
std::fs::write(&path, b"not json").unwrap();
|
||||
assert!(cached_update_hint(&path, "0.1.7", now).is_err());
|
||||
assert!(cached_update_hint(&path, "0.1.7", now, true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_update_toggle_defaults_on_and_only_off_disables() {
|
||||
assert!(auto_update_enabled_from(None));
|
||||
assert!(auto_update_enabled_from(Some("on")));
|
||||
assert!(auto_update_enabled_from(Some("1")));
|
||||
assert!(auto_update_enabled_from(Some("")));
|
||||
assert!(!auto_update_enabled_from(Some("off")));
|
||||
assert!(!auto_update_enabled_from(Some("OFF")));
|
||||
assert!(!auto_update_enabled_from(Some(" Off ")));
|
||||
}
|
||||
|
||||
fn test_candidate() -> UpdateCandidate {
|
||||
UpdateCandidate {
|
||||
current: Version::parse("0.1.7").unwrap(),
|
||||
latest: Version::parse("0.2.0").unwrap(),
|
||||
tag: "cli-v0.2.0".to_string(),
|
||||
release_url: None,
|
||||
asset: ManifestAsset {
|
||||
url: "https://example.test/bsk.tar.gz".to_string(),
|
||||
sha256: Some("abc123".to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_update_step_reports_up_to_date_without_candidate() {
|
||||
let outcome = auto_update_step(None, true, 0, |_| panic!("install must not run")).unwrap();
|
||||
assert_eq!(outcome, AutoUpdateOutcome::UpToDate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_update_step_keeps_cache_only_when_disabled() {
|
||||
let candidate = test_candidate();
|
||||
let outcome = auto_update_step(Some(&candidate), false, 0, |_| {
|
||||
panic!("install must not run")
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
outcome,
|
||||
AutoUpdateOutcome::Disabled {
|
||||
latest: "0.2.0".to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_update_step_postpones_with_active_sessions() {
|
||||
let candidate = test_candidate();
|
||||
let outcome = auto_update_step(Some(&candidate), true, 2, |_| {
|
||||
panic!("install must not run")
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
outcome,
|
||||
AutoUpdateOutcome::PostponedSessions {
|
||||
latest: "0.2.0".to_string(),
|
||||
sessions: 2,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_update_step_installs_when_no_sessions() {
|
||||
let candidate = test_candidate();
|
||||
let installs = std::cell::Cell::new(0);
|
||||
let outcome = auto_update_step(Some(&candidate), true, 0, |candidate| {
|
||||
installs.set(installs.get() + 1);
|
||||
assert_eq!(candidate.latest.to_string(), "0.2.0");
|
||||
Ok(InstallAction::Replaced)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(installs.get(), 1);
|
||||
assert_eq!(
|
||||
outcome,
|
||||
AutoUpdateOutcome::Replaced {
|
||||
latest: "0.2.0".to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_update_step_staged_outcome_requests_no_restart() {
|
||||
// The Windows shape: the replacement is staged next to the
|
||||
// running binary, so the outcome must not ask for the immediate
|
||||
// self-restart that `Replaced` triggers.
|
||||
let candidate = test_candidate();
|
||||
let outcome =
|
||||
auto_update_step(Some(&candidate), true, 0, |_| Ok(InstallAction::Staged)).unwrap();
|
||||
assert_eq!(
|
||||
outcome,
|
||||
AutoUpdateOutcome::Staged {
|
||||
latest: "0.2.0".to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_update_step_propagates_install_errors() {
|
||||
let candidate = test_candidate();
|
||||
let result = auto_update_step(Some(&candidate), true, 0, |_| bail!("boom"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
fn tar_gz_with_bsk(binary: &[u8]) -> Vec<u8> {
|
||||
|
||||
Reference in New Issue
Block a user