7 Commits
Author SHA1 Message Date
KeelrLeek 972dd103cb release: 1.8.2-alpha
release / windows (push) Canceled after 0s
2026-09-10 07:19:18 -05:00
KeelrLeek 12fff9f6ad setup, updater: the installer window was a dev build with nothing inside it, and latest never matched a prerelease
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.
2026-09-10 07:19:15 -05:00
KeelrLeek 3cfff93036 release: 1.8.1-alpha
release / windows (push) Canceled after 0s
2026-09-09 18:10:29 -05:00
KeelrLeek f8508da081 setup: the installer is our own window — the alpha key is the gate, the release comes down with it and is verified before the silent install, and the in-app tour asks for the key too 2026-09-09 18:00:02 -05:00
KeelrLeek 8093835482 release workflow: the version step is a block scalar, so its quotes reach bash whole
release / windows (push) Canceled after 0s
2026-09-09 16:20:13 -05:00
KeelrLeek fac6fc33dd release: 1.8.0-alpha
release / windows (push) Canceled after 0s
2026-09-09 16:16:22 -05:00
KeelrLeek cd2391198e first run, updates, installer: a setup tour stands in front of a fresh install, releases arrive through an in-app updater with stable and nightly channels behind a read-only token, and the branded installer builds again 2026-09-09 16:16:19 -05:00
32 changed files with 4140 additions and 38 deletions

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
# The nightly: every day at 06:00 UTC (or by hand), build `main` as
# `X.Y.Z-nightly.YYYYMMDD.<sha>` — X.Y.Z being the version the tree declares
# with its prerelease part dropped — and publish it as the rolling `nightly`
# prerelease, replacing yesterday's. The app's Nightly channel reads
# `releases/download/nightly/latest.json`; a different build is always an
# update there (updater.rs), so the number does not need to climb.
#
# Same secrets and optional publish target as release.yml.
name: nightly
on:
schedule:
- cron: "0 6 * * *"
workflow_dispatch:
permissions:
contents: write
jobs:
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
ref: main
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: "./src-tauri -> ../target"
- run: npm ci
- name: Stamp the nightly version
id: stamp
shell: bash
run: |
base=$(node -p "require('./package.json').version.split('-')[0]")
ver="$base-nightly.$(date -u +%Y%m%d).$(git rev-parse --short HEAD)"
echo "version=$ver" >> "$GITHUB_OUTPUT"
node -e '
const fs = require("fs"); const v = process.argv[1];
for (const f of ["package.json", "package-lock.json"]) {
const j = JSON.parse(fs.readFileSync(f, "utf8"));
j.version = v; if (j.packages && j.packages[""]) j.packages[""].version = v;
fs.writeFileSync(f, JSON.stringify(j, null, 2) + "\n");
}
const c = JSON.parse(fs.readFileSync("src-tauri/tauri.conf.json", "utf8"));
c.version = v; fs.writeFileSync("src-tauri/tauri.conf.json", JSON.stringify(c, null, 2) + "\n");
' "$ver"
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$ver\"/" Cargo.toml
echo "nightly $ver"
- name: Replace yesterday's nightly
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 }}
run: gh release delete nightly --repo "$TARGET" --cleanup-tag --yes || true
- name: Build, sign and publish
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.RELEASES_TOKEN || secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
owner: ${{ vars.RELEASES_OWNER || github.repository_owner }}
repo: ${{ vars.RELEASES_REPO || github.event.repository.name }}
tagName: nightly
releaseName: "Nightly ${{ steps.stamp.outputs.version }}"
releaseBody: "Built from main at ${{ github.sha }}. Nightlies can break."
releaseDraft: false
prerelease: true
includeUpdaterJson: true
args: --bundles nsis
# A private repository's assets are reachable only through the API, so the
# manifest's download URLs become API asset URLs — the app sends its token
# and `Accept: application/octet-stream` for those (updater.rs). Public
# repositories serve the same URLs to anyone, so nothing is lost.
- name: Point the manifest at the API assets
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
run: |
gh release download "$TAG" --repo "$TARGET" --pattern latest.json --output latest.json --clobber
gh api "repos/$TARGET/releases/tags/$TAG" > release.json
node -e '
const fs = require("fs");
const m = JSON.parse(fs.readFileSync("latest.json", "utf8"));
const rel = JSON.parse(fs.readFileSync("release.json", "utf8"));
for (const p of Object.values(m.platforms)) {
const name = decodeURIComponent(p.url.split("/").pop());
const a = rel.assets.find((x) => x.name === name);
if (!a) { console.error("no asset named", name); process.exit(1); }
p.url = a.url;
}
fs.writeFileSync("latest.json", JSON.stringify(m, null, 2) + "\n");
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
+134
View File
@@ -0,0 +1,134 @@
# A release: push a tag `v1.8.0` (the version in tauri.conf.json, package.json
# and Cargo.toml must already say 1.8.0) and this builds the Windows installer,
# signs its updater artifact with the project key, and publishes a GitHub
# release carrying the installer, its .sig and latest.json — the manifest the
# app's Stable channel reads (src-tauri/src/updater.rs).
#
# Secrets this needs on the repository:
# TAURI_SIGNING_PRIVATE_KEY the private key (content of soulstream.key)
# TAURI_SIGNING_PRIVATE_KEY_PASSWORD its password — empty (or unset) for a key without one
# Optional, to publish somewhere other than this repository (this one is
# private, and a private release cannot be read by the updater without a
# token — a separate PUBLIC releases repo is the usual answer):
# RELEASES_TOKEN a token with contents:write on that repository
# vars.RELEASES_OWNER / vars.RELEASES_REPO where to publish
name: release
on:
push:
tags: ["v*"]
workflow_dispatch:
permissions:
contents: write
jobs:
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: "./src-tauri -> ../target"
- run: npm ci
- name: Read the version
id: version
shell: bash
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
env:
GITHUB_TOKEN: ${{ secrets.RELEASES_TOKEN || secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
owner: ${{ vars.RELEASES_OWNER || github.repository_owner }}
repo: ${{ vars.RELEASES_REPO || github.event.repository.name }}
tagName: v__VERSION__
releaseName: "SoulStream __VERSION__"
releaseBody: "See the commits since the last release."
releaseDraft: false
prerelease: ${{ contains(github.ref_name, '-') }}
includeUpdaterJson: true
args: --bundles nsis
# A private repository's assets are reachable only through the API, so the
# manifest's download URLs become API asset URLs — the app sends its token
# and `Accept: application/octet-stream` for those (updater.rs). Public
# repositories serve the same URLs to anyone, so nothing is lost.
- name: Point the manifest at the API assets
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 }}
run: |
gh release download "$TAG" --repo "$TARGET" --pattern latest.json --output latest.json --clobber
gh api "repos/$TARGET/releases/tags/$TAG" > release.json
node -e '
const fs = require("fs");
const m = JSON.parse(fs.readFileSync("latest.json", "utf8"));
const rel = JSON.parse(fs.readFileSync("release.json", "utf8"));
for (const p of Object.values(m.platforms)) {
const name = decodeURIComponent(p.url.split("/").pop());
const a = rel.assets.find((x) => x.name === name);
if (!a) { console.error("no asset named", name); process.exit(1); }
p.url = a.url;
}
fs.writeFileSync("latest.json", JSON.stringify(m, null, 2) + "\n");
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
+4
View File
@@ -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
+933 -24
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -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.7.1-alpha"
version = "1.8.2-alpha"
edition = "2021"
rust-version = "1.77.2"
authors = ["Trifecta Solutions"]
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "soulstream",
"version": "1.7.1-alpha",
"version": "1.8.2-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "soulstream",
"version": "1.7.1-alpha",
"version": "1.8.2-alpha",
"license": "ISC",
"dependencies": {
"@tauri-apps/api": "^2"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "soulstream",
"version": "1.7.1-alpha",
"version": "1.8.2-alpha",
"type": "module",
"description": "SoulStream — music player, downloader and streamer for the Soulseek network",
"scripts": {
+12
View File
@@ -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>
+42
View File
@@ -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"
+31
View File
@@ -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();
}
+12
View File
@@ -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"
]
}
+544
View File
@@ -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");
}
+38
View File
@@ -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"
]
}
}
+524
View File
@@ -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>
+7
View File
@@ -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") });
+2
View File
@@ -56,6 +56,8 @@ tagger = { path = "../crates/tagger" }
metadata = { path = "../crates/metadata" }
library = { path = "../crates/library" }
audio-formats = { path = "../crates/audio-formats" }
tauri-plugin-updater = "2"
semver = "1"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.61.3", features = [
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

