setup: the installer is our own window — the alpha key is the gate, the release comes down with it and is verified before the silent install, and the in-app tour asks for the key too
This commit is contained in:
1 parent
8093835482
commit
f8508da081
17 files changed
+1960
-13
No files matched your search
@@ -0,0 +1,485 @@
|
||||
//! SoulStream Setup — the file a tester runs. Our own window in the app's
|
||||
//! stack (a Tauri webview drawing the first-run tour's design). The alpha
|
||||
//! key is the gate: it is checked against GitHub first, and only a key that
|
||||
//! can read the private repository gets any further. The installer is then
|
||||
//! DOWNLOADED from the latest release with that key (never carried inside
|
||||
//! this exe, where it could be pulled out), its signature verified against
|
||||
//! the same public key the app's updater trusts, and run silently. The key
|
||||
//! goes to the Credential Manager where the app reads it, the choices into
|
||||
//! the app's config.json, and the app is launched.
|
||||
//!
|
||||
//! Without WebView2 this window cannot draw. main() checks the runtime
|
||||
//! first and, if it is missing, says so and leaves — the app itself needs
|
||||
//! WebView2 too, so there is nothing to install on such a machine yet.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod pubkey {
|
||||
include!(concat!(env!("OUT_DIR"), "/pubkey.rs"));
|
||||
}
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine;
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
const REPO: &str = "KeelrLeek/SoulStream";
|
||||
const APP_DATA_DIRNAME: &str = "com.soulstream.app";
|
||||
const KEYRING_SERVICE: &str = "com.soulstream.app";
|
||||
const KEYRING_TOKEN_USER: &str = "updater-token";
|
||||
const USER_AGENT: &str = concat!("SoulStream-Setup/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
fn app_data_dir() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join(APP_DATA_DIRNAME))
|
||||
}
|
||||
|
||||
fn config_path() -> Option<PathBuf> {
|
||||
app_data_dir().map(|d| d.join("config.json"))
|
||||
}
|
||||
|
||||
fn load_config() -> serde_json::Map<String, serde_json::Value> {
|
||||
config_path()
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
.and_then(|v| v.as_object().cloned())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save_config(cfg: &serde_json::Map<String, serde_json::Value>) -> Result<(), String> {
|
||||
let p = config_path().ok_or("no app data folder")?;
|
||||
if let Some(dir) = p.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(&serde_json::Value::Object(cfg.clone()))
|
||||
.map_err(|e| e.to_string())?;
|
||||
std::fs::write(&p, json).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn music_dir() -> PathBuf {
|
||||
dirs::audio_dir()
|
||||
.or_else(dirs::home_dir)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
fn keyring_entry() -> Option<keyring::Entry> {
|
||||
keyring::Entry::new(KEYRING_SERVICE, KEYRING_TOKEN_USER).ok()
|
||||
}
|
||||
|
||||
fn saved_key() -> Option<String> {
|
||||
keyring_entry()
|
||||
.and_then(|e| e.get_password().ok())
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
}
|
||||
|
||||
/// Where the installer put the app, from the uninstall entry it registers;
|
||||
/// the per-user default when there is none yet.
|
||||
fn install_location() -> Option<PathBuf> {
|
||||
use winreg::enums::HKEY_CURRENT_USER;
|
||||
use winreg::RegKey;
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
if let Ok(uninstall) = hkcu.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall") {
|
||||
for name in uninstall.enum_keys().flatten() {
|
||||
if let Ok(k) = uninstall.open_subkey(&name) {
|
||||
let display: String = k.get_value("DisplayName").unwrap_or_default();
|
||||
if display == "SoulStream" {
|
||||
if let Ok(loc) = k.get_value::<String, _>("InstallLocation") {
|
||||
let loc = loc.trim_matches('"');
|
||||
if !loc.is_empty() {
|
||||
return Some(PathBuf::from(loc));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dirs::data_local_dir().map(|d| d.join("SoulStream"))
|
||||
}
|
||||
|
||||
fn installed_version() -> Option<String> {
|
||||
use winreg::enums::HKEY_CURRENT_USER;
|
||||
use winreg::RegKey;
|
||||
let uninstall = RegKey::predef(HKEY_CURRENT_USER)
|
||||
.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall")
|
||||
.ok()?;
|
||||
for name in uninstall.enum_keys().flatten() {
|
||||
if let Ok(k) = uninstall.open_subkey(&name) {
|
||||
let display: String = k.get_value("DisplayName").unwrap_or_default();
|
||||
if display == "SoulStream" {
|
||||
return k.get_value::<String, _>("DisplayVersion").ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn app_exe() -> Option<PathBuf> {
|
||||
let dir = install_location()?;
|
||||
for name in ["SoulStream.exe", "app.exe"] {
|
||||
let p = dir.join(name);
|
||||
if p.is_file() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn webview2_present() -> bool {
|
||||
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
|
||||
use winreg::RegKey;
|
||||
const CLIENT: &str = r"Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}";
|
||||
const CLIENT_WOW: &str = r"SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}";
|
||||
for (hive, path) in [
|
||||
(HKEY_LOCAL_MACHINE, CLIENT_WOW),
|
||||
(HKEY_LOCAL_MACHINE, CLIENT),
|
||||
(HKEY_CURRENT_USER, CLIENT),
|
||||
] {
|
||||
if let Ok(k) = RegKey::predef(hive).open_subkey(path) {
|
||||
if let Ok(pv) = k.get_value::<String, _>("pv") {
|
||||
if !pv.trim().is_empty() && pv.trim() != "0.0.0.0" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- GitHub
|
||||
|
||||
/// The latest release as the key can see it: the installer and its
|
||||
/// signature, by their API asset URLs (a private repository's assets are
|
||||
/// reachable only that way).
|
||||
#[derive(Serialize, Clone)]
|
||||
struct Release {
|
||||
version: String,
|
||||
name: String,
|
||||
published: Option<String>,
|
||||
installer_name: String,
|
||||
installer_size: u64,
|
||||
installer_url: String,
|
||||
sig_url: Option<String>,
|
||||
}
|
||||
|
||||
fn client() -> Result<reqwest::Client, String> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(60))
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn fetch_release(token: &str) -> Result<Release, String> {
|
||||
let resp = client()?
|
||||
.get(format!("https://api.github.com/repos/{REPO}/releases/latest"))
|
||||
.bearer_auth(token)
|
||||
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Could not reach GitHub — is this computer online? ({e})"))?;
|
||||
match resp.status().as_u16() {
|
||||
200 => {}
|
||||
401 => return Err("That key was refused.".into()),
|
||||
403 => return Err("That key cannot read the SoulStream repository.".into()),
|
||||
404 => return Err("No release found — or that key cannot see the repository.".into()),
|
||||
s => return Err(format!("GitHub answered {s}.")),
|
||||
}
|
||||
let v: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||
let tag = v["tag_name"].as_str().unwrap_or("");
|
||||
let assets = v["assets"].as_array().cloned().unwrap_or_default();
|
||||
let find = |suffix: &str| {
|
||||
assets
|
||||
.iter()
|
||||
.find(|a| a["name"].as_str().map_or(false, |n| n.ends_with(suffix)))
|
||||
.cloned()
|
||||
};
|
||||
let installer = find("_x64-setup.exe").ok_or("The release carries no installer.")?;
|
||||
let sig = find("_x64-setup.exe.sig");
|
||||
Ok(Release {
|
||||
version: tag.trim_start_matches('v').to_string(),
|
||||
name: v["name"].as_str().unwrap_or(tag).to_string(),
|
||||
published: v["published_at"].as_str().map(String::from),
|
||||
installer_name: installer["name"].as_str().unwrap_or("SoulStream-setup.exe").to_string(),
|
||||
installer_size: installer["size"].as_u64().unwrap_or(0),
|
||||
installer_url: installer["url"].as_str().unwrap_or("").to_string(),
|
||||
sig_url: sig.and_then(|a| a["url"].as_str().map(String::from)),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct Phase {
|
||||
phase: String,
|
||||
detail: String,
|
||||
received: u64,
|
||||
total: u64,
|
||||
}
|
||||
|
||||
fn phase(app: &AppHandle, name: &str, detail: impl Into<String>, received: u64, total: u64) {
|
||||
let _ = app.emit(
|
||||
"setup:phase",
|
||||
Phase { phase: name.into(), detail: detail.into(), received, total },
|
||||
);
|
||||
}
|
||||
|
||||
/// An asset, streamed down with the key; progress goes to the window.
|
||||
async fn download(app: &AppHandle, token: &str, url: &str, total: u64, label: &str) -> Result<Vec<u8>, String> {
|
||||
let mut resp = client()?
|
||||
.get(url)
|
||||
.bearer_auth(token)
|
||||
.header(reqwest::header::ACCEPT, "application/octet-stream")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("The download failed to start: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("The download was refused ({}).", resp.status().as_u16()));
|
||||
}
|
||||
let total = resp.content_length().unwrap_or(total);
|
||||
let mut bytes = Vec::with_capacity(total as usize);
|
||||
let mut last = std::time::Instant::now();
|
||||
while let Some(chunk) = resp.chunk().await.map_err(|e| format!("The download broke off: {e}"))? {
|
||||
bytes.extend_from_slice(&chunk);
|
||||
if last.elapsed() >= Duration::from_millis(120) {
|
||||
last = std::time::Instant::now();
|
||||
phase(app, "downloading", label, bytes.len() as u64, total);
|
||||
}
|
||||
}
|
||||
phase(app, "downloading", label, bytes.len() as u64, total);
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// The updater plugin's check, verbatim: the pubkey and the signature are
|
||||
/// base64 wrappings of minisign's text formats.
|
||||
fn verify_signature(data: &[u8], signature_b64: &str) -> Result<(), String> {
|
||||
let decode = |s: &str| -> Result<String, String> {
|
||||
let raw = base64::engine::general_purpose::STANDARD
|
||||
.decode(s.trim())
|
||||
.map_err(|e| format!("signature is not base64: {e}"))?;
|
||||
String::from_utf8(raw).map_err(|_| "signature is not text".to_string())
|
||||
};
|
||||
let public_key = minisign_verify::PublicKey::decode(&decode(pubkey::PUBKEY)?)
|
||||
.map_err(|e| format!("public key: {e}"))?;
|
||||
let signature = minisign_verify::Signature::decode(&decode(signature_b64)?)
|
||||
.map_err(|e| format!("signature: {e}"))?;
|
||||
public_key
|
||||
.verify(data, &signature, true)
|
||||
.map_err(|e| format!("the installer does not match its signature: {e}"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- commands
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Defaults {
|
||||
version: String,
|
||||
music_dir: String,
|
||||
library_dir: String,
|
||||
library_layout: String,
|
||||
glass_mode: bool,
|
||||
loudness: bool,
|
||||
has_token: bool,
|
||||
installed_version: Option<String>,
|
||||
install_location: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn defaults() -> Defaults {
|
||||
let cfg = load_config();
|
||||
let music = music_dir();
|
||||
let library_dir = cfg
|
||||
.get("download_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| {
|
||||
// The app's own default, legacy name honoured.
|
||||
let legacy = music.join("SoulSeek+");
|
||||
if legacy.is_dir() { legacy } else { music.join("SoulStream") }
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
});
|
||||
let library_layout = cfg
|
||||
.get("library_layout")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| "{artist}\\{album}".into());
|
||||
Defaults {
|
||||
version: env!("CARGO_PKG_VERSION").into(),
|
||||
music_dir: music.to_string_lossy().into_owned(),
|
||||
library_dir,
|
||||
library_layout,
|
||||
glass_mode: cfg.get("glass_mode").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
loudness: cfg.get("volume_normalization").and_then(|v| v.as_bool()).unwrap_or(true),
|
||||
has_token: saved_key().is_some(),
|
||||
installed_version: installed_version(),
|
||||
install_location: install_location().map(|p| p.to_string_lossy().into_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The gate. A key that can read the release is saved to the Credential
|
||||
/// Manager and answered with what it would install; any other is refused
|
||||
/// and nothing is kept.
|
||||
#[tauri::command]
|
||||
async fn verify_key(token: String) -> Result<Release, String> {
|
||||
let t = token.trim().to_string();
|
||||
if t.is_empty() {
|
||||
return Err("Paste your alpha key first.".into());
|
||||
}
|
||||
let release = fetch_release(&t).await?;
|
||||
let e = keyring_entry().ok_or("the Credential Manager is not available")?;
|
||||
e.set_password(&t).map_err(|e| format!("the Credential Manager refused the key: {e}"))?;
|
||||
Ok(release)
|
||||
}
|
||||
|
||||
/// A key saved by an earlier run, re-checked: still good, or gone.
|
||||
#[tauri::command]
|
||||
async fn check_saved_key() -> Result<Option<Release>, String> {
|
||||
let Some(t) = saved_key() else { return Ok(None) };
|
||||
match fetch_release(&t).await {
|
||||
Ok(r) => Ok(Some(r)),
|
||||
Err(e) => {
|
||||
if let Some(entry) = keyring_entry() {
|
||||
let _ = entry.delete_credential();
|
||||
}
|
||||
Err(format!("The key saved on this computer no longer works — {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn pick_folder() -> Option<String> {
|
||||
tauri::async_runtime::spawn_blocking(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Where your music lives")
|
||||
.pick_folder()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// The choices, merged into the app's config.json (everything else in it is
|
||||
/// kept), and the first-run flag set: the tour asked its questions here.
|
||||
#[tauri::command]
|
||||
fn write_config(
|
||||
library_dir: String,
|
||||
library_layout: String,
|
||||
glass_mode: bool,
|
||||
loudness: bool,
|
||||
) -> Result<(), String> {
|
||||
let mut cfg = load_config();
|
||||
let lib = library_dir.trim();
|
||||
if !lib.is_empty() {
|
||||
cfg.insert("download_dir".into(), lib.into());
|
||||
}
|
||||
let lay = library_layout.trim();
|
||||
if !lay.is_empty() {
|
||||
cfg.insert("library_layout".into(), lay.into());
|
||||
}
|
||||
cfg.insert("glass_mode".into(), glass_mode.into());
|
||||
cfg.insert("volume_normalization".into(), loudness.into());
|
||||
cfg.insert("setup_done".into(), true.into());
|
||||
if !cfg.contains_key("update_channel") {
|
||||
cfg.insert("update_channel".into(), "stable".into());
|
||||
}
|
||||
save_config(&cfg)
|
||||
}
|
||||
|
||||
/// Download the latest release with the saved key, verify it, run it
|
||||
/// silently. Phases go to the window; the result is the install folder.
|
||||
#[tauri::command]
|
||||
async fn install(app: AppHandle) -> Result<String, String> {
|
||||
let run = async {
|
||||
let token = saved_key().ok_or("No alpha key is saved — go back and verify one.")?;
|
||||
phase(&app, "resolving", "Asking GitHub for the latest release…", 0, 0);
|
||||
let release = fetch_release(&token).await?;
|
||||
let sig_url = release
|
||||
.sig_url
|
||||
.clone()
|
||||
.ok_or("The release carries no signature; refusing to install it.")?;
|
||||
let label = format!("SoulStream {} · {:.1} MB", release.version, release.installer_size as f64 / 1e6);
|
||||
let bytes = download(&app, &token, &release.installer_url, release.installer_size, &label).await?;
|
||||
phase(&app, "verifying", "Checking the installer's signature…", bytes.len() as u64, bytes.len() as u64);
|
||||
let sig = download(&app, &token, &sig_url, 0, "signature").await?;
|
||||
let sig = String::from_utf8(sig).map_err(|_| "the signature is not text".to_string())?;
|
||||
verify_signature(&bytes, &sig)?;
|
||||
let dir = std::env::temp_dir().join("SoulStream-Setup");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let exe = dir.join(&release.installer_name);
|
||||
std::fs::write(&exe, &bytes).map_err(|e| format!("could not write the installer: {e}"))?;
|
||||
phase(&app, "installing", "Copying SoulStream into place…", 0, 0);
|
||||
let status = tauri::async_runtime::spawn_blocking(move || Command::new(&exe).arg("/S").status())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| format!("could not start the installer: {e}"))?;
|
||||
if !status.success() {
|
||||
return Err(format!("the installer ended with code {}", status.code().unwrap_or(-1)));
|
||||
}
|
||||
let loc = install_location().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
Ok::<String, String>(loc)
|
||||
};
|
||||
match run.await {
|
||||
Ok(loc) => {
|
||||
phase(&app, "done", loc.clone(), 0, 0);
|
||||
Ok(loc)
|
||||
}
|
||||
Err(e) => {
|
||||
phase(&app, "failed", e.clone(), 0, 0);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the installed app and leave.
|
||||
#[tauri::command]
|
||||
fn launch(app: AppHandle) -> Result<(), String> {
|
||||
let exe = app_exe().ok_or("SoulStream is not installed here")?;
|
||||
Command::new(&exe)
|
||||
.current_dir(exe.parent().unwrap_or(&exe))
|
||||
.spawn()
|
||||
.map_err(|e| format!("could not start SoulStream: {e}"))?;
|
||||
app.exit(0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn quit(app: AppHandle) {
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if !webview2_present() {
|
||||
// Nothing here can draw without it, and neither can the app.
|
||||
rfd::MessageDialog::new()
|
||||
.set_title("SoulStream Setup")
|
||||
.set_description("SoulStream needs Microsoft Edge WebView2, which this computer does not have. Install it from Microsoft, then run Setup again.")
|
||||
.show();
|
||||
return;
|
||||
}
|
||||
tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
defaults,
|
||||
verify_key,
|
||||
check_saved_key,
|
||||
pick_folder,
|
||||
write_config,
|
||||
install,
|
||||
launch,
|
||||
quit
|
||||
])
|
||||
.run({
|
||||
let mut context = tauri::generate_context!();
|
||||
// Driving the window from a probe, debug builds only.
|
||||
#[cfg(debug_assertions)]
|
||||
if let Ok(port) = std::env::var("SOULSTREAM_SETUP_CDP") {
|
||||
for w in &mut context.config_mut().app.windows {
|
||||
w.additional_browser_args = Some(format!("--remote-debugging-port={port}"));
|
||||
}
|
||||
}
|
||||
context
|
||||
})
|
||||
.expect("SoulStream Setup could not start");
|
||||
}
|
||||
Reference in new issue
Block a user