use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
process::Command,
time::{SystemTime, UNIX_EPOCH},
};
use hdiff_update_core::{
apply_patch as core_apply_patch, create_patch as core_create_patch, default_platform,
load_update_manifest, prepare_delta_update as core_prepare_delta_update,
prepare_update as core_prepare_update, sha256_file, verify_sha256, ApplyPatchOptions,
CreatePatchOptions, DeltaArtifact, DownloadEvent, FileDigest, HttpHeader, PatchApplyResult,
PatchCreateResult, PlatformRelease, PrepareUpdateOptions, PreparedUpdate,
};
use serde::{Deserialize, Serialize};
use tauri::{ipc::Channel, AppHandle, Manager, Runtime};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CommandError {
pub message: String,
}
impl<E> From<E> for CommandError
where
E: std::fmt::Display,
{
fn from(error: E) -> Self {
Self {
message: error.to_string(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RunInstallerOptions {
pub installer_path: PathBuf,
#[serde(default)]
pub args: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_dir: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_sha256: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadAndInstallOptions {
pub update: PrepareUpdateOptions,
#[serde(default)]
pub installer_args: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CachedInstallerUpdateOptions {
pub manifest_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub platform: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub installer_cache_dir: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hpatchz_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature_public_key: Option<String>,
#[serde(default = "default_true")]
pub require_signature: bool,
#[serde(default)]
pub force: bool,
#[serde(default)]
pub headers: Vec<HttpHeader>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TryInstallWithCachedInstallerOptions {
pub update: CachedInstallerUpdateOptions,
#[serde(default)]
pub installer_args: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RunInstallerResult {
pub pid: u32,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadAndInstallResult {
pub update: PreparedUpdate,
pub pid: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallerCacheEntry {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub platform: Option<String>,
pub sha256: String,
pub size: u64,
pub path: PathBuf,
pub created_at_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct InstallerCacheIndex {
version: u32,
#[serde(default)]
installers: Vec<InstallerCacheEntry>,
}
#[derive(Debug, Clone)]
struct InstallerCacheCandidate {
version: Option<String>,
platform: Option<String>,
indexed_sha256: Option<String>,
path: PathBuf,
created_at_ms: u64,
}
#[derive(Debug, Clone)]
struct CachedInstallerMatch {
entry: InstallerCacheEntry,
delta: DeltaArtifact,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum TryInstallWithCachedInstallerResult {
InstalledDiff {
update: PreparedUpdate,
pid: u32,
saved_bytes: u64,
source_installer: InstallerCacheEntry,
},
Fallback {
reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
detail: Option<String>,
},
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum PrepareWithCachedInstallerResult {
PreparedDiff {
update: PreparedUpdate,
saved_bytes: u64,
source_installer: InstallerCacheEntry,
},
Fallback {
reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
detail: Option<String>,
},
}
#[tauri::command]
pub async fn artifact_sha256(path: PathBuf) -> Result<FileDigest, CommandError> {
tokio::task::spawn_blocking(move || sha256_file(path))
.await
.map_err(CommandError::from)?
.map_err(CommandError::from)
}
#[tauri::command]
pub async fn create_patch(options: CreatePatchOptions) -> Result<PatchCreateResult, CommandError> {
tokio::task::spawn_blocking(move || core_create_patch(&options))
.await
.map_err(CommandError::from)?
.map_err(CommandError::from)
}
#[tauri::command]
pub async fn apply_patch(options: ApplyPatchOptions) -> Result<PatchApplyResult, CommandError> {
tokio::task::spawn_blocking(move || core_apply_patch(&options))
.await
.map_err(CommandError::from)?
.map_err(CommandError::from)
}
#[tauri::command]
pub async fn download_and_apply(
options: PrepareUpdateOptions,
on_event: Channel<DownloadEvent>,
) -> Result<PreparedUpdate, CommandError> {
core_prepare_update(options, |event| {
let _ = on_event.send(event);
})
.await
.map_err(CommandError::from)
}
#[tauri::command]
pub async fn download_and_install(
options: DownloadAndInstallOptions,
on_event: Channel<DownloadEvent>,
) -> Result<DownloadAndInstallResult, CommandError> {
let installer_args = options.installer_args;
let current_dir = options.current_dir;
let update = core_prepare_update(options.update, |event| {
let _ = on_event.send(event);
})
.await
.map_err(CommandError::from)?;
let install = tokio::task::spawn_blocking({
let update = update.clone();
move || {
spawn_installer(
RunInstallerOptions {
installer_path: update.artifact_path.clone(),
args: installer_args,
current_dir,
expected_sha256: Some(update.artifact.sha256.clone()),
},
true,
)
}
})
.await
.map_err(CommandError::from)??;
Ok(DownloadAndInstallResult {
update,
pid: install.pid,
})
}
#[tauri::command]
pub async fn try_install_with_cached_installer<R: Runtime>(
app: AppHandle<R>,
options: TryInstallWithCachedInstallerOptions,
on_event: Channel<DownloadEvent>,
) -> Result<TryInstallWithCachedInstallerResult, CommandError> {
let prepared = prepare_with_cached_installer_inner(&app, options.update, &on_event).await?;
let prepared = match prepared {
PrepareWithCachedInstallerResult::PreparedDiff {
update,
saved_bytes,
source_installer,
} => (update, saved_bytes, source_installer),
PrepareWithCachedInstallerResult::Fallback { reason, detail } => {
return Ok(TryInstallWithCachedInstallerResult::Fallback { reason, detail })
}
};
let (prepared, saved_bytes, source_installer) = prepared;
let install = tokio::task::spawn_blocking({
let prepared = prepared.clone();
let installer_args = options.installer_args;
let current_dir = options.current_dir;
move || {
spawn_installer(
RunInstallerOptions {
installer_path: prepared.artifact_path.clone(),
args: installer_args,
current_dir,
expected_sha256: Some(prepared.artifact.sha256.clone()),
},
true,
)
}
})
.await
.map_err(CommandError::from);
let install = match install {
Ok(Ok(result)) => result,
Ok(Err(error)) => return Ok(fallback("installerSpawnFailed", error.message)),
Err(error) => return Ok(fallback("installerSpawnFailed", error.message)),
};
Ok(TryInstallWithCachedInstallerResult::InstalledDiff {
update: prepared,
pid: install.pid,
saved_bytes,
source_installer,
})
}
#[tauri::command]
pub async fn prepare_with_cached_installer<R: Runtime>(
app: AppHandle<R>,
options: CachedInstallerUpdateOptions,
on_event: Channel<DownloadEvent>,
) -> Result<PrepareWithCachedInstallerResult, CommandError> {
prepare_with_cached_installer_inner(&app, options, &on_event).await
}
async fn prepare_with_cached_installer_inner<R: Runtime>(
app: &AppHandle<R>,
update_options: CachedInstallerUpdateOptions,
on_event: &Channel<DownloadEvent>,
) -> Result<PrepareWithCachedInstallerResult, CommandError> {
let platform = update_options
.platform
.clone()
.unwrap_or_else(default_platform);
let cache_root = match installer_cache_root(&app, update_options.installer_cache_dir.as_deref())
{
Ok(path) => path,
Err(error) => return Ok(prepare_fallback("cacheRootUnavailable", error.message)),
};
let manifest = match load_update_manifest(
&update_options.manifest_url,
&update_options.headers,
update_options.timeout_secs,
update_options.signature_public_key.as_deref(),
update_options.require_signature,
update_options.expected_version.as_deref(),
)
.await
{
Ok(manifest) => manifest,
Err(error) => return Ok(prepare_fallback("manifestUnavailable", error)),
};
let platform_release = match manifest.platform(&platform) {
Ok(release) => release.clone(),
Err(error) => return Ok(prepare_fallback("missingPlatform", error)),
};
let cached = match find_cached_installer(
&cache_root,
&platform,
update_options.current_version.as_deref(),
&platform_release,
) {
Ok(Some(cached)) => cached,
Ok(None) => {
return Ok(PrepareWithCachedInstallerResult::Fallback {
reason: "noMatchingCachedInstaller".to_string(),
detail: None,
})
}
Err(error) => return Ok(prepare_fallback("cacheScanFailed", error.message)),
};
let pending_dir = cache_root.join("pending");
let patches_dir = cache_root.join("patches");
if let Err(error) = reset_transient_dir(&pending_dir) {
return Ok(prepare_fallback("pendingCleanupFailed", error.message));
}
if let Err(error) = reset_transient_dir(&patches_dir) {
return Ok(prepare_fallback("patchCleanupFailed", error.message));
}
let output_name = cache_artifact_name(
&manifest.version,
&platform,
&platform_release.full.sha256,
&platform_release.full.url,
);
let patch_name = artifact_file_name(&cached.delta.url).unwrap_or_else(|| {
format!(
"{}-to-{}-{}.hpatch",
sanitize(
cached
.delta
.from_version
.as_deref()
.unwrap_or("cached-installer")
),
sanitize(&manifest.version),
sanitize(&platform)
)
});
let prepared = match core_prepare_delta_update(
PrepareUpdateOptions {
manifest_url: update_options.manifest_url,
platform: Some(platform.clone()),
current_version: update_options.current_version.clone(),
expected_version: update_options.expected_version,
current_artifact_path: Some(cached.entry.path.clone()),
output_path: Some(pending_dir.join(output_name)),
patch_path: Some(patches_dir.join(patch_name)),
cache_dir: Some(cache_root.join("work")),
hpatchz_path: update_options.hpatchz_path,
signature_public_key: update_options.signature_public_key,
require_signature: update_options.require_signature,
force: update_options.force,
headers: update_options.headers,
timeout_secs: update_options.timeout_secs,
},
|event| {
let _ = on_event.send(event);
},
)
.await
{
Ok(update) => update,
Err(error) => return Ok(prepare_fallback("deltaFailed", error)),
};
let saved_bytes = platform_release
.full
.size
.saturating_sub(prepared.bytes_downloaded);
Ok(PrepareWithCachedInstallerResult::PreparedDiff {
update: prepared,
saved_bytes,
source_installer: cached.entry,
})
}
#[tauri::command]
pub async fn run_installer(
options: RunInstallerOptions,
) -> Result<RunInstallerResult, CommandError> {
tokio::task::spawn_blocking(move || spawn_installer(options, false))
.await
.map_err(CommandError::from)?
}
fn spawn_installer(
options: RunInstallerOptions,
require_expected_hash: bool,
) -> Result<RunInstallerResult, CommandError> {
match &options.expected_sha256 {
Some(expected_sha256) => {
verify_sha256(&options.installer_path, expected_sha256).map_err(CommandError::from)?;
}
None if require_expected_hash => {
return Err(CommandError::from(
"refusing to run installer without an expected SHA-256",
));
}
None => {}
}
let mut command = Command::new(&options.installer_path);
command.args(&options.args);
if let Some(current_dir) = &options.current_dir {
command.current_dir(current_dir);
}
let child = command.spawn().map_err(|error| {
CommandError::from(format!(
"failed to start installer {}: {error}",
options.installer_path.display()
))
})?;
Ok(RunInstallerResult { pid: child.id() })
}
fn fallback(
reason: impl Into<String>,
detail: impl std::fmt::Display,
) -> TryInstallWithCachedInstallerResult {
TryInstallWithCachedInstallerResult::Fallback {
reason: reason.into(),
detail: Some(detail.to_string()),
}
}
fn prepare_fallback(
reason: impl Into<String>,
detail: impl std::fmt::Display,
) -> PrepareWithCachedInstallerResult {
PrepareWithCachedInstallerResult::Fallback {
reason: reason.into(),
detail: Some(detail.to_string()),
}
}
fn installer_cache_root<R: Runtime>(
app: &AppHandle<R>,
override_dir: Option<&Path>,
) -> Result<PathBuf, CommandError> {
if let Some(path) = override_dir {
return Ok(path.to_path_buf());
}
let app_data_dir = app.path().app_data_dir().map_err(CommandError::from)?;
Ok(app_data_dir.join("hdiff-update").join("installers"))
}
fn find_cached_installer(
cache_root: &Path,
platform: &str,
current_version: Option<&str>,
release: &PlatformRelease,
) -> Result<Option<CachedInstallerMatch>, CommandError> {
let candidates = load_cache_candidates(cache_root)?;
if candidates.is_empty() {
return Ok(None);
}
let eligible_deltas: Vec<_> = release
.deltas
.iter()
.filter(|delta| delta_version_matches(delta, current_version))
.collect();
if eligible_deltas.is_empty() {
return Ok(None);
}
for candidate in candidates {
if let Some(candidate_platform) = candidate.platform.as_deref() {
if candidate_platform != platform {
continue;
}
}
if let (Some(candidate_version), Some(current_version)) =
(candidate.version.as_deref(), current_version)
{
if candidate_version != current_version {
continue;
}
}
if let Some(indexed_sha256) = candidate.indexed_sha256.as_deref() {
if !eligible_deltas
.iter()
.any(|delta| delta.from_sha256.eq_ignore_ascii_case(indexed_sha256))
{
continue;
}
}
if !candidate.path.is_file() {
continue;
}
let digest = match sha256_file(&candidate.path) {
Ok(digest) => digest,
Err(_) => continue,
};
let Some(delta) = eligible_deltas
.iter()
.find(|delta| delta.from_sha256.eq_ignore_ascii_case(&digest.sha256))
else {
continue;
};
return Ok(Some(CachedInstallerMatch {
entry: InstallerCacheEntry {
version: candidate
.version
.clone()
.or_else(|| delta.from_version.clone()),
platform: Some(platform.to_string()),
sha256: digest.sha256,
size: digest.size,
path: candidate.path,
created_at_ms: candidate.created_at_ms,
},
delta: (*delta).clone(),
}));
}
Ok(None)
}
fn load_cache_candidates(cache_root: &Path) -> Result<Vec<InstallerCacheCandidate>, CommandError> {
let mut by_path = BTreeMap::<String, InstallerCacheCandidate>::new();
let index_path = cache_root.join("index.json");
if index_path.is_file() {
if let Ok(bytes) = fs::read(&index_path) {
if let Ok(index) = serde_json::from_slice::<InstallerCacheIndex>(&bytes) {
for entry in index.installers {
by_path.insert(
normalize_path_key(&entry.path),
InstallerCacheCandidate {
version: entry.version,
platform: entry.platform,
indexed_sha256: Some(entry.sha256),
path: entry.path,
created_at_ms: entry.created_at_ms,
},
);
}
}
}
}
let artifacts_dir = cache_root.join("artifacts");
if artifacts_dir.is_dir() {
let entries = fs::read_dir(&artifacts_dir).map_err(CommandError::from)?;
for entry in entries {
let entry = entry.map_err(CommandError::from)?;
let path = entry.path();
if !path.is_file() {
continue;
}
let key = normalize_path_key(&path);
by_path
.entry(key)
.or_insert_with(|| InstallerCacheCandidate {
version: None,
platform: None,
indexed_sha256: None,
path,
created_at_ms: now_millis(),
});
}
}
Ok(by_path.into_values().collect())
}
fn delta_version_matches(delta: &DeltaArtifact, current_version: Option<&str>) -> bool {
match (&delta.from_version, current_version) {
(Some(from_version), Some(current_version)) => from_version == current_version,
(Some(_), None) => true,
(None, _) => true,
}
}
fn reset_transient_dir(path: &Path) -> Result<(), CommandError> {
if path.exists() {
fs::remove_dir_all(path).map_err(CommandError::from)?;
}
fs::create_dir_all(path).map_err(CommandError::from)
}
fn artifact_file_name(value: &str) -> Option<String> {
let value = value.split(['?', '#']).next().unwrap_or(value);
value
.rsplit(['/', '\\'])
.next()
.filter(|name| !name.is_empty())
.map(ToOwned::to_owned)
}
fn cache_artifact_name(version: &str, platform: &str, sha256: &str, source_name: &str) -> String {
let original = artifact_file_name(source_name).unwrap_or_else(|| "installer.bin".to_string());
let hash_prefix = sha256.get(..12).unwrap_or(sha256);
format!(
"{}-{}-{}-{}",
sanitize(version),
sanitize(platform),
sanitize(hash_prefix),
sanitize(&original)
)
}
fn normalize_path_key(path: &Path) -> String {
path.to_string_lossy().to_ascii_lowercase()
}
fn sanitize(value: &str) -> String {
value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
ch
} else {
'_'
}
})
.collect()
}
fn now_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
.unwrap_or_default()
}
fn default_true() -> bool {
true
}