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.
545 lines
20 KiB
Rust
545 lines
20 KiB
Rust
//! 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";
|
|
/// The rolling prerelease `.github/workflows/nightly.yml` replaces daily —
|
|
/// never the one Setup installs.
|
|
const NIGHTLY_TAG: &str = "nightly";
|
|
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>,
|
|
}
|
|
|
|
/// Why a release lookup failed, and whether it was the KEY that GitHub
|
|
/// refused. Only a refusal is a reason to throw the saved key away — a
|
|
/// laptop between wifi networks is not, and the entry thrown away is the
|
|
/// one the app reads for its own updates.
|
|
struct Failure {
|
|
message: String,
|
|
key_refused: bool,
|
|
}
|
|
|
|
impl Failure {
|
|
fn plain(message: impl Into<String>) -> Self {
|
|
Self { message: message.into(), key_refused: false }
|
|
}
|
|
fn refused(message: impl Into<String>) -> Self {
|
|
Self { message: message.into(), key_refused: true }
|
|
}
|
|
}
|
|
|
|
impl From<Failure> for String {
|
|
fn from(f: Failure) -> Self {
|
|
f.message
|
|
}
|
|
}
|
|
|
|
/// A bare message is never a refusal — a release missing its installer says
|
|
/// nothing about the key that read it.
|
|
impl From<&str> for Failure {
|
|
fn from(m: &str) -> Self {
|
|
Failure::plain(m)
|
|
}
|
|
}
|
|
|
|
impl From<String> for Failure {
|
|
fn from(m: String) -> Self {
|
|
Failure::plain(m)
|
|
}
|
|
}
|
|
|
|
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, Failure> {
|
|
// Not `/releases/latest`: that endpoint answers with the newest FULL
|
|
// release and every SoulStream release so far is a prerelease, so it
|
|
// 404s and a perfectly good key reads back as a bad one. The list comes
|
|
// newest first — take the first that is neither a draft nor the rolling
|
|
// nightly, which is not what a tester is here to install.
|
|
let resp = client()?
|
|
.get(format!("https://api.github.com/repos/{REPO}/releases?per_page=30"))
|
|
.bearer_auth(token)
|
|
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
|
|
.header("X-GitHub-Api-Version", "2022-11-28")
|
|
.send()
|
|
.await
|
|
.map_err(|e| Failure::plain(format!("Could not reach GitHub — is this computer online? ({e})")))?;
|
|
match resp.status().as_u16() {
|
|
200 => {}
|
|
401 => return Err(Failure::refused("That key was refused.")),
|
|
403 => return Err(Failure::refused("That key cannot read the SoulStream repository.")),
|
|
// A private repository answers 404 to a key that cannot see it.
|
|
404 => return Err(Failure::refused("No release found — or that key cannot see the repository.")),
|
|
s => return Err(Failure::plain(format!("GitHub answered {s}."))),
|
|
}
|
|
let list: Vec<serde_json::Value> =
|
|
resp.json().await.map_err(|e| Failure::plain(e.to_string()))?;
|
|
// The key worked; there is simply nothing published yet. Not its fault.
|
|
let v = list
|
|
.into_iter()
|
|
.find(|r| {
|
|
r["draft"].as_bool() != Some(true) && r["tag_name"].as_str() != Some(NIGHTLY_TAG)
|
|
})
|
|
.ok_or_else(|| Failure::plain("That key works, but the repository has no release to install yet."))?;
|
|
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 e.key_refused => {
|
|
// The same Credential Manager entry the app reads for its own
|
|
// updates, so it goes only when GitHub actually turned it down.
|
|
if let Some(entry) = keyring_entry() {
|
|
let _ = entry.delete_credential();
|
|
}
|
|
Err(format!("The key saved on this computer no longer works — {}", e.message))
|
|
}
|
|
Err(e) => Err(e.message),
|
|
}
|
|
}
|
|
|
|
#[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({
|
|
#[cfg_attr(not(debug_assertions), allow(unused_mut))]
|
|
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");
|
|
}
|