Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
972dd103cb | ||
|
|
12fff9f6ad | ||
|
|
3cfff93036 | ||
|
|
f8508da081 | ||
|
|
8093835482 |
No files matched your search
@@ -108,3 +108,44 @@ jobs:
|
||||
console.log(JSON.stringify(m, null, 2));
|
||||
'
|
||||
gh release upload "$TAG" latest.json --repo "$TARGET" --clobber
|
||||
|
||||
# The file testers run: our own setup window (setup/). It carries no
|
||||
# installer — it downloads the latest release with the tester's key and
|
||||
# verifies the signature — so it is built the same for every release.
|
||||
- name: Build SoulStream Setup
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ steps.stamp.outputs.version }}
|
||||
run: |
|
||||
npx vite build --config vite.setup.config.js
|
||||
cargo build --release -p soulstream-setup
|
||||
cp target/release/SoulStream-Setup.exe "SoulStream-Setup-$VERSION.exe"
|
||||
ls -la "SoulStream-Setup-$VERSION.exe"
|
||||
|
||||
# Which of the files on the page a tester runs, said on the page: the
|
||||
# bare NSIS installer reads like the download and installs with no key
|
||||
# gate and no signature check.
|
||||
- name: Publish SoulStream Setup
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASES_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
TARGET: ${{ vars.RELEASES_OWNER || github.repository_owner }}/${{ vars.RELEASES_REPO || github.event.repository.name }}
|
||||
TAG: nightly
|
||||
VERSION: ${{ steps.stamp.outputs.version }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
gh release upload "$TAG" "SoulStream-Setup-$VERSION.exe" --repo "$TARGET" --clobber
|
||||
{
|
||||
echo "### Download \`SoulStream-Setup-$VERSION.exe\`"
|
||||
echo
|
||||
echo "It asks for your alpha key, fetches this build with it, checks the signature and installs — no admin prompt."
|
||||
echo
|
||||
echo "The other files are the ones Setup uses:"
|
||||
echo
|
||||
echo "- \`SoulStream_${VERSION}_x64-setup.exe\` — the bare installer Setup downloads. Running it yourself skips the key and the signature check."
|
||||
echo "- \`SoulStream_${VERSION}_x64-setup.exe.sig\` — its signature."
|
||||
echo "- \`latest.json\` — the manifest the app's Nightly channel reads."
|
||||
echo
|
||||
echo "Built from main at $SHA. Nightlies can break."
|
||||
} > notes.md
|
||||
gh release edit "$TAG" --repo "$TARGET" --notes-file notes.md
|
||||
@@ -44,7 +44,8 @@ jobs:
|
||||
- name: Read the version
|
||||
id: version
|
||||
shell: bash
|
||||
run: echo "version=$(node -p \"require('./src-tauri/tauri.conf.json').version\")" >> "$GITHUB_OUTPUT"
|
||||
run: |
|
||||
echo "version=$(node -p "require('./src-tauri/tauri.conf.json').version")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build, sign and publish
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
@@ -90,3 +91,44 @@ jobs:
|
||||
console.log(JSON.stringify(m, null, 2));
|
||||
'
|
||||
gh release upload "$TAG" latest.json --repo "$TARGET" --clobber
|
||||
|
||||
# The file testers run: our own setup window (setup/). It carries no
|
||||
# installer — it downloads the latest release with the tester's key and
|
||||
# verifies the signature — so it is built the same for every release.
|
||||
- name: Build SoulStream Setup
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
npx vite build --config vite.setup.config.js
|
||||
cargo build --release -p soulstream-setup
|
||||
cp target/release/SoulStream-Setup.exe "SoulStream-Setup-$VERSION.exe"
|
||||
ls -la "SoulStream-Setup-$VERSION.exe"
|
||||
|
||||
# Six files on the page and nothing saying which one a tester runs: the
|
||||
# NSIS installer is the biggest and reads like the download, and running
|
||||
# it puts the app on with no key gate and no signature check. The notes
|
||||
# go on last, once the file they point at is actually up.
|
||||
- name: Publish SoulStream Setup
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASES_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
TARGET: ${{ vars.RELEASES_OWNER || github.repository_owner }}/${{ vars.RELEASES_REPO || github.event.repository.name }}
|
||||
TAG: v${{ steps.version.outputs.version }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
gh release upload "$TAG" "SoulStream-Setup-$VERSION.exe" --repo "$TARGET" --clobber
|
||||
{
|
||||
echo "### Download \`SoulStream-Setup-$VERSION.exe\`"
|
||||
echo
|
||||
echo "It asks for your alpha key, fetches this release with it, checks the signature and installs — no admin prompt."
|
||||
echo
|
||||
echo "The other files are the ones Setup uses:"
|
||||
echo
|
||||
echo "- \`SoulStream_${VERSION}_x64-setup.exe\` — the bare installer Setup downloads. Running it yourself skips the key and the signature check."
|
||||
echo "- \`SoulStream_${VERSION}_x64-setup.exe.sig\` — its signature."
|
||||
echo "- \`latest.json\` — the manifest the in-app updater reads."
|
||||
echo
|
||||
echo "See the commits since the last release."
|
||||
} > notes.md
|
||||
gh release edit "$TAG" --repo "$TARGET" --notes-file notes.md
|
||||
@@ -19,3 +19,7 @@ GAMEPLAN.md
|
||||
|
||||
# Logo/icon kits — worked on outside this repo, not shipped from it
|
||||
branding/
|
||||
|
||||
# the setup app frontend build (vite.setup.config.js)
|
||||
setup/dist/
|
||||
setup/src-tauri/gen/
|
||||
Generated
+698
-19
File diff suppressed because it is too large.
Load diff
+2
-1
@@ -2,6 +2,7 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"src-tauri",
|
||||
"setup/src-tauri",
|
||||
"crates/soulseek-proto",
|
||||
"crates/soulseek-client",
|
||||
"crates/player",
|
||||
@@ -14,7 +15,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.8.0-alpha"
|
||||
version = "1.8.2-alpha"
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
authors = ["Trifecta Solutions"]
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "soulstream",
|
||||
"version": "1.8.0-alpha",
|
||||
"version": "1.8.2-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "soulstream",
|
||||
"version": "1.8.0-alpha",
|
||||
"version": "1.8.2-alpha",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soulstream",
|
||||
"version": "1.8.0-alpha",
|
||||
"version": "1.8.2-alpha",
|
||||
"type": "module",
|
||||
"description": "SoulStream — music player, downloader and streamer for the Soulseek network",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SoulStream Setup</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
[package]
|
||||
name = "soulstream-setup"
|
||||
version.workspace = true
|
||||
description = "SoulStream Setup — the installer testers run: our own window, the alpha key as the gate, the real installer downloaded from the private release and verified"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
# One file to hand out: SoulStream-Setup.exe. Not bundled — it IS the bundle.
|
||||
[[bin]]
|
||||
name = "SoulStream-Setup"
|
||||
path = "src/main.rs"
|
||||
|
||||
# Tauri serves the frontend it embedded at compile time only with this on;
|
||||
# without it the window loads `devUrl` instead and a tester gets a blank
|
||||
# window saying "localhost refused to connect". The workflows build this
|
||||
# crate with plain `cargo build` — no Tauri CLI to turn it on for them — so
|
||||
# it is a DEFAULT feature here: every build that does not say otherwise is a
|
||||
# shippable one. `--no-default-features` is the build that talks to the vite
|
||||
# dev server on 1425.
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
serde_json.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
# The alpha key goes where the app reads it: the Credential Manager.
|
||||
keyring = { version = "3", features = ["windows-native"] }
|
||||
dirs = "5"
|
||||
rfd = "0.15"
|
||||
winreg = "0.52"
|
||||
# The release comes down from GitHub with the key, and its signature is
|
||||
# checked against the same public key the app's updater trusts.
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
minisign-verify = "0.2"
|
||||
base64 = "0.21"
|
||||
@@ -0,0 +1,31 @@
|
||||
//! The setup app trusts exactly what the app's updater trusts: the public
|
||||
//! key in the app's tauri.conf.json. Read at build time so there is one
|
||||
//! copy of it in the repository.
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn main() {
|
||||
// The frontend is embedded only when tauri's `custom-protocol` feature is
|
||||
// on; without it the window points at devUrl and the exe we hand a tester
|
||||
// shows "localhost refused to connect". It is a default feature, so this
|
||||
// only fires when something turned the defaults off — and it fires at
|
||||
// build time rather than on the tester's machine.
|
||||
if tauri_build::is_dev() && std::env::var("PROFILE").as_deref() == Ok("release") {
|
||||
panic!("SoulStream Setup: a release build needs the `custom-protocol` feature (on by default); without it the window loads devUrl and is blank");
|
||||
}
|
||||
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
|
||||
let conf = manifest.join("../../src-tauri/tauri.conf.json");
|
||||
println!("cargo:rerun-if-changed={}", conf.display());
|
||||
// tauri-build tracks the config, not the frontend the exe embeds: without
|
||||
// this, a changed Setup.svelte rebuilds `setup/dist` and cargo hands back
|
||||
// yesterday's binary, still carrying yesterday's window.
|
||||
println!("cargo:rerun-if-changed={}", manifest.join("../dist").display());
|
||||
let text = std::fs::read_to_string(&conf).expect("read src-tauri/tauri.conf.json");
|
||||
let json: serde_json::Value = serde_json::from_str(&text).expect("parse tauri.conf.json");
|
||||
let pubkey = json["plugins"]["updater"]["pubkey"]
|
||||
.as_str()
|
||||
.filter(|s| !s.is_empty())
|
||||
.expect("plugins.updater.pubkey in src-tauri/tauri.conf.json");
|
||||
let out = Path::new(&std::env::var("OUT_DIR").unwrap()).join("pubkey.rs");
|
||||
std::fs::write(&out, format!("pub const PUBKEY: &str = {pubkey:?};\n")).expect("write pubkey.rs");
|
||||
tauri_build::build();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "The setup window: its own chrome (drag, minimize, close) and the app's commands.",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-close"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
//! 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");
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "SoulStream Setup",
|
||||
"version": "1.8.2-alpha",
|
||||
"identifier": "com.soulstream.setup",
|
||||
"build": {
|
||||
"beforeDevCommand": "npx vite --config vite.setup.config.js",
|
||||
"devUrl": "http://localhost:1425",
|
||||
"beforeBuildCommand": "npx vite build --config vite.setup.config.js",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "SoulStream Setup",
|
||||
"width": 800,
|
||||
"height": 580,
|
||||
"resizable": false,
|
||||
"maximizable": false,
|
||||
"decorations": false,
|
||||
"center": true,
|
||||
"backgroundColor": "#121212"
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": false,
|
||||
"icon": [
|
||||
"../../src-tauri/icons/32x32.png",
|
||||
"../../src-tauri/icons/128x128.png",
|
||||
"../../src-tauri/icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
<script>
|
||||
// SoulStream Setup: the first-run tour's design, standing in for the
|
||||
// Windows installer. Welcome → the alpha key (the gate: verified against
|
||||
// GitHub, nothing installs without one) → the music folder → the look →
|
||||
// Install (the latest release downloaded with the key, its signature
|
||||
// checked, the NSIS installer run silently; phases from the backend) →
|
||||
// Done, Open SoulStream. Every choice is written into the app's config
|
||||
// before the install runs, the key into the Credential Manager, so the
|
||||
// app opens straight into itself.
|
||||
import { onMount, tick } from "svelte";
|
||||
import { fly, scale } from "svelte/transition";
|
||||
import { cubicOut, quintOut } from "svelte/easing";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import Icon from "../../src/components/Icon.svelte";
|
||||
|
||||
const STEPS = [
|
||||
{ id: "welcome", label: "Welcome", icon: "sparkle" },
|
||||
{ id: "key", label: "Alpha key", icon: "key" },
|
||||
{ id: "library", label: "Your library", icon: "library" },
|
||||
{ id: "look", label: "Look & sound", icon: "eye" },
|
||||
{ id: "install", label: "Install", icon: "download" },
|
||||
{ id: "done", label: "Ready", icon: "check" },
|
||||
];
|
||||
const PRESETS = [
|
||||
["{artist}\\{album}", "Artist › Album"],
|
||||
["{artist}\\{year} - {album}", "Artist › Year - Album"],
|
||||
["{artist} - {album}", "Artist - Album"],
|
||||
];
|
||||
|
||||
let step = $state(0);
|
||||
let dir = $state(1);
|
||||
let d = $state(null);
|
||||
let alphaKey = $state("");
|
||||
let hasToken = $state(false);
|
||||
// What the key can see: set only by a successful check, cleared by a failed one.
|
||||
let keyOk = $state(null);
|
||||
let verifying = $state(false);
|
||||
let libraryDir = $state("");
|
||||
let layout = $state("");
|
||||
let glass = $state(false);
|
||||
let loudness = $state(true);
|
||||
let busy = $state(false);
|
||||
let error = $state("");
|
||||
let phase = $state({ phase: "", detail: "", received: 0, total: 0 });
|
||||
const pct = $derived(phase.total > 0 ? Math.min(100, Math.round((phase.received / phase.total) * 100)) : null);
|
||||
let installedAt = $state("");
|
||||
let panelEl = $state(null);
|
||||
|
||||
const cur = $derived(STEPS[step]);
|
||||
const installing = $derived(cur.id === "install" && phase.phase !== "done" && phase.phase !== "failed");
|
||||
const reduced = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const dur = (ms) => (reduced() ? 0 : ms);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
d = await invoke("defaults");
|
||||
libraryDir = d.library_dir;
|
||||
layout = d.library_layout;
|
||||
glass = !!d.glass_mode;
|
||||
loudness = d.loudness !== false;
|
||||
hasToken = !!d.has_token;
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
}
|
||||
if (hasToken) {
|
||||
verifying = true;
|
||||
try {
|
||||
keyOk = await invoke("check_saved_key");
|
||||
} catch (e) {
|
||||
hasToken = false;
|
||||
keyOk = null;
|
||||
error = String(e);
|
||||
} finally {
|
||||
verifying = false;
|
||||
}
|
||||
}
|
||||
const un = await listen("setup:phase", (e) => {
|
||||
phase = e.payload ?? { phase: "", detail: "", received: 0, total: 0 };
|
||||
if (phase.phase === "done") {
|
||||
installedAt = phase.detail;
|
||||
go(1);
|
||||
} else if (phase.phase === "failed") {
|
||||
error = phase.detail;
|
||||
}
|
||||
});
|
||||
await tick();
|
||||
panelEl?.querySelector(".primary")?.focus();
|
||||
return () => un();
|
||||
});
|
||||
|
||||
async function go(delta) {
|
||||
dir = delta;
|
||||
step = Math.max(0, Math.min(STEPS.length - 1, step + delta));
|
||||
await tick();
|
||||
panelEl?.querySelector(".primary")?.focus();
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
const k = alphaKey.trim();
|
||||
if (!k || verifying) return;
|
||||
verifying = true;
|
||||
error = "";
|
||||
try {
|
||||
keyOk = await invoke("verify_key", { token: k });
|
||||
hasToken = true;
|
||||
alphaKey = "";
|
||||
} catch (e) {
|
||||
keyOk = null;
|
||||
error = String(e);
|
||||
} finally {
|
||||
verifying = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pickDir() {
|
||||
const p = await invoke("pick_folder").catch(() => null);
|
||||
if (p) libraryDir = p;
|
||||
}
|
||||
|
||||
async function next() {
|
||||
if (busy || installing) return;
|
||||
error = "";
|
||||
if (cur.id === "key") {
|
||||
if (alphaKey.trim() && !keyOk) {
|
||||
await verify();
|
||||
if (!keyOk) return;
|
||||
}
|
||||
if (!keyOk) {
|
||||
error = "An accepted alpha key is needed to install.";
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (cur.id === "look") {
|
||||
// The choices land first, then the installer runs.
|
||||
busy = true;
|
||||
try {
|
||||
await invoke("write_config", { libraryDir, libraryLayout: layout, glassMode: glass, loudness });
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
if (error) return;
|
||||
await go(1);
|
||||
phase = { phase: "resolving", detail: "", received: 0, total: 0 };
|
||||
void invoke("install").catch((e) => {
|
||||
error = String(e);
|
||||
phase = { phase: "failed", detail: String(e), received: 0, total: 0 };
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (cur.id === "done") return launch();
|
||||
await go(1);
|
||||
}
|
||||
|
||||
function back() {
|
||||
if (step === 0 || busy || installing || cur.id === "done") return;
|
||||
go(-1);
|
||||
}
|
||||
|
||||
async function launch() {
|
||||
try {
|
||||
await invoke("launch");
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
function quit() {
|
||||
void invoke("quit");
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key !== "Enter" || e.shiftKey) return;
|
||||
const t = e.target;
|
||||
if (t instanceof HTMLButtonElement) return;
|
||||
e.preventDefault();
|
||||
next();
|
||||
}
|
||||
|
||||
const win = () => getCurrentWindow();
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<div class="panel" bind:this={panelEl} role="dialog" aria-modal="true" aria-labelledby="fr-title">
|
||||
<div class="chrome" data-tauri-drag-region>
|
||||
<span class="wtitle" data-tauri-drag-region>SoulStream Setup</span>
|
||||
<button class="wbtn" onclick={() => win().minimize()} title="Minimize" aria-label="Minimize"><Icon name="minus" size={14} /></button>
|
||||
<button class="wbtn x" onclick={() => (installing ? null : win().close())} title="Close" aria-label="Close" disabled={installing}><Icon name="x" size={14} /></button>
|
||||
</div>
|
||||
|
||||
<aside class="rail" data-tauri-drag-region>
|
||||
<div class="brand" data-tauri-drag-region>
|
||||
<img src="/logo.png" alt="" draggable="false" />
|
||||
<span>SoulStream</span>
|
||||
</div>
|
||||
<ol>
|
||||
{#each STEPS as s, i (s.id)}
|
||||
<li class:on={i === step} class:done={i < step} aria-current={i === step ? "step" : undefined}>
|
||||
<span class="dot">
|
||||
{#if i < step}<Icon name="check" size={11} />{:else}<Icon name={s.icon} size={12} />{/if}
|
||||
</span>
|
||||
<span class="lab">{s.label}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
<div class="railfoot">{d ? `SoulStream ${d.version}` : ""}<br />Everything here lives in Settings too.</div>
|
||||
</aside>
|
||||
|
||||
<div class="stage">
|
||||
<div class="steps">
|
||||
{#key step}
|
||||
<section
|
||||
class="step"
|
||||
in:fly={{ x: 36 * dir, duration: dur(380), easing: quintOut, delay: dur(60) }}
|
||||
out:fly={{ x: -36 * dir, duration: dur(200), easing: cubicOut }}
|
||||
>
|
||||
{#if cur.id === "welcome"}
|
||||
<div class="welcome">
|
||||
<div class="halo" in:scale={{ start: 0.6, duration: dur(760), easing: quintOut }}>
|
||||
<img class="mark" src="/logo.png" alt="" draggable="false" />
|
||||
</div>
|
||||
<h1 id="fr-title" in:fly={{ y: 14, duration: dur(520), delay: dur(220), easing: quintOut }}>
|
||||
{d?.installed_version ? "Set SoulStream up again" : "Welcome to SoulStream"}
|
||||
</h1>
|
||||
<p class="lede" in:fly={{ y: 14, duration: dur(520), delay: dur(340), easing: quintOut }}>
|
||||
{#if d?.installed_version}
|
||||
SoulStream {d.installed_version} is already on this computer. This installs
|
||||
the latest release in its place and keeps your music and settings. About a minute,
|
||||
no admin prompt.
|
||||
{:else}
|
||||
Your library on your own disk, music from people on Soulseek, and a community
|
||||
that runs on a peer you own. Your alpha key, two short questions, then about a
|
||||
minute to download and install — no admin prompt, and your music never lives
|
||||
inside the program.
|
||||
{/if}
|
||||
</p>
|
||||
<ul class="pills" in:fly={{ y: 10, duration: dur(520), delay: dur(460), easing: quintOut }}>
|
||||
{#each STEPS.slice(1, 4) as s (s.id)}
|
||||
<li><Icon name={s.icon} size={13} /> {s.label}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{:else if cur.id === "key"}
|
||||
<h1 id="fr-title">Your alpha key</h1>
|
||||
<p class="lede">
|
||||
SoulStream is in a closed alpha. This key — one per tester, able only to read the
|
||||
private repository — is how Setup fetches the app and how the app finds updates
|
||||
later. It goes to the Windows Credential Manager, not a plain file. Nothing installs
|
||||
without it.
|
||||
</p>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Alpha key {#if keyOk}<span class="tag ok">accepted</span>{:else if verifying}<span class="tag">checking…</span>{/if}</div>
|
||||
<div class="hint">
|
||||
{#if keyOk}
|
||||
SoulStream <b>{keyOk.version}</b> is what it will install{#if keyOk.installer_size} — {(keyOk.installer_size / 1e6).toFixed(1)} MB{/if}. Paste another key to replace it.
|
||||
{:else}
|
||||
Paste the key you were given (it starts with github_pat_) and press Verify. Setup asks GitHub whether the key can read the release.
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<input type="password" class="mono" bind:value={alphaKey} placeholder={keyOk ? "••••••••••••" : "github_pat_…"} spellcheck="false" autocomplete="off" aria-label="Alpha key" onkeydown={(e) => { if (e.key === "Enter") { e.stopPropagation(); verify(); } }} />
|
||||
<button class="ax-white" onclick={verify} disabled={verifying || !alphaKey.trim()}>{verifying ? "Checking…" : "Verify"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if cur.id === "library"}
|
||||
<h1 id="fr-title">Where does your music live?</h1>
|
||||
<p class="lede">
|
||||
Downloads land in this folder, it is always shared on Soulseek, and albums are filed
|
||||
into it by the format below. Files already in it are never moved.
|
||||
</p>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Library folder</div>
|
||||
<div class="path">{libraryDir || "Not chosen yet"}</div>
|
||||
</div>
|
||||
<div class="ctl"><button class="ax-white" onclick={pickDir} disabled={busy}>Choose folder…</button></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Album folder format</div>
|
||||
<div class="hint">Use {"{artist}"}, {"{album}"}, {"{date}"} or {"{year}"}; "\" starts a subfolder.</div>
|
||||
<div class="presets">
|
||||
{#each PRESETS as [v, l] (v)}
|
||||
<button class="preset" class:on={layout.trim() === v} onclick={() => (layout = v)}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ctl"><input class="mono" bind:value={layout} spellcheck="false" autocomplete="off" aria-label="Album folder format" /></div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if cur.id === "look"}
|
||||
<h1 id="fr-title">How the window is drawn</h1>
|
||||
<p class="lede">Both can be changed any time in Settings.</p>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Dark glass</div>
|
||||
<div class="hint">Whatever is behind the window glows through the app's dark surfaces with a live blur (Windows acrylic). Menus and dialogs stay solid.</div>
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<span class="tval">{glass ? "dark glass" : "solid"}</span>
|
||||
<button class="toggle" class:on={glass} role="switch" aria-checked={glass} aria-label="Dark glass" onclick={() => (glass = !glass)}><i></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Loudness normalization</div>
|
||||
<div class="hint">Every track plays at roughly the same level (−14 LUFS). Off plays each file as recorded.</div>
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<span class="tval">{loudness ? "on" : "off"}</span>
|
||||
<button class="toggle" class:on={loudness} role="switch" aria-checked={loudness} aria-label="Loudness normalization" onclick={() => (loudness = !loudness)}><i></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if cur.id === "install"}
|
||||
<h1 id="fr-title">{phase.phase === "failed" ? "That did not go through" : "Installing SoulStream"}</h1>
|
||||
<p class="lede">
|
||||
{#if phase.phase === "failed"}
|
||||
{phase.detail}
|
||||
{:else if phase.phase === "downloading"}
|
||||
Downloading {phase.detail}{pct != null ? ` · ${pct}%` : "…"}
|
||||
{:else if phase.phase === "verifying"}
|
||||
{phase.detail}
|
||||
{:else if phase.phase === "installing"}
|
||||
{phase.detail} Nothing to click; this takes a moment.
|
||||
{:else}
|
||||
{phase.detail || "Starting…"}
|
||||
{/if}
|
||||
</p>
|
||||
<div class="progress" class:failed={phase.phase === "failed"} class:busy={pct == null && phase.phase !== "failed"} aria-hidden="true"><i style={pct != null ? `width: ${pct}%` : ""}></i></div>
|
||||
<ul class="summary">
|
||||
<li><span class="ic"><Icon name="key" size={14} /></span><span class="what">Alpha key</span><b>{keyOk ? `accepted · SoulStream ${keyOk.version}` : "not set"}</b></li>
|
||||
<li><span class="ic"><Icon name="folder-open" size={14} /></span><span class="what">Library</span><b class="mono">{libraryDir}</b></li>
|
||||
<li><span class="ic"><Icon name="eye" size={14} /></span><span class="what">Window</span><b>{glass ? "dark glass" : "solid"}</b></li>
|
||||
</ul>
|
||||
{:else}
|
||||
<h1 id="fr-title">SoulStream is ready</h1>
|
||||
<p class="lede">Installed to <span class="mono">{installedAt}</span>. It lives in your Start menu now; your music stays where you chose.</p>
|
||||
<ul class="summary">
|
||||
{#each [
|
||||
["key", "Alpha key", keyOk ? "accepted" : "set"],
|
||||
["folder-open", "Library", libraryDir],
|
||||
["eye", "Window", glass ? "dark glass" : "solid"],
|
||||
["volume", "Loudness", loudness ? "normalized" : "as recorded"],
|
||||
] as [icon, what, value], i (what)}
|
||||
<li in:fly={{ y: 10, duration: dur(360), delay: dur(120 + i * 70), easing: quintOut }}>
|
||||
<span class="ic"><Icon name={icon} size={14} /></span>
|
||||
<span class="what">{what}</span>
|
||||
<b class:mono={what === "Library"}>{value}</b>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<footer class="foot">
|
||||
{#if step > 0 && cur.id !== "done" && !installing}
|
||||
<button class="ax-ghost" onclick={back} disabled={busy}>Back</button>
|
||||
{/if}
|
||||
<span class="spacer"></span>
|
||||
{#if error && cur.id !== "install"}<span class="err">{error}</span>{/if}
|
||||
{#if cur.id === "welcome"}
|
||||
<button class="ax-fill primary" onclick={next} disabled={!d}>Let's set it up <Icon name="chevron-right" size={14} /></button>
|
||||
{:else if cur.id === "key"}
|
||||
<button class="ax-fill primary" onclick={next} disabled={busy || verifying || !keyOk}>Next <Icon name="chevron-right" size={14} /></button>
|
||||
{:else if cur.id === "look"}
|
||||
<button class="ax-fill primary big" onclick={next} disabled={busy}><Icon name="download" size={13} /> {busy ? "Saving…" : "Install SoulStream"}</button>
|
||||
{:else if cur.id === "install"}
|
||||
{#if phase.phase === "failed"}
|
||||
<button class="ax-ghost" onclick={quit}>Close</button>
|
||||
<button class="ax-fill primary" onclick={() => { error = ""; phase = { phase: "resolving", detail: "", received: 0, total: 0 }; void invoke("install").catch((e) => { error = String(e); phase = { phase: "failed", detail: String(e), received: 0, total: 0 }; }); }}>Try again</button>
|
||||
{/if}
|
||||
{:else if cur.id === "done"}
|
||||
<button class="lnk" onclick={quit}>Close</button>
|
||||
<button class="ax-fill primary big" onclick={launch}>Open SoulStream</button>
|
||||
{:else}
|
||||
<button class="ax-fill primary" onclick={next} disabled={busy}>Next <Icon name="chevron-right" size={14} /></button>
|
||||
{/if}
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(html, body) { height: 100%; margin: 0; background: #121212; overflow: hidden; }
|
||||
:global(#app) { height: 100%; }
|
||||
.panel {
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 212px 1fr;
|
||||
grid-template-rows: 30px 1fr;
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
--surf: var(--bg-elevated);
|
||||
--surf-hi: var(--bg-highlight);
|
||||
--hair: rgba(255, 255, 255, 0.07);
|
||||
--ok: #3ddc84;
|
||||
user-select: none;
|
||||
}
|
||||
/* ── our own title strip ──────────────────────────────────────── */
|
||||
.chrome {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 0 4px 0 14px;
|
||||
background: var(--bg-base);
|
||||
border-bottom: 1px solid var(--hair);
|
||||
}
|
||||
.wtitle { flex: 1; font-size: 11.5px; font-weight: 800; color: var(--text-muted); letter-spacing: 0.02em; }
|
||||
.wbtn { width: 34px; height: 24px; border-radius: 6px; display: grid; place-items: center; color: var(--text-secondary); }
|
||||
.wbtn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.1); color: #fff; }
|
||||
.wbtn.x:hover:not(:disabled) { background: #c42b1c; color: #fff; }
|
||||
.wbtn:disabled { opacity: 0.35; }
|
||||
|
||||
/* ── the rail ─────────────────────────────────────────────────── */
|
||||
.rail { display: flex; flex-direction: column; background: var(--bg-base); border-right: 1px solid var(--hair); padding: 22px 16px 18px; min-width: 0; }
|
||||
.brand { display: flex; align-items: center; gap: 10px; padding: 0 6px 22px; font-size: 15px; font-weight: 900; letter-spacing: 0.01em; }
|
||||
.brand img { width: 26px; height: 26px; border-radius: 7px; }
|
||||
.rail ol { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.rail li { display: flex; align-items: center; gap: 10px; padding: 8px 8px; border-radius: 10px; color: var(--text-muted); font-size: 12.5px; font-weight: 700; transition: color 0.2s, background 0.2s; }
|
||||
.rail li.on { color: var(--text-primary); background: rgba(255, 255, 255, 0.06); }
|
||||
.rail li.done { color: var(--text-secondary); }
|
||||
.dot { width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center; background: var(--surf-hi); color: var(--text-secondary); flex: none; transition: background 0.2s, color 0.2s; }
|
||||
.rail li.on .dot { background: var(--accent); color: #000; }
|
||||
.rail li.done .dot { background: color-mix(in srgb, var(--ok) 18%, transparent); color: var(--ok); }
|
||||
.railfoot { margin-top: auto; font-size: 11px; color: var(--text-muted); line-height: 1.5; padding: 0 6px; }
|
||||
|
||||
/* ── the stage ────────────────────────────────────────────────── */
|
||||
.stage { display: grid; grid-template-rows: 1fr auto; min-width: 0; }
|
||||
.steps { position: relative; display: grid; overflow: hidden; }
|
||||
.steps > .step { grid-area: 1 / 1; padding: 30px 32px 8px; overflow: auto; min-height: 0; }
|
||||
h1 { font-size: 22px; font-weight: 900; letter-spacing: -0.01em; margin: 0 0 8px; line-height: 1.15; }
|
||||
.lede { font-size: 13px; color: var(--text-secondary); line-height: 1.55; margin: 0 0 18px; max-width: 58ch; }
|
||||
.mono { font-family: "Cascadia Code", Consolas, monospace; font-size: 12px; }
|
||||
|
||||
.welcome { display: flex; flex-direction: column; align-items: center; text-align: center; padding-top: 14px; }
|
||||
.welcome h1 { font-size: 26px; margin-top: 18px; }
|
||||
.welcome .lede { max-width: 48ch; }
|
||||
.halo { position: relative; width: 108px; height: 108px; display: grid; place-items: center; }
|
||||
.halo::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -18px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, color-mix(in srgb, var(--accent) 55%, transparent) 0, color-mix(in srgb, var(--accent) 18%, transparent) 38%, transparent 70%);
|
||||
animation: breathe 3.8s ease-in-out infinite;
|
||||
}
|
||||
.mark { position: relative; width: 84px; height: 84px; border-radius: 22px; box-shadow: 0 18px 40px rgba(0, 0, 0, 0.5); }
|
||||
@keyframes breathe {
|
||||
0%, 100% { transform: scale(1); opacity: 0.85; }
|
||||
50% { transform: scale(1.1); opacity: 1; }
|
||||
}
|
||||
.pills { list-style: none; margin: 4px 0 0; padding: 0; display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
|
||||
.pills li { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 99px; background: var(--surf-hi); font-size: 12px; font-weight: 800; color: var(--text-secondary); }
|
||||
|
||||
.card { background: var(--surf); border: 1px solid var(--hair); border-radius: 12px; overflow: hidden; }
|
||||
.row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 16px; border-top: 1px solid var(--hair); }
|
||||
.card > .row:first-child { border-top: none; }
|
||||
.label { min-width: 0; flex: 1; }
|
||||
.name { font-size: 13.5px; font-weight: 700; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.hint { font-size: 12px; color: var(--text-secondary); margin-top: 3px; line-height: 1.45; max-width: 60ch; }
|
||||
.path { font-family: "Cascadia Code", Consolas, monospace; font-size: 11.5px; color: var(--text-secondary); word-break: break-all; margin-top: 3px; }
|
||||
.ctl { flex: none; display: flex; align-items: center; gap: 8px; }
|
||||
.tag { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 7px; border-radius: 99px; background: var(--accent-soft); color: var(--accent-hover); display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||||
.tag.ok { background: color-mix(in srgb, var(--ok) 14%, transparent); color: #5ee89a; }
|
||||
.tval { font-size: 12px; font-weight: 800; color: var(--text-secondary); min-width: 62px; text-align: right; }
|
||||
.toggle { width: 38px; height: 21px; border-radius: 99px; background: var(--surf-hi); position: relative; flex: none; transition: 0.18s; }
|
||||
.toggle i { position: absolute; top: 3px; left: 3px; width: 15px; height: 15px; border-radius: 50%; background: var(--text-muted); transition: 0.18s; }
|
||||
.toggle.on { background: var(--accent); }
|
||||
.toggle.on i { left: 20px; background: #000; }
|
||||
.presets { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
|
||||
.preset { padding: 4px 10px; border-radius: 99px; font-size: 11.5px; font-weight: 800; background: var(--surf-hi); color: var(--text-secondary); }
|
||||
.preset:hover { color: #fff; }
|
||||
.preset.on { background: var(--accent); color: #000; }
|
||||
input { height: 32px; padding: 0 10px; border-radius: 8px; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); color: var(--text-primary); font: inherit; font-size: 13px; min-width: 0; user-select: text; }
|
||||
input:focus { outline: none; border-color: var(--accent); }
|
||||
input.mono { font-family: "Cascadia Code", Consolas, monospace; font-size: 12px; width: 220px; }
|
||||
|
||||
.progress { height: 6px; border-radius: 99px; background: var(--surf-hi); overflow: hidden; margin: 6px 0 18px; max-width: 420px; }
|
||||
.progress i { display: block; height: 100%; width: 0; border-radius: 99px; background: var(--accent); transition: width 0.2s; }
|
||||
.progress.busy i { width: 38%; animation: slide 1.4s ease-in-out infinite; }
|
||||
.progress.failed i { animation: none; width: 100%; background: #ff8d98; }
|
||||
@keyframes slide {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(280%); }
|
||||
}
|
||||
.summary { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.summary li { display: grid; grid-template-columns: 28px 130px 1fr; align-items: center; gap: 8px; padding: 10px 12px; border-radius: 10px; background: var(--surf); border: 1px solid var(--hair); font-size: 13px; }
|
||||
.summary .ic { display: grid; place-items: center; color: var(--accent-hover); }
|
||||
.summary .what { color: var(--text-secondary); font-weight: 700; }
|
||||
.summary b { font-weight: 800; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.summary b.mono { font-weight: 600; }
|
||||
|
||||
.foot { display: flex; align-items: center; gap: 12px; padding: 14px 24px 18px 32px; border-top: 1px solid var(--hair); }
|
||||
.spacer { flex: 1; }
|
||||
.err { font-size: 12px; color: #ff8d98; max-width: 40ch; }
|
||||
.lnk { color: var(--text-muted); font-weight: 700; font-size: 12px; text-decoration: underline; text-underline-offset: 2px; padding: 0 6px; }
|
||||
.lnk:hover:not(:disabled) { color: var(--text-primary); }
|
||||
.ax-ghost { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 14px; border-radius: 99px; font-size: 12px; font-weight: 800; border: 1px solid rgba(255, 255, 255, 0.28); transition: 0.18s; white-space: nowrap; }
|
||||
.ax-ghost:hover:not(:disabled) { border-color: #fff; background: rgba(255, 255, 255, 0.08); }
|
||||
.ax-ghost:disabled { opacity: 0.45; cursor: default; }
|
||||
.ax-fill { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 18px; border-radius: 99px; font-size: 12.5px; font-weight: 800; background: var(--accent); color: #000; white-space: nowrap; transition: background 0.18s; }
|
||||
.ax-fill:hover:not(:disabled) { background: var(--accent-hover); }
|
||||
.ax-fill:disabled { opacity: 0.55; cursor: default; }
|
||||
.ax-fill.big { height: 38px; padding: 0 22px; font-size: 13.5px; }
|
||||
.ax-white { display: inline-flex; align-items: center; height: 32px; padding: 0 16px; border-radius: 99px; font-size: 12.5px; font-weight: 800; background: #fff; color: #000; white-space: nowrap; transition: transform 0.15s; }
|
||||
.ax-white:hover:not(:disabled) { transform: scale(1.03); }
|
||||
.ax-white:disabled { opacity: 0.55; cursor: default; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.halo::before, .progress i { animation: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
// SoulStream Setup's page: the app's own stylesheet (tokens, fonts, buttons)
|
||||
// and one component, the tour as an installer.
|
||||
import "../../src/styles/global.css";
|
||||
import { mount } from "svelte";
|
||||
import Setup from "./Setup.svelte";
|
||||
|
||||
mount(Setup, { target: document.getElementById("app") });
|
||||
+55
-16
@@ -11,8 +11,12 @@
|
||||
//! 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
|
||||
//! `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
|
||||
@@ -37,6 +41,8 @@ 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);
|
||||
@@ -199,22 +205,15 @@ 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,
|
||||
/// 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<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())?;
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let resp = client
|
||||
.get(&api)
|
||||
.get(url)
|
||||
.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
|
||||
.header(reqwest::header::USER_AGENT, user_agent)
|
||||
@@ -229,7 +228,47 @@ async fn resolve_github_asset(
|
||||
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())?;
|
||||
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()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "SoulStream",
|
||||
"version": "1.8.0-alpha",
|
||||
"version": "1.8.2-alpha",
|
||||
"identifier": "com.soulstream.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
const STEPS = [
|
||||
{ id: "welcome", label: "Welcome", icon: "sparkle" },
|
||||
{ id: "alpha", label: "Alpha key", icon: "key" },
|
||||
{ id: "library", label: "Your library", icon: "library" },
|
||||
{ id: "soulseek", label: "Soulseek", icon: "users" },
|
||||
{ id: "community", label: "Community", icon: "globe" },
|
||||
@@ -53,6 +54,10 @@
|
||||
let displayName = $state("");
|
||||
let glass = $state(false);
|
||||
let loudness = $state(true);
|
||||
// The closed alpha's read-only release key (updater.rs): typed here,
|
||||
// kept by the backend in the Credential Manager, never read back.
|
||||
let alphaKey = $state("");
|
||||
let hasToken = $state(false);
|
||||
let busy = $state(false);
|
||||
let error = $state("");
|
||||
let closing = $state(false);
|
||||
@@ -90,6 +95,11 @@
|
||||
} catch {
|
||||
freenetOn = false;
|
||||
}
|
||||
try {
|
||||
hasToken = !!(await api.updaterStatus()).has_token;
|
||||
} catch {
|
||||
hasToken = false;
|
||||
}
|
||||
await tick();
|
||||
panelEl?.querySelector(".primary")?.focus();
|
||||
}
|
||||
@@ -132,7 +142,25 @@
|
||||
|
||||
async function next() {
|
||||
if (busy || closing) return;
|
||||
if (cur.id === "library") {
|
||||
if (cur.id === "alpha") {
|
||||
const k = alphaKey.trim();
|
||||
if (k) {
|
||||
busy = true;
|
||||
error = "";
|
||||
try {
|
||||
await api.updaterSet({ token: k });
|
||||
hasToken = true;
|
||||
alphaKey = "";
|
||||
// The first check can start now; the toast reports it.
|
||||
void api.updaterCheck().catch(() => {});
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
if (error) return;
|
||||
}
|
||||
} else if (cur.id === "library") {
|
||||
const patch = {};
|
||||
if (libraryDir && libraryDir !== settings?.download_dir) patch.downloadDir = libraryDir;
|
||||
const lay = layout.trim();
|
||||
@@ -274,11 +302,28 @@
|
||||
that runs on a peer you own. Four short questions and the app is yours.
|
||||
</p>
|
||||
<ul class="pills" in:fly={{ y: 10, duration: dur(520), delay: dur(460), easing: quintOut }}>
|
||||
{#each STEPS.slice(1, 5) as s (s.id)}
|
||||
{#each STEPS.slice(1, 6) as s (s.id)}
|
||||
<li><Icon name={s.icon} size={13} /> {s.label}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{:else if cur.id === "alpha"}
|
||||
<h1 id="fr-title">Your alpha key</h1>
|
||||
<p class="lede">
|
||||
SoulStream is in a closed alpha. New builds come from a private repository, and
|
||||
this key — one per tester, able only to read them — is how your copy finds
|
||||
updates. It goes to the Windows Credential Manager, not a plain file. Without it
|
||||
the app runs, but cannot update itself.
|
||||
</p>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Alpha key {#if hasToken}<span class="tag ok">set</span>{/if}</div>
|
||||
<div class="hint">{hasToken ? "A key is saved on this computer. Paste another to replace it, or press Next to keep it." : "Paste the key you were given. It starts with github_pat_."}</div>
|
||||
</div>
|
||||
<div class="ctl"><input type="password" class="mono" bind:value={alphaKey} placeholder={hasToken ? "••••••••••••" : "github_pat_…"} spellcheck="false" autocomplete="off" aria-label="Alpha key" /></div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if cur.id === "library"}
|
||||
<h1 id="fr-title">Where does your music live?</h1>
|
||||
<p class="lede">
|
||||
@@ -400,6 +445,7 @@
|
||||
<p class="lede">Here is what you chose. Every line of it lives in Settings.</p>
|
||||
<ul class="summary">
|
||||
{#each [
|
||||
["key", "Alpha key", hasToken ? "set" : "not set — updates need it"],
|
||||
["folder-open", "Library", libraryDir || "not chosen"],
|
||||
["users", "Soulseek", online ? `signed in as ${app.username}` : "not signed in yet"],
|
||||
["globe", "Community peer", freenetOn ? (displayName.trim() ? `on · ${displayName.trim()}` : "on") : "off"],
|
||||
@@ -430,7 +476,7 @@
|
||||
{:else if cur.id === "ready"}
|
||||
<button class="ax-fill primary big" onclick={finish} disabled={closing}>Open SoulStream</button>
|
||||
{:else}
|
||||
{#if cur.id === "soulseek" && !online}
|
||||
{#if (cur.id === "soulseek" && !online) || (cur.id === "alpha" && !hasToken && !alphaKey.trim())}
|
||||
<button class="lnk" onclick={next} disabled={busy}>Later</button>
|
||||
{/if}
|
||||
<button class="ax-fill primary" onclick={next} disabled={busy || closing}>{busy ? "Saving…" : "Next"} <Icon name="chevron-right" size={14} /></button>
|
||||
|
||||
@@ -3,17 +3,34 @@
|
||||
// the other toasts, but it STAYS until answered — Install here, or Later
|
||||
// (the Settings card keeps the offer). Installing on its own was never on
|
||||
// the table; this is the button the updater's note talks about.
|
||||
//
|
||||
// Its other face, for the closed alpha: a copy whose check failed for
|
||||
// want of the read-only key says so once per session, with the way to
|
||||
// Settings — a tester who skipped the tour's key step still finds out.
|
||||
import Icon from "./Icon.svelte";
|
||||
import { updater, installUpdate, dismissUpdate } from "../lib/updater.svelte.js";
|
||||
import { goToView } from "../lib/state.svelte.js";
|
||||
import { updater, installUpdate, dismissUpdate, dismissNudge } from "../lib/updater.svelte.js";
|
||||
|
||||
const available = $derived(updater.status?.available ?? null);
|
||||
const installing = $derived(!!updater.status?.installing);
|
||||
const shown = $derived(!!available && !updater.dismissed);
|
||||
const needsKey = $derived(
|
||||
!available
|
||||
&& !!updater.status
|
||||
&& !updater.status.has_token
|
||||
&& /access token/.test(updater.status.last_error ?? "")
|
||||
&& !updater.nudgeDismissed,
|
||||
);
|
||||
const pct = $derived.by(() => {
|
||||
const p = updater.progress;
|
||||
if (!p || !p.total) return null;
|
||||
return Math.min(100, Math.round((p.received / p.total) * 100));
|
||||
});
|
||||
|
||||
function openSettings() {
|
||||
dismissNudge();
|
||||
goToView("settings");
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if shown}
|
||||
@@ -30,6 +47,15 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if needsKey}
|
||||
<div class="layer" aria-live="polite">
|
||||
<div class="toast" role="status">
|
||||
<span class="ic"><Icon name="key" size={14} /></span>
|
||||
<span class="text">Updates need your <b>alpha key</b></span>
|
||||
<button class="go" onclick={openSettings}>Open Settings</button>
|
||||
<button class="later" onclick={dismissNudge} title="Not now" aria-label="Not now"><Icon name="x" size={13} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
|
||||
@@ -10,9 +10,15 @@ export const updater = $state({
|
||||
progress: null,
|
||||
/** The toast for the current find was dismissed. */
|
||||
dismissed: false,
|
||||
/** The "updates need your alpha key" nudge was waved away this session. */
|
||||
nudgeDismissed: false,
|
||||
error: "",
|
||||
});
|
||||
|
||||
export function dismissNudge() {
|
||||
updater.nudgeDismissed = true;
|
||||
}
|
||||
|
||||
export const updateAvailable = () => updater.status?.available ?? null;
|
||||
|
||||
export async function refreshUpdater() {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// The setup app's frontend: the same toolchain as the app, its own root and
|
||||
// port, and the app's public folder (logo, fonts) served as if its own.
|
||||
import { defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default defineConfig({
|
||||
root: "setup",
|
||||
publicDir: "../public",
|
||||
plugins: [svelte()],
|
||||
clearScreen: false,
|
||||
server: { port: 1425, strictPort: true },
|
||||
build: { outDir: "dist", emptyOutDir: true },
|
||||
});
|
||||
Reference in new issue
Block a user