11 Commits
Author SHA1 Message Date
KeelrLeek 972dd103cb release: 1.8.2-alpha 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 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 2026-09-09 16:20:13 -05:00
KeelrLeek fac6fc33dd release: 1.8.0-alpha 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
KeelrLeek 07810f7543 release: 1.7.1-alpha 2026-09-09 12:13:10 -05:00
KeelrLeek 4ea1582afc library: the toolbar is a solid card under dark glass, because a backdrop blur over the frost lets titles through 2026-09-09 12:12:56 -05:00
KeelrLeek aaacfc9ff5 holdings: a dash-led edition tail normalises like a bracketed one, so the catalogue's "Pinkerton - Deluxe Edition" finds the member who holds Pinkerton 2026-09-09 12:12:55 -05:00
KeelrLeek 1c66602b89 who has: the sheet closes when the page scrolls under it, instead of floating beside a card that has moved 2026-09-09 12:12:55 -05:00
35 changed files with 4214 additions and 52 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.0-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.0-alpha",
"version": "1.8.2-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "soulstream",
"version": "1.7.0-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.0-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 {
+58 -3
View File
@@ -87,10 +87,13 @@ fn key(username: &str) -> String {
// ---------------------------------------------------------------- names
/// Bracketed chunks go — "(2019)", "[FLAC]", "{web}" — then only letters and
/// digits remain, lowercased; a leading "the" goes too. "Sigur Rós" and
/// "sigur ros" are not the same string here, and that is a known miss.
/// Bracketed chunks go — "(2019)", "[FLAC]", "{web}" — and so does a
/// dash-led edition tail — "Pinkerton - Deluxe Edition", "Foo - EP" — then
/// only letters and digits remain, lowercased; a leading "the" goes too.
/// "Sigur Rós" and "sigur ros" are not the same string here, and that is a
/// known miss.
pub(crate) fn norm(s: &str) -> String {
let s = strip_edition_tail(s);
let mut out = String::with_capacity(s.len());
let mut depth = 0i32;
for c in s.chars() {
@@ -115,6 +118,41 @@ pub(crate) fn norm(s: &str) -> String {
out
}
/// The catalogue spells an edition after a spaced dash where a folder would
/// bracket it — "Pinkerton - Deluxe Edition" is the gap card, "Pinkerton
/// (Deluxe Edition)" the folder — so a dash-led tail that reads as an
/// edition goes the way brackets do, and only that tail: "Boards of Canada -
/// Geogaddi" is a name with a dash in it and stays whole. Found 2026-09-09
/// when the deluxe card beside a marked Pinkerton carried no "has it".
fn strip_edition_tail(s: &str) -> &str {
let mut s = s.trim_end();
loop {
let Some((at, len)) = [" - ", " – ", " — "]
.iter()
.filter_map(|d| s.rfind(d).map(|i| (i, d.len())))
.max_by_key(|(i, _)| *i)
else {
return s;
};
let tail: String = s[at + len..]
.chars()
.filter(|c| c.is_alphanumeric())
.flat_map(|c| c.to_lowercase())
.collect();
if !is_edition_tail(&tail) {
return s;
}
s = s[..at].trim_end();
}
}
fn is_edition_tail(t: &str) -> bool {
matches!(t, "ep" | "single")
|| ["deluxe", "remaster", "edition", "expanded", "anniversary", "reissue", "bonus", "explicit"]
.iter()
.any(|w| t.contains(w))
}
fn is_disc_folder(s: &str) -> bool {
let n: String = s.to_lowercase().chars().filter(|c| c.is_alphanumeric()).collect();
for p in ["cd", "disc", "disk", "vol", "volume", "side"] {
@@ -352,4 +390,21 @@ mod tests {
assert_eq!(norm("2000 - Spirit They're Gone Spirit They've Vanished"), "spirittheyregonespirittheyvevanished");
assert!(is_disc_folder("Disc 2") && is_disc_folder("cd1") && !is_disc_folder("Discovery"));
}
#[test]
fn dash_led_editions_go_the_way_brackets_do() {
assert_eq!(norm("Pinkerton - Deluxe Edition"), "pinkerton");
assert_eq!(norm("Pinkerton – Deluxe Edition"), "pinkerton");
assert_eq!(norm("Pinkerton — Deluxe Edition"), "pinkerton");
assert_eq!(norm("Pinkerton - Deluxe Edition (2010)"), "pinkerton");
assert_eq!(norm("Album - Deluxe Edition - Remastered"), "album");
assert_eq!(norm("Kid A - 2019 Remaster"), "kida");
assert_eq!(norm("Foo - EP"), "foo");
assert_eq!(norm("Foo - Single"), "foo");
assert_eq!(norm("Pinkerton (Deluxe Edition) [2010]"), norm("Pinkerton - Deluxe Edition"));
// A dash inside a name is not an edition.
assert_eq!(norm("Boards of Canada - Geogaddi"), "boardsofcanadageogaddi");
assert_eq!(norm("Album - Live"), "albumlive");
assert_eq!(norm("1996 - Expert Knob Twiddlers"), "expertknobtwiddlers");
}
}
+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.0-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>
Binary file not shown.
+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();
}
+16 -11
View File
@@ -3583,18 +3583,23 @@
/* ── dark glass ────────────────────────────────────────────────────────
The ladder lives in global.css. Solid mode sees none of this.
The toolbar was the loudest surface on the page and it was loud for two
compounding reasons, both measured on 2026-08-27 with a known-colour
ground substituted behind the window (research/scroll-diag/glassshot.mjs):
its own fill nets 0.82 + 0.7075x0.18 = 0.947 — a slab by alpha alone —
AND its backdrop-filter samples the see-through pane, which the
compositor flattens against black (the measurement lives in
Sidebar.svelte). Chroma of the shipped bar, blue-minus-red over a
#6b3fa8 ground: +2, i.e. dead. Fill alone got it to +12; dropping the
filter as well got it to +16, against +30 for the bare pane beside it.
Both halves were needed, so both are here. */
The toolbar was the loudest surface on the page, measured on 2026-08-27
with a known-colour ground substituted behind the window
(research/scroll-diag/glassshot.mjs): its 0.82 fill over the frost
netted 0.947 — a slab by alpha alone — and dropping fill and filter
made it the see-through card that shipped. It is SOLID again by
Kyle's word (2026-09-09: "just make it a solid bar"), and on purpose:
a backdrop-filter over the pane's frost cannot hide thin content —
Chromium paints the filtered backdrop OVER the original, so a title
on bare frost stays crisp under the haze (measured that day, chroma
+11 with the filter vs +17 without) — and the periscope twin built
that afternoon (a pane-level copy of the bar over a deepened band
cut, fogged by a page clone, GlassBar's pattern) still read as
broken on his screen and went the same day. An opaque card floating
on the glass: the elevated surface at full strength, hairline and
shadow as before, nothing behind it to sample. */
:global(:root.glass) .lib-toolbar {
background: rgba(255, 255, 255, 0.06);
background: var(--bg-elevated);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
+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 },
});