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:
KeelrLeek committed 2026-09-09 16:16:19 -05:00
1 parent 07810f7543
commit cd2391198e
19 files changed
+2010 -15

No files matched your search

+2
View File
@@ -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

+7
View File
@@ -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
+46
View File
@@ -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
View File
@@ -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 {
+516
View File
@@ -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(&current);
}
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);
}
}
+24 -2
View File
@@ -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"
}
}
}