Files
Starlight_Lancher/apps/app/src/updater_impl.rs
T
AxTps f776bf8995
Sync source to CNB / Sync Git ref (push) Has been cancelled
fix: 修复跨 crate 可见性与 Version 导入,补充 semver 依赖
- updater_impl.rs: Version 改从 semver 导入(tauri_plugin_updater::Version 是私有)

- files.rs: 移除未使用的 list 导入

- state/mod.rs: update_http_client_for_settings 由 pub(crate) 改 pub(apps/app 跨 crate 调用)

- workspace 与 apps/app 补充 semver 依赖,同步 Cargo.lock
2026-10-03 11:20:33 +08:00

679 lines
22 KiB
Rust

use crate::api::Result;
use futures::StreamExt;
use semver::Version;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::sync::{Arc, Mutex};
use tauri::http::HeaderValue;
use tauri::http::header::ACCEPT;
use tauri::{Manager, ResourceId, Runtime, Webview};
use tauri_plugin_http::reqwest;
use tauri_plugin_http::reqwest::ClientBuilder;
use tauri_plugin_updater::{Error, Update, UpdaterExt};
use theseus::{
LoadingBarType, emit_loading, init_loading, launcher_user_agent,
};
use tokio::time::Instant;
use url::Url;
const STARLIGHT_UPDATE_LATEST_URL: &str =
"https://skin.starlight.cool/starlight/launcher/latest";
const STARLIGHT_UPDATE_VERSIONS_URL: &str =
"https://skin.starlight.cool/starlight/launcher/versions";
const STARLIGHT_UPDATE_BASE_URL: &str = "https://skin.starlight.cool/";
// The updater plugin builds `Update` with no request timeout, so a stalled
// connection would hang the download forever. Bound the whole download.
const UPDATE_DOWNLOAD_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(15 * 60);
// semver compares prerelease identifiers lexically, so "beta10" < "beta9"
// (because the character '1' sorts before '9'). Our release names use a
// "betaN" scheme where N is a decimal counter, so a strict lexical compare
// would wrongly report an older beta as the latest. Use a comparator that
// treats the trailing digits of a prerelease identifier numerically.
fn is_remote_newer(current: &Version, remote: &Version) -> bool {
use std::cmp::Ordering;
let core = (remote.major, remote.minor, remote.patch).cmp(&(
current.major,
current.minor,
current.patch,
));
if core != Ordering::Equal {
return core == Ordering::Greater;
}
// Same x.y.z. A final release outranks any prerelease of that version.
match (current.pre.is_empty(), remote.pre.is_empty()) {
(true, true) => false,
(true, false) => false,
(false, true) => true,
(false, false) => {
compare_prerelease(remote.pre.as_str(), current.pre.as_str())
== Ordering::Greater
}
}
}
/// Split a prerelease identifier into its non-digit prefix and an optional
/// trailing decimal number (so "beta10" -> ("beta", Some(10))).
fn split_prerelease_identifier(segment: &str) -> (&str, Option<u64>) {
let digit_start = segment
.char_indices()
.find(|(_, c)| c.is_ascii_digit())
.map(|(i, _)| i);
match digit_start {
Some(i) => (&segment[..i], segment[i..].parse::<u64>().ok()),
None => (segment, None),
}
}
/// Compare two prerelease strings, treating a trailing run of ASCII digits
/// as a decimal number (so "beta10" > "beta9"). All other segments fall back
/// to a plain lexical comparison.
fn compare_prerelease(left: &str, right: &str) -> std::cmp::Ordering {
use std::cmp::Ordering;
let left_segments: Vec<&str> = left.split('.').collect();
let right_segments: Vec<&str> = right.split('.').collect();
for (l, r) in left_segments.iter().zip(right_segments.iter()) {
let (l_prefix, l_num) = split_prerelease_identifier(l);
let (r_prefix, r_num) = split_prerelease_identifier(r);
match (l_num, r_num) {
(Some(ln), Some(rn)) if l_prefix == r_prefix => match ln.cmp(&rn) {
Ordering::Equal => continue,
other => return other,
},
_ => {
let prefix_cmp = l_prefix.cmp(r_prefix);
if prefix_cmp != Ordering::Equal {
return prefix_cmp;
}
let num_cmp = l_num.cmp(&r_num);
if num_cmp != Ordering::Equal {
return num_cmp;
}
}
}
}
left_segments.len().cmp(&right_segments.len())
}
// ── Shared types ─────────────────────────────────────────────────
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateMetadata {
rid: ResourceId,
current_version: String,
version: String,
date: Option<String>,
body: Option<String>,
published_at: Option<String>,
force_update: bool,
raw_json: serde_json::Value,
}
#[derive(Default)]
pub struct PendingUpdateData(pub Mutex<Option<(Arc<Update>, Vec<u8>)>>);
// ── Update Server API types ─────────────────────────────────────
#[derive(Deserialize)]
struct VersionsResponse {
versions: Vec<VersionEntry>,
}
#[derive(Deserialize)]
struct VersionEntry {
version: String,
artifacts: Vec<ArtifactEntry>,
}
#[derive(Deserialize)]
struct ArtifactEntry {
kind: String,
#[serde(default)]
variant: Option<String>,
platform: String,
architecture: String,
#[serde(default)]
relative_path: Option<String>,
#[serde(default)]
download_url: Option<String>,
#[serde(default)]
sha256: Option<String>,
#[serde(default)]
size: u64,
}
/// The .deb asset for an apt-managed Linux update, from the Update Server
/// catalog (`/api/versions`). The deb has no minisign signature, so its
/// integrity is verified with the catalog's sha256 and size instead.
struct AptDebAsset {
url: Url,
sha256: String,
size: u64,
}
fn apt_deb_arch() -> Result<&'static str> {
match std::env::consts::ARCH {
"x86_64" => Ok("amd64"),
"aarch64" => Ok("arm64"),
arch => Err(theseus::Error::from(theseus::ErrorKind::OtherError(
format!("Unsupported architecture for apt updates: {arch}"),
))
.into()),
}
}
async fn fetch_apt_deb_asset(version: &str) -> Result<AptDebAsset> {
let response = ClientBuilder::new()
.user_agent(launcher_user_agent())
.timeout(UPDATE_DOWNLOAD_TIMEOUT)
.build()?
.get(STARLIGHT_UPDATE_VERSIONS_URL)
.send()
.await?;
if !response.status().is_success() {
return Err(Error::Network(format!(
"Failed to fetch update catalog: {}",
response.status()
))
.into());
}
let catalog: VersionsResponse = response.json().await?;
let release = catalog
.versions
.iter()
.find(|entry| entry.version == version)
.ok_or_else(|| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Update catalog has no entry for version {version}"
)))
})?;
let arch = std::env::consts::ARCH;
let artifact = release
.artifacts
.iter()
.find(|entry| {
entry.kind == "installer"
&& entry.variant.as_deref() == Some("deb")
&& entry.platform == "linux"
&& entry.architecture == arch
})
.ok_or_else(|| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Update catalog has no deb artifact for {version} on {arch}"
)))
})?;
let sha256 = artifact.sha256.clone().ok_or_else(|| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Update catalog has no sha256 for the deb artifact of {version}"
)))
})?;
let url = artifact_download_url(artifact)?;
Ok(AptDebAsset {
url,
sha256,
size: artifact.size,
})
}
fn artifact_download_url(artifact: &ArtifactEntry) -> Result<Url> {
if let Some(download_url) = artifact.download_url.as_deref() {
return Url::parse(download_url).map_err(|error| {
theseus::Error::from(theseus::ErrorKind::OtherError(
error.to_string(),
))
.into()
});
}
let relative_path = artifact.relative_path.as_deref().ok_or_else(|| {
theseus::Error::from(theseus::ErrorKind::OtherError(
"Update catalog artifact has no download URL".to_string(),
))
})?;
Url::parse(STARLIGHT_UPDATE_BASE_URL)
.and_then(|base| base.join(relative_path))
.map_err(|error| {
theseus::Error::from(theseus::ErrorKind::OtherError(
error.to_string(),
))
.into()
})
}
// ── Updater plugin helpers ───────────────────────────────────────
fn update_channel(channel: &str) -> Result<&str> {
match channel {
"release" | "beta" => Ok(channel),
_ => Err(theseus::Error::from(theseus::ErrorKind::OtherError(
format!("Unknown update channel: {channel}"),
))
.into()),
}
}
fn update_platform() -> Result<&'static str> {
match (std::env::consts::OS, std::env::consts::ARCH) {
("windows", "x86_64") => Ok("windows-x86_64"),
("linux", "x86_64") => Ok("linux-x86_64"),
("linux", "aarch64") => Ok("linux-aarch64"),
("macos", "x86_64") => Ok("darwin-x86_64"),
("macos", "aarch64") => Ok("darwin-aarch64"),
(os, arch) => {
Err(theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Unsupported updater platform: {os}-{arch}"
)))
.into())
}
}
}
fn update_endpoint() -> Result<Url> {
Url::parse(STARLIGHT_UPDATE_LATEST_URL).map_err(|error| {
theseus::Error::from(theseus::ErrorKind::OtherError(error.to_string()))
.into()
})
}
/// Build the platform-updater with the given endpoints and run a check.
async fn check_with_endpoints<R: Runtime>(
webview: &Webview<R>,
channel: &str,
) -> Result<Option<Update>> {
let channel = update_channel(channel)?;
let platform = update_platform()?;
let current_version =
webview.app_handle().package_info().version.to_string();
let mut updater = webview
.updater_builder()
.endpoints(vec![update_endpoint()?])?
.header("Accept", "application/json")?
.header("X-Axolotl-Channel", channel)?
.header("X-Axolotl-Platform", platform)?
.header("X-Axolotl-Version", current_version)?
.version_comparator(|current, release| {
is_remote_newer(&current, &release.version)
});
#[cfg(target_os = "windows")]
{
let install_dir = std::env::current_exe()
.map_err(|error| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Failed to resolve current executable: {error}"
)))
})?
.parent()
.ok_or_else(|| {
theseus::Error::from(theseus::ErrorKind::OtherError(
"Current executable has no parent directory".to_string(),
))
})?
.to_path_buf();
tracing::debug!(
install_dir = %install_dir.display(),
"Using current executable directory for Windows app updates"
);
updater = updater.installer_arg(format!(
"/INSTALL_DIR=\"{}\"",
install_dir.display()
));
}
let updater = updater.build()?;
updater.check().await.map_err(Into::into)
}
/// Check the updater manifest through the configured Update Server endpoint.
async fn check_with_updater<R: Runtime>(
webview: &Webview<R>,
channel: &str,
) -> Result<Option<UpdateMetadata>> {
let Some(mut update) = check_with_endpoints(webview, channel).await? else {
return Ok(None);
};
update.timeout = Some(UPDATE_DOWNLOAD_TIMEOUT);
// On Debian and derivatives the plugin's minisign signature check cannot
// validate the unsigned .deb, so point the download at the deb from the
// Update Server catalog instead of the AppImage artifact. Its integrity
// is verified with the catalog's sha256/size during the download.
if is_apt_linux() {
update.download_url = fetch_apt_deb_asset(&update.version).await?.url;
}
let published_at = update
.raw_json
.get("published_at")
.and_then(serde_json::Value::as_str)
.map(str::to_owned);
let force_update = update
.raw_json
.get("force_update")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let metadata = UpdateMetadata {
rid: webview.resources_table().add(update.clone()),
current_version: update.current_version.clone(),
version: update.version.clone(),
date: None,
body: update.body.clone(),
published_at,
force_update,
raw_json: update.raw_json,
};
Ok(Some(metadata))
}
// ── Tauri commands ───────────────────────────────────────────────
#[tauri::command]
pub async fn check_app_update<R: Runtime>(
webview: Webview<R>,
channel: String,
) -> Result<Option<UpdateMetadata>> {
check_with_updater(&webview, &channel).await
}
// Reimplementation of Update::download mostly, minus the actual download part
#[tauri::command]
pub async fn get_update_size<R: Runtime>(
webview: Webview<R>,
rid: ResourceId,
) -> Result<Option<u64>> {
let update = webview.resources_table().get::<Update>(rid)?;
let mut headers = update.headers.clone();
if !headers.contains_key(ACCEPT) {
headers.insert(
ACCEPT,
HeaderValue::from_static("application/octet-stream"),
);
}
let mut request = ClientBuilder::new().user_agent(launcher_user_agent());
if let Some(timeout) = update.timeout {
request = request.timeout(timeout);
}
if let Some(ref proxy) = update.proxy {
let proxy = reqwest::Proxy::all(proxy.as_str())?;
request = request.proxy(proxy);
}
let response = request
.build()?
.head(update.download_url.clone())
.headers(headers)
.send()
.await?;
if !response.status().is_success() {
return Err(Error::Network(format!(
"Download request failed with status: {}",
response.status()
))
.into());
}
let content_length = response
.headers()
.get("Content-Length")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse().ok());
Ok(content_length)
}
#[tauri::command]
pub async fn enqueue_update_for_installation<R: Runtime>(
webview: Webview<R>,
rid: ResourceId,
) -> Result<()> {
let pending_data = webview.state::<PendingUpdateData>().inner();
let update = webview.resources_table().get::<Update>(rid)?;
let progress = init_loading(
LoadingBarType::LauncherUpdate {
version: update.version.clone(),
current_version: update.current_version.clone(),
},
1.0,
"Downloading update...",
)
.await?;
let download_start = Instant::now();
let update_data = if is_apt_linux() {
// The .deb carries no minisign signature, so the plugin's signed
// download cannot be used. Fetch the catalog entry and verify the
// downloaded bytes against its sha256 and size instead.
let asset = fetch_apt_deb_asset(&update.version).await?;
let mut headers = update.headers.clone();
if !headers.contains_key(ACCEPT) {
headers.insert(
ACCEPT,
HeaderValue::from_static("application/octet-stream"),
);
}
let mut request =
ClientBuilder::new().user_agent(launcher_user_agent());
if let Some(timeout) = update.timeout {
request = request.timeout(timeout);
}
if let Some(ref proxy) = update.proxy {
let proxy = reqwest::Proxy::all(proxy.as_str())?;
request = request.proxy(proxy);
}
let response = request
.build()?
.get(update.download_url.clone())
.headers(headers)
.send()
.await?;
if !response.status().is_success() {
return Err(Error::Network(format!(
"Download request failed with status: {}",
response.status()
))
.into());
}
let total_size = response.content_length().unwrap_or(asset.size);
let mut buffer = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
buffer.extend_from_slice(&chunk);
if total_size > 0 {
if let Err(e) = emit_loading(
&progress,
buffer.len() as f64 / total_size as f64,
None,
) {
tracing::error!(
"Failed to update download progress bar: {e}"
);
}
}
}
if buffer.len() as u64 != asset.size {
return Err(theseus::Error::from(theseus::ErrorKind::OtherError(
format!(
"Downloaded deb size mismatch: expected {}, got {}",
asset.size,
buffer.len()
),
))
.into());
}
let digest = Sha256::digest(&buffer);
let digest_hex = digest
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
if digest_hex != asset.sha256 {
return Err(theseus::Error::from(theseus::ErrorKind::OtherError(
"Downloaded deb sha256 mismatch".to_string(),
))
.into());
}
buffer
} else {
update
.download(
|chunk_size, total_size| {
let Some(total_size) = total_size else {
return;
};
if let Err(e) = emit_loading(
&progress,
chunk_size as f64 / total_size as f64,
None,
) {
tracing::error!(
"Failed to update download progress bar: {e}"
);
}
},
|| {},
)
.await?
};
let download_duration = download_start.elapsed();
tracing::info!("Downloaded update in {download_duration:?}");
pending_data
.0
.lock()
.unwrap()
.replace((update, update_data));
Ok(())
}
#[tauri::command]
pub fn remove_enqueued_update<R: Runtime>(webview: Webview<R>) {
let pending_data = webview.state::<PendingUpdateData>().inner();
pending_data.0.lock().unwrap().take();
}
// ── Debian / derivatives apt update ─────────────────────────────
/// Whether this Linux system updates Axolotl through apt (Debian and its
/// derivatives) and has `pkexec` available for a single privileged prompt.
#[tauri::command]
pub fn is_apt_linux() -> bool {
#[cfg(target_os = "linux")]
{
let debian_like = std::path::Path::new("/etc/debian_version").exists()
|| std::path::Path::new("/etc/apt").is_dir()
|| std::path::Path::new("/usr/bin/apt-get").exists();
let has_pkexec = ["/usr/bin/pkexec", "/bin/pkexec"]
.iter()
.any(|path| std::path::Path::new(path).exists());
debian_like && has_pkexec
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
/// Install the downloaded .deb on Debian and its derivatives, prompting for
/// root once via `pkexec`. The package is installed from the absolute path
/// of a temporary file, which is removed afterwards.
pub async fn install_apt_package(version: &str, data: &[u8]) -> Result<()> {
if !is_apt_linux() {
return Err(theseus::Error::from(theseus::ErrorKind::OtherError(
"apt updates are only supported on Debian-based Linux systems with pkexec"
.to_string(),
))
.into());
}
let arch = apt_deb_arch()?;
let deb_path = std::env::temp_dir()
.join(format!("Axolotl.Launcher_{version}_{arch}.deb"));
std::fs::write(&deb_path, data).map_err(|io| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Failed to write the downloaded deb: {io}"
)))
})?;
let _deb_cleanup = TempDebFile(deb_path.clone());
let install_path = deb_path.clone();
let output = tokio::task::spawn_blocking(move || {
std::process::Command::new("pkexec")
.arg("apt")
.arg("install")
.arg("-y")
.arg(&install_path)
.output()
})
.await
.map_err(|join| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Failed to run the apt updater: {join}"
)))
})?
.map_err(|io| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Failed to start pkexec: {io}"
)))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(theseus::Error::from(theseus::ErrorKind::OtherError(
format!("apt install failed: {}", stderr.trim()),
))
.into());
}
Ok(())
}
/// Removes a temporary Debian package when installation finishes or fails.
///
/// The installer awaits a blocking task and has several fallible operations
/// after creating the file. Keeping cleanup in `Drop` makes every return path
/// (including task and process-launch errors) remove the package.
struct TempDebFile(std::path::PathBuf);
impl Drop for TempDebFile {
fn drop(&mut self) {
if let Err(error) = std::fs::remove_file(&self.0) {
if error.kind() != std::io::ErrorKind::NotFound {
tracing::warn!(
path = %self.0.display(),
error = %error,
"Failed to remove temporary deb file"
);
}
}
}
}