fix: refresh app updates and apply the bundled core
This commit is contained in:
1 parent
67cdf40faf
commit
ae5471642e
5 files changed
+467
-69
No files matched your search
+83
-34
@@ -152,6 +152,24 @@ pub(crate) async fn check_app_update(
|
||||
state: tauri::State<'_, AppUpdateState>,
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
) -> Result<AppUpdateInfo, String> {
|
||||
let (info, _) = resolve_app_update(&app, gui_config_state.inner()).await?;
|
||||
state.set_available(AppUpdateTask {
|
||||
phase: if info.update_available {
|
||||
"available".to_string()
|
||||
} else {
|
||||
"idle".to_string()
|
||||
},
|
||||
target_version: info.update_available.then(|| info.latest_version.clone()),
|
||||
total_bytes: info.download_size_bytes,
|
||||
..AppUpdateTask::default()
|
||||
});
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
async fn resolve_app_update(
|
||||
app: &tauri::AppHandle,
|
||||
gui_config_state: &GuiConfigState,
|
||||
) -> Result<(AppUpdateInfo, Option<PendingAppUpdate>), String> {
|
||||
let _detection_guard = VERSION_SOURCE_DETECTION_LOCK.lock().await;
|
||||
let config = gui_config_state.snapshot()?;
|
||||
let proxy_url = config.proxy_url.clone();
|
||||
@@ -174,8 +192,8 @@ pub(crate) async fn check_app_update(
|
||||
)
|
||||
.await?;
|
||||
persist_automatic_download_source_switch(
|
||||
&app,
|
||||
gui_config_state.inner(),
|
||||
app,
|
||||
gui_config_state,
|
||||
&requested_source,
|
||||
&resolved_source,
|
||||
)?;
|
||||
@@ -207,21 +225,7 @@ pub(crate) async fn check_app_update(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
state.set_pending(
|
||||
pending,
|
||||
AppUpdateTask {
|
||||
phase: if update_available {
|
||||
"available".to_string()
|
||||
} else {
|
||||
"idle".to_string()
|
||||
},
|
||||
target_version: update_available.then(|| latest_version.clone()),
|
||||
total_bytes: asset.as_ref().map(|value| value.size_bytes),
|
||||
..AppUpdateTask::default()
|
||||
},
|
||||
);
|
||||
|
||||
Ok(AppUpdateInfo {
|
||||
let info = AppUpdateInfo {
|
||||
current_version,
|
||||
latest_version,
|
||||
update_available,
|
||||
@@ -231,7 +235,8 @@ pub(crate) async fn check_app_update(
|
||||
auto_update_supported,
|
||||
download_size_bytes: asset.map(|value| value.size_bytes),
|
||||
unsupported_reason,
|
||||
})
|
||||
};
|
||||
Ok((info, pending))
|
||||
}
|
||||
|
||||
pub(crate) async fn fetch_portable_update_manifest(
|
||||
@@ -276,8 +281,7 @@ pub(crate) async fn fetch_portable_update_manifest_from_gitcode(
|
||||
repository: &str,
|
||||
) -> Result<PortableUpdateManifest, String> {
|
||||
let release_url = format!("https://api.gitcode.com/api/v5/repos/{repository}/releases/latest");
|
||||
let release = client
|
||||
.get(release_url)
|
||||
let release = app_update_metadata_request(client, &release_url)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.header(reqwest::header::USER_AGENT, APP_USER_AGENT)
|
||||
.send()
|
||||
@@ -298,8 +302,7 @@ pub(crate) async fn fetch_portable_update_manifest_url(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
) -> Result<PortableUpdateManifest, String> {
|
||||
let manifest = client
|
||||
.get(url)
|
||||
let manifest = app_update_metadata_request(client, url)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.header(reqwest::header::USER_AGENT, APP_USER_AGENT)
|
||||
.send()
|
||||
@@ -314,6 +317,19 @@ pub(crate) async fn fetch_portable_update_manifest_url(
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
fn app_update_metadata_request(client: &reqwest::Client, url: &str) -> reqwest::RequestBuilder {
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
.to_string();
|
||||
client
|
||||
.get(url)
|
||||
.query(&[("_", nonce)])
|
||||
.header(reqwest::header::CACHE_CONTROL, "no-cache")
|
||||
.header(reqwest::header::PRAGMA, "no-cache")
|
||||
}
|
||||
|
||||
pub(crate) fn configured_gitcode_gui_repository() -> Option<&'static str> {
|
||||
configured_gitcode_repository(option_env!("GITCODE_GUI_REPOSITORY"))
|
||||
}
|
||||
@@ -651,27 +667,43 @@ pub(crate) fn cancel_app_update(state: tauri::State<'_, AppUpdateState>) -> Resu
|
||||
pub(crate) async fn start_app_update(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppUpdateState>,
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
) -> Result<(), String> {
|
||||
if portable_update_platform_key().is_none() {
|
||||
return Err("当前平台不支持应用内自动升级".to_string());
|
||||
}
|
||||
let config = gui_config_state.snapshot()?;
|
||||
let proxy_url = config.proxy_url.clone();
|
||||
let download_source = config.selected_download_candidate();
|
||||
let token = CancellationToken::new();
|
||||
let pending = state.start(token.clone())?;
|
||||
state.start(token.clone())?;
|
||||
let task = state.snapshot();
|
||||
let _ = app.emit(APP_UPDATE_PROGRESS_EVENT, task);
|
||||
let update_app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let outcome = download_and_stage_portable_app_update(
|
||||
&update_app,
|
||||
&pending,
|
||||
&token,
|
||||
&proxy_url,
|
||||
download_source,
|
||||
)
|
||||
let outcome = async {
|
||||
let state = update_app.state::<AppUpdateState>();
|
||||
let gui_config_state = update_app.state::<GuiConfigState>();
|
||||
let pending = refresh_app_update_for_install(state.inner(), &token, async {
|
||||
let (info, pending) =
|
||||
resolve_app_update(&update_app, gui_config_state.inner()).await?;
|
||||
pending.ok_or_else(|| {
|
||||
if info.update_available {
|
||||
info.unsupported_reason
|
||||
.unwrap_or_else(|| "当前版本不支持自动更新".to_string())
|
||||
} else {
|
||||
"当前没有可安装的软件更新,请重新检查更新".to_string()
|
||||
}
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
let _ = update_app.emit(APP_UPDATE_PROGRESS_EVENT, state.snapshot());
|
||||
let config = gui_config_state.snapshot()?;
|
||||
download_and_stage_portable_app_update(
|
||||
&update_app,
|
||||
&pending,
|
||||
&token,
|
||||
&config.proxy_url,
|
||||
config.selected_download_candidate(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = outcome {
|
||||
let state = update_app.state::<AppUpdateState>();
|
||||
@@ -690,6 +722,23 @@ pub(crate) async fn start_app_update(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn refresh_app_update_for_install<F>(
|
||||
state: &AppUpdateState,
|
||||
token: &CancellationToken,
|
||||
refresh: F,
|
||||
) -> Result<PendingAppUpdate, String>
|
||||
where
|
||||
F: std::future::Future<Output = Result<PendingAppUpdate, String>>,
|
||||
{
|
||||
let pending = tokio::select! {
|
||||
biased;
|
||||
_ = token.cancelled() => return Err("应用更新下载已取消".to_string()),
|
||||
result = refresh => result?,
|
||||
};
|
||||
state.start_download(&pending)?;
|
||||
Ok(pending)
|
||||
}
|
||||
|
||||
pub(crate) async fn download_and_stage_portable_app_update(
|
||||
app: &tauri::AppHandle,
|
||||
pending: &PendingAppUpdate,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) static CORE_OPERATION_LOCK: Mutex<()> = Mutex::new(());
|
||||
const BUNDLED_CORE_HANDLED_VERSION_FILE: &str = "cpa-gui-bundled-core-handled.txt";
|
||||
|
||||
pub(crate) fn lock_core_operation(
|
||||
process_state: &CoreProcessState,
|
||||
@@ -203,25 +204,87 @@ pub(crate) async fn install_bundled_core(
|
||||
.map_err(|error| format!("离线内核安装后台任务失败: {error}"))?
|
||||
}
|
||||
|
||||
pub(crate) fn core_needs_bundled_bootstrap(install_dir: &Path) -> bool {
|
||||
find_core_binary(install_dir).is_none()
|
||||
pub(crate) fn core_needs_bundled_install(install_dir: &Path, bundled_version: &str) -> bool {
|
||||
let Ok(bundled_version) =
|
||||
semver::Version::parse(bundled_version.trim().trim_start_matches('v'))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if find_core_binary(install_dir).is_none() {
|
||||
return true;
|
||||
}
|
||||
if bundled_core_version_handled(install_dir, &bundled_version) {
|
||||
return false;
|
||||
}
|
||||
read_core_metadata(install_dir)
|
||||
.and_then(|metadata| {
|
||||
semver::Version::parse(metadata.version.trim().trim_start_matches('v')).ok()
|
||||
})
|
||||
.is_none_or(|installed| bundled_version > installed)
|
||||
}
|
||||
|
||||
pub(crate) fn auto_install_bundled_core_if_missing(app: &tauri::AppHandle) -> Result<bool, String> {
|
||||
fn bundled_core_version_handled(install_dir: &Path, bundled_version: &semver::Version) -> bool {
|
||||
fs::read_to_string(install_dir.join(BUNDLED_CORE_HANDLED_VERSION_FILE))
|
||||
.ok()
|
||||
.and_then(|version| semver::Version::parse(version.trim().trim_start_matches('v')).ok())
|
||||
.is_some_and(|version| &version == bundled_version)
|
||||
}
|
||||
|
||||
pub(crate) fn mark_bundled_core_version_handled(
|
||||
install_dir: &Path,
|
||||
bundled_version: &str,
|
||||
) -> Result<(), String> {
|
||||
write_bytes_atomically(
|
||||
&install_dir.join(BUNDLED_CORE_HANDLED_VERSION_FILE),
|
||||
bundled_version.as_bytes(),
|
||||
)
|
||||
.map_err(|error| format!("记录已处理的内置内核版本失败: {error}"))
|
||||
}
|
||||
|
||||
pub(crate) fn remember_bundled_core_when_up_to_date(
|
||||
install_dir: &Path,
|
||||
bundled_version: &str,
|
||||
) -> Result<(), String> {
|
||||
let Ok(bundled) = semver::Version::parse(bundled_version.trim().trim_start_matches('v')) else {
|
||||
return Ok(());
|
||||
};
|
||||
if bundled_core_version_handled(install_dir, &bundled) {
|
||||
return Ok(());
|
||||
}
|
||||
let installed = read_core_metadata(install_dir).and_then(|metadata| {
|
||||
semver::Version::parse(metadata.version.trim().trim_start_matches('v')).ok()
|
||||
});
|
||||
if find_core_binary(install_dir).is_some()
|
||||
&& installed.is_some_and(|version| version >= bundled)
|
||||
{
|
||||
mark_bundled_core_version_handled(install_dir, bundled_version)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn auto_install_bundled_core_if_needed(app: &tauri::AppHandle) -> Result<bool, String> {
|
||||
let install_dir = core_install_dir()?;
|
||||
if !core_needs_bundled_bootstrap(&install_dir) {
|
||||
let (info, archive_path) = match bundled_core_archive()? {
|
||||
Some(bundled) => bundled,
|
||||
None if find_core_binary(&install_dir).is_some() => return Ok(false),
|
||||
None => return Err("未检测到 CPA 内核,且当前发行包没有匹配的离线内核".to_string()),
|
||||
};
|
||||
if !core_needs_bundled_install(&install_dir, &info.version) {
|
||||
remember_bundled_core_when_up_to_date(&install_dir, &info.version)?;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let (info, archive_path) = bundled_core_archive()?
|
||||
.ok_or_else(|| "未检测到 CPA 内核,且当前发行包没有匹配的离线内核".to_string())?;
|
||||
let window = app
|
||||
.get_webview_window("main")
|
||||
.map(|webview| webview.as_ref().window())
|
||||
.ok_or_else(|| "无法获取主窗口,不能自动安装离线内核".to_string())?;
|
||||
let state = app.state::<CoreDownloadState>();
|
||||
state.start(CancellationToken::new(), Some(info.version.clone()))?;
|
||||
let result = install_bundled_core_inner(&window, state.inner(), &info, &archive_path);
|
||||
let result = install_core_with_runtime_restore(
|
||||
app,
|
||||
app.state::<CoreProcessState>().inner(),
|
||||
app.state::<GuiConfigState>().inner(),
|
||||
|| install_bundled_core_inner(&window, state.inner(), &info, &archive_path),
|
||||
);
|
||||
if result.is_err() {
|
||||
let _ = cleanup_core_work_dirs();
|
||||
}
|
||||
@@ -585,6 +648,7 @@ pub(crate) fn install_bundled_core_inner(
|
||||
},
|
||||
)?;
|
||||
overlay_install_dir(&install_dir, &staging_dir)?;
|
||||
mark_bundled_core_version_handled(&install_dir, &info.version)?;
|
||||
|
||||
Ok(CoreInstallResult {
|
||||
version: info.version.clone(),
|
||||
@@ -1691,13 +1755,14 @@ pub(crate) fn bundled_core_locations(
|
||||
base_dir: &Path,
|
||||
executable_dir: &Path,
|
||||
) -> Vec<(PathBuf, PathBuf)> {
|
||||
let mut locations = vec![(base_dir.join(CORE_VERSION_FILE), base_dir.join("cpa-core"))];
|
||||
let mut locations = Vec::new();
|
||||
if let Some(resources_dir) = macos_app_resources_dir(executable_dir) {
|
||||
locations.push((
|
||||
resources_dir.join(CORE_VERSION_FILE),
|
||||
resources_dir.join("cpa-core"),
|
||||
));
|
||||
}
|
||||
locations.push((base_dir.join(CORE_VERSION_FILE), base_dir.join("cpa-core")));
|
||||
if let Some(project_root) = source_project_root(executable_dir) {
|
||||
if project_root != base_dir {
|
||||
locations.push((
|
||||
@@ -1918,7 +1983,7 @@ pub(crate) fn validate_bundled_core_checksum(archive_path: &Path) -> Result<(),
|
||||
(name == archive_name && digest.len() == 64).then(|| digest.to_ascii_lowercase())
|
||||
});
|
||||
let Some(expected) = expected else {
|
||||
return Err(format!("校验文件中没有 {archive_name} 的 SHA-256"));
|
||||
return Ok(());
|
||||
};
|
||||
let actual = sha256_file(archive_path)?;
|
||||
if actual != expected {
|
||||
|
||||
+29
-11
@@ -309,7 +309,6 @@ struct AppUpdateState {
|
||||
struct AppUpdateInner {
|
||||
task: AppUpdateTask,
|
||||
token: Option<CancellationToken>,
|
||||
pending: Option<PendingAppUpdate>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -1772,16 +1771,15 @@ impl AppUpdateState {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn set_pending(&self, pending: Option<PendingAppUpdate>, task: AppUpdateTask) {
|
||||
fn set_available(&self, task: AppUpdateTask) {
|
||||
if let Ok(mut inner) = self.inner.lock() {
|
||||
inner.pending = pending;
|
||||
if !inner.task.running {
|
||||
inner.task = task;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start(&self, token: CancellationToken) -> Result<PendingAppUpdate, String> {
|
||||
fn start(&self, token: CancellationToken) -> Result<(), String> {
|
||||
let mut inner = self
|
||||
.inner
|
||||
.lock()
|
||||
@@ -1789,11 +1787,31 @@ impl AppUpdateState {
|
||||
if inner.task.running {
|
||||
return Err("已有应用更新任务正在运行".to_string());
|
||||
}
|
||||
let pending = inner
|
||||
.pending
|
||||
.clone()
|
||||
.ok_or_else(|| "没有可安装的应用更新,请先检查更新".to_string())?;
|
||||
inner.token = Some(token);
|
||||
inner.task = AppUpdateTask {
|
||||
running: true,
|
||||
cancellable: true,
|
||||
phase: "checking".to_string(),
|
||||
..AppUpdateTask::default()
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start_download(&self, pending: &PendingAppUpdate) -> Result<(), String> {
|
||||
let mut inner = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| "应用更新状态锁已损坏".to_string())?;
|
||||
if !inner.task.running || inner.task.phase != "checking" {
|
||||
return Err("应用更新未处于检查阶段".to_string());
|
||||
}
|
||||
if inner
|
||||
.token
|
||||
.as_ref()
|
||||
.is_none_or(CancellationToken::is_cancelled)
|
||||
{
|
||||
return Err("应用更新下载已取消".to_string());
|
||||
}
|
||||
inner.task = AppUpdateTask {
|
||||
running: true,
|
||||
cancellable: true,
|
||||
@@ -1804,7 +1822,7 @@ impl AppUpdateState {
|
||||
percent: Some(0.0),
|
||||
message: None,
|
||||
};
|
||||
Ok(pending)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
|
||||
@@ -2563,8 +2581,8 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
match auto_install_bundled_core_if_missing(&core_app) {
|
||||
Ok(true) => eprintln!("未检测到 CPA 内核,已自动安装内置离线版本"),
|
||||
match auto_install_bundled_core_if_needed(&core_app) {
|
||||
Ok(true) => eprintln!("已自动安装或升级至软件内置的 CPA 内核版本"),
|
||||
Ok(false) => {}
|
||||
Err(error) => eprintln!("自动安装 CPA 离线内核失败: {error}"),
|
||||
}
|
||||
|
||||
@@ -352,18 +352,17 @@ fn portable_update_state_supports_cancellation_and_snapshot_recovery() {
|
||||
asset: portable_update_test_asset("1.2.3", "amd64"),
|
||||
arch: "amd64".to_string(),
|
||||
};
|
||||
state.set_pending(
|
||||
Some(pending),
|
||||
AppUpdateTask {
|
||||
phase: "available".to_string(),
|
||||
target_version: Some("1.2.3".to_string()),
|
||||
..AppUpdateTask::default()
|
||||
},
|
||||
);
|
||||
state.set_available(AppUpdateTask {
|
||||
phase: "available".to_string(),
|
||||
target_version: Some("1.2.3".to_string()),
|
||||
..AppUpdateTask::default()
|
||||
});
|
||||
|
||||
let token = CancellationToken::new();
|
||||
let started = state.start(token.clone()).unwrap();
|
||||
assert_eq!(started.version, "1.2.3");
|
||||
state.start(token.clone()).unwrap();
|
||||
assert_eq!(state.snapshot().phase, "checking");
|
||||
assert!(state.start(CancellationToken::new()).is_err());
|
||||
state.start_download(&pending).unwrap();
|
||||
let recovered = state.snapshot();
|
||||
assert!(recovered.running);
|
||||
assert!(recovered.cancellable);
|
||||
@@ -377,6 +376,136 @@ fn portable_update_state_supports_cancellation_and_snapshot_recovery() {
|
||||
assert_eq!(state.snapshot().phase, "cancelled");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn installing_refreshes_an_old_offer_and_bypasses_cached_manifest_responses() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!(
|
||||
"http://{}/releases/latest/manifest.json",
|
||||
listener.local_addr().unwrap()
|
||||
);
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = tokio::time::timeout(Duration::from_secs(5), listener.accept())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let mut request = Vec::new();
|
||||
loop {
|
||||
let mut buffer = [0; 1024];
|
||||
let count = tokio::time::timeout(Duration::from_secs(5), stream.read(&mut buffer))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(count > 0);
|
||||
request.extend_from_slice(&buffer[..count]);
|
||||
if request.windows(4).any(|bytes| bytes == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let request = String::from_utf8(request).unwrap().to_ascii_lowercase();
|
||||
let fresh = request.contains("manifest.json?_=")
|
||||
&& request.contains("cache-control: no-cache")
|
||||
&& request.contains("pragma: no-cache");
|
||||
let manifest = portable_update_test_manifest(if fresh { "0.3.3" } else { "0.3.2" });
|
||||
let assets: serde_json::Map<String, serde_json::Value> = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.map(|(key, asset)| {
|
||||
(
|
||||
key.clone(),
|
||||
serde_json::json!({
|
||||
"url": asset.url, "sha256": asset.sha256, "sizeBytes": asset.size_bytes,
|
||||
}),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let body = serde_json::json!({
|
||||
"schemaVersion": 1, "version": manifest.version,
|
||||
"publishedAt": manifest.published_at, "releaseUrl": manifest.release_url,
|
||||
"assets": assets,
|
||||
})
|
||||
.to_string();
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len(),
|
||||
);
|
||||
stream.write_all(response.as_bytes()).await.unwrap();
|
||||
});
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap();
|
||||
let state = AppUpdateState::default();
|
||||
state.set_available(AppUpdateTask {
|
||||
phase: "available".to_string(),
|
||||
target_version: Some("v0.3.2".to_string()),
|
||||
total_bytes: Some(1),
|
||||
..AppUpdateTask::default()
|
||||
});
|
||||
let token = CancellationToken::new();
|
||||
state.start(token.clone()).unwrap();
|
||||
let pending = refresh_app_update_for_install(&state, &token, async {
|
||||
let manifest = fetch_portable_update_manifest_url(&client, &url).await?;
|
||||
let (key, arch) = portable_update_target().unwrap();
|
||||
Ok(PendingAppUpdate {
|
||||
version: normalize_version(&manifest.version),
|
||||
asset: manifest.assets.get(key).unwrap().clone(),
|
||||
arch: arch.to_string(),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
server.await.unwrap();
|
||||
assert_eq!(pending.version, "v0.3.3");
|
||||
assert!(pending.asset.url.contains("/v0.3.3/"));
|
||||
assert_eq!(state.snapshot().target_version.as_deref(), Some("v0.3.3"));
|
||||
assert_eq!(state.snapshot().total_bytes, Some(pending.asset.size_bytes));
|
||||
assert_eq!(state.snapshot().phase, "downloading");
|
||||
state.set_available(AppUpdateTask::default());
|
||||
assert_eq!(state.snapshot().target_version.as_deref(), Some("v0.3.3"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_or_cancelled_refresh_never_downloads_the_previous_offer() {
|
||||
let state = AppUpdateState::default();
|
||||
state.set_available(AppUpdateTask {
|
||||
phase: "available".to_string(),
|
||||
target_version: Some("v0.3.2".to_string()),
|
||||
..AppUpdateTask::default()
|
||||
});
|
||||
let token = CancellationToken::new();
|
||||
state.start(token.clone()).unwrap();
|
||||
let result = refresh_app_update_for_install(&state, &token, async {
|
||||
Err("version check failed".to_string())
|
||||
})
|
||||
.await;
|
||||
assert_eq!(result.err().as_deref(), Some("version check failed"));
|
||||
assert_eq!(state.snapshot().phase, "checking");
|
||||
assert!(state.snapshot().target_version.is_none());
|
||||
state.finish("failed", None);
|
||||
|
||||
let token = CancellationToken::new();
|
||||
state.start(token.clone()).unwrap();
|
||||
let refresh = refresh_app_update_for_install(
|
||||
&state,
|
||||
&token,
|
||||
std::future::pending::<Result<PendingAppUpdate, String>>(),
|
||||
);
|
||||
let (result, _) = tokio::join!(refresh, async {
|
||||
state.cancel();
|
||||
});
|
||||
assert_eq!(result.err().as_deref(), Some("应用更新下载已取消"));
|
||||
assert!(state.snapshot().target_version.is_none());
|
||||
let pending = PendingAppUpdate {
|
||||
version: "v0.3.3".to_string(),
|
||||
asset: portable_update_test_asset("0.3.3", "amd64"),
|
||||
arch: "amd64".to_string(),
|
||||
};
|
||||
assert!(state.start_download(&pending).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_file_hashes_exact_portable_asset_bytes() {
|
||||
let root = agent_test_home("portable-sha256");
|
||||
|
||||
@@ -656,17 +656,153 @@ fn replacing_a_core_rejects_locked_config_without_using_defaults() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_core_bootstrap_runs_only_when_no_core_binary_exists() {
|
||||
fn bundled_core_install_handles_missing_and_unversioned_binaries() {
|
||||
let root = agent_test_home("bundled-bootstrap-detection");
|
||||
let install_dir = root.join("cpa-core");
|
||||
|
||||
assert!(core_needs_bundled_bootstrap(&install_dir));
|
||||
assert!(core_needs_bundled_install(&install_dir, "v7.3.17"));
|
||||
|
||||
let existing_version = install_dir.join("existing-version");
|
||||
fs::create_dir_all(&existing_version).unwrap();
|
||||
fs::write(existing_version.join(core_binary_name()), b"existing core").unwrap();
|
||||
|
||||
assert!(!core_needs_bundled_bootstrap(&install_dir));
|
||||
assert!(core_needs_bundled_install(&install_dir, "v7.3.17"));
|
||||
assert!(!core_needs_bundled_install(&install_dir, "invalid"));
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_core_install_replaces_older_installs_without_downgrading() {
|
||||
let root = agent_test_home("bundled-upgrade-detection");
|
||||
let install_dir = root.join("cpa-core");
|
||||
fs::create_dir_all(&install_dir).unwrap();
|
||||
fs::write(install_dir.join(core_binary_name()), b"installed core").unwrap();
|
||||
|
||||
for (installed, bundled, should_install) in [
|
||||
("v7.3.9", "v7.3.17", true),
|
||||
("7.3.15", "v7.3.17", true),
|
||||
("v7.3.17-beta.1", "v7.3.17", true),
|
||||
("v7.3.17", "7.3.17", false),
|
||||
("v7.3.18", "v7.3.17", false),
|
||||
("unknown", "v7.3.17", true),
|
||||
("v7.3.15", "invalid", false),
|
||||
] {
|
||||
write_core_metadata(
|
||||
&install_dir,
|
||||
&CoreMetadata {
|
||||
version: installed.to_string(),
|
||||
asset_name: "installed-core.zip".to_string(),
|
||||
installed_at_unix: 0,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
core_needs_bundled_install(&install_dir, bundled),
|
||||
should_install,
|
||||
"installed {installed}, bundled {bundled}"
|
||||
);
|
||||
}
|
||||
|
||||
fs::remove_file(install_dir.join(core_binary_name())).unwrap();
|
||||
assert!(core_needs_bundled_install(&install_dir, "v7.3.17"));
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handled_bundled_core_does_not_undo_a_manual_rollback_on_restart() {
|
||||
let root = agent_test_home("bundled-manual-rollback");
|
||||
let install_dir = root.join("cpa-core");
|
||||
fs::create_dir_all(&install_dir).unwrap();
|
||||
let binary = install_dir.join(core_binary_name());
|
||||
fs::write(&binary, b"installed core").unwrap();
|
||||
write_core_metadata(
|
||||
&install_dir,
|
||||
&CoreMetadata {
|
||||
version: "v7.3.15".to_string(),
|
||||
asset_name: "installed-core.zip".to_string(),
|
||||
installed_at_unix: 0,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(core_needs_bundled_install(&install_dir, "v7.3.17"));
|
||||
remember_bundled_core_when_up_to_date(&install_dir, "v7.3.17").unwrap();
|
||||
assert!(core_needs_bundled_install(&install_dir, "v7.3.17"));
|
||||
|
||||
mark_bundled_core_version_handled(&install_dir, "v7.3.17").unwrap();
|
||||
assert!(!core_needs_bundled_install(&install_dir, "v7.3.17"));
|
||||
assert!(core_needs_bundled_install(&install_dir, "v7.3.18"));
|
||||
|
||||
fs::remove_file(binary).unwrap();
|
||||
assert!(core_needs_bundled_install(&install_dir, "v7.3.17"));
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_already_newer_core_satisfies_the_bundle_after_manual_rollback() {
|
||||
let root = agent_test_home("bundled-newer-manual-rollback");
|
||||
let install_dir = root.join("cpa-core");
|
||||
fs::create_dir_all(&install_dir).unwrap();
|
||||
fs::write(install_dir.join(core_binary_name()), b"installed core").unwrap();
|
||||
write_core_metadata(
|
||||
&install_dir,
|
||||
&CoreMetadata {
|
||||
version: "v7.3.18".to_string(),
|
||||
asset_name: "installed-core.zip".to_string(),
|
||||
installed_at_unix: 0,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!core_needs_bundled_install(&install_dir, "v7.3.17"));
|
||||
remember_bundled_core_when_up_to_date(&install_dir, "v7.3.17").unwrap();
|
||||
write_core_metadata(
|
||||
&install_dir,
|
||||
&CoreMetadata {
|
||||
version: "v7.3.15".to_string(),
|
||||
asset_name: "manual-rollback.zip".to_string(),
|
||||
installed_at_unix: 1,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!core_needs_bundled_install(&install_dir, "v7.3.17"));
|
||||
assert!(core_needs_bundled_install(&install_dir, "v7.3.19"));
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_core_checksums_do_not_block_a_new_bundled_archive() {
|
||||
let root = agent_test_home("bundled-stale-checksums");
|
||||
let archive = root.join("CLIProxyAPI_7.3.17_windows_amd64.zip");
|
||||
fs::write(&archive, b"new bundled core").unwrap();
|
||||
fs::write(
|
||||
root.join(CORE_CHECKSUMS_FILE),
|
||||
format!("{} CLIProxyAPI_7.3.15_windows_amd64.zip\n", "a".repeat(64)),
|
||||
)
|
||||
.unwrap();
|
||||
validate_bundled_core_checksum(&archive).unwrap();
|
||||
|
||||
fs::write(
|
||||
root.join(CORE_CHECKSUMS_FILE),
|
||||
format!(
|
||||
"{} {}\n",
|
||||
"a".repeat(64),
|
||||
archive.file_name().unwrap().to_string_lossy()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(validate_bundled_core_checksum(&archive).is_err());
|
||||
|
||||
fs::write(
|
||||
root.join(CORE_CHECKSUMS_FILE),
|
||||
format!(
|
||||
"{} {}\n",
|
||||
sha256_file(&archive).unwrap(),
|
||||
archive.file_name().unwrap().to_string_lossy()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
validate_bundled_core_checksum(&archive).unwrap();
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
@@ -686,7 +822,8 @@ fn bundled_core_locations_include_macos_app_resources() {
|
||||
macos_app_resources_dir(&executable_dir),
|
||||
Some(contents_dir.join("Resources"))
|
||||
);
|
||||
assert!(bundled_core_locations(&base_dir, &executable_dir).contains(&resource_location));
|
||||
let locations = bundled_core_locations(&base_dir, &executable_dir);
|
||||
assert_eq!(locations.first(), Some(&resource_location));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user