feat:移除了弹窗,服务器添加sls
This commit is contained in:
commit
6a295f9a7a
4082 files changed
+1322534
No files matched your search
@@ -0,0 +1,574 @@
|
||||
use crate::api::Result;
|
||||
use futures::StreamExt;
|
||||
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 UPDATE_SERVER_LATEST_URL: &str = "https://update.axlmc.org/latest";
|
||||
const UPDATE_SERVER_API: &str = "https://update.axlmc.org/api/versions";
|
||||
const UPDATE_SERVER_BASE: &str = "https://update.axlmc.org/";
|
||||
|
||||
// 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);
|
||||
|
||||
// ── 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,
|
||||
relative_path: 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(UPDATE_SERVER_API)
|
||||
.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 =
|
||||
Url::parse(&format!("{UPDATE_SERVER_BASE}{}", artifact.relative_path))
|
||||
.map_err(|error| {
|
||||
theseus::Error::from(theseus::ErrorKind::OtherError(
|
||||
error.to_string(),
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(AptDebAsset {
|
||||
url,
|
||||
sha256,
|
||||
size: artifact.size,
|
||||
})
|
||||
}
|
||||
|
||||
// ── 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(UPDATE_SERVER_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)?;
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user