+7
View File
@@ -80,6 +80,8 @@ pub struct Settings {
pub word_timing: bool,
/// Dark-glass acrylic backdrop, default off.
pub glass_mode: bool,
/// The first-run tour has been completed or skipped on this install.
pub setup_done: bool,
}
#[tauri::command]
@@ -120,6 +122,7 @@ pub async fn get_settings(
volume_normalization: config::volume_normalization(&cfg),
word_timing: config::word_timing(&cfg),
glass_mode: config::glass_mode(&cfg),
setup_done: config::setup_done(&cfg),
username: cfg.username,
})
}
@@ -149,6 +152,7 @@ pub async fn set_settings(
volume_normalization: Option<bool>,
word_timing: Option<bool>,
glass_mode: Option<bool>,
setup_done: Option<bool>,
) -> Result<(), String> {
let mut cfg = config::load(&app);
let library_folder_moved = download_dir
@@ -223,6 +227,9 @@ pub async fn set_settings(
if glass_mode.is_some() {
cfg.glass_mode = glass_mode;
}
if setup_done.is_some() {
cfg.setup_done = setup_done;
}
config::save(&app, &cfg);
// A new library folder is a new library. Re-base immediately rather than
+46
View File
@@ -175,6 +175,22 @@ pub struct AppConfig {
/// of it is ever published or sent to the person it describes.
#[serde(default)]
pub contacts: Vec<ContactMeta>,
/// The first-run setup has been completed, or skipped, on this install.
/// None → false: a fresh install opens on the welcome tour before the
/// app, and Settings → Account can run it again.
#[serde(default)]
pub setup_done: Option<bool>,
/// The in-app updater (updater.rs). Channel "stable" (None) or
/// "nightly"; auto-check None → true; the two endpoints override the
/// built-in release URLs (a mirror, a public releases repo, a Gitea).
#[serde(default)]
pub update_channel: Option<String>,
#[serde(default)]
pub update_auto_check: Option<bool>,
#[serde(default)]
pub update_endpoint_stable: Option<String>,
#[serde(default)]
pub update_endpoint_nightly: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -243,6 +259,10 @@ pub fn word_timing(cfg: &AppConfig) -> bool {
cfg.word_timing.unwrap_or(false)
}
pub fn setup_done(cfg: &AppConfig) -> bool {
cfg.setup_done.unwrap_or(false)
}
/// One-time migration after the identifier rename: the app-data dir moved
/// from `com.trifecta.soulseek-plus` to the new identifier's folder. Rename
/// the old dir into place so the library DB, config and stream cache
@@ -479,6 +499,32 @@ fn entry(username: &str) -> Option<keyring::Entry> {
keyring::Entry::new(KEYRING_SERVICE, username).ok()
}
/// The updater's read-only token for a private release source (updater.rs),
/// in the Credential Manager beside the Soulseek password: it is a
/// credential, and config.json is a plain file that travels on the drive.
const UPDATE_TOKEN_USER: &str = "updater-token";
pub fn save_update_token(token: &str) {
if let Some(e) = entry(UPDATE_TOKEN_USER) {
if let Err(err) = e.set_password(token) {
log::warn!("failed to store the update token in credential manager: {err}");
}
}
}
pub fn load_update_token() -> Option<String> {
entry(UPDATE_TOKEN_USER)
.and_then(|e| e.get_password().ok())
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
}
pub fn clear_update_token() {
if let Some(e) = entry(UPDATE_TOKEN_USER) {
let _ = e.delete_credential();
}
}
pub fn save_password(username: &str, password: &str) {
if let Some(e) = entry(username) {
if let Err(err) = e.set_password(password) {
+20 -4
View File
@@ -34,6 +34,7 @@ mod overlap;
mod holdings;
mod state;
mod thumbbar;
mod updater;
mod vocal_sep;
mod youtube_playlist;
mod youtube_setlist;
@@ -43,6 +44,7 @@ mod ytdlp;
pub fn run() {
tauri::Builder::default()
.manage(state::AppState::default())
.manage(updater::UpdaterState::default())
.setup(|app| {
app.handle().plugin(
tauri_plugin_log::Builder::default()
@@ -54,6 +56,7 @@ pub fn run() {
.build(),
)?;
app.handle().plugin(tauri_plugin_dialog::init())?;
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
// Must run before anything touches the app-data dir.
config::migrate_app_data(app.handle());
// Prepare the small local vision model while the user is still on
@@ -75,6 +78,9 @@ pub fn run() {
// by default) and forwards adapter status to the webview. Fully
// isolated — its absence or failure touches nothing else.
freenet::init(app.handle());
// Release checks: one shortly after launch, then every six hours,
// never installing on their own (updater.rs).
updater::init(app.handle());
if let Err(e) = pipeline::init(app.handle()) {
log::error!("library init failed: {e}");
} else {
@@ -90,6 +96,10 @@ pub fn run() {
})
.invoke_handler(tauri::generate_handler![
commands::app_status,
updater::updater_status,
updater::updater_check,
updater::updater_set,
updater::updater_install,
commands::excluded_search_phrases,
commands::get_credentials,
commands::connect_and_login,
@@ -370,11 +380,17 @@ pub fn run() {
--autoplay-policy=no-user-gesture-required \
--enable-smooth-scrolling \
--force-color-profile=srgb";
// One cfg over BOTH statements: the attribute on the `let cdp`
// alone left the `format!` below reading a name that release
// builds never define — every `tauri build` since 0.1.0-alpha.3
// failed on it (found 2026-09-09 making the first installer).
#[cfg(debug_assertions)]
// A second instance needs its own CDP port, or it silently fails
// to bind and cannot be driven at all.
let cdp = std::env::var("SOULSTREAM_CDP_PORT").unwrap_or_else(|_| "9222".into());
let args = format!("{base} --remote-debugging-port={cdp}");
let args = {
// A second instance needs its own CDP port, or it silently
// fails to bind and cannot be driven at all.
let cdp = std::env::var("SOULSTREAM_CDP_PORT").unwrap_or_else(|_| "9222".into());
format!("{base} --remote-debugging-port={cdp}")
};
#[cfg(not(debug_assertions))]
let args = base.to_string();
for window in &mut context.config_mut().app.windows {
+555
View File
@@ -0,0 +1,555 @@
//! The in-app updater: checks a release manifest, offers what it finds, and
//! hands the installer to `tauri-plugin-updater` (signature verified against
//! the key baked into tauri.conf.json). Two channels — `stable`, the latest
//! GitHub release, and `nightly`, a rolling prerelease rebuilt from `main`
//! by `.github/workflows/nightly.yml` — with the manifest URLs overridable
//! in config (a mirror, a separate public releases repo, a Gitea). Nothing
//! is ever installed without the user pressing Install: the automatic
//! check only records what it found and tells the webview.
//!
//! PRIVATE RELEASES (the closed alpha): the repository is private, and a
//! private release cannot be read without a token. With a read-only token
//! saved in the Credential Manager (Settings → Updates → Access token), a
//! GitHub source is resolved through the API instead of the browser URLs:
//! `GET /repos/{o}/{r}/releases/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
//! headers. Any non-GitHub source simply gets the token as a bearer header.
//! Without a token the public browser URLs are used, unchanged.
//!
//! Versions: a nightly is `X.Y.Z-nightly.YYYYMMDD.sha`, so on the nightly
//! channel any different build is an update; on stable, newer wins, and a
//! machine that was on nightly is offered the stable with the same base
//! number or higher (semver would call that a downgrade and refuse).
use std::sync::Mutex;
use std::time::Duration;
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, State, Url};
use tauri_plugin_updater::{Update, UpdaterExt};
use crate::config;
pub const DEFAULT_STABLE: &str =
"https://github.com/KeelrLeek/SoulStream/releases/latest/download/latest.json";
pub const DEFAULT_NIGHTLY: &str =
"https://github.com/KeelrLeek/SoulStream/releases/download/nightly/latest.json";
/// 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);
const CHECK_EVERY: Duration = Duration::from_secs(6 * 60 * 60);
#[derive(Default)]
pub struct UpdaterState {
inner: Mutex<Inner>,
}
#[derive(Default)]
struct Inner {
found: Option<Update>,
checking: bool,
installing: bool,
last_check: Option<i64>,
last_error: Option<String>,
progress: Option<Progress>,
}
#[derive(Serialize, Clone, Copy)]
pub struct Progress {
pub received: u64,
pub total: Option<u64>,
}
#[derive(Serialize, Clone)]
pub struct Available {
pub version: String,
pub notes: Option<String>,
pub date: Option<i64>,
}
/// What the Settings card and the toast read.
#[derive(Serialize, Clone)]
pub struct Status {
pub version: String,
pub channel: String,
pub auto_check: bool,
pub endpoint_stable: String,
pub endpoint_nightly: String,
pub default_stable: String,
pub default_nightly: String,
/// A read-only token for a private source is saved (never the token).
pub has_token: bool,
/// "github" when the current channel's source is a GitHub releases URL
/// (resolved through the API when a token is set), else "raw".
pub source_kind: String,
pub available: Option<Available>,
pub checking: bool,
pub installing: bool,
pub last_check: Option<i64>,
pub last_error: Option<String>,
pub progress: Option<Progress>,
/// A dev build run from the target dir has nothing to update in place.
pub dev_build: bool,
}
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
pub fn channel_of(cfg: &config::AppConfig) -> String {
match cfg.update_channel.as_deref() {
Some("nightly") => "nightly".into(),
_ => "stable".into(),
}
}
pub fn auto_check(cfg: &config::AppConfig) -> bool {
cfg.update_auto_check.unwrap_or(true)
}
fn endpoint(cfg: &config::AppConfig, channel: &str) -> String {
let pick = |v: &Option<String>, d: &str| {
v.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.unwrap_or_else(|| d.to_string())
};
if channel == "nightly" {
pick(&cfg.update_endpoint_nightly, DEFAULT_NIGHTLY)
} else {
pick(&cfg.update_endpoint_stable, DEFAULT_STABLE)
}
}
/// A GitHub releases URL, taken apart: `github.com/{owner}/{repo}/releases/
/// latest/download/<file>` or `…/releases/download/{tag}/<file>`.
#[derive(Debug, PartialEq, Eq)]
pub struct GitHubSource {
pub owner: String,
pub repo: String,
/// `latest`, or `tags/<tag>` — the API path segment.
pub release: String,
pub file: String,
}
pub fn github_source(url: &Url) -> Option<GitHubSource> {
if url.host_str()? != "github.com" {
return None;
}
let seg: Vec<&str> = url.path_segments()?.filter(|s| !s.is_empty()).collect();
match seg.as_slice() {
[owner, repo, "releases", "latest", "download", file] => Some(GitHubSource {
owner: owner.to_string(),
repo: repo.to_string(),
release: "latest".into(),
file: file.to_string(),
}),
[owner, repo, "releases", "download", tag, file] => Some(GitHubSource {
owner: owner.to_string(),
repo: repo.to_string(),
release: format!("tags/{tag}"),
file: file.to_string(),
}),
_ => None,
}
}
fn status(app: &AppHandle) -> Status {
let cfg = config::load(app);
let channel = channel_of(&cfg);
let ep = endpoint(&cfg, &channel);
let source_kind = match Url::parse(&ep).ok().and_then(|u| github_source(&u)) {
Some(_) => "github",
None => "raw",
};
let st = app.state::<UpdaterState>();
let inner = st.inner.lock().unwrap();
Status {
version: app.package_info().version.to_string(),
endpoint_stable: endpoint(&cfg, "stable"),
endpoint_nightly: endpoint(&cfg, "nightly"),
default_stable: DEFAULT_STABLE.into(),
default_nightly: DEFAULT_NIGHTLY.into(),
has_token: config::load_update_token().is_some(),
source_kind: source_kind.into(),
auto_check: auto_check(&cfg),
channel,
available: inner.found.as_ref().map(|u| Available {
version: u.version.clone(),
notes: u.body.clone(),
date: u.date.map(|d| d.unix_timestamp()),
}),
checking: inner.checking,
installing: inner.installing,
last_check: inner.last_check,
last_error: inner.last_error.clone(),
progress: inner.progress,
dev_build: cfg!(debug_assertions),
}
}
fn announce(app: &AppHandle) {
let _ = app.emit("updater:status", status(app));
}
/// 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<serde_json::Value, String> {
let resp = client
.get(url)
.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"))
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
.header(reqwest::header::USER_AGENT, user_agent)
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.await
.map_err(|e| format!("could not reach the GitHub API: {e}"))?;
match resp.status().as_u16() {
200 => {}
401 => return Err("the access token was refused".into()),
403 => return Err("the access token cannot read that repository".into()),
404 => return Err("no release found — or the token cannot see the repository".into()),
s => return Err(format!("GitHub answered {s}")),
}
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()
.find(|a| a["name"].as_str() == Some(src.file.as_str()))
.ok_or_else(|| format!("the release has no {}", src.file))?;
let url = asset["url"].as_str().ok_or("asset without a URL")?;
Url::parse(url).map_err(|e| e.to_string())
}
/// One check against the current channel's manifest. Records the result
/// (found update, time, error) and announces it; never installs.
async fn check_inner(app: &AppHandle) -> Result<(), String> {
let cfg = config::load(app);
let channel = channel_of(&cfg);
let nightly = channel == "nightly";
let ep = endpoint(&cfg, &channel);
let mut url = Url::parse(&ep).map_err(|e| format!("update source is not a URL: {e}"))?;
let token = config::load_update_token();
let user_agent = format!("SoulStream/{}", app.package_info().version);
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::USER_AGENT,
user_agent.parse().map_err(|_| "user agent")?,
);
let gh = github_source(&url);
if let Some(t) = &token {
headers.insert(
reqwest::header::AUTHORIZATION,
format!("Bearer {t}").parse().map_err(|_| "token has characters a header cannot carry")?,
);
if let Some(src) = &gh {
url = resolve_github_asset(src, t, &user_agent).await?;
}
}
if gh.is_some() {
// Asset URLs (the API kind the workflows write into the manifest, and
// the one resolved above) hand over bytes only for this Accept; the
// browser URLs do not mind it.
headers.insert(
reqwest::header::ACCEPT,
"application/octet-stream".parse().map_err(|_| "accept")?,
);
}
let updater = app
.updater_builder()
.endpoints(vec![url])
.map_err(|e| e.to_string())?
.headers(headers)
.timeout(Duration::from_secs(30))
.version_comparator(move |current, release| {
if nightly {
// Every nightly carries its date and commit: a different
// build is an update, older number or not.
return release.version != current;
}
if release.version > current {
return true;
}
// Back from nightly to stable: the release with the nightly's
// base number, or higher, is the way home.
if current.pre.as_str().starts_with("nightly") && release.version.pre.is_empty() {
let base = |v: &semver::Version| (v.major, v.minor, v.patch);
return base(&release.version) >= base(&current);
}
false
})
.build()
.map_err(|e| e.to_string())?;
let result = updater.check().await;
let st = app.state::<UpdaterState>();
let mut inner = st.inner.lock().unwrap();
inner.last_check = Some(now());
inner.checking = false;
match result {
Ok(found) => {
if let Some(u) = &found {
log::info!("updater: {} {} available on {channel}", u.version, u.target);
} else {
log::info!("updater: up to date on {channel}");
}
inner.found = found;
inner.last_error = None;
Ok(())
}
Err(e) => {
let mut msg = e.to_string();
if token.is_none() && gh.is_some() && msg.contains("valid release JSON") {
msg.push_str(" — a private repository needs an access token (Settings → Updates)");
}
log::warn!("updater: check on {channel} failed: {msg}");
inner.found = None;
inner.last_error = Some(msg.clone());
Err(msg)
}
}
}
async fn run_check(app: &AppHandle) -> Result<(), String> {
{
let st = app.state::<UpdaterState>();
let mut inner = st.inner.lock().unwrap();
if inner.checking || inner.installing {
return Ok(());
}
inner.checking = true;
}
announce(app);
let r = match check_inner(app).await {
Ok(()) => Ok(()),
Err(e) => {
// check_inner records its own errors; a failure before the
// check ran (a bad URL, the API) lands here.
let st = app.state::<UpdaterState>();
let mut inner = st.inner.lock().unwrap();
inner.last_check = Some(now());
inner.found = None;
inner.last_error = Some(e.clone());
Err(e)
}
};
{
let st = app.state::<UpdaterState>();
st.inner.lock().unwrap().checking = false;
}
announce(app);
r
}
/// The automatic checks: one shortly after launch, then every six hours,
/// as long as the setting allows. Finding one only announces it.
pub fn init(app: &AppHandle) {
let app = app.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(FIRST_CHECK).await;
loop {
if auto_check(&config::load(&app)) {
let _ = run_check(&app).await;
}
tokio::time::sleep(CHECK_EVERY).await;
}
});
}
#[tauri::command]
pub fn updater_status(app: AppHandle) -> Status {
status(&app)
}
#[tauri::command]
pub async fn updater_check(app: AppHandle) -> Result<Status, String> {
run_check(&app).await?;
Ok(status(&app))
}
/// Channel, automatic checking, the two manifest URLs, and the access token
/// (empty clears it; it lives in the Credential Manager). A channel change
/// forgets whatever the old channel found.
#[tauri::command]
pub fn updater_set(
app: AppHandle,
state: State<'_, UpdaterState>,
channel: Option<String>,
auto_check: Option<bool>,
endpoint_stable: Option<String>,
endpoint_nightly: Option<String>,
token: Option<String>,
) -> Status {
let mut cfg = config::load(&app);
if let Some(c) = channel {
let c = if c == "nightly" { "nightly" } else { "stable" };
if channel_of(&cfg) != c {
let mut inner = state.inner.lock().unwrap();
inner.found = None;
inner.last_error = None;
inner.last_check = None;
}
cfg.update_channel = Some(c.to_string());
}
if let Some(a) = auto_check {
cfg.update_auto_check = Some(a);
}
if let Some(e) = endpoint_stable {
cfg.update_endpoint_stable = Some(e.trim().to_string()).filter(|s| !s.is_empty());
}
if let Some(e) = endpoint_nightly {
cfg.update_endpoint_nightly = Some(e.trim().to_string()).filter(|s| !s.is_empty());
}
if let Some(t) = token {
let t = t.trim();
if t.is_empty() {
config::clear_update_token();
} else {
config::save_update_token(t);
}
// A new key to the door: the last answer no longer applies.
let mut inner = state.inner.lock().unwrap();
inner.last_error = None;
}
config::save(&app, &cfg);
let s = status(&app);
let _ = app.emit("updater:status", s.clone());
s
}
/// Download the found update and run its installer. On Windows the plugin
/// starts the installer (passive mode, per tauri.conf.json) and exits this
/// process, so anything after a success here never runs.
#[tauri::command]
pub async fn updater_install(app: AppHandle) -> Result<(), String> {
let update = {
let st = app.state::<UpdaterState>();
let mut inner = st.inner.lock().unwrap();
if inner.installing {
return Err("already installing".into());
}
let Some(u) = inner.found.clone() else {
return Err("no update has been found — check first".into());
};
inner.installing = true;
inner.progress = Some(Progress { received: 0, total: None });
u
};
announce(&app);
let progress_app = app.clone();
let mut received: u64 = 0;
let mut last_emit = std::time::Instant::now();
let result = update
.download_and_install(
move |chunk, total| {
received += chunk as u64;
if last_emit.elapsed() >= Duration::from_millis(150) {
last_emit = std::time::Instant::now();
let _ = progress_app.emit(
"updater:progress",
Progress { received, total },
);
}
},
|| {
log::info!("updater: download complete, handing over to the installer");
},
)
.await;
let st = app.state::<UpdaterState>();
let mut inner = st.inner.lock().unwrap();
inner.installing = false;
inner.progress = None;
match result {
Ok(()) => Ok(()),
Err(e) => {
let msg = e.to_string();
log::warn!("updater: install failed: {msg}");
inner.last_error = Some(msg.clone());
drop(inner);
announce(&app);
Err(msg)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn github_urls_are_taken_apart() {
let u = Url::parse(DEFAULT_STABLE).unwrap();
assert_eq!(
github_source(&u),
Some(GitHubSource {
owner: "KeelrLeek".into(),
repo: "SoulStream".into(),
release: "latest".into(),
file: "latest.json".into()
})
);
let u = Url::parse(DEFAULT_NIGHTLY).unwrap();
assert_eq!(github_source(&u).unwrap().release, "tags/nightly");
let u = Url::parse("https://git.whoagland.com/Keelrleek/SoulStream/releases/download/v1/latest.json").unwrap();
assert_eq!(github_source(&u), None);
let u = Url::parse("https://github.com/KeelrLeek/SoulStream/releases").unwrap();
assert_eq!(github_source(&u), None);
}
}
+25 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "SoulStream",
"version": "1.7.1-alpha",
"version": "1.8.2-alpha",
"identifier": "com.soulstream.app",
"build": {
"beforeDevCommand": "npm run dev",
@@ -44,7 +44,9 @@
},
"bundle": {
"active": true,
"targets": ["nsis"],
"targets": [
"nsis"
],
"resources": [
"resources/FidelityFX_CLI.exe",
"resources/FidelityFX-LICENSE.txt",
@@ -67,7 +69,27 @@
"windows": {
"nsis": {
"installMode": "currentUser",
"startMenuFolder": "SoulStream"
"startMenuFolder": "SoulStream",
"installerIcon": "icons/icon.ico",
"uninstallerIcon": "icons/icon.ico",
"headerImage": "installer/header.bmp",
"sidebarImage": "installer/sidebar.bmp",
"languages": [
"English"
],
"displayLanguageSelector": false
}
},
"createUpdaterArtifacts": true
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQxOTQyMEVCN0QwMUJCNjgKUldSb3V3Rjk2eUNVUWJaZUpZcUt1VWlZbmo1Sm9tS01zMTAzSjJpdksrazQ2dVNGU0RxKzAreTIK",
"endpoints": [
"https://github.com/KeelrLeek/SoulStream/releases/latest/download/latest.json"
],
"windows": {
"installMode": "passive"
}
}
}
+24 -3
View File
@@ -7,6 +7,10 @@
import LyricsSyncEditor from "./components/LyricsSyncEditor.svelte";
import ImportWindow from "./components/ImportWindow.svelte";
import LoginModal from "./components/LoginModal.svelte";
import FirstRun from "./components/FirstRun.svelte";
import { firstRun } from "./lib/firstRun.svelte.js";
import UpdateToast from "./components/UpdateToast.svelte";
import { startUpdaterWatch } from "./lib/updater.svelte.js";
import PreviewOverlay from "./components/PreviewOverlay.svelte";
import DebugOverlay from "./components/DebugOverlay.svelte";
import GlassBar from "./components/GlassBar.svelte";
@@ -128,7 +132,13 @@
// Dark-glass mode is persisted in config; the window boots solid and
// glasses up as soon as the setting arrives (no-op when off).
void getSettings()
.then((s) => s?.glass_mode && applyGlass(true))
.then((s) => {
if (s?.glass_mode) applyGlass(true);
// A fresh install (no setup_done in its config) meets the
// first-run tour before anything else; FirstRun.svelte stamps
// the flag when it is finished or skipped.
if (s && !s.setup_done) firstRun.open = true;
})
.catch(() => {});
// Windows drops the acrylic blur for the length of a live resize and the
// window stays transparent through it, so the desktop punches through
@@ -136,6 +146,9 @@
// armGlassResizeGuard. Armed unconditionally: the CSS gates on .glass, and
// this way the mode can be toggled mid-drag without leaving stale state.
armGlassResizeGuard();
// The updater's status and finds arrive as events; the backend checks
// on its own schedule and never installs without the button.
startUpdaterWatch();
autoConnect();
// Sealed cargo arrives on the inbox check, and that has to keep happening
// wherever the user is — not only while the Community page is mounted.
@@ -157,6 +170,7 @@
class:player-expanded={ui.playerExpanded}
class:np-dragging={ui.nowPlayingDragging}
class:preview-open={previewUi.open}
class:setup-open={firstRun.open}
style="--np-open-width: {ui.nowPlayingWidth}px"
>
<TitleBar />
@@ -175,7 +189,7 @@
would be masked with it; the per-frame page-mask this replaces
never tracked cleanly). The inner div KEEPS the .content class:
every page resolves its scroll pane via closest(".content"). -->
<main class="pane-shell" class:barless={!BAR_VIEWS.has(ui.view)} inert={previewUi.open || undefined}>
<main class="pane-shell" class:barless={!BAR_VIEWS.has(ui.view)} inert={previewUi.open || firstRun.open || undefined}>
<GlassBar hidden={!BAR_VIEWS.has(ui.view)} />
<div class="content" use:floatScroll>
<CurrentView />
@@ -189,6 +203,7 @@
<UploadToast />
<ProposalToast />
<FollowNewsToast />
<UpdateToast />
<WhoHasSheet />
<SetlistScanChip />
<PreviewOverlay />
@@ -196,6 +211,9 @@
<ArtworkViewer />
<ImportWindow />
<LoginModal />
<!-- Above the login modal: the tour has its own sign-in step, and a
failed auto-connect at boot must not raise the modal over it. -->
<FirstRun />
<DebugOverlay />
</div>
@@ -208,7 +226,10 @@
visible (dimmed under the scrim) but cannot command main audio. */
.app.preview-open :global(.sidebar),
.app.preview-open :global(.nowplaying),
.app.preview-open :global(.player) {
.app.preview-open :global(.player),
.app.setup-open :global(.sidebar),
.app.setup-open :global(.nowplaying),
.app.setup-open :global(.player) {
pointer-events: none;
}
+632
View File
@@ -0,0 +1,632 @@
<script>
// The first-run setup: a tour that stands in front of the app on a fresh
// install (config.setup_done unset) and asks for the things the app
// cannot guess — where music lives, the Soulseek account, whether the
// community peer runs, how the window is drawn. Every answer is applied
// the moment its step is left, so a skip halfway loses nothing, and
// Settings → Account runs the tour again. Solid surfaces on purpose:
// no backdrop-filter anywhere (dead over the frost, Library.svelte's
// note) — the scrim is a plain veil, the panel the elevated surface.
import { tick } from "svelte";
import { fade, fly, scale } from "svelte/transition";
import { cubicOut, quintOut } from "svelte/easing";
import Icon from "./Icon.svelte";
import * as api from "../lib/api.js";
import { app, connect } from "../lib/state.svelte.js";
import { applyGlass } from "../lib/glass.js";
import { firstRun, finishFirstRun } from "../lib/firstRun.svelte.js";
import {
community,
refreshFreenet,
setFreenetEnabled,
refreshPersona,
createPersona,
publishProfile,
personaKey,
} from "../lib/community.svelte.js";
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" },
{ id: "look", label: "Look & sound", icon: "eye" },
{ id: "ready", label: "Ready", icon: "check" },
];
// Album folder formats offered as one-click presets; the field beside
// them takes anything Settings would.
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 settings = $state(null);
let libraryDir = $state("");
let layout = $state("");
let username = $state("");
let password = $state("");
let remember = $state(true);
let freenetOn = $state(false);
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);
let panelEl = $state(null);
const cur = $derived(STEPS[step]);
const online = $derived(app.connection === "online");
const reduced = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
const dur = (ms) => (reduced() ? 0 : ms);
$effect(() => {
if (!firstRun.open) return;
step = 0;
dir = 1;
error = "";
closing = false;
password = "";
void load();
});
async function load() {
try {
settings = await api.getSettings();
libraryDir = settings.download_dir ?? "";
layout = settings.library_layout ?? "";
username = settings.username ?? "";
glass = !!settings.glass_mode;
loudness = settings.volume_normalization !== false;
} catch (e) {
error = String(e);
}
try {
await refreshFreenet();
freenetOn = !!community.freenet?.enabled;
} catch {
freenetOn = false;
}
try {
hasToken = !!(await api.updaterStatus()).has_token;
} catch {
hasToken = false;
}
await tick();
panelEl?.querySelector(".primary")?.focus();
}
async function commit(patch) {
busy = true;
error = "";
try {
await api.setSettings(patch);
return true;
} catch (e) {
error = String(e);
return false;
} finally {
busy = false;
}
}
async function pickDir() {
const dir = await api.pickFolder().catch(() => null);
if (dir) libraryDir = dir;
}
async function signIn() {
if (!username.trim() || !password) return;
await connect(username.trim(), password, remember);
}
async function setGlass(v) {
glass = v;
// Applies live, like Settings: the acrylic and the CSS thinning land
// together, and the scrim lets the change show around the panel.
if (await commit({ glassMode: v })) applyGlass(v);
}
async function setLoudness(v) {
loudness = v;
await commit({ volumeNormalization: v });
}
async function next() {
if (busy || closing) return;
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();
if (lay && lay !== settings?.library_layout) patch.libraryLayout = lay;
if (Object.keys(patch).length) {
if (!(await commit(patch))) return;
if (settings) {
if (patch.downloadDir) settings.download_dir = patch.downloadDir;
if (patch.libraryLayout) settings.library_layout = patch.libraryLayout;
}
}
} else if (cur.id === "community") {
if (freenetOn !== !!community.freenet?.enabled) {
busy = true;
error = "";
try {
await setFreenetEnabled(freenetOn);
} catch (e) {
error = String(e);
} finally {
busy = false;
}
if (error) return;
}
}
if (step < STEPS.length - 1) {
dir = 1;
step += 1;
await tick();
panelEl?.querySelector(".primary")?.focus();
}
}
function back() {
if (step === 0 || busy || closing) return;
dir = -1;
step -= 1;
}
async function finish() {
if (closing) return;
closing = true;
const name = displayName.trim();
const wantsIdentity = freenetOn && name;
await finishFirstRun();
if (wantsIdentity) {
// Off the tour, best effort: the peer may still be coming up.
// Either half can be redone from Community → You.
void (async () => {
try {
await refreshPersona();
if (!personaKey()) await createPersona();
await publishProfile(name, "");
} catch {
// Community → You shows the state and the buttons
}
})();
}
}
function onKeydown(e) {
if (!firstRun.open || closing) return;
if (e.key === "Tab") return trapTab(e);
if (e.key !== "Enter" || e.shiftKey) return;
const t = e.target;
if (t instanceof HTMLButtonElement || t instanceof HTMLTextAreaElement) return;
if (t instanceof HTMLInputElement && t.form) return; // the sign-in form submits itself
e.preventDefault();
if (cur.id === "ready") finish();
else next();
}
function trapTab(e) {
if (!panelEl) return;
const focusables = [...panelEl.querySelectorAll('button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])')];
if (!focusables.length) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
} else if (!panelEl.contains(document.activeElement)) {
e.preventDefault();
first.focus();
}
}
</script>
<svelte:window onkeydown={onKeydown} />
{#if firstRun.open}
<div class="scrim" transition:fade={{ duration: dur(260) }} role="presentation">
<div
class="panel"
bind:this={panelEl}
role="dialog"
aria-modal="true"
aria-labelledby="fr-title"
in:scale={{ start: 0.96, duration: dur(460), easing: quintOut }}
out:scale={{ start: 1.03, duration: dur(320), easing: cubicOut }}
>
<aside class="rail" aria-label="Setup steps">
<div class="brand">
<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">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 }}>Welcome to SoulStream</h1>
<p class="lede" in:fly={{ y: 14, duration: dur(520), delay: dur(340), easing: quintOut }}>
Your library on your own disk, music from people on Soulseek, and a community
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, 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">
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 === "soulseek"}
<h1 id="fr-title">Your Soulseek account</h1>
<p class="lede">
Any username and password works — a new account is created on first sign-in,
so pick something unique. The password goes to the Windows Credential Manager,
never a plain file.
</p>
{#if online}
<div class="card">
<div class="row">
<div class="label">
<div class="name">Signed in <span class="tag ok">online</span></div>
<div class="hint">As <b>{app.username}</b>. Your library folder is being shared. You can switch accounts any time in Settings → Account.</div>
</div>
</div>
</div>
{:else}
<form class="card form" onsubmit={(e) => { e.preventDefault(); signIn(); }}>
<label class="field">
<span>Username</span>
<input bind:value={username} spellcheck="false" autocomplete="off" />
</label>
<label class="field">
<span>Password</span>
<input type="password" bind:value={password} autocomplete="off" />
</label>
<label class="check"><input type="checkbox" bind:checked={remember} /> Remember me on this computer</label>
{#if app.loginError}<div class="err">{app.loginError}</div>{/if}
<div class="formfoot">
<button class="ax-white" type="submit" disabled={app.connection === "connecting" || !username.trim() || !password}>
{app.connection === "connecting" ? "Connecting…" : "Sign in"}
</button>
<span class="hint">Or press Later and sign in when you first play something.</span>
</div>
</form>
{/if}
{:else if cur.id === "community"}
<h1 id="fr-title">A community on your own peer</h1>
<p class="lede">
The social side — an identity, a profile, shared playlists, friends and Spaces —
runs on Freenet, a peer on this machine. Off by default, and nothing is ever
published until you press a button that says so.
</p>
<div class="card">
<div class="row">
<div class="label">
<div class="name">Run the community peer <span class="tag">experimental</span></div>
<div class="hint">Uses some network, disk and CPU in the background. Turning it off later is instant and touches nothing else in the app.</div>
</div>
<div class="ctl">
<span class="tval">{freenetOn ? "on" : "off"}</span>
<button class="toggle" class:on={freenetOn} role="switch" aria-checked={freenetOn} aria-label="Run the community peer" onclick={() => (freenetOn = !freenetOn)} disabled={busy}><i></i></button>
</div>
</div>
{#if freenetOn}
<div class="row" in:fly={{ y: -6, duration: dur(220) }}>
<div class="label">
<div class="name">Display name</div>
<div class="hint">What others see on your profile. Optional — the identity is created either way, and the profile can be written from Community → You.</div>
</div>
<div class="ctl"><input bind:value={displayName} placeholder="Your name" maxlength="48" spellcheck="false" autocomplete="off" aria-label="Display name" /></div>
</div>
{/if}
</div>
{:else if cur.id === "look"}
<h1 id="fr-title">How the window is drawn</h1>
<p class="lede">Both apply as you flip them, so look at the window behind this card.</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={() => setGlass(!glass)} disabled={busy}><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={() => setLoudness(!loudness)} disabled={busy}><i></i></button>
</div>
</div>
</div>
{:else}
<h1 id="fr-title">You're set</h1>
<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"],
["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}
<button class="ax-ghost" onclick={back} disabled={busy || closing}>Back</button>
{/if}
<span class="spacer"></span>
{#if error}<span class="err">{error}</span>{/if}
{#if cur.id === "welcome"}
<button class="lnk" onclick={finish} disabled={closing}>Skip setup</button>
<button class="ax-fill primary" onclick={next} disabled={closing}>Let's set it up <Icon name="chevron-right" size={14} /></button>
{:else if cur.id === "ready"}
<button class="ax-fill primary big" onclick={finish} disabled={closing}>Open SoulStream</button>
{:else}
{#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>
{/if}
</footer>
</div>
</div>
</div>
{/if}
<style>
.scrim {
position: fixed;
inset: 0;
z-index: 110;
display: grid;
place-items: center;
/* A plain veil — never a backdrop-filter (dead over the frost). */
background: rgba(0, 0, 0, 0.72);
--surf: var(--bg-elevated);
--surf-hi: var(--bg-highlight);
--hair: rgba(255, 255, 255, 0.07);
--ok: #3ddc84;
}
.panel {
width: min(780px, 94vw);
height: min(560px, 92vh);
display: grid;
grid-template-columns: 212px 1fr;
background: var(--bg-elevated);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 18px;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6);
overflow: hidden;
color: var(--text-primary);
}
/* ── 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; }
/* In and out share the cell so a step leaving never pushes the next. */
.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; }
.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: 46ch; }
.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); }
/* Settings' own card vocabulary, so the tour and the page read as one. */
.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; }
.hint b { color: var(--text-primary); font-weight: 700; }
.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; }
.toggle:disabled { opacity: 0.45; cursor: default; }
.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; }
input:focus { outline: none; border-color: var(--accent); }
input.mono { font-family: "Cascadia Code", Consolas, monospace; font-size: 12px; width: 220px; }
.form { display: flex; flex-direction: column; gap: 12px; padding: 16px; }
.field { display: flex; flex-direction: column; gap: 5px; font-size: 12px; font-weight: 700; color: var(--text-secondary); }
.field input { width: 100%; max-width: 360px; }
.check { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--text-secondary); }
.check input { height: auto; accent-color: var(--accent); }
.formfoot { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.err { font-size: 12px; color: #ff8d98; }
.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-family: "Cascadia Code", Consolas, monospace; font-size: 12px; font-weight: 600; }
/* ── the footer ───────────────────────────────────────────────── */
.foot { display: flex; align-items: center; gap: 12px; padding: 14px 24px 18px 32px; border-top: 1px solid var(--hair); }
.spacer { flex: 1; }
.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, transform 0.15s; }
.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 { animation: none; }
}
</style>
+156
View File
@@ -0,0 +1,156 @@
<script>
// A release was found by the automatic check: the same pill and slot as
// 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 { 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}
<div class="layer" aria-live="polite">
<div class="toast" role="status">
<span class="ic"><Icon name="download" size={14} /></span>
{#if installing}
<span class="text">Installing SoulStream {available.version}{#if pct != null} · {pct}%{:else}…{/if}</span>
<span class="bar" aria-hidden="true"><i style="width: {pct ?? 8}%"></i></span>
{:else}
<span class="text">SoulStream <b>{available.version}</b> is ready</span>
<button class="go" onclick={installUpdate}>Install and restart</button>
<button class="later" onclick={dismissUpdate} title="Later" aria-label="Later"><Icon name="x" size={13} /></button>
{/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>
/* The shared toast slot — FollowToast's geometry verbatim. */
.layer {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--player-height, 62px) + 8px + 26px);
display: flex;
justify-content: center;
z-index: 80;
pointer-events: none;
}
.toast {
pointer-events: auto;
display: inline-flex;
align-items: center;
gap: 10px;
max-width: min(70vw, 560px);
padding: 7px 8px 7px 16px;
border-radius: 99px;
background: rgba(22, 23, 28, 0.96);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.55);
font-size: 12.5px;
font-weight: 700;
color: #fff;
animation: toast-in 340ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(16px) scale(0.95);
}
to {
opacity: 1;
transform: none;
}
}
.ic {
display: inline-flex;
color: var(--accent);
}
.text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
b {
font-weight: 800;
}
.go {
height: 28px;
padding: 0 12px;
border-radius: 99px;
background: var(--accent);
color: #000;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.go:hover {
background: var(--accent-hover);
}
.later {
width: 28px;
height: 28px;
border-radius: 99px;
display: grid;
place-items: center;
color: var(--text-secondary);
}
.later:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.bar {
width: 120px;
height: 4px;
border-radius: 99px;
background: rgba(255, 255, 255, 0.12);
overflow: hidden;
margin-right: 8px;
}
.bar i {
display: block;
height: 100%;
background: var(--accent);
border-radius: 99px;
transition: width 0.2s;
}
@media (prefers-reduced-motion: reduce) {
.toast {
animation-duration: 1ms;
}
}
</style>
+7
View File
@@ -226,6 +226,13 @@ export const refreshAlbumArt = (albumId) => invoke("refresh_album_art", { albumI
export const refreshAllAlbumArt = () => invoke("refresh_all_album_art");
export const getSettings = () => invoke("get_settings");
export const setSettings = (patch) => invoke("set_settings", patch);
// The in-app updater (updater.rs): status, a check on the current channel,
// settings (channel / autoCheck / endpointStable / endpointNightly), and the
// install — which on Windows ends this process when it succeeds.
export const updaterStatus = () => invoke("updater_status");
export const updaterCheck = () => invoke("updater_check");
export const updaterSet = (patch) => invoke("updater_set", patch);
export const updaterInstall = () => invoke("updater_install");
export const windowGlass = (on) => invoke("window_glass", { on });
export const forgetCredentials = () => invoke("forget_credentials");
export const pickFolder = () => invoke("pick_folder");
+29
View File
@@ -0,0 +1,29 @@
// The first-run setup's one piece of shared state: whether the tour stands
// in front of the app. App.svelte opens it when the settings arrive without
// `setup_done`; Settings → Account opens it again on request; the tour
// itself closes it and stamps the flag. The flag lives in config.json (per
// install, so it travels with a portable data dir), not in the webview.
import { setSettings } from "./api.js";
export const firstRun = $state({
open: false,
/** "boot" = a fresh install, "settings" = run again by hand. */
reason: "boot",
});
export function openFirstRun(reason = "settings") {
firstRun.reason = reason;
firstRun.open = true;
}
/** Close the tour and remember that it ran. The flag write is best effort:
* a failed save re-opens the tour on the next launch, which is the honest
* outcome — nothing else in the app depends on it. */
export async function finishFirstRun() {
firstRun.open = false;
try {
await setSettings({ setupDone: true });
} catch {
// see above
}
}
+4
View File
@@ -18,6 +18,7 @@ import {
notePlaybackGeneration,
} from "./preview/previewSession.svelte.js";
import { CROSSFADE_MS as PREVIEW_CROSSFADE_MS } from "./preview/service.js";
import { firstRun, openFirstRun, finishFirstRun } from "./firstRun.svelte.js";
export const app = $state({
connection: "offline", // offline | connecting | online
@@ -7836,5 +7837,8 @@ if (import.meta.env.DEV) {
followNewsToast, announceFollowNews,
searchTabs, selectSearchTab, closeSearchTab, filesForGroup,
downloads, network, sendDm, openChat,
// The first-run tour: probes on a fresh data dir need to open it,
// drive it, or clear it out of the way.
firstRun, openFirstRun, finishFirstRun,
};
}
+85
View File
@@ -0,0 +1,85 @@
// The updater's view of things: the backend's status snapshot (updater.rs),
// download progress, and whether the toast was waved away. App.svelte
// starts the watch once; Settings → Updates and UpdateToast read it.
import * as api from "./api.js";
export const updater = $state({
/** updater.rs `Status`, or null before the first read. */
status: null,
/** { received, total } while an install downloads. */
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() {
try {
updater.status = await api.updaterStatus();
} catch (e) {
updater.error = String(e);
}
}
export async function checkForUpdates() {
updater.error = "";
updater.dismissed = false;
try {
updater.status = await api.updaterCheck();
} catch (e) {
// The status carries last_error too; this is the same text, live.
updater.error = String(e);
await refreshUpdater();
}
}
export async function setUpdater(patch) {
updater.error = "";
try {
updater.status = await api.updaterSet(patch);
updater.dismissed = false;
} catch (e) {
updater.error = String(e);
}
}
/** On success the app exits into the installer; on failure the error
* lands here and in the status. */
export async function installUpdate() {
updater.error = "";
try {
await api.updaterInstall();
} catch (e) {
updater.error = String(e);
await refreshUpdater();
}
}
export function dismissUpdate() {
updater.dismissed = true;
}
let started = false;
export function startUpdaterWatch() {
if (started) return;
started = true;
api.onEvent("updater:status", (s) => {
const before = updater.status?.available?.version ?? null;
updater.status = s;
// A new find un-dismisses the toast; the same one stays dismissed.
if ((s?.available?.version ?? null) !== before) updater.dismissed = false;
if (!s?.installing) updater.progress = null;
});
api.onEvent("updater:progress", (p) => {
updater.progress = p;
});
void refreshUpdater();
}
+98
View File
@@ -8,6 +8,21 @@
import { app, network, clearCatalogHeroArtCache, refreshSourcePrefs } from "../lib/state.svelte.js";
import { setWordFill } from "../lib/lyrics.svelte.js";
import { applyGlass } from "../lib/glass.js";
import { openFirstRun } from "../lib/firstRun.svelte.js";
import { updater, checkForUpdates, setUpdater, installUpdate } from "../lib/updater.svelte.js";
// The updater card reads the backend's snapshot; `pct` is the install download.
const up = $derived(updater.status);
const avail = $derived(up?.available ?? null);
const pct = $derived(updater.progress?.total ? Math.min(100, Math.round((updater.progress.received / updater.progress.total) * 100)) : null);
// The private-release token: typed here, saved to the Credential Manager
// by the backend, never read back into the page.
let tokenDraft = $state("");
async function saveToken() {
const t = tokenDraft.trim();
if (!t) return;
await setUpdater({ token: t });
tokenDraft = "";
}
import * as api from "../lib/api.js";
import Icon from "../components/Icon.svelte";
import Chooser from "../components/Chooser.svelte";
@@ -329,6 +344,7 @@
const SECTIONS = [
{ id: "account", title: "Account", group: "You", icon: "users" },
{ id: "networks", title: "Networks", group: "You", icon: "globe" },
{ id: "updates", title: "Updates", group: "You", icon: "download" },
{ id: "appearance", title: "Appearance", group: "Look & sound", icon: "eye" },
{ id: "playback", title: "Playback", group: "Look & sound", icon: "volume" },
{ id: "lyrics", title: "Lyrics", group: "Look & sound", icon: "lyrics" },
@@ -423,6 +439,13 @@
{/if}
</div>
</div>
<div class="row">
<div class="label">
<div class="name">First-time setup</div>
<div class="hint">The welcome tour from the first launch: library folder, account, community peer, look. Runs again from here and changes nothing until you do.</div>
</div>
<div class="ctl"><button class="ax-ghost" onclick={() => openFirstRun("settings")}>Run again</button></div>
</div>
</div>
</section>
@@ -525,6 +548,72 @@
</div>
</section>
<!-- ── Updates ─────────────────────────────────────────── -->
<section id="set-updates" class="sec">
<div class="sec-head"><span class="hic"><Icon name={iconOf("updates")} size={14} /></span><h2>Updates</h2><span class="blurb">New releases from the project, on the channel you pick. Installing is always a button.</span></div>
<div class="card">
<div class="row">
<div class="label">
<div class="name">SoulStream {up?.version ?? "…"} {#if up?.dev_build}<span class="tag off">dev build</span>{/if}{#if avail}<span class="tag acc">{avail.version} available</span>{:else if up?.last_check && !up?.last_error}<span class="tag ok">up to date</span>{/if}</div>
<div class="hint">
{#if up?.checking}Checking {up.channel}…
{:else if up?.last_error}<span class="warn">Last check failed: {up.last_error}</span>
{:else if up?.last_check}Checked {fmtWhen(up.last_check * 1000)} on the {up.channel} channel.
{:else}Not checked yet on the {up?.channel ?? "stable"} channel.{/if}
{#if updater.error}<span class="warn"> {updater.error}</span>{/if}
</div>
</div>
<div class="ctl"><button class="ax-ghost" onclick={() => checkForUpdates()} disabled={!!up?.checking || !!up?.installing}>{up?.checking ? "Checking…" : "Check now"}</button></div>
</div>
{#if avail}
<div class="row">
<div class="label">
<div class="name">{avail.version} is ready {#if avail.date}<span class="tag">{new Date(avail.date * 1000).toLocaleDateString()}</span>{/if}</div>
{#if avail.notes}<div class="hint notes">{avail.notes}</div>{/if}
{#if up?.installing}
<div class="hint">{pct != null ? `Downloading · ${pct}%` : "Downloading…"} — the app closes into the installer and comes back on its own.</div>
<span class="pbar" aria-hidden="true"><i style="width: {pct ?? 6}%"></i></span>
{:else if up?.dev_build}
<div class="hint warn">This is a dev build run from the target folder; the installer would set up the packaged app beside it.</div>
{/if}
</div>
<div class="ctl"><button class="ax-fill" onclick={() => installUpdate()} disabled={!!up?.installing}><Icon name="download" size={12} /> {up?.installing ? "Installing…" : "Install and restart"}</button></div>
</div>
{/if}
<div class="row">
<div class="label">
<div class="name">Channel</div>
<div class="hint">Stable follows the releases. Nightly is built from <b>main</b> every day and can break; switching back to Stable offers the next stable build even when its number is lower.</div>
</div>
<div class="ctl">{@render seg([["stable", "Stable"], ["nightly", "Nightly"]], up?.channel ?? "stable", (v) => setUpdater({ channel: v }), !up || !!up.installing)}</div>
</div>
<div class="row">
<div class="label">
<div class="name">Check automatically</div>
<div class="hint">Shortly after launch and every six hours. Finding one shows a toast; nothing installs on its own.</div>
</div>
<div class="ctl">{@render sw(up?.auto_check ?? true, ["on", "off"], (v) => setUpdater({ autoCheck: v }), !up)}</div>
</div>
<div class="row">
<div class="label">
<div class="name">Access token {#if up?.has_token}<span class="tag ok">set</span>{:else if up?.source_kind === "github"}<span class="tag warn">needed for a private repository</span>{/if}</div>
<div class="hint">The project's repository is private while the alpha runs: releases are read with a token that can only read them. Paste the one you were given — it goes to the Windows Credential Manager, not a plain file, and leaves with Clear.</div>
</div>
<div class="ctl">
<input type="password" class="mono tok" bind:value={tokenDraft} placeholder={up?.has_token ? "••••••••••••" : "github_pat_…"} spellcheck="false" autocomplete="off" aria-label="Access token" onkeydown={(e) => e.key === "Enter" && saveToken()} disabled={!up} />
<button class="ax-white" onclick={saveToken} disabled={!tokenDraft.trim()}>Save</button>
{#if up?.has_token}<button class="ax-ghost" onclick={() => setUpdater({ token: "" })}>Clear</button>{/if}
</div>
</div>
<details class="disclose src">
<summary>Update source</summary>
<div class="hint">The manifest each channel reads (<code>latest.json</code>, Tauri's updater format, signed with the project's key). Leave empty for the project's GitHub releases; point it at a mirror or your own Gitea to update from there.</div>
<label class="srcrow"><span>Stable</span><input class="mono" value={up?.endpoint_stable ?? ""} placeholder={up?.default_stable ?? ""} spellcheck="false" onkeydown={(e) => e.key === "Enter" && e.currentTarget.blur()} onchange={(e) => setUpdater({ endpointStable: e.currentTarget.value })} /></label>
<label class="srcrow"><span>Nightly</span><input class="mono" value={up?.endpoint_nightly ?? ""} placeholder={up?.default_nightly ?? ""} spellcheck="false" onkeydown={(e) => e.key === "Enter" && e.currentTarget.blur()} onchange={(e) => setUpdater({ endpointNightly: e.currentTarget.value })} /></label>
</details>
</div>
</section>
<!-- ── Appearance ──────────────────────────────────────── -->
<section id="set-appearance" class="sec">
<div class="sec-head"><span class="hic"><Icon name={iconOf("appearance")} size={14} /></span><h2>Appearance</h2><span class="blurb">How the window is drawn.</span></div>
@@ -840,6 +929,15 @@
.subgrp { display: flex; align-items: center; gap: 8px; padding: 12px 16px 2px; font-size: 10.5px; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); }
.subgrp .ln { flex: 1; height: 1px; background: var(--hair); }
.noderow { display: flex; align-items: center; gap: 10px; margin-top: 8px; flex-wrap: wrap; }
.hint.notes { white-space: pre-wrap; max-height: 9em; overflow: auto; }
.pbar { display: block; width: min(100%, 360px); height: 4px; border-radius: 99px; background: var(--surf-hi); overflow: hidden; margin-top: 8px; }
.pbar i { display: block; height: 100%; background: var(--accent); border-radius: 99px; transition: width 0.2s; }
.disclose.src { padding: 10px 16px 14px; border-top: 1px solid var(--hair); }
.disclose.src .hint { margin: 6px 0 10px; }
.disclose.src code { font-family: "Cascadia Code", Consolas, monospace; font-size: 11px; }
.srcrow { display: grid; grid-template-columns: 64px 1fr; align-items: center; gap: 10px; margin-top: 6px; font-size: 12px; font-weight: 700; color: var(--text-secondary); }
.srcrow input { width: 100%; min-width: 0; }
input.tok { width: 220px; }
.lnk { color: var(--text-secondary); font-weight: 700; text-decoration: underline; text-underline-offset: 2px; font-size: 12px; }
.lnk:disabled { opacity: 0.5; }
.disclose { margin-top: 8px; }
+13
View File
@@ -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 },
});