Tauri decides dev-vs-production from the `custom-protocol` feature, not from --release, and both workflows build the setup exe with a plain `cargo build`. So cfg(dev) stayed on, the frontend was never embedded, and the window a tester opened loaded devUrl: "localhost refused to connect" on a blank 800x580. The feature is a default of the crate now, with a build.rs guard that fails a release build without it, and build.rs tracks setup/dist so a changed window cannot be left out of the binary it is supposed to be in. Behind that, the next wall: /releases/latest answers only with full releases, and every release here is an alpha, so it 404s. The setup app read that as a refused key and the app's Stable channel read it as a token that cannot see the repository — both have been broken since the first alpha. Each now lists /releases and takes the newest that is neither a draft nor the rolling nightly, which the list really does put first. And check_saved_key threw the saved key away on any failure at all, a dropped connection included. The entry it deletes is the app's own updater token, so only a refusal clears it now. The release notes name the file to hand a tester: six assets on the page and nothing saying which, while the bare NSIS installer is the biggest of them and installs with no key gate and no signature check.
556 lines
20 KiB
Rust
556 lines
20 KiB
Rust
//! The in-app updater: checks a release manifest, offers what it finds, and
|
|
//! hands the installer to `tauri-plugin-updater` (signature verified against
|
|
//! the key baked into tauri.conf.json). Two channels — `stable`, the latest
|
|
//! GitHub release, and `nightly`, a rolling prerelease rebuilt from `main`
|
|
//! by `.github/workflows/nightly.yml` — with the manifest URLs overridable
|
|
//! in config (a mirror, a separate public releases repo, a Gitea). Nothing
|
|
//! is ever installed without the user pressing Install: the automatic
|
|
//! check only records what it found and tells the webview.
|
|
//!
|
|
//! PRIVATE RELEASES (the closed alpha): the repository is private, and a
|
|
//! private release cannot be read without a token. With a read-only token
|
|
//! saved in the Credential Manager (Settings → Updates → Access token), a
|
|
//! GitHub source is resolved through the API instead of the browser URLs:
|
|
//! `GET /repos/{o}/{r}/releases/tags/{tag}` for a pinned tag, and for
|
|
//! `latest` a `GET /repos/{o}/{r}/releases` whose first entry that is
|
|
//! neither a draft nor the rolling `nightly` wins. NOT `/releases/latest`:
|
|
//! that answers only with full releases, and every release here is an
|
|
//! alpha, so it 404s. Either way the `latest.json` asset is found, the
|
|
//! plugin fetches THAT asset URL with
|
|
//! `Authorization: Bearer` + `Accept: application/octet-stream`, and the
|
|
//! manifest's own download URLs are API asset URLs too — the workflows
|
|
//! rewrite them after publishing — so the installer downloads with the same
|
|
//! headers. Any non-GitHub source simply gets the token as a bearer header.
|
|
//! Without a token the public browser URLs are used, unchanged.
|
|
//!
|
|
//! Versions: a nightly is `X.Y.Z-nightly.YYYYMMDD.sha`, so on the nightly
|
|
//! channel any different build is an update; on stable, newer wins, and a
|
|
//! machine that was on nightly is offered the stable with the same base
|
|
//! number or higher (semver would call that a downgrade and refuse).
|
|
|
|
use std::sync::Mutex;
|
|
use std::time::Duration;
|
|
|
|
use serde::Serialize;
|
|
use tauri::{AppHandle, Emitter, Manager, State, Url};
|
|
use tauri_plugin_updater::{Update, UpdaterExt};
|
|
|
|
use crate::config;
|
|
|
|
pub const DEFAULT_STABLE: &str =
|
|
"https://github.com/KeelrLeek/SoulStream/releases/latest/download/latest.json";
|
|
pub const DEFAULT_NIGHTLY: &str =
|
|
"https://github.com/KeelrLeek/SoulStream/releases/download/nightly/latest.json";
|
|
/// The tag `nightly.yml` rewrites daily. Never what `latest` should resolve to.
|
|
const NIGHTLY_TAG: &str = "nightly";
|
|
|
|
/// First automatic check after launch, then the interval between checks.
|
|
const FIRST_CHECK: Duration = Duration::from_secs(25);
|
|
const CHECK_EVERY: Duration = Duration::from_secs(6 * 60 * 60);
|
|
|
|
#[derive(Default)]
|
|
pub struct UpdaterState {
|
|
inner: Mutex<Inner>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct Inner {
|
|
found: Option<Update>,
|
|
checking: bool,
|
|
installing: bool,
|
|
last_check: Option<i64>,
|
|
last_error: Option<String>,
|
|
progress: Option<Progress>,
|
|
}
|
|
|
|
#[derive(Serialize, Clone, Copy)]
|
|
pub struct Progress {
|
|
pub received: u64,
|
|
pub total: Option<u64>,
|
|
}
|
|
|
|
#[derive(Serialize, Clone)]
|
|
pub struct Available {
|
|
pub version: String,
|
|
pub notes: Option<String>,
|
|
pub date: Option<i64>,
|
|
}
|
|
|
|
/// What the Settings card and the toast read.
|
|
#[derive(Serialize, Clone)]
|
|
pub struct Status {
|
|
pub version: String,
|
|
pub channel: String,
|
|
pub auto_check: bool,
|
|
pub endpoint_stable: String,
|
|
pub endpoint_nightly: String,
|
|
pub default_stable: String,
|
|
pub default_nightly: String,
|
|
/// A read-only token for a private source is saved (never the token).
|
|
pub has_token: bool,
|
|
/// "github" when the current channel's source is a GitHub releases URL
|
|
/// (resolved through the API when a token is set), else "raw".
|
|
pub source_kind: String,
|
|
pub available: Option<Available>,
|
|
pub checking: bool,
|
|
pub installing: bool,
|
|
pub last_check: Option<i64>,
|
|
pub last_error: Option<String>,
|
|
pub progress: Option<Progress>,
|
|
/// A dev build run from the target dir has nothing to update in place.
|
|
pub dev_build: bool,
|
|
}
|
|
|
|
fn now() -> i64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs() as i64)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
pub fn channel_of(cfg: &config::AppConfig) -> String {
|
|
match cfg.update_channel.as_deref() {
|
|
Some("nightly") => "nightly".into(),
|
|
_ => "stable".into(),
|
|
}
|
|
}
|
|
|
|
pub fn auto_check(cfg: &config::AppConfig) -> bool {
|
|
cfg.update_auto_check.unwrap_or(true)
|
|
}
|
|
|
|
fn endpoint(cfg: &config::AppConfig, channel: &str) -> String {
|
|
let pick = |v: &Option<String>, d: &str| {
|
|
v.as_deref()
|
|
.map(str::trim)
|
|
.filter(|s| !s.is_empty())
|
|
.map(String::from)
|
|
.unwrap_or_else(|| d.to_string())
|
|
};
|
|
if channel == "nightly" {
|
|
pick(&cfg.update_endpoint_nightly, DEFAULT_NIGHTLY)
|
|
} else {
|
|
pick(&cfg.update_endpoint_stable, DEFAULT_STABLE)
|
|
}
|
|
}
|
|
|
|
/// A GitHub releases URL, taken apart: `github.com/{owner}/{repo}/releases/
|
|
/// latest/download/<file>` or `…/releases/download/{tag}/<file>`.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub struct GitHubSource {
|
|
pub owner: String,
|
|
pub repo: String,
|
|
/// `latest`, or `tags/<tag>` — the API path segment.
|
|
pub release: String,
|
|
pub file: String,
|
|
}
|
|
|
|
pub fn github_source(url: &Url) -> Option<GitHubSource> {
|
|
if url.host_str()? != "github.com" {
|
|
return None;
|
|
}
|
|
let seg: Vec<&str> = url.path_segments()?.filter(|s| !s.is_empty()).collect();
|
|
match seg.as_slice() {
|
|
[owner, repo, "releases", "latest", "download", file] => Some(GitHubSource {
|
|
owner: owner.to_string(),
|
|
repo: repo.to_string(),
|
|
release: "latest".into(),
|
|
file: file.to_string(),
|
|
}),
|
|
[owner, repo, "releases", "download", tag, file] => Some(GitHubSource {
|
|
owner: owner.to_string(),
|
|
repo: repo.to_string(),
|
|
release: format!("tags/{tag}"),
|
|
file: file.to_string(),
|
|
}),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn status(app: &AppHandle) -> Status {
|
|
let cfg = config::load(app);
|
|
let channel = channel_of(&cfg);
|
|
let ep = endpoint(&cfg, &channel);
|
|
let source_kind = match Url::parse(&ep).ok().and_then(|u| github_source(&u)) {
|
|
Some(_) => "github",
|
|
None => "raw",
|
|
};
|
|
let st = app.state::<UpdaterState>();
|
|
let inner = st.inner.lock().unwrap();
|
|
Status {
|
|
version: app.package_info().version.to_string(),
|
|
endpoint_stable: endpoint(&cfg, "stable"),
|
|
endpoint_nightly: endpoint(&cfg, "nightly"),
|
|
default_stable: DEFAULT_STABLE.into(),
|
|
default_nightly: DEFAULT_NIGHTLY.into(),
|
|
has_token: config::load_update_token().is_some(),
|
|
source_kind: source_kind.into(),
|
|
auto_check: auto_check(&cfg),
|
|
channel,
|
|
available: inner.found.as_ref().map(|u| Available {
|
|
version: u.version.clone(),
|
|
notes: u.body.clone(),
|
|
date: u.date.map(|d| d.unix_timestamp()),
|
|
}),
|
|
checking: inner.checking,
|
|
installing: inner.installing,
|
|
last_check: inner.last_check,
|
|
last_error: inner.last_error.clone(),
|
|
progress: inner.progress,
|
|
dev_build: cfg!(debug_assertions),
|
|
}
|
|
}
|
|
|
|
fn announce(app: &AppHandle) {
|
|
let _ = app.emit("updater:status", status(app));
|
|
}
|
|
|
|
/// One authenticated GitHub API GET, its failures already in words.
|
|
async fn github_json(
|
|
client: &reqwest::Client,
|
|
url: &str,
|
|
token: &str,
|
|
user_agent: &str,
|
|
) -> Result<serde_json::Value, String> {
|
|
let resp = client
|
|
.get(url)
|
|
.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"))
|
|
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
|
|
.header(reqwest::header::USER_AGENT, user_agent)
|
|
.header("X-GitHub-Api-Version", "2022-11-28")
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("could not reach the GitHub API: {e}"))?;
|
|
match resp.status().as_u16() {
|
|
200 => {}
|
|
401 => return Err("the access token was refused".into()),
|
|
403 => return Err("the access token cannot read that repository".into()),
|
|
404 => return Err("no release found — or the token cannot see the repository".into()),
|
|
s => return Err(format!("GitHub answered {s}")),
|
|
}
|
|
resp.json().await.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Ask the GitHub API where a private release keeps its `latest.json`.
|
|
async fn resolve_github_asset(
|
|
src: &GitHubSource,
|
|
token: &str,
|
|
user_agent: &str,
|
|
) -> Result<Url, String> {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(30))
|
|
.build()
|
|
.map_err(|e| e.to_string())?;
|
|
let repo = format!("https://api.github.com/repos/{}/{}", src.owner, src.repo);
|
|
let body = if src.release == "latest" {
|
|
// `/releases/latest` answers with the newest FULL release, and every
|
|
// SoulStream release so far is a prerelease — so it 404s and Stable
|
|
// reports the token cannot see the repository. The list comes newest
|
|
// first: take the first that is neither a draft nor the rolling
|
|
// nightly, which the Nightly channel asks for by tag instead.
|
|
let list = github_json(&client, &format!("{repo}/releases?per_page=30"), token, user_agent)
|
|
.await?;
|
|
list.as_array()
|
|
.and_then(|rs| {
|
|
rs.iter()
|
|
.find(|r| {
|
|
r["draft"].as_bool() != Some(true)
|
|
&& r["tag_name"].as_str() != Some(NIGHTLY_TAG)
|
|
})
|
|
.cloned()
|
|
})
|
|
.ok_or("no release found — or the token cannot see the repository")?
|
|
} else {
|
|
github_json(
|
|
&client,
|
|
&format!("{repo}/releases/{}", src.release),
|
|
token,
|
|
user_agent,
|
|
)
|
|
.await?
|
|
};
|
|
let assets = body["assets"].as_array().cloned().unwrap_or_default();
|
|
let asset = assets
|
|
.iter()
|
|
.find(|a| a["name"].as_str() == Some(src.file.as_str()))
|
|
.ok_or_else(|| format!("the release has no {}", src.file))?;
|
|
let url = asset["url"].as_str().ok_or("asset without a URL")?;
|
|
Url::parse(url).map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// One check against the current channel's manifest. Records the result
|
|
/// (found update, time, error) and announces it; never installs.
|
|
async fn check_inner(app: &AppHandle) -> Result<(), String> {
|
|
let cfg = config::load(app);
|
|
let channel = channel_of(&cfg);
|
|
let nightly = channel == "nightly";
|
|
let ep = endpoint(&cfg, &channel);
|
|
let mut url = Url::parse(&ep).map_err(|e| format!("update source is not a URL: {e}"))?;
|
|
let token = config::load_update_token();
|
|
let user_agent = format!("SoulStream/{}", app.package_info().version);
|
|
let mut headers = reqwest::header::HeaderMap::new();
|
|
headers.insert(
|
|
reqwest::header::USER_AGENT,
|
|
user_agent.parse().map_err(|_| "user agent")?,
|
|
);
|
|
let gh = github_source(&url);
|
|
if let Some(t) = &token {
|
|
headers.insert(
|
|
reqwest::header::AUTHORIZATION,
|
|
format!("Bearer {t}").parse().map_err(|_| "token has characters a header cannot carry")?,
|
|
);
|
|
if let Some(src) = &gh {
|
|
url = resolve_github_asset(src, t, &user_agent).await?;
|
|
}
|
|
}
|
|
if gh.is_some() {
|
|
// Asset URLs (the API kind the workflows write into the manifest, and
|
|
// the one resolved above) hand over bytes only for this Accept; the
|
|
// browser URLs do not mind it.
|
|
headers.insert(
|
|
reqwest::header::ACCEPT,
|
|
"application/octet-stream".parse().map_err(|_| "accept")?,
|
|
);
|
|
}
|
|
let updater = app
|
|
.updater_builder()
|
|
.endpoints(vec![url])
|
|
.map_err(|e| e.to_string())?
|
|
.headers(headers)
|
|
.timeout(Duration::from_secs(30))
|
|
.version_comparator(move |current, release| {
|
|
if nightly {
|
|
// Every nightly carries its date and commit: a different
|
|
// build is an update, older number or not.
|
|
return release.version != current;
|
|
}
|
|
if release.version > current {
|
|
return true;
|
|
}
|
|
// Back from nightly to stable: the release with the nightly's
|
|
// base number, or higher, is the way home.
|
|
if current.pre.as_str().starts_with("nightly") && release.version.pre.is_empty() {
|
|
let base = |v: &semver::Version| (v.major, v.minor, v.patch);
|
|
return base(&release.version) >= base(¤t);
|
|
}
|
|
false
|
|
})
|
|
.build()
|
|
.map_err(|e| e.to_string())?;
|
|
let result = updater.check().await;
|
|
let st = app.state::<UpdaterState>();
|
|
let mut inner = st.inner.lock().unwrap();
|
|
inner.last_check = Some(now());
|
|
inner.checking = false;
|
|
match result {
|
|
Ok(found) => {
|
|
if let Some(u) = &found {
|
|
log::info!("updater: {} {} available on {channel}", u.version, u.target);
|
|
} else {
|
|
log::info!("updater: up to date on {channel}");
|
|
}
|
|
inner.found = found;
|
|
inner.last_error = None;
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
let mut msg = e.to_string();
|
|
if token.is_none() && gh.is_some() && msg.contains("valid release JSON") {
|
|
msg.push_str(" — a private repository needs an access token (Settings → Updates)");
|
|
}
|
|
log::warn!("updater: check on {channel} failed: {msg}");
|
|
inner.found = None;
|
|
inner.last_error = Some(msg.clone());
|
|
Err(msg)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn run_check(app: &AppHandle) -> Result<(), String> {
|
|
{
|
|
let st = app.state::<UpdaterState>();
|
|
let mut inner = st.inner.lock().unwrap();
|
|
if inner.checking || inner.installing {
|
|
return Ok(());
|
|
}
|
|
inner.checking = true;
|
|
}
|
|
announce(app);
|
|
let r = match check_inner(app).await {
|
|
Ok(()) => Ok(()),
|
|
Err(e) => {
|
|
// check_inner records its own errors; a failure before the
|
|
// check ran (a bad URL, the API) lands here.
|
|
let st = app.state::<UpdaterState>();
|
|
let mut inner = st.inner.lock().unwrap();
|
|
inner.last_check = Some(now());
|
|
inner.found = None;
|
|
inner.last_error = Some(e.clone());
|
|
Err(e)
|
|
}
|
|
};
|
|
{
|
|
let st = app.state::<UpdaterState>();
|
|
st.inner.lock().unwrap().checking = false;
|
|
}
|
|
announce(app);
|
|
r
|
|
}
|
|
|
|
/// The automatic checks: one shortly after launch, then every six hours,
|
|
/// as long as the setting allows. Finding one only announces it.
|
|
pub fn init(app: &AppHandle) {
|
|
let app = app.clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
tokio::time::sleep(FIRST_CHECK).await;
|
|
loop {
|
|
if auto_check(&config::load(&app)) {
|
|
let _ = run_check(&app).await;
|
|
}
|
|
tokio::time::sleep(CHECK_EVERY).await;
|
|
}
|
|
});
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn updater_status(app: AppHandle) -> Status {
|
|
status(&app)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn updater_check(app: AppHandle) -> Result<Status, String> {
|
|
run_check(&app).await?;
|
|
Ok(status(&app))
|
|
}
|
|
|
|
/// Channel, automatic checking, the two manifest URLs, and the access token
|
|
/// (empty clears it; it lives in the Credential Manager). A channel change
|
|
/// forgets whatever the old channel found.
|
|
#[tauri::command]
|
|
pub fn updater_set(
|
|
app: AppHandle,
|
|
state: State<'_, UpdaterState>,
|
|
channel: Option<String>,
|
|
auto_check: Option<bool>,
|
|
endpoint_stable: Option<String>,
|
|
endpoint_nightly: Option<String>,
|
|
token: Option<String>,
|
|
) -> Status {
|
|
let mut cfg = config::load(&app);
|
|
if let Some(c) = channel {
|
|
let c = if c == "nightly" { "nightly" } else { "stable" };
|
|
if channel_of(&cfg) != c {
|
|
let mut inner = state.inner.lock().unwrap();
|
|
inner.found = None;
|
|
inner.last_error = None;
|
|
inner.last_check = None;
|
|
}
|
|
cfg.update_channel = Some(c.to_string());
|
|
}
|
|
if let Some(a) = auto_check {
|
|
cfg.update_auto_check = Some(a);
|
|
}
|
|
if let Some(e) = endpoint_stable {
|
|
cfg.update_endpoint_stable = Some(e.trim().to_string()).filter(|s| !s.is_empty());
|
|
}
|
|
if let Some(e) = endpoint_nightly {
|
|
cfg.update_endpoint_nightly = Some(e.trim().to_string()).filter(|s| !s.is_empty());
|
|
}
|
|
if let Some(t) = token {
|
|
let t = t.trim();
|
|
if t.is_empty() {
|
|
config::clear_update_token();
|
|
} else {
|
|
config::save_update_token(t);
|
|
}
|
|
// A new key to the door: the last answer no longer applies.
|
|
let mut inner = state.inner.lock().unwrap();
|
|
inner.last_error = None;
|
|
}
|
|
config::save(&app, &cfg);
|
|
let s = status(&app);
|
|
let _ = app.emit("updater:status", s.clone());
|
|
s
|
|
}
|
|
|
|
/// Download the found update and run its installer. On Windows the plugin
|
|
/// starts the installer (passive mode, per tauri.conf.json) and exits this
|
|
/// process, so anything after a success here never runs.
|
|
#[tauri::command]
|
|
pub async fn updater_install(app: AppHandle) -> Result<(), String> {
|
|
let update = {
|
|
let st = app.state::<UpdaterState>();
|
|
let mut inner = st.inner.lock().unwrap();
|
|
if inner.installing {
|
|
return Err("already installing".into());
|
|
}
|
|
let Some(u) = inner.found.clone() else {
|
|
return Err("no update has been found — check first".into());
|
|
};
|
|
inner.installing = true;
|
|
inner.progress = Some(Progress { received: 0, total: None });
|
|
u
|
|
};
|
|
announce(&app);
|
|
let progress_app = app.clone();
|
|
let mut received: u64 = 0;
|
|
let mut last_emit = std::time::Instant::now();
|
|
let result = update
|
|
.download_and_install(
|
|
move |chunk, total| {
|
|
received += chunk as u64;
|
|
if last_emit.elapsed() >= Duration::from_millis(150) {
|
|
last_emit = std::time::Instant::now();
|
|
let _ = progress_app.emit(
|
|
"updater:progress",
|
|
Progress { received, total },
|
|
);
|
|
}
|
|
},
|
|
|| {
|
|
log::info!("updater: download complete, handing over to the installer");
|
|
},
|
|
)
|
|
.await;
|
|
let st = app.state::<UpdaterState>();
|
|
let mut inner = st.inner.lock().unwrap();
|
|
inner.installing = false;
|
|
inner.progress = None;
|
|
match result {
|
|
Ok(()) => Ok(()),
|
|
Err(e) => {
|
|
let msg = e.to_string();
|
|
log::warn!("updater: install failed: {msg}");
|
|
inner.last_error = Some(msg.clone());
|
|
drop(inner);
|
|
announce(&app);
|
|
Err(msg)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn github_urls_are_taken_apart() {
|
|
let u = Url::parse(DEFAULT_STABLE).unwrap();
|
|
assert_eq!(
|
|
github_source(&u),
|
|
Some(GitHubSource {
|
|
owner: "KeelrLeek".into(),
|
|
repo: "SoulStream".into(),
|
|
release: "latest".into(),
|
|
file: "latest.json".into()
|
|
})
|
|
);
|
|
let u = Url::parse(DEFAULT_NIGHTLY).unwrap();
|
|
assert_eq!(github_source(&u).unwrap().release, "tags/nightly");
|
|
let u = Url::parse("https://git.whoagland.com/Keelrleek/SoulStream/releases/download/v1/latest.json").unwrap();
|
|
assert_eq!(github_source(&u), None);
|
|
let u = Url::parse("https://github.com/KeelrLeek/SoulStream/releases").unwrap();
|
|
assert_eq!(github_source(&u), None);
|
|
}
|
|
}
|