diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..0c0c5cc --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,110 @@ +# The nightly: every day at 06:00 UTC (or by hand), build `main` as +# `X.Y.Z-nightly.YYYYMMDD.` — 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f92c96c --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 1ab14ec..af84a35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b89205a..7bdd600 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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 = [ diff --git a/src-tauri/installer/header.bmp b/src-tauri/installer/header.bmp new file mode 100644 index 0000000..4b7f854 Binary files /dev/null and b/src-tauri/installer/header.bmp differ diff --git a/src-tauri/installer/sidebar.bmp b/src-tauri/installer/sidebar.bmp new file mode 100644 index 0000000..d1c74e8 Binary files /dev/null and b/src-tauri/installer/sidebar.bmp differ diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f90ee0d..31e76b0 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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, word_timing: Option, glass_mode: Option, + setup_done: Option, ) -> 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 diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 349da6b..91f8008 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -175,6 +175,22 @@ pub struct AppConfig { /// of it is ever published or sent to the person it describes. #[serde(default)] pub contacts: Vec, + /// 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, + /// 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, + #[serde(default)] + pub update_auto_check: Option, + #[serde(default)] + pub update_endpoint_stable: Option, + #[serde(default)] + pub update_endpoint_nightly: Option, } #[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::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 { + 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) { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6e5c7a9..17be2a8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -34,6 +34,7 @@ mod overlap; mod holdings; mod state; mod thumbbar; +mod updater; mod vocal_sep; mod youtube_playlist; mod youtube_setlist; @@ -43,6 +44,7 @@ mod ytdlp; pub fn run() { tauri::Builder::default() .manage(state::AppState::default()) + .manage(updater::UpdaterState::default()) .setup(|app| { app.handle().plugin( tauri_plugin_log::Builder::default() @@ -54,6 +56,7 @@ pub fn run() { .build(), )?; app.handle().plugin(tauri_plugin_dialog::init())?; + app.handle().plugin(tauri_plugin_updater::Builder::new().build())?; // Must run before anything touches the app-data dir. config::migrate_app_data(app.handle()); // Prepare the small local vision model while the user is still on @@ -75,6 +78,9 @@ pub fn run() { // by default) and forwards adapter status to the webview. Fully // isolated — its absence or failure touches nothing else. freenet::init(app.handle()); + // Release checks: one shortly after launch, then every six hours, + // never installing on their own (updater.rs). + updater::init(app.handle()); if let Err(e) = pipeline::init(app.handle()) { log::error!("library init failed: {e}"); } else { @@ -90,6 +96,10 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ commands::app_status, + updater::updater_status, + updater::updater_check, + updater::updater_set, + updater::updater_install, commands::excluded_search_phrases, commands::get_credentials, commands::connect_and_login, @@ -370,11 +380,17 @@ pub fn run() { --autoplay-policy=no-user-gesture-required \ --enable-smooth-scrolling \ --force-color-profile=srgb"; + // One cfg over BOTH statements: the attribute on the `let cdp` + // alone left the `format!` below reading a name that release + // builds never define — every `tauri build` since 0.1.0-alpha.3 + // failed on it (found 2026-09-09 making the first installer). #[cfg(debug_assertions)] - // A second instance needs its own CDP port, or it silently fails - // to bind and cannot be driven at all. - let cdp = std::env::var("SOULSTREAM_CDP_PORT").unwrap_or_else(|_| "9222".into()); - let args = format!("{base} --remote-debugging-port={cdp}"); + let args = { + // A second instance needs its own CDP port, or it silently + // fails to bind and cannot be driven at all. + let cdp = std::env::var("SOULSTREAM_CDP_PORT").unwrap_or_else(|_| "9222".into()); + format!("{base} --remote-debugging-port={cdp}") + }; #[cfg(not(debug_assertions))] let args = base.to_string(); for window in &mut context.config_mut().app.windows { diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs new file mode 100644 index 0000000..ffe474f --- /dev/null +++ b/src-tauri/src/updater.rs @@ -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, +} + +#[derive(Default)] +struct Inner { + found: Option, + checking: bool, + installing: bool, + last_check: Option, + last_error: Option, + progress: Option, +} + +#[derive(Serialize, Clone, Copy)] +pub struct Progress { + pub received: u64, + pub total: Option, +} + +#[derive(Serialize, Clone)] +pub struct Available { + pub version: String, + pub notes: Option, + pub date: Option, +} + +/// 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, + pub checking: bool, + pub installing: bool, + pub last_check: Option, + pub last_error: Option, + pub progress: Option, + /// 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, 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/` or `…/releases/download/{tag}/`. +#[derive(Debug, PartialEq, Eq)] +pub struct GitHubSource { + pub owner: String, + pub repo: String, + /// `latest`, or `tags/` — the API path segment. + pub release: String, + pub file: String, +} + +pub fn github_source(url: &Url) -> Option { + 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::(); + 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 { + 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::(); + 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::(); + 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::(); + 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::(); + 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 { + 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, + auto_check: Option, + endpoint_stable: Option, + endpoint_nightly: Option, + token: Option, +) -> 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::(); + 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::(); + 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); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8b6048b..db37193 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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" } } } diff --git a/src/App.svelte b/src/App.svelte index ada0b01..66aaaba 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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" > @@ -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"). --> -
+