setup: the installer is our own window — the alpha key is the gate, the release comes down with it and is verified before the silent install, and the in-app tour asks for the key too

This commit is contained in:
KeelrLeek committed 2026-09-09 18:00:02 -05:00
1 parent 8093835482
commit f8508da081
17 files changed
+1960 -13

No files matched your search

+22
View File
@@ -108,3 +108,25 @@ jobs:
console.log(JSON.stringify(m, null, 2)); console.log(JSON.stringify(m, null, 2));
' '
gh release upload "$TAG" latest.json --repo "$TARGET" --clobber 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
+22
View File
@@ -91,3 +91,25 @@ jobs:
console.log(JSON.stringify(m, null, 2)); console.log(JSON.stringify(m, null, 2));
' '
gh release upload "$TAG" latest.json --repo "$TARGET" --clobber 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
+4
View File
@@ -19,3 +19,7 @@ GAMEPLAN.md
# Logo/icon kits — worked on outside this repo, not shipped from it # Logo/icon kits — worked on outside this repo, not shipped from it
branding/ branding/
# the setup app frontend build (vite.setup.config.js)
setup/dist/
setup/src-tauri/gen/
Generated
+688 -9
View File
@@ -212,6 +212,181 @@ version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" 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]] [[package]]
name = "atk" name = "atk"
version = "0.18.2" version = "0.18.2"
@@ -360,6 +535,19 @@ dependencies = [
"objc2", "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]] [[package]]
name = "brotli" name = "brotli"
version = "8.0.4" version = "8.0.4"
@@ -602,6 +790,15 @@ dependencies = [
"memchr", "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]] [[package]]
name = "const-oid" name = "const-oid"
version = "0.9.6" version = "0.9.6"
@@ -1029,13 +1226,34 @@ dependencies = [
"ctutils", "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]] [[package]]
name = "dirs" name = "dirs"
version = "6.0.0" version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [ 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]] [[package]]
@@ -1046,7 +1264,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [ dependencies = [
"libc", "libc",
"option-ext", "option-ext",
"redox_users", "redox_users 0.5.2",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -1073,6 +1291,15 @@ dependencies = [
"syn 3.0.4", "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]] [[package]]
name = "dlopen2" name = "dlopen2"
version = "0.8.2" version = "0.8.2"
@@ -1111,6 +1338,12 @@ dependencies = [
"tendril", "tendril",
] ]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]] [[package]]
name = "dpi" name = "dpi"
version = "0.1.2" version = "0.1.2"
@@ -1204,7 +1437,7 @@ dependencies = [
"rustc_version", "rustc_version",
"toml 1.1.5+spec-1.1.0", "toml 1.1.5+spec-1.1.0",
"vswhom", "vswhom",
"winreg", "winreg 0.55.0",
] ]
[[package]] [[package]]
@@ -1222,6 +1455,33 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "env_filter" name = "env_filter"
version = "0.1.4" version = "0.1.4"
@@ -1282,6 +1542,26 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "extended" name = "extended"
version = "0.1.0" version = "0.1.0"
@@ -1542,6 +1822,19 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" 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]] [[package]]
name = "futures-macro" name = "futures-macro"
version = "0.3.34" version = "0.3.34"
@@ -1934,6 +2227,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
[[package]] [[package]]
name = "hex" name = "hex"
version = "0.4.3" version = "0.4.3"
@@ -3310,6 +3609,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 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]] [[package]]
name = "osakit" name = "osakit"
version = "0.3.1" version = "0.3.1"
@@ -3349,6 +3658,12 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -3455,6 +3770,17 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" 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]] [[package]]
name = "pkcs8" name = "pkcs8"
version = "0.10.2" version = "0.10.2"
@@ -3523,6 +3849,26 @@ dependencies = [
"miniz_oxide 0.8.9", "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]] [[package]]
name = "poly1305" name = "poly1305"
version = "0.8.0" version = "0.8.0"
@@ -3804,6 +4150,17 @@ dependencies = [
"bitflags 2.13.1", "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]] [[package]]
name = "redox_users" name = "redox_users"
version = "0.5.2" version = "0.5.2"
@@ -3943,6 +4300,30 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "rfd" name = "rfd"
version = "0.16.0" version = "0.16.0"
@@ -4203,6 +4584,12 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@@ -4480,6 +4867,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" 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]] [[package]]
name = "signature" name = "signature"
version = "2.2.0" version = "2.2.0"
@@ -4586,6 +4983,23 @@ dependencies = [
"thiserror 2.0.20", "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]] [[package]]
name = "soup3" name = "soup3"
version = "0.5.0" version = "0.5.0"
@@ -5062,7 +5476,7 @@ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
"cookie", "cookie",
"dirs", "dirs 6.0.0",
"dunce", "dunce",
"embed_plist", "embed_plist",
"getrandom 0.3.4", "getrandom 0.3.4",
@@ -5112,7 +5526,7 @@ checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cargo_toml", "cargo_toml",
"dirs", "dirs 6.0.0",
"glob", "glob",
"heck 0.5.0", "heck 0.5.0",
"json-patch", "json-patch",
@@ -5190,7 +5604,7 @@ checksum = "61854a36651aa48381e5e209f69a01273b77f3f9f91f0c430b1b98d33bd47229"
dependencies = [ dependencies = [
"log", "log",
"raw-window-handle", "raw-window-handle",
"rfd", "rfd 0.16.0",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
@@ -5252,7 +5666,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b28d8cabdeb0564f03ae261963de4bc3d98321cd3d213e76a81b7d344e5df606" checksum = "b28d8cabdeb0564f03ae261963de4bc3d98321cd3d213e76a81b7d344e5df606"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"dirs", "dirs 6.0.0",
"flate2", "flate2",
"futures-util", "futures-util",
"http 1.5.0", "http 1.5.0",
@@ -5822,7 +6236,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e"
dependencies = [ dependencies = [
"crossbeam-channel", "crossbeam-channel",
"dirs", "dirs 6.0.0",
"libappindicator", "libappindicator",
"muda", "muda",
"objc2", "objc2",
@@ -5872,6 +6286,17 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" 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]] [[package]]
name = "unic-char-property" name = "unic-char-property"
version = "0.9.0" version = "0.9.0"
@@ -5954,6 +6379,12 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "urlpattern" name = "urlpattern"
version = "0.3.0" version = "0.3.0"
@@ -6144,6 +6575,66 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.104" version = "0.3.104"
@@ -6533,6 +7024,15 @@ dependencies = [
"windows-targets 0.42.2", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
@@ -6584,6 +7084,21 @@ dependencies = [
"windows_x86_64_msvc 0.42.2", "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]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.52.6" version = "0.52.6"
@@ -6641,6 +7156,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.52.6" version = "0.52.6"
@@ -6659,6 +7180,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.6" version = "0.52.6"
@@ -6677,6 +7204,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.6" version = "0.52.6"
@@ -6707,6 +7240,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.6" version = "0.52.6"
@@ -6725,6 +7264,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" 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]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.6" version = "0.52.6"
@@ -6743,6 +7288,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" 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]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.6" version = "0.52.6"
@@ -6761,6 +7312,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" 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]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"
@@ -6797,6 +7354,16 @@ dependencies = [
"memchr", "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]] [[package]]
name = "winreg" name = "winreg"
version = "0.55.0" version = "0.55.0"
@@ -6829,7 +7396,7 @@ dependencies = [
"block2", "block2",
"cookie", "cookie",
"crossbeam-channel", "crossbeam-channel",
"dirs", "dirs 6.0.0",
"dom_query", "dom_query",
"dpi", "dpi",
"dunce", "dunce",
@@ -6944,6 +7511,76 @@ dependencies = [
"synstructure", "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]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.56" version = "0.8.56"
@@ -7076,3 +7713,45 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [ dependencies = [
"zune-core", "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",
]
+1
View File
@@ -2,6 +2,7 @@
resolver = "2" resolver = "2"
members = [ members = [
"src-tauri", "src-tauri",
"setup/src-tauri",
"crates/soulseek-proto", "crates/soulseek-proto",
"crates/soulseek-client", "crates/soulseek-client",
"crates/player", "crates/player",
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SoulStream Setup</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+31
View File
@@ -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"
+19
View File
@@ -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();
}
+12
View File
@@ -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"
]
}
+485
View File
@@ -0,0 +1,485 @@
//! SoulStream Setup — the file a tester runs. Our own window in the app's
//! stack (a Tauri webview drawing the first-run tour's design). The alpha
//! key is the gate: it is checked against GitHub first, and only a key that
//! can read the private repository gets any further. The installer is then
//! DOWNLOADED from the latest release with that key (never carried inside
//! this exe, where it could be pulled out), its signature verified against
//! the same public key the app's updater trusts, and run silently. The key
//! goes to the Credential Manager where the app reads it, the choices into
//! the app's config.json, and the app is launched.
//!
//! Without WebView2 this window cannot draw. main() checks the runtime
//! first and, if it is missing, says so and leaves — the app itself needs
//! WebView2 too, so there is nothing to install on such a machine yet.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod pubkey {
include!(concat!(env!("OUT_DIR"), "/pubkey.rs"));
}
use std::path::PathBuf;
use std::process::Command;
use std::time::Duration;
use base64::Engine;
use serde::Serialize;
use tauri::{AppHandle, Emitter};
const REPO: &str = "KeelrLeek/SoulStream";
const APP_DATA_DIRNAME: &str = "com.soulstream.app";
const KEYRING_SERVICE: &str = "com.soulstream.app";
const KEYRING_TOKEN_USER: &str = "updater-token";
const USER_AGENT: &str = concat!("SoulStream-Setup/", env!("CARGO_PKG_VERSION"));
fn app_data_dir() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join(APP_DATA_DIRNAME))
}
fn config_path() -> Option<PathBuf> {
app_data_dir().map(|d| d.join("config.json"))
}
fn load_config() -> serde_json::Map<String, serde_json::Value> {
config_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v.as_object().cloned())
.unwrap_or_default()
}
fn save_config(cfg: &serde_json::Map<String, serde_json::Value>) -> Result<(), String> {
let p = config_path().ok_or("no app data folder")?;
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
let json = serde_json::to_string_pretty(&serde_json::Value::Object(cfg.clone()))
.map_err(|e| e.to_string())?;
std::fs::write(&p, json).map_err(|e| e.to_string())
}
fn music_dir() -> PathBuf {
dirs::audio_dir()
.or_else(dirs::home_dir)
.unwrap_or_else(|| PathBuf::from("."))
}
fn keyring_entry() -> Option<keyring::Entry> {
keyring::Entry::new(KEYRING_SERVICE, KEYRING_TOKEN_USER).ok()
}
fn saved_key() -> Option<String> {
keyring_entry()
.and_then(|e| e.get_password().ok())
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
}
/// Where the installer put the app, from the uninstall entry it registers;
/// the per-user default when there is none yet.
fn install_location() -> Option<PathBuf> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
if let Ok(uninstall) = hkcu.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall") {
for name in uninstall.enum_keys().flatten() {
if let Ok(k) = uninstall.open_subkey(&name) {
let display: String = k.get_value("DisplayName").unwrap_or_default();
if display == "SoulStream" {
if let Ok(loc) = k.get_value::<String, _>("InstallLocation") {
let loc = loc.trim_matches('"');
if !loc.is_empty() {
return Some(PathBuf::from(loc));
}
}
}
}
}
}
dirs::data_local_dir().map(|d| d.join("SoulStream"))
}
fn installed_version() -> Option<String> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let uninstall = RegKey::predef(HKEY_CURRENT_USER)
.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall")
.ok()?;
for name in uninstall.enum_keys().flatten() {
if let Ok(k) = uninstall.open_subkey(&name) {
let display: String = k.get_value("DisplayName").unwrap_or_default();
if display == "SoulStream" {
return k.get_value::<String, _>("DisplayVersion").ok();
}
}
}
None
}
fn app_exe() -> Option<PathBuf> {
let dir = install_location()?;
for name in ["SoulStream.exe", "app.exe"] {
let p = dir.join(name);
if p.is_file() {
return Some(p);
}
}
None
}
fn webview2_present() -> bool {
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
use winreg::RegKey;
const CLIENT: &str = r"Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}";
const CLIENT_WOW: &str = r"SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}";
for (hive, path) in [
(HKEY_LOCAL_MACHINE, CLIENT_WOW),
(HKEY_LOCAL_MACHINE, CLIENT),
(HKEY_CURRENT_USER, CLIENT),
] {
if let Ok(k) = RegKey::predef(hive).open_subkey(path) {
if let Ok(pv) = k.get_value::<String, _>("pv") {
if !pv.trim().is_empty() && pv.trim() != "0.0.0.0" {
return true;
}
}
}
}
false
}
// ---------------------------------------------------------------- GitHub
/// The latest release as the key can see it: the installer and its
/// signature, by their API asset URLs (a private repository's assets are
/// reachable only that way).
#[derive(Serialize, Clone)]
struct Release {
version: String,
name: String,
published: Option<String>,
installer_name: String,
installer_size: u64,
installer_url: String,
sig_url: Option<String>,
}
fn client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.user_agent(USER_AGENT)
.build()
.map_err(|e| e.to_string())
}
async fn fetch_release(token: &str) -> Result<Release, String> {
let resp = client()?
.get(format!("https://api.github.com/repos/{REPO}/releases/latest"))
.bearer_auth(token)
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.await
.map_err(|e| format!("Could not reach GitHub — is this computer online? ({e})"))?;
match resp.status().as_u16() {
200 => {}
401 => return Err("That key was refused.".into()),
403 => return Err("That key cannot read the SoulStream repository.".into()),
404 => return Err("No release found — or that key cannot see the repository.".into()),
s => return Err(format!("GitHub answered {s}.")),
}
let v: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
let tag = v["tag_name"].as_str().unwrap_or("");
let assets = v["assets"].as_array().cloned().unwrap_or_default();
let find = |suffix: &str| {
assets
.iter()
.find(|a| a["name"].as_str().map_or(false, |n| n.ends_with(suffix)))
.cloned()
};
let installer = find("_x64-setup.exe").ok_or("The release carries no installer.")?;
let sig = find("_x64-setup.exe.sig");
Ok(Release {
version: tag.trim_start_matches('v').to_string(),
name: v["name"].as_str().unwrap_or(tag).to_string(),
published: v["published_at"].as_str().map(String::from),
installer_name: installer["name"].as_str().unwrap_or("SoulStream-setup.exe").to_string(),
installer_size: installer["size"].as_u64().unwrap_or(0),
installer_url: installer["url"].as_str().unwrap_or("").to_string(),
sig_url: sig.and_then(|a| a["url"].as_str().map(String::from)),
})
}
#[derive(Serialize, Clone)]
struct Phase {
phase: String,
detail: String,
received: u64,
total: u64,
}
fn phase(app: &AppHandle, name: &str, detail: impl Into<String>, received: u64, total: u64) {
let _ = app.emit(
"setup:phase",
Phase { phase: name.into(), detail: detail.into(), received, total },
);
}
/// An asset, streamed down with the key; progress goes to the window.
async fn download(app: &AppHandle, token: &str, url: &str, total: u64, label: &str) -> Result<Vec<u8>, String> {
let mut resp = client()?
.get(url)
.bearer_auth(token)
.header(reqwest::header::ACCEPT, "application/octet-stream")
.send()
.await
.map_err(|e| format!("The download failed to start: {e}"))?;
if !resp.status().is_success() {
return Err(format!("The download was refused ({}).", resp.status().as_u16()));
}
let total = resp.content_length().unwrap_or(total);
let mut bytes = Vec::with_capacity(total as usize);
let mut last = std::time::Instant::now();
while let Some(chunk) = resp.chunk().await.map_err(|e| format!("The download broke off: {e}"))? {
bytes.extend_from_slice(&chunk);
if last.elapsed() >= Duration::from_millis(120) {
last = std::time::Instant::now();
phase(app, "downloading", label, bytes.len() as u64, total);
}
}
phase(app, "downloading", label, bytes.len() as u64, total);
Ok(bytes)
}
/// The updater plugin's check, verbatim: the pubkey and the signature are
/// base64 wrappings of minisign's text formats.
fn verify_signature(data: &[u8], signature_b64: &str) -> Result<(), String> {
let decode = |s: &str| -> Result<String, String> {
let raw = base64::engine::general_purpose::STANDARD
.decode(s.trim())
.map_err(|e| format!("signature is not base64: {e}"))?;
String::from_utf8(raw).map_err(|_| "signature is not text".to_string())
};
let public_key = minisign_verify::PublicKey::decode(&decode(pubkey::PUBKEY)?)
.map_err(|e| format!("public key: {e}"))?;
let signature = minisign_verify::Signature::decode(&decode(signature_b64)?)
.map_err(|e| format!("signature: {e}"))?;
public_key
.verify(data, &signature, true)
.map_err(|e| format!("the installer does not match its signature: {e}"))
}
// ---------------------------------------------------------------- commands
#[derive(Serialize)]
struct Defaults {
version: String,
music_dir: String,
library_dir: String,
library_layout: String,
glass_mode: bool,
loudness: bool,
has_token: bool,
installed_version: Option<String>,
install_location: Option<String>,
}
#[tauri::command]
fn defaults() -> Defaults {
let cfg = load_config();
let music = music_dir();
let library_dir = cfg
.get("download_dir")
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.map(String::from)
.unwrap_or_else(|| {
// The app's own default, legacy name honoured.
let legacy = music.join("SoulSeek+");
if legacy.is_dir() { legacy } else { music.join("SoulStream") }
.to_string_lossy()
.into_owned()
});
let library_layout = cfg
.get("library_layout")
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.map(String::from)
.unwrap_or_else(|| "{artist}\\{album}".into());
Defaults {
version: env!("CARGO_PKG_VERSION").into(),
music_dir: music.to_string_lossy().into_owned(),
library_dir,
library_layout,
glass_mode: cfg.get("glass_mode").and_then(|v| v.as_bool()).unwrap_or(false),
loudness: cfg.get("volume_normalization").and_then(|v| v.as_bool()).unwrap_or(true),
has_token: saved_key().is_some(),
installed_version: installed_version(),
install_location: install_location().map(|p| p.to_string_lossy().into_owned()),
}
}
/// The gate. A key that can read the release is saved to the Credential
/// Manager and answered with what it would install; any other is refused
/// and nothing is kept.
#[tauri::command]
async fn verify_key(token: String) -> Result<Release, String> {
let t = token.trim().to_string();
if t.is_empty() {
return Err("Paste your alpha key first.".into());
}
let release = fetch_release(&t).await?;
let e = keyring_entry().ok_or("the Credential Manager is not available")?;
e.set_password(&t).map_err(|e| format!("the Credential Manager refused the key: {e}"))?;
Ok(release)
}
/// A key saved by an earlier run, re-checked: still good, or gone.
#[tauri::command]
async fn check_saved_key() -> Result<Option<Release>, String> {
let Some(t) = saved_key() else { return Ok(None) };
match fetch_release(&t).await {
Ok(r) => Ok(Some(r)),
Err(e) => {
if let Some(entry) = keyring_entry() {
let _ = entry.delete_credential();
}
Err(format!("The key saved on this computer no longer works — {e}"))
}
}
}
#[tauri::command]
async fn pick_folder() -> Option<String> {
tauri::async_runtime::spawn_blocking(|| {
rfd::FileDialog::new()
.set_title("Where your music lives")
.pick_folder()
.map(|p| p.to_string_lossy().into_owned())
})
.await
.ok()
.flatten()
}
/// The choices, merged into the app's config.json (everything else in it is
/// kept), and the first-run flag set: the tour asked its questions here.
#[tauri::command]
fn write_config(
library_dir: String,
library_layout: String,
glass_mode: bool,
loudness: bool,
) -> Result<(), String> {
let mut cfg = load_config();
let lib = library_dir.trim();
if !lib.is_empty() {
cfg.insert("download_dir".into(), lib.into());
}
let lay = library_layout.trim();
if !lay.is_empty() {
cfg.insert("library_layout".into(), lay.into());
}
cfg.insert("glass_mode".into(), glass_mode.into());
cfg.insert("volume_normalization".into(), loudness.into());
cfg.insert("setup_done".into(), true.into());
if !cfg.contains_key("update_channel") {
cfg.insert("update_channel".into(), "stable".into());
}
save_config(&cfg)
}
/// Download the latest release with the saved key, verify it, run it
/// silently. Phases go to the window; the result is the install folder.
#[tauri::command]
async fn install(app: AppHandle) -> Result<String, String> {
let run = async {
let token = saved_key().ok_or("No alpha key is saved — go back and verify one.")?;
phase(&app, "resolving", "Asking GitHub for the latest release…", 0, 0);
let release = fetch_release(&token).await?;
let sig_url = release
.sig_url
.clone()
.ok_or("The release carries no signature; refusing to install it.")?;
let label = format!("SoulStream {} · {:.1} MB", release.version, release.installer_size as f64 / 1e6);
let bytes = download(&app, &token, &release.installer_url, release.installer_size, &label).await?;
phase(&app, "verifying", "Checking the installer's signature…", bytes.len() as u64, bytes.len() as u64);
let sig = download(&app, &token, &sig_url, 0, "signature").await?;
let sig = String::from_utf8(sig).map_err(|_| "the signature is not text".to_string())?;
verify_signature(&bytes, &sig)?;
let dir = std::env::temp_dir().join("SoulStream-Setup");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let exe = dir.join(&release.installer_name);
std::fs::write(&exe, &bytes).map_err(|e| format!("could not write the installer: {e}"))?;
phase(&app, "installing", "Copying SoulStream into place…", 0, 0);
let status = tauri::async_runtime::spawn_blocking(move || Command::new(&exe).arg("/S").status())
.await
.map_err(|e| e.to_string())?
.map_err(|e| format!("could not start the installer: {e}"))?;
if !status.success() {
return Err(format!("the installer ended with code {}", status.code().unwrap_or(-1)));
}
let loc = install_location().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
Ok::<String, String>(loc)
};
match run.await {
Ok(loc) => {
phase(&app, "done", loc.clone(), 0, 0);
Ok(loc)
}
Err(e) => {
phase(&app, "failed", e.clone(), 0, 0);
Err(e)
}
}
}
/// Open the installed app and leave.
#[tauri::command]
fn launch(app: AppHandle) -> Result<(), String> {
let exe = app_exe().ok_or("SoulStream is not installed here")?;
Command::new(&exe)
.current_dir(exe.parent().unwrap_or(&exe))
.spawn()
.map_err(|e| format!("could not start SoulStream: {e}"))?;
app.exit(0);
Ok(())
}
#[tauri::command]
fn quit(app: AppHandle) {
app.exit(0);
}
fn main() {
if !webview2_present() {
// Nothing here can draw without it, and neither can the app.
rfd::MessageDialog::new()
.set_title("SoulStream Setup")
.set_description("SoulStream needs Microsoft Edge WebView2, which this computer does not have. Install it from Microsoft, then run Setup again.")
.show();
return;
}
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
defaults,
verify_key,
check_saved_key,
pick_folder,
write_config,
install,
launch,
quit
])
.run({
let mut context = tauri::generate_context!();
// Driving the window from a probe, debug builds only.
#[cfg(debug_assertions)]
if let Ok(port) = std::env::var("SOULSTREAM_SETUP_CDP") {
for w in &mut context.config_mut().app.windows {
w.additional_browser_args = Some(format!("--remote-debugging-port={port}"));
}
}
context
})
.expect("SoulStream Setup could not start");
}
+38
View File
@@ -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"
]
}
}
+524
View File
@@ -0,0 +1,524 @@
<script>
// SoulStream Setup: the first-run tour's design, standing in for the
// Windows installer. Welcome → the alpha key (the gate: verified against
// GitHub, nothing installs without one) → the music folder → the look →
// Install (the latest release downloaded with the key, its signature
// checked, the NSIS installer run silently; phases from the backend) →
// Done, Open SoulStream. Every choice is written into the app's config
// before the install runs, the key into the Credential Manager, so the
// app opens straight into itself.
import { onMount, tick } from "svelte";
import { fly, scale } from "svelte/transition";
import { cubicOut, quintOut } from "svelte/easing";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import Icon from "../../src/components/Icon.svelte";
const STEPS = [
{ id: "welcome", label: "Welcome", icon: "sparkle" },
{ id: "key", label: "Alpha key", icon: "key" },
{ id: "library", label: "Your library", icon: "library" },
{ id: "look", label: "Look & sound", icon: "eye" },
{ id: "install", label: "Install", icon: "download" },
{ id: "done", label: "Ready", icon: "check" },
];
const PRESETS = [
["{artist}\\{album}", "Artist › Album"],
["{artist}\\{year} - {album}", "Artist › Year - Album"],
["{artist} - {album}", "Artist - Album"],
];
let step = $state(0);
let dir = $state(1);
let d = $state(null);
let alphaKey = $state("");
let hasToken = $state(false);
// What the key can see: set only by a successful check, cleared by a failed one.
let keyOk = $state(null);
let verifying = $state(false);
let libraryDir = $state("");
let layout = $state("");
let glass = $state(false);
let loudness = $state(true);
let busy = $state(false);
let error = $state("");
let phase = $state({ phase: "", detail: "", received: 0, total: 0 });
const pct = $derived(phase.total > 0 ? Math.min(100, Math.round((phase.received / phase.total) * 100)) : null);
let installedAt = $state("");
let panelEl = $state(null);
const cur = $derived(STEPS[step]);
const installing = $derived(cur.id === "install" && phase.phase !== "done" && phase.phase !== "failed");
const reduced = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
const dur = (ms) => (reduced() ? 0 : ms);
onMount(async () => {
try {
d = await invoke("defaults");
libraryDir = d.library_dir;
layout = d.library_layout;
glass = !!d.glass_mode;
loudness = d.loudness !== false;
hasToken = !!d.has_token;
} catch (e) {
error = String(e);
}
if (hasToken) {
verifying = true;
try {
keyOk = await invoke("check_saved_key");
} catch (e) {
hasToken = false;
keyOk = null;
error = String(e);
} finally {
verifying = false;
}
}
const un = await listen("setup:phase", (e) => {
phase = e.payload ?? { phase: "", detail: "", received: 0, total: 0 };
if (phase.phase === "done") {
installedAt = phase.detail;
go(1);
} else if (phase.phase === "failed") {
error = phase.detail;
}
});
await tick();
panelEl?.querySelector(".primary")?.focus();
return () => un();
});
async function go(delta) {
dir = delta;
step = Math.max(0, Math.min(STEPS.length - 1, step + delta));
await tick();
panelEl?.querySelector(".primary")?.focus();
}
async function verify() {
const k = alphaKey.trim();
if (!k || verifying) return;
verifying = true;
error = "";
try {
keyOk = await invoke("verify_key", { token: k });
hasToken = true;
alphaKey = "";
} catch (e) {
keyOk = null;
error = String(e);
} finally {
verifying = false;
}
}
async function pickDir() {
const p = await invoke("pick_folder").catch(() => null);
if (p) libraryDir = p;
}
async function next() {
if (busy || installing) return;
error = "";
if (cur.id === "key") {
if (alphaKey.trim() && !keyOk) {
await verify();
if (!keyOk) return;
}
if (!keyOk) {
error = "An accepted alpha key is needed to install.";
return;
}
}
if (cur.id === "look") {
// The choices land first, then the installer runs.
busy = true;
try {
await invoke("write_config", { libraryDir, libraryLayout: layout, glassMode: glass, loudness });
} catch (e) {
error = String(e);
} finally {
busy = false;
}
if (error) return;
await go(1);
phase = { phase: "resolving", detail: "", received: 0, total: 0 };
void invoke("install").catch((e) => {
error = String(e);
phase = { phase: "failed", detail: String(e), received: 0, total: 0 };
});
return;
}
if (cur.id === "done") return launch();
await go(1);
}
function back() {
if (step === 0 || busy || installing || cur.id === "done") return;
go(-1);
}
async function launch() {
try {
await invoke("launch");
} catch (e) {
error = String(e);
}
}
function quit() {
void invoke("quit");
}
function onKeydown(e) {
if (e.key !== "Enter" || e.shiftKey) return;
const t = e.target;
if (t instanceof HTMLButtonElement) return;
e.preventDefault();
next();
}
const win = () => getCurrentWindow();
</script>
<svelte:window onkeydown={onKeydown} />
<div class="panel" bind:this={panelEl} role="dialog" aria-modal="true" aria-labelledby="fr-title">
<div class="chrome" data-tauri-drag-region>
<span class="wtitle" data-tauri-drag-region>SoulStream Setup</span>
<button class="wbtn" onclick={() => win().minimize()} title="Minimize" aria-label="Minimize"><Icon name="minus" size={14} /></button>
<button class="wbtn x" onclick={() => (installing ? null : win().close())} title="Close" aria-label="Close" disabled={installing}><Icon name="x" size={14} /></button>
</div>
<aside class="rail" data-tauri-drag-region>
<div class="brand" data-tauri-drag-region>
<img src="/logo.png" alt="" draggable="false" />
<span>SoulStream</span>
</div>
<ol>
{#each STEPS as s, i (s.id)}
<li class:on={i === step} class:done={i < step} aria-current={i === step ? "step" : undefined}>
<span class="dot">
{#if i < step}<Icon name="check" size={11} />{:else}<Icon name={s.icon} size={12} />{/if}
</span>
<span class="lab">{s.label}</span>
</li>
{/each}
</ol>
<div class="railfoot">{d ? `SoulStream ${d.version}` : ""}<br />Everything here lives in Settings too.</div>
</aside>
<div class="stage">
<div class="steps">
{#key step}
<section
class="step"
in:fly={{ x: 36 * dir, duration: dur(380), easing: quintOut, delay: dur(60) }}
out:fly={{ x: -36 * dir, duration: dur(200), easing: cubicOut }}
>
{#if cur.id === "welcome"}
<div class="welcome">
<div class="halo" in:scale={{ start: 0.6, duration: dur(760), easing: quintOut }}>
<img class="mark" src="/logo.png" alt="" draggable="false" />
</div>
<h1 id="fr-title" in:fly={{ y: 14, duration: dur(520), delay: dur(220), easing: quintOut }}>
{d?.installed_version ? "Set SoulStream up again" : "Welcome to SoulStream"}
</h1>
<p class="lede" in:fly={{ y: 14, duration: dur(520), delay: dur(340), easing: quintOut }}>
{#if d?.installed_version}
SoulStream {d.installed_version} is already on this computer. This installs
the latest release in its place and keeps your music and settings. About a minute,
no admin prompt.
{:else}
Your library on your own disk, music from people on Soulseek, and a community
that runs on a peer you own. Your alpha key, two short questions, then about a
minute to download and install — no admin prompt, and your music never lives
inside the program.
{/if}
</p>
<ul class="pills" in:fly={{ y: 10, duration: dur(520), delay: dur(460), easing: quintOut }}>
{#each STEPS.slice(1, 4) as s (s.id)}
<li><Icon name={s.icon} size={13} /> {s.label}</li>
{/each}
</ul>
</div>
{:else if cur.id === "key"}
<h1 id="fr-title">Your alpha key</h1>
<p class="lede">
SoulStream is in a closed alpha. This key — one per tester, able only to read the
private repository — is how Setup fetches the app and how the app finds updates
later. It goes to the Windows Credential Manager, not a plain file. Nothing installs
without it.
</p>
<div class="card">
<div class="row">
<div class="label">
<div class="name">Alpha key {#if keyOk}<span class="tag ok">accepted</span>{:else if verifying}<span class="tag">checking…</span>{/if}</div>
<div class="hint">
{#if keyOk}
SoulStream <b>{keyOk.version}</b> is what it will install{#if keyOk.installer_size} — {(keyOk.installer_size / 1e6).toFixed(1)} MB{/if}. Paste another key to replace it.
{:else}
Paste the key you were given (it starts with github_pat_) and press Verify. Setup asks GitHub whether the key can read the release.
{/if}
</div>
</div>
<div class="ctl">
<input type="password" class="mono" bind:value={alphaKey} placeholder={keyOk ? "••••••••••••" : "github_pat_…"} spellcheck="false" autocomplete="off" aria-label="Alpha key" onkeydown={(e) => { if (e.key === "Enter") { e.stopPropagation(); verify(); } }} />
<button class="ax-white" onclick={verify} disabled={verifying || !alphaKey.trim()}>{verifying ? "Checking…" : "Verify"}</button>
</div>
</div>
</div>
{:else if cur.id === "library"}
<h1 id="fr-title">Where does your music live?</h1>
<p class="lede">
Downloads land in this folder, it is always shared on Soulseek, and albums are filed
into it by the format below. Files already in it are never moved.
</p>
<div class="card">
<div class="row">
<div class="label">
<div class="name">Library folder</div>
<div class="path">{libraryDir || "Not chosen yet"}</div>
</div>
<div class="ctl"><button class="ax-white" onclick={pickDir} disabled={busy}>Choose folder…</button></div>
</div>
<div class="row">
<div class="label">
<div class="name">Album folder format</div>
<div class="hint">Use {"{artist}"}, {"{album}"}, {"{date}"} or {"{year}"}; "\" starts a subfolder.</div>
<div class="presets">
{#each PRESETS as [v, l] (v)}
<button class="preset" class:on={layout.trim() === v} onclick={() => (layout = v)}>{l}</button>
{/each}
</div>
</div>
<div class="ctl"><input class="mono" bind:value={layout} spellcheck="false" autocomplete="off" aria-label="Album folder format" /></div>
</div>
</div>
{:else if cur.id === "look"}
<h1 id="fr-title">How the window is drawn</h1>
<p class="lede">Both can be changed any time in Settings.</p>
<div class="card">
<div class="row">
<div class="label">
<div class="name">Dark glass</div>
<div class="hint">Whatever is behind the window glows through the app's dark surfaces with a live blur (Windows acrylic). Menus and dialogs stay solid.</div>
</div>
<div class="ctl">
<span class="tval">{glass ? "dark glass" : "solid"}</span>
<button class="toggle" class:on={glass} role="switch" aria-checked={glass} aria-label="Dark glass" onclick={() => (glass = !glass)}><i></i></button>
</div>
</div>
<div class="row">
<div class="label">
<div class="name">Loudness normalization</div>
<div class="hint">Every track plays at roughly the same level (−14 LUFS). Off plays each file as recorded.</div>
</div>
<div class="ctl">
<span class="tval">{loudness ? "on" : "off"}</span>
<button class="toggle" class:on={loudness} role="switch" aria-checked={loudness} aria-label="Loudness normalization" onclick={() => (loudness = !loudness)}><i></i></button>
</div>
</div>
</div>
{:else if cur.id === "install"}
<h1 id="fr-title">{phase.phase === "failed" ? "That did not go through" : "Installing SoulStream"}</h1>
<p class="lede">
{#if phase.phase === "failed"}
{phase.detail}
{:else if phase.phase === "downloading"}
Downloading {phase.detail}{pct != null ? ` · ${pct}%` : "…"}
{:else if phase.phase === "verifying"}
{phase.detail}
{:else if phase.phase === "installing"}
{phase.detail} Nothing to click; this takes a moment.
{:else}
{phase.detail || "Starting…"}
{/if}
</p>
<div class="progress" class:failed={phase.phase === "failed"} class:busy={pct == null && phase.phase !== "failed"} aria-hidden="true"><i style={pct != null ? `width: ${pct}%` : ""}></i></div>
<ul class="summary">
<li><span class="ic"><Icon name="key" size={14} /></span><span class="what">Alpha key</span><b>{keyOk ? `accepted · SoulStream ${keyOk.version}` : "not set"}</b></li>
<li><span class="ic"><Icon name="folder-open" size={14} /></span><span class="what">Library</span><b class="mono">{libraryDir}</b></li>
<li><span class="ic"><Icon name="eye" size={14} /></span><span class="what">Window</span><b>{glass ? "dark glass" : "solid"}</b></li>
</ul>
{:else}
<h1 id="fr-title">SoulStream is ready</h1>
<p class="lede">Installed to <span class="mono">{installedAt}</span>. It lives in your Start menu now; your music stays where you chose.</p>
<ul class="summary">
{#each [
["key", "Alpha key", keyOk ? "accepted" : "set"],
["folder-open", "Library", libraryDir],
["eye", "Window", glass ? "dark glass" : "solid"],
["volume", "Loudness", loudness ? "normalized" : "as recorded"],
] as [icon, what, value], i (what)}
<li in:fly={{ y: 10, duration: dur(360), delay: dur(120 + i * 70), easing: quintOut }}>
<span class="ic"><Icon name={icon} size={14} /></span>
<span class="what">{what}</span>
<b class:mono={what === "Library"}>{value}</b>
</li>
{/each}
</ul>
{/if}
</section>
{/key}
</div>
<footer class="foot">
{#if step > 0 && cur.id !== "done" && !installing}
<button class="ax-ghost" onclick={back} disabled={busy}>Back</button>
{/if}
<span class="spacer"></span>
{#if error && cur.id !== "install"}<span class="err">{error}</span>{/if}
{#if cur.id === "welcome"}
<button class="ax-fill primary" onclick={next} disabled={!d}>Let's set it up <Icon name="chevron-right" size={14} /></button>
{:else if cur.id === "key"}
<button class="ax-fill primary" onclick={next} disabled={busy || verifying || !keyOk}>Next <Icon name="chevron-right" size={14} /></button>
{:else if cur.id === "look"}
<button class="ax-fill primary big" onclick={next} disabled={busy}><Icon name="download" size={13} /> {busy ? "Saving…" : "Install SoulStream"}</button>
{:else if cur.id === "install"}
{#if phase.phase === "failed"}
<button class="ax-ghost" onclick={quit}>Close</button>
<button class="ax-fill primary" onclick={() => { error = ""; phase = { phase: "resolving", detail: "", received: 0, total: 0 }; void invoke("install").catch((e) => { error = String(e); phase = { phase: "failed", detail: String(e), received: 0, total: 0 }; }); }}>Try again</button>
{/if}
{:else if cur.id === "done"}
<button class="lnk" onclick={quit}>Close</button>
<button class="ax-fill primary big" onclick={launch}>Open SoulStream</button>
{:else}
<button class="ax-fill primary" onclick={next} disabled={busy}>Next <Icon name="chevron-right" size={14} /></button>
{/if}
</footer>
</div>
</div>
<style>
:global(html, body) { height: 100%; margin: 0; background: #121212; overflow: hidden; }
:global(#app) { height: 100%; }
.panel {
height: 100vh;
display: grid;
grid-template-columns: 212px 1fr;
grid-template-rows: 30px 1fr;
background: var(--bg-elevated);
color: var(--text-primary);
--surf: var(--bg-elevated);
--surf-hi: var(--bg-highlight);
--hair: rgba(255, 255, 255, 0.07);
--ok: #3ddc84;
user-select: none;
}
/* ── our own title strip ──────────────────────────────────────── */
.chrome {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 2px;
padding: 0 4px 0 14px;
background: var(--bg-base);
border-bottom: 1px solid var(--hair);
}
.wtitle { flex: 1; font-size: 11.5px; font-weight: 800; color: var(--text-muted); letter-spacing: 0.02em; }
.wbtn { width: 34px; height: 24px; border-radius: 6px; display: grid; place-items: center; color: var(--text-secondary); }
.wbtn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.1); color: #fff; }
.wbtn.x:hover:not(:disabled) { background: #c42b1c; color: #fff; }
.wbtn:disabled { opacity: 0.35; }
/* ── the rail ─────────────────────────────────────────────────── */
.rail { display: flex; flex-direction: column; background: var(--bg-base); border-right: 1px solid var(--hair); padding: 22px 16px 18px; min-width: 0; }
.brand { display: flex; align-items: center; gap: 10px; padding: 0 6px 22px; font-size: 15px; font-weight: 900; letter-spacing: 0.01em; }
.brand img { width: 26px; height: 26px; border-radius: 7px; }
.rail ol { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.rail li { display: flex; align-items: center; gap: 10px; padding: 8px 8px; border-radius: 10px; color: var(--text-muted); font-size: 12.5px; font-weight: 700; transition: color 0.2s, background 0.2s; }
.rail li.on { color: var(--text-primary); background: rgba(255, 255, 255, 0.06); }
.rail li.done { color: var(--text-secondary); }
.dot { width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center; background: var(--surf-hi); color: var(--text-secondary); flex: none; transition: background 0.2s, color 0.2s; }
.rail li.on .dot { background: var(--accent); color: #000; }
.rail li.done .dot { background: color-mix(in srgb, var(--ok) 18%, transparent); color: var(--ok); }
.railfoot { margin-top: auto; font-size: 11px; color: var(--text-muted); line-height: 1.5; padding: 0 6px; }
/* ── the stage ────────────────────────────────────────────────── */
.stage { display: grid; grid-template-rows: 1fr auto; min-width: 0; }
.steps { position: relative; display: grid; overflow: hidden; }
.steps > .step { grid-area: 1 / 1; padding: 30px 32px 8px; overflow: auto; min-height: 0; }
h1 { font-size: 22px; font-weight: 900; letter-spacing: -0.01em; margin: 0 0 8px; line-height: 1.15; }
.lede { font-size: 13px; color: var(--text-secondary); line-height: 1.55; margin: 0 0 18px; max-width: 58ch; }
.mono { font-family: "Cascadia Code", Consolas, monospace; font-size: 12px; }
.welcome { display: flex; flex-direction: column; align-items: center; text-align: center; padding-top: 14px; }
.welcome h1 { font-size: 26px; margin-top: 18px; }
.welcome .lede { max-width: 48ch; }
.halo { position: relative; width: 108px; height: 108px; display: grid; place-items: center; }
.halo::before {
content: "";
position: absolute;
inset: -18px;
border-radius: 50%;
background: radial-gradient(circle, color-mix(in srgb, var(--accent) 55%, transparent) 0, color-mix(in srgb, var(--accent) 18%, transparent) 38%, transparent 70%);
animation: breathe 3.8s ease-in-out infinite;
}
.mark { position: relative; width: 84px; height: 84px; border-radius: 22px; box-shadow: 0 18px 40px rgba(0, 0, 0, 0.5); }
@keyframes breathe {
0%, 100% { transform: scale(1); opacity: 0.85; }
50% { transform: scale(1.1); opacity: 1; }
}
.pills { list-style: none; margin: 4px 0 0; padding: 0; display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
.pills li { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 99px; background: var(--surf-hi); font-size: 12px; font-weight: 800; color: var(--text-secondary); }
.card { background: var(--surf); border: 1px solid var(--hair); border-radius: 12px; overflow: hidden; }
.row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 16px; border-top: 1px solid var(--hair); }
.card > .row:first-child { border-top: none; }
.label { min-width: 0; flex: 1; }
.name { font-size: 13.5px; font-weight: 700; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.hint { font-size: 12px; color: var(--text-secondary); margin-top: 3px; line-height: 1.45; max-width: 60ch; }
.path { font-family: "Cascadia Code", Consolas, monospace; font-size: 11.5px; color: var(--text-secondary); word-break: break-all; margin-top: 3px; }
.ctl { flex: none; display: flex; align-items: center; gap: 8px; }
.tag { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 7px; border-radius: 99px; background: var(--accent-soft); color: var(--accent-hover); display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; }
.tag.ok { background: color-mix(in srgb, var(--ok) 14%, transparent); color: #5ee89a; }
.tval { font-size: 12px; font-weight: 800; color: var(--text-secondary); min-width: 62px; text-align: right; }
.toggle { width: 38px; height: 21px; border-radius: 99px; background: var(--surf-hi); position: relative; flex: none; transition: 0.18s; }
.toggle i { position: absolute; top: 3px; left: 3px; width: 15px; height: 15px; border-radius: 50%; background: var(--text-muted); transition: 0.18s; }
.toggle.on { background: var(--accent); }
.toggle.on i { left: 20px; background: #000; }
.presets { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
.preset { padding: 4px 10px; border-radius: 99px; font-size: 11.5px; font-weight: 800; background: var(--surf-hi); color: var(--text-secondary); }
.preset:hover { color: #fff; }
.preset.on { background: var(--accent); color: #000; }
input { height: 32px; padding: 0 10px; border-radius: 8px; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); color: var(--text-primary); font: inherit; font-size: 13px; min-width: 0; user-select: text; }
input:focus { outline: none; border-color: var(--accent); }
input.mono { font-family: "Cascadia Code", Consolas, monospace; font-size: 12px; width: 220px; }
.progress { height: 6px; border-radius: 99px; background: var(--surf-hi); overflow: hidden; margin: 6px 0 18px; max-width: 420px; }
.progress i { display: block; height: 100%; width: 0; border-radius: 99px; background: var(--accent); transition: width 0.2s; }
.progress.busy i { width: 38%; animation: slide 1.4s ease-in-out infinite; }
.progress.failed i { animation: none; width: 100%; background: #ff8d98; }
@keyframes slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(280%); }
}
.summary { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.summary li { display: grid; grid-template-columns: 28px 130px 1fr; align-items: center; gap: 8px; padding: 10px 12px; border-radius: 10px; background: var(--surf); border: 1px solid var(--hair); font-size: 13px; }
.summary .ic { display: grid; place-items: center; color: var(--accent-hover); }
.summary .what { color: var(--text-secondary); font-weight: 700; }
.summary b { font-weight: 800; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.summary b.mono { font-weight: 600; }
.foot { display: flex; align-items: center; gap: 12px; padding: 14px 24px 18px 32px; border-top: 1px solid var(--hair); }
.spacer { flex: 1; }
.err { font-size: 12px; color: #ff8d98; max-width: 40ch; }
.lnk { color: var(--text-muted); font-weight: 700; font-size: 12px; text-decoration: underline; text-underline-offset: 2px; padding: 0 6px; }
.lnk:hover:not(:disabled) { color: var(--text-primary); }
.ax-ghost { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 14px; border-radius: 99px; font-size: 12px; font-weight: 800; border: 1px solid rgba(255, 255, 255, 0.28); transition: 0.18s; white-space: nowrap; }
.ax-ghost:hover:not(:disabled) { border-color: #fff; background: rgba(255, 255, 255, 0.08); }
.ax-ghost:disabled { opacity: 0.45; cursor: default; }
.ax-fill { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 18px; border-radius: 99px; font-size: 12.5px; font-weight: 800; background: var(--accent); color: #000; white-space: nowrap; transition: background 0.18s; }
.ax-fill:hover:not(:disabled) { background: var(--accent-hover); }
.ax-fill:disabled { opacity: 0.55; cursor: default; }
.ax-fill.big { height: 38px; padding: 0 22px; font-size: 13.5px; }
.ax-white { display: inline-flex; align-items: center; height: 32px; padding: 0 16px; border-radius: 99px; font-size: 12.5px; font-weight: 800; background: #fff; color: #000; white-space: nowrap; transition: transform 0.15s; }
.ax-white:hover:not(:disabled) { transform: scale(1.03); }
.ax-white:disabled { opacity: 0.55; cursor: default; }
@media (prefers-reduced-motion: reduce) {
.halo::before, .progress i { animation: none; }
}
</style>
+7
View File
@@ -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") });
+49 -3
View File
@@ -27,6 +27,7 @@
const STEPS = [ const STEPS = [
{ id: "welcome", label: "Welcome", icon: "sparkle" }, { id: "welcome", label: "Welcome", icon: "sparkle" },
{ id: "alpha", label: "Alpha key", icon: "key" },
{ id: "library", label: "Your library", icon: "library" }, { id: "library", label: "Your library", icon: "library" },
{ id: "soulseek", label: "Soulseek", icon: "users" }, { id: "soulseek", label: "Soulseek", icon: "users" },
{ id: "community", label: "Community", icon: "globe" }, { id: "community", label: "Community", icon: "globe" },
@@ -53,6 +54,10 @@
let displayName = $state(""); let displayName = $state("");
let glass = $state(false); let glass = $state(false);
let loudness = $state(true); 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 busy = $state(false);
let error = $state(""); let error = $state("");
let closing = $state(false); let closing = $state(false);
@@ -90,6 +95,11 @@
} catch { } catch {
freenetOn = false; freenetOn = false;
} }
try {
hasToken = !!(await api.updaterStatus()).has_token;
} catch {
hasToken = false;
}
await tick(); await tick();
panelEl?.querySelector(".primary")?.focus(); panelEl?.querySelector(".primary")?.focus();
} }
@@ -132,7 +142,25 @@
async function next() { async function next() {
if (busy || closing) return; 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 = {}; const patch = {};
if (libraryDir && libraryDir !== settings?.download_dir) patch.downloadDir = libraryDir; if (libraryDir && libraryDir !== settings?.download_dir) patch.downloadDir = libraryDir;
const lay = layout.trim(); const lay = layout.trim();
@@ -274,11 +302,28 @@
that runs on a peer you own. Four short questions and the app is yours. that runs on a peer you own. Four short questions and the app is yours.
</p> </p>
<ul class="pills" in:fly={{ y: 10, duration: dur(520), delay: dur(460), easing: quintOut }}> <ul class="pills" in:fly={{ y: 10, duration: dur(520), delay: dur(460), easing: quintOut }}>
{#each STEPS.slice(1, 5) as s (s.id)} {#each STEPS.slice(1, 6) as s (s.id)}
<li><Icon name={s.icon} size={13} /> {s.label}</li> <li><Icon name={s.icon} size={13} /> {s.label}</li>
{/each} {/each}
</ul> </ul>
</div> </div>
{:else if cur.id === "alpha"}
<h1 id="fr-title">Your alpha key</h1>
<p class="lede">
SoulStream is in a closed alpha. New builds come from a private repository, and
this key — one per tester, able only to read them — is how your copy finds
updates. It goes to the Windows Credential Manager, not a plain file. Without it
the app runs, but cannot update itself.
</p>
<div class="card">
<div class="row">
<div class="label">
<div class="name">Alpha key {#if hasToken}<span class="tag ok">set</span>{/if}</div>
<div class="hint">{hasToken ? "A key is saved on this computer. Paste another to replace it, or press Next to keep it." : "Paste the key you were given. It starts with github_pat_."}</div>
</div>
<div class="ctl"><input type="password" class="mono" bind:value={alphaKey} placeholder={hasToken ? "••••••••••••" : "github_pat_…"} spellcheck="false" autocomplete="off" aria-label="Alpha key" /></div>
</div>
</div>
{:else if cur.id === "library"} {:else if cur.id === "library"}
<h1 id="fr-title">Where does your music live?</h1> <h1 id="fr-title">Where does your music live?</h1>
<p class="lede"> <p class="lede">
@@ -400,6 +445,7 @@
<p class="lede">Here is what you chose. Every line of it lives in Settings.</p> <p class="lede">Here is what you chose. Every line of it lives in Settings.</p>
<ul class="summary"> <ul class="summary">
{#each [ {#each [
["key", "Alpha key", hasToken ? "set" : "not set — updates need it"],
["folder-open", "Library", libraryDir || "not chosen"], ["folder-open", "Library", libraryDir || "not chosen"],
["users", "Soulseek", online ? `signed in as ${app.username}` : "not signed in yet"], ["users", "Soulseek", online ? `signed in as ${app.username}` : "not signed in yet"],
["globe", "Community peer", freenetOn ? (displayName.trim() ? `on · ${displayName.trim()}` : "on") : "off"], ["globe", "Community peer", freenetOn ? (displayName.trim() ? `on · ${displayName.trim()}` : "on") : "off"],
@@ -430,7 +476,7 @@
{:else if cur.id === "ready"} {:else if cur.id === "ready"}
<button class="ax-fill primary big" onclick={finish} disabled={closing}>Open SoulStream</button> <button class="ax-fill primary big" onclick={finish} disabled={closing}>Open SoulStream</button>
{:else} {:else}
{#if cur.id === "soulseek" && !online} {#if (cur.id === "soulseek" && !online) || (cur.id === "alpha" && !hasToken && !alphaKey.trim())}
<button class="lnk" onclick={next} disabled={busy}>Later</button> <button class="lnk" onclick={next} disabled={busy}>Later</button>
{/if} {/if}
<button class="ax-fill primary" onclick={next} disabled={busy || closing}>{busy ? "Saving…" : "Next"} <Icon name="chevron-right" size={14} /></button> <button class="ax-fill primary" onclick={next} disabled={busy || closing}>{busy ? "Saving…" : "Next"} <Icon name="chevron-right" size={14} /></button>
+27 -1
View File
@@ -3,17 +3,34 @@
// the other toasts, but it STAYS until answered — Install here, or Later // 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 Settings card keeps the offer). Installing on its own was never on
// the table; this is the button the updater's note talks about. // 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 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 available = $derived(updater.status?.available ?? null);
const installing = $derived(!!updater.status?.installing); const installing = $derived(!!updater.status?.installing);
const shown = $derived(!!available && !updater.dismissed); 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 pct = $derived.by(() => {
const p = updater.progress; const p = updater.progress;
if (!p || !p.total) return null; if (!p || !p.total) return null;
return Math.min(100, Math.round((p.received / p.total) * 100)); return Math.min(100, Math.round((p.received / p.total) * 100));
}); });
function openSettings() {
dismissNudge();
goToView("settings");
}
</script> </script>
{#if shown} {#if shown}
@@ -30,6 +47,15 @@
{/if} {/if}
</div> </div>
</div> </div>
{:else if needsKey}
<div class="layer" aria-live="polite">
<div class="toast" role="status">
<span class="ic"><Icon name="key" size={14} /></span>
<span class="text">Updates need your <b>alpha key</b></span>
<button class="go" onclick={openSettings}>Open Settings</button>
<button class="later" onclick={dismissNudge} title="Not now" aria-label="Not now"><Icon name="x" size={13} /></button>
</div>
</div>
{/if} {/if}
<style> <style>
+6
View File
@@ -10,9 +10,15 @@ export const updater = $state({
progress: null, progress: null,
/** The toast for the current find was dismissed. */ /** The toast for the current find was dismissed. */
dismissed: false, dismissed: false,
/** The "updates need your alpha key" nudge was waved away this session. */
nudgeDismissed: false,
error: "", error: "",
}); });
export function dismissNudge() {
updater.nudgeDismissed = true;
}
export const updateAvailable = () => updater.status?.available ?? null; export const updateAvailable = () => updater.status?.available ?? null;
export async function refreshUpdater() { export async function refreshUpdater() {
+13
View File
@@ -0,0 +1,13 @@
// The setup app's frontend: the same toolchain as the app, its own root and
// port, and the app's public folder (logo, fonts) served as if its own.
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
export default defineConfig({
root: "setup",
publicDir: "../public",
plugins: [svelte()],
clearScreen: false,
server: { port: 1425, strictPort: true },
build: { outDir: "dist", emptyOutDir: true },
});