From cd2391198e25d7f2cfdad22f899ada35da48cde9 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:16:19 -0500 Subject: [PATCH] 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 --- .github/workflows/nightly.yml | 110 ++++++ .github/workflows/release.yml | 92 +++++ Cargo.lock | 242 +++++++++++- src-tauri/Cargo.toml | 2 + src-tauri/installer/header.bmp | Bin 0 -> 25818 bytes src-tauri/installer/sidebar.bmp | Bin 0 -> 154542 bytes src-tauri/src/commands.rs | 7 + src-tauri/src/config.rs | 46 +++ src-tauri/src/lib.rs | 24 +- src-tauri/src/updater.rs | 516 ++++++++++++++++++++++++++ src-tauri/tauri.conf.json | 26 +- src/App.svelte | 27 +- src/components/FirstRun.svelte | 586 ++++++++++++++++++++++++++++++ src/components/UpdateToast.svelte | 130 +++++++ src/lib/api.js | 7 + src/lib/firstRun.svelte.js | 29 ++ src/lib/state.svelte.js | 4 + src/lib/updater.svelte.js | 79 ++++ src/views/Settings.svelte | 98 +++++ 19 files changed, 2010 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/nightly.yml create mode 100644 .github/workflows/release.yml create mode 100644 src-tauri/installer/header.bmp create mode 100644 src-tauri/installer/sidebar.bmp create mode 100644 src-tauri/src/updater.rs create mode 100644 src/components/FirstRun.svelte create mode 100644 src/components/UpdateToast.svelte create mode 100644 src/lib/firstRun.svelte.js create mode 100644 src/lib/updater.svelte.js 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 0000000000000000000000000000000000000000..4b7f854ecd94d7b142e7eb667d9ee535ff206adb GIT binary patch literal 25818 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 CYEw)A literal 0 HcmV?d00001 diff --git a/src-tauri/installer/sidebar.bmp b/src-tauri/installer/sidebar.bmp new file mode 100644 index 0000000000000000000000000000000000000000..d1c74e8f852d004f46f6c4c1ee64334afc5f14f2 GIT binary patch literal 154542 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 Y5@rfqM&}6ta!M#)eMJo%wF!Rx|7;(JO#lD@ literal 0 HcmV?d00001 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"). --> -
+