feat(app): update the desktop app from its own menu - #161
Conversation
There was a problem hiding this comment.
One blocking item: the bundled pubkey is still PUBKEY_PLACEHOLDER, which makes the whole feature error out at the first click. The rest are nits.
The parts I checked and found correct: nullglob is set in the bundle step, so the new signatures emptiness check is a real check and not a literal glob; -setup.exe / .AppImage and their .sig names match what Tauri v2 writes with createUpdaterArtifacts; the windows-x86_64 / linux-x86_64 platform keys and the url/apiUrl asset fields are the right ones; manifest is skipped when either matrix leg fails, so a half-built release cannot get a manifest; app.restart() after download_and_install is right for the AppImage path and unreachable on Windows, where the plugin exits itself. The dialog cannot be dismissed mid-install, and the cancel handler holds too.
| "plugins": { | ||
| "updater": { | ||
| "endpoints": [ | ||
| "https://github.com/FerrLabs/RoxyCloud/releases/latest/download/latest.json" |
There was a problem hiding this comment.
Nit: releases/latest/download/latest.json resolves to whatever the newest non-prerelease release is, which is not necessarily one this workflow ran for. A release published while the Desktop job fails, or one that carries only the API image, becomes "latest" without a latest.json, GitHub answers 404, and the plugin surfaces that as an error to people who are in fact up to date.
Worth deciding now rather than later, since the endpoint is baked into every installed copy and cannot be changed retroactively. The alternative I'd pick is the releases API endpoint Tauri supports (https://api.github.com/repos/FerrLabs/RoxyCloud/releases/latest is not a manifest, so that means keeping this shape but making the manifest job a hard gate on publishing) or hosting latest.json at a fixed URL you control. Either way the current form is fine if the Desktop workflow failing is treated as a release failure.
There was a problem hiding this comment.
Deciding it as it stands, on purpose. Every release here is one version of the whole repository, this workflow runs for each of them, and the manifest job is part of it, so a release without latest.json means the job failed and the operator has a red run to fix. The alternative, a manifest hosted somewhere independent of releases, buys a stable URL at the cost of a second place to publish to and keep alive; not worth it for a repository that cuts one release at a time. The 404 window is real and it is short: it opens when the Desktop workflow fails and closes when it is rerun, which workflow_dispatch on the tag does. Worth revisiting if it ever happens twice.
| tokio = { workspace = true } | ||
| serde = { workspace = true } | ||
| serde_json = { workspace = true } | ||
|
|
There was a problem hiding this comment.
Nit: serde_json is not referenced anywhere in the crate (app/src/main.rs is its only file, and it uses serde::Serialize only).
There was a problem hiding this comment.
It is needed, and not by my code: tauri::generate_context! expands to code that names ::serde_json as soon as the config has a plugins section, which this commit adds. Without the dependency the build fails with error[E0433]: cannot find serde_json in the crate root pointing at the macro call in main. That is how the dependency got added in the first place.
There was a problem hiding this comment.
The blocking PUBKEY_PLACEHOLDER is resolved in c46b368: the value is a well-formed minisign public key whose key id matches its own comment line. Nothing else changed, and I found nothing new. The open nits stand as written; none of them hold up a merge.
Signed-off-by: BryanFRD <bryanferrando59@gmail.com>
Signed-off-by: BryanFRD <bryanferrando59@gmail.com>
Signed-off-by: BryanFRD <bryanferrando59@gmail.com>
c46b368 to
4571f02
Compare
There was a problem hiding this comment.
Both earlier findings that moved are fixed: the install now uses the offer check_update stored (4571f02), and the manifest job checks the signature URLs before fetching them (1f968ca). One new nit on the state handling, no blocking finding. The Mutex<Option<Update>> on Desktop is correct otherwise: Default still derives, a check with no new version clears a stale offer, and the version and notes are cloned for the dialog before the offer is stored.
| async fn install_update(desktop: State<'_, Desktop>, app: AppHandle) -> Result<(), String> { | ||
| let update = desktop | ||
| .offered | ||
| .lock() | ||
| .await | ||
| .take() | ||
| .ok_or("check for updates before installing one")?; |
There was a problem hiding this comment.
Nit: take() consumes the offer even when the install fails, so a retry cannot work. download_and_install fails on a dropped connection or a signature mismatch, update.ts catches it, clears installing, and leaves Install enabled; the second click finds offered empty and reports "check for updates before installing one", which is not what went wrong and sends the user back through the dialog to get the offer again.
Holding the guard instead keeps the offer for a retry and, as a side effect, serialises a concurrent check_update against a running install rather than letting it overwrite the offer mid-download.
| async fn install_update(desktop: State<'_, Desktop>, app: AppHandle) -> Result<(), String> { | |
| let update = desktop | |
| .offered | |
| .lock() | |
| .await | |
| .take() | |
| .ok_or("check for updates before installing one")?; | |
| async fn install_update(desktop: State<'_, Desktop>, app: AppHandle) -> Result<(), String> { | |
| let offered = desktop.offered.lock().await; | |
| let update = offered | |
| .as_ref() | |
| .ok_or("check for updates before installing one")?; |
Closes #150, on top of #160.
An installed copy had no way to learn about a new version. The account menu gets "Check for updates": it reads the manifest attached to the newest release, says whether there is anything newer, shows the release notes, and installs on request, which restarts the app on the new version.
The check and the install both live in Rust, as
check_updateandinstall_updatecommands overtauri-plugin-updater. The web side only calls them, so there is no capability file to keep in step with what the frontend is allowed to invoke.The release side gains what the app needs to trust an update:
createUpdaterArtifactssigns each installer and drops a.sigbeside it, both are attached to the release, and amanifestjob then writeslatest.jsonfrom the release assets once both platforms are up. Losing the signing key means no installed copy accepts any later version, which the README says out loud.Before merging
The key pair exists and its public half is committed here. The private half still has to reach the
repository secrets as
TAURI_SIGNING_PRIVATE_KEY, or the first release after this merge fails atthe bundle step rather than publishing something no installed copy can verify. The key carries no
passphrase, so there is no second secret to set: a
TAURI_SIGNING_PRIVATE_KEY_PASSWORDthat doesnot exist renders as the empty string, which is what the CLI wants for an unprotected key.
Verification
The bundle was run against a throwaway key pair, discarded afterwards:
pnpm exec tauri build --bundles nsisproducedRoxyCloud_0.25.2_x64-setup.exeandRoxyCloud_0.25.2_x64-setup.exe.sigat the two paths the workflow globs. EnablingcreateUpdaterArtifactswithout the plugin config fails the bundle outright, which is why both land in the same commit.The dialog was driven in a browser with the Tauri bridge stubbed, through all three answers: a newer version shows its number, the notes and Install, and Install calls
install_update; no newer version reads "You are running the latest version"; a failing check shows the error rather than a dialog that says nothing. The manifest step's jq was run against a release listing to check it picks the installer rather than its signature, and writes the browser download URLs.