Skip to content

feat(app): update the desktop app from its own menu - #161

Merged
BryanFRD merged 3 commits into
ci/desktop-installersfrom
feat/desktop-in-app-update
Sep 20, 2026
Merged

BryanFRD merged 3 commits into
ci/desktop-installersfrom
feat/desktop-in-app-update

Conversation

@BryanFRD

@BryanFRD BryanFRD commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

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_update and install_update commands over tauri-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: createUpdaterArtifacts signs each installer and drops a .sig beside it, both are attached to the release, and a manifest job then writes latest.json from 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 at
the 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_PASSWORD that does
not 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 nsis produced RoxyCloud_0.25.2_x64-setup.exe and RoxyCloud_0.25.2_x64-setup.exe.sig at the two paths the workflow globs. Enabling createUpdaterArtifacts without 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.

@BryanFRD
BryanFRD added this pull request to stack #162 September 20, 2026 10:44

@ferrfleet ferrfleet Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread app/tauri.conf.json Outdated
Comment thread app/tauri.conf.json
"plugins": {
"updater": {
"endpoints": [
"https://github.com/FerrLabs/RoxyCloud/releases/latest/download/latest.json"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread app/Cargo.toml
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: serde_json is not referenced anywhere in the crate (app/src/main.rs is its only file, and it uses serde::Serialize only).

Suggested change

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread app/src/main.rs Outdated
Comment thread .github/workflows/desktop.yml Outdated

@ferrfleet ferrfleet Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@BryanFRD
BryanFRD marked this pull request as ready for review September 20, 2026 10:54
Signed-off-by: BryanFRD <bryanferrando59@gmail.com>
Signed-off-by: BryanFRD <bryanferrando59@gmail.com>
Signed-off-by: BryanFRD <bryanferrando59@gmail.com>
@BryanFRD
BryanFRD force-pushed the feat/desktop-in-app-update branch from c46b368 to 4571f02 Compare September 20, 2026 10:59

@ferrfleet ferrfleet Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread app/src/main.rs
Comment on lines +78 to +84
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")?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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")?;

@BryanFRD
BryanFRD merged commit 2c0f8c4 into main Sep 20, 2026
13 of 16 checks passed
@BryanFRD
BryanFRD deleted the feat/desktop-in-app-update branch September 20, 2026 11:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Publier un installeur de bureau (Windows, Linux) à chaque release

1 participant