From f8508da081d515ed40dc601b4b729bc267cd7cc7 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:00:02 -0500 Subject: [PATCH] =?UTF-8?q?setup:=20the=20installer=20is=20our=20own=20win?= =?UTF-8?q?dow=20=E2=80=94=20the=20alpha=20key=20is=20the=20gate,=20the=20?= =?UTF-8?q?release=20comes=20down=20with=20it=20and=20is=20verified=20befo?= =?UTF-8?q?re=20the=20silent=20install,=20and=20the=20in-app=20tour=20asks?= =?UTF-8?q?=20for=20the=20key=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/nightly.yml | 22 + .github/workflows/release.yml | 22 + .gitignore | 4 + Cargo.lock | 697 +++++++++++++++++++++- Cargo.toml | 1 + setup/index.html | 12 + setup/src-tauri/Cargo.toml | 31 + setup/src-tauri/build.rs | 19 + setup/src-tauri/capabilities/default.json | 12 + setup/src-tauri/src/main.rs | 485 +++++++++++++++ setup/src-tauri/tauri.conf.json | 38 ++ setup/src/Setup.svelte | 524 ++++++++++++++++ setup/src/main.js | 7 + src/components/FirstRun.svelte | 52 +- src/components/UpdateToast.svelte | 28 +- src/lib/updater.svelte.js | 6 + vite.setup.config.js | 13 + 17 files changed, 1960 insertions(+), 13 deletions(-) create mode 100644 setup/index.html create mode 100644 setup/src-tauri/Cargo.toml create mode 100644 setup/src-tauri/build.rs create mode 100644 setup/src-tauri/capabilities/default.json create mode 100644 setup/src-tauri/src/main.rs create mode 100644 setup/src-tauri/tauri.conf.json create mode 100644 setup/src/Setup.svelte create mode 100644 setup/src/main.js create mode 100644 vite.setup.config.js diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 0c0c5cc..5547d4a 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -108,3 +108,25 @@ jobs: console.log(JSON.stringify(m, null, 2)); ' gh release upload "$TAG" latest.json --repo "$TARGET" --clobber + + # The file testers run: our own setup window (setup/). It carries no + # installer — it downloads the latest release with the tester's key and + # verifies the signature — so it is built the same for every release. + - name: Build SoulStream Setup + shell: bash + env: + VERSION: ${{ steps.stamp.outputs.version }} + run: | + npx vite build --config vite.setup.config.js + cargo build --release -p soulstream-setup + cp target/release/SoulStream-Setup.exe "SoulStream-Setup-$VERSION.exe" + ls -la "SoulStream-Setup-$VERSION.exe" + + - name: Publish SoulStream Setup + shell: bash + env: + GH_TOKEN: ${{ secrets.RELEASES_TOKEN || secrets.GITHUB_TOKEN }} + TARGET: ${{ vars.RELEASES_OWNER || github.repository_owner }}/${{ vars.RELEASES_REPO || github.event.repository.name }} + TAG: nightly + VERSION: ${{ steps.stamp.outputs.version }} + run: gh release upload "$TAG" "SoulStream-Setup-$VERSION.exe" --repo "$TARGET" --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6faa5c4..b49b2b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -91,3 +91,25 @@ jobs: console.log(JSON.stringify(m, null, 2)); ' gh release upload "$TAG" latest.json --repo "$TARGET" --clobber + + # The file testers run: our own setup window (setup/). It carries no + # installer — it downloads the latest release with the tester's key and + # verifies the signature — so it is built the same for every release. + - name: Build SoulStream Setup + shell: bash + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + npx vite build --config vite.setup.config.js + cargo build --release -p soulstream-setup + cp target/release/SoulStream-Setup.exe "SoulStream-Setup-$VERSION.exe" + ls -la "SoulStream-Setup-$VERSION.exe" + + - name: Publish SoulStream Setup + shell: bash + env: + GH_TOKEN: ${{ secrets.RELEASES_TOKEN || secrets.GITHUB_TOKEN }} + TARGET: ${{ vars.RELEASES_OWNER || github.repository_owner }}/${{ vars.RELEASES_REPO || github.event.repository.name }} + TAG: v${{ steps.version.outputs.version }} + VERSION: ${{ steps.version.outputs.version }} + run: gh release upload "$TAG" "SoulStream-Setup-$VERSION.exe" --repo "$TARGET" --clobber diff --git a/.gitignore b/.gitignore index cb16dc0..463fb6c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ GAMEPLAN.md # Logo/icon kits — worked on outside this repo, not shipped from it branding/ + +# the setup app frontend build (vite.setup.config.js) +setup/dist/ +setup/src-tauri/gen/ diff --git a/Cargo.lock b/Cargo.lock index 4317d7c..39570d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -212,6 +212,181 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.5", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "atk" version = "0.18.2" @@ -360,6 +535,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "8.0.4" @@ -602,6 +790,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1029,13 +1226,34 @@ dependencies = [ "ctutils", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -1046,7 +1264,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -1073,6 +1291,15 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + [[package]] name = "dlopen2" version = "0.8.2" @@ -1111,6 +1338,12 @@ dependencies = [ "tendril", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -1204,7 +1437,7 @@ dependencies = [ "rustc_version", "toml 1.1.5+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -1222,6 +1455,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "env_filter" version = "0.1.4" @@ -1282,6 +1542,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "extended" version = "0.1.0" @@ -1542,6 +1822,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -1934,6 +2227,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -3310,6 +3609,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "osakit" version = "0.3.1" @@ -3349,6 +3658,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -3455,6 +3770,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -3523,6 +3849,26 @@ dependencies = [ "miniz_oxide 0.8.9", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + [[package]] name = "poly1305" version = "0.8.0" @@ -3804,6 +4150,17 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3943,6 +4300,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2", + "dispatch2", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + [[package]] name = "rfd" version = "0.16.0" @@ -4203,6 +4584,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -4480,6 +4867,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "signature" version = "2.2.0" @@ -4586,6 +4983,23 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "soulstream-setup" +version = "1.8.0-alpha" +dependencies = [ + "base64 0.21.7", + "dirs 5.0.1", + "keyring", + "minisign-verify", + "reqwest 0.12.28", + "rfd 0.15.4", + "serde", + "serde_json", + "tauri", + "tauri-build", + "winreg 0.52.0", +] + [[package]] name = "soup3" version = "0.5.0" @@ -5062,7 +5476,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -5112,7 +5526,7 @@ checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -5190,7 +5604,7 @@ checksum = "61854a36651aa48381e5e209f69a01273b77f3f9f91f0c430b1b98d33bd47229" dependencies = [ "log", "raw-window-handle", - "rfd", + "rfd 0.16.0", "serde", "serde_json", "tauri", @@ -5252,7 +5666,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b28d8cabdeb0564f03ae261963de4bc3d98321cd3d213e76a81b7d344e5df606" dependencies = [ "base64 0.22.1", - "dirs", + "dirs 6.0.0", "flate2", "futures-util", "http 1.5.0", @@ -5822,7 +6236,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2", @@ -5872,6 +6286,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -5954,6 +6379,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.3.0" @@ -6144,6 +6575,66 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml 0.41.0", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.104" @@ -6533,6 +7024,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -6584,6 +7084,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -6641,6 +7156,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -6659,6 +7180,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -6677,6 +7204,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -6707,6 +7240,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -6725,6 +7264,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -6743,6 +7288,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -6761,6 +7312,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -6797,6 +7354,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winreg" version = "0.55.0" @@ -6829,7 +7396,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dom_query", "dpi", "dunce", @@ -6944,6 +7511,76 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.4", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.56" @@ -7076,3 +7713,45 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.4", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.4", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml index de2c98c..5e5eb53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "src-tauri", + "setup/src-tauri", "crates/soulseek-proto", "crates/soulseek-client", "crates/player", diff --git a/setup/index.html b/setup/index.html new file mode 100644 index 0000000..b9032b5 --- /dev/null +++ b/setup/index.html @@ -0,0 +1,12 @@ + + + + + + SoulStream Setup + + +
+ + + diff --git a/setup/src-tauri/Cargo.toml b/setup/src-tauri/Cargo.toml new file mode 100644 index 0000000..6fdac7a --- /dev/null +++ b/setup/src-tauri/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "soulstream-setup" +version.workspace = true +description = "SoulStream Setup — the installer testers run: our own window, the alpha key as the gate, the real installer downloaded from the private release and verified" +authors.workspace = true +edition.workspace = true +rust-version.workspace = true + +# One file to hand out: SoulStream-Setup.exe. Not bundled — it IS the bundle. +[[bin]] +name = "SoulStream-Setup" +path = "src/main.rs" + +[build-dependencies] +tauri-build = { version = "2", features = [] } +serde_json.workspace = true + +[dependencies] +tauri = { version = "2", features = [] } +serde.workspace = true +serde_json.workspace = true +# The alpha key goes where the app reads it: the Credential Manager. +keyring = { version = "3", features = ["windows-native"] } +dirs = "5" +rfd = "0.15" +winreg = "0.52" +# The release comes down from GitHub with the key, and its signature is +# checked against the same public key the app's updater trusts. +reqwest = { version = "0.12", features = ["json"] } +minisign-verify = "0.2" +base64 = "0.21" diff --git a/setup/src-tauri/build.rs b/setup/src-tauri/build.rs new file mode 100644 index 0000000..a362d22 --- /dev/null +++ b/setup/src-tauri/build.rs @@ -0,0 +1,19 @@ +//! The setup app trusts exactly what the app's updater trusts: the public +//! key in the app's tauri.conf.json. Read at build time so there is one +//! copy of it in the repository. +use std::path::{Path, PathBuf}; + +fn main() { + let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let conf = manifest.join("../../src-tauri/tauri.conf.json"); + println!("cargo:rerun-if-changed={}", conf.display()); + let text = std::fs::read_to_string(&conf).expect("read src-tauri/tauri.conf.json"); + let json: serde_json::Value = serde_json::from_str(&text).expect("parse tauri.conf.json"); + let pubkey = json["plugins"]["updater"]["pubkey"] + .as_str() + .filter(|s| !s.is_empty()) + .expect("plugins.updater.pubkey in src-tauri/tauri.conf.json"); + let out = Path::new(&std::env::var("OUT_DIR").unwrap()).join("pubkey.rs"); + std::fs::write(&out, format!("pub const PUBKEY: &str = {pubkey:?};\n")).expect("write pubkey.rs"); + tauri_build::build(); +} diff --git a/setup/src-tauri/capabilities/default.json b/setup/src-tauri/capabilities/default.json new file mode 100644 index 0000000..1cbf6b9 --- /dev/null +++ b/setup/src-tauri/capabilities/default.json @@ -0,0 +1,12 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "The setup window: its own chrome (drag, minimize, close) and the app's commands.", + "windows": ["main"], + "permissions": [ + "core:default", + "core:window:allow-start-dragging", + "core:window:allow-minimize", + "core:window:allow-close" + ] +} diff --git a/setup/src-tauri/src/main.rs b/setup/src-tauri/src/main.rs new file mode 100644 index 0000000..b76bbe6 --- /dev/null +++ b/setup/src-tauri/src/main.rs @@ -0,0 +1,485 @@ +//! SoulStream Setup — the file a tester runs. Our own window in the app's +//! stack (a Tauri webview drawing the first-run tour's design). The alpha +//! key is the gate: it is checked against GitHub first, and only a key that +//! can read the private repository gets any further. The installer is then +//! DOWNLOADED from the latest release with that key (never carried inside +//! this exe, where it could be pulled out), its signature verified against +//! the same public key the app's updater trusts, and run silently. The key +//! goes to the Credential Manager where the app reads it, the choices into +//! the app's config.json, and the app is launched. +//! +//! Without WebView2 this window cannot draw. main() checks the runtime +//! first and, if it is missing, says so and leaves — the app itself needs +//! WebView2 too, so there is nothing to install on such a machine yet. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +mod pubkey { + include!(concat!(env!("OUT_DIR"), "/pubkey.rs")); +} + +use std::path::PathBuf; +use std::process::Command; +use std::time::Duration; + +use base64::Engine; +use serde::Serialize; +use tauri::{AppHandle, Emitter}; + +const REPO: &str = "KeelrLeek/SoulStream"; +const APP_DATA_DIRNAME: &str = "com.soulstream.app"; +const KEYRING_SERVICE: &str = "com.soulstream.app"; +const KEYRING_TOKEN_USER: &str = "updater-token"; +const USER_AGENT: &str = concat!("SoulStream-Setup/", env!("CARGO_PKG_VERSION")); + +fn app_data_dir() -> Option { + dirs::data_dir().map(|d| d.join(APP_DATA_DIRNAME)) +} + +fn config_path() -> Option { + app_data_dir().map(|d| d.join("config.json")) +} + +fn load_config() -> serde_json::Map { + config_path() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default() +} + +fn save_config(cfg: &serde_json::Map) -> Result<(), String> { + let p = config_path().ok_or("no app data folder")?; + if let Some(dir) = p.parent() { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + let json = serde_json::to_string_pretty(&serde_json::Value::Object(cfg.clone())) + .map_err(|e| e.to_string())?; + std::fs::write(&p, json).map_err(|e| e.to_string()) +} + +fn music_dir() -> PathBuf { + dirs::audio_dir() + .or_else(dirs::home_dir) + .unwrap_or_else(|| PathBuf::from(".")) +} + +fn keyring_entry() -> Option { + keyring::Entry::new(KEYRING_SERVICE, KEYRING_TOKEN_USER).ok() +} + +fn saved_key() -> Option { + keyring_entry() + .and_then(|e| e.get_password().ok()) + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) +} + +/// Where the installer put the app, from the uninstall entry it registers; +/// the per-user default when there is none yet. +fn install_location() -> Option { + use winreg::enums::HKEY_CURRENT_USER; + use winreg::RegKey; + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + if let Ok(uninstall) = hkcu.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall") { + for name in uninstall.enum_keys().flatten() { + if let Ok(k) = uninstall.open_subkey(&name) { + let display: String = k.get_value("DisplayName").unwrap_or_default(); + if display == "SoulStream" { + if let Ok(loc) = k.get_value::("InstallLocation") { + let loc = loc.trim_matches('"'); + if !loc.is_empty() { + return Some(PathBuf::from(loc)); + } + } + } + } + } + } + dirs::data_local_dir().map(|d| d.join("SoulStream")) +} + +fn installed_version() -> Option { + use winreg::enums::HKEY_CURRENT_USER; + use winreg::RegKey; + let uninstall = RegKey::predef(HKEY_CURRENT_USER) + .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall") + .ok()?; + for name in uninstall.enum_keys().flatten() { + if let Ok(k) = uninstall.open_subkey(&name) { + let display: String = k.get_value("DisplayName").unwrap_or_default(); + if display == "SoulStream" { + return k.get_value::("DisplayVersion").ok(); + } + } + } + None +} + +fn app_exe() -> Option { + let dir = install_location()?; + for name in ["SoulStream.exe", "app.exe"] { + let p = dir.join(name); + if p.is_file() { + return Some(p); + } + } + None +} + +fn webview2_present() -> bool { + use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE}; + use winreg::RegKey; + const CLIENT: &str = r"Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"; + const CLIENT_WOW: &str = r"SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"; + for (hive, path) in [ + (HKEY_LOCAL_MACHINE, CLIENT_WOW), + (HKEY_LOCAL_MACHINE, CLIENT), + (HKEY_CURRENT_USER, CLIENT), + ] { + if let Ok(k) = RegKey::predef(hive).open_subkey(path) { + if let Ok(pv) = k.get_value::("pv") { + if !pv.trim().is_empty() && pv.trim() != "0.0.0.0" { + return true; + } + } + } + } + false +} + +// ---------------------------------------------------------------- GitHub + +/// The latest release as the key can see it: the installer and its +/// signature, by their API asset URLs (a private repository's assets are +/// reachable only that way). +#[derive(Serialize, Clone)] +struct Release { + version: String, + name: String, + published: Option, + installer_name: String, + installer_size: u64, + installer_url: String, + sig_url: Option, +} + +fn client() -> Result { + reqwest::Client::builder() + .timeout(Duration::from_secs(60)) + .user_agent(USER_AGENT) + .build() + .map_err(|e| e.to_string()) +} + +async fn fetch_release(token: &str) -> Result { + let resp = client()? + .get(format!("https://api.github.com/repos/{REPO}/releases/latest")) + .bearer_auth(token) + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .send() + .await + .map_err(|e| format!("Could not reach GitHub — is this computer online? ({e})"))?; + match resp.status().as_u16() { + 200 => {} + 401 => return Err("That key was refused.".into()), + 403 => return Err("That key cannot read the SoulStream repository.".into()), + 404 => return Err("No release found — or that key cannot see the repository.".into()), + s => return Err(format!("GitHub answered {s}.")), + } + let v: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; + let tag = v["tag_name"].as_str().unwrap_or(""); + let assets = v["assets"].as_array().cloned().unwrap_or_default(); + let find = |suffix: &str| { + assets + .iter() + .find(|a| a["name"].as_str().map_or(false, |n| n.ends_with(suffix))) + .cloned() + }; + let installer = find("_x64-setup.exe").ok_or("The release carries no installer.")?; + let sig = find("_x64-setup.exe.sig"); + Ok(Release { + version: tag.trim_start_matches('v').to_string(), + name: v["name"].as_str().unwrap_or(tag).to_string(), + published: v["published_at"].as_str().map(String::from), + installer_name: installer["name"].as_str().unwrap_or("SoulStream-setup.exe").to_string(), + installer_size: installer["size"].as_u64().unwrap_or(0), + installer_url: installer["url"].as_str().unwrap_or("").to_string(), + sig_url: sig.and_then(|a| a["url"].as_str().map(String::from)), + }) +} + +#[derive(Serialize, Clone)] +struct Phase { + phase: String, + detail: String, + received: u64, + total: u64, +} + +fn phase(app: &AppHandle, name: &str, detail: impl Into, received: u64, total: u64) { + let _ = app.emit( + "setup:phase", + Phase { phase: name.into(), detail: detail.into(), received, total }, + ); +} + +/// An asset, streamed down with the key; progress goes to the window. +async fn download(app: &AppHandle, token: &str, url: &str, total: u64, label: &str) -> Result, String> { + let mut resp = client()? + .get(url) + .bearer_auth(token) + .header(reqwest::header::ACCEPT, "application/octet-stream") + .send() + .await + .map_err(|e| format!("The download failed to start: {e}"))?; + if !resp.status().is_success() { + return Err(format!("The download was refused ({}).", resp.status().as_u16())); + } + let total = resp.content_length().unwrap_or(total); + let mut bytes = Vec::with_capacity(total as usize); + let mut last = std::time::Instant::now(); + while let Some(chunk) = resp.chunk().await.map_err(|e| format!("The download broke off: {e}"))? { + bytes.extend_from_slice(&chunk); + if last.elapsed() >= Duration::from_millis(120) { + last = std::time::Instant::now(); + phase(app, "downloading", label, bytes.len() as u64, total); + } + } + phase(app, "downloading", label, bytes.len() as u64, total); + Ok(bytes) +} + +/// The updater plugin's check, verbatim: the pubkey and the signature are +/// base64 wrappings of minisign's text formats. +fn verify_signature(data: &[u8], signature_b64: &str) -> Result<(), String> { + let decode = |s: &str| -> Result { + let raw = base64::engine::general_purpose::STANDARD + .decode(s.trim()) + .map_err(|e| format!("signature is not base64: {e}"))?; + String::from_utf8(raw).map_err(|_| "signature is not text".to_string()) + }; + let public_key = minisign_verify::PublicKey::decode(&decode(pubkey::PUBKEY)?) + .map_err(|e| format!("public key: {e}"))?; + let signature = minisign_verify::Signature::decode(&decode(signature_b64)?) + .map_err(|e| format!("signature: {e}"))?; + public_key + .verify(data, &signature, true) + .map_err(|e| format!("the installer does not match its signature: {e}")) +} + +// ---------------------------------------------------------------- commands + +#[derive(Serialize)] +struct Defaults { + version: String, + music_dir: String, + library_dir: String, + library_layout: String, + glass_mode: bool, + loudness: bool, + has_token: bool, + installed_version: Option, + install_location: Option, +} + +#[tauri::command] +fn defaults() -> Defaults { + let cfg = load_config(); + let music = music_dir(); + let library_dir = cfg + .get("download_dir") + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) + .map(String::from) + .unwrap_or_else(|| { + // The app's own default, legacy name honoured. + let legacy = music.join("SoulSeek+"); + if legacy.is_dir() { legacy } else { music.join("SoulStream") } + .to_string_lossy() + .into_owned() + }); + let library_layout = cfg + .get("library_layout") + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) + .map(String::from) + .unwrap_or_else(|| "{artist}\\{album}".into()); + Defaults { + version: env!("CARGO_PKG_VERSION").into(), + music_dir: music.to_string_lossy().into_owned(), + library_dir, + library_layout, + glass_mode: cfg.get("glass_mode").and_then(|v| v.as_bool()).unwrap_or(false), + loudness: cfg.get("volume_normalization").and_then(|v| v.as_bool()).unwrap_or(true), + has_token: saved_key().is_some(), + installed_version: installed_version(), + install_location: install_location().map(|p| p.to_string_lossy().into_owned()), + } +} + +/// The gate. A key that can read the release is saved to the Credential +/// Manager and answered with what it would install; any other is refused +/// and nothing is kept. +#[tauri::command] +async fn verify_key(token: String) -> Result { + let t = token.trim().to_string(); + if t.is_empty() { + return Err("Paste your alpha key first.".into()); + } + let release = fetch_release(&t).await?; + let e = keyring_entry().ok_or("the Credential Manager is not available")?; + e.set_password(&t).map_err(|e| format!("the Credential Manager refused the key: {e}"))?; + Ok(release) +} + +/// A key saved by an earlier run, re-checked: still good, or gone. +#[tauri::command] +async fn check_saved_key() -> Result, String> { + let Some(t) = saved_key() else { return Ok(None) }; + match fetch_release(&t).await { + Ok(r) => Ok(Some(r)), + Err(e) => { + if let Some(entry) = keyring_entry() { + let _ = entry.delete_credential(); + } + Err(format!("The key saved on this computer no longer works — {e}")) + } + } +} + +#[tauri::command] +async fn pick_folder() -> Option { + tauri::async_runtime::spawn_blocking(|| { + rfd::FileDialog::new() + .set_title("Where your music lives") + .pick_folder() + .map(|p| p.to_string_lossy().into_owned()) + }) + .await + .ok() + .flatten() +} + +/// The choices, merged into the app's config.json (everything else in it is +/// kept), and the first-run flag set: the tour asked its questions here. +#[tauri::command] +fn write_config( + library_dir: String, + library_layout: String, + glass_mode: bool, + loudness: bool, +) -> Result<(), String> { + let mut cfg = load_config(); + let lib = library_dir.trim(); + if !lib.is_empty() { + cfg.insert("download_dir".into(), lib.into()); + } + let lay = library_layout.trim(); + if !lay.is_empty() { + cfg.insert("library_layout".into(), lay.into()); + } + cfg.insert("glass_mode".into(), glass_mode.into()); + cfg.insert("volume_normalization".into(), loudness.into()); + cfg.insert("setup_done".into(), true.into()); + if !cfg.contains_key("update_channel") { + cfg.insert("update_channel".into(), "stable".into()); + } + save_config(&cfg) +} + +/// Download the latest release with the saved key, verify it, run it +/// silently. Phases go to the window; the result is the install folder. +#[tauri::command] +async fn install(app: AppHandle) -> Result { + let run = async { + let token = saved_key().ok_or("No alpha key is saved — go back and verify one.")?; + phase(&app, "resolving", "Asking GitHub for the latest release…", 0, 0); + let release = fetch_release(&token).await?; + let sig_url = release + .sig_url + .clone() + .ok_or("The release carries no signature; refusing to install it.")?; + let label = format!("SoulStream {} · {:.1} MB", release.version, release.installer_size as f64 / 1e6); + let bytes = download(&app, &token, &release.installer_url, release.installer_size, &label).await?; + phase(&app, "verifying", "Checking the installer's signature…", bytes.len() as u64, bytes.len() as u64); + let sig = download(&app, &token, &sig_url, 0, "signature").await?; + let sig = String::from_utf8(sig).map_err(|_| "the signature is not text".to_string())?; + verify_signature(&bytes, &sig)?; + let dir = std::env::temp_dir().join("SoulStream-Setup"); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let exe = dir.join(&release.installer_name); + std::fs::write(&exe, &bytes).map_err(|e| format!("could not write the installer: {e}"))?; + phase(&app, "installing", "Copying SoulStream into place…", 0, 0); + let status = tauri::async_runtime::spawn_blocking(move || Command::new(&exe).arg("/S").status()) + .await + .map_err(|e| e.to_string())? + .map_err(|e| format!("could not start the installer: {e}"))?; + if !status.success() { + return Err(format!("the installer ended with code {}", status.code().unwrap_or(-1))); + } + let loc = install_location().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default(); + Ok::(loc) + }; + match run.await { + Ok(loc) => { + phase(&app, "done", loc.clone(), 0, 0); + Ok(loc) + } + Err(e) => { + phase(&app, "failed", e.clone(), 0, 0); + Err(e) + } + } +} + +/// Open the installed app and leave. +#[tauri::command] +fn launch(app: AppHandle) -> Result<(), String> { + let exe = app_exe().ok_or("SoulStream is not installed here")?; + Command::new(&exe) + .current_dir(exe.parent().unwrap_or(&exe)) + .spawn() + .map_err(|e| format!("could not start SoulStream: {e}"))?; + app.exit(0); + Ok(()) +} + +#[tauri::command] +fn quit(app: AppHandle) { + app.exit(0); +} + +fn main() { + if !webview2_present() { + // Nothing here can draw without it, and neither can the app. + rfd::MessageDialog::new() + .set_title("SoulStream Setup") + .set_description("SoulStream needs Microsoft Edge WebView2, which this computer does not have. Install it from Microsoft, then run Setup again.") + .show(); + return; + } + tauri::Builder::default() + .invoke_handler(tauri::generate_handler![ + defaults, + verify_key, + check_saved_key, + pick_folder, + write_config, + install, + launch, + quit + ]) + .run({ + let mut context = tauri::generate_context!(); + // Driving the window from a probe, debug builds only. + #[cfg(debug_assertions)] + if let Ok(port) = std::env::var("SOULSTREAM_SETUP_CDP") { + for w in &mut context.config_mut().app.windows { + w.additional_browser_args = Some(format!("--remote-debugging-port={port}")); + } + } + context + }) + .expect("SoulStream Setup could not start"); +} diff --git a/setup/src-tauri/tauri.conf.json b/setup/src-tauri/tauri.conf.json new file mode 100644 index 0000000..f891bea --- /dev/null +++ b/setup/src-tauri/tauri.conf.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "SoulStream Setup", + "version": "1.8.0-alpha", + "identifier": "com.soulstream.setup", + "build": { + "beforeDevCommand": "npx vite --config vite.setup.config.js", + "devUrl": "http://localhost:1425", + "beforeBuildCommand": "npx vite build --config vite.setup.config.js", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "label": "main", + "title": "SoulStream Setup", + "width": 800, + "height": 580, + "resizable": false, + "maximizable": false, + "decorations": false, + "center": true, + "backgroundColor": "#121212" + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": false, + "icon": [ + "../../src-tauri/icons/32x32.png", + "../../src-tauri/icons/128x128.png", + "../../src-tauri/icons/icon.ico" + ] + } +} diff --git a/setup/src/Setup.svelte b/setup/src/Setup.svelte new file mode 100644 index 0000000..d4d5256 --- /dev/null +++ b/setup/src/Setup.svelte @@ -0,0 +1,524 @@ + + + + + + + diff --git a/setup/src/main.js b/setup/src/main.js new file mode 100644 index 0000000..3311d33 --- /dev/null +++ b/setup/src/main.js @@ -0,0 +1,7 @@ +// SoulStream Setup's page: the app's own stylesheet (tokens, fonts, buttons) +// and one component, the tour as an installer. +import "../../src/styles/global.css"; +import { mount } from "svelte"; +import Setup from "./Setup.svelte"; + +mount(Setup, { target: document.getElementById("app") }); diff --git a/src/components/FirstRun.svelte b/src/components/FirstRun.svelte index 4ef11ea..1045782 100644 --- a/src/components/FirstRun.svelte +++ b/src/components/FirstRun.svelte @@ -27,6 +27,7 @@ const STEPS = [ { id: "welcome", label: "Welcome", icon: "sparkle" }, + { id: "alpha", label: "Alpha key", icon: "key" }, { id: "library", label: "Your library", icon: "library" }, { id: "soulseek", label: "Soulseek", icon: "users" }, { id: "community", label: "Community", icon: "globe" }, @@ -53,6 +54,10 @@ let displayName = $state(""); let glass = $state(false); let loudness = $state(true); + // The closed alpha's read-only release key (updater.rs): typed here, + // kept by the backend in the Credential Manager, never read back. + let alphaKey = $state(""); + let hasToken = $state(false); let busy = $state(false); let error = $state(""); let closing = $state(false); @@ -90,6 +95,11 @@ } catch { freenetOn = false; } + try { + hasToken = !!(await api.updaterStatus()).has_token; + } catch { + hasToken = false; + } await tick(); panelEl?.querySelector(".primary")?.focus(); } @@ -132,7 +142,25 @@ async function next() { if (busy || closing) return; - if (cur.id === "library") { + if (cur.id === "alpha") { + const k = alphaKey.trim(); + if (k) { + busy = true; + error = ""; + try { + await api.updaterSet({ token: k }); + hasToken = true; + alphaKey = ""; + // The first check can start now; the toast reports it. + void api.updaterCheck().catch(() => {}); + } catch (e) { + error = String(e); + } finally { + busy = false; + } + if (error) return; + } + } else if (cur.id === "library") { const patch = {}; if (libraryDir && libraryDir !== settings?.download_dir) patch.downloadDir = libraryDir; const lay = layout.trim(); @@ -274,11 +302,28 @@ that runs on a peer you own. Four short questions and the app is yours.

    - {#each STEPS.slice(1, 5) as s (s.id)} + {#each STEPS.slice(1, 6) as s (s.id)}
  • {s.label}
  • {/each}
+ {:else if cur.id === "alpha"} +

Your alpha key

+

+ SoulStream is in a closed alpha. New builds come from a private repository, and + this key — one per tester, able only to read them — is how your copy finds + updates. It goes to the Windows Credential Manager, not a plain file. Without it + the app runs, but cannot update itself. +

+
+
+
+
Alpha key {#if hasToken}set{/if}
+
{hasToken ? "A key is saved on this computer. Paste another to replace it, or press Next to keep it." : "Paste the key you were given. It starts with github_pat_."}
+
+
+
+
{:else if cur.id === "library"}

Where does your music live?

@@ -400,6 +445,7 @@

Here is what you chose. Every line of it lives in Settings.

    {#each [ + ["key", "Alpha key", hasToken ? "set" : "not set — updates need it"], ["folder-open", "Library", libraryDir || "not chosen"], ["users", "Soulseek", online ? `signed in as ${app.username}` : "not signed in yet"], ["globe", "Community peer", freenetOn ? (displayName.trim() ? `on · ${displayName.trim()}` : "on") : "off"], @@ -430,7 +476,7 @@ {:else if cur.id === "ready"} {:else} - {#if cur.id === "soulseek" && !online} + {#if (cur.id === "soulseek" && !online) || (cur.id === "alpha" && !hasToken && !alphaKey.trim())} {/if} diff --git a/src/components/UpdateToast.svelte b/src/components/UpdateToast.svelte index bd1937a..925d018 100644 --- a/src/components/UpdateToast.svelte +++ b/src/components/UpdateToast.svelte @@ -3,17 +3,34 @@ // the other toasts, but it STAYS until answered — Install here, or Later // (the Settings card keeps the offer). Installing on its own was never on // the table; this is the button the updater's note talks about. + // + // Its other face, for the closed alpha: a copy whose check failed for + // want of the read-only key says so once per session, with the way to + // Settings — a tester who skipped the tour's key step still finds out. import Icon from "./Icon.svelte"; - import { updater, installUpdate, dismissUpdate } from "../lib/updater.svelte.js"; + import { goToView } from "../lib/state.svelte.js"; + import { updater, installUpdate, dismissUpdate, dismissNudge } from "../lib/updater.svelte.js"; const available = $derived(updater.status?.available ?? null); const installing = $derived(!!updater.status?.installing); const shown = $derived(!!available && !updater.dismissed); + const needsKey = $derived( + !available + && !!updater.status + && !updater.status.has_token + && /access token/.test(updater.status.last_error ?? "") + && !updater.nudgeDismissed, + ); const pct = $derived.by(() => { const p = updater.progress; if (!p || !p.total) return null; return Math.min(100, Math.round((p.received / p.total) * 100)); }); + + function openSettings() { + dismissNudge(); + goToView("settings"); + } {#if shown} @@ -30,6 +47,15 @@ {/if} +{:else if needsKey} +
    +
    + + Updates need your alpha key + + +
    +
    {/if}