setup, updater: the installer window was a dev build with nothing inside it, and latest never matched a prerelease

Tauri decides dev-vs-production from the `custom-protocol` feature, not from
--release, and both workflows build the setup exe with a plain `cargo build`.
So cfg(dev) stayed on, the frontend was never embedded, and the window a
tester opened loaded devUrl: "localhost refused to connect" on a blank
800x580. The feature is a default of the crate now, with a build.rs guard
that fails a release build without it, and build.rs tracks setup/dist so a
changed window cannot be left out of the binary it is supposed to be in.

Behind that, the next wall: /releases/latest answers only with full
releases, and every release here is an alpha, so it 404s. The setup app read
that as a refused key and the app's Stable channel read it as a token that
cannot see the repository — both have been broken since the first alpha.
Each now lists /releases and takes the newest that is neither a draft nor
the rolling nightly, which the list really does put first.

And check_saved_key threw the saved key away on any failure at all, a
dropped connection included. The entry it deletes is the app's own updater
token, so only a refusal clears it now.

The release notes name the file to hand a tester: six assets on the page and
nothing saying which, while the bare NSIS installer is the biggest of them
and installs with no key gate and no signature check.
This commit is contained in:
KeelrLeek committed 2026-09-10 07:19:15 -05:00
1 parent 3cfff93036
commit 12fff9f6ad
6 files changed
+187 -28

No files matched your search

