21 Commits
Author SHA1 Message Date
KeelrLeek 972dd103cb release: 1.8.2-alpha
release / windows (push) Canceled after 0s
2026-09-10 07:19:18 -05:00
KeelrLeek 12fff9f6ad setup, updater: the installer window was a dev build with nothing inside it, and latest never matched a prerelease
Tauri decides dev-vs-production from the `custom-protocol` feature, not from
--release, and both workflows build the setup exe with a plain `cargo build`.
So cfg(dev) stayed on, the frontend was never embedded, and the window a
tester opened loaded devUrl: "localhost refused to connect" on a blank
800x580. The feature is a default of the crate now, with a build.rs guard
that fails a release build without it, and build.rs tracks setup/dist so a
changed window cannot be left out of the binary it is supposed to be in.

Behind that, the next wall: /releases/latest answers only with full
releases, and every release here is an alpha, so it 404s. The setup app read
that as a refused key and the app's Stable channel read it as a token that
cannot see the repository — both have been broken since the first alpha.
Each now lists /releases and takes the newest that is neither a draft nor
the rolling nightly, which the list really does put first.

And check_saved_key threw the saved key away on any failure at all, a
dropped connection included. The entry it deletes is the app's own updater
token, so only a refusal clears it now.

The release notes name the file to hand a tester: six assets on the page and
nothing saying which, while the bare NSIS installer is the biggest of them
and installs with no key gate and no signature check.
2026-09-10 07:19:15 -05:00
KeelrLeek 3cfff93036 release: 1.8.1-alpha
release / windows (push) Canceled after 0s
2026-09-09 18:10:29 -05:00
KeelrLeek f8508da081 setup: the installer is our own window — the alpha key is the gate, the release comes down with it and is verified before the silent install, and the in-app tour asks for the key too 2026-09-09 18:00:02 -05:00
KeelrLeek 8093835482 release workflow: the version step is a block scalar, so its quotes reach bash whole
release / windows (push) Canceled after 0s
2026-09-09 16:20:13 -05:00
KeelrLeek fac6fc33dd release: 1.8.0-alpha
release / windows (push) Canceled after 0s
2026-09-09 16:16:22 -05:00
KeelrLeek cd2391198e first run, updates, installer: a setup tour stands in front of a fresh install, releases arrive through an in-app updater with stable and nightly channels behind a read-only token, and the branded installer builds again 2026-09-09 16:16:19 -05:00
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
KeelrLeek 67b7444f82 settings: each section heading carries the same icon as its index entry 2026-09-08 16:28:18 -05:00
KeelrLeek 48ae01c753 settings: Appearance gets a real icon — "display" was a CSS property that had leaked into my list of icon names 2026-09-08 16:16:40 -05:00
KeelrLeek a0528c6d3f settings: each index entry carries its section's icon, lit in the accent when it is the one on screen 2026-09-08 15:45:42 -05:00
KeelrLeek 07ac3d3631 settings: the long lists use the app's own chooser — the sort menu's popover under a pill — instead of native selects the webview draws with the OS's text 2026-09-08 15:43:13 -05:00
KeelrLeek 7e291cfccc settings: one long page of section cards behind a sticky index, segments and switches for the choices, and the Networks card moved in from Community 2026-09-08 15:39:19 -05:00
KeelrLeek 6c44f3e2ab community: a chat thread stretches with the pane too — no inner width cap remains 2026-09-08 14:53:05 -05:00
KeelrLeek f006c1cd5c community: the page fills the pane edge to edge instead of sitting in a centred 1520px column 2026-09-08 12:55:10 -05:00
KeelrLeek b8e6fb7396 who has this: a gap card says who holds it, the sheet and the open album name them, a Space grows a Holdings tab, and Get it is the hub's own grab or a wait for their presence 2026-09-08 12:10:49 -05:00
KeelrLeek a98c750908 holdings: a member of a Space publishes what they hold as room-salted album keys, sealed to the room, so "who has this" is answered while they are offline 2026-09-08 12:10:49 -05:00
KeelrLeek 08ca09586a community: Discover ranks the shelves it already has into one, and every card still says where it came from 2026-09-08 09:48:07 -05:00
55 changed files with 8672 additions and 888 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"]
+1449
View File
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "soulstream-holdings-contract"
version = "0.1.0"
edition = "2021"
description = "The SoulStream holdings contract — one member's album list in one Space, a thin shell over persona_state::holdings. Built to wasm32-unknown-unknown and bundled into the app; its code hash is a commitment (see src-tauri/assets)."
[lib]
crate-type = ["cdylib"]
[dependencies]
freenet-stdlib = { version = "=0.8.5" }
persona-state = { path = "../persona-state" }
bincode = "1"
blake3 = "1"
[features]
default = ["freenet-main-contract", "contract"]
freenet-main-contract = []
contract = ["freenet-stdlib/contract"]
trace = ["freenet-stdlib/trace"]
[profile.release]
strip = true
lto = true
# Its own workspace: this crate targets wasm32 and must never join the
# app's host build.
[workspace]
+132
View File
@@ -0,0 +1,132 @@
//! The SoulStream holdings contract. Single writer: one member's latest
//! signed album list in one Space, at the address (space id, member key).
//! Merge is the pointer contract's total order, (sequence, blake3) —
//! `persona_state::holdings::merge` decides, so every delivery order
//! converges, and a stale replay cannot bring a withdrawn list back.
//!
//! The body is opaque here: sealed to the room's epoch key in a private
//! Space, and the contract only ever checks the envelope and its signature.
//!
//! Build: `cargo build --release --target wasm32-unknown-unknown` in this
//! directory; copy `target/wasm32-unknown-unknown/release/
//! soulstream_holdings_contract.wasm` to `src-tauri/assets/holdings_contract.wasm`
//! and re-pin the code hash in `src-tauri/src/holdings.rs`.
use freenet_stdlib::prelude::*;
use persona_state::holdings as t;
pub struct Contract;
fn parts(parameters: &Parameters<'static>) -> Result<([u8; 16], [u8; 32]), ContractError> {
t::params_parts(parameters.as_ref())
.ok_or_else(|| ContractError::Other("params are not family+space+member".into()))
}
fn summary_of(bytes: &[u8], s: &t::HoldingsState) -> Vec<u8> {
let (seq, hash) = t::rank(bytes, s);
let mut out = Vec::with_capacity(40);
out.extend_from_slice(&seq.to_le_bytes());
out.extend_from_slice(&hash);
out
}
#[contract]
impl ContractInterface for Contract {
fn validate_state(
parameters: Parameters<'static>,
state: State<'static>,
_related: RelatedContracts<'static>,
) -> Result<ValidateResult, ContractError> {
let (space_id, member) = parts(&parameters)?;
if state.as_ref().is_empty() {
return Ok(ValidateResult::Valid);
}
match t::validate(&space_id, &member, state.as_ref()) {
Ok(_) => Ok(ValidateResult::Valid),
Err(e) => Err(ContractError::Other(format!("invalid holdings: {e}"))),
}
}
fn update_state(
parameters: Parameters<'static>,
state: State<'static>,
data: Vec<UpdateData<'static>>,
) -> Result<UpdateModification<'static>, ContractError> {
let (space_id, member) = parts(&parameters)?;
let mut candidates: Vec<Vec<u8>> = Vec::new();
if !state.as_ref().is_empty() {
candidates.push(state.as_ref().to_vec());
}
let mut incoming_valid = 0usize;
for ud in &data {
let mut push = |bytes: &[u8]| {
if bytes.is_empty() {
return;
}
if t::validate(&space_id, &member, bytes).is_ok() {
incoming_valid += 1;
}
candidates.push(bytes.to_vec());
};
match ud {
UpdateData::State(s) => push(s.as_ref()),
UpdateData::Delta(d) => push(d.as_ref()),
UpdateData::StateAndDelta { state, delta } => {
push(state.as_ref());
push(delta.as_ref());
}
_ => {}
}
}
if incoming_valid == 0 && !data.is_empty() {
return Err(ContractError::InvalidUpdate);
}
match t::merge(&space_id, &member, candidates.iter().map(|v| v.as_slice())) {
Some(bytes) => Ok(UpdateModification::valid(State::from(bytes))),
None => Ok(UpdateModification::valid(state)),
}
}
fn summarize_state(
parameters: Parameters<'static>,
state: State<'static>,
) -> Result<StateSummary<'static>, ContractError> {
let (space_id, member) = parts(&parameters)?;
if state.as_ref().is_empty() {
return Ok(StateSummary::from(Vec::new()));
}
let s = t::validate(&space_id, &member, state.as_ref())
.map_err(|e| ContractError::Other(format!("invalid holdings: {e}")))?;
Ok(StateSummary::from(summary_of(state.as_ref(), &s)))
}
fn get_state_delta(
parameters: Parameters<'static>,
state: State<'static>,
summary: StateSummary<'static>,
) -> Result<StateDelta<'static>, ContractError> {
let (space_id, member) = parts(&parameters)?;
if state.as_ref().is_empty() {
return Ok(StateDelta::from(Vec::new()));
}
let s = t::validate(&space_id, &member, state.as_ref())
.map_err(|e| ContractError::Other(format!("invalid holdings: {e}")))?;
let ours = t::rank(state.as_ref(), &s);
let theirs = {
let b = summary.as_ref();
if b.len() == 40 {
let seq = u64::from_le_bytes(b[..8].try_into().unwrap());
let mut h = [0u8; 32];
h.copy_from_slice(&b[8..40]);
Some((seq, h))
} else {
None
}
};
let newer = match theirs {
Some(t) => ours > t,
None => true,
};
Ok(StateDelta::from(if newer { state.as_ref().to_vec() } else { Vec::new() }))
}
}
+8
View File
@@ -811,6 +811,14 @@ impl Library {
Ok(rows.filter_map(|r| r.ok()).collect())
}
/// Album id → MusicBrainz release id, for the albums that have one.
pub fn album_mbids(&self) -> Result<std::collections::BTreeMap<i64, String>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, mbid FROM albums WHERE mbid IS NOT NULL AND mbid != ''")?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
/// Per-album aggregates for the Library page: what the FILES add up to.
/// One call for the whole library, because the page derives its stat row,
/// its Format grouping and four of its sorts (length, size, bitrate,
+446
View File
@@ -0,0 +1,446 @@
//! Holdings — what a member of a Space holds, as album keys and never as
//! files. One contract per (space, member): the member's latest signed list,
//! sealed to the room's epoch key in a private Space. Merge is the pointer
//! contract's total order, (sequence, blake3): a withdrawal is just a newer
//! sequence with an empty body, and a stale replay cannot resurrect a list.
//!
//! An album is named by a key, not a name: blake3 over the room's id and the
//! same normalised (artist, album) the library-overlap comparison uses, so a
//! holding matches exactly when overlap would have said "you own this". The
//! room id salts it — the same album hashes differently in every room, so
//! two rooms' lists cannot be joined. A member can still learn whether you
//! hold any album they can NAME, by hashing it and looking; that is the
//! feature, and the switch's disclosure says so.
//!
//! Nothing here reads a library or a network; the app does both.
use serde::{Deserialize, Serialize};
pub const SCHEMA_VERSION: u32 = 1;
pub const PARAMS_FAMILY: &[u8] = b"soulstream.holdings.v1";
/// The Space family's domain, not one of its own: the identity delegate
/// signs only under an allowlist, and a new domain would be a delegate
/// generation (a migration event) for the one case — an owner signing their
/// own list — that needs it. The signed payload's shape keeps a holdings
/// signature from ever reading as a Space message or a checkpoint.
pub const SIGN_DOMAIN: &str = crate::space::SIGN_DOMAIN;
/// The contract's gate: envelope plus body, sealed or not.
pub const MAX_STATE_BYTES: usize = 1024 * 1024;
/// Entries per list. A `Holding` is at most 66 bytes on the wire, so this
/// keeps a full list — with its username and the envelope — under the gate
/// with room to spare. A library above it publishes its newest entries.
pub const MAX_ENTRIES: usize = 14_000;
pub const MAX_USERNAME_BYTES: usize = 64;
pub const TIER_UNKNOWN: u8 = 0;
pub const TIER_LOSSY: u8 = 1;
pub const TIER_LOSSLESS: u8 = 2;
/// One album, as the room sees it.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Holding {
/// `album_key` of the normalised (artist, album), salted with the room.
pub key: [u8; 32],
/// `mbid_key` when the library knows the release's MusicBrainz id.
pub mbid: Option<[u8; 32]>,
/// One word about quality: lossless, lossy, or unknown. Never a format,
/// a bitrate, or a size.
pub tier: u8,
}
/// The plaintext a list carries. Sealed in a private Space, so the Soulseek
/// name inside it is readable by members of that epoch only — it is what
/// lets "who has this" become "get it from them".
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct HoldingsBody {
pub schema_version: u32,
pub slsk_username: Option<String>,
/// Sorted by key, no duplicates — `encode_body` enforces it.
pub entries: Vec<Holding>,
}
/// The contract state: the member's latest list, signed by the key they
/// wear in the room.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HoldingsState {
pub schema_version: u32,
pub space_id: [u8; 16],
pub member: [u8; 32],
pub sequence: u64,
/// The epoch whose key sealed `body`; readers look their key up by it.
pub epoch: u64,
/// False in a public Space: `body` is the plain encoding.
pub sealed: bool,
pub published_at: u64,
/// Withdrawn: the list is gone from current state (`body` empty). Copies
/// members already read stay with them — never claim erasure.
pub withdrawn: bool,
pub body: Vec<u8>,
pub sig: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Invalid {
Oversized(usize),
Malformed,
WrongSchema(u32),
WrongAddress,
BadSignature,
TooManyEntries(usize),
FieldTooLong(&'static str, usize),
}
impl core::fmt::Display for Invalid {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Invalid::Oversized(n) => write!(f, "{n} bytes is over the cap"),
Invalid::Malformed => write!(f, "does not decode"),
Invalid::WrongSchema(v) => write!(f, "schema {v} is not {SCHEMA_VERSION}"),
Invalid::WrongAddress => write!(f, "names another room or member"),
Invalid::BadSignature => write!(f, "the signature does not verify"),
Invalid::TooManyEntries(n) => write!(f, "{n} entries is over the cap of {MAX_ENTRIES}"),
Invalid::FieldTooLong(what, n) => write!(f, "{what} is {n} bytes, over its cap"),
}
}
}
// ---------------------------------------------------------------- params
pub fn make_params(space_id: &[u8; 16], member: &[u8; 32]) -> Vec<u8> {
let mut p = Vec::with_capacity(PARAMS_FAMILY.len() + 48);
p.extend_from_slice(PARAMS_FAMILY);
p.extend_from_slice(space_id);
p.extend_from_slice(member);
p
}
pub fn params_parts(params: &[u8]) -> Option<([u8; 16], [u8; 32])> {
let t = PARAMS_FAMILY.len();
if params.len() != t + 48 || &params[..t] != PARAMS_FAMILY {
return None;
}
Some((params[t..t + 16].try_into().unwrap(), params[t + 16..t + 48].try_into().unwrap()))
}
// ---------------------------------------------------------------- keys
/// The key an album is held under in ONE room. `artist` and `album` are
/// the overlap normalisation's output (lowercase alphanumerics, brackets
/// gone, a leading "the" and a filing year dropped) — the caller's job, so
/// a wasm build of this module never carries the normaliser.
pub fn album_key(space_id: &[u8; 16], artist: &str, album: &str) -> [u8; 32] {
let mut h = blake3::Hasher::new_derive_key("soulstream.holding.v1");
h.update(space_id);
h.update(artist.as_bytes());
h.update(&[0]);
h.update(album.as_bytes());
*h.finalize().as_bytes()
}
/// The same album by its MusicBrainz release id, for the day identity is
/// anchored rather than named.
pub fn mbid_key(space_id: &[u8; 16], mbid: &str) -> [u8; 32] {
let mut h = blake3::Hasher::new_derive_key("soulstream.holding-mbid.v1");
h.update(space_id);
h.update(mbid.trim().to_lowercase().as_bytes());
*h.finalize().as_bytes()
}
// ---------------------------------------------------------------- body
/// Encode a list for the wire: sorted by key, duplicates folded (the better
/// tier wins), caps enforced.
pub fn encode_body(mut b: HoldingsBody) -> Result<Vec<u8>, Invalid> {
if let Some(u) = &b.slsk_username {
if u.len() > MAX_USERNAME_BYTES {
return Err(Invalid::FieldTooLong("slsk_username", u.len()));
}
}
b.schema_version = SCHEMA_VERSION;
b.entries.sort_by(|x, y| x.key.cmp(&y.key).then(y.tier.cmp(&x.tier)));
b.entries.dedup_by(|later, earlier| {
if later.key != earlier.key {
return false;
}
// The first of a run has the higher tier already; keep its mbid if
// the later one had none.
if earlier.mbid.is_none() {
earlier.mbid = later.mbid;
}
true
});
if b.entries.len() > MAX_ENTRIES {
return Err(Invalid::TooManyEntries(b.entries.len()));
}
bincode::serialize(&b).map_err(|_| Invalid::Malformed)
}
pub fn decode_body(bytes: &[u8]) -> Option<HoldingsBody> {
let b: HoldingsBody = bincode::deserialize(bytes).ok()?;
if b.schema_version != SCHEMA_VERSION || b.entries.len() > MAX_ENTRIES {
return None;
}
Some(b)
}
// ---------------------------------------------------------------- sealing
fn body_key(space_key: &[u8; 32]) -> [u8; 32] {
blake3::derive_key("soulstream.holdings-body.v1", space_key)
}
/// The AAD binds a body to its envelope: another member's sealed bytes
/// under this address, or this member's older sequence, do not open.
fn aad(s: &HoldingsState) -> Vec<u8> {
let mut v = Vec::with_capacity(64);
v.extend_from_slice(&s.space_id);
v.extend_from_slice(&s.member);
v.extend_from_slice(&s.sequence.to_le_bytes());
v.extend_from_slice(&s.epoch.to_le_bytes());
v
}
/// Unique per (member, sequence) under one room — a sequence is never
/// published twice by the same key, which is the nonce's whole contract.
fn nonce(s: &HoldingsState) -> [u8; 12] {
blake3::hash(&aad(s)).as_bytes()[..12].try_into().unwrap()
}
/// Seal `s.body` (the plain encoding going in) under the room's epoch key.
/// Call BEFORE signing; the signature covers the sealed bytes.
pub fn seal(space_key: &[u8; 32], s: &HoldingsState) -> Vec<u8> {
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
let key = body_key(space_key);
ChaCha20Poly1305::new(Key::from_slice(&key))
.encrypt(Nonce::from_slice(&nonce(s)), Payload { msg: &s.body, aad: &aad(s) })
.expect("seal")
}
/// Open a sealed body. None = wrong key for this epoch, or tampered.
pub fn open(space_key: &[u8; 32], s: &HoldingsState) -> Option<Vec<u8>> {
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
let key = body_key(space_key);
ChaCha20Poly1305::new(Key::from_slice(&key))
.decrypt(Nonce::from_slice(&nonce(s)), Payload { msg: &s.body, aad: &aad(s) })
.ok()
}
// ---------------------------------------------------------------- signing
#[derive(Serialize)]
struct SigBody<'a> {
schema_version: u32,
space_id: &'a [u8; 16],
member: &'a [u8; 32],
sequence: u64,
epoch: u64,
sealed: bool,
published_at: u64,
withdrawn: bool,
body: &'a [u8],
}
/// What the member signs: blake3 of the envelope, the sealed body included.
pub fn sign_payload(s: &HoldingsState) -> Vec<u8> {
let body = bincode::serialize(&SigBody {
schema_version: s.schema_version,
space_id: &s.space_id,
member: &s.member,
sequence: s.sequence,
epoch: s.epoch,
sealed: s.sealed,
published_at: s.published_at,
withdrawn: s.withdrawn,
body: &s.body,
})
.expect("sig body serializes");
blake3::hash(&body).as_bytes().to_vec()
}
/// Sign as the key worn in the room (a room-local member key, or an
/// owner's persona through the delegate — the app decides which).
pub fn sign_with(secret: &[u8; 32], s: &HoldingsState) -> Vec<u8> {
crate::sign_local(SIGN_DOMAIN, secret, &sign_payload(s))
}
// ---------------------------------------------------------------- validate / merge
/// Strict, in the plan's order: size → parse → schema → address → caps →
/// signature. A state that passes is exactly what its member published.
pub fn validate(space_id: &[u8; 16], member: &[u8; 32], bytes: &[u8]) -> Result<HoldingsState, Invalid> {
if bytes.len() > MAX_STATE_BYTES {
return Err(Invalid::Oversized(bytes.len()));
}
let s: HoldingsState = bincode::deserialize(bytes).map_err(|_| Invalid::Malformed)?;
if s.schema_version != SCHEMA_VERSION {
return Err(Invalid::WrongSchema(s.schema_version));
}
if &s.space_id != space_id || &s.member != member {
return Err(Invalid::WrongAddress);
}
if s.withdrawn && !s.body.is_empty() {
return Err(Invalid::Malformed);
}
if !crate::verify_sig_in(SIGN_DOMAIN, member, &sign_payload(&s), &s.sig) {
return Err(Invalid::BadSignature);
}
Ok(s)
}
/// The order every replica agrees on: highest sequence, then the byte hash.
pub fn rank(bytes: &[u8], s: &HoldingsState) -> (u64, [u8; 32]) {
(s.sequence, *blake3::hash(bytes).as_bytes())
}
/// The winning candidate's bytes, or None when nothing valid was offered.
pub fn merge<'a>(space_id: &[u8; 16], member: &[u8; 32], candidates: impl IntoIterator<Item = &'a [u8]>) -> Option<Vec<u8>> {
let mut best: Option<((u64, [u8; 32]), &[u8])> = None;
for bytes in candidates {
let Ok(s) = validate(space_id, member, bytes) else { continue };
let r = rank(bytes, &s);
if best.as_ref().is_none_or(|(b, _)| r > *b) {
best = Some((r, bytes));
}
}
best.map(|(_, b)| b.to_vec())
}
#[cfg(test)]
mod tests {
use super::*;
fn secret(n: u8) -> [u8; 32] {
[n; 32]
}
fn state(secret: &[u8; 32], seq: u64, body: Vec<u8>, withdrawn: bool) -> HoldingsState {
let mut s = HoldingsState {
schema_version: SCHEMA_VERSION,
space_id: [3; 16],
member: crate::public_of(secret),
sequence: seq,
epoch: 4,
sealed: false,
published_at: 1_700_000_000,
withdrawn,
body,
sig: vec![],
};
s.sig = sign_with(secret, &s);
s
}
fn bytes(s: &HoldingsState) -> Vec<u8> {
bincode::serialize(s).unwrap()
}
fn holding(n: u8, tier: u8) -> Holding {
Holding { key: [n; 32], mbid: None, tier }
}
#[test]
fn a_list_round_trips_and_refuses_tampering() {
let k = secret(7);
let member = crate::public_of(&k);
let body = encode_body(HoldingsBody { schema_version: 0, slsk_username: Some("zoocone".into()), entries: vec![holding(2, TIER_LOSSY), holding(1, TIER_LOSSLESS)] }).unwrap();
let s = state(&k, 1, body.clone(), false);
let b = bytes(&s);
let back = validate(&[3; 16], &member, &b).unwrap();
assert_eq!(back, s);
let d = decode_body(&back.body).unwrap();
assert_eq!(d.entries.iter().map(|h| h.key[0]).collect::<Vec<_>>(), [1, 2], "sorted by key");
assert_eq!(d.slsk_username.as_deref(), Some("zoocone"));
// another room, another member, a flipped byte, a foreign signature
assert_eq!(validate(&[4; 16], &member, &b), Err(Invalid::WrongAddress));
assert_eq!(validate(&[3; 16], &crate::public_of(&secret(8)), &b), Err(Invalid::WrongAddress));
let mut t = s.clone();
t.sequence = 9;
assert_eq!(validate(&[3; 16], &member, &bytes(&t)), Err(Invalid::BadSignature));
let mut f = s.clone();
f.sig = sign_with(&secret(8), &f);
assert_eq!(validate(&[3; 16], &member, &bytes(&f)), Err(Invalid::BadSignature));
let mut w = state(&k, 2, body, true);
w.sig = sign_with(&k, &w);
assert_eq!(validate(&[3; 16], &member, &bytes(&w)), Err(Invalid::Malformed), "withdrawn keeps no body");
}
#[test]
fn merge_is_by_sequence_then_hash_and_a_replay_cannot_resurrect() {
let k = secret(5);
let member = crate::public_of(&k);
let one = bytes(&state(&k, 1, vec![1], false));
let two = bytes(&state(&k, 2, vec![2], false));
let gone = bytes(&state(&k, 3, vec![], true));
let stranger = bytes(&state(&secret(6), 9, vec![9], false));
let sid = [3; 16];
assert_eq!(merge(&sid, &member, [one.as_slice(), two.as_slice()]), Some(two.clone()));
assert_eq!(merge(&sid, &member, [two.as_slice(), one.as_slice()]), Some(two.clone()));
assert_eq!(merge(&sid, &member, [gone.as_slice(), two.as_slice(), one.as_slice()]), Some(gone.clone()));
assert_eq!(merge(&sid, &member, [stranger.as_slice()]), None);
assert_eq!(merge(&sid, &member, [stranger.as_slice(), one.as_slice()]), Some(one.clone()));
// same sequence twice: the hash breaks the tie the same way in any order
let a = bytes(&state(&k, 4, vec![1, 1], false));
let b = bytes(&state(&k, 4, vec![2, 2], false));
let ab = merge(&sid, &member, [a.as_slice(), b.as_slice()]);
let ba = merge(&sid, &member, [b.as_slice(), a.as_slice()]);
assert_eq!(ab, ba);
assert!(ab == Some(a.clone()) || ab == Some(b.clone()));
}
#[test]
fn a_private_list_opens_only_with_the_epoch_key_and_only_in_its_envelope() {
let k = secret(2);
let room_key = [11u8; 32];
let plain = encode_body(HoldingsBody { schema_version: 0, slsk_username: None, entries: vec![holding(1, TIER_LOSSLESS)] }).unwrap();
let mut s = state(&k, 1, plain.clone(), false);
s.sealed = true;
s.body = seal(&room_key, &s);
s.sig = sign_with(&k, &s);
assert_ne!(s.body, plain);
assert_eq!(open(&room_key, &s), Some(plain.clone()));
assert_eq!(open(&[12u8; 32], &s), None);
// the same ciphertext under another sequence is not this list
let mut moved = s.clone();
moved.sequence = 2;
assert_eq!(open(&room_key, &moved), None);
// validate does not need the key: the envelope stands on its own
assert!(validate(&[3; 16], &crate::public_of(&k), &bytes(&s)).is_ok());
}
#[test]
fn keys_are_salted_by_room_and_the_body_folds_duplicates_to_the_better_tier() {
let a = album_key(&[1; 16], "broadcast", "tenderbuttons");
let b = album_key(&[2; 16], "broadcast", "tenderbuttons");
assert_ne!(a, b);
assert_eq!(a, album_key(&[1; 16], "broadcast", "tenderbuttons"));
assert_ne!(a, album_key(&[1; 16], "broadcasttender", "buttons"), "the separator keeps the two names apart");
assert_ne!(mbid_key(&[1; 16], "abc"), mbid_key(&[2; 16], "ABC"));
assert_eq!(mbid_key(&[1; 16], " abc "), mbid_key(&[1; 16], "ABC"));
let body = encode_body(HoldingsBody {
schema_version: 0,
slsk_username: None,
entries: vec![
Holding { key: [1; 32], mbid: None, tier: TIER_LOSSY },
Holding { key: [1; 32], mbid: Some([9; 32]), tier: TIER_LOSSLESS },
Holding { key: [1; 32], mbid: None, tier: TIER_UNKNOWN },
],
})
.unwrap();
let d = decode_body(&body).unwrap();
assert_eq!(d.entries, vec![Holding { key: [1; 32], mbid: Some([9; 32]), tier: TIER_LOSSLESS }]);
let too_many = HoldingsBody { schema_version: 0, slsk_username: None, entries: (0..MAX_ENTRIES + 1).map(|i| Holding { key: blake3::hash(&i.to_le_bytes()).into(), mbid: None, tier: 0 }).collect() };
assert!(matches!(encode_body(too_many), Err(Invalid::TooManyEntries(_))));
let long = HoldingsBody { schema_version: 0, slsk_username: Some("x".repeat(65)), entries: vec![] };
assert!(matches!(encode_body(long), Err(Invalid::FieldTooLong("slsk_username", 65))));
}
#[test]
fn params_round_trip() {
let p = make_params(&[3; 16], &[4; 32]);
assert_eq!(params_parts(&p), Some(([3; 16], [4; 32])));
assert_eq!(params_parts(&p[..p.len() - 1]), None);
assert_eq!(params_parts(&crate::space::make_params(&[4; 32], &[3; 16])), None);
}
}
+1
View File
@@ -21,6 +21,7 @@ pub mod playlist;
pub mod inbox;
pub mod space;
pub mod proposals;
pub mod holdings;
pub const SCHEMA_VERSION: u32 = 1;
pub const SIGN_DOMAIN: &str = "soulstream.profile/v1";
+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"
+2 -2
View File
@@ -1,13 +1,13 @@
{
"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": {
"dev": "vite",
"build": "vite build",
"tauri": "tauri",
"test": "node --test \"tests/preview/*.test.mjs\""
"test": "node --test \"tests/preview/*.test.mjs\" \"tests/discover/*.test.mjs\""
},
"license": "ISC",
"devDependencies": {
+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.
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) {
+809
View File
@@ -0,0 +1,809 @@
//! Holdings — "who has this, and get it from them".
//!
//! Each member of a Space may publish what they HOLD: the albums of their
//! library as room-salted keys (`persona_state::holdings::album_key` over
//! `overlap::norm`), one quality word each, and their Soulseek name — sealed
//! to the room's epoch key in a private Space, signed by the key they wear
//! there, one contract per (room, member). Never files, paths, sizes or
//! bitrates: the same rule the overlap shelf lives by, and the switch's
//! disclosure says exactly what a member can learn.
//!
//! Reading a room caches each member's list on disk (`holdings.json`, the
//! overlap ledger's pattern), so the ASK is local and instant and works with
//! the holders offline: hash the album per room, look it up, name the
//! members. "Get it" is the hub's album grab aimed at that member's share —
//! `find_folder` walks their browse tree with overlap's `pair_of` and hands
//! the files back for the frontend to queue; a holder who is offline goes on
//! a local wait list the frontend drains when their presence flips.
//!
//! Nothing is ever pushed to a holder. The only new network object is the
//! list, and only the room's members can read it.
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use freenet_adapter::FreenetAdapter;
use persona_state::holdings as hd;
use serde::{Deserialize, Serialize};
use tauri::{Emitter, Manager};
use crate::pipeline::LibraryState;
use crate::state::AppState;
use crate::{ledger, overlap, persona, slsk_link, spaces};
const HOLDINGS_WASM: &[u8] = include_bytes!("../assets/holdings_contract.wasm");
/// The ledger changed (a publish, a read, a wait): the page re-reads.
pub const EVENT: &str = "community:holdings";
const LEDGER_FILE: &str = "holdings.json";
/// A member's cached list is re-read after this long, or on Refresh.
const FRESH_SECS: u64 = 10 * 60;
/// A member with no list is asked again after this long: a GET for a
/// contract nobody put is a ring search that costs seconds.
const ABSENT_SECS: u64 = 60 * 60;
const LOSSLESS: &[&str] = &["flac", "alac", "wav", "aiff", "aif", "ape", "wv", "dsf", "dff", "tta", "shn"];
const LOSSY: &[&str] = &["mp3", "ogg", "oga", "opus", "m4a", "aac", "wma", "mpc"];
// ---------------------------------------------------------------- ledger
/// What this device last published into one room.
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Mine {
pub sequence: u64,
pub epoch: u64,
pub published_at: u64,
pub count: u32,
/// blake3 over the sorted keys, so a library that has not changed does
/// not republish.
pub digest: String,
#[serde(default)]
pub withdrawn: bool,
}
/// One member's list as this device last read it.
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
struct Cached {
sequence: u64,
epoch: u64,
read_at: u64,
/// No contract under that address when last asked.
absent: bool,
/// Sealed to an epoch this device has no key for.
unreadable: bool,
withdrawn: bool,
slsk_username: Option<String>,
/// (key b58, mbid key b58, tier) — the same three the wire carries.
entries: Vec<(String, Option<String>, u8)>,
}
/// An album to fetch from someone the moment they come online.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Wait {
pub username: String,
pub artist: String,
pub album: String,
pub since: u64,
/// The room the holder was found in, for the row's provenance.
#[serde(default)]
pub via: Option<String>,
}
#[derive(Default, Serialize, Deserialize)]
struct Ledger {
#[serde(default)]
mine: BTreeMap<String, Mine>,
/// room record id → member key (b58) → the list.
#[serde(default)]
cache: BTreeMap<String, BTreeMap<String, Cached>>,
#[serde(default)]
waits: Vec<Wait>,
}
fn path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
Ok(crate::paths::app_data(app)?.join("freenet").join(LEDGER_FILE))
}
fn load(app: &tauri::AppHandle) -> Ledger {
path(app)
.ok()
.and_then(|p| std::fs::read(p).ok())
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
fn save(app: &tauri::AppHandle, l: &Ledger) -> Result<(), String> {
let p = path(app)?;
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
std::fs::write(&p, serde_json::to_vec(l).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
let _ = app.emit(EVENT, ());
Ok(())
}
fn now_secs() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
fn b58(bytes: &[u8]) -> String {
bs58::encode(bytes).into_string()
}
// ---------------------------------------------------------------- my list
/// The tier a dominant extension speaks for.
fn tier_of(ext: &str) -> u8 {
let e = ext.to_lowercase();
if LOSSLESS.contains(&e.as_str()) {
hd::TIER_LOSSLESS
} else if LOSSY.contains(&e.as_str()) {
hd::TIER_LOSSY
} else {
hd::TIER_UNKNOWN
}
}
/// The library as one room would hold it: keyed, tiered, sorted. Reads the
/// database, so it runs off the async runtime.
fn my_list(lib: &library::Library, space_id: &[u8; 16]) -> Result<(Vec<hd::Holding>, u32), String> {
let albums = lib.list_albums().map_err(|e| e.to_string())?;
let mbids = lib.album_mbids().unwrap_or_default();
let tiers: BTreeMap<i64, u8> = lib
.album_stats(0)
.map_err(|e| e.to_string())?
.into_iter()
.map(|s| (s.album_id, s.formats.first().map(|f| tier_of(f)).unwrap_or(hd::TIER_UNKNOWN)))
.collect();
let total = albums.len() as u32;
// Newest first is the library's own order, so a list over the cap keeps
// what was added last.
let entries: Vec<hd::Holding> = albums
.iter()
.filter_map(|a| {
let (na, nt) = (overlap::norm(&a.artist), overlap::norm(&a.title));
if na.is_empty() || nt.is_empty() {
return None;
}
Some(hd::Holding {
key: hd::album_key(space_id, &na, &nt),
mbid: mbids.get(&a.id).map(|m| hd::mbid_key(space_id, m)),
tier: tiers.get(&a.id).copied().unwrap_or(hd::TIER_UNKNOWN),
})
})
.take(hd::MAX_ENTRIES)
.collect();
Ok((entries, total))
}
fn digest_of(entries: &[hd::Holding]) -> String {
let mut buf = Vec::with_capacity(entries.len() * 33);
for e in entries {
buf.extend_from_slice(&e.key);
buf.push(e.tier);
}
b58(&persona_state::content_hash(&buf))
}
// ---------------------------------------------------------------- network
fn adapter(app: &tauri::AppHandle) -> FreenetAdapter {
app.state::<AppState>().freenet.clone()
}
/// The list at one address, verified. None when nothing was ever put there.
async fn fetch(app: &tauri::AppHandle, space_id: &[u8; 16], member: &[u8; 32]) -> Result<Option<hd::HoldingsState>, String> {
let ids = FreenetAdapter::contract_ids(HOLDINGS_WASM.to_vec(), &hd::make_params(space_id, member));
match adapter(app).contract_get(&ids.instance, false).await {
Ok(bytes) if bytes.is_empty() => Ok(None),
Ok(bytes) => hd::validate(space_id, member, &bytes)
.map(Some)
.map_err(|e| format!("holdings failed verification: {e}")),
Err(e) if e.to_string().contains("not found") => Ok(None),
Err(e) => Err(e.to_string()),
}
}
async fn offer(app: &tauri::AppHandle, space_id: &[u8; 16], member: &[u8; 32], state: &hd::HoldingsState, first: bool) -> Result<(), String> {
let bytes = bincode::serialize(state).map_err(|e| e.to_string())?;
let params = hd::make_params(space_id, member);
let a = adapter(app);
if first {
a.contract_put(HOLDINGS_WASM.to_vec(), &params, bytes, false).await.map(|_| ())
} else {
a.get_then_update(HOLDINGS_WASM.to_vec(), &params, bytes).await.map(|_| ())
}
.map_err(|e| e.to_string())
}
/// Sign as the key worn in the room: the room secret for a member, the
/// delegate for an owner (their persona is their key there).
async fn sign(app: &tauri::AppHandle, me: &[u8; 32], worn: &[u8; 32], room_secret: Option<[u8; 32]>, s: &hd::HoldingsState) -> Result<Vec<u8>, String> {
match room_secret {
Some(secret) if worn != me => Ok(hd::sign_with(&secret, s)),
_ => persona::sign(&adapter(app), hd::SIGN_DOMAIN, hd::sign_payload(s)).await,
}
}
/// Everything a publish or a withdrawal needs to know about where it goes.
struct Seat {
me: [u8; 32],
worn: [u8; 32],
room_secret: Option<[u8; 32]>,
space_id: [u8; 16],
epoch: u64,
epoch_key: Option<[u8; 32]>,
}
async fn seat(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<Seat, String> {
let me = spaces::my_key(app).await?;
let owner = spaces::key_of(owner_b58)?;
let space_id = spaces::id_of(space_b58)?;
spaces::room_facts(app, owner_b58, space_b58).ok_or("not a Space this device knows")?;
let (worn, room_secret) = spaces::worn_identity(app, owner_b58, space_b58, &me);
let (state, _) = spaces::fetch_state(app, &owner, &space_id).await?;
let c = state.checkpoint.ok_or("the Space has not reached this node yet")?;
if !c.members.iter().any(|m| m.key == worn) {
return Err("you are not in this Space's roster".into());
}
let epoch_key = if c.private {
Some(spaces::epoch_key_for(app, owner_b58, space_b58, c.epoch).ok_or("no key for this epoch yet — the owner has to hand it to you")?)
} else {
None
};
Ok(Seat { me, worn, room_secret, space_id, epoch: c.epoch, epoch_key })
}
/// Publish this device's list into one room. Read-and-bump: the sequence
/// follows whatever is on the network, so a device that lost its ledger
/// cannot publish under a sequence the room has already seen.
pub async fn publish(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<Mine, String> {
let st = seat(app, owner_b58, space_b58).await?;
let lib = app.state::<LibraryState>().library.clone();
let sid = st.space_id;
let (entries, total) = tauri::async_runtime::spawn_blocking(move || my_list(&lib, &sid))
.await
.map_err(|e| e.to_string())??;
let count = entries.len() as u32;
let digest = digest_of(&entries);
let body = hd::encode_body(hd::HoldingsBody {
schema_version: hd::SCHEMA_VERSION,
slsk_username: slsk_link::own_linked_username(app),
entries,
})
.map_err(|e| e.to_string())?;
let rid = spaces::record_id(owner_b58, space_b58);
let on_net = fetch(app, &st.space_id, &st.worn).await?;
let known = load(app).mine.get(&rid).map(|m| m.sequence).unwrap_or(0);
let sequence = on_net.as_ref().map(|s| s.sequence).unwrap_or(0).max(known) + 1;
let mut s = hd::HoldingsState {
schema_version: hd::SCHEMA_VERSION,
space_id: st.space_id,
member: st.worn,
sequence,
epoch: st.epoch,
sealed: st.epoch_key.is_some(),
published_at: now_secs(),
withdrawn: false,
body,
sig: vec![],
};
if let Some(k) = &st.epoch_key {
s.body = hd::seal(k, &s);
}
s.sig = sign(app, &st.me, &st.worn, st.room_secret, &s).await?;
offer(app, &st.space_id, &st.worn, &s, on_net.is_none()).await?;
let mine = Mine { sequence, epoch: st.epoch, published_at: s.published_at, count, digest, withdrawn: false };
{
let _guard = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
l.mine.insert(rid, mine.clone());
save(app, &l)?;
}
log::info!("holdings: published {count} of {total} albums into {space_b58} as sequence {sequence} (epoch {})", st.epoch);
Ok(mine)
}
/// Take the list out of current state. Copies members already read stay
/// with them — never claim erasure.
pub async fn withdraw(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<(), String> {
let st = seat(app, owner_b58, space_b58).await?;
let rid = spaces::record_id(owner_b58, space_b58);
let on_net = fetch(app, &st.space_id, &st.worn).await?;
let Some(current) = on_net else {
// Nothing there to withdraw; forget what the ledger thought.
let _guard = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
l.mine.remove(&rid);
return save(app, &l);
};
if current.withdrawn {
return Ok(());
}
let sequence = current.sequence.max(load(app).mine.get(&rid).map(|m| m.sequence).unwrap_or(0)) + 1;
let mut s = hd::HoldingsState {
schema_version: hd::SCHEMA_VERSION,
space_id: st.space_id,
member: st.worn,
sequence,
epoch: st.epoch,
sealed: false,
published_at: now_secs(),
withdrawn: true,
body: vec![],
sig: vec![],
};
s.sig = sign(app, &st.me, &st.worn, st.room_secret, &s).await?;
offer(app, &st.space_id, &st.worn, &s, false).await?;
{
let _guard = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
l.mine.insert(rid, Mine { sequence, epoch: st.epoch, published_at: s.published_at, count: 0, digest: String::new(), withdrawn: true });
save(app, &l)?;
}
log::info!("holdings: withdrawn from {space_b58} as sequence {sequence}");
Ok(())
}
/// The switch. On: publish now. Off: withdraw. Either way the flag is set
/// first, so a publish that fails on the network is retried by the next
/// library change rather than forgotten.
pub async fn set_share(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, on: bool) -> Result<(), String> {
spaces::set_share_holdings_flag(app, owner_b58, space_b58, on).await?;
if on {
publish(app, owner_b58, space_b58).await.map(|_| ())
} else {
withdraw(app, owner_b58, space_b58).await
}
}
/// The library changed: every room with the switch on gets the new list,
/// unless the list is byte-for-byte what that room already has. Quiet per
/// room — a failed republish is logged and tried again next change.
pub async fn republish_all(app: &tauri::AppHandle) -> Result<u32, String> {
let rooms = spaces::rooms_sharing_holdings(app);
let mut n = 0;
for (owner, space) in rooms {
let stale = {
let lib = app.state::<LibraryState>().library.clone();
let sid = spaces::id_of(&space)?;
let (entries, _) = tauri::async_runtime::spawn_blocking(move || my_list(&lib, &sid))
.await
.map_err(|e| e.to_string())??;
let l = load(app);
l.mine.get(&spaces::record_id(&owner, &space)).map(|m| m.withdrawn || m.digest != digest_of(&entries)).unwrap_or(true)
};
if !stale {
continue;
}
match publish(app, &owner, &space).await {
Ok(_) => n += 1,
Err(e) => log::info!("holdings: could not republish into {space}: {e}"),
}
}
Ok(n)
}
// ---------------------------------------------------------------- reading a room
#[derive(Debug, Serialize)]
pub struct MemberHoldings {
pub key: String,
pub username: Option<String>,
pub is_me: bool,
/// "shared" | "none" | "sealed" (no key for its epoch) | "withdrawn" | "unread"
pub state: String,
pub count: u32,
pub sequence: u64,
pub read_at: u64,
}
#[derive(Debug, Serialize)]
pub struct RoomHoldings {
pub owner: String,
pub space_id: String,
pub name: String,
pub share_holdings: bool,
pub mine: Option<Mine>,
pub members: Vec<MemberHoldings>,
pub sharing: u32,
}
/// The room's lists as this device knows them, reading the network for
/// members whose cached copy is stale (or all of them, on `refresh`). The
/// reads are sequential ring searches — seconds each for a member who
/// never published — so this runs from a spawned task and the page hears
/// the result by event; it never blocks the Space opening.
pub async fn read_room(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, refresh: bool) -> Result<RoomHoldings, String> {
let me = spaces::my_key(app).await?;
let owner = spaces::key_of(owner_b58)?;
let space_id = spaces::id_of(space_b58)?;
let facts = spaces::room_facts(app, owner_b58, space_b58).ok_or("not a Space this device knows")?;
let (worn, _) = spaces::worn_identity(app, owner_b58, space_b58, &me);
let (state, _) = spaces::fetch_state(app, &owner, &space_id).await?;
let c = state.checkpoint.ok_or("the Space has not reached this node yet")?;
let rid = spaces::record_id(owner_b58, space_b58);
let now = now_secs();
let members: Vec<[u8; 32]> = c.members.iter().map(|m| m.key).filter(|k| *k != worn).collect();
for member in members {
let mb58 = b58(&member);
let cached = load(app).cache.get(&rid).and_then(|m| m.get(&mb58)).cloned();
let fresh = cached.as_ref().is_some_and(|c| {
let age = now.saturating_sub(c.read_at);
if c.absent { age < ABSENT_SECS } else { age < FRESH_SECS }
});
if fresh && !refresh {
continue;
}
let entry = match fetch(app, &space_id, &member).await {
Ok(None) => Cached { read_at: now, absent: true, ..Default::default() },
Ok(Some(s)) if s.withdrawn => Cached { sequence: s.sequence, epoch: s.epoch, read_at: now, withdrawn: true, ..Default::default() },
Ok(Some(s)) => {
let plain = if s.sealed {
match spaces::epoch_key_for(app, owner_b58, space_b58, s.epoch) {
Some(k) => hd::open(&k, &s),
None => None,
}
} else {
Some(s.body.clone())
};
match plain.as_deref().and_then(hd::decode_body) {
Some(b) => Cached {
sequence: s.sequence,
epoch: s.epoch,
read_at: now,
slsk_username: b.slsk_username,
entries: b.entries.iter().map(|h| (b58(&h.key), h.mbid.map(|m| b58(&m)), h.tier)).collect(),
..Default::default()
},
None => Cached { sequence: s.sequence, epoch: s.epoch, read_at: now, unreadable: true, ..Default::default() },
}
}
Err(e) => {
log::info!("holdings: could not read {mb58} in {space_b58}: {e}");
continue;
}
};
let _guard = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
l.cache.entry(rid.clone()).or_default().insert(mb58, entry);
save(app, &l)?;
}
Ok(room_view(app, owner_b58, space_b58, &facts, &c, &worn))
}
/// The room as the cache has it — no network.
pub fn room_now(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<RoomHoldings, String> {
let facts = spaces::room_facts(app, owner_b58, space_b58).ok_or("not a Space this device knows")?;
let l = load(app);
let rid = spaces::record_id(owner_b58, space_b58);
let mine = l.mine.get(&rid).cloned();
let cache = l.cache.get(&rid).cloned().unwrap_or_default();
let members = cache
.iter()
.map(|(k, c)| MemberHoldings {
key: k.clone(),
username: c.slsk_username.clone().or_else(|| spaces::username_behind(app, owner_b58, space_b58, k)),
is_me: false,
state: state_word(c).into(),
count: c.entries.len() as u32,
sequence: c.sequence,
read_at: c.read_at,
})
.collect::<Vec<_>>();
let sharing = members.iter().filter(|m| m.state == "shared").count() as u32 + u32::from(mine.as_ref().is_some_and(|m| !m.withdrawn));
Ok(RoomHoldings { owner: owner_b58.into(), space_id: space_b58.into(), name: facts.name, share_holdings: facts.share_holdings, mine, members, sharing })
}
fn state_word(c: &Cached) -> &'static str {
if c.absent {
"none"
} else if c.withdrawn {
"withdrawn"
} else if c.unreadable {
"sealed"
} else {
"shared"
}
}
fn room_view(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, facts: &spaces::RoomFacts, c: &persona_state::space::Checkpoint, worn: &[u8; 32]) -> RoomHoldings {
let l = load(app);
let rid = spaces::record_id(owner_b58, space_b58);
let mine = l.mine.get(&rid).cloned();
let cache = l.cache.get(&rid).cloned().unwrap_or_default();
let members: Vec<MemberHoldings> = c
.members
.iter()
.map(|m| {
let k = b58(&m.key);
let is_me = &m.key == worn;
let cached = cache.get(&k);
MemberHoldings {
username: cached.and_then(|c| c.slsk_username.clone()).or_else(|| spaces::username_behind(app, owner_b58, space_b58, &k)),
is_me,
state: if is_me {
match &mine {
Some(m) if !m.withdrawn => "shared",
_ => "none",
}
} else {
cached.map(state_word).unwrap_or("unread")
}
.into(),
count: if is_me { mine.as_ref().map(|m| m.count).unwrap_or(0) } else { cached.map(|c| c.entries.len() as u32).unwrap_or(0) },
sequence: if is_me { mine.as_ref().map(|m| m.sequence).unwrap_or(0) } else { cached.map(|c| c.sequence).unwrap_or(0) },
read_at: cached.map(|c| c.read_at).unwrap_or(0),
key: k,
}
})
.collect();
let sharing = members.iter().filter(|m| m.state == "shared").count() as u32;
RoomHoldings { owner: owner_b58.into(), space_id: space_b58.into(), name: facts.name.clone(), share_holdings: facts.share_holdings, mine, members, sharing }
}
// ---------------------------------------------------------------- the ask
#[derive(Debug, Deserialize)]
pub struct AskItem {
pub artist: String,
pub album: String,
#[serde(default)]
pub mbid: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Holder {
pub owner: String,
pub space_id: String,
pub space_name: String,
pub member: String,
pub username: Option<String>,
pub tier: u8,
/// How the holding matched: "name" | "mbid".
pub matched_by: &'static str,
}
#[derive(Debug, Serialize)]
pub struct Answer {
pub artist: String,
pub album: String,
pub holders: Vec<Holder>,
}
/// Who holds each album, across every room this device is in — from the
/// cache, no network. A person in two rooms is one holder per room; the
/// frontend folds by username when it wants to.
pub fn ask(app: &tauri::AppHandle, items: &[AskItem]) -> Vec<Answer> {
let l = load(app);
let rooms = spaces::rooms(app);
// Per room: member → (key set, mbid set, username, tier by key).
struct RoomIndex {
owner: String,
space_id: String,
name: String,
sid: [u8; 16],
members: Vec<(String, Option<String>, BTreeMap<String, u8>, BTreeSet<String>)>,
}
let index: Vec<RoomIndex> = rooms
.into_iter()
.filter_map(|(owner, space, name)| {
let sid = spaces::id_of(&space).ok()?;
let cache = l.cache.get(&spaces::record_id(&owner, &space))?;
let members = cache
.iter()
.filter(|(_, c)| !c.absent && !c.withdrawn && !c.unreadable && !c.entries.is_empty())
.map(|(k, c)| {
let mut keys = BTreeMap::new();
let mut mbids = BTreeSet::new();
for (key, mbid, tier) in &c.entries {
keys.insert(key.clone(), *tier);
if let Some(m) = mbid {
mbids.insert(m.clone());
}
}
(k.clone(), c.slsk_username.clone().or_else(|| spaces::username_behind(app, &owner, &space, k)), keys, mbids)
})
.collect();
Some(RoomIndex { owner, space_id: space, name, sid, members })
})
.collect();
items
.iter()
.map(|it| {
let (na, nt) = (overlap::norm(&it.artist), overlap::norm(&it.album));
let mut holders = Vec::new();
if !na.is_empty() && !nt.is_empty() {
for r in &index {
let key = b58(&hd::album_key(&r.sid, &na, &nt));
let mk = it.mbid.as_deref().filter(|m| !m.trim().is_empty()).map(|m| b58(&hd::mbid_key(&r.sid, m)));
for (member, username, keys, mbids) in &r.members {
let by_name = keys.get(&key).copied();
let by_mbid = mk.as_ref().filter(|m| mbids.contains(*m)).map(|_| hd::TIER_UNKNOWN);
if let Some((tier, how)) = by_name.map(|t| (t, "name")).or(by_mbid.map(|t| (t, "mbid"))) {
holders.push(Holder {
owner: r.owner.clone(),
space_id: r.space_id.clone(),
space_name: r.name.clone(),
member: member.clone(),
username: username.clone(),
tier,
matched_by: how,
});
}
}
}
}
// Lossless first, then those who can actually be asked.
holders.sort_by(|a, b| b.tier.cmp(&a.tier).then(b.username.is_some().cmp(&a.username.is_some())));
Answer { artist: it.artist.clone(), album: it.album.clone(), holders }
})
.collect()
}
// ---------------------------------------------------------------- getting it
#[derive(Debug, Serialize)]
pub struct FileRef {
pub path: String,
pub name: String,
pub size: u64,
}
#[derive(Debug, Serialize)]
pub struct FolderMatch {
pub username: String,
pub folder: String,
pub files: Vec<FileRef>,
pub tier: u8,
}
/// Browse a holder's share and find the folder that names this album — the
/// overlap walk, so what the room said they hold is what gets grabbed. Disc
/// subfolders come along. None when their share does not name it (they
/// moved it, or the room's word was a near-name).
pub async fn find_folder(app: &tauri::AppHandle, username: &str, artist: &str, album: &str) -> Result<Option<FolderMatch>, String> {
let (na, nt) = (overlap::norm(artist), overlap::norm(album));
if na.is_empty() || nt.is_empty() {
return Err("that album has no name to look for".into());
}
let fut = {
let state = app.state::<AppState>();
let guard = state.client.lock().await;
let client = guard.as_ref().ok_or("not connected to Soulseek")?;
client.browse(username)
};
let dirs = fut.await.map_err(|e| e.to_string())?;
overlap::note_browse(app, username, &dirs);
let mut files = Vec::new();
let mut folder: Option<String> = None;
for d in &dirs {
let Some((a, b)) = overlap::pair_of(&d.name) else { continue };
let (da, db) = (overlap::norm(&a), overlap::norm(&b));
let hit = da == na && (db == nt || (nt.len() >= 5 && db.len() >= 5 && (db.contains(nt.as_str()) || nt.contains(db.as_str()))));
if !hit {
continue;
}
let audio: Vec<&soulseek_proto::types::File> = d.files.iter().filter(|f| overlap::is_audio(f)).collect();
if audio.is_empty() {
continue;
}
if folder.is_none() {
folder = Some(d.name.clone());
}
for f in audio {
files.push(FileRef { path: format!("{}\\{}", d.name, f.filename), name: f.filename.clone(), size: f.size });
}
}
let Some(folder) = folder else { return Ok(None) };
let tier = files
.iter()
.map(|f| tier_of(f.name.rsplit('.').next().unwrap_or_default()))
.max()
.unwrap_or(hd::TIER_UNKNOWN);
Ok(Some(FolderMatch { username: username.to_string(), folder, files, tier }))
}
// ---------------------------------------------------------------- waits
pub async fn wait_add(app: &tauri::AppHandle, w: Wait) -> Result<Vec<Wait>, String> {
let _guard = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
let same = |x: &Wait| x.username.eq_ignore_ascii_case(&w.username) && overlap::norm(&x.artist) == overlap::norm(&w.artist) && overlap::norm(&x.album) == overlap::norm(&w.album);
if !l.waits.iter().any(same) {
l.waits.push(Wait { since: now_secs(), ..w });
save(app, &l)?;
}
Ok(l.waits)
}
pub async fn wait_remove(app: &tauri::AppHandle, username: &str, artist: &str, album: &str) -> Result<Vec<Wait>, String> {
let _guard = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
let (na, nt) = (overlap::norm(artist), overlap::norm(album));
l.waits.retain(|x| !(x.username.eq_ignore_ascii_case(username) && overlap::norm(&x.artist) == na && overlap::norm(&x.album) == nt));
save(app, &l)?;
Ok(l.waits)
}
// ---------------------------------------------------------------- commands
#[tauri::command]
pub async fn holdings_set_share(app: tauri::AppHandle, owner: String, space: String, on: bool) -> Result<RoomHoldings, String> {
set_share(&app, &owner, &space, on).await?;
room_now(&app, &owner, &space)
}
/// The room's lists. `refresh` re-reads every member; otherwise only stale
/// ones, and only from a spawned task so the answer is immediate — the
/// page hears the read finish by event.
#[tauri::command]
pub async fn holdings_room(app: tauri::AppHandle, owner: String, space: String, refresh: bool) -> Result<RoomHoldings, String> {
let now = room_now(&app, &owner, &space)?;
let app2 = app.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = read_room(&app2, &owner, &space, refresh).await {
log::info!("holdings: reading {space}: {e}");
}
});
Ok(now)
}
#[tauri::command]
pub fn holdings_ask(app: tauri::AppHandle, items: Vec<AskItem>) -> Vec<Answer> {
ask(&app, &items)
}
#[tauri::command]
pub async fn holdings_find_folder(app: tauri::AppHandle, username: String, artist: String, album: String) -> Result<Option<FolderMatch>, String> {
find_folder(&app, &username, &artist, &album).await
}
#[tauri::command]
pub async fn holdings_republish(app: tauri::AppHandle) -> Result<u32, String> {
republish_all(&app).await
}
#[tauri::command]
pub fn holdings_waits(app: tauri::AppHandle) -> Vec<Wait> {
load(&app).waits
}
#[tauri::command]
pub async fn holdings_wait_add(app: tauri::AppHandle, username: String, artist: String, album: String, via: Option<String>) -> Result<Vec<Wait>, String> {
wait_add(&app, Wait { username, artist, album, since: 0, via }).await
}
#[tauri::command]
pub async fn holdings_wait_remove(app: tauri::AppHandle, username: String, artist: String, album: String) -> Result<Vec<Wait>, String> {
wait_remove(&app, &username, &artist, &album).await
}
#[cfg(test)]
mod tests {
use super::*;
/// The bundled contract is a commitment: a changed wasm re-keys every
/// list on the network.
#[test]
fn contract_generation_is_pinned() {
let ids = FreenetAdapter::contract_ids(HOLDINGS_WASM.to_vec(), &hd::make_params(&[1u8; 16], &[7u8; 32]));
assert_eq!(ids.code_hash, "3roNW8VMniLcQVWSzrAZ9Vum8KvFY7DD2dFkrXy43h9Q");
}
#[test]
fn a_dominant_format_speaks_for_the_tier() {
assert_eq!(tier_of("flac"), hd::TIER_LOSSLESS);
assert_eq!(tier_of("FLAC"), hd::TIER_LOSSLESS);
assert_eq!(tier_of("mp3"), hd::TIER_LOSSY);
assert_eq!(tier_of("opus"), hd::TIER_LOSSY);
assert_eq!(tier_of("bin"), hd::TIER_UNKNOWN);
}
/// An older ledger loads, and a wait is one per (person, album) however
/// the names are spelled.
#[test]
fn the_ledger_reads_older_files_and_waits_fold_by_name() {
let l: Ledger = serde_json::from_str("{}").unwrap();
assert!(l.mine.is_empty() && l.cache.is_empty() && l.waits.is_empty());
let w = Wait { username: "Kraven".into(), artist: "Stars of the Lid".into(), album: "And Their Refinement of the Decline".into(), since: 1, via: None };
let same = Wait { username: "kraven".into(), artist: "stars of the lid".into(), album: "And Their Refinement Of The Decline (2007) [FLAC]".into(), since: 2, via: None };
assert_eq!(overlap::norm(&w.album), overlap::norm(&same.album));
assert!(w.username.eq_ignore_ascii_case(&same.username));
}
}
+29 -4
View File
@@ -31,8 +31,10 @@ mod smtc;
mod spaces;
mod proposals;
mod overlap;
mod holdings;
mod state;
mod thumbbar;
mod updater;
mod vocal_sep;
mod youtube_playlist;
mod youtube_setlist;
@@ -42,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()
@@ -53,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
@@ -74,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 {
@@ -89,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,
@@ -186,6 +197,14 @@ pub fn run() {
overlap::overlap_list,
overlap::overlap_compute,
overlap::overlap_forget,
holdings::holdings_set_share,
holdings::holdings_room,
holdings::holdings_ask,
holdings::holdings_find_folder,
holdings::holdings_republish,
holdings::holdings_waits,
holdings::holdings_wait_add,
holdings::holdings_wait_remove,
commands::buddy_list,
commands::buddy_add,
commands::buddy_remove,
@@ -361,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 {
+61 -6
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.
fn norm(s: &str) -> String {
/// 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 @@ 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"] {
@@ -127,7 +165,7 @@ fn is_disc_folder(s: &str) -> bool {
false
}
fn is_audio(f: &soulseek_proto::types::File) -> bool {
pub(crate) fn is_audio(f: &soulseek_proto::types::File) -> bool {
let ext = if f.extension.is_empty() {
f.filename.rsplit('.').next().unwrap_or_default()
} else {
@@ -138,7 +176,7 @@ fn is_audio(f: &soulseek_proto::types::File) -> bool {
}
/// The (artist, album) a share folder names, with display spellings.
fn pair_of(dir: &str) -> Option<(String, String)> {
pub(crate) fn pair_of(dir: &str) -> Option<(String, String)> {
let segs: Vec<&str> = dir
.split(['\\', '/'])
.map(str::trim)
@@ -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");
}
}
+6
View File
@@ -293,6 +293,12 @@ pub(crate) fn remember(app: &tauri::AppHandle, link: KnownLink) {
/// The Soulseek account a verified persona key belongs to, if this device
/// verified one (our own included).
/// The Soulseek name this device has VERIFIED as its own through the link
/// handshake, if any — not merely the account signed in right now.
pub(crate) fn own_linked_username(app: &tauri::AppHandle) -> Option<String> {
load(app).own.map(|o| o.username)
}
pub(crate) fn username_for_key(app: &tauri::AppHandle, persona_key: &str) -> Option<String> {
let links = load(app);
if let Some(o) = &links.own {
+64 -5
View File
@@ -66,6 +66,11 @@ struct SpaceRecord {
/// room is not consent to be watched in it.
#[serde(default)]
share_listens: bool,
/// Whether this device publishes what it HOLDS into this Space — album
/// keys sealed to the room, never files (`holdings.rs`). Per Space and
/// off until asked, for the same reason as listens.
#[serde(default)]
share_holdings: bool,
/// The last listen posted here — what, and when. A reload or a restart
/// re-reports whatever is already past the player's threshold, and this
/// is the only side that knows what a room was last told.
@@ -153,6 +158,8 @@ pub struct SpaceView {
pub can_post: bool,
/// Whether THIS device posts what it plays here.
pub share_listens: bool,
/// Whether THIS device publishes its holdings here.
pub share_holdings: bool,
pub has_key: bool,
/// The contract exists on the network (a Space someone invited us to
/// may not have propagated to this node yet).
@@ -226,13 +233,13 @@ fn rand32() -> [u8; 32] {
rand::thread_rng().fill_bytes(&mut n);
n
}
fn b58(bytes: &[u8]) -> String {
pub(crate) fn b58(bytes: &[u8]) -> String {
bs58::encode(bytes).into_string()
}
fn key_of(s: &str) -> Result<[u8; 32], String> {
pub(crate) fn key_of(s: &str) -> Result<[u8; 32], String> {
inbox::key_of(s)
}
fn id_of(s: &str) -> Result<[u8; 16], String> {
pub(crate) fn id_of(s: &str) -> Result<[u8; 16], String> {
bs58::decode(s)
.into_vec()
.map_err(|e| format!("bad id: {e}"))?
@@ -250,7 +257,7 @@ fn cap(s: &str, max: usize) -> String {
}
s[..end].to_string()
}
fn record_id(owner: &str, space_id: &str) -> String {
pub(crate) fn record_id(owner: &str, space_id: &str) -> String {
format!("{owner}:{space_id}")
}
@@ -281,7 +288,7 @@ fn save(app: &tauri::AppHandle, l: &Ledger) -> Result<(), String> {
Ok(())
}
async fn my_key(app: &tauri::AppHandle) -> Result<[u8; 32], String> {
pub(crate) async fn my_key(app: &tauri::AppHandle) -> Result<[u8; 32], String> {
let adapter = app.state::<AppState>().freenet.clone();
let info = persona::current(&adapter)
.await?
@@ -404,6 +411,7 @@ fn view_of(app: &tauri::AppHandle, rec: &SpaceRecord, state: &sp::SpaceState, me
listens: vec![],
can_post: false,
share_listens: rec.share_listens,
share_holdings: rec.share_holdings,
has_key: false,
reachable,
};
@@ -490,6 +498,7 @@ fn view_of(app: &tauri::AppHandle, rec: &SpaceRecord, state: &sp::SpaceState, me
.collect(),
can_post: is_member && has_key,
share_listens: rec.share_listens,
share_holdings: rec.share_holdings,
has_key,
reachable,
}
@@ -563,6 +572,7 @@ pub async fn create(app: &tauri::AppHandle, name: &str, description: &str, priva
roster: BTreeMap::new(),
invited: BTreeMap::new(),
share_listens: false,
share_holdings: false,
last_listen: None,
join_sent_at: 0,
};
@@ -826,6 +836,53 @@ pub async fn set_pinned(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str
/// Turn posting-what-I-play on or off for one Space. Nothing is published
/// by flipping it; it only decides what the next track does.
/// What `holdings.rs` needs to know about a room, without the record.
pub(crate) struct RoomFacts {
pub name: String,
pub private: bool,
pub my_role: String,
pub share_holdings: bool,
}
pub(crate) fn room_facts(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Option<RoomFacts> {
let l = load(app);
let rec = l.spaces.get(&record_id(owner_b58, space_b58))?;
Some(RoomFacts { name: rec.name.clone(), private: rec.private, my_role: rec.my_role.clone(), share_holdings: rec.share_holdings })
}
/// The room's key for one epoch, if this device was handed it.
pub(crate) fn epoch_key_for(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, epoch: u64) -> Option<[u8; 32]> {
let l = load(app);
let rec = l.spaces.get(&record_id(owner_b58, space_b58))?;
rec.keys.get(&epoch.to_string()).and_then(|k| key_of(k).ok())
}
/// Every room this device is in: (owner, space id, name).
pub(crate) fn rooms(app: &tauri::AppHandle) -> Vec<(String, String, String)> {
load(app).spaces.values().map(|r| (r.owner.clone(), r.space_id.clone(), r.name.clone())).collect()
}
/// The rooms this device publishes its holdings into.
pub(crate) fn rooms_sharing_holdings(app: &tauri::AppHandle) -> Vec<(String, String)> {
load(app).spaces.values().filter(|r| r.share_holdings).map(|r| (r.owner.clone(), r.space_id.clone())).collect()
}
/// The Soulseek name behind a room key as this device knows it: through the
/// owner's private roster, or the key itself when it is a persona (an owner's).
pub(crate) fn username_behind(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, member_b58: &str) -> Option<String> {
let l = load(app);
let rec = l.spaces.get(&record_id(owner_b58, space_b58))?;
slsk_link::username_for_key(app, rec.roster.get(member_b58).map(String::as_str).unwrap_or(member_b58))
}
pub(crate) async fn set_share_holdings_flag(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, on: bool) -> Result<(), String> {
let _ledger = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
let rec = l.spaces.get_mut(&record_id(owner_b58, space_b58)).ok_or("not a Space this device knows")?;
rec.share_holdings = on;
save(app, &l)
}
pub async fn set_share_listens(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, on: bool) -> Result<SpaceView, String> {
{
let _ledger = ledger::hold(LEDGER_FILE).await;
@@ -946,6 +1003,7 @@ pub(crate) async fn on_inbox_invite(app: &tauri::AppHandle, sender: [u8; 32], in
roster: BTreeMap::new(),
invited: BTreeMap::new(),
share_listens: false,
share_holdings: false,
last_listen: None,
join_sent_at: 0,
});
@@ -1197,6 +1255,7 @@ mod tests {
roster: BTreeMap::new(),
invited: BTreeMap::new(),
share_listens: false,
share_holdings: false,
last_listen: None,
join_sent_at: 0,
}
+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"
}
}
}
+26 -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";
@@ -28,6 +32,7 @@
import UploadToast from "./components/UploadToast.svelte";
import ProposalToast from "./components/ProposalToast.svelte";
import FollowNewsToast from "./components/FollowNewsToast.svelte";
import WhoHasSheet from "./components/WhoHasSheet.svelte";
import SetlistScanChip from "./components/SetlistScanChip.svelte";
import {
ui,
@@ -127,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
@@ -135,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.
@@ -156,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 />
@@ -174,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 />
@@ -188,12 +203,17 @@
<UploadToast />
<ProposalToast />
<FollowNewsToast />
<UpdateToast />
<WhoHasSheet />
<SetlistScanChip />
<PreviewOverlay />
<LyricsSyncEditor />
<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>
@@ -206,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;
}
+149
View File
@@ -0,0 +1,149 @@
<script>
// A chooser: one value out of a list, in the app's own menu vocabulary
// (Library's sort menu ← CatalogDiscography ← the player bar's popover)
// instead of a native <select>, which WebView2 draws with the OS's text
// rendering and looks wrong beside everything else. The trigger is a
// pill with the current label and a chevron; the popover is the same
// dark .menu as the sort menu, with the accent check on the current row.
//
// Portalled to body and positioned fixed: a settings card clips its
// overflow, and the pane is a containing block for anything fixed
// inside it. Closes on a choice, Escape, an outside press, a scroll, or
// a resize — a menu that drifts from its trigger is worse than none.
import Icon from "./Icon.svelte";
/** @type {{ options: [string, string][], value: string, onchange: (v: string) => void, disabled?: boolean, title?: string, width?: number }} */
let { options, value, onchange, disabled = false, title = "", width = 0 } = $props();
let triggerEl = $state(null);
let menuEl = $state(null);
let open = $state(false);
let pos = $state({ top: 0, left: 0, minWidth: 186 });
const label = $derived(options.find(([v]) => v === value)?.[1] ?? value ?? "");
function portal(node) {
document.body.appendChild(node);
return { destroy: () => node.remove() };
}
function place() {
const r = triggerEl.getBoundingClientRect();
const minWidth = Math.max(186, width || r.width);
const est = 12 + options.length * 30;
let top = r.bottom + 6;
if (top + est > window.innerHeight - 8) top = Math.max(8, r.top - 6 - est);
let left = Math.min(r.right - minWidth, window.innerWidth - minWidth - 8);
left = Math.max(8, left);
pos = { top, left, minWidth };
}
function toggle(e) {
e.stopPropagation();
if (disabled) return;
if (open) {
open = false;
return;
}
place();
open = true;
}
function pick(v) {
open = false;
if (v !== value) onchange(v);
}
$effect(() => {
if (!open) return;
const onDown = (e) => {
if (triggerEl?.contains(e.target) || menuEl?.contains(e.target)) return;
open = false;
};
const onKey = (e) => {
if (e.key === "Escape") open = false;
};
const close = () => (open = false);
document.addEventListener("pointerdown", onDown, true);
document.addEventListener("keydown", onKey);
document.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
return () => {
document.removeEventListener("pointerdown", onDown, true);
document.removeEventListener("keydown", onKey);
document.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
};
});
</script>
<button bind:this={triggerEl} class="chooser" class:on={open} onclick={toggle} {disabled} {title} aria-haspopup="menu" aria-expanded={open}>
<span class="lab">{label}</span>
<Icon name="chevron-down" size={13} />
</button>
{#if open}
<div class="menu" role="menu" use:portal bind:this={menuEl} style:top="{pos.top}px" style:left="{pos.left}px" style:min-width="{pos.minWidth}px">
{#each options as [v, l] (v)}
<button role="menuitemradio" aria-checked={v === value} class:cur={v === value} onclick={() => pick(v)}>
{l}
{#if v === value}<span class="mcheck"><Icon name="check" size={13} /></span>{/if}
</button>
{/each}
</div>
{/if}
<style>
.chooser {
display: inline-flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 11px 0 14px;
border-radius: 99px;
background: var(--bg-highlight);
color: var(--text-primary);
font-size: 12.5px;
font-weight: 800;
white-space: nowrap;
max-width: 320px;
transition: background 0.15s;
}
.chooser .lab { overflow: hidden; text-overflow: ellipsis; }
.chooser :global(svg) { color: var(--text-secondary); flex: none; transition: transform 160ms ease; }
.chooser:hover:not(:disabled), .chooser.on { background: var(--bg-press); }
.chooser.on :global(svg) { transform: rotate(180deg); }
.chooser:disabled { opacity: 0.6; cursor: default; }
/* the sort menu's popover, verbatim — solid on purpose: a menu has to be
readable over whatever it lands on, dark glass included */
.menu {
position: fixed;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 2px;
padding: 7px;
border-radius: 9px;
background: #0d0d0d;
border: 1px solid var(--border);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.7);
font-family: var(--app-font);
}
.menu button {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 6px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-align: left;
white-space: nowrap;
}
.menu button:hover, .menu button.cur { color: var(--text-primary); }
.menu button:hover { background: var(--bg-highlight); }
.menu .mcheck { margin-left: auto; display: inline-flex; color: var(--accent-hover); }
</style>
+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>
+196
View File
@@ -0,0 +1,196 @@
<script>
// The answer to "who has this?": the members of your rooms whose holdings
// name this album, grouped by room, each with the line Discover already
// writes for that person and the action the answer allows —
//
// Get it a linked holder: browse their share, find the
// folder the room said they hold, queue the grab
// Wait for them the grab could not reach them: a local wait
// that fires when their presence flips online
// Ask in chat a room-local member with no Soulseek name;
// nothing is ever pushed to them
//
// Reads the cached holdings only; the network was read when the room was.
import Icon from "./Icon.svelte";
import Identicon, { hueOf } from "./Identicon.svelte";
import { slsk } from "../lib/state.svelte.js";
import { community, askHoldings, holdingsGrab, waitFor, forgetWait, waitingOn } from "../lib/community.svelte.js";
let { artist, album, mbid = null, compact = false, onanswer = null } = $props();
let holders = $state(null);
let asking = $state(false);
let grabbed = $state({});
let failed = $state({});
$effect(() => {
const a = artist;
const b = album;
const m = mbid;
if (!a || !b) {
holders = [];
return;
}
let live = true;
asking = true;
askHoldings([{ artist: a, album: b, mbid: m }])
.then((r) => {
if (!live) return;
holders = r?.[0]?.holders ?? [];
onanswer?.(holders);
})
.catch(() => {
if (live) holders = [];
})
.finally(() => {
if (live) asking = false;
});
return () => {
live = false;
};
});
/** Rooms in the order they appear, each with its holders. */
const rooms = $derived.by(() => {
const by = new Map();
for (const h of holders ?? []) {
const k = h.owner + ":" + h.space_id;
if (!by.has(k)) by.set(k, { key: k, name: h.space_name, holders: [] });
by.get(k).holders.push(h);
}
return [...by.values()];
});
const people = $derived.by(() => {
const s = new Set();
for (const h of holders ?? []) s.add((h.username ?? h.member).toLowerCase());
return s.size;
});
const display = (u) => community.contacts[u]?.petname || u;
const fpShort = (k) => (k ? `${k.slice(0, 4)}…${k.slice(-4)}` : "");
function presenceOf(u) {
const st = slsk.buddyState[u];
if (!st || st.exists === false) return null;
if (st.status === 2) return "online";
if (st.status === 1) return "away";
return "off";
}
const fmtClock = (ms) => new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
function seenLine(u) {
const ms = community.lastSeen[u];
if (!ms) return null;
const d = new Date(ms);
const now = new Date();
if (d.toDateString() === now.toDateString()) return `last seen by this device today · ${fmtClock(ms)}`;
if (new Date(now - 86400000).toDateString() === d.toDateString()) return `last seen by this device yesterday · ${fmtClock(ms)}`;
return `last seen by this device ${d.toLocaleDateString([], { month: "short", day: "numeric" })}`;
}
/** The second line: presence, then what Discover knows about them. */
function line(h) {
const u = h.username;
if (!u) return "room-local · not linked to a Soulseek account";
const parts = [];
const p = presenceOf(u);
if (p === "online") parts.push("online now");
else if (p === "away") parts.push("away");
else {
const s = seenLine(u);
if (s) parts.push(s);
}
const o = community.overlap.find((x) => x.username.toLowerCase() === u.toLowerCase());
if (o?.albums_in_common_count) parts.push(`shares ${o.albums_in_common_count} ${o.albums_in_common_count === 1 ? "album" : "albums"} you own`);
if (o?.gaps_count) parts.push(`${o.gaps_count} you lack`);
return parts.join(" · ") || "linked to Soulseek";
}
const tierWord = (t) => (t === 2 ? "FLAC" : t === 1 ? "MP3" : null);
async function getIt(h) {
const u = h.username;
delete failed[u];
try {
const m = await holdingsGrab(u, artist, album);
grabbed[u] = m?.files?.length ?? 0;
} catch (e) {
failed[u] = String(e?.message ?? e);
}
}
const busy = (h) => !!community.busy[`grab:${h.username}:${album}`] || !!community.busy[`wait:${h.username}:${album}`];
</script>
<div class="whohas" class:compact>
{#if asking && holders === null}
<div class="quiet"><span class="bl"></span> Asking your rooms…</div>
{:else if !holders?.length}
<div class="quiet">
<Icon name="users" size={16} />
<span>{community.spaces.length ? "Nobody in your rooms has published this one." : "You are in no rooms yet — holdings are asked room by room."}</span>
</div>
{:else}
{#each rooms as r (r.key)}
<div class="grp"><span class="src">{r.name}</span><span class="n">{r.holders.length} of the room</span><span class="ln"></span></div>
{#each r.holders as h (h.member)}
{@const u = h.username}
<div class="row">
<span class="av" style:background="hsl({hueOf(u ?? h.member)} 30% 14%)"><Identicon seed={u ?? h.member} size={compact ? 30 : 36} presence={u ? presenceOf(u) : null} /></span>
<span class="tx">
<span class="l1"><span class="nm">{u ? display(u) : fpShort(h.member)}</span>{#if u}<span class="badge">Soulseek</span>{/if}</span>
<span class="l2">{line(h)}</span>
{#if failed[u]}<span class="l2 warn">{failed[u]}</span>{/if}
</span>
<span class="r">
{#if tierWord(h.tier)}<span class="tier" class:lossless={h.tier === 2}>{tierWord(h.tier)}</span>{/if}
{#if !u}
<button class="ax-ghost sm" disabled title="They have no Soulseek name here; ask in the room's chat">Ask in chat</button>
{:else if grabbed[u] != null}
<span class="chip ok"><Icon name="check" size={11} /> {grabbed[u]} {grabbed[u] === 1 ? "file" : "files"} queued</span>
{:else if waitingOn(u, artist, album)}
<span class="chip acc">waiting</span>
<button class="ax-ghost sm" onclick={() => forgetWait(u, artist, album)} disabled={busy(h)}>Forget</button>
{:else if failed[u]}
<button class="ax-ghost sm" onclick={() => waitFor(u, artist, album, h.space_name)} disabled={busy(h)}>Wait for them</button>
<button class="ax-fill sm" onclick={() => getIt(h)} disabled={busy(h)}>Try again</button>
{:else}
<button class="ax-fill sm" onclick={() => getIt(h)} disabled={busy(h)}>{busy(h) ? "Browsing…" : "Get it"}</button>
{/if}
</span>
</div>
{/each}
{/each}
<div class="foot"><span class="prov"><Icon name="compass" size={11} /> from your rooms' holdings · {people} {people === 1 ? "person" : "people"}</span><span class="note">names compared, never files</span></div>
{/if}
</div>
<style>
.whohas { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.quiet { display: flex; align-items: center; gap: 10px; padding: 12px 10px; font-size: 12.5px; color: var(--text-muted); line-height: 1.5; }
.bl { width: 12px; height: 12px; border-radius: 50%; border: 2px solid var(--text-muted); border-top-color: transparent; animation: spin 0.8s linear infinite; flex: none; }
@keyframes spin { to { transform: rotate(360deg); } }
.grp { display: flex; align-items: center; gap: 8px; height: 26px; padding: 0 2px; font-size: 10.5px; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); margin-top: 4px; }
.grp .n { font-weight: 700; letter-spacing: 0; text-transform: none; }
.grp .ln { flex: 1; height: 1px; background: var(--hair); }
.src { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 99px; background: color-mix(in srgb, var(--space, #c77dff) 14%, transparent); color: #d9a6ff; text-transform: none; }
.row { display: flex; align-items: center; gap: 10px; min-height: 46px; padding: 5px 8px; border-radius: 8px; }
.compact .row { min-height: 40px; padding: 3px 8px; }
.row:hover { background: var(--surf-hi, var(--bg-highlight)); }
.av { display: grid; place-items: center; border-radius: 50%; flex: none; }
.tx { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
.l1 { display: flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 800; }
.l1 .nm { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.badge { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 99px; background: var(--surf-hi, var(--bg-highlight)); color: var(--text-secondary); }
.l2 { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.l2.warn { color: #f0c070; white-space: normal; }
.r { display: flex; align-items: center; gap: 8px; flex: none; }
.tier { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 7px; border-radius: 5px; background: rgba(255, 255, 255, 0.08); color: var(--text-secondary); }
.tier.lossless { background: color-mix(in srgb, var(--ok, #3ddc84) 12%, transparent); color: #5ee89a; }
.chip { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 99px; white-space: nowrap; display: inline-flex; align-items: center; gap: 5px; line-height: 1.5; }
.chip.ok { background: color-mix(in srgb, var(--ok, #3ddc84) 14%, transparent); color: #5ee89a; }
.chip.acc { background: var(--accent-soft, color-mix(in srgb, var(--accent) 15%, transparent)); color: var(--accent-hover); }
.ax-ghost { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 11px; border-radius: 99px; font-size: 11px; 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: 28px; padding: 0 12px; border-radius: 99px; font-size: 11px; font-weight: 800; background: var(--accent); color: #000; white-space: nowrap; }
.ax-fill:disabled { opacity: 0.55; cursor: default; }
.foot { display: flex; align-items: center; gap: 10px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--hair); }
.prov { font-size: 11.5px; color: var(--text-muted); display: flex; align-items: center; gap: 6px; }
.note { margin-left: auto; font-size: 11px; color: var(--text-muted); }
</style>
Binary file not shown.
+15
View File
@@ -196,6 +196,14 @@ export const spaceBoard = (owner, space) => invoke("space_board", { owner, space
export const spacePropose = (owner, space, base, ops, note) => invoke("space_propose", { owner, space, base, ops, note });
export const spaceDecide = (owner, space, proposal, accept, reason) => invoke("space_decide", { owner, space, proposal, accept, reason });
export const overlapList = () => invoke("overlap_list");
export const holdingsSetShare = (owner, space, on) => invoke("holdings_set_share", { owner, space, on });
export const holdingsRoom = (owner, space, refresh = false) => invoke("holdings_room", { owner, space, refresh });
export const holdingsAsk = (items) => invoke("holdings_ask", { items });
export const holdingsFindFolder = (username, artist, album) => invoke("holdings_find_folder", { username, artist, album });
export const holdingsRepublish = () => invoke("holdings_republish");
export const holdingsWaits = () => invoke("holdings_waits");
export const holdingsWaitAdd = (username, artist, album, via = null) => invoke("holdings_wait_add", { username, artist, album, via });
export const holdingsWaitRemove = (username, artist, album) => invoke("holdings_wait_remove", { username, artist, album });
export const overlapCompute = (username) => invoke("overlap_compute", { username });
export const overlapForget = (username) => invoke("overlap_forget", { username });
export const buddyList = () => invoke("buddy_list");
@@ -218,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");
+85 -2
View File
@@ -91,6 +91,11 @@ export const community = $state({
boardError: "",
/** overlap_list: library comparisons computed on this device, best first. */
overlap: [],
/** `owner:space` → holdings_room: who in that room publishes what they
* hold, and whether this device does. Read when a Space opens. */
holdings: {},
/** holdings_waits: albums to grab from someone the moment they come online. */
waits: [],
/** persona key → ProfileView | {none} | {error} | {pending}: other
* people's signed profiles, read once per session when surfaced. */
remoteProfiles: {},
@@ -350,6 +355,18 @@ function wireEvents() {
void refreshFollowing();
});
api.onEvent("community:overlap", () => void refreshOverlap());
api.onEvent("community:holdings", () => {
// A read finished, a publish landed, a wait changed: re-read the
// open room's lists from the cache (fresh members cost no network).
if (community.space) void refreshHoldings(community.space.owner, community.space.space_id);
void refreshWaits();
});
// The library changed: every room with the switch on gets the new
// list, coalesced so a scan's burst of changes is one publish.
api.onEvent("soulseek:library-changed", () => {
clearTimeout(republishTimer);
republishTimer = setTimeout(() => void api.holdingsRepublish().catch(() => {}), REPUBLISH_DEBOUNCE_MS);
});
api.onEvent("community:proposal", (p) => {
announceProposal(p.from, p.playlist);
// If the board is already on screen, show what it is talking about.
@@ -357,6 +374,8 @@ function wireEvents() {
});
api.onEvent("soulseek:user-state", (p) => {
if (p.status === 1 || p.status === 2) noteSeen(p.username);
// Someone we are waiting on came online: the grab runs now.
if (p.status === 2) void drainWaits(p.username);
});
}
}
@@ -364,7 +383,7 @@ function wireEvents() {
/** Idempotent: pulls the state the Community page draws. */
export async function initCommunity() {
wireEvents();
await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished(), refreshFollowing(), refreshInbox(), refreshSpaces(), refreshOverlap()]);
await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished(), refreshFollowing(), refreshInbox(), refreshSpaces(), refreshOverlap(), refreshWaits()]);
await refreshPersona();
}
@@ -379,6 +398,9 @@ export async function initCommunity() {
* that comes up later — or an inbox published later — simply starts working
* without anything having to restart the timer. */
const INBOX_POLL_MS = 90_000;
/** A library scan raises many change events in a row; one publish serves them all. */
const REPUBLISH_DEBOUNCE_MS = 30_000;
let republishTimer = 0;
/** Followed playlists are read far less often: every pointer read is a ring
* search that can take seconds, and an owner's next revision is a matter
* of days, not minutes. */
@@ -705,6 +727,67 @@ export async function refreshSpaces() {
}
}
/** Read a Space through the node; the view is what the checkpoint says. */
// ---- holdings: who has this, and get it from them ---------------------------------
const holdingsId = (owner, space) => `${owner}:${space}`;
/** The room's lists as the cache has them; the backend reads stale members
* in the background and the event brings the result back here. */
export async function refreshHoldings(owner, space, refresh = false) {
try {
community.holdings[holdingsId(owner, space)] = await api.holdingsRoom(owner, space, refresh);
} catch {
// stays as it was
}
}
export const holdingsFor = (owner, space) => community.holdings[holdingsId(owner, space)] ?? null;
/** The switch: publish this device's list into the room, or withdraw it. */
export const setShareHoldings = (owner, space, on) =>
run("holdings:share", async () => {
community.holdings[holdingsId(owner, space)] = await api.holdingsSetShare(owner, space, on);
if (community.space?.owner === owner && community.space?.space_id === space) await openSpace(owner, space);
});
/** Who holds each (artist, album), across every room — local, instant. */
export const askHoldings = (items) => api.holdingsAsk(items);
/** Get it: browse the holder, find the folder the room said they hold, and
* queue every audio file through the hub's own download — the transfers
* page shows them like any album grab. */
export const holdingsGrab = (username, artist, album) =>
run(`grab:${username}:${album}`, async () => {
const m = await api.holdingsFindFolder(username, artist, album);
if (!m) throw new Error(`${username}'s share does not name ${album} any more`);
for (const f of m.files) await api.downloadFile(username, f.path, f.size);
return m;
});
export async function refreshWaits() {
try {
community.waits = await api.holdingsWaits();
} catch {
// stays as it was
}
}
export const waitFor = (username, artist, album, via = null) =>
run(`wait:${username}:${album}`, async () => {
community.waits = await api.holdingsWaitAdd(username, artist, album, via);
});
export const forgetWait = (username, artist, album) =>
run(`wait:${username}:${album}`, async () => {
community.waits = await api.holdingsWaitRemove(username, artist, album);
});
export const waitingOn = (username, artist, album) =>
community.waits.some((w) => w.username.toLowerCase() === (username ?? "").toLowerCase() && w.artist === artist && w.album === album);
/** A waited-on person came online: run their grabs, each once. */
async function drainWaits(username) {
const mine = community.waits.filter((w) => w.username.toLowerCase() === (username ?? "").toLowerCase());
for (const w of mine) {
try {
await holdingsGrab(w.username, w.artist, w.album);
await forgetWait(w.username, w.artist, w.album);
} catch {
// their share no longer names it, or the browse failed: the wait
// stays, and the row says what happened when it is looked at
}
}
}
export async function openSpace(owner, space) {
community.spaceError = "";
community.busy["space:" + space] = true;
@@ -814,4 +897,4 @@ export const decideProposal = (owner, space, proposal, accept, reason) =>
// Dev handle beside `__soulstream` (state.svelte.js): probes reach the store
// through this, never through a bare import (which would be a second copy).
if (import.meta.env.DEV) globalThis.__community = community;
if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, refreshFollowing, followFor, followNews, checkFollows, followUpdate, unfollow, publishInbox, checkInbox, refreshInbox, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace, setShareListens, openBoard, proposeToBoard, decideProposal, refreshOverlap, computeOverlap, forgetOverlap, refreshInterests, setInterest };
if (import.meta.env.DEV) globalThis.__communityActions = { refreshHoldings, holdingsFor, setShareHoldings, askHoldings, holdingsGrab, refreshWaits, waitFor, forgetWait, waitingOn, friendRequest, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, refreshFollowing, followFor, followNews, checkFollows, followUpdate, unfollow, publishInbox, checkInbox, refreshInbox, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace, setShareListens, openBoard, proposeToBoard, decideProposal, refreshOverlap, computeOverlap, forgetOverlap, refreshInterests, setInterest };
+165
View File
@@ -0,0 +1,165 @@
// Discover's ranked shelf: one list across the signals the page already
// draws in silos — the room you are in, the people your transfers touched,
// the server's similar-interests answer, the library overlaps computed on
// this device, the roster of the Space you have open. Nothing here reads
// the network; every input is a store the page holds already.
//
// The page's rule holds: every shelf says where its people came from. A
// ranked card is not exempt — its `reasons` are the shelves it drew from,
// strongest first, and the template names each one. Contacts and friends
// are left out: this is for people you have not sorted yet.
//
// The weights are the whole opinion, in one place so they can be argued
// with. Each signal is capped so one huge browse cannot bury everything.
export const WEIGHTS = {
/** Per album you both own (overlap), up to `CAPS.albums`. */
albumInCommon: 3,
/** Per album of theirs by an artist you follow that you lack. */
gap: 1,
/** Per artist in common, only when no album is (a weaker overlap). */
artistInCommon: 0.5,
/** Per declared like the server says you share. */
like: 2,
/** In the room you are looking at right now. */
activeRoom: 2,
/** Per other joined room they are in. */
room: 1,
/** Per file, either direction, this session. */
transfer: 1,
/** A seat beside yours in the open Space. */
spaceMember: 3,
};
export const CAPS = { albums: 10, gaps: 10, artists: 10, rooms: 3, transfers: 5 };
const cap = (n, max) => Math.min(Math.max(n ?? 0, 0), max);
/**
* @param {object} input
* @param {string} input.me our own username (never listed)
* @param {(u: string) => boolean} [input.skip] contacts, friends — people already sorted
* @param {Record<string, {users: {username: string}[]}>} [input.rooms] joined rooms by name
* @param {string|null} [input.activeRoom]
* @param {{username?: string}[]} [input.downloads] this session's downloads
* @param {{username?: string}[]} [input.uploads] this session's uploads
* @param {{username: string, rating: number}[]} [input.similar]
* @param {{username: string, albums_in_common_count?: number, gaps_count?: number, artists_in_common_count?: number}[]} [input.overlap]
* @param {{name: string, members: {username?: string|null, is_me?: boolean}[]}|null} [input.space] the open Space
* @param {number} [input.limit]
* @returns {{username: string, score: number, reasons: object[]}[]} best first
*/
export function rankPeople(input) {
const { me = "", skip = () => false, limit = 12 } = input;
const people = new Map();
const at = (u) => {
if (!u || u === me || skip(u)) return null;
let p = people.get(u);
if (!p) people.set(u, (p = { username: u, score: 0, reasons: [] }));
return p;
};
const add = (u, points, reason) => {
const p = at(u);
if (!p || points <= 0) return;
p.score += points;
p.reasons.push({ ...reason, points });
};
for (const o of input.overlap ?? []) {
const albums = cap(o.albums_in_common_count, CAPS.albums);
const gaps = cap(o.gaps_count, CAPS.gaps);
const artists = albums ? 0 : cap(o.artists_in_common_count, CAPS.artists);
const points = albums * WEIGHTS.albumInCommon + gaps * WEIGHTS.gap + artists * WEIGHTS.artistInCommon;
add(o.username, points, {
kind: "overlap",
albums: o.albums_in_common_count ?? 0,
gaps: o.gaps_count ?? 0,
artists: o.artists_in_common_count ?? 0,
});
}
for (const su of input.similar ?? []) {
add(su.username, (su.rating ?? 0) * WEIGHTS.like, { kind: "likes", n: su.rating ?? 0 });
}
// Rooms: the one on screen counts most; the rest count once each.
const roomsOf = new Map();
for (const [name, room] of Object.entries(input.rooms ?? {})) {
for (const u of room?.users ?? []) {
if (!u?.username) continue;
if (!roomsOf.has(u.username)) roomsOf.set(u.username, []);
roomsOf.get(u.username).push(name);
}
}
for (const [u, names] of roomsOf) {
const active = input.activeRoom && names.includes(input.activeRoom) ? input.activeRoom : null;
const others = names.filter((n) => n !== active);
const points = (active ? WEIGHTS.activeRoom : 0) + cap(others.length, CAPS.rooms) * WEIGHTS.room;
add(u, points, { kind: "rooms", active, others });
}
const tally = new Map();
const count = (u, side) => {
if (!u) return;
if (!tally.has(u)) tally.set(u, { from: 0, to: 0 });
tally.get(u)[side] += 1;
};
for (const d of input.downloads ?? []) count(d?.username, "from");
for (const x of input.uploads ?? []) count(x?.username, "to");
for (const [u, t] of tally) {
add(u, cap(t.from + t.to, CAPS.transfers) * WEIGHTS.transfer, { kind: "transfers", from: t.from, to: t.to });
}
const space = input.space;
for (const m of space?.members ?? []) {
if (!m?.username || m.is_me) continue;
add(m.username, WEIGHTS.spaceMember, { kind: "space", name: space.name });
}
const out = [...people.values()].filter((p) => p.score > 0);
// Reasons strongest first; a tie keeps the order the signals were read
// in above, which is the order of how much each one says about taste.
for (const p of out) p.reasons.sort((a, b) => b.points - a.points);
out.sort((a, b) => b.score - a.score || a.reasons.length - b.reasons.length || a.username.localeCompare(b.username));
return out.slice(0, limit);
}
/** A reason as the card draws it: segments, the numbers and names bold.
* The wording is each source shelf's own, so a reason reads the same here
* as where it came from. */
export function reasonParts(r) {
const b = (t) => ({ t: String(t), b: true });
const t = (s) => ({ t: s, b: false });
const n = (k, one, many) => [b(k), t(` ${k === 1 ? one : many}`)];
switch (r.kind) {
case "overlap":
if (r.albums) return [t("shares "), ...n(r.albums, "album", "albums"), t(" you own"), ...(r.gaps ? [t(" · "), b(r.gaps), t(" you lack")] : [])];
if (r.artists) return [...n(r.artists, "artist", "artists"), t(" in common"), ...(r.gaps ? [t(" · "), b(r.gaps), t(" albums you lack")] : [])];
return [t("browsed, nothing in common")];
case "likes":
return [t("shares "), b(r.n), t(" of your declared likes")];
case "rooms": {
const parts = [];
if (r.active) parts.push(t("in "), b(`#${r.active}`), t(" now"));
if (r.others.length) {
if (parts.length) parts.push(t(" · "));
if (r.others.length === 1) parts.push(t("also in "), b(`#${r.others[0]}`));
else parts.push(t("in "), b(r.others.length), t(" rooms with you"));
}
return parts;
}
case "transfers": {
const parts = [];
if (r.from) parts.push(t("sent you "), ...n(r.from, "file", "files"));
if (r.from && r.to) parts.push(t(" · "));
if (r.to) parts.push(t("you sent them "), b(r.to));
return parts;
}
case "space":
return [t("with you in "), b(r.name)];
default:
return [];
}
}
/** A reason in words — what a test reads, and what the card says. */
export const reasonText = (r) => reasonParts(r).map((s) => s.t).join("");
+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();
}
Binary file not shown.
+13
View File
@@ -30,6 +30,8 @@
import { listenerPeak, listenerShare } from "../lib/listenerBars.js";
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
import Icon from "../components/Icon.svelte";
import WhoHas from "../components/WhoHas.svelte";
import { community } from "../lib/community.svelte.js";
import PlaylistMenu from "../components/PlaylistMenu.svelte";
const page = $derived(catalog.albumPage); // { album, tracks, album_plays, … }
@@ -887,6 +889,13 @@
</div>
{/if}
{#if !inLibrary && community.spaces.length}
<section class="whohas-sec">
<div class="whohas-head"><h2>Who has this</h2><span class="p">from your rooms' holdings · names compared, never files</span></div>
{#key album.artist + "\u0000" + album.title}<WhoHas artist={album.artist} album={album.title} />{/key}
</section>
{/if}
<div class="tracklist">
{#if tracks.length}
<div class="tcols" class:has-count={showCount}>
@@ -1976,4 +1985,8 @@
:global(:root.glass) .stat-note {
border-color: var(--glass-hairline);
}
.whohas-sec { margin: 8px 0 18px; padding: 12px 14px; border-radius: 12px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.07); }
.whohas-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 6px; }
.whohas-head h2 { margin: 0; font-size: 15px; font-weight: 800; }
.whohas-head .p { font-size: 11.5px; color: var(--text-muted); }
</style>
+29
View File
@@ -52,6 +52,8 @@
import { openPreview } from "../lib/preview/previewSession.svelte.js";
import { traceToSvg, svgDataUrl } from "../lib/trace.js";
import Icon from "../components/Icon.svelte";
import WhoHas from "../components/WhoHas.svelte";
import { openWhoHas, holdersFor, pairKey, holderCount } from "../lib/whoHas.svelte.js";
import PlaylistMenu from "../components/PlaylistMenu.svelte";
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
import HeroGpuCanvas from "../components/HeroGpuCanvas.svelte";
@@ -2536,6 +2538,17 @@
});
const visibleTabs = $derived(tabs.filter((tab) => tab.key === activeTabKey));
// Who in your rooms holds a release you lack: asked for the visible tab's
// families at once, filed by pairKey; the count rides the card, and the
// open album shows the people.
let heldBy = $state({});
const heldFor = (title) => (page ? heldBy[pairKey(page.artist.name, title)] : null);
$effect(() => {
const name = page?.artist?.name;
const items = visibleTabs.flatMap((g) => g.items).filter((al) => !inLibrary(al)).map((al) => ({ artist: name, album: al.title }));
if (!name || !items.length) return;
untrack(() => void holdersFor(items).then((m) => { for (const [k, v] of m) heldBy[k] = v; }));
});
// FLIP for the stage-2 correction. Svelte's `animate:` directive can't be
// used here — it requires the animated element to be the ONLY child of the
@@ -5399,6 +5412,9 @@
onclick={(e) => { e.stopPropagation(); streamRelease(al, al); }}
><Icon name="play" size={13} /></button>
{/if}
{#if heldFor(al.title)?.length}
<button class="have" title="In your rooms — who has it?" onclick={(e) => { e.stopPropagation(); openWhoHas({ artist: page.artist.name, album: al.title, cover: al.cover ?? null }, e.currentTarget.closest(".release")); }}>{holderCount(heldFor(al.title))} {holderCount(heldFor(al.title)) === 1 ? "has it" : "have it"}</button>
{/if}
</div>
<div class="title" title={al.title}>
{al.title}
@@ -5565,6 +5581,12 @@
{/if}
</div>
<div class="xp-tracks">
{#if openAlbum && !inLibrary(openAlbum) && heldFor(openAlbum.title)?.length}
<div class="xp-whohas">
<div class="xp-whohas-head"><span class="t">Who has this</span><span class="p">from your rooms' holdings · names compared, never files</span></div>
{#key openAlbum.id}<WhoHas artist={page.artist.name} album={openAlbum.title} compact />{/key}
</div>
{/if}
{#if openLoading}
<!-- Nothing at all until the load
has outlasted LOADING_GRACE:
@@ -9602,4 +9624,11 @@
:global(:root.glass) .notch {
background: var(--glass-surface);
}
/* who in your rooms holds a release you lack — the count rides the card */
.have { position: absolute; right: 6px; bottom: 6px; z-index: 2; display: inline-flex; align-items: center; gap: 4px; height: 20px; padding: 0 7px; border-radius: 99px; background: rgba(0, 0, 0, 0.62); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); color: #fff; font-size: 10.5px; font-weight: 900; border: 1px solid rgba(255, 255, 255, 0.14); cursor: pointer; white-space: nowrap; }
.have:hover { background: rgba(0, 0, 0, 0.82); border-color: rgba(255, 255, 255, 0.34); }
.xp-whohas { margin: 0 0 12px; padding: 10px 12px; border-radius: 10px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.07); }
.xp-whohas-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 4px; flex-wrap: wrap; }
.xp-whohas-head .t { font-size: 12.5px; font-weight: 800; white-space: nowrap; }
.xp-whohas-head .p { font-size: 11px; color: var(--text-muted); }
</style>
+118 -116
View File
@@ -13,6 +13,8 @@
import { onMount, untrack } from "svelte";
import Icon from "../components/Icon.svelte";
import Identicon, { hueOf } from "../components/Identicon.svelte";
import WhoHas from "../components/WhoHas.svelte";
import { rankPeople, reasonParts } from "../lib/discover.js";
import Soulseek from "./Soulseek.svelte";
import * as api from "../lib/api.js";
import {
@@ -95,6 +97,9 @@
pinToSpace,
leaveSpace,
setShareListens,
refreshHoldings,
holdingsFor,
setShareHoldings,
openBoard,
proposeToBoard,
decideProposal,
@@ -362,6 +367,22 @@
}
// ---- discover ------------------------------------------------------------
// The ranked shelf: the four silos below scored as one list, each card
// naming the shelves it drew from. Contacts and friends are left off —
// they have shelves of their own; this one is for people not yet sorted.
const ranked = $derived.by(() =>
rankPeople({
me,
skip: (u) => isContact(u) || friendshipFor(u)?.state === "friends",
rooms: slsk.joined,
activeRoom: slsk.activeRoom,
downloads: Object.values(downloads.items),
uploads: Object.values(network.uploads),
similar: community.similar?.users ?? [],
overlap: community.overlap,
space: community.space ? { name: community.space.name, members: community.space.members } : null,
}),
);
const roomPeople = $derived.by(() => {
if (!activeRoom) return [];
return activeRoom.users
@@ -413,7 +434,6 @@
let exportDone = $state(null);
let confirmRotate = $state(false);
let confirmRemove = $state(false);
let disclosuresOpen = $state(false);
async function doExport() {
const r = await exportPersona(passExport);
if (r) {
@@ -531,6 +551,18 @@
let spaceCreating = $state(false);
const sv = $derived(community.space);
const spaceOwner = $derived(sv?.my_role === "owner");
// Holdings: the room's lists are read when a Space opens (stale members
// in the background; the event brings the result back), and the ask box
// answers from the cache.
const hv = $derived(sv ? holdingsFor(sv.owner, sv.space_id) : null);
let askArtist = $state("");
let askAlbum = $state("");
let asked = $state(null);
$effect(() => {
const id = sv?.id;
if (!id) return;
untrack(() => void refreshHoldings(sv.owner, sv.space_id));
});
let spaceEl = $state(null);
$effect(() => {
const n = sv?.messages.length;
@@ -1173,6 +1205,25 @@
<!-- ── DISCOVER ───────────────────────────────────────────────────── -->
{:else if community.section === "discover"}
<div class="shelf">
<div class="ax-head">
<h2 class="ax-title">Closest to you</h2>
<span class="prov"><Icon name="sparkle" size={12} /> the shelves below, scored together · every reason named</span>
</div>
{#if ranked.length}
<div class="track">
{#each ranked as p (p.username)}
<button class="pcard" onclick={() => (community.drawer = p.username)}>
<span class="face" style:background="hsl({hueOf(p.username)} 30% 14%)"><Identicon seed={p.username} size={84} presence={presenceOf(p.username)} /></span>
<span class="nm">{display(p.username)}</span>
<span class="why">{#each p.reasons as r, i}{#if i}{" · "}{/if}{#each reasonParts(r) as seg}{#if seg.b}<b>{seg.t}</b>{:else}{seg.t}{/if}{/each}{/each}</span>
</button>
{/each}
</div>
{:else}
<div class="emptyshelf"><Icon name="sparkle" size={22} /><div><b>Nothing to rank yet.</b> This shelf is the ones below added up — join a room, browse someone, declare a like — with contacts and friends left off.</div></div>
{/if}
</div>
<div class="shelf">
<div class="ax-head">
<h2 class="ax-title">{slsk.activeRoom ? `In #${slsk.activeRoom} right now` : "In a room right now"}</h2>
@@ -1326,6 +1377,7 @@
<div class="sptabs">
<button class="subtab" class:on={spaceTab === "chat"} onclick={() => (spaceTab = "chat")}>Chat<b>{sv.messages.length}</b></button>
<button class="subtab" class:on={spaceTab === "listening"} onclick={() => (spaceTab = "listening")}>Listening{#if sv.listens.length}<b>{sv.listens.length}</b>{/if}</button>
<button class="subtab" class:on={spaceTab === "holdings"} onclick={() => (spaceTab = "holdings")}>Holdings{#if hv?.sharing}<b>{hv.sharing}</b>{/if}</button>
<button class="subtab" class:on={spaceTab === "members"} onclick={() => (spaceTab = "members")}>Members<b>{sv.members.length}</b></button>
<button class="subtab" class:on={spaceTab === "about"} onclick={() => (spaceTab = "about")}>About</button>
<button class="subtab" class:on={spaceTab === "proposals"} onclick={() => (spaceTab = "proposals")}>Proposals{#if bv && bv.uuid === sv.pinned_uuid && bv.proposals.length}<b>{bv.proposals.length}</b>{/if}</button>
@@ -1410,6 +1462,50 @@
</div>
{/if}
</div>
{:else if spaceTab === "holdings"}
<div class="panel sharebar" style="margin-bottom:12px">
<button class="toggle" class:on={sv.share_holdings} aria-pressed={sv.share_holdings} title="Share what I hold in this Space"
onclick={() => setShareHoldings(sv.owner, sv.space_id, !sv.share_holdings)} disabled={!nodeUp || !sv.can_post || !!community.busy["holdings:share"]}><i></i></button>
<div class="grow">
<div class="ttl">Share what I hold in this Space</div>
<p class="hint">{#if sv.share_holdings && hv?.mine && !hv.mine.withdrawn}<b>{fmtCount(hv.mine.count)} albums</b> · published{#if sv.private} sealed to this room's epoch key{/if} · updated when your library changes · <b>synced as of {fmtClock(hv.mine.published_at * 1000)}</b>{:else}Album names as keys, one quality word each, and your Soulseek name so members can get it from you — never files. Per Space, off until you say so.{/if}</p>
</div>
{#if community.busy["holdings:share"]}<span class="chip off">working…</span>{:else if sv.share_holdings}<span class="chip acc">sharing</span>{:else}<span class="chip off">not sharing</span>{/if}
</div>
<div class="holddisc"><Icon name="alert" size={14} /><span><b>What the room learns:</b> members can tell whether you hold any album they can name, and roughly how many you hold. They never see files, paths, or formats beyond a lossless/lossy word. Turning this off withdraws the list; copies members already read stay with them.</span></div>
<div class="panel" style="margin-top:12px">
<h3><Icon name="search" size={13} /> Ask this room{#if hv}<span class="chip up">{hv.sharing} of {sv.members.length} sharing</span>{/if}</h3>
<form class="inl askinl" style="margin-top:8px" onsubmit={(e) => { e.preventDefault(); if (askArtist.trim() && askAlbum.trim()) asked = { artist: askArtist.trim(), album: askAlbum.trim() }; }}>
<input placeholder="Artist" bind:value={askArtist} />
<input placeholder="Album" bind:value={askAlbum} />
<button class="ax-ghost sm" type="submit" disabled={!askArtist.trim() || !askAlbum.trim()}>Ask</button>
</form>
{#if asked}{#key asked.artist + "\u0000" + asked.album}<div style="margin-top:8px"><WhoHas artist={asked.artist} album={asked.album} /></div>{/key}{/if}
</div>
<div class="panel" style="margin-top:12px">
<h3><Icon name="users" size={13} /> Who shares here <button class="ax-ghost sm" style="margin-left:auto" onclick={() => refreshHoldings(sv.owner, sv.space_id, true)} disabled={!nodeUp}><Icon name="refresh" size={12} /> Refresh</button></h3>
{#if hv}
<div class="hlist">
<div class="hrow">
<Identicon seed={key ?? "you"} size={30} />
<span class="tx"><span class="l1">you</span>
<span class="l2">{#if sv.share_holdings && hv.mine && !hv.mine.withdrawn}<b>{fmtCount(hv.mine.count)}</b> albums · list {hv.mine.sequence} · published {fmtClock(hv.mine.published_at * 1000)}{:else}not sharing{/if}</span></span>
<span class="chip" class:acc={sv.share_holdings && hv.mine && !hv.mine.withdrawn} class:off={!(sv.share_holdings && hv.mine && !hv.mine.withdrawn)}>{sv.share_holdings && hv.mine && !hv.mine.withdrawn ? "shared" : "none"}</span>
</div>
{#each hv.members.filter((m) => !m.is_me) as m (m.key)}
<div class="hrow">
<Identicon seed={m.username ?? m.key} size={30} />
<span class="tx"><span class="l1">{m.is_me ? "you" : m.username ? display(m.username) : fpShort(m.key)}</span>
<span class="l2">{#if m.state === "shared"}<b>{fmtCount(m.count)}</b> albums · list {m.sequence}{#if m.read_at} · read {fmtClock(m.read_at * 1000)}{/if}{:else if m.state === "none"}not sharing{:else if m.state === "withdrawn"}withdrew their list{:else if m.state === "sealed"}sealed to an epoch you have no key for{:else}not read yet{/if}</span></span>
<span class="chip" class:acc={m.state === "shared"} class:off={m.state !== "shared"}>{m.state}</span>
</div>
{/each}
</div>
{:else}
<p class="hint" style="margin-top:8px">Reading the room's lists…</p>
{/if}
<p class="hint" style="margin-top:8px">A member's list is read when the Space opens and again after ten minutes; a member who never published costs a slow search, so they are asked again only hourly.</p>
</div>
{:else if spaceTab === "members"}
<div class="panel">
<div class="members">
@@ -1712,98 +1808,7 @@
<div class="yc c">
<div class="panel">
<h3>Networks</h3>
<div class="netcard">
<div class="nico slsk"><Icon name="globe" size={18} /></div>
<div class="ntx">
<div class="t">Soulseek <span class="chip {online ? 'ok' : 'off'}">{online ? "online" : "offline"}</span></div>
<div class="s">
{#if me}Signed in as <b>{me}</b> · {fmtCount(network.shareFiles)} files shared.{:else}Not signed in — see Settings.{/if}
<br />
{#if !key}
Create a SoulStream identity to link this account to it.
{:else if !me}
Sign in to Soulseek to link this account to your identity.
{:else if ownLinkCurrent}
<b>Linked to this identity</b> — proof verified {new Date(ownLink.verified_at * 1000).toLocaleString()} through a Soulseek message to yourself. Other SoulStream users who ask over Soulseek now get your signed ID; nothing is published.
{:else}
Not linked to your identity. Linking sends one message to yourself over Soulseek and verifies the signed answer — nothing is published, and from then on other SoulStream users who ask get your signed ID.
{/if}
</div>
{#if key && me}
<div class="noderow">
{#if ownLinkCurrent}
<button class="ax-ghost sm" onclick={() => unlinkSelf()} disabled={!!community.busy.linkself}>Unlink</button>
{:else}
<button class="ax-fill sm" onclick={() => linkSelf()} disabled={!online || !nodeUp || !!community.busy.linkself}><Icon name="link" size={12} /> {community.busy.linkself ? "Linking…" : "Link this account"}</button>
{#if !nodeUp}<span class="hint">needs the node connected</span>{/if}
{/if}
</div>
{/if}
</div>
</div>
<div class="netcard">
<div class="nico fn"><Icon name="disc" size={18} /></div>
<div class="ntx">
<div class="t">Freenet <span class="chip acc">experimental</span></div>
<div class="s">
{#if freenetOn}
Local node on 127.0.0.1:{community.freenet?.ws_port ?? 7509} · <span class="chip {status.tone}">{status.text}</span>
<button class="lnk" onclick={() => detectNode()} disabled={!!community.busy.detect}>{community.busy.detect ? "checking…" : "check again"}</button>
{#if statusKind(community.freenet?.status) === "node-absent"}
<div class="noderow">
{#if community.nodeInfo?.binary}
<button class="ax-fill sm" onclick={() => startNode()} disabled={!!community.busy.node}>
<Icon name="disc" size={12} /> {community.busy.node ? "Starting…" : "Start node"}
</button>
<span class="hint">runs the installed peer{#if community.nodeInfo.version} ({community.nodeInfo.version}){/if} in the background · telemetry pinned off · data in the app folder</span>
{:else}
<span class="hint warn">No Freenet peer found on this machine — install it (cargo install freenet) and check again.</span>
{/if}
</div>
{:else if community.nodeInfo?.started_by_app}
<div class="noderow">
<button class="ax-ghost sm" onclick={() => stopNode()} disabled={!!community.busy.node}>{community.busy.node ? "Stopping…" : "Stop node"}</button>
<span class="hint">started by SoulStream · keeps running when the app closes; this button stops it</span>
</div>
{/if}
{:else}
Off. Carries your identity and profile; later playlists, friend links and Spaces. Turning it on publishes nothing. Turning it off is instant and touches nothing else in the app.
{/if}
</div>
<details class="disc" bind:open={disclosuresOpen}>
<summary>Before you rely on it — seven things that are true</summary>
<ul>
<li>A desktop background peer must run; it uses network, disk, memory and CPU.</li>
<li>The network is alpha. Things you publish are replicated and cannot be guaranteed erased.</li>
<li>Freenet is not an anonymity service.</li>
<li>The node's own diagnostics are a node setting SoulStream pins off; verify after upgrades.</li>
<li>Nothing is published when you enable this. Publishing is always a button.</li>
<li>Nothing here ever says "online" — a successful fetch is not presence.</li>
<li>Phones and TVs cannot run the same peer today.</li>
</ul>
</details>
{#if freenetOn}
<label class="swrow">
<button class="tog" class:on={community.freenet?.load_remote_images ?? true} role="switch" aria-label="Load remote profile images" aria-checked={community.freenet?.load_remote_images ?? true} onclick={() => setLoadRemoteImages(!(community.freenet?.load_remote_images ?? true))} disabled={!!community.busy.images}></button>
<span>Load remote profile images</span>
<span class="hint r">verified, resized, cached by hash</span>
</label>
{#if key}
<div class="noderow">
{#if community.inbox?.published}
<span class="chip ok"><Icon name="lock" size={10} />encrypted inbox · key {community.inbox.box_key_id}</span>
<button class="ax-ghost sm" onclick={() => checkInbox()} disabled={!nodeUp || !!community.busy.inboxcheck}>{community.busy.inboxcheck ? "Checking…" : "Check now"}</button>
<span class="hint">{community.inbox.last_check ? `checked ${fmtWhen(community.inbox.last_check * 1000)} · ` : ""}friend requests and shares arrive sealed to a key kept on this device; a persona restored elsewhere publishes a new one</span>
{:else}
<button class="ax-fill sm" onclick={() => publishInbox()} disabled={!nodeUp || !!community.busy.inbox}><Icon name="lock" size={12} /> {community.busy.inbox ? "Publishing…" : "Publish an encrypted inbox"}</button>
<span class="hint">lets other SoulStream users send you friend requests and shares sealed, without Soulseek in between</span>
{/if}
</div>
{/if}
{/if}
</div>
<button class="tog big" class:on={freenetOn} role="switch" aria-checked={freenetOn} title={freenetOn ? "Turn the Freenet layer off" : "Turn the Freenet layer on"} onclick={() => setFreenetEnabled(!freenetOn)} disabled={!!community.busy.freenet}></button>
</div>
<p class="hint">The Soulseek link, the Freenet node, remote images and the encrypted inbox live in <button class="lnk" onclick={() => goToView("settings")}>Settings → Networks</button> now — every switch in one place.</p>
</div>
<div class="panel dim">
<h3><Icon name="devices" size={13} /> Devices <span class="chip off">later</span></h3>
@@ -1993,13 +1998,14 @@
throughout (the approved knob): 46px rows, 12px gaps and pads. */
.community {
position: relative;
/* A designed page width, centred in the pane: at 2560 the sections used
to huddle top-left with the rest of the pane empty (Kyle, 2026-09-02).
Below the cap nothing changes. */
max-width: 1520px;
margin: 0 auto;
padding-top: calc(var(--bar-h) + 18px);
padding-bottom: 40px;
/* The whole pane, edge to edge (Kyle, 2026-09-08: "not using the full
window"). The 1520px cap from 2026-09-02 answered a page that
huddled top-left at 2560; now every section stretches instead —
the shelves, the split grids, the You grid and the chat threads all
size to the pane; no inner cap remains. */
max-width: none;
margin: 0;
padding: calc(var(--bar-h) + 18px) 24px 40px;
--row: 46px;
--gap: 12px;
--pad: 12px;
@@ -2354,6 +2360,18 @@
.mem .nm { font-size: 11.5px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 96px; }
.mem .role { font-size: 9.5px; font-weight: 900; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); }
.mem .role.own { color: #d9a6ff; }
/* holdings: the disclosure and the who-shares list */
.holddisc { display: flex; gap: 8px; align-items: flex-start; padding: 9px 11px; border-radius: 9px; background: color-mix(in srgb, var(--warn) 7%, transparent); border: 1px solid color-mix(in srgb, var(--warn) 25%, transparent); font-size: 11.5px; color: var(--text-secondary); line-height: 1.5; }
.holddisc b { color: #f0c070; }
.askinl input { height: 30px; padding: 0 10px; border-radius: 8px; background: var(--surf-hi); border: none; color: var(--text-primary); font-size: 12px; outline: none; }
.askinl input::placeholder { color: var(--text-muted); }
.hlist { display: flex; flex-direction: column; gap: 2px; margin-top: 8px; }
.hrow { display: flex; align-items: center; gap: 10px; padding: 5px 8px; border-radius: 8px; min-height: 40px; }
.hrow:hover { background: var(--surf-hi); }
.hrow .tx { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
.hrow .l1 { font-size: 13px; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hrow .l2 { font-size: 11.5px; color: var(--text-muted); }
.hrow .l2 b { color: var(--text-secondary); }
/* ── you ── */
/* The You section sized by the PANE (container query), never the window:
three columns from ~1240px of pane, two down to ~820, then one. */
@@ -2372,8 +2390,6 @@
.yougrid, .yougrid.setup { grid-template-columns: 1fr; }
.yc.c { grid-column: 1; grid-row: auto; }
}
/* A chat wider than a comfortable line reads badly; the pane may be 1900px. */
.split .thread { max-width: 1200px; }
.noderow { display: flex; align-items: center; gap: 10px; margin-top: 10px; flex-wrap: wrap; }
.bl { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--accent-hover); animation: cm-blink 1.4s infinite; margin-right: 2px; vertical-align: middle; }
@keyframes cm-blink { 50% { opacity: 0.25; } }
@@ -2414,25 +2430,11 @@
.kv dt { color: var(--text-muted); font-weight: 700; }
.kv dd { margin: 0; color: var(--text-secondary); overflow-wrap: anywhere; }
.kv dd b { color: var(--text-primary); }
.netcard { display: flex; gap: 12px; align-items: flex-start; margin-top: 12px; }
.nico { width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; flex: none; }
.nico.slsk { background: rgba(255, 255, 255, 0.08); color: #fff; }
.nico.fn { background: var(--accent-soft); color: var(--accent-hover); }
.ntx { min-width: 0; flex: 1; }
.netcard .t { font-size: 13.5px; font-weight: 800; display: flex; align-items: center; gap: 8px; }
.netcard .s { font-size: 11.5px; color: var(--text-muted); margin-top: 3px; line-height: 1.5; }
.netcard .s b { color: var(--text-secondary); font-weight: 700; }
.tog { display: inline-block; width: 36px; height: 20px; border-radius: 10px; background: rgba(255, 255, 255, 0.16); position: relative; flex: none; transition: background 0.15s; }
.tog::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: left 0.15s; }
.tog.on { background: var(--accent); }
.tog.on::after { left: 18px; }
.tog:disabled { opacity: 0.5; cursor: default; }
.tog.big { margin-top: 6px; }
.swrow { display: flex; align-items: center; gap: 10px; margin-top: 12px; font-size: 12px; color: var(--text-secondary); }
.swrow .hint.r { margin-left: auto; }
.disc { margin-top: 10px; }
.disc summary { font-size: 11.5px; font-weight: 800; color: var(--text-secondary); cursor: pointer; }
.disc ul { margin: 8px 0 0; padding-left: 18px; font-size: 11.5px; color: var(--text-muted); line-height: 1.6; }
.danger { display: flex; gap: 8px; align-items: center; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--hair); flex-wrap: wrap; }
/* ── the person drawer (portaled to body — see portal()) ── */
+41 -15
View File
@@ -19,7 +19,7 @@
// is CatalogArtist's class for class with its animation numbers unchanged.
// Styles are copied because Svelte scopes them per component — the same
// trade CatalogDiscography made, with provenance comments at each block.
import { tick } from "svelte";
import { tick, untrack } from "svelte";
import { fade } from "svelte/transition";
import Icon from "../components/Icon.svelte";
import PlaylistMenu from "../components/PlaylistMenu.svelte";
@@ -41,6 +41,7 @@
importResume,
goToView,
} from "../lib/state.svelte.js";
import { openWhoHas, holdersFor, pairKey, holderCount } from "../lib/whoHas.svelte.js";
import {
fileRelease,
CAT_ORDER,
@@ -121,6 +122,17 @@
} catch { return {}; }
})());
let gapRows = $state({}); // foldKey(artist) -> catalog rows not owned
let gapNames = $state({}); // foldKey(artist) -> the artist's display name
// Who in your rooms holds a gap: asked for every open shelf's rows at
// once, filed by pairKey. A marked card opens the ask sheet.
let heldBy = $state({});
$effect(() => {
const items = Object.keys(gapOpen)
.filter((k) => gapOpen[k] && gapRows[k]?.length && gapNames[k])
.flatMap((k) => gapRows[k].map((r) => ({ artist: gapNames[k], album: r.title })));
if (!items.length) return;
untrack(() => void holdersFor(items).then((m) => { for (const [k, v] of m) heldBy[k] = v; }));
});
let gapOpen = $state({}); // foldKey(artist) -> line expanded
let gapLoading = $state({});
@@ -182,6 +194,7 @@
...gapRows,
[key]: released.filter((r) => !ownedTitles.has(normKey(r.title))),
};
gapNames = { ...gapNames, [key]: artistName };
gapTotals = { ...gapTotals, [key]: { total: released.length } };
try { localStorage.setItem(GAPS_KEY, JSON.stringify(gapTotals)); } catch { /* ignore */ }
} catch { /* the line stays collapsed; nothing is invented */ }
@@ -1544,7 +1557,7 @@
</div>
{/snippet}
{#snippet gapCard(r)}
{#snippet gapCard(r, artist)}
<!-- A record you do NOT own, at full size and dimmed. It is filed by the
same classifier as everything else, off the CatalogAlbum payload's own
fields (`record_type`/`track_count`/`release_date`, plus the is_live and
@@ -1558,7 +1571,8 @@
is_live: r.is_live,
is_soundtrack: r.is_soundtrack,
})}
<div class="release gap" role="listitem" title={`${r.title} — not in your library`}>
{@const held = heldBy[pairKey(artist, r.title)]}
<div class="release gap" class:held={!!held?.length} role="listitem" title={`${r.title} — not in your library`}>
<div class="cover-wrap">
{#if r.release_date?.slice(0, 4)}
<span class="ed-badge">{r.release_date.slice(0, 4)}</span>
@@ -1568,6 +1582,9 @@
{:else}
<div class="cover placeholder"><Icon name="disc" size={30} /></div>
{/if}
{#if held?.length}
<button class="have" title="{holderCount(held)} in your rooms {holderCount(held) === 1 ? "has" : "have"} this — who?" onclick={(e) => { e.stopPropagation(); openWhoHas({ artist, album: r.title, cover: r.cover ?? null }, e.currentTarget.closest(".release")); }}>{holderCount(held)} {holderCount(held) === 1 ? "has it" : "have it"}</button>
{/if}
</div>
<div class="title" title={r.title}>{r.title}</div>
<div class="sub">{cat === "other" ? "" : CAT_ONE[cat]}</div>
@@ -1826,7 +1843,7 @@
<div class="lib-gaps" role="list">
<div class="grid">
{#each missing as r (r.id)}
{@render gapCard(r)}
{@render gapCard(r, name)}
{/each}
</div>
</div>
@@ -2942,6 +2959,10 @@
.lib-gaps { margin-top: 12px; }
.lib-gaps .release.gap { opacity: 0.44; transition: opacity 150ms ease; cursor: default; }
.lib-gaps .release.gap:hover { opacity: 1; background: none; }
.lib-gaps .release.gap.held { opacity: 0.72; }
/* who in your rooms holds a release you lack — the count rides the card */
.have { position: absolute; right: 6px; bottom: 6px; z-index: 2; display: inline-flex; align-items: center; gap: 4px; height: 20px; padding: 0 7px; border-radius: 99px; background: rgba(0, 0, 0, 0.62); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); color: #fff; font-size: 10.5px; font-weight: 900; border: 1px solid rgba(255, 255, 255, 0.14); cursor: pointer; white-space: nowrap; }
.have:hover { background: rgba(0, 0, 0, 0.82); border-color: rgba(255, 255, 255, 0.34); }
/* ============================================================ the table
The power-user surface: every column is something the files carry. The
@@ -3562,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;
}
+701 -705
View File
File diff suppressed because it is too large. Load diff
+23
View File
@@ -39,6 +39,7 @@
} from "../lib/state.svelte.js";
import { untrack } from "svelte";
import * as api from "../lib/api.js";
import { community, forgetWait } from "../lib/community.svelte.js";
import Icon from "../components/Icon.svelte";
import PlaylistMenu from "../components/PlaylistMenu.svelte";
@@ -408,6 +409,18 @@
</div>
{#if sub === "downloads"}
{#if community.waits.length}
<div class="waits">
<div class="waits-head">Waiting for <b>{community.waits.length}</b> · grabs that start the moment the person comes online</div>
{#each community.waits as w (w.username + "\u0000" + w.artist + "\u0000" + w.album)}
<div class="wait">
<span class="tx"><span class="t">{w.album}</span><span class="a">{w.artist} · from <b>{w.username}</b>{#if w.via} · via {w.via}{/if}</span></span>
<span class="chip">waiting</span>
<button class="toolbtn" onclick={() => forgetWait(w.username, w.artist, w.album)}>Forget</button>
</div>
{/each}
</div>
{/if}
{#if dlItems.length === 0}
<div class="empty">
<p class="big">No downloads yet</p>
@@ -1622,4 +1635,14 @@
:global(:root.glass) .bub.me {
background: var(--accent-soft);
}
/* holdings: albums waiting on a person's presence */
.waits { margin: 0 0 12px; padding: 8px 10px; border: 1px dashed rgba(255, 255, 255, 0.16); border-radius: 10px; }
.waits-head { font-size: 11.5px; color: var(--text-muted); margin-bottom: 4px; }
.waits-head b { color: var(--text-secondary); }
.wait { display: flex; align-items: center; gap: 10px; padding: 6px 4px; }
.wait .tx { min-width: 0; flex: 1; display: flex; flex-direction: column; }
.wait .t { font-size: 12.5px; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wait .a { font-size: 11px; color: var(--text-muted); }
.wait .a b { color: var(--text-secondary); }
.wait .chip { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 99px; background: var(--bg-highlight); color: var(--text-secondary); }
</style>
+86
View File
@@ -0,0 +1,86 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { rankPeople, reasonText, reasonParts, WEIGHTS, CAPS } from "../../src/lib/discover.js";
const overlapOf = (username, albums, gaps = 0, artists = 0) => ({
username,
albums_in_common_count: albums,
gaps_count: gaps,
artists_in_common_count: artists,
});
test("one list across the shelves, every card naming where it came from", () => {
const ranked = rankPeople({
me: "zoocone",
rooms: {
ambient: { users: [{ username: "aphex" }, { username: "zoocone" }, { username: "boc" }] },
idm: { users: [{ username: "boc" }] },
},
activeRoom: "ambient",
downloads: [{ username: "aphex" }, { username: "aphex" }],
uploads: [{ username: "autechre" }],
similar: [{ username: "boc", rating: 3 }, { username: "aphex", rating: 1 }],
overlap: [overlapOf("autechre", 4, 2), overlapOf("aphex", 0, 0, 0)],
space: { name: "Late Night Ambient", members: [{ username: "boc" }, { username: null }, { username: "zoocone", is_me: true }] },
});
assert.deepEqual(
ranked.map((p) => p.username),
["autechre", "boc", "aphex"],
);
const [autechre, boc, aphex] = ranked;
// autechre: 4 albums in common + 2 gaps, one upload
assert.equal(autechre.score, 4 * WEIGHTS.albumInCommon + 2 * WEIGHTS.gap + WEIGHTS.transfer);
assert.deepEqual(autechre.reasons.map(reasonText), ["shares 4 albums you own · 2 you lack", "you sent them 1"]);
// boc: 3 likes, in the active room and one other, a seat in the Space
assert.equal(boc.score, 3 * WEIGHTS.like + WEIGHTS.activeRoom + WEIGHTS.room + WEIGHTS.spaceMember);
assert.deepEqual(boc.reasons.map(reasonText), [
"shares 3 of your declared likes",
"in #ambient now · also in #idm",
"with you in Late Night Ambient",
]);
// aphex: a browse with nothing in common scores nothing and is not a
// reason; the three that remain tie at two points each and keep the
// signal order (likes, rooms, transfers)
assert.equal(aphex.score, WEIGHTS.activeRoom + 2 * WEIGHTS.transfer + WEIGHTS.like);
assert.deepEqual(aphex.reasons.map(reasonText), ["shares 1 of your declared likes", "in #ambient now", "sent you 2 files"]);
// we never list ourselves, and a member without a Soulseek name has no card
assert.ok(!ranked.some((p) => p.username === "zoocone"));
});
test("contacts and friends are someone else's shelf, and a cap keeps one browse honest", () => {
const ranked = rankPeople({
me: "zoocone",
skip: (u) => u === "buddy",
overlap: [overlapOf("buddy", 40, 40), overlapOf("hoarder", 40, 40, 40), overlapOf("thin", 0, 3, 2)],
similar: [{ username: "buddy", rating: 9 }],
});
assert.deepEqual(ranked.map((p) => p.username), ["hoarder", "thin"]);
assert.equal(ranked[0].score, CAPS.albums * WEIGHTS.albumInCommon + CAPS.gaps * WEIGHTS.gap);
// artists only count when no album is in common
assert.equal(ranked[1].score, 3 * WEIGHTS.gap + 2 * WEIGHTS.artistInCommon);
assert.equal(reasonText(ranked[1].reasons[0]), "2 artists in common · 3 albums you lack");
});
test("ties break toward fewer, stronger reasons, then by name; the limit holds", () => {
const ranked = rankPeople({
me: "",
similar: [{ username: "b", rating: 2 }, { username: "a", rating: 2 }],
downloads: [{ username: "c" }, { username: "c" }, { username: "c" }, { username: "c" }],
limit: 2,
});
// c: 4 transfers = 4 points; a and b: 2 likes = 4 points each, one reason each
assert.deepEqual(ranked.map((p) => p.username), ["a", "b"]);
assert.deepEqual(rankPeople({ me: "", similar: [] }), []);
assert.equal(reasonText({ kind: "rooms", active: null, others: ["x", "y"] }), "in 2 rooms with you");
// the numbers and names are what the card bolds
assert.deepEqual(
reasonParts({ kind: "transfers", from: 2, to: 1 }).filter((s) => s.b).map((s) => s.t),
["2", "1"],
);
assert.deepEqual(reasonParts({ kind: "rooms", active: "idm", others: [] }), [
{ t: "in ", b: false },
{ t: "#idm", b: true },
{ t: " now", b: false },
]);
});
+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 },
});