Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cfff93036 | ||
|
|
f8508da081 | ||
|
|
8093835482 | ||
|
|
fac6fc33dd | ||
|
|
cd2391198e | ||
|
|
07810f7543 | ||
|
|
4ea1582afc | ||
|
|
aaacfc9ff5 | ||
|
|
1c66602b89 | ||
|
|
67b7444f82 | ||
|
|
48ae01c753 | ||
|
|
a0528c6d3f | ||
|
|
07ac3d3631 | ||
|
|
7e291cfccc | ||
|
|
6c44f3e2ab | ||
|
|
f006c1cd5c | ||
|
|
b8e6fb7396 | ||
|
|
a98c750908 | ||
|
|
08ca09586a |
No files matched your search
@@ -0,0 +1,132 @@
|
||||
# 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"
|
||||
|
||||
- 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 }}
|
||||
run: gh release upload "$TAG" "SoulStream-Setup-$VERSION.exe" --repo "$TARGET" --clobber
|
||||
@@ -0,0 +1,115 @@
|
||||
# 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"
|
||||
|
||||
- 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
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+2
-1
@@ -2,6 +2,7 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"src-tauri",
|
||||
"setup/src-tauri",
|
||||
"crates/soulseek-proto",
|
||||
"crates/soulseek-client",
|
||||
"crates/player",
|
||||
@@ -14,7 +15,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.7.0-alpha"
|
||||
version = "1.8.1-alpha"
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
authors = ["Trifecta Solutions"]
|
||||
|
||||
Generated
+1449
File diff suppressed because it is too large.
Load diff
@@ -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]
|
||||
@@ -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(¶meters)?;
|
||||
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(¶meters)?;
|
||||
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(¶meters)?;
|
||||
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(¶meters)?;
|
||||
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() }))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || ¶ms[..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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "soulstream",
|
||||
"version": "1.7.0-alpha",
|
||||
"version": "1.8.1-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "soulstream",
|
||||
"version": "1.7.0-alpha",
|
||||
"version": "1.8.1-alpha",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2"
|
||||
|
||||
+2
-2
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "soulstream",
|
||||
"version": "1.7.0-alpha",
|
||||
"version": "1.8.1-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": {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SoulStream Setup</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
[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"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
serde_json.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
# The alpha key goes where the app reads it: the Credential Manager.
|
||||
keyring = { version = "3", features = ["windows-native"] }
|
||||
dirs = "5"
|
||||
rfd = "0.15"
|
||||
winreg = "0.52"
|
||||
# The release comes down from GitHub with the key, and its signature is
|
||||
# checked against the same public key the app's updater trusts.
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
minisign-verify = "0.2"
|
||||
base64 = "0.21"
|
||||
@@ -0,0 +1,19 @@
|
||||
//! 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() {
|
||||
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());
|
||||
let text = std::fs::read_to_string(&conf).expect("read src-tauri/tauri.conf.json");
|
||||
let json: serde_json::Value = serde_json::from_str(&text).expect("parse tauri.conf.json");
|
||||
let pubkey = json["plugins"]["updater"]["pubkey"]
|
||||
.as_str()
|
||||
.filter(|s| !s.is_empty())
|
||||
.expect("plugins.updater.pubkey in src-tauri/tauri.conf.json");
|
||||
let out = Path::new(&std::env::var("OUT_DIR").unwrap()).join("pubkey.rs");
|
||||
std::fs::write(&out, format!("pub const PUBKEY: &str = {pubkey:?};\n")).expect("write pubkey.rs");
|
||||
tauri_build::build();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "The setup window: its own chrome (drag, minimize, close) and the app's commands.",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-close"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
//! 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";
|
||||
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>,
|
||||
}
|
||||
|
||||
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, String> {
|
||||
let resp = client()?
|
||||
.get(format!("https://api.github.com/repos/{REPO}/releases/latest"))
|
||||
.bearer_auth(token)
|
||||
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Could not reach GitHub — is this computer online? ({e})"))?;
|
||||
match resp.status().as_u16() {
|
||||
200 => {}
|
||||
401 => return Err("That key was refused.".into()),
|
||||
403 => return Err("That key cannot read the SoulStream repository.".into()),
|
||||
404 => return Err("No release found — or that key cannot see the repository.".into()),
|
||||
s => return Err(format!("GitHub answered {s}.")),
|
||||
}
|
||||
let v: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||
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 let Some(entry) = keyring_entry() {
|
||||
let _ = entry.delete_credential();
|
||||
}
|
||||
Err(format!("The key saved on this computer no longer works — {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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({
|
||||
let mut context = tauri::generate_context!();
|
||||
// Driving the window from a probe, debug builds only.
|
||||
#[cfg(debug_assertions)]
|
||||
if let Ok(port) = std::env::var("SOULSTREAM_SETUP_CDP") {
|
||||
for w in &mut context.config_mut().app.windows {
|
||||
w.additional_browser_args = Some(format!("--remote-debugging-port={port}"));
|
||||
}
|
||||
}
|
||||
context
|
||||
})
|
||||
.expect("SoulStream Setup could not start");
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "SoulStream Setup",
|
||||
"version": "1.8.1-alpha",
|
||||
"identifier": "com.soulstream.setup",
|
||||
"build": {
|
||||
"beforeDevCommand": "npx vite --config vite.setup.config.js",
|
||||
"devUrl": "http://localhost:1425",
|
||||
"beforeBuildCommand": "npx vite build --config vite.setup.config.js",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "SoulStream Setup",
|
||||
"width": 800,
|
||||
"height": 580,
|
||||
"resizable": false,
|
||||
"maximizable": false,
|
||||
"decorations": false,
|
||||
"center": true,
|
||||
"backgroundColor": "#121212"
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": false,
|
||||
"icon": [
|
||||
"../../src-tauri/icons/32x32.png",
|
||||
"../../src-tauri/icons/128x128.png",
|
||||
"../../src-tauri/icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
<script>
|
||||
// SoulStream Setup: the first-run tour's design, standing in for the
|
||||
// Windows installer. Welcome → the alpha key (the gate: verified against
|
||||
// GitHub, nothing installs without one) → the music folder → the look →
|
||||
// Install (the latest release downloaded with the key, its signature
|
||||
// checked, the NSIS installer run silently; phases from the backend) →
|
||||
// Done, Open SoulStream. Every choice is written into the app's config
|
||||
// before the install runs, the key into the Credential Manager, so the
|
||||
// app opens straight into itself.
|
||||
import { onMount, tick } from "svelte";
|
||||
import { fly, scale } from "svelte/transition";
|
||||
import { cubicOut, quintOut } from "svelte/easing";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import Icon from "../../src/components/Icon.svelte";
|
||||
|
||||
const STEPS = [
|
||||
{ id: "welcome", label: "Welcome", icon: "sparkle" },
|
||||
{ id: "key", label: "Alpha key", icon: "key" },
|
||||
{ id: "library", label: "Your library", icon: "library" },
|
||||
{ id: "look", label: "Look & sound", icon: "eye" },
|
||||
{ id: "install", label: "Install", icon: "download" },
|
||||
{ id: "done", label: "Ready", icon: "check" },
|
||||
];
|
||||
const PRESETS = [
|
||||
["{artist}\\{album}", "Artist › Album"],
|
||||
["{artist}\\{year} - {album}", "Artist › Year - Album"],
|
||||
["{artist} - {album}", "Artist - Album"],
|
||||
];
|
||||
|
||||
let step = $state(0);
|
||||
let dir = $state(1);
|
||||
let d = $state(null);
|
||||
let alphaKey = $state("");
|
||||
let hasToken = $state(false);
|
||||
// What the key can see: set only by a successful check, cleared by a failed one.
|
||||
let keyOk = $state(null);
|
||||
let verifying = $state(false);
|
||||
let libraryDir = $state("");
|
||||
let layout = $state("");
|
||||
let glass = $state(false);
|
||||
let loudness = $state(true);
|
||||
let busy = $state(false);
|
||||
let error = $state("");
|
||||
let phase = $state({ phase: "", detail: "", received: 0, total: 0 });
|
||||
const pct = $derived(phase.total > 0 ? Math.min(100, Math.round((phase.received / phase.total) * 100)) : null);
|
||||
let installedAt = $state("");
|
||||
let panelEl = $state(null);
|
||||
|
||||
const cur = $derived(STEPS[step]);
|
||||
const installing = $derived(cur.id === "install" && phase.phase !== "done" && phase.phase !== "failed");
|
||||
const reduced = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const dur = (ms) => (reduced() ? 0 : ms);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
d = await invoke("defaults");
|
||||
libraryDir = d.library_dir;
|
||||
layout = d.library_layout;
|
||||
glass = !!d.glass_mode;
|
||||
loudness = d.loudness !== false;
|
||||
hasToken = !!d.has_token;
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
}
|
||||
if (hasToken) {
|
||||
verifying = true;
|
||||
try {
|
||||
keyOk = await invoke("check_saved_key");
|
||||
} catch (e) {
|
||||
hasToken = false;
|
||||
keyOk = null;
|
||||
error = String(e);
|
||||
} finally {
|
||||
verifying = false;
|
||||
}
|
||||
}
|
||||
const un = await listen("setup:phase", (e) => {
|
||||
phase = e.payload ?? { phase: "", detail: "", received: 0, total: 0 };
|
||||
if (phase.phase === "done") {
|
||||
installedAt = phase.detail;
|
||||
go(1);
|
||||
} else if (phase.phase === "failed") {
|
||||
error = phase.detail;
|
||||
}
|
||||
});
|
||||
await tick();
|
||||
panelEl?.querySelector(".primary")?.focus();
|
||||
return () => un();
|
||||
});
|
||||
|
||||
async function go(delta) {
|
||||
dir = delta;
|
||||
step = Math.max(0, Math.min(STEPS.length - 1, step + delta));
|
||||
await tick();
|
||||
panelEl?.querySelector(".primary")?.focus();
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
const k = alphaKey.trim();
|
||||
if (!k || verifying) return;
|
||||
verifying = true;
|
||||
error = "";
|
||||
try {
|
||||
keyOk = await invoke("verify_key", { token: k });
|
||||
hasToken = true;
|
||||
alphaKey = "";
|
||||
} catch (e) {
|
||||
keyOk = null;
|
||||
error = String(e);
|
||||
} finally {
|
||||
verifying = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pickDir() {
|
||||
const p = await invoke("pick_folder").catch(() => null);
|
||||
if (p) libraryDir = p;
|
||||
}
|
||||
|
||||
async function next() {
|
||||
if (busy || installing) return;
|
||||
error = "";
|
||||
if (cur.id === "key") {
|
||||
if (alphaKey.trim() && !keyOk) {
|
||||
await verify();
|
||||
if (!keyOk) return;
|
||||
}
|
||||
if (!keyOk) {
|
||||
error = "An accepted alpha key is needed to install.";
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (cur.id === "look") {
|
||||
// The choices land first, then the installer runs.
|
||||
busy = true;
|
||||
try {
|
||||
await invoke("write_config", { libraryDir, libraryLayout: layout, glassMode: glass, loudness });
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
if (error) return;
|
||||
await go(1);
|
||||
phase = { phase: "resolving", detail: "", received: 0, total: 0 };
|
||||
void invoke("install").catch((e) => {
|
||||
error = String(e);
|
||||
phase = { phase: "failed", detail: String(e), received: 0, total: 0 };
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (cur.id === "done") return launch();
|
||||
await go(1);
|
||||
}
|
||||
|
||||
function back() {
|
||||
if (step === 0 || busy || installing || cur.id === "done") return;
|
||||
go(-1);
|
||||
}
|
||||
|
||||
async function launch() {
|
||||
try {
|
||||
await invoke("launch");
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
function quit() {
|
||||
void invoke("quit");
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key !== "Enter" || e.shiftKey) return;
|
||||
const t = e.target;
|
||||
if (t instanceof HTMLButtonElement) return;
|
||||
e.preventDefault();
|
||||
next();
|
||||
}
|
||||
|
||||
const win = () => getCurrentWindow();
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<div class="panel" bind:this={panelEl} role="dialog" aria-modal="true" aria-labelledby="fr-title">
|
||||
<div class="chrome" data-tauri-drag-region>
|
||||
<span class="wtitle" data-tauri-drag-region>SoulStream Setup</span>
|
||||
<button class="wbtn" onclick={() => win().minimize()} title="Minimize" aria-label="Minimize"><Icon name="minus" size={14} /></button>
|
||||
<button class="wbtn x" onclick={() => (installing ? null : win().close())} title="Close" aria-label="Close" disabled={installing}><Icon name="x" size={14} /></button>
|
||||
</div>
|
||||
|
||||
<aside class="rail" data-tauri-drag-region>
|
||||
<div class="brand" data-tauri-drag-region>
|
||||
<img src="/logo.png" alt="" draggable="false" />
|
||||
<span>SoulStream</span>
|
||||
</div>
|
||||
<ol>
|
||||
{#each STEPS as s, i (s.id)}
|
||||
<li class:on={i === step} class:done={i < step} aria-current={i === step ? "step" : undefined}>
|
||||
<span class="dot">
|
||||
{#if i < step}<Icon name="check" size={11} />{:else}<Icon name={s.icon} size={12} />{/if}
|
||||
</span>
|
||||
<span class="lab">{s.label}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
<div class="railfoot">{d ? `SoulStream ${d.version}` : ""}<br />Everything here lives in Settings too.</div>
|
||||
</aside>
|
||||
|
||||
<div class="stage">
|
||||
<div class="steps">
|
||||
{#key step}
|
||||
<section
|
||||
class="step"
|
||||
in:fly={{ x: 36 * dir, duration: dur(380), easing: quintOut, delay: dur(60) }}
|
||||
out:fly={{ x: -36 * dir, duration: dur(200), easing: cubicOut }}
|
||||
>
|
||||
{#if cur.id === "welcome"}
|
||||
<div class="welcome">
|
||||
<div class="halo" in:scale={{ start: 0.6, duration: dur(760), easing: quintOut }}>
|
||||
<img class="mark" src="/logo.png" alt="" draggable="false" />
|
||||
</div>
|
||||
<h1 id="fr-title" in:fly={{ y: 14, duration: dur(520), delay: dur(220), easing: quintOut }}>
|
||||
{d?.installed_version ? "Set SoulStream up again" : "Welcome to SoulStream"}
|
||||
</h1>
|
||||
<p class="lede" in:fly={{ y: 14, duration: dur(520), delay: dur(340), easing: quintOut }}>
|
||||
{#if d?.installed_version}
|
||||
SoulStream {d.installed_version} is already on this computer. This installs
|
||||
the latest release in its place and keeps your music and settings. About a minute,
|
||||
no admin prompt.
|
||||
{:else}
|
||||
Your library on your own disk, music from people on Soulseek, and a community
|
||||
that runs on a peer you own. Your alpha key, two short questions, then about a
|
||||
minute to download and install — no admin prompt, and your music never lives
|
||||
inside the program.
|
||||
{/if}
|
||||
</p>
|
||||
<ul class="pills" in:fly={{ y: 10, duration: dur(520), delay: dur(460), easing: quintOut }}>
|
||||
{#each STEPS.slice(1, 4) as s (s.id)}
|
||||
<li><Icon name={s.icon} size={13} /> {s.label}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{:else if cur.id === "key"}
|
||||
<h1 id="fr-title">Your alpha key</h1>
|
||||
<p class="lede">
|
||||
SoulStream is in a closed alpha. This key — one per tester, able only to read the
|
||||
private repository — is how Setup fetches the app and how the app finds updates
|
||||
later. It goes to the Windows Credential Manager, not a plain file. Nothing installs
|
||||
without it.
|
||||
</p>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Alpha key {#if keyOk}<span class="tag ok">accepted</span>{:else if verifying}<span class="tag">checking…</span>{/if}</div>
|
||||
<div class="hint">
|
||||
{#if keyOk}
|
||||
SoulStream <b>{keyOk.version}</b> is what it will install{#if keyOk.installer_size} — {(keyOk.installer_size / 1e6).toFixed(1)} MB{/if}. Paste another key to replace it.
|
||||
{:else}
|
||||
Paste the key you were given (it starts with github_pat_) and press Verify. Setup asks GitHub whether the key can read the release.
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<input type="password" class="mono" bind:value={alphaKey} placeholder={keyOk ? "••••••••••••" : "github_pat_…"} spellcheck="false" autocomplete="off" aria-label="Alpha key" onkeydown={(e) => { if (e.key === "Enter") { e.stopPropagation(); verify(); } }} />
|
||||
<button class="ax-white" onclick={verify} disabled={verifying || !alphaKey.trim()}>{verifying ? "Checking…" : "Verify"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if cur.id === "library"}
|
||||
<h1 id="fr-title">Where does your music live?</h1>
|
||||
<p class="lede">
|
||||
Downloads land in this folder, it is always shared on Soulseek, and albums are filed
|
||||
into it by the format below. Files already in it are never moved.
|
||||
</p>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Library folder</div>
|
||||
<div class="path">{libraryDir || "Not chosen yet"}</div>
|
||||
</div>
|
||||
<div class="ctl"><button class="ax-white" onclick={pickDir} disabled={busy}>Choose folder…</button></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Album folder format</div>
|
||||
<div class="hint">Use {"{artist}"}, {"{album}"}, {"{date}"} or {"{year}"}; "\" starts a subfolder.</div>
|
||||
<div class="presets">
|
||||
{#each PRESETS as [v, l] (v)}
|
||||
<button class="preset" class:on={layout.trim() === v} onclick={() => (layout = v)}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ctl"><input class="mono" bind:value={layout} spellcheck="false" autocomplete="off" aria-label="Album folder format" /></div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if cur.id === "look"}
|
||||
<h1 id="fr-title">How the window is drawn</h1>
|
||||
<p class="lede">Both can be changed any time in Settings.</p>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Dark glass</div>
|
||||
<div class="hint">Whatever is behind the window glows through the app's dark surfaces with a live blur (Windows acrylic). Menus and dialogs stay solid.</div>
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<span class="tval">{glass ? "dark glass" : "solid"}</span>
|
||||
<button class="toggle" class:on={glass} role="switch" aria-checked={glass} aria-label="Dark glass" onclick={() => (glass = !glass)}><i></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Loudness normalization</div>
|
||||
<div class="hint">Every track plays at roughly the same level (−14 LUFS). Off plays each file as recorded.</div>
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<span class="tval">{loudness ? "on" : "off"}</span>
|
||||
<button class="toggle" class:on={loudness} role="switch" aria-checked={loudness} aria-label="Loudness normalization" onclick={() => (loudness = !loudness)}><i></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if cur.id === "install"}
|
||||
<h1 id="fr-title">{phase.phase === "failed" ? "That did not go through" : "Installing SoulStream"}</h1>
|
||||
<p class="lede">
|
||||
{#if phase.phase === "failed"}
|
||||
{phase.detail}
|
||||
{:else if phase.phase === "downloading"}
|
||||
Downloading {phase.detail}{pct != null ? ` · ${pct}%` : "…"}
|
||||
{:else if phase.phase === "verifying"}
|
||||
{phase.detail}
|
||||
{:else if phase.phase === "installing"}
|
||||
{phase.detail} Nothing to click; this takes a moment.
|
||||
{:else}
|
||||
{phase.detail || "Starting…"}
|
||||
{/if}
|
||||
</p>
|
||||
<div class="progress" class:failed={phase.phase === "failed"} class:busy={pct == null && phase.phase !== "failed"} aria-hidden="true"><i style={pct != null ? `width: ${pct}%` : ""}></i></div>
|
||||
<ul class="summary">
|
||||
<li><span class="ic"><Icon name="key" size={14} /></span><span class="what">Alpha key</span><b>{keyOk ? `accepted · SoulStream ${keyOk.version}` : "not set"}</b></li>
|
||||
<li><span class="ic"><Icon name="folder-open" size={14} /></span><span class="what">Library</span><b class="mono">{libraryDir}</b></li>
|
||||
<li><span class="ic"><Icon name="eye" size={14} /></span><span class="what">Window</span><b>{glass ? "dark glass" : "solid"}</b></li>
|
||||
</ul>
|
||||
{:else}
|
||||
<h1 id="fr-title">SoulStream is ready</h1>
|
||||
<p class="lede">Installed to <span class="mono">{installedAt}</span>. It lives in your Start menu now; your music stays where you chose.</p>
|
||||
<ul class="summary">
|
||||
{#each [
|
||||
["key", "Alpha key", keyOk ? "accepted" : "set"],
|
||||
["folder-open", "Library", libraryDir],
|
||||
["eye", "Window", glass ? "dark glass" : "solid"],
|
||||
["volume", "Loudness", loudness ? "normalized" : "as recorded"],
|
||||
] as [icon, what, value], i (what)}
|
||||
<li in:fly={{ y: 10, duration: dur(360), delay: dur(120 + i * 70), easing: quintOut }}>
|
||||
<span class="ic"><Icon name={icon} size={14} /></span>
|
||||
<span class="what">{what}</span>
|
||||
<b class:mono={what === "Library"}>{value}</b>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<footer class="foot">
|
||||
{#if step > 0 && cur.id !== "done" && !installing}
|
||||
<button class="ax-ghost" onclick={back} disabled={busy}>Back</button>
|
||||
{/if}
|
||||
<span class="spacer"></span>
|
||||
{#if error && cur.id !== "install"}<span class="err">{error}</span>{/if}
|
||||
{#if cur.id === "welcome"}
|
||||
<button class="ax-fill primary" onclick={next} disabled={!d}>Let's set it up <Icon name="chevron-right" size={14} /></button>
|
||||
{:else if cur.id === "key"}
|
||||
<button class="ax-fill primary" onclick={next} disabled={busy || verifying || !keyOk}>Next <Icon name="chevron-right" size={14} /></button>
|
||||
{:else if cur.id === "look"}
|
||||
<button class="ax-fill primary big" onclick={next} disabled={busy}><Icon name="download" size={13} /> {busy ? "Saving…" : "Install SoulStream"}</button>
|
||||
{:else if cur.id === "install"}
|
||||
{#if phase.phase === "failed"}
|
||||
<button class="ax-ghost" onclick={quit}>Close</button>
|
||||
<button class="ax-fill primary" onclick={() => { error = ""; phase = { phase: "resolving", detail: "", received: 0, total: 0 }; void invoke("install").catch((e) => { error = String(e); phase = { phase: "failed", detail: String(e), received: 0, total: 0 }; }); }}>Try again</button>
|
||||
{/if}
|
||||
{:else if cur.id === "done"}
|
||||
<button class="lnk" onclick={quit}>Close</button>
|
||||
<button class="ax-fill primary big" onclick={launch}>Open SoulStream</button>
|
||||
{:else}
|
||||
<button class="ax-fill primary" onclick={next} disabled={busy}>Next <Icon name="chevron-right" size={14} /></button>
|
||||
{/if}
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(html, body) { height: 100%; margin: 0; background: #121212; overflow: hidden; }
|
||||
:global(#app) { height: 100%; }
|
||||
.panel {
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 212px 1fr;
|
||||
grid-template-rows: 30px 1fr;
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
--surf: var(--bg-elevated);
|
||||
--surf-hi: var(--bg-highlight);
|
||||
--hair: rgba(255, 255, 255, 0.07);
|
||||
--ok: #3ddc84;
|
||||
user-select: none;
|
||||
}
|
||||
/* ── our own title strip ──────────────────────────────────────── */
|
||||
.chrome {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 0 4px 0 14px;
|
||||
background: var(--bg-base);
|
||||
border-bottom: 1px solid var(--hair);
|
||||
}
|
||||
.wtitle { flex: 1; font-size: 11.5px; font-weight: 800; color: var(--text-muted); letter-spacing: 0.02em; }
|
||||
.wbtn { width: 34px; height: 24px; border-radius: 6px; display: grid; place-items: center; color: var(--text-secondary); }
|
||||
.wbtn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.1); color: #fff; }
|
||||
.wbtn.x:hover:not(:disabled) { background: #c42b1c; color: #fff; }
|
||||
.wbtn:disabled { opacity: 0.35; }
|
||||
|
||||
/* ── the rail ─────────────────────────────────────────────────── */
|
||||
.rail { display: flex; flex-direction: column; background: var(--bg-base); border-right: 1px solid var(--hair); padding: 22px 16px 18px; min-width: 0; }
|
||||
.brand { display: flex; align-items: center; gap: 10px; padding: 0 6px 22px; font-size: 15px; font-weight: 900; letter-spacing: 0.01em; }
|
||||
.brand img { width: 26px; height: 26px; border-radius: 7px; }
|
||||
.rail ol { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.rail li { display: flex; align-items: center; gap: 10px; padding: 8px 8px; border-radius: 10px; color: var(--text-muted); font-size: 12.5px; font-weight: 700; transition: color 0.2s, background 0.2s; }
|
||||
.rail li.on { color: var(--text-primary); background: rgba(255, 255, 255, 0.06); }
|
||||
.rail li.done { color: var(--text-secondary); }
|
||||
.dot { width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center; background: var(--surf-hi); color: var(--text-secondary); flex: none; transition: background 0.2s, color 0.2s; }
|
||||
.rail li.on .dot { background: var(--accent); color: #000; }
|
||||
.rail li.done .dot { background: color-mix(in srgb, var(--ok) 18%, transparent); color: var(--ok); }
|
||||
.railfoot { margin-top: auto; font-size: 11px; color: var(--text-muted); line-height: 1.5; padding: 0 6px; }
|
||||
|
||||
/* ── the stage ────────────────────────────────────────────────── */
|
||||
.stage { display: grid; grid-template-rows: 1fr auto; min-width: 0; }
|
||||
.steps { position: relative; display: grid; overflow: hidden; }
|
||||
.steps > .step { grid-area: 1 / 1; padding: 30px 32px 8px; overflow: auto; min-height: 0; }
|
||||
h1 { font-size: 22px; font-weight: 900; letter-spacing: -0.01em; margin: 0 0 8px; line-height: 1.15; }
|
||||
.lede { font-size: 13px; color: var(--text-secondary); line-height: 1.55; margin: 0 0 18px; max-width: 58ch; }
|
||||
.mono { font-family: "Cascadia Code", Consolas, monospace; font-size: 12px; }
|
||||
|
||||
.welcome { display: flex; flex-direction: column; align-items: center; text-align: center; padding-top: 14px; }
|
||||
.welcome h1 { font-size: 26px; margin-top: 18px; }
|
||||
.welcome .lede { max-width: 48ch; }
|
||||
.halo { position: relative; width: 108px; height: 108px; display: grid; place-items: center; }
|
||||
.halo::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -18px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, color-mix(in srgb, var(--accent) 55%, transparent) 0, color-mix(in srgb, var(--accent) 18%, transparent) 38%, transparent 70%);
|
||||
animation: breathe 3.8s ease-in-out infinite;
|
||||
}
|
||||
.mark { position: relative; width: 84px; height: 84px; border-radius: 22px; box-shadow: 0 18px 40px rgba(0, 0, 0, 0.5); }
|
||||
@keyframes breathe {
|
||||
0%, 100% { transform: scale(1); opacity: 0.85; }
|
||||
50% { transform: scale(1.1); opacity: 1; }
|
||||
}
|
||||
.pills { list-style: none; margin: 4px 0 0; padding: 0; display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
|
||||
.pills li { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 99px; background: var(--surf-hi); font-size: 12px; font-weight: 800; color: var(--text-secondary); }
|
||||
|
||||
.card { background: var(--surf); border: 1px solid var(--hair); border-radius: 12px; overflow: hidden; }
|
||||
.row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 16px; border-top: 1px solid var(--hair); }
|
||||
.card > .row:first-child { border-top: none; }
|
||||
.label { min-width: 0; flex: 1; }
|
||||
.name { font-size: 13.5px; font-weight: 700; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.hint { font-size: 12px; color: var(--text-secondary); margin-top: 3px; line-height: 1.45; max-width: 60ch; }
|
||||
.path { font-family: "Cascadia Code", Consolas, monospace; font-size: 11.5px; color: var(--text-secondary); word-break: break-all; margin-top: 3px; }
|
||||
.ctl { flex: none; display: flex; align-items: center; gap: 8px; }
|
||||
.tag { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 7px; border-radius: 99px; background: var(--accent-soft); color: var(--accent-hover); display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||||
.tag.ok { background: color-mix(in srgb, var(--ok) 14%, transparent); color: #5ee89a; }
|
||||
.tval { font-size: 12px; font-weight: 800; color: var(--text-secondary); min-width: 62px; text-align: right; }
|
||||
.toggle { width: 38px; height: 21px; border-radius: 99px; background: var(--surf-hi); position: relative; flex: none; transition: 0.18s; }
|
||||
.toggle i { position: absolute; top: 3px; left: 3px; width: 15px; height: 15px; border-radius: 50%; background: var(--text-muted); transition: 0.18s; }
|
||||
.toggle.on { background: var(--accent); }
|
||||
.toggle.on i { left: 20px; background: #000; }
|
||||
.presets { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
|
||||
.preset { padding: 4px 10px; border-radius: 99px; font-size: 11.5px; font-weight: 800; background: var(--surf-hi); color: var(--text-secondary); }
|
||||
.preset:hover { color: #fff; }
|
||||
.preset.on { background: var(--accent); color: #000; }
|
||||
input { height: 32px; padding: 0 10px; border-radius: 8px; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); color: var(--text-primary); font: inherit; font-size: 13px; min-width: 0; user-select: text; }
|
||||
input:focus { outline: none; border-color: var(--accent); }
|
||||
input.mono { font-family: "Cascadia Code", Consolas, monospace; font-size: 12px; width: 220px; }
|
||||
|
||||
.progress { height: 6px; border-radius: 99px; background: var(--surf-hi); overflow: hidden; margin: 6px 0 18px; max-width: 420px; }
|
||||
.progress i { display: block; height: 100%; width: 0; border-radius: 99px; background: var(--accent); transition: width 0.2s; }
|
||||
.progress.busy i { width: 38%; animation: slide 1.4s ease-in-out infinite; }
|
||||
.progress.failed i { animation: none; width: 100%; background: #ff8d98; }
|
||||
@keyframes slide {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(280%); }
|
||||
}
|
||||
.summary { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.summary li { display: grid; grid-template-columns: 28px 130px 1fr; align-items: center; gap: 8px; padding: 10px 12px; border-radius: 10px; background: var(--surf); border: 1px solid var(--hair); font-size: 13px; }
|
||||
.summary .ic { display: grid; place-items: center; color: var(--accent-hover); }
|
||||
.summary .what { color: var(--text-secondary); font-weight: 700; }
|
||||
.summary b { font-weight: 800; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.summary b.mono { font-weight: 600; }
|
||||
|
||||
.foot { display: flex; align-items: center; gap: 12px; padding: 14px 24px 18px 32px; border-top: 1px solid var(--hair); }
|
||||
.spacer { flex: 1; }
|
||||
.err { font-size: 12px; color: #ff8d98; max-width: 40ch; }
|
||||
.lnk { color: var(--text-muted); font-weight: 700; font-size: 12px; text-decoration: underline; text-underline-offset: 2px; padding: 0 6px; }
|
||||
.lnk:hover:not(:disabled) { color: var(--text-primary); }
|
||||
.ax-ghost { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 14px; border-radius: 99px; font-size: 12px; font-weight: 800; border: 1px solid rgba(255, 255, 255, 0.28); transition: 0.18s; white-space: nowrap; }
|
||||
.ax-ghost:hover:not(:disabled) { border-color: #fff; background: rgba(255, 255, 255, 0.08); }
|
||||
.ax-ghost:disabled { opacity: 0.45; cursor: default; }
|
||||
.ax-fill { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 18px; border-radius: 99px; font-size: 12.5px; font-weight: 800; background: var(--accent); color: #000; white-space: nowrap; transition: background 0.18s; }
|
||||
.ax-fill:hover:not(:disabled) { background: var(--accent-hover); }
|
||||
.ax-fill:disabled { opacity: 0.55; cursor: default; }
|
||||
.ax-fill.big { height: 38px; padding: 0 22px; font-size: 13.5px; }
|
||||
.ax-white { display: inline-flex; align-items: center; height: 32px; padding: 0 16px; border-radius: 99px; font-size: 12.5px; font-weight: 800; background: #fff; color: #000; white-space: nowrap; transition: transform 0.15s; }
|
||||
.ax-white:hover:not(:disabled) { transform: scale(1.03); }
|
||||
.ax-white:disabled { opacity: 0.55; cursor: default; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.halo::before, .progress i { animation: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
// SoulStream Setup's page: the app's own stylesheet (tokens, fonts, buttons)
|
||||
// and one component, the tour as an installer.
|
||||
import "../../src/styles/global.css";
|
||||
import { mount } from "svelte";
|
||||
import Setup from "./Setup.svelte";
|
||||
|
||||
mount(Setup, { target: document.getElementById("app") });
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(), ¶ms, bytes, false).await.map(|_| ())
|
||||
} else {
|
||||
a.get_then_update(HOLDINGS_WASM.to_vec(), ¶ms, 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));
|
||||
}
|
||||
}
|
||||
+28
-3
@@ -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 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());
|
||||
let args = format!("{base} --remote-debugging-port={cdp}");
|
||||
format!("{base} --remote-debugging-port={cdp}")
|
||||
};
|
||||
#[cfg(not(debug_assertions))]
|
||||
let args = base.to_string();
|
||||
for window in &mut context.config_mut().app.windows {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
//! 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/latest` (or `/tags/{tag}`) finds the
|
||||
//! `latest.json` asset, 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";
|
||||
|
||||
/// 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));
|
||||
}
|
||||
|
||||
/// 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 api = format!(
|
||||
"https://api.github.com/repos/{}/{}/releases/{}",
|
||||
src.owner, src.repo, src.release
|
||||
);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let resp = client
|
||||
.get(&api)
|
||||
.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}")),
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||
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(¤t);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "SoulStream",
|
||||
"version": "1.7.0-alpha",
|
||||
"version": "1.8.1-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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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.
@@ -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");
|
||||
|
||||
@@ -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 };
|
||||
@@ -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("");
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
+588
-592
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
// The setup app's frontend: the same toolchain as the app, its own root and
|
||||
// port, and the app's public folder (logo, fonts) served as if its own.
|
||||
import { defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default defineConfig({
|
||||
root: "setup",
|
||||
publicDir: "../public",
|
||||
plugins: [svelte()],
|
||||
clearScreen: false,
|
||||
server: { port: 1425, strictPort: true },
|
||||
build: { outDir: "dist", emptyOutDir: true },
|
||||
});
|
||||
Reference in new issue
Block a user