+20 -1
View File
@@ -122,6 +122,9 @@ jobs:
cp target/release/SoulStream-Setup.exe "SoulStream-Setup-$VERSION.exe" cp target/release/SoulStream-Setup.exe "SoulStream-Setup-$VERSION.exe"
ls -la "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 - name: Publish SoulStream Setup
shell: bash shell: bash
env: env:
@@ -129,4 +132,20 @@ jobs:
TARGET: ${{ vars.RELEASES_OWNER || github.repository_owner }}/${{ vars.RELEASES_REPO || github.event.repository.name }} TARGET: ${{ vars.RELEASES_OWNER || github.repository_owner }}/${{ vars.RELEASES_REPO || github.event.repository.name }}
TAG: nightly TAG: nightly
VERSION: ${{ steps.stamp.outputs.version }} 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
+20 -1
View File
@@ -105,6 +105,10 @@ jobs:
cp target/release/SoulStream-Setup.exe "SoulStream-Setup-$VERSION.exe" cp target/release/SoulStream-Setup.exe "SoulStream-Setup-$VERSION.exe"
ls -la "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 - name: Publish SoulStream Setup
shell: bash shell: bash
env: env:
@@ -112,4 +116,19 @@ jobs:
TARGET: ${{ vars.RELEASES_OWNER || github.repository_owner }}/${{ vars.RELEASES_REPO || github.event.repository.name }} TARGET: ${{ vars.RELEASES_OWNER || github.repository_owner }}/${{ vars.RELEASES_REPO || github.event.repository.name }}
TAG: v${{ steps.version.outputs.version }} TAG: v${{ steps.version.outputs.version }}
VERSION: ${{ 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
+11
View File
@@ -11,6 +11,17 @@ rust-version.workspace = true
name = "SoulStream-Setup" name = "SoulStream-Setup"
path = "src/main.rs" 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] [build-dependencies]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
serde_json.workspace = true serde_json.workspace = true
+12
View File
@@ -4,9 +4,21 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
fn main() { 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 manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
let conf = manifest.join("../../src-tauri/tauri.conf.json"); let conf = manifest.join("../../src-tauri/tauri.conf.json");
println!("cargo:rerun-if-changed={}", conf.display()); 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 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 json: serde_json::Value = serde_json::from_str(&text).expect("parse tauri.conf.json");
let pubkey = json["plugins"]["updater"]["pubkey"] let pubkey = json["plugins"]["updater"]["pubkey"]
+69 -10
View File
@@ -26,6 +26,9 @@ use serde::Serialize;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
const REPO: &str = "KeelrLeek/SoulStream"; 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 APP_DATA_DIRNAME: &str = "com.soulstream.app";
const KEYRING_SERVICE: &str = "com.soulstream.app"; const KEYRING_SERVICE: &str = "com.soulstream.app";
const KEYRING_TOKEN_USER: &str = "updater-token"; const KEYRING_TOKEN_USER: &str = "updater-token";
@@ -163,6 +166,44 @@ struct Release {
sig_url: Option<String>, sig_url: Option<String>,
} }
/// 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<String>) -> Self {
Self { message: message.into(), key_refused: false }
}
fn refused(message: impl Into<String>) -> Self {
Self { message: message.into(), key_refused: true }
}
}
impl From<Failure> 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<String> for Failure {
fn from(m: String) -> Self {
Failure::plain(m)
}
}
fn client() -> Result<reqwest::Client, String> { fn client() -> Result<reqwest::Client, String> {
reqwest::Client::builder() reqwest::Client::builder()
.timeout(Duration::from_secs(60)) .timeout(Duration::from_secs(60))
@@ -171,23 +212,37 @@ fn client() -> Result<reqwest::Client, String> {
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
async fn fetch_release(token: &str) -> Result<Release, String> { async fn fetch_release(token: &str) -> Result<Release, Failure> {
// 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()? 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) .bearer_auth(token)
.header(reqwest::header::ACCEPT, "application/vnd.github+json") .header(reqwest::header::ACCEPT, "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28") .header("X-GitHub-Api-Version", "2022-11-28")
.send() .send()
.await .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() { match resp.status().as_u16() {
200 => {} 200 => {}
401 => return Err("That key was refused.".into()), 401 => return Err(Failure::refused("That key was refused.")),
403 => return Err("That key cannot read the SoulStream repository.".into()), 403 => return Err(Failure::refused("That key cannot read the SoulStream repository.")),
404 => return Err("No release found — or that key cannot see the repository.".into()), // A private repository answers 404 to a key that cannot see it.
s => return Err(format!("GitHub answered {s}.")), 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<serde_json::Value> =
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 tag = v["tag_name"].as_str().unwrap_or("");
let assets = v["assets"].as_array().cloned().unwrap_or_default(); let assets = v["assets"].as_array().cloned().unwrap_or_default();
let find = |suffix: &str| { let find = |suffix: &str| {
@@ -339,12 +394,15 @@ async fn check_saved_key() -> Result<Option<Release>, String> {
let Some(t) = saved_key() else { return Ok(None) }; let Some(t) = saved_key() else { return Ok(None) };
match fetch_release(&t).await { match fetch_release(&t).await {
Ok(r) => Ok(Some(r)), 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() { if let Some(entry) = keyring_entry() {
let _ = entry.delete_credential(); 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 quit
]) ])
.run({ .run({
#[cfg_attr(not(debug_assertions), allow(unused_mut))]
let mut context = tauri::generate_context!(); let mut context = tauri::generate_context!();
// Driving the window from a probe, debug builds only. // Driving the window from a probe, debug builds only.
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
+55 -16
View File
@@ -11,8 +11,12 @@
//! private release cannot be read without a token. With a read-only token //! private release cannot be read without a token. With a read-only token
//! saved in the Credential Manager (Settings → Updates → Access token), a //! saved in the Credential Manager (Settings → Updates → Access token), a
//! GitHub source is resolved through the API instead of the browser URLs: //! GitHub source is resolved through the API instead of the browser URLs:
//! `GET /repos/{o}/{r}/releases/latest` (or `/tags/{tag}`) finds the //! `GET /repos/{o}/{r}/releases/tags/{tag}` for a pinned tag, and for
//! `latest.json` asset, the plugin fetches THAT asset URL with //! `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 //! `Authorization: Bearer` + `Accept: application/octet-stream`, and the
//! manifest's own download URLs are API asset URLs too — the workflows //! manifest's own download URLs are API asset URLs too — the workflows
//! rewrite them after publishing — so the installer downloads with the same //! 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"; "https://github.com/KeelrLeek/SoulStream/releases/latest/download/latest.json";
pub const DEFAULT_NIGHTLY: &str = pub const DEFAULT_NIGHTLY: &str =
"https://github.com/KeelrLeek/SoulStream/releases/download/nightly/latest.json"; "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. /// First automatic check after launch, then the interval between checks.
const FIRST_CHECK: Duration = Duration::from_secs(25); const FIRST_CHECK: Duration = Duration::from_secs(25);
@@ -199,22 +205,15 @@ fn announce(app: &AppHandle) {
let _ = app.emit("updater:status", status(app)); let _ = app.emit("updater:status", status(app));
} }
/// Ask the GitHub API where a private release keeps its `latest.json`. /// One authenticated GitHub API GET, its failures already in words.
async fn resolve_github_asset( async fn github_json(
src: &GitHubSource, client: &reqwest::Client,
url: &str,
token: &str, token: &str,
user_agent: &str, user_agent: &str,
) -> Result<Url, String> { ) -> Result<serde_json::Value, String> {
let api = format!(
"https://api.github.com/repos/{}/{}/releases/{}",
src.owner, src.repo, src.release
);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| e.to_string())?;
let resp = client let resp = client
.get(&api) .get(url)
.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}")) .header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"))
.header(reqwest::header::ACCEPT, "application/vnd.github+json") .header(reqwest::header::ACCEPT, "application/vnd.github+json")
.header(reqwest::header::USER_AGENT, user_agent) .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()), 404 => return Err("no release found — or the token cannot see the repository".into()),
s => return Err(format!("GitHub answered {s}")), 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<Url, String> {
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 assets = body["assets"].as_array().cloned().unwrap_or_default();
let asset = assets let asset = assets
.iter() .iter()