2479 lines
98 KiB
Rust
2479 lines
98 KiB
Rust
use super::*;
|
||
|
||
pub(crate) const PORTABLE_UPDATE_HELPER_ACK_FILE: &str = "update-helper-started.ack";
|
||
const PORTABLE_UPDATE_HELPER_START_TIMEOUT: Duration = Duration::from_secs(10);
|
||
|
||
pub(crate) fn deserialize_release_notes<'de, D>(
|
||
deserializer: D,
|
||
) -> Result<HashMap<String, String>, D::Error>
|
||
where
|
||
D: serde::Deserializer<'de>,
|
||
{
|
||
let value = serde_json::Value::deserialize(deserializer)?;
|
||
// Untagged legacy text and invalid entries cannot identify a translation.
|
||
// Treat them as unavailable instead of failing the entire version check.
|
||
let mut notes = HashMap::new();
|
||
if let Some(translations) = value.as_object() {
|
||
for locale in ["zh-CN", "zh-TW", "en", "ja"] {
|
||
if let Some(text) = translations.get(locale).and_then(|value| value.as_str()) {
|
||
if !text.trim().is_empty() {
|
||
notes.insert(locale.to_string(), text.to_string());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Ok(notes)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn get_version_source_settings(
|
||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||
) -> Result<VersionSourceSettings, String> {
|
||
let config = gui_config_state.snapshot()?;
|
||
Ok(version_source_settings(&config))
|
||
}
|
||
|
||
fn version_source_settings(config: &GuiConfigFile) -> VersionSourceSettings {
|
||
VersionSourceSettings {
|
||
source: config.selected_download_candidate().key(),
|
||
gitcode_available: gitcode_version_source_available(),
|
||
custom_mirrors: config.custom_download_mirrors.clone(),
|
||
}
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn set_download_source(
|
||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||
source: String,
|
||
) -> Result<VersionSourceSettings, String> {
|
||
let candidate = if let Some(url) = source.strip_prefix("custom:") {
|
||
VersionDownloadCandidate::custom(&normalize_custom_download_mirror_url(url)?)
|
||
} else {
|
||
VersionDownloadCandidate::builtin(
|
||
VersionDownloadSource::from_str(&source)
|
||
.filter(|source| *source != VersionDownloadSource::Custom)
|
||
.ok_or_else(|| "不支持的下载源".to_string())?,
|
||
)
|
||
};
|
||
if candidate.source == VersionDownloadSource::Gitcode && !gitcode_version_source_available() {
|
||
return Err("当前构建未配置完整的 GitCode 软件与内核镜像源".to_string());
|
||
}
|
||
let config = gui_config_state.set_download_candidate(candidate)?;
|
||
Ok(version_source_settings(&config))
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn add_custom_download_mirror(
|
||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||
url: String,
|
||
) -> Result<VersionSourceSettings, String> {
|
||
let url = normalize_custom_download_mirror_url(&url)?;
|
||
let config = gui_config_state.update(|config| {
|
||
if !config.custom_download_mirrors.contains(&url) {
|
||
if config.custom_download_mirrors.len() >= 12 {
|
||
return Err("最多可添加 12 个自定义镜像".to_string());
|
||
}
|
||
config.custom_download_mirrors.push(url.clone());
|
||
}
|
||
config.download_source = VersionDownloadSource::Custom;
|
||
config.active_custom_download_mirror = url.clone();
|
||
config.prefer_gitcode_downloads = false;
|
||
Ok(())
|
||
})?;
|
||
Ok(version_source_settings(&config))
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn remove_custom_download_mirror(
|
||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||
url: String,
|
||
) -> Result<VersionSourceSettings, String> {
|
||
let url = normalize_custom_download_mirror_url(&url)?;
|
||
let config = gui_config_state.update(|config| {
|
||
config.custom_download_mirrors.retain(|item| item != &url);
|
||
if config.download_source == VersionDownloadSource::Custom
|
||
&& config.active_custom_download_mirror == url
|
||
{
|
||
config.download_source = VersionDownloadSource::Github;
|
||
config.active_custom_download_mirror.clear();
|
||
}
|
||
Ok(())
|
||
})?;
|
||
Ok(version_source_settings(&config))
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn set_prefer_gitcode_downloads(
|
||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||
enabled: bool,
|
||
) -> Result<VersionSourceSettings, String> {
|
||
let source = if enabled {
|
||
VersionDownloadSource::Gitcode
|
||
} else {
|
||
VersionDownloadSource::Github
|
||
};
|
||
if enabled && !gitcode_version_source_available() {
|
||
return Err("当前构建未配置完整的 GitCode 软件与内核镜像源".to_string());
|
||
}
|
||
let config = gui_config_state.set_download_source(source)?;
|
||
Ok(version_source_settings(&config))
|
||
}
|
||
|
||
pub(crate) fn gitcode_version_source_available() -> bool {
|
||
configured_gitcode_gui_repository().is_some() && configured_gitcode_core_repository().is_some()
|
||
}
|
||
|
||
pub(crate) fn persist_automatic_download_source_switch(
|
||
app: &tauri::AppHandle,
|
||
gui_config_state: &GuiConfigState,
|
||
requested_source: &VersionDownloadCandidate,
|
||
resolved_source: &VersionDownloadCandidate,
|
||
) -> Result<(), String> {
|
||
let Some(config) =
|
||
gui_config_state.switch_download_source_after_failure(requested_source, resolved_source)?
|
||
else {
|
||
return Ok(());
|
||
};
|
||
app.emit(
|
||
VERSION_DOWNLOAD_SOURCE_CHANGED_EVENT,
|
||
version_source_settings(&config),
|
||
)
|
||
.map_err(|error| format!("通知下载源自动切换失败: {error}"))
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn open_external_url(app: tauri::AppHandle, url: String) -> Result<(), String> {
|
||
open_external_url_inner(&app, &url)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn check_app_update(
|
||
app: tauri::AppHandle,
|
||
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();
|
||
let client = build_http_client_with_proxy(
|
||
reqwest::Client::builder()
|
||
.redirect(release_https_redirect_policy_with_mirrors(
|
||
&config.custom_download_mirrors,
|
||
))
|
||
.connect_timeout(Duration::from_secs(8))
|
||
.read_timeout(Duration::from_secs(15))
|
||
.timeout(Duration::from_secs(20)),
|
||
&proxy_url,
|
||
"创建版本检查客户端失败",
|
||
)?;
|
||
let requested_source = config.selected_download_candidate();
|
||
let (manifest, resolved_source) = fetch_portable_update_manifest(
|
||
&client,
|
||
requested_source.clone(),
|
||
&config.custom_download_mirrors,
|
||
)
|
||
.await?;
|
||
persist_automatic_download_source_switch(
|
||
app,
|
||
gui_config_state,
|
||
&requested_source,
|
||
&resolved_source,
|
||
)?;
|
||
let latest_version = normalize_version(&manifest.version);
|
||
let current_version = normalize_version(env!("CARGO_PKG_VERSION"));
|
||
let update_available = is_app_update_available(¤t_version, &latest_version)?;
|
||
let target = portable_update_target();
|
||
let asset_catalog = manifest.full_assets.as_ref().unwrap_or(&manifest.assets);
|
||
let asset = target.and_then(|(key, _)| asset_catalog.get(key)).cloned();
|
||
let portable_support = target
|
||
.map(|(_, arch)| validate_local_portable_app_manifest(arch))
|
||
.transpose()?;
|
||
let auto_update_supported = portable_support == Some(true) && asset.is_some();
|
||
let unsupported_reason = if auto_update_supported {
|
||
None
|
||
} else if portable_support != Some(true) {
|
||
Some("当前程序不是支持自动升级的便携版,请手动下载首个支持版本".to_string())
|
||
} else {
|
||
Some("更新清单不包含当前平台或架构".to_string())
|
||
};
|
||
|
||
let pending = if update_available && auto_update_supported {
|
||
let (_, arch) = target.expect("portable target checked above");
|
||
Some(PendingAppUpdate {
|
||
version: latest_version.clone(),
|
||
asset: asset.clone().expect("portable asset checked above"),
|
||
arch: arch.to_string(),
|
||
})
|
||
} else {
|
||
None
|
||
};
|
||
let info = AppUpdateInfo {
|
||
current_version,
|
||
latest_version,
|
||
update_available,
|
||
release_url: manifest.release_url,
|
||
release_notes: manifest.release_notes,
|
||
published_at: manifest.published_at,
|
||
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(
|
||
client: &reqwest::Client,
|
||
source: VersionDownloadCandidate,
|
||
custom_mirrors: &[String],
|
||
) -> Result<(PortableUpdateManifest, VersionDownloadCandidate), String> {
|
||
let gitcode_repository = configured_gitcode_gui_repository();
|
||
let mut failures = Vec::new();
|
||
for candidate in
|
||
version_download_source_candidates(source, gitcode_repository.is_some(), custom_mirrors)
|
||
{
|
||
let result = match candidate.source {
|
||
VersionDownloadSource::Gitcode => {
|
||
fetch_portable_update_manifest_from_gitcode(
|
||
client,
|
||
gitcode_repository.expect("GitCode candidate requires a configured repository"),
|
||
)
|
||
.await
|
||
}
|
||
VersionDownloadSource::Github
|
||
| VersionDownloadSource::GhProxy
|
||
| VersionDownloadSource::GhFast => {
|
||
let url = version_source_url(&candidate, APP_UPDATE_MANIFEST_URL);
|
||
fetch_portable_update_manifest_url(client, &url).await
|
||
}
|
||
VersionDownloadSource::Custom => {
|
||
let url = version_source_url(&candidate, APP_UPDATE_MANIFEST_URL);
|
||
fetch_portable_update_manifest_url(client, &url).await
|
||
}
|
||
};
|
||
match result {
|
||
Ok(manifest) => return Ok((manifest, candidate)),
|
||
Err(error) => failures.push(format!("{}: {error}", candidate.display_name())),
|
||
}
|
||
}
|
||
Err(format!("所有软件版本检测源均失败: {}", failures.join("; ")))
|
||
}
|
||
|
||
pub(crate) async fn fetch_portable_update_manifest_from_gitcode(
|
||
client: &reqwest::Client,
|
||
repository: &str,
|
||
) -> Result<PortableUpdateManifest, String> {
|
||
let release_url = format!("https://api.gitcode.com/api/v5/repos/{repository}/releases/latest");
|
||
let release = app_update_metadata_request(client, &release_url)
|
||
.header(reqwest::header::ACCEPT, "application/json")
|
||
.header(reqwest::header::USER_AGENT, APP_USER_AGENT)
|
||
.send()
|
||
.await
|
||
.map_err(|error| format!("query GitCode latest release: {error}"))?
|
||
.error_for_status()
|
||
.map_err(|error| format!("read GitCode latest release: {error}"))?
|
||
.json::<GitcodeRelease>()
|
||
.await
|
||
.map_err(|error| format!("parse GitCode latest release: {error}"))?;
|
||
validate_release_tag(&release.tag_name)?;
|
||
let manifest_url =
|
||
gitcode_release_attachment_url(repository, &release.tag_name, APP_UPDATE_MANIFEST_NAME);
|
||
fetch_portable_update_manifest_url(client, &manifest_url).await
|
||
}
|
||
|
||
pub(crate) async fn fetch_portable_update_manifest_url(
|
||
client: &reqwest::Client,
|
||
url: &str,
|
||
) -> Result<PortableUpdateManifest, String> {
|
||
let manifest = app_update_metadata_request(client, url)
|
||
.header(reqwest::header::ACCEPT, "application/json")
|
||
.header(reqwest::header::USER_AGENT, APP_USER_AGENT)
|
||
.send()
|
||
.await
|
||
.map_err(|error| format!("request update manifest: {error}"))?
|
||
.error_for_status()
|
||
.map_err(|error| format!("read update manifest: {error}"))?
|
||
.json::<PortableUpdateManifest>()
|
||
.await
|
||
.map_err(|error| format!("parse update manifest: {error}"))?;
|
||
validate_portable_update_manifest(&manifest)?;
|
||
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"))
|
||
}
|
||
|
||
pub(crate) fn configured_gitcode_core_repository() -> Option<&'static str> {
|
||
configured_gitcode_repository(option_env!("GITCODE_CORE_REPOSITORY"))
|
||
}
|
||
|
||
pub(crate) fn configured_gitcode_repository(
|
||
repository: Option<&'static str>,
|
||
) -> Option<&'static str> {
|
||
let repository = repository?.trim();
|
||
let mut parts = repository.split('/');
|
||
let valid_part = |part: &str| {
|
||
!part.is_empty()
|
||
&& part
|
||
.bytes()
|
||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
|
||
};
|
||
match (parts.next(), parts.next(), parts.next()) {
|
||
(Some(owner), Some(repo), None) if valid_part(owner) && valid_part(repo) => {
|
||
Some(repository)
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
pub(crate) fn validate_release_tag(tag: &str) -> Result<(), String> {
|
||
semver::Version::parse(tag.strip_prefix('v').unwrap_or(tag))
|
||
.map(|_| ())
|
||
.map_err(|error| format!("invalid release tag {tag}: {error}"))
|
||
}
|
||
|
||
pub(crate) fn gitcode_release_attachment_url(
|
||
repository: &str,
|
||
tag: &str,
|
||
filename: &str,
|
||
) -> String {
|
||
format!(
|
||
"https://api.gitcode.com/api/v5/repos/{repository}/releases/{tag}/attach_files/{filename}/download"
|
||
)
|
||
}
|
||
|
||
pub(crate) fn release_https_redirect_policy_with_mirrors(
|
||
custom_mirrors: &[String],
|
||
) -> reqwest::redirect::Policy {
|
||
let custom_hosts = custom_mirrors
|
||
.iter()
|
||
.filter_map(|value| reqwest::Url::parse(value).ok())
|
||
.filter_map(|url| url.host_str().map(str::to_string))
|
||
.collect::<HashSet<_>>();
|
||
reqwest::redirect::Policy::custom(move |attempt| {
|
||
let url = attempt.url();
|
||
let trusted_host = matches!(
|
||
url.host_str(),
|
||
Some(
|
||
"github.com"
|
||
| "objects.githubusercontent.com"
|
||
| "release-assets.githubusercontent.com"
|
||
| "api.gitcode.com"
|
||
| "gitcode.com"
|
||
| "file-cdn.gitcode.com"
|
||
| "gh-proxy.com"
|
||
| "ghfast.top"
|
||
)
|
||
) || url
|
||
.host_str()
|
||
.is_some_and(|host| custom_hosts.contains(host));
|
||
if url.scheme() == "https"
|
||
&& url.port().is_none()
|
||
&& url.username().is_empty()
|
||
&& url.password().is_none()
|
||
&& trusted_host
|
||
{
|
||
attempt.follow()
|
||
} else {
|
||
attempt.stop()
|
||
}
|
||
})
|
||
}
|
||
|
||
pub(crate) fn github_https_redirect_policy() -> reqwest::redirect::Policy {
|
||
reqwest::redirect::Policy::custom(|attempt| {
|
||
let url = attempt.url();
|
||
let trusted_host = matches!(
|
||
url.host_str(),
|
||
Some(
|
||
"github.com"
|
||
| "raw.githubusercontent.com"
|
||
| "objects.githubusercontent.com"
|
||
| "release-assets.githubusercontent.com"
|
||
)
|
||
);
|
||
if url.scheme() == "https"
|
||
&& url.port().is_none()
|
||
&& url.username().is_empty()
|
||
&& url.password().is_none()
|
||
&& trusted_host
|
||
{
|
||
attempt.follow()
|
||
} else {
|
||
attempt.stop()
|
||
}
|
||
})
|
||
}
|
||
|
||
pub(crate) fn validate_portable_update_manifest(
|
||
manifest: &PortableUpdateManifest,
|
||
) -> Result<(), String> {
|
||
if manifest.schema_version != 1 {
|
||
return Err(format!(
|
||
"不支持的软件更新清单版本: {}",
|
||
manifest.schema_version
|
||
));
|
||
}
|
||
semver::Version::parse(manifest.version.trim().trim_start_matches('v'))
|
||
.map_err(|error| format!("软件更新版本无效: {error}"))?;
|
||
chrono::DateTime::parse_from_rfc3339(manifest.published_at.trim())
|
||
.map_err(|error| format!("软件更新发布时间无效: {error}"))?;
|
||
let release_url = reqwest::Url::parse(&manifest.release_url)
|
||
.map_err(|_| "软件更新发布地址无效".to_string())?;
|
||
if release_url.scheme() != "https"
|
||
|| release_url.host_str() != Some("github.com")
|
||
|| release_url.port().is_some()
|
||
|| !release_url.username().is_empty()
|
||
|| release_url.password().is_some()
|
||
|| release_url.query().is_some()
|
||
|| release_url.fragment().is_some()
|
||
|| !release_url
|
||
.path()
|
||
.starts_with("/router-for-me/EasyCLIProxyAPI/releases/tag/v")
|
||
{
|
||
return Err("软件更新发布地址不受信任".to_string());
|
||
}
|
||
let (platform, display_platform, suffix) = portable_update_asset_platform()?;
|
||
validate_portable_update_asset_catalog(
|
||
&manifest.assets,
|
||
manifest,
|
||
platform,
|
||
display_platform,
|
||
suffix,
|
||
platform == "windows",
|
||
)?;
|
||
if let Some(full_assets) = &manifest.full_assets {
|
||
validate_portable_update_asset_catalog(
|
||
full_assets,
|
||
manifest,
|
||
platform,
|
||
display_platform,
|
||
suffix,
|
||
false,
|
||
)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn portable_update_asset_platform(
|
||
) -> Result<(&'static str, &'static str, &'static str), String> {
|
||
match portable_update_platform_key() {
|
||
Some("windows") => Ok(("windows", "Windows", "zip")),
|
||
Some("linux") => Ok(("linux", "Linux", "tar.gz")),
|
||
Some("darwin") => Ok(("darwin", "Darwin", "dmg")),
|
||
_ => Err("当前平台不支持应用内自动升级".to_string()),
|
||
}
|
||
}
|
||
|
||
fn validate_portable_update_asset_catalog(
|
||
assets: &std::collections::HashMap<String, PortableUpdateAsset>,
|
||
manifest: &PortableUpdateManifest,
|
||
platform: &str,
|
||
display_platform: &str,
|
||
suffix: &str,
|
||
allow_windows_legacy: bool,
|
||
) -> Result<(), String> {
|
||
if assets.len() != 2 {
|
||
return Err(format!("软件更新清单必须包含两个 {display_platform} 架构"));
|
||
}
|
||
let version = manifest.version.trim().trim_start_matches('v');
|
||
let tag = format!("v{version}");
|
||
for arch in ["amd64", "aarch64"] {
|
||
let key = format!("{platform}-{arch}");
|
||
let asset = assets
|
||
.get(&key)
|
||
.ok_or_else(|| format!("软件更新清单缺少 {key}"))?;
|
||
validate_portable_update_asset(asset)?;
|
||
let full_package_name =
|
||
format!("EasyCLIProxyAPI-v{version}-{display_platform}-{arch}.{suffix}");
|
||
let full_package_url = format!("{APP_RELEASE_DOWNLOAD_PREFIX}{tag}/{full_package_name}");
|
||
let legacy_package_name = format!("EasyCLIProxyAPI-update-v{version}-Windows-{arch}.zip");
|
||
let legacy_package_url =
|
||
format!("{APP_RELEASE_DOWNLOAD_PREFIX}{tag}/{legacy_package_name}");
|
||
let is_expected = asset.url == full_package_url
|
||
|| (allow_windows_legacy && asset.url == legacy_package_url);
|
||
if !is_expected {
|
||
return Err(format!("软件更新资产名称与 {key} 不匹配"));
|
||
}
|
||
let mut expected_filenames = vec![full_package_name.as_str()];
|
||
if allow_windows_legacy {
|
||
expected_filenames.push(legacy_package_name.as_str());
|
||
}
|
||
validate_portable_update_asset_fallbacks(asset, &tag, &expected_filenames)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn validate_portable_update_asset(asset: &PortableUpdateAsset) -> Result<(), String> {
|
||
let url = reqwest::Url::parse(&asset.url).map_err(|_| "软件更新下载地址无效".to_string())?;
|
||
if url.scheme() != "https"
|
||
|| url.host_str() != Some("github.com")
|
||
|| url.port().is_some()
|
||
|| !url.username().is_empty()
|
||
|| url.password().is_some()
|
||
|| url.query().is_some()
|
||
|| url.fragment().is_some()
|
||
|| !asset.url.starts_with(APP_RELEASE_DOWNLOAD_PREFIX)
|
||
{
|
||
return Err("软件更新下载地址不受信任".to_string());
|
||
}
|
||
if asset.size_bytes == 0 || asset.size_bytes > 512 * 1024 * 1024 {
|
||
return Err("软件更新包大小无效".to_string());
|
||
}
|
||
let digest = asset.sha256.trim().to_ascii_lowercase();
|
||
if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||
return Err("软件更新 SHA-256 无效".to_string());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn validate_portable_update_asset_fallbacks(
|
||
asset: &PortableUpdateAsset,
|
||
tag: &str,
|
||
expected_filenames: &[&str],
|
||
) -> Result<(), String> {
|
||
validate_portable_update_asset_fallbacks_for_repository(
|
||
asset,
|
||
tag,
|
||
expected_filenames,
|
||
configured_gitcode_gui_repository(),
|
||
)
|
||
}
|
||
|
||
pub(crate) fn validate_portable_update_asset_fallbacks_for_repository(
|
||
asset: &PortableUpdateAsset,
|
||
tag: &str,
|
||
expected_filenames: &[&str],
|
||
repository: Option<&str>,
|
||
) -> Result<(), String> {
|
||
if asset.fallback_urls.is_empty() {
|
||
return Ok(());
|
||
}
|
||
let repository = repository
|
||
.ok_or_else(|| "GitCode fallback is not configured for this build".to_string())?;
|
||
if asset.fallback_urls.len() != 1 {
|
||
return Err("Software update assets may define only one fallback URL".to_string());
|
||
}
|
||
let fallback = &asset.fallback_urls[0];
|
||
let trusted = expected_filenames
|
||
.iter()
|
||
.any(|filename| fallback == &gitcode_release_attachment_url(repository, tag, filename));
|
||
if !trusted {
|
||
return Err("Software update fallback URL is not trusted".to_string());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn portable_update_target() -> Option<(&'static str, &'static str)> {
|
||
let platform = portable_update_platform_key()?;
|
||
let arch = match env::consts::ARCH {
|
||
"x86_64" => "amd64",
|
||
"aarch64" => "aarch64",
|
||
_ => return None,
|
||
};
|
||
match (platform, arch) {
|
||
("windows", "amd64") => Some(("windows-amd64", "amd64")),
|
||
("windows", "aarch64") => Some(("windows-aarch64", "aarch64")),
|
||
("linux", "amd64") => Some(("linux-amd64", "amd64")),
|
||
("linux", "aarch64") => Some(("linux-aarch64", "aarch64")),
|
||
("darwin", "amd64") => Some(("darwin-amd64", "amd64")),
|
||
("darwin", "aarch64") => Some(("darwin-aarch64", "aarch64")),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
pub(crate) fn portable_update_platform_key() -> Option<&'static str> {
|
||
match env::consts::OS {
|
||
"windows" => Some("windows"),
|
||
"linux" => Some("linux"),
|
||
"macos" => Some("darwin"),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
pub(crate) fn local_portable_app_manifest_path() -> Result<PathBuf, String> {
|
||
let executable_dir = executable_dir()?;
|
||
#[cfg(target_os = "macos")]
|
||
if let Some(resources_dir) = macos_app_resources_dir(&executable_dir) {
|
||
return Ok(resources_dir.join(PORTABLE_APP_MANIFEST_FILE));
|
||
}
|
||
Ok(executable_dir.join(PORTABLE_APP_MANIFEST_FILE))
|
||
}
|
||
|
||
pub(crate) fn validate_local_portable_app_manifest(expected_arch: &str) -> Result<bool, String> {
|
||
let path = local_portable_app_manifest_path()?;
|
||
if !path.is_file() {
|
||
return Ok(false);
|
||
}
|
||
let contents =
|
||
fs::read_to_string(&path).map_err(|error| format!("读取便携版标识失败: {error}"))?;
|
||
let manifest = serde_json::from_str::<PortableAppManifest>(&contents)
|
||
.map_err(|error| format!("解析便携版标识失败: {error}"))?;
|
||
Ok(manifest.schema_version == 1
|
||
&& manifest.application == "EasyCLIProxyAPI"
|
||
&& Some(manifest.platform.as_str()) == portable_update_platform_key()
|
||
&& manifest.arch == expected_arch
|
||
&& manifest.auto_update
|
||
&& normalize_version(&manifest.version) == normalize_version(env!("CARGO_PKG_VERSION")))
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn get_app_update_task(state: tauri::State<'_, AppUpdateState>) -> AppUpdateTask {
|
||
state.snapshot()
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn cancel_app_update(state: tauri::State<'_, AppUpdateState>) -> Result<(), String> {
|
||
let task = state.snapshot();
|
||
if !task.running || !task.cancellable {
|
||
return Err("当前应用更新阶段无法取消".to_string());
|
||
}
|
||
state.cancel();
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn start_app_update(
|
||
app: tauri::AppHandle,
|
||
state: tauri::State<'_, AppUpdateState>,
|
||
) -> Result<(), String> {
|
||
if portable_update_platform_key().is_none() {
|
||
return Err("当前平台不支持应用内自动升级".to_string());
|
||
}
|
||
let token = CancellationToken::new();
|
||
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 = 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>();
|
||
let cancelled = token.is_cancelled();
|
||
let task = state.finish(
|
||
if cancelled { "cancelled" } else { "failed" },
|
||
Some(if cancelled {
|
||
"应用更新下载已取消".to_string()
|
||
} else {
|
||
error
|
||
}),
|
||
);
|
||
let _ = update_app.emit(APP_UPDATE_PROGRESS_EVENT, task);
|
||
}
|
||
});
|
||
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,
|
||
token: &CancellationToken,
|
||
proxy_url: &str,
|
||
download_source: VersionDownloadCandidate,
|
||
) -> Result<(), String> {
|
||
#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
|
||
{
|
||
let _ = (app, pending, token, proxy_url, download_source);
|
||
Err("当前平台不支持应用内自动升级".to_string())
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
{
|
||
validate_portable_update_asset(&pending.asset)?;
|
||
let work_dir = env::temp_dir().join(format!(
|
||
"EasyCLIProxyAPI-update-{}-{}-{}",
|
||
pending.version,
|
||
std::process::id(),
|
||
SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_nanos()
|
||
));
|
||
fs::create_dir_all(&work_dir)
|
||
.map_err(|error| format!("创建应用更新临时目录失败: {error}"))?;
|
||
let archive_path = work_dir.join("update.zip");
|
||
let result = async {
|
||
download_portable_update_archive(
|
||
app,
|
||
pending,
|
||
token,
|
||
&archive_path,
|
||
proxy_url,
|
||
&download_source,
|
||
)
|
||
.await?;
|
||
if token.is_cancelled() {
|
||
return Err("应用更新下载已取消".to_string());
|
||
}
|
||
|
||
update_app_task(app, |task| {
|
||
task.cancellable = false;
|
||
task.phase = "verifying".to_string();
|
||
task.message = Some("正在校验应用更新包".to_string());
|
||
});
|
||
let actual_sha256 = sha256_file(&archive_path)?;
|
||
if actual_sha256 != pending.asset.sha256.trim().to_ascii_lowercase() {
|
||
return Err("应用更新包 SHA-256 校验失败".to_string());
|
||
}
|
||
|
||
update_app_task(app, |task| {
|
||
task.phase = "staging".to_string();
|
||
task.message = Some("正在准备应用更新".to_string());
|
||
});
|
||
let staging_dir = work_dir.join("staging");
|
||
let package = extract_portable_update_archive(&archive_path, &staging_dir)?;
|
||
if normalize_version(&package.manifest.version) != normalize_version(&pending.version)
|
||
|| package.manifest.platform != "windows"
|
||
|| package.manifest.arch != pending.arch
|
||
{
|
||
return Err("应用更新包版本或架构不匹配".to_string());
|
||
}
|
||
|
||
let current_exe =
|
||
env::current_exe().map_err(|error| format!("读取当前程序路径失败: {error}"))?;
|
||
let app_dir = current_exe
|
||
.parent()
|
||
.ok_or_else(|| "当前程序路径没有父目录".to_string())?;
|
||
preflight_portable_update_directory(app_dir)?;
|
||
let core_archive_name = match package.core_archive_name {
|
||
Some(name) => name,
|
||
None => stage_current_portable_core_payload(app_dir, &staging_dir, &pending.arch)?,
|
||
};
|
||
let helper_path = work_dir.join("EasyCLIProxyAPI-updater.exe");
|
||
fs::copy(¤t_exe, &helper_path)
|
||
.map_err(|error| format!("准备应用更新助手失败: {error}"))?;
|
||
|
||
let descriptor = PortableUpdateDescriptor {
|
||
parent_pid: std::process::id(),
|
||
current_exe: current_exe.clone(),
|
||
staged_exe: staging_dir.join(PORTABLE_APP_BINARY),
|
||
current_manifest: app_dir.join(PORTABLE_APP_MANIFEST_FILE),
|
||
staged_manifest: staging_dir.join(PORTABLE_APP_MANIFEST_FILE),
|
||
backup_exe: app_dir.join(".EasyCLIProxyAPI.exe.update-backup"),
|
||
backup_manifest: app_dir.join(".portable-app.json.update-backup"),
|
||
current_core_version: app_dir.join(CORE_VERSION_FILE),
|
||
staged_core_version: staging_dir.join(CORE_VERSION_FILE),
|
||
backup_core_version: app_dir.join(".core-version.txt.update-backup"),
|
||
staged_core_archive: staging_dir.join("cpa-core").join(&core_archive_name),
|
||
target_core_archive: app_dir.join("cpa-core").join(&core_archive_name),
|
||
install_core_archive: !app_dir.join("cpa-core").join(&core_archive_name).is_file(),
|
||
ack_path: work_dir.join("update-started.ack"),
|
||
work_dir: work_dir.clone(),
|
||
target_version: pending.version.clone(),
|
||
};
|
||
launch_portable_update_helper(app, &helper_path, &work_dir, &descriptor).await
|
||
}
|
||
.await;
|
||
|
||
if result.is_err() {
|
||
let _ = fs::remove_dir_all(&work_dir);
|
||
}
|
||
result
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
validate_portable_update_asset(&pending.asset)?;
|
||
let work_dir = portable_update_work_dir(&pending.version);
|
||
fs::create_dir_all(&work_dir)
|
||
.map_err(|error| format!("创建应用更新临时目录失败: {error}"))?;
|
||
let archive_path = work_dir.join("update.tar.gz");
|
||
let result = async {
|
||
download_portable_update_archive(
|
||
app,
|
||
pending,
|
||
token,
|
||
&archive_path,
|
||
proxy_url,
|
||
&download_source,
|
||
)
|
||
.await?;
|
||
ensure_portable_update_download(token, &archive_path, pending)?;
|
||
update_app_task(app, |task| {
|
||
task.cancellable = false;
|
||
task.phase = "staging".to_string();
|
||
task.message = Some("正在准备应用更新".to_string());
|
||
});
|
||
|
||
let staging_dir = work_dir.join("staging");
|
||
let package = extract_portable_update_tar_gz(&archive_path, &staging_dir)?;
|
||
if normalize_version(&package.manifest.version) != normalize_version(&pending.version)
|
||
|| package.manifest.platform != "linux"
|
||
|| package.manifest.arch != pending.arch
|
||
{
|
||
return Err("应用更新包版本或架构不匹配".to_string());
|
||
}
|
||
let core_archive_name = package
|
||
.core_archive_name
|
||
.ok_or_else(|| "Linux 应用更新包缺少内置核心".to_string())?;
|
||
let current_exe =
|
||
env::current_exe().map_err(|error| format!("读取当前程序路径失败: {error}"))?;
|
||
let app_dir = current_exe
|
||
.parent()
|
||
.ok_or_else(|| "当前程序路径没有父目录".to_string())?;
|
||
preflight_portable_update_directory(app_dir)?;
|
||
let helper_path = work_dir.join("EasyCLIProxyAPI-updater");
|
||
fs::copy(¤t_exe, &helper_path)
|
||
.map_err(|error| format!("准备应用更新助手失败: {error}"))?;
|
||
|
||
let descriptor = PortableUpdateDescriptor {
|
||
parent_pid: std::process::id(),
|
||
current_exe: current_exe.clone(),
|
||
staged_exe: staging_dir.join(PORTABLE_APP_BINARY),
|
||
current_manifest: app_dir.join(PORTABLE_APP_MANIFEST_FILE),
|
||
staged_manifest: staging_dir.join(PORTABLE_APP_MANIFEST_FILE),
|
||
backup_exe: app_dir.join(".EasyCLIProxyAPI.update-backup"),
|
||
backup_manifest: app_dir.join(".portable-app.json.update-backup"),
|
||
current_core_version: app_dir.join(CORE_VERSION_FILE),
|
||
staged_core_version: staging_dir.join(CORE_VERSION_FILE),
|
||
backup_core_version: app_dir.join(".core-version.txt.update-backup"),
|
||
staged_core_archive: staging_dir.join("cpa-core").join(&core_archive_name),
|
||
target_core_archive: app_dir.join("cpa-core").join(&core_archive_name),
|
||
install_core_archive: !app_dir.join("cpa-core").join(&core_archive_name).is_file(),
|
||
ack_path: work_dir.join("update-started.ack"),
|
||
work_dir: work_dir.clone(),
|
||
target_version: pending.version.clone(),
|
||
};
|
||
launch_portable_update_helper(app, &helper_path, &work_dir, &descriptor).await
|
||
}
|
||
.await;
|
||
if result.is_err() {
|
||
let _ = fs::remove_dir_all(&work_dir);
|
||
}
|
||
result
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
validate_portable_update_asset(&pending.asset)?;
|
||
let work_dir = portable_update_work_dir(&pending.version);
|
||
fs::create_dir_all(&work_dir)
|
||
.map_err(|error| format!("创建应用更新临时目录失败: {error}"))?;
|
||
let archive_path = work_dir.join("update.dmg");
|
||
let result = async {
|
||
download_portable_update_archive(
|
||
app,
|
||
pending,
|
||
token,
|
||
&archive_path,
|
||
proxy_url,
|
||
&download_source,
|
||
)
|
||
.await?;
|
||
ensure_portable_update_download(token, &archive_path, pending)?;
|
||
update_app_task(app, |task| {
|
||
task.cancellable = false;
|
||
task.phase = "staging".to_string();
|
||
task.message = Some("正在准备应用更新".to_string());
|
||
});
|
||
|
||
let staged_app = work_dir.join("staging").join("EasyCLIProxyAPI.app");
|
||
stage_macos_application_from_dmg(&archive_path, &work_dir, &staged_app)?;
|
||
validate_macos_staged_application(&staged_app, pending)?;
|
||
let current_exe =
|
||
env::current_exe().map_err(|error| format!("读取当前程序路径失败: {error}"))?;
|
||
let current_app = macos_application_bundle_from_executable(¤t_exe)?;
|
||
preflight_macos_update_directory(¤t_app)?;
|
||
let executable_relative_path = current_exe
|
||
.strip_prefix(¤t_app)
|
||
.map_err(|_| "macOS 应用程序路径无效".to_string())?
|
||
.to_path_buf();
|
||
let backup_app = current_app
|
||
.parent()
|
||
.ok_or_else(|| "macOS 应用程序路径没有父目录".to_string())?
|
||
.join(".EasyCLIProxyAPI.app.update-backup");
|
||
let descriptor = MacosUpdateDescriptor {
|
||
parent_pid: std::process::id(),
|
||
current_app,
|
||
staged_app,
|
||
backup_app,
|
||
executable_relative_path,
|
||
ack_path: work_dir.join("update-started.ack"),
|
||
work_dir: work_dir.clone(),
|
||
target_version: pending.version.clone(),
|
||
};
|
||
let helper_path = macos_update_helper_path(¤t_exe);
|
||
launch_portable_update_helper(app, &helper_path, &work_dir, &descriptor).await
|
||
}
|
||
.await;
|
||
if result.is_err() {
|
||
let _ = fs::remove_dir_all(&work_dir);
|
||
}
|
||
result
|
||
}
|
||
}
|
||
|
||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||
fn portable_update_work_dir(version: &str) -> PathBuf {
|
||
env::temp_dir().join(format!(
|
||
"EasyCLIProxyAPI-update-{}-{}-{}",
|
||
version,
|
||
std::process::id(),
|
||
SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_nanos()
|
||
))
|
||
}
|
||
|
||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||
fn ensure_portable_update_download(
|
||
token: &CancellationToken,
|
||
archive_path: &Path,
|
||
pending: &PendingAppUpdate,
|
||
) -> Result<(), String> {
|
||
if token.is_cancelled() {
|
||
return Err("应用更新下载已取消".to_string());
|
||
}
|
||
let actual_sha256 = sha256_file(archive_path)?;
|
||
if actual_sha256 != pending.asset.sha256.trim().to_ascii_lowercase() {
|
||
return Err("应用更新包 SHA-256 校验失败".to_string());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(any(target_os = "macos", test))]
|
||
pub(crate) fn macos_update_helper_path(current_exe: &Path) -> PathBuf {
|
||
current_exe.to_path_buf()
|
||
}
|
||
|
||
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
|
||
async fn launch_portable_update_helper<T: Serialize>(
|
||
app: &tauri::AppHandle,
|
||
helper_path: &Path,
|
||
work_dir: &Path,
|
||
descriptor: &T,
|
||
) -> Result<(), String> {
|
||
let descriptor_path = work_dir.join("update-descriptor.json");
|
||
fs::write(
|
||
&descriptor_path,
|
||
serde_json::to_vec_pretty(descriptor)
|
||
.map_err(|error| format!("序列化应用更新描述失败: {error}"))?,
|
||
)
|
||
.map_err(|error| format!("写入应用更新描述失败: {error}"))?;
|
||
let helper_ack_path = portable_update_helper_ack_path(work_dir);
|
||
let _ = fs::remove_file(&helper_ack_path);
|
||
let mut command = Command::new(helper_path);
|
||
command
|
||
.arg("--portable-update-helper")
|
||
.arg(&descriptor_path)
|
||
.stdin(Stdio::null())
|
||
.stdout(Stdio::null())
|
||
.stderr(Stdio::null());
|
||
configure_background_command(&mut command);
|
||
let mut child = command
|
||
.spawn()
|
||
.map_err(|error| format!("启动应用更新助手失败: {error}"))?;
|
||
wait_for_portable_update_helper_start(
|
||
&mut child,
|
||
&helper_ack_path,
|
||
PORTABLE_UPDATE_HELPER_START_TIMEOUT,
|
||
)
|
||
.await?;
|
||
update_app_task(app, |task| {
|
||
task.cancellable = false;
|
||
task.phase = "restarting".to_string();
|
||
task.message = Some("更新已准备完成,应用即将重启".to_string());
|
||
});
|
||
app.exit(0);
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
|
||
pub(crate) fn portable_update_helper_ack_path(work_dir: &Path) -> PathBuf {
|
||
work_dir.join(PORTABLE_UPDATE_HELPER_ACK_FILE)
|
||
}
|
||
|
||
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
|
||
pub(crate) fn acknowledge_portable_update_helper_start(work_dir: &Path) -> Result<(), String> {
|
||
fs::write(
|
||
portable_update_helper_ack_path(work_dir),
|
||
std::process::id().to_string(),
|
||
)
|
||
.map_err(|error| format!("写入应用更新助手启动确认失败: {error}"))
|
||
}
|
||
|
||
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
|
||
async fn wait_for_portable_update_helper_start(
|
||
child: &mut Child,
|
||
ack_path: &Path,
|
||
timeout: Duration,
|
||
) -> Result<(), String> {
|
||
let deadline = Instant::now() + timeout;
|
||
loop {
|
||
if ack_path.is_file() {
|
||
return Ok(());
|
||
}
|
||
if let Some(status) = child
|
||
.try_wait()
|
||
.map_err(|error| format!("检查应用更新助手状态失败: {error}"))?
|
||
{
|
||
return Err(format!("应用更新助手未能启动,退出状态: {status}"));
|
||
}
|
||
if Instant::now() >= deadline {
|
||
let _ = child.kill();
|
||
let _ = child.wait();
|
||
return Err(format!(
|
||
"应用更新助手未能在 {} 秒内完成启动确认",
|
||
timeout.as_secs()
|
||
));
|
||
}
|
||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||
}
|
||
}
|
||
|
||
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
|
||
pub(crate) async fn download_portable_update_archive(
|
||
app: &tauri::AppHandle,
|
||
pending: &PendingAppUpdate,
|
||
token: &CancellationToken,
|
||
destination: &Path,
|
||
proxy_url: &str,
|
||
download_source: &VersionDownloadCandidate,
|
||
) -> Result<(), String> {
|
||
let client = build_http_client_with_proxy(
|
||
reqwest::Client::builder()
|
||
.redirect(release_https_redirect_policy_with_mirrors(
|
||
&download_source
|
||
.custom_url
|
||
.clone()
|
||
.into_iter()
|
||
.collect::<Vec<_>>(),
|
||
))
|
||
.connect_timeout(Duration::from_secs(15))
|
||
.read_timeout(Duration::from_secs(30))
|
||
.timeout(Duration::from_secs(15 * 60)),
|
||
proxy_url,
|
||
"创建应用更新下载客户端失败",
|
||
)?;
|
||
let urls = portable_update_download_urls(&pending.asset, download_source);
|
||
let mut failures = Vec::new();
|
||
for (index, url) in urls.iter().enumerate() {
|
||
update_app_task(app, |task| {
|
||
task.downloaded_bytes = 0;
|
||
task.percent = Some(0.0);
|
||
if index > 0 {
|
||
task.message = Some(format!(
|
||
"下载失败,正在切换到 {}",
|
||
update_download_source_name(url)
|
||
));
|
||
}
|
||
});
|
||
match download_portable_update_archive_url(app, pending, token, destination, &client, url)
|
||
.await
|
||
{
|
||
Ok(()) => return Ok(()),
|
||
Err(error) if token.is_cancelled() => return Err(error),
|
||
Err(error) => failures.push(error),
|
||
}
|
||
}
|
||
Err(format!("所有应用更新下载源均失败: {}", failures.join("; ")))
|
||
}
|
||
|
||
pub(crate) fn portable_update_download_urls(
|
||
asset: &PortableUpdateAsset,
|
||
source: &VersionDownloadCandidate,
|
||
) -> Vec<String> {
|
||
let mut urls = std::iter::once(asset.url.as_str())
|
||
.chain(asset.fallback_urls.iter().map(String::as_str))
|
||
.map(str::to_string)
|
||
.collect::<Vec<_>>();
|
||
if source.source == VersionDownloadSource::Gitcode {
|
||
urls.sort_by_key(|url| update_download_source_name(url) != "GitCode");
|
||
} else if source.proxy_prefix().is_some() {
|
||
if let Some(github_url) = urls
|
||
.iter()
|
||
.find(|url| update_download_source_name(url) == "GitHub")
|
||
.cloned()
|
||
{
|
||
urls.insert(0, version_source_url(source, &github_url));
|
||
}
|
||
}
|
||
urls.dedup();
|
||
urls
|
||
}
|
||
|
||
pub(crate) fn update_download_source_name(url: &str) -> String {
|
||
let host = reqwest::Url::parse(url)
|
||
.ok()
|
||
.and_then(|url| url.host_str().map(str::to_string));
|
||
match host.as_deref() {
|
||
Some("gh-proxy.com") => "gh-proxy.com".to_string(),
|
||
Some("ghfast.top") => "ghfast.top".to_string(),
|
||
Some(host) if host == "api.gitcode.com" || host.ends_with(".gitcode.com") => {
|
||
"GitCode".to_string()
|
||
}
|
||
Some(
|
||
"github.com" | "objects.githubusercontent.com" | "release-assets.githubusercontent.com",
|
||
) => "GitHub".to_string(),
|
||
Some(host) => host.to_string(),
|
||
None => "GitHub".to_string(),
|
||
}
|
||
}
|
||
|
||
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
|
||
pub(crate) async fn download_portable_update_archive_url(
|
||
app: &tauri::AppHandle,
|
||
pending: &PendingAppUpdate,
|
||
token: &CancellationToken,
|
||
destination: &Path,
|
||
client: &reqwest::Client,
|
||
url: &str,
|
||
) -> Result<(), String> {
|
||
let response = client
|
||
.get(url)
|
||
.header(reqwest::header::ACCEPT, "application/octet-stream")
|
||
.header(reqwest::header::USER_AGENT, APP_USER_AGENT)
|
||
.send()
|
||
.await
|
||
.map_err(|error| format!("下载应用更新失败: {error}"))?
|
||
.error_for_status()
|
||
.map_err(|error| format!("下载应用更新失败: {error}"))?;
|
||
let mut stream = response.bytes_stream();
|
||
let mut file =
|
||
File::create(destination).map_err(|error| format!("创建应用更新临时文件失败: {error}"))?;
|
||
let mut downloaded = 0_u64;
|
||
let mut progress = crate::progress::ProgressThrottle::default();
|
||
while let Some(chunk) = stream.next().await {
|
||
if token.is_cancelled() {
|
||
return Err("应用更新下载已取消".to_string());
|
||
}
|
||
let chunk = chunk.map_err(|error| format!("读取应用更新下载数据失败: {error}"))?;
|
||
file.write_all(&chunk)
|
||
.map_err(|error| format!("写入应用更新临时文件失败: {error}"))?;
|
||
downloaded = downloaded.saturating_add(chunk.len() as u64);
|
||
if downloaded > pending.asset.size_bytes {
|
||
return Err("应用更新包超过清单声明大小".to_string());
|
||
}
|
||
if progress.ready(Instant::now(), downloaded == pending.asset.size_bytes) {
|
||
update_app_task(app, |task| {
|
||
task.downloaded_bytes = downloaded;
|
||
task.total_bytes = Some(pending.asset.size_bytes);
|
||
task.percent = Some((downloaded as f64 / pending.asset.size_bytes as f64) * 100.0);
|
||
task.message = Some(format!(
|
||
"{} / {}",
|
||
format_byte_count(downloaded),
|
||
format_byte_count(pending.asset.size_bytes)
|
||
));
|
||
});
|
||
}
|
||
}
|
||
file.flush()
|
||
.map_err(|error| format!("保存应用更新临时文件失败: {error}"))?;
|
||
if downloaded != pending.asset.size_bytes {
|
||
return Err(format!(
|
||
"应用更新包大小不匹配: 预期 {},实际 {}",
|
||
pending.asset.size_bytes, downloaded
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
|
||
pub(crate) fn update_app_task<F>(app: &tauri::AppHandle, update: F)
|
||
where
|
||
F: FnOnce(&mut AppUpdateTask),
|
||
{
|
||
let state = app.state::<AppUpdateState>();
|
||
let task = state.update_task(update);
|
||
let _ = app.emit(APP_UPDATE_PROGRESS_EVENT, task);
|
||
}
|
||
|
||
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
|
||
pub(crate) fn format_byte_count(bytes: u64) -> String {
|
||
const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
|
||
let mut value = bytes as f64;
|
||
let mut unit = 0_usize;
|
||
while value >= 1024.0 && unit < UNITS.len() - 1 {
|
||
value /= 1024.0;
|
||
unit += 1;
|
||
}
|
||
if unit == 0 {
|
||
format!("{bytes} {}", UNITS[unit])
|
||
} else {
|
||
format!("{value:.1} {}", UNITS[unit])
|
||
}
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
pub(crate) fn extract_portable_update_archive(
|
||
archive_path: &Path,
|
||
staging_dir: &Path,
|
||
) -> Result<PortablePackagePayload, String> {
|
||
fs::create_dir_all(staging_dir)
|
||
.map_err(|error| format!("创建应用更新暂存目录失败: {error}"))?;
|
||
let file = File::open(archive_path).map_err(|error| format!("打开应用更新包失败: {error}"))?;
|
||
let mut archive =
|
||
ZipArchive::new(file).map_err(|error| format!("读取应用更新 ZIP 失败: {error}"))?;
|
||
let archive_names = archive
|
||
.file_names()
|
||
.map(|name| name.replace('\\', "/"))
|
||
.collect::<Vec<_>>();
|
||
let legacy_layout = archive_names.len() == 2
|
||
&& archive_names.iter().any(|name| name == PORTABLE_APP_BINARY)
|
||
&& archive_names
|
||
.iter()
|
||
.any(|name| name == PORTABLE_APP_MANIFEST_FILE);
|
||
let mut package_root = None::<String>;
|
||
let mut seen_binary = false;
|
||
let mut seen_manifest = false;
|
||
let mut seen_core_version = false;
|
||
let mut core_archive_name = None::<String>;
|
||
let mut regular_file_count = 0_u32;
|
||
for index in 0..archive.len() {
|
||
let mut entry = archive
|
||
.by_index(index)
|
||
.map_err(|error| format!("读取应用更新 ZIP 条目失败: {error}"))?;
|
||
let name = entry.name().replace('\\', "/");
|
||
if entry.enclosed_name().is_none() {
|
||
return Err("应用更新包包含不安全路径".to_string());
|
||
}
|
||
if entry
|
||
.unix_mode()
|
||
.is_some_and(|mode| mode & 0o170000 == 0o120000)
|
||
{
|
||
return Err("应用更新包不能包含符号链接".to_string());
|
||
}
|
||
if entry.is_dir() {
|
||
continue;
|
||
}
|
||
regular_file_count = regular_file_count.saturating_add(1);
|
||
let relative;
|
||
let relative_name;
|
||
if legacy_layout {
|
||
relative = Vec::new();
|
||
relative_name = name.clone();
|
||
} else {
|
||
let mut components = name.split('/');
|
||
let root = components
|
||
.next()
|
||
.filter(|value| !value.is_empty())
|
||
.ok_or_else(|| "应用更新包缺少顶层目录".to_string())?;
|
||
relative = components.collect::<Vec<_>>();
|
||
if relative.is_empty() || relative.iter().any(|value| value.is_empty()) {
|
||
return Err("应用更新包目录结构无效".to_string());
|
||
}
|
||
if package_root.as_deref().is_some_and(|value| value != root) {
|
||
return Err("应用更新包必须只包含一个顶层目录".to_string());
|
||
}
|
||
package_root.get_or_insert_with(|| root.to_string());
|
||
relative_name = relative.join("/");
|
||
}
|
||
let destination = match relative_name.as_str() {
|
||
PORTABLE_APP_BINARY if !seen_binary => {
|
||
if entry.size() == 0 || entry.size() > 256 * 1024 * 1024 {
|
||
return Err("应用更新程序大小异常".to_string());
|
||
}
|
||
seen_binary = true;
|
||
staging_dir.join(PORTABLE_APP_BINARY)
|
||
}
|
||
PORTABLE_APP_MANIFEST_FILE if !seen_manifest => {
|
||
if entry.size() == 0 || entry.size() > 64 * 1024 {
|
||
return Err("应用更新标识大小异常".to_string());
|
||
}
|
||
seen_manifest = true;
|
||
staging_dir.join(PORTABLE_APP_MANIFEST_FILE)
|
||
}
|
||
CORE_VERSION_FILE if !seen_core_version => {
|
||
if entry.size() == 0 || entry.size() > 1024 {
|
||
return Err("应用更新包内核版本文件大小异常".to_string());
|
||
}
|
||
seen_core_version = true;
|
||
staging_dir.join(CORE_VERSION_FILE)
|
||
}
|
||
_ if relative.len() == 2
|
||
&& relative[0] == "cpa-core"
|
||
&& core_archive_name.is_none() =>
|
||
{
|
||
let filename = relative[1];
|
||
if !filename.starts_with("CLIProxyAPI_")
|
||
|| !filename.ends_with(".zip")
|
||
|| filename.contains("_no-plugin")
|
||
|| entry.size() == 0
|
||
|| entry.size() > 512 * 1024 * 1024
|
||
{
|
||
return Err("应用更新包内置内核压缩包无效".to_string());
|
||
}
|
||
core_archive_name = Some(filename.to_string());
|
||
let core_staging_dir = staging_dir.join("cpa-core");
|
||
fs::create_dir_all(&core_staging_dir)
|
||
.map_err(|error| format!("创建内置内核暂存目录失败: {error}"))?;
|
||
core_staging_dir.join(filename)
|
||
}
|
||
_ => return Err(format!("应用更新包包含未知文件: {name}")),
|
||
};
|
||
let mut output = File::create(&destination)
|
||
.map_err(|error| format!("创建应用更新暂存文件失败: {error}"))?;
|
||
io::copy(&mut entry, &mut output)
|
||
.map_err(|error| format!("解压应用更新文件失败: {error}"))?;
|
||
output
|
||
.flush()
|
||
.map_err(|error| format!("保存应用更新暂存文件失败: {error}"))?;
|
||
}
|
||
let required_files_present = if legacy_layout {
|
||
regular_file_count == 2 && seen_binary && seen_manifest
|
||
} else {
|
||
regular_file_count == 4 && seen_binary && seen_manifest && seen_core_version
|
||
};
|
||
if !required_files_present {
|
||
return Err("应用更新包缺少必要文件".to_string());
|
||
}
|
||
let manifest = fs::read_to_string(staging_dir.join(PORTABLE_APP_MANIFEST_FILE))
|
||
.map_err(|error| format!("读取应用更新标识失败: {error}"))?;
|
||
let manifest = serde_json::from_str::<PortableAppManifest>(&manifest)
|
||
.map_err(|error| format!("解析应用更新标识失败: {error}"))?;
|
||
if manifest.schema_version != 1
|
||
|| manifest.application != "EasyCLIProxyAPI"
|
||
|| !manifest.auto_update
|
||
{
|
||
return Err("应用更新标识无效".to_string());
|
||
}
|
||
if !legacy_layout {
|
||
let expected_root = format!(
|
||
"EasyCLIProxyAPI-v{}-Windows-{}",
|
||
manifest.version.trim().trim_start_matches('v'),
|
||
manifest.arch
|
||
);
|
||
if package_root.as_deref() != Some(expected_root.as_str()) {
|
||
return Err("应用更新包顶层目录与版本或架构不匹配".to_string());
|
||
}
|
||
let core_version = fs::read_to_string(staging_dir.join(CORE_VERSION_FILE))
|
||
.map_err(|error| format!("读取内置内核版本失败: {error}"))?;
|
||
let core_version = core_version.trim().trim_start_matches('v');
|
||
if core_version.is_empty()
|
||
|| !core_version.split('.').all(|segment| {
|
||
!segment.is_empty() && segment.bytes().all(|byte| byte.is_ascii_digit())
|
||
})
|
||
{
|
||
return Err("应用更新包内置内核版本无效".to_string());
|
||
}
|
||
let expected_core_archive =
|
||
format!("CLIProxyAPI_{core_version}_windows_{}.zip", manifest.arch);
|
||
if core_archive_name.as_deref() != Some(expected_core_archive.as_str()) {
|
||
return Err("应用更新包内置内核版本或架构不匹配".to_string());
|
||
}
|
||
}
|
||
Ok(PortablePackagePayload {
|
||
manifest,
|
||
core_archive_name,
|
||
})
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
pub(crate) fn extract_portable_update_tar_gz(
|
||
archive_path: &Path,
|
||
staging_dir: &Path,
|
||
) -> Result<PortablePackagePayload, String> {
|
||
fs::create_dir_all(staging_dir)
|
||
.map_err(|error| format!("创建应用更新暂存目录失败: {error}"))?;
|
||
let file = File::open(archive_path).map_err(|error| format!("打开应用更新包失败: {error}"))?;
|
||
let decoder = GzDecoder::new(file);
|
||
let mut archive = Archive::new(decoder);
|
||
let entries = archive
|
||
.entries()
|
||
.map_err(|error| format!("读取应用更新 TAR.GZ 失败: {error}"))?;
|
||
let mut package_root = None::<String>;
|
||
let mut seen_binary = false;
|
||
let mut seen_manifest = false;
|
||
let mut seen_core_version = false;
|
||
let mut core_archive_name = None::<String>;
|
||
let mut regular_file_count = 0_u32;
|
||
|
||
for entry in entries {
|
||
let mut entry = entry.map_err(|error| format!("读取应用更新条目失败: {error}"))?;
|
||
let entry_type = entry.header().entry_type();
|
||
if entry_type.is_dir() {
|
||
continue;
|
||
}
|
||
if !entry_type.is_file() {
|
||
return Err("应用更新包不能包含链接或特殊文件".to_string());
|
||
}
|
||
let entry_path = entry
|
||
.path()
|
||
.map_err(|error| format!("读取应用更新条目路径失败: {error}"))?;
|
||
let mut components = Vec::new();
|
||
for component in entry_path.components() {
|
||
match component {
|
||
Component::Normal(value) => components.push(value.to_string_lossy().into_owned()),
|
||
Component::CurDir => {}
|
||
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
|
||
return Err("应用更新包包含不安全路径".to_string());
|
||
}
|
||
}
|
||
}
|
||
if components.len() < 2 || components.iter().any(String::is_empty) {
|
||
return Err("应用更新包目录结构无效".to_string());
|
||
}
|
||
let root = &components[0];
|
||
if package_root.as_deref().is_some_and(|value| value != root) {
|
||
return Err("应用更新包必须只包含一个顶层目录".to_string());
|
||
}
|
||
package_root.get_or_insert_with(|| root.clone());
|
||
let relative = &components[1..];
|
||
let relative_name = relative.join("/");
|
||
regular_file_count = regular_file_count.saturating_add(1);
|
||
let size = entry.size();
|
||
let destination = match relative_name.as_str() {
|
||
PORTABLE_APP_BINARY if !seen_binary => {
|
||
if size == 0 || size > 256 * 1024 * 1024 {
|
||
return Err("应用更新程序大小异常".to_string());
|
||
}
|
||
seen_binary = true;
|
||
staging_dir.join(PORTABLE_APP_BINARY)
|
||
}
|
||
PORTABLE_APP_MANIFEST_FILE if !seen_manifest => {
|
||
if size == 0 || size > 64 * 1024 {
|
||
return Err("应用更新标识大小异常".to_string());
|
||
}
|
||
seen_manifest = true;
|
||
staging_dir.join(PORTABLE_APP_MANIFEST_FILE)
|
||
}
|
||
CORE_VERSION_FILE if !seen_core_version => {
|
||
if size == 0 || size > 1024 {
|
||
return Err("应用更新包内核版本文件大小异常".to_string());
|
||
}
|
||
seen_core_version = true;
|
||
staging_dir.join(CORE_VERSION_FILE)
|
||
}
|
||
_ if relative.len() == 2
|
||
&& relative[0] == "cpa-core"
|
||
&& core_archive_name.is_none() =>
|
||
{
|
||
let filename = &relative[1];
|
||
if !filename.starts_with("CLIProxyAPI_")
|
||
|| !filename.ends_with(".tar.gz")
|
||
|| filename.contains("_no-plugin")
|
||
|| size == 0
|
||
|| size > 512 * 1024 * 1024
|
||
{
|
||
return Err("应用更新包内置内核压缩包无效".to_string());
|
||
}
|
||
core_archive_name = Some(filename.clone());
|
||
let core_staging_dir = staging_dir.join("cpa-core");
|
||
fs::create_dir_all(&core_staging_dir)
|
||
.map_err(|error| format!("创建内置内核暂存目录失败: {error}"))?;
|
||
core_staging_dir.join(filename)
|
||
}
|
||
_ => return Err(format!("应用更新包包含未知文件: {relative_name}")),
|
||
};
|
||
let mut output = File::create(&destination)
|
||
.map_err(|error| format!("创建应用更新暂存文件失败: {error}"))?;
|
||
io::copy(&mut entry, &mut output)
|
||
.map_err(|error| format!("解压应用更新文件失败: {error}"))?;
|
||
output
|
||
.flush()
|
||
.map_err(|error| format!("保存应用更新暂存文件失败: {error}"))?;
|
||
}
|
||
if regular_file_count != 4
|
||
|| !seen_binary
|
||
|| !seen_manifest
|
||
|| !seen_core_version
|
||
|| core_archive_name.is_none()
|
||
{
|
||
return Err("应用更新包缺少必要文件".to_string());
|
||
}
|
||
use std::os::unix::fs::PermissionsExt;
|
||
fs::set_permissions(
|
||
staging_dir.join(PORTABLE_APP_BINARY),
|
||
fs::Permissions::from_mode(0o755),
|
||
)
|
||
.map_err(|error| format!("设置应用更新程序权限失败: {error}"))?;
|
||
let manifest = fs::read_to_string(staging_dir.join(PORTABLE_APP_MANIFEST_FILE))
|
||
.map_err(|error| format!("读取应用更新标识失败: {error}"))?;
|
||
let manifest = serde_json::from_str::<PortableAppManifest>(&manifest)
|
||
.map_err(|error| format!("解析应用更新标识失败: {error}"))?;
|
||
if manifest.schema_version != 1
|
||
|| manifest.application != "EasyCLIProxyAPI"
|
||
|| manifest.platform != "linux"
|
||
|| !manifest.auto_update
|
||
{
|
||
return Err("应用更新标识无效".to_string());
|
||
}
|
||
let expected_root = format!(
|
||
"EasyCLIProxyAPI-v{}-Linux-{}",
|
||
manifest.version.trim().trim_start_matches('v'),
|
||
manifest.arch
|
||
);
|
||
if package_root.as_deref() != Some(expected_root.as_str()) {
|
||
return Err("应用更新包顶层目录与版本或架构不匹配".to_string());
|
||
}
|
||
let core_version = fs::read_to_string(staging_dir.join(CORE_VERSION_FILE))
|
||
.map_err(|error| format!("读取内置内核版本失败: {error}"))?;
|
||
let core_version = core_version.trim().trim_start_matches('v');
|
||
if core_version.is_empty()
|
||
|| !core_version
|
||
.split('.')
|
||
.all(|segment| !segment.is_empty() && segment.bytes().all(|byte| byte.is_ascii_digit()))
|
||
{
|
||
return Err("应用更新包内置内核版本无效".to_string());
|
||
}
|
||
let expected_core_archive =
|
||
format!("CLIProxyAPI_{core_version}_linux_{}.tar.gz", manifest.arch);
|
||
if core_archive_name.as_deref() != Some(expected_core_archive.as_str()) {
|
||
return Err("应用更新包内置内核版本或架构不匹配".to_string());
|
||
}
|
||
Ok(PortablePackagePayload {
|
||
manifest,
|
||
core_archive_name,
|
||
})
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
pub(crate) fn stage_current_portable_core_payload(
|
||
app_dir: &Path,
|
||
staging_dir: &Path,
|
||
arch: &str,
|
||
) -> Result<String, String> {
|
||
let current_core_version = app_dir.join(CORE_VERSION_FILE);
|
||
let core_version = fs::read_to_string(¤t_core_version)
|
||
.map_err(|error| format!("读取当前内置内核版本失败: {error}"))?;
|
||
let core_version = core_version.trim().trim_start_matches('v');
|
||
if core_version.is_empty()
|
||
|| !core_version
|
||
.split('.')
|
||
.all(|segment| !segment.is_empty() && segment.bytes().all(|byte| byte.is_ascii_digit()))
|
||
{
|
||
return Err("当前内置内核版本无效,无法兼容旧版更新包".to_string());
|
||
}
|
||
let core_archive_name = format!("CLIProxyAPI_{core_version}_windows_{arch}.zip");
|
||
let current_core_archive = app_dir.join("cpa-core").join(&core_archive_name);
|
||
if !current_core_archive.is_file() {
|
||
return Err(format!(
|
||
"当前便携版缺少内置内核压缩包 {core_archive_name},无法兼容旧版更新包"
|
||
));
|
||
}
|
||
let staged_core_dir = staging_dir.join("cpa-core");
|
||
fs::create_dir_all(&staged_core_dir)
|
||
.map_err(|error| format!("创建旧版更新兼容暂存目录失败: {error}"))?;
|
||
fs::copy(¤t_core_version, staging_dir.join(CORE_VERSION_FILE))
|
||
.map_err(|error| format!("暂存当前内核版本文件失败: {error}"))?;
|
||
fs::copy(
|
||
current_core_archive,
|
||
staged_core_dir.join(&core_archive_name),
|
||
)
|
||
.map_err(|error| format!("暂存当前内置内核压缩包失败: {error}"))?;
|
||
Ok(core_archive_name)
|
||
}
|
||
|
||
#[cfg(any(windows, target_os = "linux"))]
|
||
pub(crate) fn preflight_portable_update_directory(app_dir: &Path) -> Result<(), String> {
|
||
if !app_dir.join(CORE_VERSION_FILE).is_file() || !app_dir.join("cpa-core").is_dir() {
|
||
return Err("当前便携版目录不完整,请下载最新版完整包覆盖升级".to_string());
|
||
}
|
||
let probe = app_dir.join(format!(
|
||
".easycliproxy-update-write-test-{}",
|
||
std::process::id()
|
||
));
|
||
fs::write(&probe, b"update-write-test")
|
||
.map_err(|error| format!("应用目录不可写,无法自动升级: {error}"))?;
|
||
fs::remove_file(&probe).map_err(|error| format!("清理应用更新写入测试失败: {error}"))?;
|
||
let core_probe = app_dir.join("cpa-core").join(format!(
|
||
".easycliproxy-update-write-test-{}",
|
||
std::process::id()
|
||
));
|
||
fs::write(&core_probe, b"update-write-test")
|
||
.map_err(|error| format!("内置内核目录不可写,无法自动升级: {error}"))?;
|
||
fs::remove_file(&core_probe)
|
||
.map_err(|error| format!("清理内置内核更新写入测试失败: {error}"))?;
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
pub(crate) fn wait_for_windows_process_exit(
|
||
process_id: u32,
|
||
timeout: Duration,
|
||
) -> Result<(), String> {
|
||
use windows_sys::Win32::{
|
||
Foundation::{CloseHandle, WAIT_FAILED, WAIT_TIMEOUT},
|
||
System::Threading::{OpenProcess, WaitForSingleObject},
|
||
};
|
||
|
||
const SYNCHRONIZE_ACCESS: u32 = 0x0010_0000;
|
||
let handle = unsafe { OpenProcess(SYNCHRONIZE_ACCESS, 0, process_id) };
|
||
if handle.is_null() {
|
||
let error = io::Error::last_os_error();
|
||
if error.raw_os_error() == Some(87) {
|
||
return Ok(());
|
||
}
|
||
return Err(format!("打开旧版应用进程失败: {error}"));
|
||
}
|
||
let timeout_ms = timeout.as_millis().min(u32::MAX as u128) as u32;
|
||
let result = unsafe { WaitForSingleObject(handle, timeout_ms) };
|
||
unsafe { CloseHandle(handle) };
|
||
if result == WAIT_TIMEOUT {
|
||
return Err("等待旧版应用退出超时".to_string());
|
||
}
|
||
if result == WAIT_FAILED {
|
||
return Err(format!(
|
||
"等待旧版应用退出失败: {}",
|
||
io::Error::last_os_error()
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
pub(crate) fn wait_for_portable_parent_exit(pid: u32, timeout: Duration) -> Result<(), String> {
|
||
wait_for_windows_process_exit(pid, timeout)
|
||
}
|
||
|
||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||
pub(crate) fn wait_for_portable_parent_exit(pid: u32, timeout: Duration) -> Result<(), String> {
|
||
if pid == 0 || pid > i32::MAX as u32 {
|
||
return Err("应用更新父进程编号无效".to_string());
|
||
}
|
||
let deadline = Instant::now() + timeout;
|
||
loop {
|
||
let result = unsafe { libc::kill(pid as i32, 0) };
|
||
if result != 0 {
|
||
let error = io::Error::last_os_error();
|
||
if error.raw_os_error() == Some(libc::ESRCH) {
|
||
return Ok(());
|
||
}
|
||
if error.raw_os_error() != Some(libc::EPERM) {
|
||
return Err(format!("检查旧版应用进程失败: {error}"));
|
||
}
|
||
}
|
||
if Instant::now() >= deadline {
|
||
return Err("等待旧版应用退出超时".to_string());
|
||
}
|
||
thread::sleep(Duration::from_millis(100));
|
||
}
|
||
}
|
||
|
||
#[cfg(any(windows, target_os = "linux"))]
|
||
pub(crate) fn validate_portable_update_descriptor(
|
||
descriptor_path: &Path,
|
||
descriptor: &PortableUpdateDescriptor,
|
||
) -> Result<(), String> {
|
||
if descriptor.parent_pid == 0
|
||
|| semver::Version::parse(descriptor.target_version.trim().trim_start_matches('v')).is_err()
|
||
{
|
||
return Err("应用更新描述无效".to_string());
|
||
}
|
||
let app_dir = descriptor
|
||
.current_exe
|
||
.parent()
|
||
.ok_or_else(|| "应用更新目标路径无效".to_string())?;
|
||
if !descriptor.current_exe.is_absolute()
|
||
|| descriptor.current_exe != app_dir.join(PORTABLE_APP_BINARY)
|
||
|| descriptor.current_manifest != app_dir.join(PORTABLE_APP_MANIFEST_FILE)
|
||
|| descriptor.backup_exe != app_dir.join(portable_update_backup_executable_name())
|
||
|| descriptor.backup_manifest != app_dir.join(".portable-app.json.update-backup")
|
||
|| descriptor.current_core_version != app_dir.join(CORE_VERSION_FILE)
|
||
|| descriptor.backup_core_version != app_dir.join(".core-version.txt.update-backup")
|
||
{
|
||
return Err("应用更新目标路径无效".to_string());
|
||
}
|
||
let core_archive_name = descriptor
|
||
.staged_core_archive
|
||
.file_name()
|
||
.ok_or_else(|| "应用更新内置内核文件名无效".to_string())?;
|
||
if descriptor.target_core_archive != app_dir.join("cpa-core").join(core_archive_name)
|
||
|| !core_archive_name
|
||
.to_string_lossy()
|
||
.starts_with("CLIProxyAPI_")
|
||
|| !core_archive_name
|
||
.to_string_lossy()
|
||
.ends_with(portable_update_core_archive_suffix())
|
||
|| descriptor.install_core_archive == descriptor.target_core_archive.is_file()
|
||
{
|
||
return Err("应用更新内置内核目标路径无效".to_string());
|
||
}
|
||
let canonical_work_dir = fs::canonicalize(&descriptor.work_dir)
|
||
.map_err(|error| format!("读取应用更新临时目录失败: {error}"))?;
|
||
let canonical_temp_dir = fs::canonicalize(env::temp_dir())
|
||
.map_err(|error| format!("读取系统临时目录失败: {error}"))?;
|
||
if !canonical_work_dir.starts_with(&canonical_temp_dir)
|
||
|| !canonical_work_dir
|
||
.file_name()
|
||
.and_then(|value| value.to_str())
|
||
.is_some_and(|value| value.starts_with("EasyCLIProxyAPI-update-"))
|
||
{
|
||
return Err("应用更新工作目录无效".to_string());
|
||
}
|
||
let canonical_descriptor = fs::canonicalize(descriptor_path)
|
||
.map_err(|error| format!("读取应用更新描述路径失败: {error}"))?;
|
||
if canonical_descriptor.parent() != Some(canonical_work_dir.as_path())
|
||
|| canonical_descriptor
|
||
.file_name()
|
||
.and_then(|value| value.to_str())
|
||
!= Some("update-descriptor.json")
|
||
|| descriptor.ack_path != descriptor.work_dir.join("update-started.ack")
|
||
{
|
||
return Err("应用更新描述路径越界".to_string());
|
||
}
|
||
for staged in [
|
||
&descriptor.staged_exe,
|
||
&descriptor.staged_manifest,
|
||
&descriptor.staged_core_version,
|
||
&descriptor.staged_core_archive,
|
||
] {
|
||
let canonical = fs::canonicalize(staged)
|
||
.map_err(|error| format!("读取应用更新暂存文件失败: {error}"))?;
|
||
if !canonical.starts_with(&canonical_work_dir) {
|
||
return Err("应用更新暂存路径越界".to_string());
|
||
}
|
||
}
|
||
if descriptor.staged_exe
|
||
!= descriptor
|
||
.work_dir
|
||
.join("staging")
|
||
.join(PORTABLE_APP_BINARY)
|
||
|| descriptor.staged_manifest
|
||
!= descriptor
|
||
.work_dir
|
||
.join("staging")
|
||
.join(PORTABLE_APP_MANIFEST_FILE)
|
||
|| descriptor.staged_core_version
|
||
!= descriptor.work_dir.join("staging").join(CORE_VERSION_FILE)
|
||
|| descriptor.staged_core_archive
|
||
!= descriptor
|
||
.work_dir
|
||
.join("staging")
|
||
.join("cpa-core")
|
||
.join(core_archive_name)
|
||
{
|
||
return Err("应用更新暂存路径无效".to_string());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(any(windows, target_os = "linux"))]
|
||
fn portable_update_backup_executable_name() -> &'static str {
|
||
if cfg!(windows) {
|
||
".EasyCLIProxyAPI.exe.update-backup"
|
||
} else {
|
||
".EasyCLIProxyAPI.update-backup"
|
||
}
|
||
}
|
||
|
||
#[cfg(any(windows, target_os = "linux"))]
|
||
fn portable_update_replacement_executable_name() -> &'static str {
|
||
if cfg!(windows) {
|
||
".EasyCLIProxyAPI.exe.update-new"
|
||
} else {
|
||
".EasyCLIProxyAPI.update-new"
|
||
}
|
||
}
|
||
|
||
#[cfg(any(windows, target_os = "linux"))]
|
||
fn portable_update_core_archive_suffix() -> &'static str {
|
||
if cfg!(windows) {
|
||
".zip"
|
||
} else {
|
||
".tar.gz"
|
||
}
|
||
}
|
||
|
||
#[cfg(any(windows, target_os = "linux"))]
|
||
pub(crate) fn restore_portable_update_backup(
|
||
descriptor: &PortableUpdateDescriptor,
|
||
) -> Result<(), String> {
|
||
if !descriptor.backup_exe.is_file()
|
||
|| !descriptor.backup_manifest.is_file()
|
||
|| !descriptor.backup_core_version.is_file()
|
||
{
|
||
return Err("应用更新备份不完整,无法回滚".to_string());
|
||
}
|
||
if descriptor.current_exe.exists() {
|
||
fs::remove_file(&descriptor.current_exe)
|
||
.map_err(|error| format!("移除新版应用失败: {error}"))?;
|
||
}
|
||
fs::rename(&descriptor.backup_exe, &descriptor.current_exe)
|
||
.map_err(|error| format!("恢复旧版应用失败: {error}"))?;
|
||
if descriptor.current_manifest.exists() {
|
||
fs::remove_file(&descriptor.current_manifest)
|
||
.map_err(|error| format!("移除新版便携版标识失败: {error}"))?;
|
||
}
|
||
fs::rename(&descriptor.backup_manifest, &descriptor.current_manifest)
|
||
.map_err(|error| format!("恢复旧版便携版标识失败: {error}"))?;
|
||
if descriptor.current_core_version.exists() {
|
||
fs::remove_file(&descriptor.current_core_version)
|
||
.map_err(|error| format!("移除新版内核版本文件失败: {error}"))?;
|
||
}
|
||
fs::rename(
|
||
&descriptor.backup_core_version,
|
||
&descriptor.current_core_version,
|
||
)
|
||
.map_err(|error| format!("恢复旧版内核版本文件失败: {error}"))?;
|
||
if descriptor.install_core_archive && descriptor.target_core_archive.exists() {
|
||
fs::remove_file(&descriptor.target_core_archive)
|
||
.map_err(|error| format!("移除新版内置内核压缩包失败: {error}"))?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(any(windows, target_os = "linux"))]
|
||
pub(crate) fn replace_portable_update_files(
|
||
descriptor: &PortableUpdateDescriptor,
|
||
) -> Result<(), String> {
|
||
let app_dir = descriptor
|
||
.current_exe
|
||
.parent()
|
||
.ok_or_else(|| "应用更新目标路径无效".to_string())?;
|
||
let replacement_exe = app_dir.join(portable_update_replacement_executable_name());
|
||
let replacement_manifest = app_dir.join(".portable-app.json.update-new");
|
||
let replacement_core_version = app_dir.join(".core-version.txt.update-new");
|
||
let replacement_core_archive = app_dir.join(".cpa-core-archive.update-new");
|
||
let _ = fs::remove_file(&replacement_exe);
|
||
let _ = fs::remove_file(&replacement_manifest);
|
||
let _ = fs::remove_file(&replacement_core_version);
|
||
let _ = fs::remove_file(&replacement_core_archive);
|
||
fs::copy(&descriptor.staged_exe, &replacement_exe)
|
||
.map_err(|error| format!("准备新版应用程序失败: {error}"))?;
|
||
if let Err(error) = fs::copy(&descriptor.staged_manifest, &replacement_manifest) {
|
||
let _ = fs::remove_file(&replacement_exe);
|
||
return Err(format!("准备新版便携版标识失败: {error}"));
|
||
}
|
||
if let Err(error) = fs::copy(&descriptor.staged_core_version, &replacement_core_version) {
|
||
let _ = fs::remove_file(&replacement_exe);
|
||
let _ = fs::remove_file(&replacement_manifest);
|
||
return Err(format!("准备新版内核版本文件失败: {error}"));
|
||
}
|
||
if descriptor.install_core_archive {
|
||
if let Err(error) = fs::copy(&descriptor.staged_core_archive, &replacement_core_archive) {
|
||
let _ = fs::remove_file(&replacement_exe);
|
||
let _ = fs::remove_file(&replacement_manifest);
|
||
let _ = fs::remove_file(&replacement_core_version);
|
||
return Err(format!("准备新版内置内核压缩包失败: {error}"));
|
||
}
|
||
}
|
||
|
||
let _ = fs::remove_file(&descriptor.backup_exe);
|
||
let _ = fs::remove_file(&descriptor.backup_manifest);
|
||
let _ = fs::remove_file(&descriptor.backup_core_version);
|
||
if let Err(error) = fs::rename(&descriptor.current_exe, &descriptor.backup_exe) {
|
||
let _ = fs::remove_file(&replacement_exe);
|
||
let _ = fs::remove_file(&replacement_manifest);
|
||
let _ = fs::remove_file(&replacement_core_version);
|
||
let _ = fs::remove_file(&replacement_core_archive);
|
||
return Err(format!("备份旧版应用失败: {error}"));
|
||
}
|
||
if let Err(error) = fs::rename(&descriptor.current_manifest, &descriptor.backup_manifest) {
|
||
let _ = fs::rename(&descriptor.backup_exe, &descriptor.current_exe);
|
||
let _ = fs::remove_file(&replacement_exe);
|
||
let _ = fs::remove_file(&replacement_manifest);
|
||
let _ = fs::remove_file(&replacement_core_version);
|
||
let _ = fs::remove_file(&replacement_core_archive);
|
||
return Err(format!("备份便携版标识失败: {error}"));
|
||
}
|
||
if let Err(error) = fs::rename(
|
||
&descriptor.current_core_version,
|
||
&descriptor.backup_core_version,
|
||
) {
|
||
let _ = fs::rename(&descriptor.backup_manifest, &descriptor.current_manifest);
|
||
let _ = fs::rename(&descriptor.backup_exe, &descriptor.current_exe);
|
||
let _ = fs::remove_file(&replacement_exe);
|
||
let _ = fs::remove_file(&replacement_manifest);
|
||
let _ = fs::remove_file(&replacement_core_version);
|
||
let _ = fs::remove_file(&replacement_core_archive);
|
||
return Err(format!("备份旧版内核版本文件失败: {error}"));
|
||
}
|
||
|
||
let replace_result = (|| -> Result<(), String> {
|
||
fs::rename(&replacement_exe, &descriptor.current_exe)
|
||
.map_err(|error| format!("替换应用程序失败: {error}"))?;
|
||
fs::rename(&replacement_manifest, &descriptor.current_manifest)
|
||
.map_err(|error| format!("替换便携版标识失败: {error}"))?;
|
||
fs::rename(&replacement_core_version, &descriptor.current_core_version)
|
||
.map_err(|error| format!("替换内核版本文件失败: {error}"))?;
|
||
if descriptor.install_core_archive {
|
||
fs::rename(&replacement_core_archive, &descriptor.target_core_archive)
|
||
.map_err(|error| format!("安装新版内置内核压缩包失败: {error}"))?;
|
||
}
|
||
Ok(())
|
||
})();
|
||
if let Err(error) = replace_result {
|
||
let _ = fs::remove_file(&replacement_exe);
|
||
let _ = fs::remove_file(&replacement_manifest);
|
||
let _ = fs::remove_file(&replacement_core_version);
|
||
let _ = fs::remove_file(&replacement_core_archive);
|
||
restore_portable_update_backup(descriptor)
|
||
.map_err(|rollback| format!("{error};{rollback}"))?;
|
||
return Err(error);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(any(windows, target_os = "linux"))]
|
||
pub(crate) fn cleanup_superseded_bundled_core_archives(descriptor: &PortableUpdateDescriptor) {
|
||
let Some(core_dir) = descriptor.target_core_archive.parent() else {
|
||
return;
|
||
};
|
||
let Some(current_name) = descriptor
|
||
.target_core_archive
|
||
.file_name()
|
||
.and_then(|value| value.to_str())
|
||
else {
|
||
return;
|
||
};
|
||
let Ok(entries) = fs::read_dir(core_dir) else {
|
||
return;
|
||
};
|
||
for entry in entries.filter_map(Result::ok) {
|
||
let path = entry.path();
|
||
let name = entry.file_name().to_string_lossy().into_owned();
|
||
if path.is_file()
|
||
&& name != current_name
|
||
&& name.starts_with("CLIProxyAPI_")
|
||
&& name.ends_with(portable_update_core_archive_suffix())
|
||
&& !name.contains("_no-plugin")
|
||
{
|
||
if let Err(error) = fs::remove_file(&path) {
|
||
eprintln!(
|
||
"清理旧版内置内核压缩包失败 {}: {error}",
|
||
path_to_string(&path)
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(any(windows, target_os = "linux"))]
|
||
pub(crate) fn cleanup_portable_update_payload(
|
||
descriptor_path: &Path,
|
||
descriptor: &PortableUpdateDescriptor,
|
||
) {
|
||
let _ = fs::remove_file(descriptor.work_dir.join(if cfg!(windows) {
|
||
"update.zip"
|
||
} else {
|
||
"update.tar.gz"
|
||
}));
|
||
let _ = fs::remove_dir_all(descriptor.work_dir.join("staging"));
|
||
let _ = fs::remove_file(&descriptor.ack_path);
|
||
let _ = fs::remove_file(portable_update_helper_ack_path(&descriptor.work_dir));
|
||
let _ = fs::remove_file(descriptor_path);
|
||
}
|
||
|
||
#[cfg(any(windows, target_os = "linux"))]
|
||
pub(crate) fn run_portable_update_helper(descriptor_path: &Path) -> Result<(), String> {
|
||
let descriptor = serde_json::from_slice::<PortableUpdateDescriptor>(
|
||
&fs::read(descriptor_path).map_err(|error| format!("读取应用更新描述失败: {error}"))?,
|
||
)
|
||
.map_err(|error| format!("解析应用更新描述失败: {error}"))?;
|
||
validate_portable_update_descriptor(descriptor_path, &descriptor)?;
|
||
acknowledge_portable_update_helper_start(&descriptor.work_dir)?;
|
||
if let Err(error) =
|
||
wait_for_portable_parent_exit(descriptor.parent_pid, Duration::from_secs(120))
|
||
{
|
||
cleanup_portable_update_payload(descriptor_path, &descriptor);
|
||
return Err(error);
|
||
}
|
||
|
||
if let Err(error) = replace_portable_update_files(&descriptor) {
|
||
let mut rollback = Command::new(&descriptor.current_exe);
|
||
configure_background_command(&mut rollback);
|
||
let _ = rollback.spawn();
|
||
cleanup_portable_update_payload(descriptor_path, &descriptor);
|
||
return Err(error);
|
||
}
|
||
|
||
let _ = fs::remove_file(&descriptor.ack_path);
|
||
let mut command = Command::new(&descriptor.current_exe);
|
||
command
|
||
.arg("--portable-update-ack")
|
||
.arg(&descriptor.ack_path)
|
||
.stdin(Stdio::null())
|
||
.stdout(Stdio::null())
|
||
.stderr(Stdio::null());
|
||
configure_background_command(&mut command);
|
||
let mut child = match command.spawn() {
|
||
Ok(child) => child,
|
||
Err(error) => {
|
||
restore_portable_update_backup(&descriptor)?;
|
||
let mut rollback = Command::new(&descriptor.current_exe);
|
||
configure_background_command(&mut rollback);
|
||
let _ = rollback.spawn();
|
||
cleanup_portable_update_payload(descriptor_path, &descriptor);
|
||
return Err(format!("启动新版应用失败: {error}"));
|
||
}
|
||
};
|
||
|
||
let deadline = Instant::now() + Duration::from_secs(60);
|
||
let mut confirmed = false;
|
||
while Instant::now() < deadline {
|
||
if descriptor.ack_path.is_file() {
|
||
confirmed = true;
|
||
break;
|
||
}
|
||
if child
|
||
.try_wait()
|
||
.map_err(|error| format!("检查新版应用状态失败: {error}"))?
|
||
.is_some()
|
||
{
|
||
break;
|
||
}
|
||
thread::sleep(Duration::from_millis(200));
|
||
}
|
||
|
||
if confirmed {
|
||
let _ = fs::remove_file(&descriptor.backup_exe);
|
||
let _ = fs::remove_file(&descriptor.backup_manifest);
|
||
let _ = fs::remove_file(&descriptor.backup_core_version);
|
||
cleanup_superseded_bundled_core_archives(&descriptor);
|
||
cleanup_portable_update_payload(descriptor_path, &descriptor);
|
||
return Ok(());
|
||
}
|
||
|
||
let _ = child.kill();
|
||
let _ = child.wait();
|
||
restore_portable_update_backup(&descriptor)?;
|
||
let mut rollback = Command::new(&descriptor.current_exe);
|
||
configure_background_command(&mut rollback);
|
||
let _ = rollback.spawn();
|
||
cleanup_portable_update_payload(descriptor_path, &descriptor);
|
||
Err(format!(
|
||
"新版应用 {} 未能在 60 秒内完成启动确认,已回滚",
|
||
descriptor.target_version
|
||
))
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
fn run_macos_update_command(command: &mut Command, action: &str) -> Result<(), String> {
|
||
let output = command
|
||
.output()
|
||
.map_err(|error| format!("{action}失败: {error}"))?;
|
||
if output.status.success() {
|
||
return Ok(());
|
||
}
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
Err(format!("{action}失败: {}", stderr.trim()))
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
pub(crate) fn stage_macos_application_from_dmg(
|
||
dmg_path: &Path,
|
||
work_dir: &Path,
|
||
staged_app: &Path,
|
||
) -> Result<(), String> {
|
||
let mount_dir = work_dir.join("mount");
|
||
fs::create_dir_all(&mount_dir).map_err(|error| format!("创建 DMG 挂载目录失败: {error}"))?;
|
||
let mut attach = Command::new("hdiutil");
|
||
attach
|
||
.arg("attach")
|
||
.arg(dmg_path)
|
||
.args(["-nobrowse", "-readonly", "-mountpoint"])
|
||
.arg(&mount_dir);
|
||
run_macos_update_command(&mut attach, "挂载应用更新 DMG")?;
|
||
|
||
let source_app = mount_dir.join("EasyCLIProxyAPI.app");
|
||
let stage_result = (|| -> Result<(), String> {
|
||
if !source_app.is_dir() {
|
||
return Err("应用更新 DMG 缺少 EasyCLIProxyAPI.app".to_string());
|
||
}
|
||
let staging_parent = staged_app
|
||
.parent()
|
||
.ok_or_else(|| "应用更新暂存路径无效".to_string())?;
|
||
fs::create_dir_all(staging_parent)
|
||
.map_err(|error| format!("创建应用更新暂存目录失败: {error}"))?;
|
||
let mut ditto = Command::new("ditto");
|
||
ditto.arg(&source_app).arg(staged_app);
|
||
run_macos_update_command(&mut ditto, "暂存新版 macOS 应用")?;
|
||
let mut codesign = Command::new("codesign");
|
||
codesign
|
||
.args(["--verify", "--deep", "--strict"])
|
||
.arg(staged_app);
|
||
run_macos_update_command(&mut codesign, "校验新版 macOS 应用签名")?;
|
||
let mut gatekeeper = Command::new("spctl");
|
||
gatekeeper
|
||
.args(["--assess", "--type", "execute", "--verbose=2"])
|
||
.arg(staged_app);
|
||
run_macos_update_command(&mut gatekeeper, "校验新版 macOS 应用系统信任状态")
|
||
})();
|
||
|
||
let mut detach = Command::new("hdiutil");
|
||
detach.arg("detach").arg(&mount_dir);
|
||
let detach_result = run_macos_update_command(&mut detach, "卸载应用更新 DMG");
|
||
match (stage_result, detach_result) {
|
||
(Err(error), _) => Err(error),
|
||
(Ok(()), Err(error)) => Err(error),
|
||
(Ok(()), Ok(())) => Ok(()),
|
||
}
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
pub(crate) fn validate_macos_staged_application(
|
||
staged_app: &Path,
|
||
pending: &PendingAppUpdate,
|
||
) -> Result<(), String> {
|
||
let manifest_path = staged_app
|
||
.join("Contents")
|
||
.join("Resources")
|
||
.join(PORTABLE_APP_MANIFEST_FILE);
|
||
let contents = fs::read_to_string(&manifest_path)
|
||
.map_err(|error| format!("读取新版 macOS 自动更新标识失败: {error}"))?;
|
||
let manifest = serde_json::from_str::<PortableAppManifest>(&contents)
|
||
.map_err(|error| format!("解析新版 macOS 自动更新标识失败: {error}"))?;
|
||
if manifest.schema_version != 1
|
||
|| manifest.application != "EasyCLIProxyAPI"
|
||
|| manifest.platform != "darwin"
|
||
|| manifest.arch != pending.arch
|
||
|| !manifest.auto_update
|
||
|| normalize_version(&manifest.version) != normalize_version(&pending.version)
|
||
{
|
||
return Err("新版 macOS 应用标识与更新目标不匹配".to_string());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
pub(crate) fn macos_application_bundle_from_executable(
|
||
executable: &Path,
|
||
) -> Result<PathBuf, String> {
|
||
let macos_dir = executable
|
||
.parent()
|
||
.filter(|path| path.file_name().and_then(|name| name.to_str()) == Some("MacOS"))
|
||
.ok_or_else(|| "当前程序不在标准 macOS 应用包中".to_string())?;
|
||
let contents_dir = macos_dir
|
||
.parent()
|
||
.filter(|path| path.file_name().and_then(|name| name.to_str()) == Some("Contents"))
|
||
.ok_or_else(|| "当前程序不在标准 macOS 应用包中".to_string())?;
|
||
let app = contents_dir
|
||
.parent()
|
||
.filter(|path| path.extension().and_then(|value| value.to_str()) == Some("app"))
|
||
.ok_or_else(|| "当前程序不在标准 macOS 应用包中".to_string())?;
|
||
Ok(app.to_path_buf())
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
pub(crate) fn preflight_macos_update_directory(current_app: &Path) -> Result<(), String> {
|
||
if !current_app.is_dir() {
|
||
return Err("当前 macOS 应用包不存在".to_string());
|
||
}
|
||
let app_parent = current_app
|
||
.parent()
|
||
.ok_or_else(|| "macOS 应用包没有父目录".to_string())?;
|
||
let probe = app_parent.join(format!(
|
||
".easycliproxy-update-write-test-{}",
|
||
std::process::id()
|
||
));
|
||
fs::write(&probe, b"update-write-test")
|
||
.map_err(|error| format!("macOS 应用目录不可写,无法自动升级: {error}"))?;
|
||
fs::remove_file(&probe).map_err(|error| format!("清理应用更新写入测试失败: {error}"))
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
pub(crate) fn validate_macos_update_descriptor(
|
||
descriptor_path: &Path,
|
||
descriptor: &MacosUpdateDescriptor,
|
||
) -> Result<(), String> {
|
||
if descriptor.parent_pid == 0
|
||
|| semver::Version::parse(descriptor.target_version.trim().trim_start_matches('v')).is_err()
|
||
|| !descriptor.current_app.is_absolute()
|
||
|| descriptor
|
||
.current_app
|
||
.extension()
|
||
.and_then(|value| value.to_str())
|
||
!= Some("app")
|
||
{
|
||
return Err("macOS 应用更新描述无效".to_string());
|
||
}
|
||
let app_parent = descriptor
|
||
.current_app
|
||
.parent()
|
||
.ok_or_else(|| "macOS 应用更新目标路径无效".to_string())?;
|
||
if descriptor.backup_app != app_parent.join(".EasyCLIProxyAPI.app.update-backup")
|
||
|| descriptor.executable_relative_path.is_absolute()
|
||
|| descriptor
|
||
.executable_relative_path
|
||
.components()
|
||
.any(|component| !matches!(component, Component::Normal(_)))
|
||
|| !descriptor
|
||
.executable_relative_path
|
||
.starts_with(Path::new("Contents").join("MacOS"))
|
||
{
|
||
return Err("macOS 应用更新目标路径无效".to_string());
|
||
}
|
||
let canonical_work_dir = fs::canonicalize(&descriptor.work_dir)
|
||
.map_err(|error| format!("读取应用更新临时目录失败: {error}"))?;
|
||
let canonical_temp_dir = fs::canonicalize(env::temp_dir())
|
||
.map_err(|error| format!("读取系统临时目录失败: {error}"))?;
|
||
if !canonical_work_dir.starts_with(&canonical_temp_dir)
|
||
|| !canonical_work_dir
|
||
.file_name()
|
||
.and_then(|value| value.to_str())
|
||
.is_some_and(|value| value.starts_with("EasyCLIProxyAPI-update-"))
|
||
{
|
||
return Err("应用更新工作目录无效".to_string());
|
||
}
|
||
let canonical_descriptor = fs::canonicalize(descriptor_path)
|
||
.map_err(|error| format!("读取应用更新描述路径失败: {error}"))?;
|
||
let canonical_staged_app = fs::canonicalize(&descriptor.staged_app)
|
||
.map_err(|error| format!("读取新版 macOS 应用路径失败: {error}"))?;
|
||
if canonical_descriptor.parent() != Some(canonical_work_dir.as_path())
|
||
|| canonical_descriptor
|
||
.file_name()
|
||
.and_then(|value| value.to_str())
|
||
!= Some("update-descriptor.json")
|
||
|| descriptor.staged_app
|
||
!= descriptor
|
||
.work_dir
|
||
.join("staging")
|
||
.join("EasyCLIProxyAPI.app")
|
||
|| !canonical_staged_app.starts_with(&canonical_work_dir)
|
||
|| descriptor.ack_path != descriptor.work_dir.join("update-started.ack")
|
||
{
|
||
return Err("macOS 应用更新暂存路径越界".to_string());
|
||
}
|
||
if !descriptor
|
||
.current_app
|
||
.join(&descriptor.executable_relative_path)
|
||
.is_file()
|
||
|| !descriptor
|
||
.staged_app
|
||
.join(&descriptor.executable_relative_path)
|
||
.is_file()
|
||
{
|
||
return Err("macOS 应用更新可执行文件缺失".to_string());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
pub(crate) fn restore_macos_update_backup(
|
||
descriptor: &MacosUpdateDescriptor,
|
||
) -> Result<(), String> {
|
||
if !descriptor.backup_app.is_dir() {
|
||
return Err("macOS 应用更新备份不完整,无法回滚".to_string());
|
||
}
|
||
if descriptor.current_app.exists() {
|
||
fs::remove_dir_all(&descriptor.current_app)
|
||
.map_err(|error| format!("移除新版 macOS 应用失败: {error}"))?;
|
||
}
|
||
fs::rename(&descriptor.backup_app, &descriptor.current_app)
|
||
.map_err(|error| format!("恢复旧版 macOS 应用失败: {error}"))
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
pub(crate) fn replace_macos_application(descriptor: &MacosUpdateDescriptor) -> Result<(), String> {
|
||
let app_parent = descriptor
|
||
.current_app
|
||
.parent()
|
||
.ok_or_else(|| "macOS 应用更新目标路径无效".to_string())?;
|
||
let replacement_app = app_parent.join(".EasyCLIProxyAPI-update-new.app");
|
||
let legacy_replacement_app = app_parent.join(".EasyCLIProxyAPI.app.update-new");
|
||
if legacy_replacement_app.exists() {
|
||
fs::remove_dir_all(&legacy_replacement_app)
|
||
.map_err(|error| format!("清理旧格式的 macOS 更新暂存失败: {error}"))?;
|
||
}
|
||
if replacement_app.exists() {
|
||
fs::remove_dir_all(&replacement_app)
|
||
.map_err(|error| format!("清理旧的 macOS 更新暂存失败: {error}"))?;
|
||
}
|
||
let mut ditto = Command::new("ditto");
|
||
ditto.arg(&descriptor.staged_app).arg(&replacement_app);
|
||
run_macos_update_command(&mut ditto, "准备新版 macOS 应用")?;
|
||
let mut codesign = Command::new("codesign");
|
||
codesign
|
||
.args(["--verify", "--deep", "--strict"])
|
||
.arg(&replacement_app);
|
||
if let Err(error) = run_macos_update_command(&mut codesign, "校验待替换 macOS 应用签名")
|
||
{
|
||
let _ = fs::remove_dir_all(&replacement_app);
|
||
return Err(error);
|
||
}
|
||
let mut gatekeeper = Command::new("spctl");
|
||
gatekeeper
|
||
.args(["--assess", "--type", "execute", "--verbose=2"])
|
||
.arg(&replacement_app);
|
||
if let Err(error) =
|
||
run_macos_update_command(&mut gatekeeper, "校验待替换 macOS 应用系统信任状态")
|
||
{
|
||
let _ = fs::remove_dir_all(&replacement_app);
|
||
return Err(error);
|
||
}
|
||
if descriptor.backup_app.exists() {
|
||
fs::remove_dir_all(&descriptor.backup_app)
|
||
.map_err(|error| format!("清理旧的 macOS 应用备份失败: {error}"))?;
|
||
}
|
||
fs::rename(&descriptor.current_app, &descriptor.backup_app)
|
||
.map_err(|error| format!("备份旧版 macOS 应用失败: {error}"))?;
|
||
if let Err(error) = fs::rename(&replacement_app, &descriptor.current_app) {
|
||
let _ = fs::rename(&descriptor.backup_app, &descriptor.current_app);
|
||
let _ = fs::remove_dir_all(&replacement_app);
|
||
return Err(format!("替换 macOS 应用失败: {error}"));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
fn cleanup_macos_update_payload(descriptor_path: &Path, descriptor: &MacosUpdateDescriptor) {
|
||
let _ = fs::remove_file(descriptor.work_dir.join("update.dmg"));
|
||
let _ = fs::remove_dir_all(descriptor.work_dir.join("staging"));
|
||
let _ = fs::remove_dir_all(descriptor.work_dir.join("mount"));
|
||
let _ = fs::remove_file(&descriptor.ack_path);
|
||
let _ = fs::remove_file(portable_update_helper_ack_path(&descriptor.work_dir));
|
||
let _ = fs::remove_file(descriptor_path);
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
pub(crate) fn run_portable_update_helper(descriptor_path: &Path) -> Result<(), String> {
|
||
let descriptor = serde_json::from_slice::<MacosUpdateDescriptor>(
|
||
&fs::read(descriptor_path).map_err(|error| format!("读取应用更新描述失败: {error}"))?,
|
||
)
|
||
.map_err(|error| format!("解析应用更新描述失败: {error}"))?;
|
||
validate_macos_update_descriptor(descriptor_path, &descriptor)?;
|
||
acknowledge_portable_update_helper_start(&descriptor.work_dir)?;
|
||
if let Err(error) =
|
||
wait_for_portable_parent_exit(descriptor.parent_pid, Duration::from_secs(120))
|
||
{
|
||
cleanup_macos_update_payload(descriptor_path, &descriptor);
|
||
return Err(error);
|
||
}
|
||
if let Err(error) = replace_macos_application(&descriptor) {
|
||
let rollback_exe = descriptor
|
||
.current_app
|
||
.join(&descriptor.executable_relative_path);
|
||
let _ = Command::new(rollback_exe).spawn();
|
||
cleanup_macos_update_payload(descriptor_path, &descriptor);
|
||
return Err(error);
|
||
}
|
||
|
||
let current_exe = descriptor
|
||
.current_app
|
||
.join(&descriptor.executable_relative_path);
|
||
let _ = fs::remove_file(&descriptor.ack_path);
|
||
let mut command = Command::new(¤t_exe);
|
||
command
|
||
.arg("--portable-update-ack")
|
||
.arg(&descriptor.ack_path)
|
||
.stdin(Stdio::null())
|
||
.stdout(Stdio::null())
|
||
.stderr(Stdio::null());
|
||
let mut child = match command.spawn() {
|
||
Ok(child) => child,
|
||
Err(error) => {
|
||
restore_macos_update_backup(&descriptor)?;
|
||
let rollback_exe = descriptor
|
||
.current_app
|
||
.join(&descriptor.executable_relative_path);
|
||
let _ = Command::new(rollback_exe).spawn();
|
||
cleanup_macos_update_payload(descriptor_path, &descriptor);
|
||
return Err(format!("启动新版 macOS 应用失败: {error}"));
|
||
}
|
||
};
|
||
let deadline = Instant::now() + Duration::from_secs(60);
|
||
while Instant::now() < deadline {
|
||
if descriptor.ack_path.is_file() {
|
||
let _ = fs::remove_dir_all(&descriptor.backup_app);
|
||
cleanup_macos_update_payload(descriptor_path, &descriptor);
|
||
return Ok(());
|
||
}
|
||
if child
|
||
.try_wait()
|
||
.map_err(|error| format!("检查新版 macOS 应用状态失败: {error}"))?
|
||
.is_some()
|
||
{
|
||
break;
|
||
}
|
||
thread::sleep(Duration::from_millis(200));
|
||
}
|
||
let _ = child.kill();
|
||
let _ = child.wait();
|
||
restore_macos_update_backup(&descriptor)?;
|
||
let rollback_exe = descriptor
|
||
.current_app
|
||
.join(&descriptor.executable_relative_path);
|
||
let _ = Command::new(rollback_exe).spawn();
|
||
cleanup_macos_update_payload(descriptor_path, &descriptor);
|
||
Err(format!(
|
||
"新版应用 {} 未能在 60 秒内完成启动确认,已回滚",
|
||
descriptor.target_version
|
||
))
|
||
}
|
||
|
||
pub(crate) fn portable_update_ack_argument() -> Option<PathBuf> {
|
||
let mut args = env::args_os();
|
||
while let Some(argument) = args.next() {
|
||
if argument == "--portable-update-ack" {
|
||
let path = PathBuf::from(args.next()?);
|
||
let parent = path.parent()?;
|
||
let valid_name =
|
||
path.file_name().and_then(|value| value.to_str()) == Some("update-started.ack");
|
||
let valid_parent = parent.starts_with(env::temp_dir())
|
||
&& parent
|
||
.file_name()
|
||
.and_then(|value| value.to_str())
|
||
.is_some_and(|value| value.starts_with("EasyCLIProxyAPI-update-"));
|
||
return (valid_name && valid_parent).then_some(path);
|
||
}
|
||
}
|
||
None
|
||
}
|