feat(web,docs): the Updates card — version, channel, install kind, and the exact update command

Notify-only (U0): polls /update/status (which keeps the host's manifest cache
warm), Check-now with the 30s limit surfaced, release-notes link, stale-feed and
check-disabled states, copyable per-install-kind command. en+de strings; docs-site
'Updating the Host' page wired into the install section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 14:34:47 +02:00
co-authored by Claude Fable 5
parent c4c4f217bf
commit b275e6d34c
7 changed files with 295 additions and 4 deletions
+1
View File
@@ -15,6 +15,7 @@
"steamos-host",
"windows-host",
"web-console",
"updating",
"---Configure your desktop---",
"configuration",
"kde",
+60
View File
@@ -0,0 +1,60 @@
---
title: Updating the Host
description: How to see when a newer punktfunk host is available — the web console's update card — and the update command for every install method.
---
The web console tells you when a newer host is out. The **Host** page has an **Updates** card
showing the version you run, the channel you follow (stable or canary), how this host was
installed, and — once a newer release exists — the exact command that updates it. The
"update available" state also fires an `update.available` event on the host
[event stream](/docs/automation), so hooks and scripts can react to it too.
The check is a small signed manifest the host fetches from the punktfunk release feed and
verifies against keys built into the host itself — a tampered or replayed feed is rejected, and
the console will tell you when a check failed rather than silently showing stale facts.
## Updating, per install method
The console shows the right one of these automatically; for reference:
| How you installed | How to update |
|---|---|
| Windows installer / winget | `winget upgrade unom.PunktfunkHost`, or run the newer `punktfunk-host-setup.exe` |
| Ubuntu / Debian (apt) | `sudo apt update && sudo apt install --only-upgrade punktfunk-host` |
| Fedora (dnf) | `sudo dnf upgrade punktfunk` |
| Bazzite sysext (recommended) | `sudo punktfunk-sysext update` |
| Bazzite rpm-ostree layer | `sudo /usr/share/punktfunk/update-punktfunk.sh` (staged — reboot to finish) |
| Arch / CachyOS (pacman) | `sudo pacman -Syu` (a normal full system upgrade) |
| Steam Deck (on-device build) | `bash ~/punktfunk/scripts/steamdeck/update.sh --pull` |
| NixOS | update the flake input and rebuild |
After a Linux package update, restart the host to pick up the new binary:
```bash
systemctl --user restart punktfunk-host
```
(The Windows installer restarts the service itself; `punktfunk-sysext update` prints the same
restart hint when it's needed.)
One-click updating from the console is on the way, per install method — the card will grow an
**Apply** button where the platform supports it.
## Turning the check off
The check contacts `git.unom.io` (the punktfunk forge) and nothing else, and sends nothing but a
normal download request. If you'd rather the host never checks, set:
```bash
PUNKTFUNK_UPDATE_CHECK=0
```
in the host's environment (`host.env` on Windows, the systemd user unit environment on Linux).
The card then shows checks as disabled; everything else keeps working.
## If the card says the feed is stale
"Feed hasn't changed in over 45 days" means checks *succeed* but nothing new arrives. Usually
that just means no release happened for a while; if the [releases page](https://git.unom.io/unom/punktfunk/releases)
shows something newer than the card does, something between this host and the feed is pinning old
data — worth a look at proxies or DNS on the way to `git.unom.io`.
+18 -1
View File
@@ -439,5 +439,22 @@
"display_monitor_unsupported": "Das Übertragen eines dieser Bildschirme wird auf diesem Host noch nicht unterstützt — das gibt es bisher nur auf Linux-Hosts. Die Bildschirme sind hier aufgeführt, damit du siehst, was dieser Computer hat; Clients bekommen weiterhin ihren eigenen virtuellen Bildschirm.",
"display_monitor_primary": "primär",
"display_monitor_disabled": "aus",
"display_monitor_saved": "Übertragener Bildschirm gespeichert"
"display_monitor_saved": "Übertragener Bildschirm gespeichert",
"update_title": "Updates",
"update_available_badge": "Update verfügbar",
"update_current": "Installiert",
"update_install_kind": "Installationsart",
"update_latest": "Neueste Version",
"update_notes": "Versionshinweise",
"update_up_to_date": "Du bist auf dem neuesten Stand.",
"update_how": "Diesen Host aktualisieren mit:",
"update_check_now": "Jetzt prüfen",
"update_checking": "Prüfe…",
"update_last_checked": "Zuletzt geprüft",
"update_never_checked": "Noch nicht geprüft",
"update_stale": "Der Update-Feed hat sich seit über 45 Tagen nicht geändert — Prüfungen gelingen, aber es kommt nichts Neues an. Falls das nicht stimmen kann, prüfe die Verbindung dieses Hosts zu git.unom.io.",
"update_disabled": "Update-Prüfungen sind auf diesem Host deaktiviert (PUNKTFUNK_UPDATE_CHECK=0).",
"update_error": "Letzte Prüfung fehlgeschlagen:",
"update_copy": "Kopieren",
"update_copied": "Kopiert"
}
+18 -1
View File
@@ -439,5 +439,22 @@
"display_monitor_unsupported": "Streaming one of these screens isn't supported on this host yet — it's available on Linux hosts only. The screens are listed so you can see what this computer has; clients keep getting their own virtual screen.",
"display_monitor_primary": "primary",
"display_monitor_disabled": "off",
"display_monitor_saved": "Streamed screen saved"
"display_monitor_saved": "Streamed screen saved",
"update_title": "Updates",
"update_available_badge": "Update available",
"update_current": "Installed",
"update_install_kind": "Install type",
"update_latest": "Latest release",
"update_notes": "Release notes",
"update_up_to_date": "You're up to date.",
"update_how": "Update this host with:",
"update_check_now": "Check now",
"update_checking": "Checking…",
"update_last_checked": "Last checked",
"update_never_checked": "Not checked yet",
"update_stale": "The update feed hasn't changed in over 45 days — checks succeed but nothing new arrives. If that seems wrong, check this host's connectivity to git.unom.io.",
"update_disabled": "Update checks are disabled on this host (PUNKTFUNK_UPDATE_CHECK=0).",
"update_error": "Last check failed:",
"update_copy": "Copy",
"update_copied": "Copied"
}
+184
View File
@@ -0,0 +1,184 @@
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { type FC, type ReactNode, useState } from "react";
import {
getGetUpdateStatusQueryKey,
useForceUpdateCheck,
useGetUpdateStatus,
} from "@/api/gen/update/update";
import type { UpdateStatus } from "@/api/gen/model";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
/**
* Container: the host update-check card (U0 — notify-only). Reading status is what keeps the
* host's manifest cache warm (the host kicks its own background refresh when the cache is >6 h
* old), so a modest poll doubles as the check cadence while the console is open. Apply buttons
* arrive with the per-channel apply legs (U1 Windows, U2 Linux helper); until then the card's
* action is the exact update command for this install kind.
*/
export const UpdateSection: FC = () => {
const qc = useQueryClient();
const status = useGetUpdateStatus({ query: { refetchInterval: 60_000 } });
const check = useForceUpdateCheck();
const checkNow = () =>
check.mutate(undefined, {
onSuccess: (fresh) => {
qc.setQueryData(getGetUpdateStatusQueryKey(), fresh);
},
});
return <UpdateCard state={status} onCheck={checkNow} busy={check.isPending} />;
};
export const UpdateCard: FC<{
state: Loadable<UpdateStatus>;
onCheck: () => void;
busy: boolean;
}> = ({ state, onCheck, busy }) => {
const s = state.data;
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{m.update_title()}</CardTitle>
{s?.available && <Badge>{m.update_available_badge()}</Badge>}
</CardHeader>
<CardContent className="space-y-4">
<QueryState
isLoading={state.isLoading}
error={state.error}
refetch={state.refetch}
>
{s && (
<>
<dl className="grid grid-cols-1 gap-3">
<UpdateRow
label={m.update_current()}
value={
<span className="flex items-center gap-2 font-medium">
{s.current_version}
<Badge variant="secondary">{s.channel}</Badge>
<Badge variant="outline" title={m.update_install_kind()}>
{s.install_kind}
</Badge>
</span>
}
/>
<UpdateRow
label={m.update_latest()}
value={
s.manifest ? (
<span className="flex items-center gap-2 font-medium">
{s.manifest.version}
{s.manifest.notes_url && (
<a
href={s.manifest.notes_url}
target="_blank"
rel="noreferrer"
className="text-sm font-normal underline underline-offset-2"
>
{m.update_notes()}
</a>
)}
</span>
) : (
<span className="text-sm text-muted-foreground">
{m.update_never_checked()}
</span>
)
}
/>
</dl>
{s.available ? (
<div className="space-y-2 rounded-md border p-4">
<p className="text-sm">{m.update_how()}</p>
<CommandLine command={s.channel_hint} />
</div>
) : (
s.manifest && (
<p className="text-sm text-muted-foreground">
{m.update_up_to_date()}
</p>
)
)}
{s.manifest?.stale && (
<p className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
{m.update_stale()}
</p>
)}
{s.last_error && (
<p className="text-sm text-destructive">
{m.update_error()} {s.last_error}
</p>
)}
{s.check_disabled ? (
<p className="text-sm text-muted-foreground">
{m.update_disabled()}
</p>
) : (
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={onCheck}
disabled={busy}
>
{busy ? m.update_checking() : m.update_check_now()}
</Button>
{s.last_checked_unix != null && (
<span className="text-xs text-muted-foreground">
{m.update_last_checked()}{" "}
{new Date(s.last_checked_unix * 1000).toLocaleString()}
</span>
)}
</div>
)}
</>
)}
</QueryState>
</CardContent>
</Card>
);
};
const UpdateRow: FC<{ label: string; value: ReactNode }> = ({
label,
value,
}) => (
<div className="flex items-baseline justify-between gap-4">
<dt className="text-sm text-muted-foreground">{label}</dt>
<dd>{value}</dd>
</div>
);
/** The copy-pastable update command, with a small clipboard affordance. */
const CommandLine: FC<{ command: string }> = ({ command }) => {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard
.writeText(command)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(() => {
/* clipboard denied — the text is selectable, nothing to do */
});
};
return (
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 overflow-x-auto rounded bg-muted px-2 py-1.5 text-xs">
{command}
</code>
<Button variant="ghost" size="sm" onClick={copy}>
{copied ? m.update_copied() : m.update_copy()}
</Button>
</div>
);
};
+9 -1
View File
@@ -2,6 +2,7 @@ import type { FC } from "react";
import { useGetHostInfo, useListCompositors } from "@/api/gen/host/host";
import { useLocale } from "@/lib/i18n";
import { GpuSection } from "./GpuCard";
import { UpdateSection } from "./UpdateCard";
import { HostView } from "./view";
export const SectionHost: FC = () => {
@@ -9,5 +10,12 @@ export const SectionHost: FC = () => {
const host = useGetHostInfo();
const compositors = useListCompositors();
return <HostView host={host} compositors={compositors} gpu={<GpuSection />} />;
return (
<HostView
host={host}
compositors={compositors}
gpu={<GpuSection />}
update={<UpdateSection />}
/>
);
};
+5 -1
View File
@@ -14,7 +14,9 @@ export const HostView: FC<{
compositors: Loadable<AvailableCompositor[]>;
/** The GPU inventory/selection card (a self-contained container — see `GpuCard.tsx`). */
gpu?: ReactNode;
}> = ({ host, compositors, gpu }) => {
/** The update-check card (a self-contained container — see `UpdateCard.tsx`). */
update?: ReactNode;
}> = ({ host, compositors, gpu, update }) => {
const h = host.data;
return (
<Section maxWidth={false}>
@@ -88,6 +90,8 @@ export const HostView: FC<{
)}
</QueryState>
{update}
{gpu}
<Card>