use std::{
collections::HashMap,
fs,
io::Read,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
use base64::Engine;
use fs2::FileExt;
use hdiff_update_core::{
default_platform, download_to_file, sha256_file, write_json_file_atomic, DownloadEvent,
HttpHeader,
};
use minisign_verify::{PublicKey, Signature};
use semver::Version;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tauri::{ipc::Channel, AppHandle, Manager, Runtime, State};
use url::Url;
use crate::file_update::{FileUpdateCommandError, FileUpdateHelperConfig};
#[cfg(windows)]
use std::{ffi::OsStr, os::windows::ffi::OsStrExt};
#[cfg(windows)]
use windows_sys::Win32::{
Foundation::{CloseHandle, ERROR_CANCELLED, ERROR_ELEVATION_REQUIRED, WAIT_TIMEOUT},
Storage::FileSystem::SYNCHRONIZE,
System::Threading::{
GetProcessId, OpenProcess, WaitForSingleObject, PROCESS_QUERY_LIMITED_INFORMATION,
},
UI::{
Shell::{ShellExecuteExW, SEE_MASK_NOASYNC, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW},
WindowsAndMessaging::SW_SHOWNORMAL,
},
};
const FULL_UPDATE_ROOT: &str = "full-updates";
const TRANSACTION_SCHEMA_VERSION: u32 = 1;
const MAX_INSTALLER_BYTES: u64 = 1024 * 1024 * 1024;
const MAX_AUTOMATIC_LAUNCH_ATTEMPTS: u32 = 2;
const INSTALLER_PROCESS_GRACE_MS: u64 = 15 * 60 * 1000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FullUpdateState {
Preparing,
Ready,
Launching,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FullUpdateTransaction {
pub schema_version: u32,
pub transaction_id: String,
pub application_id: String,
pub current_version: String,
pub target_version: String,
pub installer_path: PathBuf,
pub installer_sha256: String,
pub installer_size: u64,
pub updater_signature: String,
pub source_executable_sha256: String,
pub state: FullUpdateState,
pub launch_attempts: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub launched_pid: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub launched_at_ms: Option<u64>,
pub created_at_ms: u64,
pub updated_at_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct FullPreparedPointer {
transaction_path: PathBuf,
target_version: String,
installer_sha256: String,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum PreparedFullUpdateKind {
Prepared,
AlreadyPrepared,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PreparedFullUpdate {
pub kind: PreparedFullUpdateKind,
pub transaction: FullUpdateTransaction,
pub transaction_path: PathBuf,
pub installer_path: PathBuf,
pub bytes_downloaded: u64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrepareFullUpdateOptions {
pub current_version: String,
pub expected_version: String,
pub raw_json: serde_json::Value,
#[serde(default)]
pub headers: Vec<HttpHeader>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Deserialize)]
struct TauriUpdateArtifact {
url: String,
signature: String,
}
#[derive(Debug, Clone, Deserialize)]
struct TauriUpdateManifest {
#[serde(alias = "name")]
version: String,
#[serde(default)]
platforms: Option<HashMap<String, TauriUpdateArtifact>>,
#[serde(default)]
url: Option<String>,
#[serde(default)]
signature: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LaunchFullUpdateOptions {
pub transaction_path: PathBuf,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LaunchFullUpdateResult {
pub pid: u32,
}
#[tauri::command]
pub async fn prepare_full_update<R: Runtime>(
app: AppHandle<R>,
config: State<'_, FileUpdateHelperConfig>,
options: PrepareFullUpdateOptions,
on_event: Channel<DownloadEvent>,
) -> Result<PreparedFullUpdate, FileUpdateCommandError> {
if config.updater_public_key.trim().is_empty() {
return Err(FileUpdateCommandError::from(
"full updater public key is not compiled into the application",
));
}
if app.config().identifier != config.application_id {
return Err(FileUpdateCommandError::from(
"full update plugin application id does not match the Tauri identifier",
));
}
validate_version_upgrade(&options.current_version, &options.expected_version)?;
if !same_version(&options.current_version, config.current_version)
|| !same_version(
&app.package_info().version.to_string(),
config.current_version,
)
{
return Err(FileUpdateCommandError::from(format!(
"full update source version mismatch: updater={}, application={}, configured={}",
options.current_version,
app.package_info().version,
config.current_version
)));
}
let manifest: TauriUpdateManifest = serde_json::from_value(options.raw_json)?;
if !same_version(&manifest.version, &options.expected_version) {
return Err(FileUpdateCommandError::from(format!(
"full update manifest version mismatch: expected {}, got {}",
options.expected_version, manifest.version
)));
}
let artifact = resolve_tauri_update_artifact(manifest)?;
let download_url = Url::parse(&artifact.url)?;
if download_url.scheme() != "https" {
return Err(FileUpdateCommandError::from(
"full update installer must use HTTPS",
));
}
let cache_root = full_update_cache_root(&app)?;
fs::create_dir_all(&cache_root)?;
let lock = open_lock(&cache_root)?;
lock.lock_exclusive()?;
if let Some(prepared) = load_reusable_update(&cache_root, &config, &options.expected_version)? {
return Ok(prepared);
}
reset_full_update_cache(&cache_root)?;
let transaction_id = full_transaction_id(
config.application_id,
&options.current_version,
&options.expected_version,
download_url.as_str(),
);
let transaction_root = cache_root.join("transactions").join(transaction_id.clone());
fs::create_dir_all(&transaction_root)?;
let transaction_path = transaction_root.join("transaction.json");
let installer_path = transaction_root.join("installer.exe");
let source_executable_sha256 = sha256_file(std::env::current_exe()?)?.sha256;
let now = now_millis();
let mut transaction = FullUpdateTransaction {
schema_version: TRANSACTION_SCHEMA_VERSION,
transaction_id,
application_id: config.application_id.to_string(),
current_version: options.current_version.clone(),
target_version: options.expected_version.clone(),
installer_path: installer_path.clone(),
installer_sha256: String::new(),
installer_size: 0,
updater_signature: artifact.signature.clone(),
source_executable_sha256,
state: FullUpdateState::Preparing,
launch_attempts: 0,
launched_pid: None,
launched_at_ms: None,
created_at_ms: now,
updated_at_ms: now,
failure_reason: None,
};
write_json_file_atomic(&transaction_path, &transaction)?;
let stats = download_to_file(
download_url.as_str(),
&installer_path,
&options.headers,
options.timeout_secs,
Some(MAX_INSTALLER_BYTES),
|event| {
let _ = on_event.send(event);
},
)
.await?;
ensure_windows_installer(&installer_path)?;
verify_updater_signature(
&installer_path,
config.updater_public_key,
&artifact.signature,
)?;
let digest = sha256_file(&installer_path)?;
transaction.installer_sha256 = digest.sha256.clone();
transaction.installer_size = digest.size;
transaction.state = FullUpdateState::Ready;
transaction.updated_at_ms = now_millis();
write_json_file_atomic(&transaction_path, &transaction)?;
write_json_file_atomic(
cache_root.join("prepared.json"),
&FullPreparedPointer {
transaction_path: transaction_path.clone(),
target_version: transaction.target_version.clone(),
installer_sha256: transaction.installer_sha256.clone(),
},
)?;
Ok(PreparedFullUpdate {
kind: PreparedFullUpdateKind::Prepared,
transaction,
transaction_path,
installer_path,
bytes_downloaded: stats.bytes_written,
})
}
#[tauri::command]
pub async fn launch_full_update<R: Runtime>(
app: AppHandle<R>,
config: State<'_, FileUpdateHelperConfig>,
options: LaunchFullUpdateOptions,
) -> Result<LaunchFullUpdateResult, FileUpdateCommandError> {
let cache_root = full_update_cache_root(&app)?;
let lock = open_lock(&cache_root)?;
lock.lock_exclusive()?;
let transaction_path = validate_transaction_path(&cache_root, &options.transaction_path)?;
let pid = launch_full_update_inner(&config, &cache_root, &transaction_path, false)?;
Ok(LaunchFullUpdateResult { pid })
}
fn resolve_tauri_update_artifact(
manifest: TauriUpdateManifest,
) -> Result<TauriUpdateArtifact, FileUpdateCommandError> {
if let Some(platforms) = manifest.platforms {
let platform = default_platform();
return platforms.get(&platform).cloned().ok_or_else(|| {
FileUpdateCommandError::from(format!(
"full update manifest has no artifact for {platform}"
))
});
}
match (manifest.url, manifest.signature) {
(Some(url), Some(signature)) => Ok(TauriUpdateArtifact { url, signature }),
_ => Err(FileUpdateCommandError::from(
"full update manifest does not contain an installer URL and signature",
)),
}
}
pub(crate) fn maybe_apply_prepared_full_update(
config: &FileUpdateHelperConfig,
) -> Result<bool, FileUpdateCommandError> {
let cache_root = helper_cache_root(config)?;
let pointer_path = cache_root.join("prepared.json");
if !pointer_path.is_file() {
return Ok(false);
}
let pointer = match read_json::<FullPreparedPointer>(&pointer_path) {
Ok(pointer) => pointer,
Err(_) => {
let _ = fs::remove_file(pointer_path);
return Ok(false);
}
};
let transaction_path = match validate_transaction_path(&cache_root, &pointer.transaction_path) {
Ok(path) => path,
Err(_) => {
let _ = fs::remove_file(pointer_path);
return Ok(false);
}
};
let mut transaction = match read_transaction(&transaction_path) {
Ok(transaction) => transaction,
Err(_) => {
let _ = fs::remove_file(pointer_path);
return Ok(false);
}
};
if same_version(config.current_version, &transaction.target_version) {
let _ = cleanup_completed_update(&cache_root, &transaction_path);
return Ok(false);
}
if !same_version(config.current_version, &transaction.current_version)
|| transaction.application_id != config.application_id
|| pointer.target_version != transaction.target_version
|| pointer.installer_sha256 != transaction.installer_sha256
{
fail_prepared_update(
&cache_root,
&transaction_path,
&mut transaction,
"prepared full update does not match the running application",
)?;
return Ok(false);
}
if transaction.state == FullUpdateState::Launching {
let launch_is_recent = transaction.launched_at_ms.is_some_and(|launched_at| {
now_millis().saturating_sub(launched_at) <= INSTALLER_PROCESS_GRACE_MS
});
if launch_is_recent && transaction.launched_pid.is_some_and(process_is_running) {
return Ok(true);
}
if transaction.launch_attempts >= MAX_AUTOMATIC_LAUNCH_ATTEMPTS {
fail_prepared_update(
&cache_root,
&transaction_path,
&mut transaction,
"full update installer did not replace the application",
)?;
return Ok(false);
}
} else if transaction.state != FullUpdateState::Ready {
fail_prepared_update(
&cache_root,
&transaction_path,
&mut transaction,
"prepared full update is not launchable",
)?;
return Ok(false);
}
match launch_full_update_inner(config, &cache_root, &transaction_path, true) {
Ok(_) => Ok(true),
Err(error) => {
let mut transaction = read_transaction(&transaction_path).unwrap_or(transaction);
fail_prepared_update(
&cache_root,
&transaction_path,
&mut transaction,
&error.message,
)?;
Ok(false)
}
}
}
fn launch_full_update_inner(
config: &FileUpdateHelperConfig,
cache_root: &Path,
transaction_path: &Path,
automatic: bool,
) -> Result<u32, FileUpdateCommandError> {
let pointer = read_json::<FullPreparedPointer>(&cache_root.join("prepared.json"))?;
let mut transaction = validate_transaction(config, cache_root, transaction_path, &pointer)?;
if transaction.state != FullUpdateState::Ready
&& !(automatic && transaction.state == FullUpdateState::Launching)
{
return Err(FileUpdateCommandError::from(format!(
"full update transaction is not ready: {:?}",
transaction.state
)));
}
let current_digest = sha256_file(std::env::current_exe()?)?;
if current_digest.sha256 != transaction.source_executable_sha256 {
return Err(FileUpdateCommandError::from(
"running executable does not match the prepared full update source",
));
}
transaction.state = FullUpdateState::Launching;
transaction.launch_attempts = transaction.launch_attempts.saturating_add(1);
transaction.launched_pid = None;
transaction.launched_at_ms = Some(now_millis());
transaction.updated_at_ms = now_millis();
transaction.failure_reason = None;
write_json_file_atomic(transaction_path, &transaction)?;
match launch_installer_process(&transaction.installer_path) {
Ok(pid) => {
transaction.launched_pid = Some(pid);
transaction.updated_at_ms = now_millis();
write_json_file_atomic(transaction_path, &transaction)?;
Ok(pid)
}
Err(error) => {
if !automatic {
transaction.state = FullUpdateState::Ready;
transaction.launched_pid = None;
transaction.updated_at_ms = now_millis();
transaction.failure_reason = Some(error.message.clone());
write_json_file_atomic(transaction_path, &transaction)?;
}
Err(error)
}
}
}
fn load_reusable_update(
cache_root: &Path,
config: &FileUpdateHelperConfig,
target_version: &str,
) -> Result<Option<PreparedFullUpdate>, FileUpdateCommandError> {
let pointer_path = cache_root.join("prepared.json");
if !pointer_path.is_file() {
return Ok(None);
}
let pointer = match read_json::<FullPreparedPointer>(&pointer_path) {
Ok(pointer) => pointer,
Err(_) => return Ok(None),
};
if !same_version(&pointer.target_version, target_version) {
return Ok(None);
}
let transaction_path = match validate_transaction_path(cache_root, &pointer.transaction_path) {
Ok(path) => path,
Err(_) => return Ok(None),
};
let transaction = match validate_transaction(config, cache_root, &transaction_path, &pointer) {
Ok(transaction) => transaction,
Err(_) => return Ok(None),
};
if transaction.state != FullUpdateState::Ready {
return Ok(None);
}
let current_digest = sha256_file(std::env::current_exe()?)?;
if current_digest.sha256 != transaction.source_executable_sha256 {
return Ok(None);
}
Ok(Some(PreparedFullUpdate {
kind: PreparedFullUpdateKind::AlreadyPrepared,
installer_path: transaction.installer_path.clone(),
transaction,
transaction_path,
bytes_downloaded: 0,
}))
}
fn validate_transaction(
config: &FileUpdateHelperConfig,
cache_root: &Path,
transaction_path: &Path,
pointer: &FullPreparedPointer,
) -> Result<FullUpdateTransaction, FileUpdateCommandError> {
let transaction_path = validate_transaction_path(cache_root, transaction_path)?;
let transaction = read_transaction(&transaction_path)?;
if transaction.application_id != config.application_id {
return Err(FileUpdateCommandError::from(
"full update transaction belongs to another application",
));
}
if !same_version(&transaction.current_version, config.current_version) {
return Err(FileUpdateCommandError::from(
"full update transaction source version does not match the application",
));
}
validate_version_upgrade(&transaction.current_version, &transaction.target_version)?;
if pointer.transaction_path.canonicalize()? != transaction_path
|| pointer.target_version != transaction.target_version
|| pointer.installer_sha256 != transaction.installer_sha256
{
return Err(FileUpdateCommandError::from(
"full update prepared pointer does not match its transaction",
));
}
let transaction_root = transaction_path
.parent()
.ok_or_else(|| FileUpdateCommandError::from("full update transaction has no parent"))?;
let installer_path = transaction.installer_path.canonicalize()?;
if !installer_path.starts_with(transaction_root)
|| installer_path.file_name() != Some(std::ffi::OsStr::new("installer.exe"))
{
return Err(FileUpdateCommandError::from(
"full update installer is outside its transaction",
));
}
ensure_plain_file(&installer_path)?;
let digest = sha256_file(&installer_path)?;
if digest.size != transaction.installer_size || digest.sha256 != transaction.installer_sha256 {
return Err(FileUpdateCommandError::from(
"full update installer hash or size does not match its transaction",
));
}
ensure_windows_installer(&installer_path)?;
verify_updater_signature(
&installer_path,
config.updater_public_key,
&transaction.updater_signature,
)?;
Ok(transaction)
}
fn read_transaction(path: &Path) -> Result<FullUpdateTransaction, FileUpdateCommandError> {
let transaction = read_json::<FullUpdateTransaction>(path)?;
if transaction.schema_version != TRANSACTION_SCHEMA_VERSION {
return Err(FileUpdateCommandError::from(format!(
"unsupported full update transaction schema version: {}",
transaction.schema_version
)));
}
Ok(transaction)
}
fn reset_full_update_cache(cache_root: &Path) -> Result<(), FileUpdateCommandError> {
let pointer_path = cache_root.join("prepared.json");
if pointer_path.is_file() {
fs::remove_file(pointer_path)?;
}
let transactions_root = cache_root.join("transactions");
if transactions_root.exists() {
ensure_plain_directory(&transactions_root)?;
fs::remove_dir_all(&transactions_root)?;
}
fs::create_dir_all(transactions_root)?;
Ok(())
}
fn cleanup_completed_update(
cache_root: &Path,
transaction_path: &Path,
) -> Result<(), FileUpdateCommandError> {
let transaction_path = validate_transaction_path(cache_root, transaction_path)?;
let transaction_root = transaction_path
.parent()
.ok_or_else(|| FileUpdateCommandError::from("full update transaction has no parent"))?;
let pointer_path = cache_root.join("prepared.json");
if pointer_path.is_file() {
fs::remove_file(pointer_path)?;
}
ensure_plain_directory(transaction_root)?;
fs::remove_dir_all(transaction_root)?;
Ok(())
}
fn fail_prepared_update(
cache_root: &Path,
transaction_path: &Path,
transaction: &mut FullUpdateTransaction,
reason: &str,
) -> Result<(), FileUpdateCommandError> {
transaction.state = FullUpdateState::Failed;
transaction.launched_pid = None;
transaction.updated_at_ms = now_millis();
transaction.failure_reason = Some(reason.to_string());
write_json_file_atomic(transaction_path, transaction)?;
let pointer_path = cache_root.join("prepared.json");
if pointer_path.is_file() {
fs::remove_file(pointer_path)?;
}
if transaction.installer_path.is_file() {
fs::remove_file(&transaction.installer_path)?;
}
Ok(())
}
fn full_update_cache_root<R: Runtime>(
app: &AppHandle<R>,
) -> Result<PathBuf, FileUpdateCommandError> {
Ok(app
.path()
.app_data_dir()?
.join("hdiff-update")
.join(FULL_UPDATE_ROOT))
}
fn helper_cache_root(config: &FileUpdateHelperConfig) -> Result<PathBuf, FileUpdateCommandError> {
let app_data = std::env::var_os("APPDATA")
.ok_or_else(|| FileUpdateCommandError::from("APPDATA is not available"))?;
Ok(PathBuf::from(app_data)
.join(config.application_id)
.join("hdiff-update")
.join(FULL_UPDATE_ROOT))
}
fn open_lock(cache_root: &Path) -> Result<fs::File, FileUpdateCommandError> {
fs::create_dir_all(cache_root)?;
Ok(fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(cache_root.join("lock"))?)
}
fn validate_transaction_path(
cache_root: &Path,
transaction_path: &Path,
) -> Result<PathBuf, FileUpdateCommandError> {
let expected_root = cache_root.join("transactions").canonicalize()?;
let transaction_path = transaction_path.canonicalize()?;
if !transaction_path.starts_with(expected_root)
|| transaction_path.file_name() != Some(std::ffi::OsStr::new("transaction.json"))
{
return Err(FileUpdateCommandError::from(format!(
"full update transaction is outside the managed cache: {}",
transaction_path.display()
)));
}
Ok(transaction_path)
}
fn verify_updater_signature(
installer_path: &Path,
encoded_public_key: &str,
encoded_signature: &str,
) -> Result<(), FileUpdateCommandError> {
let public_key_text = decode_base64_utf8(encoded_public_key, "updater public key")?;
let signature_text = decode_base64_utf8(encoded_signature, "updater signature")?;
let public_key = PublicKey::decode(&public_key_text)?;
let signature = Signature::decode(&signature_text)?;
let mut verifier = public_key.verify_stream(&signature)?;
let mut installer = fs::File::open(installer_path)?;
let mut buffer = vec![0_u8; 1024 * 1024];
loop {
let read = installer.read(&mut buffer)?;
if read == 0 {
break;
}
verifier.update(&buffer[..read]);
}
verifier.finalize()?;
Ok(())
}
fn decode_base64_utf8(value: &str, label: &str) -> Result<String, FileUpdateCommandError> {
let decoded = base64::engine::general_purpose::STANDARD
.decode(value.trim())
.map_err(|error| FileUpdateCommandError::from(format!("invalid {label}: {error}")))?;
String::from_utf8(decoded)
.map_err(|error| FileUpdateCommandError::from(format!("invalid {label}: {error}")))
}
fn ensure_windows_installer(path: &Path) -> Result<(), FileUpdateCommandError> {
let mut file = fs::File::open(path)?;
let mut magic = [0_u8; 2];
file.read_exact(&mut magic)?;
if magic != *b"MZ" {
return Err(FileUpdateCommandError::from(
"full update artifact is not a Windows executable",
));
}
Ok(())
}
fn ensure_plain_directory(path: &Path) -> Result<(), FileUpdateCommandError> {
let metadata = fs::symlink_metadata(path)?;
if !metadata.is_dir() || metadata.file_type().is_symlink() || is_reparse_point(&metadata) {
return Err(FileUpdateCommandError::from(format!(
"directory is missing or is a reparse point: {}",
path.display()
)));
}
Ok(())
}
fn ensure_plain_file(path: &Path) -> Result<(), FileUpdateCommandError> {
let metadata = fs::symlink_metadata(path)?;
if !metadata.is_file() || metadata.file_type().is_symlink() || is_reparse_point(&metadata) {
return Err(FileUpdateCommandError::from(format!(
"file is missing or is a reparse point: {}",
path.display()
)));
}
Ok(())
}
#[cfg(windows)]
fn is_reparse_point(metadata: &fs::Metadata) -> bool {
use std::os::windows::fs::MetadataExt;
metadata.file_attributes() & 0x0000_0400 != 0
}
#[cfg(not(windows))]
fn is_reparse_point(_metadata: &fs::Metadata) -> bool {
false
}
fn validate_version_upgrade(current: &str, target: &str) -> Result<(), FileUpdateCommandError> {
let current = parse_version(current)?;
let target = parse_version(target)?;
if target <= current {
return Err(FileUpdateCommandError::from(
"full update target version is not newer than the current version",
));
}
Ok(())
}
fn same_version(left: &str, right: &str) -> bool {
match (parse_version(left), parse_version(right)) {
(Ok(left), Ok(right)) => left == right,
_ => left.trim() == right.trim(),
}
}
fn parse_version(value: &str) -> Result<Version, FileUpdateCommandError> {
Version::parse(value.trim().trim_start_matches('v')).map_err(FileUpdateCommandError::from)
}
fn full_transaction_id(
application_id: &str,
current_version: &str,
target_version: &str,
download_url: &str,
) -> String {
let mut hasher = Sha256::new();
for value in [
application_id,
current_version,
target_version,
download_url,
] {
hasher.update(value.as_bytes());
hasher.update([0]);
}
hex::encode(hasher.finalize())
}
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, FileUpdateCommandError> {
Ok(serde_json::from_slice(&fs::read(path)?)?)
}
fn now_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.min(u64::MAX as u128) as u64
}
#[cfg(windows)]
fn launch_installer_process(installer_path: &Path) -> Result<u32, FileUpdateCommandError> {
let verb = wide_null(OsStr::new("runas"))?;
let executable = wide_null(installer_path.as_os_str())?;
let parameters = wide_null(OsStr::new("/P /R /UPDATE /ARGS"))?;
let directory = installer_path
.parent()
.map(|path| wide_null(path.as_os_str()))
.transpose()?;
let mut execute_info = SHELLEXECUTEINFOW {
cbSize: std::mem::size_of::<SHELLEXECUTEINFOW>() as u32,
fMask: SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC,
lpVerb: verb.as_ptr(),
lpFile: executable.as_ptr(),
lpParameters: parameters.as_ptr(),
lpDirectory: directory
.as_ref()
.map_or(std::ptr::null(), |value| value.as_ptr()),
nShow: SW_SHOWNORMAL,
..Default::default()
};
if unsafe { ShellExecuteExW(&mut execute_info) } == 0 {
let error = std::io::Error::last_os_error();
if error.raw_os_error() == Some(ERROR_CANCELLED as i32) {
return Err(FileUpdateCommandError::from(
"administrator permission was declined",
));
}
if error.raw_os_error() == Some(ERROR_ELEVATION_REQUIRED as i32) {
return Err(FileUpdateCommandError::from(
"administrator permission is required",
));
}
return Err(FileUpdateCommandError::from(format!(
"failed to launch full update installer: {error}"
)));
}
if execute_info.hProcess.is_null() {
return Err(FileUpdateCommandError::from(
"full update installer started without a process handle",
));
}
let pid = unsafe { GetProcessId(execute_info.hProcess) };
unsafe {
CloseHandle(execute_info.hProcess);
}
if pid == 0 {
return Err(FileUpdateCommandError::from(
"full update installer started without a process id",
));
}
Ok(pid)
}
#[cfg(not(windows))]
fn launch_installer_process(_installer_path: &Path) -> Result<u32, FileUpdateCommandError> {
Err(FileUpdateCommandError::from(
"persistent full updates are only implemented on Windows",
))
}
#[cfg(windows)]
fn process_is_running(pid: u32) -> bool {
let process = unsafe { OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if process.is_null() {
return false;
}
let wait = unsafe { WaitForSingleObject(process, 0) };
unsafe {
CloseHandle(process);
}
wait == WAIT_TIMEOUT
}
#[cfg(not(windows))]
fn process_is_running(_pid: u32) -> bool {
false
}
#[cfg(windows)]
fn wide_null(value: &OsStr) -> std::io::Result<Vec<u16>> {
let mut wide = Vec::new();
for unit in value.encode_wide() {
if unit == 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"value contains an embedded null character",
));
}
wide.push(unit);
}
wide.push(0);
Ok(wide)
}
#[cfg(test)]
mod tests {
use super::{
full_transaction_id, resolve_tauri_update_artifact, same_version, validate_version_upgrade,
TauriUpdateManifest,
};
#[test]
fn full_transaction_id_is_stable_and_input_sensitive() {
let first = full_transaction_id("app", "1.0.0", "1.1.0", "https://example/app.exe");
let second = full_transaction_id("app", "1.0.0", "1.1.0", "https://example/app.exe");
let different = full_transaction_id("app", "1.0.0", "1.2.0", "https://example/app.exe");
assert_eq!(first, second);
assert_ne!(first, different);
assert_eq!(first.len(), 64);
}
#[test]
fn version_comparison_accepts_v_prefix_and_rejects_non_upgrade() {
assert!(same_version("v1.2.3", "1.2.3"));
assert!(validate_version_upgrade("1.2.3", "1.2.4").is_ok());
assert!(validate_version_upgrade("1.2.3", "1.2.3").is_err());
assert!(validate_version_upgrade("1.2.3", "1.2.2").is_err());
}
#[test]
fn resolves_dynamic_tauri_update_artifact() {
let manifest: TauriUpdateManifest = serde_json::from_value(serde_json::json!({
"version": "1.2.4",
"url": "https://example.test/app.exe",
"signature": "signature"
}))
.unwrap();
let artifact = resolve_tauri_update_artifact(manifest).unwrap();
assert_eq!(artifact.url, "https://example.test/app.exe");
assert_eq!(artifact.signature, "signature");
}
}