#[cfg(feature = "updater")]
#[cfg(not(target_os = "macos"))]
use std::ffi::OsStr;
#[cfg(feature = "updater")]
use std::io::Seek;
use std::{
collections::HashMap,
env, fmt,
io::{Cursor, Read},
path::{Path, PathBuf},
str::{from_utf8, FromStr},
time::Duration
};
#[cfg(target_os = "windows")]
use std::{
fs::read_dir,
process::{exit, Command}
};
use base64::decode;
use futures::StreamExt;
use http::{
header::{HeaderName, HeaderValue},
HeaderMap, StatusCode
};
use millennium_utils::{platform::current_exe, Env};
use minisign_verify::{PublicKey, Signature};
use semver::Version;
use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize};
use time::OffsetDateTime;
use url::Url;
use super::error::{Error, Result};
#[cfg(all(feature = "updater", not(target_os = "windows")))]
use crate::api::file::Compression;
#[cfg(feature = "updater")]
use crate::api::file::{ArchiveFormat, Extract, Move};
use crate::{
api::http::{ClientBuilder, HttpRequestBuilder},
AppHandle, Manager, Runtime
};
#[derive(Debug, Deserialize, Serialize)]
#[serde(untagged)]
pub enum RemoteReleaseInner {
Dynamic(ReleaseManifestPlatform),
Static { platforms: HashMap<String, ReleaseManifestPlatform> }
}
#[derive(Debug)]
pub struct RemoteRelease {
version: Version,
notes: Option<String>,
pub_date: Option<OffsetDateTime>,
data: RemoteReleaseInner
}
impl<'de> Deserialize<'de> for RemoteRelease {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>
{
#[derive(Deserialize)]
struct InnerRemoteRelease {
#[serde(alias = "name", deserialize_with = "parse_version")]
version: Version,
notes: Option<String>,
pub_date: Option<String>,
platforms: Option<HashMap<String, ReleaseManifestPlatform>>,
url: Option<Url>,
signature: Option<String>,
#[cfg(target_os = "windows")]
#[serde(default)]
with_elevated_task: bool
}
let release = InnerRemoteRelease::deserialize(deserializer)?;
let pub_date = if let Some(date) = release.pub_date {
Some(
OffsetDateTime::parse(&date, &time::format_description::well_known::Rfc3339)
.map_err(|e| DeError::custom(format!("invalid value for `pub_date`: {}", e)))?
)
} else {
None
};
Ok(RemoteRelease {
version: release.version,
notes: release.notes,
pub_date,
data: if let Some(platforms) = release.platforms {
RemoteReleaseInner::Static { platforms }
} else {
RemoteReleaseInner::Dynamic(ReleaseManifestPlatform {
url: release
.url
.ok_or_else(|| DeError::custom("the `url` field was not set on the updater response"))?,
signature: release
.signature
.ok_or_else(|| DeError::custom("the `signature` field was not set on the updater response"))?,
#[cfg(target_os = "windows")]
with_elevated_task: release.with_elevated_task
})
}
})
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ReleaseManifestPlatform {
pub url: Url,
pub signature: String,
#[cfg(target_os = "windows")]
#[serde(default)]
pub with_elevated_task: bool
}
fn parse_version<'de, D>(deserializer: D) -> std::result::Result<Version, D::Error>
where
D: serde::Deserializer<'de>
{
let str = String::deserialize(deserializer)?;
Version::from_str(str.trim_start_matches('v')).map_err(serde::de::Error::custom)
}
impl RemoteRelease {
pub fn version(&self) -> &Version {
&self.version
}
pub fn notes(&self) -> Option<&String> {
self.notes.as_ref()
}
pub fn pub_date(&self) -> Option<&OffsetDateTime> {
self.pub_date.as_ref()
}
pub fn download_url(&self, target: &str) -> Result<&Url> {
match self.data {
RemoteReleaseInner::Dynamic(ref platform) => Ok(&platform.url),
RemoteReleaseInner::Static { ref platforms } => platforms
.get(target)
.map_or(Err(Error::TargetNotFound(target.to_string())), |p| Ok(&p.url))
}
}
pub fn signature(&self, target: &str) -> Result<&String> {
match self.data {
RemoteReleaseInner::Dynamic(ref platform) => Ok(&platform.signature),
RemoteReleaseInner::Static { ref platforms } => platforms
.get(target)
.map_or(Err(Error::TargetNotFound(target.to_string())), |platform| Ok(&platform.signature))
}
}
#[cfg(target_os = "windows")]
pub fn with_elevated_task(&self, target: &str) -> Result<bool> {
match self.data {
RemoteReleaseInner::Dynamic(ref platform) => Ok(platform.with_elevated_task),
RemoteReleaseInner::Static { ref platforms } => platforms
.get(target)
.map_or(Err(Error::TargetNotFound(target.to_string())), |platform| Ok(platform.with_elevated_task))
}
}
}
pub struct UpdateBuilder<R: Runtime> {
pub app: AppHandle<R>,
pub current_version: Version,
pub urls: Vec<String>,
pub target: Option<String>,
pub executable_path: Option<PathBuf>,
should_install: Option<Box<dyn FnOnce(&Version, &RemoteRelease) -> bool + Send>>,
timeout: Option<Duration>,
headers: HeaderMap
}
impl<R: Runtime> fmt::Debug for UpdateBuilder<R> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("UpdateBuilder")
.field("app", &self.app)
.field("current_version", &self.current_version)
.field("urls", &self.urls)
.field("target", &self.target)
.field("executable_path", &self.executable_path)
.field("timeout", &self.timeout)
.field("headers", &self.headers)
.finish()
}
}
impl<R: Runtime> UpdateBuilder<R> {
pub fn new(app: AppHandle<R>) -> Self {
UpdateBuilder {
app,
urls: Vec::new(),
target: None,
executable_path: None,
current_version: env!("CARGO_PKG_VERSION").parse().unwrap(),
should_install: None,
timeout: None,
headers: Default::default()
}
}
#[allow(dead_code)]
pub fn url(mut self, url: String) -> Self {
self.urls
.push(percent_encoding::percent_decode(url.as_bytes()).decode_utf8_lossy().to_string());
self
}
pub fn urls(mut self, urls: &[String]) -> Self {
let mut formatted_vec: Vec<String> = Vec::new();
for url in urls {
formatted_vec.push(percent_encoding::percent_decode(url.as_bytes()).decode_utf8_lossy().to_string());
}
self.urls = formatted_vec;
self
}
pub fn current_version(mut self, ver: Version) -> Self {
self.current_version = ver;
self
}
pub fn target(mut self, target: impl Into<String>) -> Self {
self.target.replace(target.into());
self
}
#[allow(dead_code)]
pub fn executable_path<A: AsRef<Path>>(mut self, executable_path: A) -> Self {
self.executable_path = Some(PathBuf::from(executable_path.as_ref()));
self
}
pub fn should_install<F: FnOnce(&Version, &RemoteRelease) -> bool + Send + 'static>(mut self, f: F) -> Self {
self.should_install.replace(Box::new(f));
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout.replace(timeout);
self
}
pub fn header<K, V>(mut self, key: K, value: V) -> Result<Self>
where
HeaderName: TryFrom<K>,
<HeaderName as TryFrom<K>>::Error: Into<http::Error>,
HeaderValue: TryFrom<V>,
<HeaderValue as TryFrom<V>>::Error: Into<http::Error>
{
let key: std::result::Result<HeaderName, http::Error> = key.try_into().map_err(Into::into);
let value: std::result::Result<HeaderValue, http::Error> = value.try_into().map_err(Into::into);
self.headers.insert(key?, value?);
Ok(self)
}
pub async fn build(mut self) -> Result<Update<R>> {
let mut remote_release: Option<RemoteRelease> = None;
if self.urls.is_empty() {
return Err(Error::Builder("Unable to check update, `url` is required.".into()));
};
let executable_path = self.executable_path.unwrap_or(current_exe()?);
let arch = get_updater_arch().ok_or(Error::UnsupportedArch)?;
let (target, json_target) = if let Some(target) = self.target {
(target.clone(), target)
} else {
let target = get_updater_target().ok_or(Error::UnsupportedOs)?;
(target.to_string(), format!("{}-{}", target, arch))
};
let extract_path = extract_path_from_executable(&self.app.state::<Env>(), &executable_path);
#[cfg(target_os = "linux")]
{
if env::var_os("SSL_CERT_FILE").is_none() {
env::set_var("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt");
}
if env::var_os("SSL_CERT_DIR").is_none() {
env::set_var("SSL_CERT_DIR", "/etc/ssl/certs");
}
}
let mut headers = self.headers;
headers.insert("Accept", HeaderValue::from_str("application/json").unwrap());
let mut last_error: Option<Error> = None;
for url in &self.urls {
let fixed_link = url
.replace("{{current_version}}", &self.current_version.to_string())
.replace("{{target}}", &target)
.replace("{{arch}}", arch);
let mut request = HttpRequestBuilder::new("GET", &fixed_link)?.headers(headers.clone());
if let Some(timeout) = self.timeout {
request = request.timeout(timeout);
}
let resp = ClientBuilder::new().build()?.send(request).await;
if let Ok(res) = resp {
let res = res.read().await?;
if StatusCode::from_u16(res.status).map_err(|e| Error::Builder(e.to_string()))?.is_success() {
if StatusCode::NO_CONTENT.as_u16() == res.status {
return Err(Error::UpToDate);
};
let built_release = serde_json::from_value(res.data).map_err(Into::into);
match built_release {
Ok(release) => {
last_error = None;
remote_release = Some(release);
break;
}
Err(err) => last_error = Some(err)
}
} }
}
if let Some(error) = last_error {
return Err(error);
}
let final_release = remote_release.ok_or(Error::ReleaseNotFound)?;
let should_update = if let Some(comparator) = self.should_install.take() {
comparator(&self.current_version, &final_release)
} else {
final_release.version() > &self.current_version
};
headers.remove("Accept");
Ok(Update {
app: self.app,
target,
extract_path,
should_update,
version: final_release.version().to_string(),
date: final_release.pub_date().cloned(),
current_version: self.current_version,
download_url: final_release.download_url(&json_target)?.to_owned(),
body: final_release.notes().cloned(),
signature: final_release.signature(&json_target)?.to_owned(),
#[cfg(target_os = "windows")]
with_elevated_task: final_release.with_elevated_task(&json_target)?,
timeout: self.timeout,
headers
})
}
}
pub fn builder<R: Runtime>(app: AppHandle<R>) -> UpdateBuilder<R> {
UpdateBuilder::new(app)
}
#[derive(Debug)]
pub struct Update<R: Runtime> {
pub app: AppHandle<R>,
pub body: Option<String>,
pub should_update: bool,
pub version: String,
pub current_version: Version,
pub date: Option<OffsetDateTime>,
#[allow(dead_code)]
target: String,
extract_path: PathBuf,
download_url: Url,
signature: String,
#[cfg(target_os = "windows")]
with_elevated_task: bool,
timeout: Option<Duration>,
headers: HeaderMap
}
impl<R: Runtime> Clone for Update<R> {
fn clone(&self) -> Self {
Update {
app: self.app.clone(),
body: self.body.clone(),
should_update: self.should_update,
version: self.version.clone(),
current_version: self.current_version.clone(),
date: self.date,
target: self.target.clone(),
extract_path: self.extract_path.clone(),
download_url: self.download_url.clone(),
signature: self.signature.clone(),
#[cfg(target_os = "windows")]
with_elevated_task: self.with_elevated_task,
timeout: self.timeout,
headers: self.headers.clone()
}
}
}
impl<R: Runtime> Update<R> {
pub async fn download_and_install<C: Fn(usize, Option<u64>), D: FnOnce()>(&self, pub_key: String, on_chunk: C, on_download_finish: D) -> Result {
#[cfg(target_os = "linux")]
if self.app.state::<Env>().appimage.is_none() {
return Err(Error::UnsupportedLinuxPackage);
}
let mut headers = self.headers.clone();
headers.insert("Accept", HeaderValue::from_str("application/octet-stream").unwrap());
headers.insert("User-Agent", HeaderValue::from_str("millennium/updater").unwrap());
let client = ClientBuilder::new().build()?;
let mut req = HttpRequestBuilder::new("GET", self.download_url.as_str())?.headers(headers);
if let Some(timeout) = self.timeout {
req = req.timeout(timeout);
}
let response = client.send(req).await?;
if !response.status().is_success() {
return Err(Error::Network(format!("Download request failed with status: {}", response.status())));
}
let content_length: Option<u64> = response
.headers()
.get("Content-Length")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse().ok());
let mut buffer = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
let bytes = chunk.as_ref().to_vec();
on_chunk(bytes.len(), content_length);
buffer.extend(bytes);
}
on_download_finish();
let mut archive_buffer = Cursor::new(buffer);
verify_signature(&mut archive_buffer, &self.signature, &pub_key)?;
#[cfg(feature = "updater")]
{
#[cfg(target_os = "windows")]
copy_files_and_run(
archive_buffer,
&self.extract_path,
self.with_elevated_task,
self.app.config().millennium.updater.windows.install_mode.clone().msiexec_args()
)?;
#[cfg(not(target_os = "windows"))]
copy_files_and_run(archive_buffer, &self.extract_path)?;
}
Ok(())
}
}
#[cfg(feature = "updater")]
#[cfg(target_os = "linux")]
fn copy_files_and_run<R: Read + Seek>(archive_buffer: R, extract_path: &Path) -> Result {
use std::os::unix::fs::PermissionsExt;
let tmp_dir = tempfile::Builder::new().prefix("millennium_current_app").tempdir()?;
let mut perms = std::fs::metadata(tmp_dir.path())?.permissions();
perms.set_mode(0o700);
std::fs::set_permissions(tmp_dir.path(), perms)?;
let tmp_app_image = &tmp_dir.path().join("current_app.AppImage");
Move::from_source(extract_path).to_dest(tmp_app_image)?;
let mut extractor = Extract::from_cursor(archive_buffer, ArchiveFormat::Tar(Some(Compression::Gz)));
extractor.with_files(|entry| {
let path = entry.path()?;
if path.extension() == Some(OsStr::new("AppImage")) {
if let Err(err) = entry.extract(extract_path) {
Move::from_source(tmp_app_image).to_dest(extract_path)?;
return Err(crate::api::Error::Extract(err.to_string()));
}
return Ok(true);
}
Ok(false)
})?;
Ok(())
}
#[cfg(feature = "updater")]
#[cfg(target_os = "windows")]
#[allow(clippy::unnecessary_wraps)]
fn copy_files_and_run<R: Read + Seek>(archive_buffer: R, _extract_path: &Path, with_elevated_task: bool, msiexec_args: &[&str]) -> Result {
let tmp_dir = tempfile::Builder::new().tempdir()?.into_path();
let mut extractor = Extract::from_cursor(archive_buffer, ArchiveFormat::Zip);
extractor.extract_into(&tmp_dir)?;
let paths = read_dir(&tmp_dir)?;
for path in paths {
let found_path = path?.path();
if found_path.extension() == Some(OsStr::new("exe")) {
Command::new(found_path).spawn().expect("installer failed to start");
exit(0);
} else if found_path.extension() == Some(OsStr::new("msi")) {
if with_elevated_task {
if let Some(bin_name) = current_exe()
.ok()
.and_then(|pb| pb.file_name().map(|s| s.to_os_string()))
.and_then(|s| s.into_string().ok())
{
let product_name = bin_name.replace(".exe", "");
let update_task_name = format!("Update {} - Skip UAC", product_name);
if let Ok(output) = Command::new("schtasks").arg("/QUERY").arg("/TN").arg(update_task_name.clone()).output() {
if output.status.success() {
let temp_msi = tmp_dir.with_file_name(bin_name).with_extension("msi");
Move::from_source(&found_path).to_dest(&temp_msi).expect("Unable to move update MSI");
let exit_status = Command::new("schtasks")
.arg("/RUN")
.arg("/TN")
.arg(update_task_name)
.status()
.expect("failed to start updater task");
if exit_status.success() {
exit(0);
}
}
}
}
}
Command::new("msiexec.exe")
.arg("/i")
.arg(found_path)
.args(msiexec_args)
.arg("/promptrestart")
.spawn()
.expect("installer failed to start");
exit(0);
}
}
Ok(())
}
#[cfg(feature = "updater")]
#[cfg(target_os = "macos")]
fn copy_files_and_run<R: Read + Seek>(archive_buffer: R, extract_path: &Path) -> Result {
let mut extracted_files: Vec<PathBuf> = Vec::new();
let mut extractor = Extract::from_cursor(archive_buffer, ArchiveFormat::Tar(Some(Compression::Gz)));
let tmp_dir = tempfile::Builder::new().prefix("millennium_current_app").tempdir()?;
Move::from_source(extract_path).to_dest(tmp_dir.path())?;
extractor.with_files(|entry| {
let path = entry.path()?;
let collected_path: PathBuf = path.iter().skip(1).collect();
let extraction_path = extract_path.join(collected_path);
if let Err(err) = entry.extract(&extraction_path) {
for file in &extracted_files {
if file.is_dir() {
std::fs::remove_dir(file)?;
} else {
std::fs::remove_file(file)?;
}
}
Move::from_source(tmp_dir.path()).to_dest(extract_path)?;
return Err(crate::api::Error::Extract(err.to_string()));
}
extracted_files.push(extraction_path);
Ok(false)
})?;
Ok(())
}
pub(crate) fn get_updater_target() -> Option<&'static str> {
if cfg!(target_os = "linux") {
Some("linux")
} else if cfg!(target_os = "macos") {
Some("darwin")
} else if cfg!(target_os = "windows") {
Some("windows")
} else {
None
}
}
pub(crate) fn get_updater_arch() -> Option<&'static str> {
if cfg!(target_arch = "x86_64") {
Some("x64")
} else if cfg!(target_arch = "x86") {
Some("x86")
} else if cfg!(target_arch = "arm") {
Some("armv7")
} else if cfg!(target_arch = "aarch64") {
Some("aarch64")
} else {
None
}
}
#[allow(unused_variables)]
pub fn extract_path_from_executable(env: &Env, executable_path: &Path) -> PathBuf {
let extract_path = executable_path.parent().map(PathBuf::from).expect("Can't determine extract path");
#[cfg(target_os = "macos")]
if extract_path.display().to_string().contains("Contents/MacOS") {
return extract_path
.parent()
.map(PathBuf::from)
.expect("Unable to find the extract path")
.parent()
.map(PathBuf::from)
.expect("Unable to find the extract path");
}
#[cfg(target_os = "linux")]
if let Some(app_image_path) = &env.appimage {
return PathBuf::from(app_image_path);
}
extract_path
}
fn base64_to_string(base64_string: &str) -> Result<String> {
let decoded_string = &decode(base64_string)?;
let result = from_utf8(decoded_string)
.map_err(|_| Error::SignatureUtf8(base64_string.into()))?
.to_string();
Ok(result)
}
pub fn verify_signature<R>(archive_reader: &mut R, release_signature: &str, pub_key: &str) -> Result<bool>
where
R: Read
{
let pub_key_decoded = base64_to_string(pub_key)?;
let public_key = PublicKey::decode(&pub_key_decoded)?;
let signature_base64_decoded = base64_to_string(release_signature)?;
let signature = Signature::decode(&signature_base64_decoded)?;
let mut data = Vec::new();
archive_reader.read_to_end(&mut data)?;
public_key.verify(&data, &signature, true)?;
Ok(true)
}