427 lines
13 KiB
Rust
427 lines
13 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use std::time::Duration;
|
|
use tauri::{ipc::Channel, AppHandle, Runtime, Url};
|
|
use tauri_plugin_updater::UpdaterExt;
|
|
|
|
const ALPHA_METADATA_ASSET_NAME: &str = "alpha-latest.json";
|
|
const GITHUB_RELEASES_API_URL: &str =
|
|
"https://api.github.com/repos/refactoringhq/tolaria/releases?per_page=100";
|
|
const RELEASES_BASE_URL: &str = "https://refactoringhq.github.io/tolaria";
|
|
const UPDATER_HTTP_TIMEOUT: Duration = Duration::from_secs(5);
|
|
const UPDATER_USER_AGENT: &str = concat!("Tolaria/", env!("CARGO_PKG_VERSION"));
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AppUpdateMetadata {
|
|
pub current_version: String,
|
|
pub version: String,
|
|
pub date: Option<String>,
|
|
pub body: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(tag = "event", content = "data")]
|
|
pub enum AppUpdateDownloadEvent {
|
|
#[serde(rename_all = "camelCase")]
|
|
Started {
|
|
content_length: Option<u64>,
|
|
},
|
|
#[serde(rename_all = "camelCase")]
|
|
Progress {
|
|
chunk_length: usize,
|
|
},
|
|
Finished,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum ReleaseChannel {
|
|
Alpha,
|
|
Stable,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
struct AlphaReleaseVersion {
|
|
year: i32,
|
|
month: u32,
|
|
day: u32,
|
|
sequence: u32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
struct GitHubRelease {
|
|
tag_name: String,
|
|
draft: bool,
|
|
assets: Vec<GitHubAsset>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
struct GitHubAsset {
|
|
name: String,
|
|
browser_download_url: String,
|
|
}
|
|
|
|
impl ReleaseChannel {
|
|
fn from_settings_value(value: Option<&str>) -> Self {
|
|
match crate::settings::effective_release_channel(value) {
|
|
"alpha" => Self::Alpha,
|
|
_ => Self::Stable,
|
|
}
|
|
}
|
|
|
|
fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Alpha => "alpha",
|
|
Self::Stable => "stable",
|
|
}
|
|
}
|
|
|
|
fn updater_endpoint(self) -> Result<Url, String> {
|
|
let endpoint = format!("{}/{}/latest.json", RELEASES_BASE_URL, self.as_str());
|
|
Url::parse(&endpoint).map_err(|e| format!("Invalid updater endpoint: {e}"))
|
|
}
|
|
}
|
|
|
|
impl AlphaReleaseVersion {
|
|
fn parse_tag(tag_name: &str) -> Option<Self> {
|
|
let release = tag_name.strip_prefix("alpha-v")?;
|
|
let (date, sequence) = release.split_once("-alpha.")?;
|
|
let sequence = sequence.parse().ok()?;
|
|
let (year, month, day) = parse_calendar_date(date)?;
|
|
chrono::NaiveDate::from_ymd_opt(year, month, day)?;
|
|
|
|
Some(Self {
|
|
year,
|
|
month,
|
|
day,
|
|
sequence,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn parse_calendar_date(value: &str) -> Option<(i32, u32, u32)> {
|
|
let mut parts = value.split('.');
|
|
let year = parts.next()?.parse().ok()?;
|
|
let month = parts.next()?.parse().ok()?;
|
|
let day = parts.next()?.parse().ok()?;
|
|
if parts.next().is_some() {
|
|
return None;
|
|
}
|
|
|
|
Some((year, month, day))
|
|
}
|
|
|
|
fn latest_alpha_release_metadata_url(releases: &[GitHubRelease]) -> Option<Url> {
|
|
releases
|
|
.iter()
|
|
.filter(|release| !release.draft)
|
|
.filter_map(alpha_release_metadata_candidate)
|
|
.max_by_key(|(version, _)| *version)
|
|
.map(|(_, url)| url)
|
|
}
|
|
|
|
fn alpha_release_metadata_candidate(release: &GitHubRelease) -> Option<(AlphaReleaseVersion, Url)> {
|
|
let version = AlphaReleaseVersion::parse_tag(&release.tag_name)?;
|
|
let asset = release
|
|
.assets
|
|
.iter()
|
|
.find(|asset| asset.name == ALPHA_METADATA_ASSET_NAME)?;
|
|
let url = Url::parse(&asset.browser_download_url).ok()?;
|
|
|
|
Some((version, url))
|
|
}
|
|
|
|
async fn alpha_release_metadata_endpoint() -> Result<Url, String> {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(UPDATER_HTTP_TIMEOUT)
|
|
.user_agent(UPDATER_USER_AGENT)
|
|
.build()
|
|
.map_err(|e| format!("Failed to create updater metadata client: {e}"))?;
|
|
|
|
let releases = client
|
|
.get(GITHUB_RELEASES_API_URL)
|
|
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Failed to fetch GitHub releases: {e}"))?
|
|
.error_for_status()
|
|
.map_err(|e| format!("GitHub releases request failed: {e}"))?
|
|
.json::<Vec<GitHubRelease>>()
|
|
.await
|
|
.map_err(|e| format!("Failed to parse GitHub releases: {e}"))?;
|
|
|
|
latest_alpha_release_metadata_url(&releases)
|
|
.ok_or_else(|| "No alpha updater metadata asset found in GitHub releases".to_string())
|
|
}
|
|
|
|
async fn updater_endpoint(release_channel: ReleaseChannel) -> Result<Url, String> {
|
|
match release_channel {
|
|
ReleaseChannel::Stable => release_channel.updater_endpoint(),
|
|
ReleaseChannel::Alpha => alpha_release_metadata_endpoint()
|
|
.await
|
|
.or_else(|_| release_channel.updater_endpoint()),
|
|
}
|
|
}
|
|
|
|
fn build_updater<R: Runtime>(
|
|
app_handle: &AppHandle<R>,
|
|
endpoint: Url,
|
|
) -> Result<tauri_plugin_updater::Updater, String> {
|
|
app_handle
|
|
.updater_builder()
|
|
.endpoints(vec![endpoint])
|
|
.map_err(|e| format!("Failed to configure updater endpoint: {e}"))?
|
|
.build()
|
|
.map_err(|e| format!("Failed to build updater: {e}"))
|
|
}
|
|
|
|
fn to_update_metadata(update: tauri_plugin_updater::Update) -> AppUpdateMetadata {
|
|
AppUpdateMetadata {
|
|
current_version: update.current_version,
|
|
version: update.version,
|
|
date: update.date.map(|value| value.to_string()),
|
|
body: update.body,
|
|
}
|
|
}
|
|
|
|
fn ensure_expected_update_version(
|
|
update: &tauri_plugin_updater::Update,
|
|
expected_version: &str,
|
|
) -> Result<(), String> {
|
|
if update.version == expected_version {
|
|
return Ok(());
|
|
}
|
|
|
|
Err(format!(
|
|
"Expected update version {}, found {}",
|
|
expected_version, update.version
|
|
))
|
|
}
|
|
|
|
async fn install_update(
|
|
update: tauri_plugin_updater::Update,
|
|
on_event: Channel<AppUpdateDownloadEvent>,
|
|
) -> Result<(), String> {
|
|
let mut started = false;
|
|
update
|
|
.download_and_install(
|
|
|chunk_length, content_length| {
|
|
if !started {
|
|
started = true;
|
|
let _ = on_event.send(AppUpdateDownloadEvent::Started { content_length });
|
|
}
|
|
|
|
let _ = on_event.send(AppUpdateDownloadEvent::Progress { chunk_length });
|
|
},
|
|
|| {
|
|
let _ = on_event.send(AppUpdateDownloadEvent::Finished);
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|e| format!("Failed to download and install update: {e}"))
|
|
}
|
|
|
|
pub async fn check_for_app_update<R: Runtime>(
|
|
app_handle: AppHandle<R>,
|
|
release_channel: Option<String>,
|
|
) -> Result<Option<AppUpdateMetadata>, String> {
|
|
let channel = ReleaseChannel::from_settings_value(release_channel.as_deref());
|
|
let updater = build_updater(&app_handle, updater_endpoint(channel).await?)?;
|
|
let update = updater
|
|
.check()
|
|
.await
|
|
.map_err(|e| format!("Failed to check for updates: {e}"))?;
|
|
|
|
Ok(update.map(to_update_metadata))
|
|
}
|
|
|
|
pub async fn download_and_install_app_update<R: Runtime>(
|
|
app_handle: AppHandle<R>,
|
|
release_channel: Option<String>,
|
|
expected_version: String,
|
|
on_event: Channel<AppUpdateDownloadEvent>,
|
|
) -> Result<(), String> {
|
|
let channel = ReleaseChannel::from_settings_value(release_channel.as_deref());
|
|
let updater = build_updater(&app_handle, updater_endpoint(channel).await?)?;
|
|
let update = updater
|
|
.check()
|
|
.await
|
|
.map_err(|e| format!("Failed to refresh update metadata: {e}"))?
|
|
.ok_or_else(|| "No update is currently available".to_string())?;
|
|
|
|
ensure_expected_update_version(&update, &expected_version)?;
|
|
|
|
install_update(update, on_event).await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
latest_alpha_release_metadata_url, AppUpdateDownloadEvent, AppUpdateMetadata, GitHubAsset,
|
|
GitHubRelease, ReleaseChannel,
|
|
};
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn release_channel_defaults_to_stable() {
|
|
assert_eq!(
|
|
ReleaseChannel::from_settings_value(None),
|
|
ReleaseChannel::Stable
|
|
);
|
|
assert_eq!(
|
|
ReleaseChannel::from_settings_value(Some("stable")),
|
|
ReleaseChannel::Stable
|
|
);
|
|
assert_eq!(
|
|
ReleaseChannel::from_settings_value(Some("beta")),
|
|
ReleaseChannel::Stable
|
|
);
|
|
assert_eq!(
|
|
ReleaseChannel::from_settings_value(Some("invalid")),
|
|
ReleaseChannel::Stable
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn release_channel_accepts_alpha() {
|
|
assert_eq!(
|
|
ReleaseChannel::from_settings_value(Some("alpha")),
|
|
ReleaseChannel::Alpha
|
|
);
|
|
assert_eq!(
|
|
ReleaseChannel::from_settings_value(Some(" alpha ")),
|
|
ReleaseChannel::Alpha
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn release_channel_endpoints_match_expected_paths() {
|
|
assert_eq!(
|
|
ReleaseChannel::Alpha.updater_endpoint().unwrap().as_str(),
|
|
"https://refactoringhq.github.io/tolaria/alpha/latest.json"
|
|
);
|
|
assert_eq!(
|
|
ReleaseChannel::Stable.updater_endpoint().unwrap().as_str(),
|
|
"https://refactoringhq.github.io/tolaria/stable/latest.json"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn alpha_release_metadata_url_uses_highest_calendar_sequence_tag() {
|
|
let releases = vec![
|
|
github_alpha_release(
|
|
"alpha-v2026.5.8-alpha.0007",
|
|
"https://github.com/refactoringhq/tolaria/releases/download/alpha-v2026.5.8-alpha.0007/alpha-latest.json",
|
|
),
|
|
github_alpha_release(
|
|
"alpha-v2026.5.8-alpha.0017",
|
|
"https://github.com/refactoringhq/tolaria/releases/download/alpha-v2026.5.8-alpha.0017/alpha-latest.json",
|
|
),
|
|
github_alpha_release(
|
|
"alpha-v2026.5.7-alpha.0099",
|
|
"https://github.com/refactoringhq/tolaria/releases/download/alpha-v2026.5.7-alpha.0099/alpha-latest.json",
|
|
),
|
|
];
|
|
|
|
assert_eq!(
|
|
latest_alpha_release_metadata_url(&releases)
|
|
.unwrap()
|
|
.as_str(),
|
|
"https://github.com/refactoringhq/tolaria/releases/download/alpha-v2026.5.8-alpha.0017/alpha-latest.json"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn alpha_release_metadata_url_ignores_drafts_and_non_alpha_assets() {
|
|
let releases = vec![
|
|
GitHubRelease {
|
|
tag_name: "alpha-v2026.5.8-alpha.0018".into(),
|
|
draft: true,
|
|
assets: vec![GitHubAsset {
|
|
name: "alpha-latest.json".into(),
|
|
browser_download_url: "https://example.com/draft.json".into(),
|
|
}],
|
|
},
|
|
GitHubRelease {
|
|
tag_name: "stable-v2026.5.8".into(),
|
|
draft: false,
|
|
assets: vec![GitHubAsset {
|
|
name: "stable-latest.json".into(),
|
|
browser_download_url: "https://example.com/stable.json".into(),
|
|
}],
|
|
},
|
|
github_alpha_release(
|
|
"alpha-v2026.5.8-alpha.0017",
|
|
"https://example.com/alpha-latest.json",
|
|
),
|
|
];
|
|
|
|
assert_eq!(
|
|
latest_alpha_release_metadata_url(&releases)
|
|
.unwrap()
|
|
.as_str(),
|
|
"https://example.com/alpha-latest.json"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn update_metadata_serializes_for_frontend_consumers() {
|
|
let metadata = AppUpdateMetadata {
|
|
current_version: "2026.4.1".into(),
|
|
version: "2026.4.2".into(),
|
|
date: Some("2026-04-30T12:00:00Z".into()),
|
|
body: Some("Bug fixes".into()),
|
|
};
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(metadata).unwrap(),
|
|
json!({
|
|
"currentVersion": "2026.4.1",
|
|
"version": "2026.4.2",
|
|
"date": "2026-04-30T12:00:00Z",
|
|
"body": "Bug fixes"
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn download_events_serialize_as_tagged_frontend_events() {
|
|
let events = [
|
|
(
|
|
AppUpdateDownloadEvent::Started {
|
|
content_length: Some(4096),
|
|
},
|
|
json!({
|
|
"event": "Started",
|
|
"data": { "contentLength": 4096 }
|
|
}),
|
|
),
|
|
(
|
|
AppUpdateDownloadEvent::Progress { chunk_length: 512 },
|
|
json!({
|
|
"event": "Progress",
|
|
"data": { "chunkLength": 512 }
|
|
}),
|
|
),
|
|
(
|
|
AppUpdateDownloadEvent::Finished,
|
|
json!({ "event": "Finished" }),
|
|
),
|
|
];
|
|
|
|
for (event, expected) in events {
|
|
assert_eq!(serde_json::to_value(event).unwrap(), expected);
|
|
}
|
|
}
|
|
|
|
fn github_alpha_release(tag_name: &str, browser_download_url: &str) -> GitHubRelease {
|
|
GitHubRelease {
|
|
tag_name: tag_name.into(),
|
|
draft: false,
|
|
assets: vec![GitHubAsset {
|
|
name: "alpha-latest.json".into(),
|
|
browser_download_url: browser_download_url.into(),
|
|
}],
|
|
}
|
|
}
|
|
}
|