first run, updates, installer: a setup tour stands in front of a fresh install, releases arrive through an in-app updater with stable and nightly channels behind a read-only token, and the branded installer builds again
This commit is contained in:
1 parent
07810f7543
commit
cd2391198e
19 files changed
+2010
-15
No files matched your search
@@ -56,6 +56,8 @@ tagger = { path = "../crates/tagger" }
|
||||
metadata = { path = "../crates/metadata" }
|
||||
library = { path = "../crates/library" }
|
||||
audio-formats = { path = "../crates/audio-formats" }
|
||||
tauri-plugin-updater = "2"
|
||||
semver = "1"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.61.3", features = [
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 151 KiB |
@@ -80,6 +80,8 @@ pub struct Settings {
|
||||
pub word_timing: bool,
|
||||
/// Dark-glass acrylic backdrop, default off.
|
||||
pub glass_mode: bool,
|
||||
/// The first-run tour has been completed or skipped on this install.
|
||||
pub setup_done: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -120,6 +122,7 @@ pub async fn get_settings(
|
||||
volume_normalization: config::volume_normalization(&cfg),
|
||||
word_timing: config::word_timing(&cfg),
|
||||
glass_mode: config::glass_mode(&cfg),
|
||||
setup_done: config::setup_done(&cfg),
|
||||
username: cfg.username,
|
||||
})
|
||||
}
|
||||
@@ -149,6 +152,7 @@ pub async fn set_settings(
|
||||
volume_normalization: Option<bool>,
|
||||
word_timing: Option<bool>,
|
||||
glass_mode: Option<bool>,
|
||||
setup_done: Option<bool>,
|
||||
) -> Result<(), String> {
|
||||
let mut cfg = config::load(&app);
|
||||
let library_folder_moved = download_dir
|
||||
@@ -223,6 +227,9 @@ pub async fn set_settings(
|
||||
if glass_mode.is_some() {
|
||||
cfg.glass_mode = glass_mode;
|
||||
}
|
||||
if setup_done.is_some() {
|
||||
cfg.setup_done = setup_done;
|
||||
}
|
||||
config::save(&app, &cfg);
|
||||
|
||||
// A new library folder is a new library. Re-base immediately rather than
|
||||
|
||||
@@ -175,6 +175,22 @@ pub struct AppConfig {
|
||||
/// of it is ever published or sent to the person it describes.
|
||||
#[serde(default)]
|
||||
pub contacts: Vec<ContactMeta>,
|
||||
/// The first-run setup has been completed, or skipped, on this install.
|
||||
/// None → false: a fresh install opens on the welcome tour before the
|
||||
/// app, and Settings → Account can run it again.
|
||||
#[serde(default)]
|
||||
pub setup_done: Option<bool>,
|
||||
/// The in-app updater (updater.rs). Channel "stable" (None) or
|
||||
/// "nightly"; auto-check None → true; the two endpoints override the
|
||||
/// built-in release URLs (a mirror, a public releases repo, a Gitea).
|
||||
#[serde(default)]
|
||||
pub update_channel: Option<String>,
|
||||
#[serde(default)]
|
||||
pub update_auto_check: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub update_endpoint_stable: Option<String>,
|
||||
#[serde(default)]
|
||||
pub update_endpoint_nightly: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
@@ -243,6 +259,10 @@ pub fn word_timing(cfg: &AppConfig) -> bool {
|
||||
cfg.word_timing.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn setup_done(cfg: &AppConfig) -> bool {
|
||||
cfg.setup_done.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// One-time migration after the identifier rename: the app-data dir moved
|
||||
/// from `com.trifecta.soulseek-plus` to the new identifier's folder. Rename
|
||||
/// the old dir into place so the library DB, config and stream cache
|
||||
@@ -479,6 +499,32 @@ fn entry(username: &str) -> Option<keyring::Entry> {
|
||||
keyring::Entry::new(KEYRING_SERVICE, username).ok()
|
||||
}
|
||||
|
||||
/// The updater's read-only token for a private release source (updater.rs),
|
||||
/// in the Credential Manager beside the Soulseek password: it is a
|
||||
/// credential, and config.json is a plain file that travels on the drive.
|
||||
const UPDATE_TOKEN_USER: &str = "updater-token";
|
||||
|
||||
pub fn save_update_token(token: &str) {
|
||||
if let Some(e) = entry(UPDATE_TOKEN_USER) {
|
||||
if let Err(err) = e.set_password(token) {
|
||||
log::warn!("failed to store the update token in credential manager: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_update_token() -> Option<String> {
|
||||
entry(UPDATE_TOKEN_USER)
|
||||
.and_then(|e| e.get_password().ok())
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
}
|
||||
|
||||
pub fn clear_update_token() {
|
||||
if let Some(e) = entry(UPDATE_TOKEN_USER) {
|
||||
let _ = e.delete_credential();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_password(username: &str, password: &str) {
|
||||
if let Some(e) = entry(username) {
|
||||
if let Err(err) = e.set_password(password) {
|
||||
|
||||
+20
-4
@@ -34,6 +34,7 @@ mod overlap;
|
||||
mod holdings;
|
||||
mod state;
|
||||
mod thumbbar;
|
||||
mod updater;
|
||||
mod vocal_sep;
|
||||
mod youtube_playlist;
|
||||
mod youtube_setlist;
|
||||
@@ -43,6 +44,7 @@ mod ytdlp;
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.manage(state::AppState::default())
|
||||
.manage(updater::UpdaterState::default())
|
||||
.setup(|app| {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
@@ -54,6 +56,7 @@ pub fn run() {
|
||||
.build(),
|
||||
)?;
|
||||
app.handle().plugin(tauri_plugin_dialog::init())?;
|
||||
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
|
||||
// Must run before anything touches the app-data dir.
|
||||
config::migrate_app_data(app.handle());
|
||||
// Prepare the small local vision model while the user is still on
|
||||
@@ -75,6 +78,9 @@ pub fn run() {
|
||||
// by default) and forwards adapter status to the webview. Fully
|
||||
// isolated — its absence or failure touches nothing else.
|
||||
freenet::init(app.handle());
|
||||
// Release checks: one shortly after launch, then every six hours,
|
||||
// never installing on their own (updater.rs).
|
||||
updater::init(app.handle());
|
||||
if let Err(e) = pipeline::init(app.handle()) {
|
||||
log::error!("library init failed: {e}");
|
||||
} else {
|
||||
@@ -90,6 +96,10 @@ pub fn run() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::app_status,
|
||||
updater::updater_status,
|
||||
updater::updater_check,
|
||||
updater::updater_set,
|
||||
updater::updater_install,
|
||||
commands::excluded_search_phrases,
|
||||
commands::get_credentials,
|
||||
commands::connect_and_login,
|
||||
@@ -370,11 +380,17 @@ pub fn run() {
|
||||
--autoplay-policy=no-user-gesture-required \
|
||||
--enable-smooth-scrolling \
|
||||
--force-color-profile=srgb";
|
||||
// One cfg over BOTH statements: the attribute on the `let cdp`
|
||||
// alone left the `format!` below reading a name that release
|
||||
// builds never define — every `tauri build` since 0.1.0-alpha.3
|
||||
// failed on it (found 2026-09-09 making the first installer).
|
||||
#[cfg(debug_assertions)]
|
||||
// A second instance needs its own CDP port, or it silently fails
|
||||
// to bind and cannot be driven at all.
|
||||
let cdp = std::env::var("SOULSTREAM_CDP_PORT").unwrap_or_else(|_| "9222".into());
|
||||
let args = format!("{base} --remote-debugging-port={cdp}");
|
||||
let args = {
|
||||
// A second instance needs its own CDP port, or it silently
|
||||
// fails to bind and cannot be driven at all.
|
||||
let cdp = std::env::var("SOULSTREAM_CDP_PORT").unwrap_or_else(|_| "9222".into());
|
||||
format!("{base} --remote-debugging-port={cdp}")
|
||||
};
|
||||
#[cfg(not(debug_assertions))]
|
||||
let args = base.to_string();
|
||||
for window in &mut context.config_mut().app.windows {
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
//! 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/latest` (or `/tags/{tag}`) finds the
|
||||
//! `latest.json` asset, 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";
|
||||
|
||||
/// 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));
|
||||
}
|
||||
|
||||
/// 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 api = format!(
|
||||
"https://api.github.com/repos/{}/{}/releases/{}",
|
||||
src.owner, src.repo, src.release
|
||||
);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let resp = client
|
||||
.get(&api)
|
||||
.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}")),
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,9 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["nsis"],
|
||||
"targets": [
|
||||
"nsis"
|
||||
],
|
||||
"resources": [
|
||||
"resources/FidelityFX_CLI.exe",
|
||||
"resources/FidelityFX-LICENSE.txt",
|
||||
@@ -67,7 +69,27 @@
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installMode": "currentUser",
|
||||
"startMenuFolder": "SoulStream"
|
||||
"startMenuFolder": "SoulStream",
|
||||
"installerIcon": "icons/icon.ico",
|
||||
"uninstallerIcon": "icons/icon.ico",
|
||||
"headerImage": "installer/header.bmp",
|
||||
"sidebarImage": "installer/sidebar.bmp",
|
||||
"languages": [
|
||||
"English"
|
||||
],
|
||||
"displayLanguageSelector": false
|
||||
}
|
||||
},
|
||||
"createUpdaterArtifacts": true
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQxOTQyMEVCN0QwMUJCNjgKUldSb3V3Rjk2eUNVUWJaZUpZcUt1VWlZbmo1Sm9tS01zMTAzSjJpdksrazQ2dVNGU0RxKzAreTIK",
|
||||
"endpoints": [
|
||||
"https://github.com/KeelrLeek/SoulStream/releases/latest/download/latest.json"
|
||||
],
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user