diff --git a/Cargo.lock b/Cargo.lock index 63f9534d..fcb8ce03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6695,7 +6695,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "voxctrl-app" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "chrono", @@ -6734,7 +6734,7 @@ dependencies = [ [[package]] name = "voxctrl-audio" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "cpal", @@ -6748,7 +6748,7 @@ dependencies = [ [[package]] name = "voxctrl-bugreport" -version = "0.7.1" +version = "0.7.2" dependencies = [ "chrono", "dirs 5.0.1", @@ -6766,7 +6766,7 @@ dependencies = [ [[package]] name = "voxctrl-config" -version = "0.7.1" +version = "0.7.2" dependencies = [ "dirs 5.0.1", "serde", @@ -6779,7 +6779,7 @@ dependencies = [ [[package]] name = "voxctrl-dbus" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "serde", @@ -6791,7 +6791,7 @@ dependencies = [ [[package]] name = "voxctrl-hotkeys" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "ashpd", @@ -6813,7 +6813,7 @@ dependencies = [ [[package]] name = "voxctrl-inference" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "bytes", @@ -6844,7 +6844,7 @@ dependencies = [ [[package]] name = "voxctrl-inject" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "arboard", @@ -6858,7 +6858,7 @@ dependencies = [ [[package]] name = "voxctrl-llm" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "reqwest 0.12.28", @@ -6872,7 +6872,7 @@ dependencies = [ [[package]] name = "voxctrl-llm-sidecar" -version = "0.7.1" +version = "0.7.2" dependencies = [ "encoding_rs", "llama-cpp-2", @@ -6884,7 +6884,7 @@ dependencies = [ [[package]] name = "voxctrl-mcp" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "serde", @@ -6896,7 +6896,7 @@ dependencies = [ [[package]] name = "voxctrl-routing" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "arboard", @@ -6922,14 +6922,14 @@ dependencies = [ [[package]] name = "voxctrl-text" -version = "0.7.1" +version = "0.7.2" dependencies = [ "regex", ] [[package]] name = "voxctrl-tts" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "cc", @@ -6953,7 +6953,7 @@ dependencies = [ [[package]] name = "voxctrl-update" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "futures-util", @@ -6971,7 +6971,7 @@ dependencies = [ [[package]] name = "voxctrl-winput" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "arboard", diff --git a/Cargo.toml b/Cargo.toml index e5b8d1bb..a0a28939 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.7.1" +version = "0.7.2" edition = "2021" authors = ["VoxCtrl Contributors"] license = "MIT" diff --git a/crates/voxctrl-update/src/lib.rs b/crates/voxctrl-update/src/lib.rs index d49a6839..8496c892 100644 --- a/crates/voxctrl-update/src/lib.rs +++ b/crates/voxctrl-update/src/lib.rs @@ -95,7 +95,45 @@ impl CheckOutcome { pub async fn check(current_version: &str, gpu_build: bool) -> Result { let client = release::client()?; let latest = release::fetch_latest(&client).await?; - Ok(evaluate(&latest, current_version, install::detect(gpu_build))) + let mut outcome = evaluate(&latest, current_version, install::detect(gpu_build)); + if let CheckOutcome::Available(pending) = &mut outcome { + // Best effort: if the list cannot be fetched, the latest release's own + // notes (already in place) are still shown. + if let Ok(all) = release::fetch_recent(&client).await { + if let Some(notes) = combined_notes(&all, current_version, &latest.tag_name) { + pending.info.notes = notes; + } + } + } + Ok(outcome) +} + +/// The notes of every published release newer than `current_version` up to and +/// including `latest_tag`, newest first, each under its tag. +pub fn combined_notes(releases: &[Release], current_version: &str, latest_tag: &str) -> Option { + let mut between: Vec<&Release> = releases + .iter() + .filter(|r| !r.draft && !r.prerelease) + .filter(|r| version::is_newer(&r.tag_name, current_version)) + .filter(|r| !version::is_newer(&r.tag_name, latest_tag)) + .collect(); + if between.is_empty() { + return None; + } + between.sort_by(|a, b| Version::parse(&b.tag_name).cmp(&Version::parse(&a.tag_name))); + + let sections: Vec = between + .iter() + .map(|r| { + let body = release::summarize_notes(r.body.as_deref().unwrap_or_default(), NOTES_BUDGET); + if body.is_empty() { + format!("## {}", r.tag_name) + } else { + format!("## {}\n{}", r.tag_name, body) + } + }) + .collect(); + Some(sections.join("\n\n")) } /// The decision half of [`check`], with the network and the machine both passed @@ -340,6 +378,18 @@ mod tests { assert_eq!(pending.info.download_size, 98_000_000); } + #[test] + fn notes_cover_every_release_between_current_and_latest() { + let mut older = release_with("v0.3.9", vec![]); + older.body = Some("old".into()); + let mut mid = release_with("v0.3.11", vec![]); + mid.body = Some("mid".into()); + let mut newest = release_with("v0.4.0", vec![]); + newest.body = Some("new".into()); + let notes = combined_notes(&[newest, mid, older], "0.3.10", "v0.4.0").unwrap(); + assert_eq!(notes, "## v0.4.0\nnew\n\n## v0.3.11\nmid"); + } + #[test] fn the_same_version_reports_up_to_date() { let rel = release_with("v0.3.10", vec![]); diff --git a/crates/voxctrl-update/src/release.rs b/crates/voxctrl-update/src/release.rs index be1a8572..83679522 100644 --- a/crates/voxctrl-update/src/release.rs +++ b/crates/voxctrl-update/src/release.rs @@ -13,6 +13,10 @@ use serde::{Deserialize, Serialize}; pub const RELEASES_API_URL: &str = "https://api.github.com/repos/JRufer/VoxCtrl/releases/latest"; +/// The most recent releases, for assembling a change log across versions. +pub const RELEASES_LIST_URL: &str = + "https://api.github.com/repos/JRufer/VoxCtrl/releases?per_page=30"; + /// Where a user is sent when VoxCtrl cannot update itself. pub const RELEASES_PAGE_URL: &str = "https://github.com/JRufer/VoxCtrl/releases/latest"; @@ -116,6 +120,26 @@ pub async fn fetch_latest_from(client: &reqwest::Client, url: &str) -> Result Result> { + let response = client + .get(RELEASES_LIST_URL) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .send() + .await + .map_err(|e| UpdateError::Network(e.to_string()))?; + if !response.status().is_success() { + return Err(UpdateError::Response(format!("HTTP {}", response.status()))); + } + let body = response + .text() + .await + .map_err(|e| UpdateError::Network(e.to_string()))?; + serde_json::from_str::>(&body).map_err(|e| UpdateError::Response(e.to_string())) +} + /// Parse a release payload, kept separate from the transport so the shape of /// what GitHub sends can be tested without a network. pub fn parse_release(body: &str) -> Result { diff --git a/docs/api.md b/docs/api.md index d70b711a..34b2a133 100644 --- a/docs/api.md +++ b/docs/api.md @@ -199,7 +199,7 @@ user triggered it from Settings → General or the update window. #### `check_for_update() → UpdateCheckPayload` Asks GitHub for the latest published release and compares it with the running -version. Also resolves which release file matches this installation, so +version, and gathers the release notes of every release in between into `notes`. Also resolves which release file matches this installation, so `install_update` does not have to fetch anything twice. ```typescript @@ -207,7 +207,7 @@ interface UpdateInfo { version: string; // "0.4.0" tag: string; // "v0.4.0" current_version: string; // the version running now - notes: string; // release notes, trimmed for a dialog + notes: string; // notes for each release newer than yours, up to the latest, newest first release_url: string; asset_name: string | null; // the file that would be installed download_size: number; // bytes diff --git a/docs/privacy.md b/docs/privacy.md index fb5891f5..9083784f 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -122,7 +122,7 @@ update check: | Trigger | Destination | Sends | |---|---|---| -| Pressing "Check for updates" in Settings → General | `api.github.com/repos/JRufer/VoxCtrl/releases/latest` | A `User-Agent` of `VoxCtrl/`. Nothing else. | +| Pressing "Check for updates" in Settings → General | `api.github.com/repos/JRufer/VoxCtrl/releases/latest`, plus `.../releases?per_page=30` when a newer version exists (for the change log) | A `User-Agent` of `VoxCtrl/`. Nothing else. | | Installing an offered update | `github.com` release download (the file and its `.minisig` signature) | Nothing beyond the request for the files | | Downloading a speech model | HuggingFace / the model host, on demand | Nothing beyond the request for the file | | Downloading a TTS voice | HuggingFace / the Piper voice host, on demand | Nothing beyond the request for the file | @@ -136,7 +136,9 @@ update check: VoxCtrl never checks for updates on its own — there is no launch-time check and nothing to turn off. Pressing "Check for updates" in Settings → General sends a plain unauthenticated `GET` for the public release listing — the same -URL anyone can open in a browser. There is no request body, no cookie, no +URL anyone can open in a browser. When a newer version exists, one more `GET` +for the recent release list fetches the change log for every version between +yours and the latest. There is no request body, no cookie, no account, no install ID, and no way for it to carry one: GitHub is told which version of VoxCtrl is asking (because the API requires a `User-Agent`) and nothing more. What comes back is the release's tag, notes and file list, which @@ -155,7 +157,7 @@ a package manager and points you at it instead of overwriting them. See Once the app and its models are on disk, VoxCtrl runs fully air-gapped. **The code:** `crates/voxctrl-update/` is the whole of it — about 400 lines, -with no dependency on the rest of the app. `release.rs` builds the one request, +with no dependency on the rest of the app. `release.rs` builds the (at most two) requests, `apply.rs` downloads and checks the digest, `signature.rs` checks the release signature, `src-tauri/src/updater.rs` is the manual check command and what it shows. diff --git a/docs/screenshots/settings-general-up-to-date.png b/docs/screenshots/settings-general-up-to-date.png new file mode 100644 index 00000000..bb20d534 Binary files /dev/null and b/docs/screenshots/settings-general-up-to-date.png differ diff --git a/docs/screenshots/settings-general-updates.png b/docs/screenshots/settings-general-updates.png new file mode 100644 index 00000000..c1e5af10 Binary files /dev/null and b/docs/screenshots/settings-general-updates.png differ diff --git a/docs/screenshots/update-window.png b/docs/screenshots/update-window.png new file mode 100644 index 00000000..080c06c8 Binary files /dev/null and b/docs/screenshots/update-window.png differ diff --git a/docs/ui.md b/docs/ui.md index c641df97..39b8dbd8 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -40,7 +40,7 @@ The main configuration interface. Organized into a sidebar with eleven tabs: ### General Tab - "Open setup wizard" button — re-runs the first-run wizard - **HuggingFace access token** — the single field for the token used by every gated model VoxCtrl can download (Pocket-TTS, Breeze-TTS-2, VoxCPM2); read-only, showing the value, when `HF_TOKEN` is exported. Writes `tts.hf_token`, the same field the setup wizard's voice step edits, so a token entered in either place shows up in the other. -- "Check for updates" button that reports the result inline (VoxCtrl never checks on its own) +- A single "Check for updates" button (VoxCtrl never checks on its own). Up to date: a green "✔ Up to date" pill appears beside the Updates heading and no window opens. A newer release: the Update window opens with the change log from the running version to the latest and a download link - MCP server toggle, visual feedback toggle, and record timeout control - Recording status indicator and word count - Manual record/stop button diff --git a/package.json b/package.json index b2b24f56..969e04e6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "voxctrl", "private": true, - "version": "0.7.1", + "version": "0.7.2", "type": "module", "scripts": { "predev": "pkill -x voxctrl 2>/dev/null || true; fuser -k 5173/tcp 2>/dev/null || true", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 09ac7605..16c6e9d3 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "VoxCtrl", - "version": "0.7.1", + "version": "0.7.2", "identifier": "ai.voxctrl.app", "build": { "frontendDist": "../dist", diff --git a/src/lib/Settings/GeneralTab.svelte b/src/lib/Settings/GeneralTab.svelte index 45d1f02b..bacb63f9 100644 --- a/src/lib/Settings/GeneralTab.svelte +++ b/src/lib/Settings/GeneralTab.svelte @@ -52,6 +52,9 @@ checkResult = null; try { checkResult = await invoke("check_for_update"); + // A newer release opens the update window straight away: change log from + // the user's version to the latest, plus the download. + if (checkResult.update) await invoke("open_update_window"); } catch (e) { checkError = `${e}`; } finally { @@ -59,14 +62,6 @@ } } - async function showUpdateWindow() { - try { - await invoke("open_update_window"); - } catch (e) { - checkError = `${e}`; - } - } - async function runSetupWizard() { wizardError = null; try { @@ -129,26 +124,23 @@
-

Updates

+
+

Updates

+ {#if checkResult && !checkResult.update} + ✔ Up to date + {/if} +

- VoxCtrl never checks for updates on its own. Press "Check now" to ask GitHub whether a newer - release has been published — that request carries nothing about you or your machine. + VoxCtrl never checks for updates on its own. Press "Check for updates" to ask GitHub whether a + newer release has been published — that request carries nothing about you or your machine. If + there is one, you'll see what changed since your version and can download it.

- Check now + Look for a newer VoxCtrl release
- {#if checkResult && !checkResult.update} -

VoxCtrl {checkResult.current_version} is the latest release.

- {:else if checkResult?.update} -

- Version {checkResult.update.version} is available (you have - {checkResult.update.current_version}). - -

- {/if} {#if checkError}

Could not check for updates: {checkError}

{/if} @@ -185,6 +177,13 @@