Measured against v0.1.2-alpha.4 (4abcf33) for the event wiring: source read for the wiring itself, cargo check --workspace --release for the cfg-gated half. The endpoint section below is re-measured against v0.1.2-alpha.5 (df63cc0), the current shipping build, with live requests to the GitHub API and to the configured updater endpoint.
A release build runs the update check twice, and the copy that runs first is the one nobody can see.
The backend check reports to an empty room
desktop/src-tauri/src/main.rs:39-46 spawns a check two seconds after setup, gated on not(debug_assertions):
#[cfg(not(debug_assertions))]
{
let handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
check_for_updates_silent(handle).await;
});
}
check_for_updates_silent (main.rs:109-126) reports its result by emitting update-available. The only thing in the repo that subscribes to that event name is api.onUpdateAvailable (desktop/src/api.ts:132), and a repo-wide grep for onUpdateAvailable returns exactly one hit — its own definition. Nothing in desktop/src, desktop/e2e or scripts/ calls it. Tauri's emit to no listener is a no-op with a discarded Result (let _ = app.emit(...)), so in a release build the app performs an HTTPS round-trip to api.github.com, writes the answer to the log at info, and drops it. A user on a release build who has an update waiting learns nothing from this path, ever.
The two checks are the same check
check_for_updates_silent calls commands::check_with_prerelease_endpoint (commands.rs:849). So does the check_for_updates command (commands.rs:911) that the renderer invokes from App.vue:71 via stores/update.ts:101. Same function, same GitHub API query, same semver pick, same latest.json endpoint — the only differences are that the backend copy fires on a 2-second timer instead of at mount, exists only in release builds, and cannot reach the UI.
The backend copy is not a periodic check and not a watchdog. It is a one-shot at startup, at roughly the same moment as the renderer's one-shot at startup, catching nothing its twin does not. There is no "an update appeared while the app was running" case that it covers: neither path re-checks after boot except when the user asks from Settings (SettingsView.vue:249) or the command palette (CommandPalette.vue:79).
So the release-build cost is two GitHub API requests per launch where one is needed, and the visible half is the one that stayed.
The configured fallback endpoint resolves to a stale manifest
Separate from the above, and not fixed by it. tauri.conf.json:54-56 sets
https://github.com/CodeAny-inc/Clavyn/releases/latest/download/latest.json
The received wisdom is that this URL cannot resolve, because every release is a prerelease and /releases/latest/ skips prereleases. That is not what the repo looks like. GET /repos/CodeAny-inc/Clavyn/releases/latest answers v0.1.1 — the newest release with prerelease=false — and the endpoint above returns HTTP 200 with {"version": "0.1.1", ...}.
It resolves, to a manifest five alphas old: v0.1.2-alpha.1 through v0.1.2-alpha.5 all ship after v0.1.1. An app on 0.1.2-alpha.5 that ends up on this endpoint compares itself against 0.1.1, finds nothing newer, and reports "Up to date" with no error raised. That part is real, and it will stay real for as long as the newest stable tag trails the newest alpha — the normal state of this repo, since release.yml:46-48 marks every alpha/beta/rc as a prerelease.
What does not reach it
An earlier revision of this issue named an API outage, a rate limit, and a network that blocks api.github.com but not github.com as the triggers. None of the three reach the fallback. All three surface as a visible error instead.
check_with_prerelease_endpoint opens with
let json_url = match find_latest_json_url().await? {
(commands.rs:854), and the ? is the whole story. find_latest_json_url returns Err for:
- any transport failure — DNS, TLS, connection refused, the 15-second timeout:
commands.rs:810, github api request: {e}. That is both a network that cannot reach api.github.com and an outage that shows up as a dead socket.
- any non-2xx status —
commands.rs:813, github api returned {status}. That is the rate limit (403 / 429) and a 5xx outage.
- a body that does not parse —
commands.rs:819, parse github response: {e}.
Each of those propagates through the ? out of check_with_prerelease_endpoint, into check_for_updates's Err arm (commands.rs:932-934, which logs and re-raises), across the IPC boundary into the store's catch (stores/update.ts:117-120), and onto update.error. SettingsView.vue:44 then reads "Update check failed" with the message under it, and UpdateModal.vue:123-127 shows it while the modal is open. info is never assigned on that path, so nothing replaces a real answer with a made-up one.
One rough edge, much smaller than what was claimed but worth writing down: the detail line under the headline (SettingsView.vue:226-238) branches on update.info before update.error, unlike the status icon (:217), which guards its success case with !update.error. So a check that fails after an earlier one succeeded shows the error headline and the error icon over the stale detail "You're on the latest version". A boot-time failure has no earlier info and reads correctly; a failed re-check contradicts itself in the third line.
What does reach it
Exactly one condition: find_latest_json_url succeeds and returns Ok(None) (commands.rs:844, consumed at commands.rs:856-859). That needs a 2xx response whose body parses as a release list in which no entry both carries a semver-parsable tag and a latest.json asset.
That is a long way from where the repo is. GITHUB_API_RELEASES_URL asks for per_page=30; the repo has 21 releases, and 20 of them carry latest.json — only v0.1.1-alpha.9 does not. The loop picks the highest semver among the releases that have the asset, so one release missing it is skipped rather than causing a fallback. Every release in the page has to lack it.
So the realistic triggers are not network conditions:
- a release-workflow regression that stops uploading
latest.json, sustained long enough to cover the newest 30 releases;
- releases published as drafts — the unauthenticated list omits drafts, so the page comes back
[];
- the repository moving or being replaced, so
CodeAny-inc/Clavyn answers 200 with an empty release list.
Severity
Latent. The stale manifest is a real defect and the silent "Up to date" is a real consequence of reaching it, but nothing the app encounters in normal operation reaches it, and the failures that were previously blamed for it are all visible errors. Worth fixing before one of the triggers above arrives; not worth treating as a false negative users are hitting today.
There is no static GitHub URL that resolves to "newest release including prereleases", so this is not a one-line endpoint swap. Filing it here so it is written down rather than fixed in passing.
Fix
Delete the backend boot check, leaving the renderer's check_for_updates as the single path that reports availability. It is the one that already feeds info, lastChecked, error, the dismissed-version state and the shouldNotify watcher that opens the modal (App.vue:43-45), and the one that already runs on every build rather than only on release builds. Wiring a listener for update-available instead would leave two callers of the same function racing to set the same store state, for no coverage the renderer does not have.
api.onUpdateAvailable goes with it — with the emitter gone, nothing can ever fire it.
What this does not fix: the fallback endpoint above, and #41's observation that the signing key is reachable from mutable action refs. Startup cost of the renderer-side check is #54's territory; that PR takes it off the critical path, this one stops the second copy from running at all.
The honest limit: a real signed release is needed to exercise download → verify → install end to end, and that has not been done here. What has been verified is that nothing listens for the event, that both paths call the same function, that the release-only half still compiles after removal (cargo check --workspace --release), and that the renderer path still raises the notification (tests in desktop/src/stores/update.test.ts).
PR: #66.
Measured against
v0.1.2-alpha.4(4abcf33) for the event wiring: source read for the wiring itself,cargo check --workspace --releasefor thecfg-gated half. The endpoint section below is re-measured againstv0.1.2-alpha.5(df63cc0), the current shipping build, with live requests to the GitHub API and to the configured updater endpoint.A release build runs the update check twice, and the copy that runs first is the one nobody can see.
The backend check reports to an empty room
desktop/src-tauri/src/main.rs:39-46spawns a check two seconds aftersetup, gated onnot(debug_assertions):check_for_updates_silent(main.rs:109-126) reports its result by emittingupdate-available. The only thing in the repo that subscribes to that event name isapi.onUpdateAvailable(desktop/src/api.ts:132), and a repo-wide grep foronUpdateAvailablereturns exactly one hit — its own definition. Nothing indesktop/src,desktop/e2eorscripts/calls it. Tauri'semitto no listener is a no-op with a discardedResult(let _ = app.emit(...)), so in a release build the app performs an HTTPS round-trip toapi.github.com, writes the answer to the log atinfo, and drops it. A user on a release build who has an update waiting learns nothing from this path, ever.The two checks are the same check
check_for_updates_silentcallscommands::check_with_prerelease_endpoint(commands.rs:849). So does thecheck_for_updatescommand (commands.rs:911) that the renderer invokes fromApp.vue:71viastores/update.ts:101. Same function, same GitHub API query, same semver pick, samelatest.jsonendpoint — the only differences are that the backend copy fires on a 2-second timer instead of at mount, exists only in release builds, and cannot reach the UI.The backend copy is not a periodic check and not a watchdog. It is a one-shot at startup, at roughly the same moment as the renderer's one-shot at startup, catching nothing its twin does not. There is no "an update appeared while the app was running" case that it covers: neither path re-checks after boot except when the user asks from Settings (
SettingsView.vue:249) or the command palette (CommandPalette.vue:79).So the release-build cost is two GitHub API requests per launch where one is needed, and the visible half is the one that stayed.
The configured fallback endpoint resolves to a stale manifest
Separate from the above, and not fixed by it.
tauri.conf.json:54-56setsThe received wisdom is that this URL cannot resolve, because every release is a prerelease and
/releases/latest/skips prereleases. That is not what the repo looks like.GET /repos/CodeAny-inc/Clavyn/releases/latestanswersv0.1.1— the newest release withprerelease=false— and the endpoint above returns HTTP 200 with{"version": "0.1.1", ...}.It resolves, to a manifest five alphas old:
v0.1.2-alpha.1throughv0.1.2-alpha.5all ship afterv0.1.1. An app on0.1.2-alpha.5that ends up on this endpoint compares itself against0.1.1, finds nothing newer, and reports "Up to date" with no error raised. That part is real, and it will stay real for as long as the newest stable tag trails the newest alpha — the normal state of this repo, sincerelease.yml:46-48marks every alpha/beta/rc as a prerelease.What does not reach it
An earlier revision of this issue named an API outage, a rate limit, and a network that blocks
api.github.qkg1.topbut notgithub.qkg1.topas the triggers. None of the three reach the fallback. All three surface as a visible error instead.check_with_prerelease_endpointopens with(
commands.rs:854), and the?is the whole story.find_latest_json_urlreturnsErrfor:commands.rs:810,github api request: {e}. That is both a network that cannot reachapi.github.qkg1.topand an outage that shows up as a dead socket.commands.rs:813,github api returned {status}. That is the rate limit (403 / 429) and a 5xx outage.commands.rs:819,parse github response: {e}.Each of those propagates through the
?out ofcheck_with_prerelease_endpoint, intocheck_for_updates'sErrarm (commands.rs:932-934, which logs and re-raises), across the IPC boundary into the store'scatch(stores/update.ts:117-120), and ontoupdate.error.SettingsView.vue:44then reads "Update check failed" with the message under it, andUpdateModal.vue:123-127shows it while the modal is open.infois never assigned on that path, so nothing replaces a real answer with a made-up one.One rough edge, much smaller than what was claimed but worth writing down: the detail line under the headline (
SettingsView.vue:226-238) branches onupdate.infobeforeupdate.error, unlike the status icon (:217), which guards its success case with!update.error. So a check that fails after an earlier one succeeded shows the error headline and the error icon over the stale detail "You're on the latest version". A boot-time failure has no earlierinfoand reads correctly; a failed re-check contradicts itself in the third line.What does reach it
Exactly one condition:
find_latest_json_urlsucceeds and returnsOk(None)(commands.rs:844, consumed atcommands.rs:856-859). That needs a 2xx response whose body parses as a release list in which no entry both carries a semver-parsable tag and alatest.jsonasset.That is a long way from where the repo is.
GITHUB_API_RELEASES_URLasks forper_page=30; the repo has 21 releases, and 20 of them carrylatest.json— onlyv0.1.1-alpha.9does not. The loop picks the highest semver among the releases that have the asset, so one release missing it is skipped rather than causing a fallback. Every release in the page has to lack it.So the realistic triggers are not network conditions:
latest.json, sustained long enough to cover the newest 30 releases;[];CodeAny-inc/Clavynanswers 200 with an empty release list.Severity
Latent. The stale manifest is a real defect and the silent "Up to date" is a real consequence of reaching it, but nothing the app encounters in normal operation reaches it, and the failures that were previously blamed for it are all visible errors. Worth fixing before one of the triggers above arrives; not worth treating as a false negative users are hitting today.
There is no static GitHub URL that resolves to "newest release including prereleases", so this is not a one-line endpoint swap. Filing it here so it is written down rather than fixed in passing.
Fix
Delete the backend boot check, leaving the renderer's
check_for_updatesas the single path that reports availability. It is the one that already feedsinfo,lastChecked,error, the dismissed-version state and theshouldNotifywatcher that opens the modal (App.vue:43-45), and the one that already runs on every build rather than only on release builds. Wiring a listener forupdate-availableinstead would leave two callers of the same function racing to set the same store state, for no coverage the renderer does not have.api.onUpdateAvailablegoes with it — with the emitter gone, nothing can ever fire it.What this does not fix: the fallback endpoint above, and #41's observation that the signing key is reachable from mutable action refs. Startup cost of the renderer-side check is #54's territory; that PR takes it off the critical path, this one stops the second copy from running at all.
The honest limit: a real signed release is needed to exercise download → verify → install end to end, and that has not been done here. What has been verified is that nothing listens for the event, that both paths call the same function, that the release-only half still compiles after removal (
cargo check --workspace --release), and that the renderer path still raises the notification (tests indesktop/src/stores/update.test.ts).PR: #66.