diff --git a/docs-site/content/docs/updating.md b/docs-site/content/docs/updating.md
index b53862e7..0bb68e49 100644
--- a/docs-site/content/docs/updating.md
+++ b/docs-site/content/docs/updating.md
@@ -50,8 +50,27 @@ Every attempt leaves a result in the card (and an installer log under
failed update is never silent. To disable the button entirely on a host, set
`PUNKTFUNK_UPDATE_APPLY=0` in `host.env`; the card then shows the manual command instead.
-One-click updating for the Linux install methods is on the way — it will be opt-in per host
-(joining a `punktfunk-update` group) because it needs a narrowly-scoped root helper.
+## One-click updating (Linux — opt-in)
+
+The apt, dnf, Bazzite-sysext, and rpm-ostree installs can one-click update too, via a small
+root helper the packages ship (`pf-update` + a `punktfunk-update.service` oneshot). It's **off
+until you opt in**, because a web button that ends in root deserves an explicit decision:
+
+```bash
+sudo usermod -aG punktfunk-update $USER # then log out and back in
+```
+
+That group membership is the entire grant — a polkit rule lets its members start exactly that
+one service, whose only job is "run this system's normal package update for the punktfunk
+packages, then prove the new binary runs". The button never chooses versions or URLs; your
+package manager's own signed repositories stay the source of truth. The card shows the opt-in
+command until you've done this, and the manual command always keeps working.
+
+Notes per method: on **rpm-ostree** the update is staged and the card will say so — reboot to
+finish (the console never reboots your machine). On **Arch/pacman** the button additionally
+requires `PACMAN_FULL_SYSUPGRADE=1` in `/etc/punktfunk/update.conf`, because the only safe
+pacman update is a full `pacman -Syu` — partial upgrades are how Arch boxes break, and we
+won't run one. After a successful update the host restarts itself and the page reconnects.
## Turning the check off
diff --git a/web/messages/de.json b/web/messages/de.json
index 39b215cf..6e2c37c9 100644
--- a/web/messages/de.json
+++ b/web/messages/de.json
@@ -476,6 +476,9 @@
"update_stage_restarting": "Der Host startet neu…",
"update_stage_reconnecting": "Warte, bis der Host wieder da ist…",
"update_result_ok": "Aktualisiert {from} → {to}.",
+ "update_result_staged": "Update auf {to} ist vorbereitet — zum Abschließen den Host neu starten.",
+ "update_result_noop": "Deine Paketquelle hatte noch nichts Neueres — in ein paar Minuten erneut versuchen.",
+ "update_opt_in": "Um Ein-Klick-Updates von hier zu aktivieren, einmal auf dem Host ausführen (danach ab- und wieder anmelden):",
"update_result_failed": "Update auf {to} ist in Phase {stage} fehlgeschlagen.",
"update_result_log": "Installer-Log:"
}
diff --git a/web/messages/en.json b/web/messages/en.json
index f0c3ef92..63a1a87f 100644
--- a/web/messages/en.json
+++ b/web/messages/en.json
@@ -476,6 +476,9 @@
"update_stage_restarting": "The host is restarting…",
"update_stage_reconnecting": "Waiting for the host to come back…",
"update_result_ok": "Updated {from} → {to}.",
+ "update_result_staged": "Update to {to} is staged — reboot this host to finish.",
+ "update_result_noop": "Your package source had nothing newer yet — try again in a few minutes.",
+ "update_opt_in": "To enable one-click updates from here, run this once on the host (then log out and back in):",
"update_result_failed": "Update to {to} failed during {stage}.",
"update_result_log": "Installer log:"
}
diff --git a/web/src/sections/Host/UpdateCard.tsx b/web/src/sections/Host/UpdateCard.tsx
index cd5f5f99..f1593ce9 100644
--- a/web/src/sections/Host/UpdateCard.tsx
+++ b/web/src/sections/Host/UpdateCard.tsx
@@ -48,14 +48,15 @@ export const UpdateSection: FC = () => {
const check = useForceUpdateCheck();
const s = status.data;
- // The apply resolved: the host answers with the target version (success — reconcile wrote
- // last_result), or reports a failed attempt at our target.
+ // The apply resolved: either the host answers with the target version (reconcile wrote
+ // last_result across the restart), or ANY apply outcome newer than our start arrived —
+ // which covers failure, "staged, reboot to finish", and "your package source had nothing
+ // newer yet" (where the version deliberately doesn't change).
if (applying && s) {
if (
s.current_version === applying.target ||
(s.last_result &&
- s.last_result.to === applying.target &&
- !s.last_result.ok)
+ s.last_result.finished_unix * 1000 >= applying.startedAt - 60_000)
) {
setApplying(null);
}
@@ -157,12 +158,20 @@ export const UpdateCard: FC<{
timedOut={timedOut}
/>
) : s.available ? (
- s.apply === "full" ? (
+ s.apply === "full" || s.apply === "staged" ? (
{m.update_how()}
+ {m.update_opt_in()} +
+- {m.update_result_ok({ from: result.from, to: result.to })} + {result.staged + ? m.update_result_staged({ to: result.to }) + : result.from === result.to + ? m.update_result_noop() + : m.update_result_ok({ from: result.from, to: result.to })}
) : (