first run, updates, installer: a setup tour stands in front of a fresh install, releases arrive through an in-app updater with stable and nightly channels behind a read-only token, and the branded installer builds again
This commit is contained in:
1 parent
07810f7543
commit
cd2391198e
19 files changed
+2009
-14
No files matched your search
@@ -0,0 +1,110 @@
|
||||
# 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
|
||||
@@ -0,0 +1,92 @@
|
||||
# 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
|
||||
Generated
+236
-6
@@ -180,6 +180,7 @@ dependencies = [
|
||||
"rand 0.8.8",
|
||||
"realfft",
|
||||
"reqwest 0.12.28",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"soulseek-client",
|
||||
@@ -190,11 +191,21 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
@@ -686,7 +697,7 @@ dependencies = [
|
||||
"alsa",
|
||||
"coreaudio-rs",
|
||||
"dasp_sample",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"mach2",
|
||||
@@ -965,6 +976,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
@@ -1318,6 +1340,16 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.11"
|
||||
@@ -2394,6 +2426,36 @@ dependencies = [
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-macros",
|
||||
"jni-sys 0.4.1",
|
||||
"log",
|
||||
"simd_cesu8",
|
||||
"thiserror 2.0.20",
|
||||
"walkdir",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-macros"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"simd_cesu8",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.1"
|
||||
@@ -2692,6 +2754,12 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -3097,6 +3165,18 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-osa-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
@@ -3230,6 +3310,20 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-osa-kit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -3824,15 +3918,20 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -3957,12 +4056,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
@@ -3972,6 +4084,33 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
@@ -4356,6 +4495,22 @@ version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||
dependencies = [
|
||||
"rustc_version",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
@@ -4848,7 +5003,7 @@ dependencies = [
|
||||
"gdkwayland-sys",
|
||||
"gdkx11-sys",
|
||||
"gtk",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
@@ -4881,6 +5036,17 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
@@ -4904,7 +5070,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http 1.5.0",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"mime",
|
||||
@@ -5079,6 +5245,39 @@ dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b28d8cabdeb0564f03ae261963de4bc3d98321cd3d213e76a81b7d344e5df606"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"http 1.5.0",
|
||||
"infer",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest 0.13.4",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tempfile",
|
||||
"thiserror 2.0.20",
|
||||
"time",
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.3"
|
||||
@@ -5089,7 +5288,7 @@ dependencies = [
|
||||
"dpi",
|
||||
"gtk",
|
||||
"http 1.5.0",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"objc2",
|
||||
"objc2-ui-kit",
|
||||
"objc2-web-kit",
|
||||
@@ -5112,7 +5311,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"http 1.5.0",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
@@ -6011,6 +6210,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@@ -6629,7 +6837,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"http 1.5.0",
|
||||
"javascriptcore-rs",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"ndk",
|
||||
"objc2",
|
||||
@@ -6688,6 +6896,16 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xml-rs"
|
||||
version = "0.8.29"
|
||||
@@ -6820,6 +7038,18 @@ dependencies = [
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"indexmap 2.14.1",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.7"
|
||||
|
||||
@@ -56,6 +56,8 @@ tagger = { path = "../crates/tagger" }
|
||||
metadata = { path = "../crates/metadata" }
|
||||
library = { path = "../crates/library" }
|
||||
audio-formats = { path = "../crates/audio-formats" }
|
||||
tauri-plugin-updater = "2"
|
||||
semver = "1"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.61.3", features = [
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 151 KiB |
@@ -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) {
|
||||
|
||||
+19
-3
@@ -34,6 +34,7 @@ mod overlap;
|
||||
mod holdings;
|
||||
mod state;
|
||||
mod thumbbar;
|
||||
mod updater;
|
||||
mod vocal_sep;
|
||||
mod youtube_playlist;
|
||||
mod youtube_setlist;
|
||||
@@ -43,6 +44,7 @@ mod ytdlp;
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.manage(state::AppState::default())
|
||||
.manage(updater::UpdaterState::default())
|
||||
.setup(|app| {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
@@ -54,6 +56,7 @@ pub fn run() {
|
||||
.build(),
|
||||
)?;
|
||||
app.handle().plugin(tauri_plugin_dialog::init())?;
|
||||
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
|
||||
// Must run before anything touches the app-data dir.
|
||||
config::migrate_app_data(app.handle());
|
||||
// Prepare the small local vision model while the user is still on
|
||||
@@ -75,6 +78,9 @@ pub fn run() {
|
||||
// by default) and forwards adapter status to the webview. Fully
|
||||
// isolated — its absence or failure touches nothing else.
|
||||
freenet::init(app.handle());
|
||||
// Release checks: one shortly after launch, then every six hours,
|
||||
// never installing on their own (updater.rs).
|
||||
updater::init(app.handle());
|
||||
if let Err(e) = pipeline::init(app.handle()) {
|
||||
log::error!("library init failed: {e}");
|
||||
} else {
|
||||
@@ -90,6 +96,10 @@ pub fn run() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::app_status,
|
||||
updater::updater_status,
|
||||
updater::updater_check,
|
||||
updater::updater_set,
|
||||
updater::updater_install,
|
||||
commands::excluded_search_phrases,
|
||||
commands::get_credentials,
|
||||
commands::connect_and_login,
|
||||
@@ -370,11 +380,17 @@ pub fn run() {
|
||||
--autoplay-policy=no-user-gesture-required \
|
||||
--enable-smooth-scrolling \
|
||||
--force-color-profile=srgb";
|
||||
// One cfg over BOTH statements: the attribute on the `let cdp`
|
||||
// alone left the `format!` below reading a name that release
|
||||
// builds never define — every `tauri build` since 0.1.0-alpha.3
|
||||
// failed on it (found 2026-09-09 making the first installer).
|
||||
#[cfg(debug_assertions)]
|
||||
// A second instance needs its own CDP port, or it silently fails
|
||||
// to bind and cannot be driven at all.
|
||||
let 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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,9 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["nsis"],
|
||||
"targets": [
|
||||
"nsis"
|
||||
],
|
||||
"resources": [
|
||||
"resources/FidelityFX_CLI.exe",
|
||||
"resources/FidelityFX-LICENSE.txt",
|
||||
@@ -67,7 +69,27 @@
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installMode": "currentUser",
|
||||
"startMenuFolder": "SoulStream"
|
||||
"startMenuFolder": "SoulStream",
|
||||
"installerIcon": "icons/icon.ico",
|
||||
"uninstallerIcon": "icons/icon.ico",
|
||||
"headerImage": "installer/header.bmp",
|
||||
"sidebarImage": "installer/sidebar.bmp",
|
||||
"languages": [
|
||||
"English"
|
||||
],
|
||||
"displayLanguageSelector": false
|
||||
}
|
||||
},
|
||||
"createUpdaterArtifacts": true
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQxOTQyMEVCN0QwMUJCNjgKUldSb3V3Rjk2eUNVUWJaZUpZcUt1VWlZbmo1Sm9tS01zMTAzSjJpdksrazQ2dVNGU0RxKzAreTIK",
|
||||
"endpoints": [
|
||||
"https://github.com/KeelrLeek/SoulStream/releases/latest/download/latest.json"
|
||||
],
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-3
@@ -7,6 +7,10 @@
|
||||
import LyricsSyncEditor from "./components/LyricsSyncEditor.svelte";
|
||||
import ImportWindow from "./components/ImportWindow.svelte";
|
||||
import LoginModal from "./components/LoginModal.svelte";
|
||||
import FirstRun from "./components/FirstRun.svelte";
|
||||
import { firstRun } from "./lib/firstRun.svelte.js";
|
||||
import UpdateToast from "./components/UpdateToast.svelte";
|
||||
import { startUpdaterWatch } from "./lib/updater.svelte.js";
|
||||
import PreviewOverlay from "./components/PreviewOverlay.svelte";
|
||||
import DebugOverlay from "./components/DebugOverlay.svelte";
|
||||
import GlassBar from "./components/GlassBar.svelte";
|
||||
@@ -128,7 +132,13 @@
|
||||
// Dark-glass mode is persisted in config; the window boots solid and
|
||||
// glasses up as soon as the setting arrives (no-op when off).
|
||||
void getSettings()
|
||||
.then((s) => s?.glass_mode && applyGlass(true))
|
||||
.then((s) => {
|
||||
if (s?.glass_mode) applyGlass(true);
|
||||
// A fresh install (no setup_done in its config) meets the
|
||||
// first-run tour before anything else; FirstRun.svelte stamps
|
||||
// the flag when it is finished or skipped.
|
||||
if (s && !s.setup_done) firstRun.open = true;
|
||||
})
|
||||
.catch(() => {});
|
||||
// Windows drops the acrylic blur for the length of a live resize and the
|
||||
// window stays transparent through it, so the desktop punches through
|
||||
@@ -136,6 +146,9 @@
|
||||
// armGlassResizeGuard. Armed unconditionally: the CSS gates on .glass, and
|
||||
// this way the mode can be toggled mid-drag without leaving stale state.
|
||||
armGlassResizeGuard();
|
||||
// The updater's status and finds arrive as events; the backend checks
|
||||
// on its own schedule and never installs without the button.
|
||||
startUpdaterWatch();
|
||||
autoConnect();
|
||||
// Sealed cargo arrives on the inbox check, and that has to keep happening
|
||||
// wherever the user is — not only while the Community page is mounted.
|
||||
@@ -157,6 +170,7 @@
|
||||
class:player-expanded={ui.playerExpanded}
|
||||
class:np-dragging={ui.nowPlayingDragging}
|
||||
class:preview-open={previewUi.open}
|
||||
class:setup-open={firstRun.open}
|
||||
style="--np-open-width: {ui.nowPlayingWidth}px"
|
||||
>
|
||||
<TitleBar />
|
||||
@@ -175,7 +189,7 @@
|
||||
would be masked with it; the per-frame page-mask this replaces
|
||||
never tracked cleanly). The inner div KEEPS the .content class:
|
||||
every page resolves its scroll pane via closest(".content"). -->
|
||||
<main class="pane-shell" class:barless={!BAR_VIEWS.has(ui.view)} inert={previewUi.open || undefined}>
|
||||
<main class="pane-shell" class:barless={!BAR_VIEWS.has(ui.view)} inert={previewUi.open || firstRun.open || undefined}>
|
||||
<GlassBar hidden={!BAR_VIEWS.has(ui.view)} />
|
||||
<div class="content" use:floatScroll>
|
||||
<CurrentView />
|
||||
@@ -189,6 +203,7 @@
|
||||
<UploadToast />
|
||||
<ProposalToast />
|
||||
<FollowNewsToast />
|
||||
<UpdateToast />
|
||||
<WhoHasSheet />
|
||||
<SetlistScanChip />
|
||||
<PreviewOverlay />
|
||||
@@ -196,6 +211,9 @@
|
||||
<ArtworkViewer />
|
||||
<ImportWindow />
|
||||
<LoginModal />
|
||||
<!-- Above the login modal: the tour has its own sign-in step, and a
|
||||
failed auto-connect at boot must not raise the modal over it. -->
|
||||
<FirstRun />
|
||||
<DebugOverlay />
|
||||
</div>
|
||||
|
||||
@@ -208,7 +226,10 @@
|
||||
visible (dimmed under the scrim) but cannot command main audio. */
|
||||
.app.preview-open :global(.sidebar),
|
||||
.app.preview-open :global(.nowplaying),
|
||||
.app.preview-open :global(.player) {
|
||||
.app.preview-open :global(.player),
|
||||
.app.setup-open :global(.sidebar),
|
||||
.app.setup-open :global(.nowplaying),
|
||||
.app.setup-open :global(.player) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
<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: "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);
|
||||
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;
|
||||
}
|
||||
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 === "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, 5) as s (s.id)}
|
||||
<li><Icon name={s.icon} size={13} /> {s.label}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</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 [
|
||||
["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}
|
||||
<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,130 @@
|
||||
<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.
|
||||
import Icon from "./Icon.svelte";
|
||||
import { updater, installUpdate, dismissUpdate } 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 pct = $derived.by(() => {
|
||||
const p = updater.progress;
|
||||
if (!p || !p.total) return null;
|
||||
return Math.min(100, Math.round((p.received / p.total) * 100));
|
||||
});
|
||||
</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>
|
||||
{/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>
|
||||
@@ -226,6 +226,13 @@ export const refreshAlbumArt = (albumId) => invoke("refresh_album_art", { albumI
|
||||
export const refreshAllAlbumArt = () => invoke("refresh_all_album_art");
|
||||
export const getSettings = () => invoke("get_settings");
|
||||
export const setSettings = (patch) => invoke("set_settings", patch);
|
||||
// The in-app updater (updater.rs): status, a check on the current channel,
|
||||
// settings (channel / autoCheck / endpointStable / endpointNightly), and the
|
||||
// install — which on Windows ends this process when it succeeds.
|
||||
export const updaterStatus = () => invoke("updater_status");
|
||||
export const updaterCheck = () => invoke("updater_check");
|
||||
export const updaterSet = (patch) => invoke("updater_set", patch);
|
||||
export const updaterInstall = () => invoke("updater_install");
|
||||
export const windowGlass = (on) => invoke("window_glass", { on });
|
||||
export const forgetCredentials = () => invoke("forget_credentials");
|
||||
export const pickFolder = () => invoke("pick_folder");
|
||||
|
||||
@@ -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,79 @@
|
||||
// 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,
|
||||
error: "",
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -8,6 +8,21 @@
|
||||
import { app, network, clearCatalogHeroArtCache, refreshSourcePrefs } from "../lib/state.svelte.js";
|
||||
import { setWordFill } from "../lib/lyrics.svelte.js";
|
||||
import { applyGlass } from "../lib/glass.js";
|
||||
import { openFirstRun } from "../lib/firstRun.svelte.js";
|
||||
import { updater, checkForUpdates, setUpdater, installUpdate } from "../lib/updater.svelte.js";
|
||||
// The updater card reads the backend's snapshot; `pct` is the install download.
|
||||
const up = $derived(updater.status);
|
||||
const avail = $derived(up?.available ?? null);
|
||||
const pct = $derived(updater.progress?.total ? Math.min(100, Math.round((updater.progress.received / updater.progress.total) * 100)) : null);
|
||||
// The private-release token: typed here, saved to the Credential Manager
|
||||
// by the backend, never read back into the page.
|
||||
let tokenDraft = $state("");
|
||||
async function saveToken() {
|
||||
const t = tokenDraft.trim();
|
||||
if (!t) return;
|
||||
await setUpdater({ token: t });
|
||||
tokenDraft = "";
|
||||
}
|
||||
import * as api from "../lib/api.js";
|
||||
import Icon from "../components/Icon.svelte";
|
||||
import Chooser from "../components/Chooser.svelte";
|
||||
@@ -329,6 +344,7 @@
|
||||
const SECTIONS = [
|
||||
{ id: "account", title: "Account", group: "You", icon: "users" },
|
||||
{ id: "networks", title: "Networks", group: "You", icon: "globe" },
|
||||
{ id: "updates", title: "Updates", group: "You", icon: "download" },
|
||||
{ id: "appearance", title: "Appearance", group: "Look & sound", icon: "eye" },
|
||||
{ id: "playback", title: "Playback", group: "Look & sound", icon: "volume" },
|
||||
{ id: "lyrics", title: "Lyrics", group: "Look & sound", icon: "lyrics" },
|
||||
@@ -423,6 +439,13 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">First-time setup</div>
|
||||
<div class="hint">The welcome tour from the first launch: library folder, account, community peer, look. Runs again from here and changes nothing until you do.</div>
|
||||
</div>
|
||||
<div class="ctl"><button class="ax-ghost" onclick={() => openFirstRun("settings")}>Run again</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -525,6 +548,72 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Updates ─────────────────────────────────────────── -->
|
||||
<section id="set-updates" class="sec">
|
||||
<div class="sec-head"><span class="hic"><Icon name={iconOf("updates")} size={14} /></span><h2>Updates</h2><span class="blurb">New releases from the project, on the channel you pick. Installing is always a button.</span></div>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">SoulStream {up?.version ?? "…"} {#if up?.dev_build}<span class="tag off">dev build</span>{/if}{#if avail}<span class="tag acc">{avail.version} available</span>{:else if up?.last_check && !up?.last_error}<span class="tag ok">up to date</span>{/if}</div>
|
||||
<div class="hint">
|
||||
{#if up?.checking}Checking {up.channel}…
|
||||
{:else if up?.last_error}<span class="warn">Last check failed: {up.last_error}</span>
|
||||
{:else if up?.last_check}Checked {fmtWhen(up.last_check * 1000)} on the {up.channel} channel.
|
||||
{:else}Not checked yet on the {up?.channel ?? "stable"} channel.{/if}
|
||||
{#if updater.error}<span class="warn"> {updater.error}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ctl"><button class="ax-ghost" onclick={() => checkForUpdates()} disabled={!!up?.checking || !!up?.installing}>{up?.checking ? "Checking…" : "Check now"}</button></div>
|
||||
</div>
|
||||
{#if avail}
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">{avail.version} is ready {#if avail.date}<span class="tag">{new Date(avail.date * 1000).toLocaleDateString()}</span>{/if}</div>
|
||||
{#if avail.notes}<div class="hint notes">{avail.notes}</div>{/if}
|
||||
{#if up?.installing}
|
||||
<div class="hint">{pct != null ? `Downloading · ${pct}%` : "Downloading…"} — the app closes into the installer and comes back on its own.</div>
|
||||
<span class="pbar" aria-hidden="true"><i style="width: {pct ?? 6}%"></i></span>
|
||||
{:else if up?.dev_build}
|
||||
<div class="hint warn">This is a dev build run from the target folder; the installer would set up the packaged app beside it.</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="ctl"><button class="ax-fill" onclick={() => installUpdate()} disabled={!!up?.installing}><Icon name="download" size={12} /> {up?.installing ? "Installing…" : "Install and restart"}</button></div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Channel</div>
|
||||
<div class="hint">Stable follows the releases. Nightly is built from <b>main</b> every day and can break; switching back to Stable offers the next stable build even when its number is lower.</div>
|
||||
</div>
|
||||
<div class="ctl">{@render seg([["stable", "Stable"], ["nightly", "Nightly"]], up?.channel ?? "stable", (v) => setUpdater({ channel: v }), !up || !!up.installing)}</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Check automatically</div>
|
||||
<div class="hint">Shortly after launch and every six hours. Finding one shows a toast; nothing installs on its own.</div>
|
||||
</div>
|
||||
<div class="ctl">{@render sw(up?.auto_check ?? true, ["on", "off"], (v) => setUpdater({ autoCheck: v }), !up)}</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Access token {#if up?.has_token}<span class="tag ok">set</span>{:else if up?.source_kind === "github"}<span class="tag warn">needed for a private repository</span>{/if}</div>
|
||||
<div class="hint">The project's repository is private while the alpha runs: releases are read with a token that can only read them. Paste the one you were given — it goes to the Windows Credential Manager, not a plain file, and leaves with Clear.</div>
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<input type="password" class="mono tok" bind:value={tokenDraft} placeholder={up?.has_token ? "••••••••••••" : "github_pat_…"} spellcheck="false" autocomplete="off" aria-label="Access token" onkeydown={(e) => e.key === "Enter" && saveToken()} disabled={!up} />
|
||||
<button class="ax-white" onclick={saveToken} disabled={!tokenDraft.trim()}>Save</button>
|
||||
{#if up?.has_token}<button class="ax-ghost" onclick={() => setUpdater({ token: "" })}>Clear</button>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<details class="disclose src">
|
||||
<summary>Update source</summary>
|
||||
<div class="hint">The manifest each channel reads (<code>latest.json</code>, Tauri's updater format, signed with the project's key). Leave empty for the project's GitHub releases; point it at a mirror or your own Gitea to update from there.</div>
|
||||
<label class="srcrow"><span>Stable</span><input class="mono" value={up?.endpoint_stable ?? ""} placeholder={up?.default_stable ?? ""} spellcheck="false" onkeydown={(e) => e.key === "Enter" && e.currentTarget.blur()} onchange={(e) => setUpdater({ endpointStable: e.currentTarget.value })} /></label>
|
||||
<label class="srcrow"><span>Nightly</span><input class="mono" value={up?.endpoint_nightly ?? ""} placeholder={up?.default_nightly ?? ""} spellcheck="false" onkeydown={(e) => e.key === "Enter" && e.currentTarget.blur()} onchange={(e) => setUpdater({ endpointNightly: e.currentTarget.value })} /></label>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Appearance ──────────────────────────────────────── -->
|
||||
<section id="set-appearance" class="sec">
|
||||
<div class="sec-head"><span class="hic"><Icon name={iconOf("appearance")} size={14} /></span><h2>Appearance</h2><span class="blurb">How the window is drawn.</span></div>
|
||||
@@ -840,6 +929,15 @@
|
||||
.subgrp { display: flex; align-items: center; gap: 8px; padding: 12px 16px 2px; font-size: 10.5px; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); }
|
||||
.subgrp .ln { flex: 1; height: 1px; background: var(--hair); }
|
||||
.noderow { display: flex; align-items: center; gap: 10px; margin-top: 8px; flex-wrap: wrap; }
|
||||
.hint.notes { white-space: pre-wrap; max-height: 9em; overflow: auto; }
|
||||
.pbar { display: block; width: min(100%, 360px); height: 4px; border-radius: 99px; background: var(--surf-hi); overflow: hidden; margin-top: 8px; }
|
||||
.pbar i { display: block; height: 100%; background: var(--accent); border-radius: 99px; transition: width 0.2s; }
|
||||
.disclose.src { padding: 10px 16px 14px; border-top: 1px solid var(--hair); }
|
||||
.disclose.src .hint { margin: 6px 0 10px; }
|
||||
.disclose.src code { font-family: "Cascadia Code", Consolas, monospace; font-size: 11px; }
|
||||
.srcrow { display: grid; grid-template-columns: 64px 1fr; align-items: center; gap: 10px; margin-top: 6px; font-size: 12px; font-weight: 700; color: var(--text-secondary); }
|
||||
.srcrow input { width: 100%; min-width: 0; }
|
||||
input.tok { width: 220px; }
|
||||
.lnk { color: var(--text-secondary); font-weight: 700; text-decoration: underline; text-underline-offset: 2px; font-size: 12px; }
|
||||
.lnk:disabled { opacity: 0.5; }
|
||||
.disclose { margin-top: 8px; }
|
||||
|
||||
Reference in new issue
Block a user