diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 5547d4a..3ef778e 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -122,6 +122,9 @@ jobs: cp target/release/SoulStream-Setup.exe "SoulStream-Setup-$VERSION.exe" ls -la "SoulStream-Setup-$VERSION.exe" + # Which of the files on the page a tester runs, said on the page: the + # bare NSIS installer reads like the download and installs with no key + # gate and no signature check. - name: Publish SoulStream Setup shell: bash env: @@ -129,4 +132,20 @@ jobs: 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 + SHA: ${{ github.sha }} + run: | + gh release upload "$TAG" "SoulStream-Setup-$VERSION.exe" --repo "$TARGET" --clobber + { + echo "### Download \`SoulStream-Setup-$VERSION.exe\`" + echo + echo "It asks for your alpha key, fetches this build with it, checks the signature and installs — no admin prompt." + echo + echo "The other files are the ones Setup uses:" + echo + echo "- \`SoulStream_${VERSION}_x64-setup.exe\` — the bare installer Setup downloads. Running it yourself skips the key and the signature check." + echo "- \`SoulStream_${VERSION}_x64-setup.exe.sig\` — its signature." + echo "- \`latest.json\` — the manifest the app's Nightly channel reads." + echo + echo "Built from main at $SHA. Nightlies can break." + } > notes.md + gh release edit "$TAG" --repo "$TARGET" --notes-file notes.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b49b2b2..94cabdf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -105,6 +105,10 @@ jobs: cp target/release/SoulStream-Setup.exe "SoulStream-Setup-$VERSION.exe" ls -la "SoulStream-Setup-$VERSION.exe" + # Six files on the page and nothing saying which one a tester runs: the + # NSIS installer is the biggest and reads like the download, and running + # it puts the app on with no key gate and no signature check. The notes + # go on last, once the file they point at is actually up. - name: Publish SoulStream Setup shell: bash env: @@ -112,4 +116,19 @@ jobs: 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 + run: | + gh release upload "$TAG" "SoulStream-Setup-$VERSION.exe" --repo "$TARGET" --clobber + { + echo "### Download \`SoulStream-Setup-$VERSION.exe\`" + echo + echo "It asks for your alpha key, fetches this release with it, checks the signature and installs — no admin prompt." + echo + echo "The other files are the ones Setup uses:" + echo + echo "- \`SoulStream_${VERSION}_x64-setup.exe\` — the bare installer Setup downloads. Running it yourself skips the key and the signature check." + echo "- \`SoulStream_${VERSION}_x64-setup.exe.sig\` — its signature." + echo "- \`latest.json\` — the manifest the in-app updater reads." + echo + echo "See the commits since the last release." + } > notes.md + gh release edit "$TAG" --repo "$TARGET" --notes-file notes.md diff --git a/setup/src-tauri/Cargo.toml b/setup/src-tauri/Cargo.toml index 6fdac7a..e0c4442 100644 --- a/setup/src-tauri/Cargo.toml +++ b/setup/src-tauri/Cargo.toml @@ -11,6 +11,17 @@ rust-version.workspace = true name = "SoulStream-Setup" path = "src/main.rs" +# Tauri serves the frontend it embedded at compile time only with this on; +# without it the window loads `devUrl` instead and a tester gets a blank +# window saying "localhost refused to connect". The workflows build this +# crate with plain `cargo build` — no Tauri CLI to turn it on for them — so +# it is a DEFAULT feature here: every build that does not say otherwise is a +# shippable one. `--no-default-features` is the build that talks to the vite +# dev server on 1425. +[features] +default = ["custom-protocol"] +custom-protocol = ["tauri/custom-protocol"] + [build-dependencies] tauri-build = { version = "2", features = [] } serde_json.workspace = true diff --git a/setup/src-tauri/build.rs b/setup/src-tauri/build.rs index a362d22..c0cab18 100644 --- a/setup/src-tauri/build.rs +++ b/setup/src-tauri/build.rs @@ -4,9 +4,21 @@ use std::path::{Path, PathBuf}; fn main() { + // The frontend is embedded only when tauri's `custom-protocol` feature is + // on; without it the window points at devUrl and the exe we hand a tester + // shows "localhost refused to connect". It is a default feature, so this + // only fires when something turned the defaults off — and it fires at + // build time rather than on the tester's machine. + if tauri_build::is_dev() && std::env::var("PROFILE").as_deref() == Ok("release") { + panic!("SoulStream Setup: a release build needs the `custom-protocol` feature (on by default); without it the window loads devUrl and is blank"); + } 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()); + // tauri-build tracks the config, not the frontend the exe embeds: without + // this, a changed Setup.svelte rebuilds `setup/dist` and cargo hands back + // yesterday's binary, still carrying yesterday's window. + println!("cargo:rerun-if-changed={}", manifest.join("../dist").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"] diff --git a/setup/src-tauri/src/main.rs b/setup/src-tauri/src/main.rs index b76bbe6..f0b83d3 100644 --- a/setup/src-tauri/src/main.rs +++ b/setup/src-tauri/src/main.rs @@ -26,6 +26,9 @@ use serde::Serialize; use tauri::{AppHandle, Emitter}; const REPO: &str = "KeelrLeek/SoulStream"; +/// The rolling prerelease `.github/workflows/nightly.yml` replaces daily — +/// never the one Setup installs. +const NIGHTLY_TAG: &str = "nightly"; const APP_DATA_DIRNAME: &str = "com.soulstream.app"; const KEYRING_SERVICE: &str = "com.soulstream.app"; const KEYRING_TOKEN_USER: &str = "updater-token"; @@ -163,6 +166,44 @@ struct Release { sig_url: Option, } +/// Why a release lookup failed, and whether it was the KEY that GitHub +/// refused. Only a refusal is a reason to throw the saved key away — a +/// laptop between wifi networks is not, and the entry thrown away is the +/// one the app reads for its own updates. +struct Failure { + message: String, + key_refused: bool, +} + +impl Failure { + fn plain(message: impl Into) -> Self { + Self { message: message.into(), key_refused: false } + } + fn refused(message: impl Into) -> Self { + Self { message: message.into(), key_refused: true } + } +} + +impl From for String { + fn from(f: Failure) -> Self { + f.message + } +} + +/// A bare message is never a refusal — a release missing its installer says +/// nothing about the key that read it. +impl From<&str> for Failure { + fn from(m: &str) -> Self { + Failure::plain(m) + } +} + +impl From for Failure { + fn from(m: String) -> Self { + Failure::plain(m) + } +} + fn client() -> Result { reqwest::Client::builder() .timeout(Duration::from_secs(60)) @@ -171,23 +212,37 @@ fn client() -> Result { .map_err(|e| e.to_string()) } -async fn fetch_release(token: &str) -> Result { +async fn fetch_release(token: &str) -> Result { + // Not `/releases/latest`: that endpoint answers with the newest FULL + // release and every SoulStream release so far is a prerelease, so it + // 404s and a perfectly good key reads back as a bad one. The list comes + // newest first — take the first that is neither a draft nor the rolling + // nightly, which is not what a tester is here to install. let resp = client()? - .get(format!("https://api.github.com/repos/{REPO}/releases/latest")) + .get(format!("https://api.github.com/repos/{REPO}/releases?per_page=30")) .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})"))?; + .map_err(|e| Failure::plain(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}.")), + 401 => return Err(Failure::refused("That key was refused.")), + 403 => return Err(Failure::refused("That key cannot read the SoulStream repository.")), + // A private repository answers 404 to a key that cannot see it. + 404 => return Err(Failure::refused("No release found — or that key cannot see the repository.")), + s => return Err(Failure::plain(format!("GitHub answered {s}."))), } - let v: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; + let list: Vec = + resp.json().await.map_err(|e| Failure::plain(e.to_string()))?; + // The key worked; there is simply nothing published yet. Not its fault. + let v = list + .into_iter() + .find(|r| { + r["draft"].as_bool() != Some(true) && r["tag_name"].as_str() != Some(NIGHTLY_TAG) + }) + .ok_or_else(|| Failure::plain("That key works, but the repository has no release to install yet."))?; let tag = v["tag_name"].as_str().unwrap_or(""); let assets = v["assets"].as_array().cloned().unwrap_or_default(); let find = |suffix: &str| { @@ -339,12 +394,15 @@ 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) => { + Err(e) if e.key_refused => { + // The same Credential Manager entry the app reads for its own + // updates, so it goes only when GitHub actually turned it down. if let Some(entry) = keyring_entry() { let _ = entry.delete_credential(); } - Err(format!("The key saved on this computer no longer works — {e}")) + Err(format!("The key saved on this computer no longer works — {}", e.message)) } + Err(e) => Err(e.message), } } @@ -471,6 +529,7 @@ fn main() { quit ]) .run({ + #[cfg_attr(not(debug_assertions), allow(unused_mut))] let mut context = tauri::generate_context!(); // Driving the window from a probe, debug builds only. #[cfg(debug_assertions)] diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs index ffe474f..e0e7ffa 100644 --- a/src-tauri/src/updater.rs +++ b/src-tauri/src/updater.rs @@ -11,8 +11,12 @@ //! private release cannot be read without a token. With a read-only token //! saved in the Credential Manager (Settings → Updates → Access token), a //! GitHub source is resolved through the API instead of the browser URLs: -//! `GET /repos/{o}/{r}/releases/latest` (or `/tags/{tag}`) finds the -//! `latest.json` asset, the plugin fetches THAT asset URL with +//! `GET /repos/{o}/{r}/releases/tags/{tag}` for a pinned tag, and for +//! `latest` a `GET /repos/{o}/{r}/releases` whose first entry that is +//! neither a draft nor the rolling `nightly` wins. NOT `/releases/latest`: +//! that answers only with full releases, and every release here is an +//! alpha, so it 404s. Either way the `latest.json` asset is found, the +//! plugin fetches THAT asset URL with //! `Authorization: Bearer` + `Accept: application/octet-stream`, and the //! manifest's own download URLs are API asset URLs too — the workflows //! rewrite them after publishing — so the installer downloads with the same @@ -37,6 +41,8 @@ pub const DEFAULT_STABLE: &str = "https://github.com/KeelrLeek/SoulStream/releases/latest/download/latest.json"; pub const DEFAULT_NIGHTLY: &str = "https://github.com/KeelrLeek/SoulStream/releases/download/nightly/latest.json"; +/// The tag `nightly.yml` rewrites daily. Never what `latest` should resolve to. +const NIGHTLY_TAG: &str = "nightly"; /// First automatic check after launch, then the interval between checks. const FIRST_CHECK: Duration = Duration::from_secs(25); @@ -199,22 +205,15 @@ fn announce(app: &AppHandle) { let _ = app.emit("updater:status", status(app)); } -/// Ask the GitHub API where a private release keeps its `latest.json`. -async fn resolve_github_asset( - src: &GitHubSource, +/// One authenticated GitHub API GET, its failures already in words. +async fn github_json( + client: &reqwest::Client, + url: &str, token: &str, user_agent: &str, -) -> Result { - let api = format!( - "https://api.github.com/repos/{}/{}/releases/{}", - src.owner, src.repo, src.release - ); - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .build() - .map_err(|e| e.to_string())?; +) -> Result { let resp = client - .get(&api) + .get(url) .header(reqwest::header::AUTHORIZATION, format!("Bearer {token}")) .header(reqwest::header::ACCEPT, "application/vnd.github+json") .header(reqwest::header::USER_AGENT, user_agent) @@ -229,7 +228,47 @@ async fn resolve_github_asset( 404 => return Err("no release found — or the token cannot see the repository".into()), s => return Err(format!("GitHub answered {s}")), } - let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; + resp.json().await.map_err(|e| e.to_string()) +} + +/// Ask the GitHub API where a private release keeps its `latest.json`. +async fn resolve_github_asset( + src: &GitHubSource, + token: &str, + user_agent: &str, +) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .map_err(|e| e.to_string())?; + let repo = format!("https://api.github.com/repos/{}/{}", src.owner, src.repo); + let body = if src.release == "latest" { + // `/releases/latest` answers with the newest FULL release, and every + // SoulStream release so far is a prerelease — so it 404s and Stable + // reports the token cannot see the repository. The list comes newest + // first: take the first that is neither a draft nor the rolling + // nightly, which the Nightly channel asks for by tag instead. + let list = github_json(&client, &format!("{repo}/releases?per_page=30"), token, user_agent) + .await?; + list.as_array() + .and_then(|rs| { + rs.iter() + .find(|r| { + r["draft"].as_bool() != Some(true) + && r["tag_name"].as_str() != Some(NIGHTLY_TAG) + }) + .cloned() + }) + .ok_or("no release found — or the token cannot see the repository")? + } else { + github_json( + &client, + &format!("{repo}/releases/{}", src.release), + token, + user_agent, + ) + .await? + }; let assets = body["assets"].as_array().cloned().unwrap_or_default(); let asset = assets .iter()