Implements design/host-actions.md — phases P0 (host core), P1 (web console) and P2 (every client). P3 (plugin-provided actions) is deliberately left for when the first real plugin action exists; §8 of the design is its contract.
Wake-on-LAN has always let a client wake a sleeping host. Nothing could put it back. This closes that loop: Sleep host, Restart host and Shut down host, from the web console or from a paired client, gated per device by a new grant.
The shape
A small host-action registry rather than three power endpoints, so future verbs slot in without new plumbing:
GET /api/v1/actions — what this host offers as seen by the caller: platform availability with an honest reason when something can't run, and whether this caller may invoke it.
POST /api/v1/actions/{id} — id-only, empty body. No request field ever reaches the privileged path (the host-update invariant).
Clients render the list generically: known ids get local wording, unknown ids get the host's own title. A later host action needs no client release to appear.
Full control includes it, on purpose. A device holding KEYBOARD+POINTER can already power the host off through the streamed desktop's own menu, so "full control except power" would be a lock painted on an open door. The bit's real job is keeping power away from limited devices — the controller-only guest, the view-only spectator — which it does.
A legacy-full read rule keeps that honest for existing records: a stored mask that is exactly the old GRANT_ALL was an explicit "Full control" from before Power existed, so it reads as the current one. Without it those devices would render as "Custom" everywhere while silently lacking Power. Applied in the host store, moonlight_effective, and every client mirror.
Execution
Ordering on accept, all platforms: reply 202 → end every session with a typed close → ~1 s grace → act. The client sees "the host is going to sleep or shutting down", not a dropped connection.
Linux: logind over zbus, authorized by the polkit rule that already ships (49-punktfunk-power.rules). Deliberately still without -ignore-inhibit / -multiple-sessions: a foreign inhibitor or a second local user makes logind refuse, and that refusal is surfaced as an honest 409 rather than steamrolled.
Windows: the interactive user token's own SeShutdownPrivilege + InitiateSystemShutdownExW / SetSuspendState.
macOS host: 501, listed unavailable.
Refusals are specific: another device streaming is a 409 with the reason, an ungranted device a 403, an unknown id a 404. One action in flight host-wide; denials logged once per (device, action) per boot.
Clients
The rows appear in each host's own menu, exactly where "Wake host" sits when the machine is asleep — the gamepad console, the GTK and Windows host cards, the Android touch home and Skia console, the Apple card and gamepad options.
Two decisions worth flagging for review:
Discovery is pre-fetched on each shell's existing refresh tick, never fetched when a menu opens. The console screen holds a cloned row, and rows that appear under a cursor already in motion are a hazard when two of them shut a machine down. One shared TTL cache in pf_client_core::host_actions serves the console, GTK and Windows, so three shells can't disagree about what a host offers.
The console's arming state became which row is armed, not a boolean. As a flag, arming "Forget" left "Shut down host" live one row away. Restart and shut down confirm; sleep is reversible from the same menu, so it goes on one press.
An action the host says it cannot run right now stays listed, disabled, and explains itself rather than vanishing.
Also in here
The tray's "Restart host" row restarts the service, and clients are now about to use that phrase for the machine — renamed "Restart Punktfunk" before two adjacent surfaces mean two things by one phrase.
pf-client-core::preset_label compared against GRANT_ALL unmasked, so an old client would have rendered a new host's Full session as "Custom". Masked.
Docs: a new Host power page, an access-levels row, and the Wake-on-LAN page's now-false "Punktfunk never puts a host to sleep" bullet corrected.
Real Windows box (.133): punktfunk-host, punktfunk-client-windows, punktfunk-tray clippy --all-targets -D warnings
clean (cold build, all three genuinely compiled)
Web console build + server tests + biome
clean / 13 pass
Regenerated and committed: api/openapi.json, the docs-site copy, include/punktfunk_core.h, the SDK client.
The Windows run matters more than usual here: punktfunk-host has had no pre-merge Windows gate since #408 (the package job carrying it only runs on push to main), and clients/windows is outside xcheck's coverage entirely. Both are verified on the real box for this branch. Two would-be Windows breaks were caught and fixed on the way — IsPwrSuspendAllowed and SetSuspendState return a plain bool in windows-rs 0.62, not a BOOL with .as_bool().
Open
§11 decisions were taken as the design proposed, not confirmed by anyone: legacy-full read rule on, another device's live session hard-blocks a cert-lane invoke, "Sleep host" naming plus the tray rename, in-session quit-menu variant deferred, no plugin-lane read access. Any of these is cheap to flip before merge.
On-glass is owed: no real sleep/reboot has been run against .21 or a Windows box. The P0 exit criterion in the design is a curl-level invoke on both.
Verify the sysext/Bazzite channel ships 49-punktfunk-power.rules — the Linux leg is authorized by it and the design flags this as unchecked.
The in-session "Disconnect and sleep host" quit-menu variant is not built.
Implements `design/host-actions.md` — phases P0 (host core), P1 (web console) and P2 (every client). P3 (plugin-provided actions) is deliberately left for when the first real plugin action exists; §8 of the design is its contract.
Wake-on-LAN has always let a client wake a sleeping host. Nothing could put it back. This closes that loop: **Sleep host**, **Restart host** and **Shut down host**, from the web console or from a paired client, gated per device by a new grant.
## The shape
A small **host-action registry** rather than three power endpoints, so future verbs slot in without new plumbing:
- `GET /api/v1/actions` — what this host offers **as seen by the caller**: platform availability with an honest reason when something can't run, and whether *this* caller may invoke it.
- `POST /api/v1/actions/{id}` — id-only, empty body. No request field ever reaches the privileged path (the `host-update` invariant).
Clients render the list generically: known ids get local wording, unknown ids get the host's own title. **A later host action needs no client release to appear.**
## Permissions
`GRANT_POWER` (bit 6) joins the access mask; `GRANT_ALL` goes 0x3F → 0x7F.
**Full control includes it, on purpose.** A device holding KEYBOARD+POINTER can already power the host off through the streamed desktop's own menu, so "full control except power" would be a lock painted on an open door. The bit's real job is keeping power away from *limited* devices — the controller-only guest, the view-only spectator — which it does.
A **legacy-full read rule** keeps that honest for existing records: a stored mask that is exactly the old `GRANT_ALL` was an explicit "Full control" from before Power existed, so it reads as the current one. Without it those devices would render as "Custom" everywhere while silently lacking Power. Applied in the host store, `moonlight_effective`, and every client mirror.
## Execution
Ordering on accept, all platforms: **reply 202 → end every session with a typed close → ~1 s grace → act**. The client sees "the host is going to sleep or shutting down", not a dropped connection.
- **Linux**: logind over zbus, authorized by the polkit rule that already ships (`49-punktfunk-power.rules`). Deliberately still without `-ignore-inhibit` / `-multiple-sessions`: a foreign inhibitor or a second local user makes logind refuse, and that refusal is surfaced as an honest `409` rather than steamrolled.
- **Windows**: the interactive user token's own `SeShutdownPrivilege` + `InitiateSystemShutdownExW` / `SetSuspendState`.
- **macOS host**: `501`, listed unavailable.
Refusals are specific: another device streaming is a `409` with the reason, an ungranted device a `403`, an unknown id a `404`. One action in flight host-wide; denials logged once per (device, action) per boot.
## Clients
The rows appear in each host's own menu, exactly where "Wake host" sits when the machine is asleep — the gamepad console, the GTK and Windows host cards, the Android touch home and Skia console, the Apple card and gamepad options.
Two decisions worth flagging for review:
1. **Discovery is pre-fetched on each shell's existing refresh tick, never fetched when a menu opens.** The console screen holds a cloned row, and rows that appear under a cursor already in motion are a hazard when two of them shut a machine down. One shared TTL cache in `pf_client_core::host_actions` serves the console, GTK and Windows, so three shells can't disagree about what a host offers.
2. **The console's arming state became *which* row is armed, not a boolean.** As a flag, arming "Forget" left "Shut down host" live one row away. Restart and shut down confirm; sleep is reversible from the same menu, so it goes on one press.
An action the host says it cannot run right now stays listed, disabled, and explains itself rather than vanishing.
## Also in here
- The tray's "Restart host" row restarts the **service**, and clients are now about to use that phrase for the **machine** — renamed "Restart Punktfunk" before two adjacent surfaces mean two things by one phrase.
- `pf-client-core::preset_label` compared against `GRANT_ALL` unmasked, so an old client would have rendered a new host's Full session as "Custom". Masked.
- Docs: a new Host power page, an access-levels row, and the Wake-on-LAN page's now-false "Punktfunk never puts a host to sleep" bullet corrected.
## Verification
| Gate | Result |
|---|---|
| `punktfunk-core` (quic) | 504 tests pass |
| `punktfunk-host` clippy `--all-targets -D warnings` (Linux) | clean |
| `punktfunk-host` mgmt / events / native_pairing suites | 61 + 9 + 19 pass |
| Client crates clippy `--all-targets -D warnings` | clean (`pf-client-core`, `pf-console-ui`, session, GTK) |
| Console options + host-actions tests | 16 + 2 pass |
| Swift (`swift build`, full 32-file recompile) | clean |
| Android `:app:compileDebugKotlin`, kit unit tests | clean / pass |
| `scripts/xcheck.sh windows clippy` | clean |
| **Real Windows box (.133)**: `punktfunk-host`, `punktfunk-client-windows`, `punktfunk-tray` clippy `--all-targets -D warnings` | **clean** (cold build, all three genuinely compiled) |
| Web console build + server tests + biome | clean / 13 pass |
Regenerated and committed: `api/openapi.json`, the docs-site copy, `include/punktfunk_core.h`, the SDK client.
The Windows run matters more than usual here: `punktfunk-host` has had no pre-merge Windows gate since #408 (the `package` job carrying it only runs on push to main), and `clients/windows` is outside `xcheck`'s coverage entirely. Both are verified on the real box for this branch. Two would-be Windows breaks were caught and fixed on the way — `IsPwrSuspendAllowed` and `SetSuspendState` return a plain `bool` in windows-rs 0.62, not a `BOOL` with `.as_bool()`.
## Open
- **§11 decisions were taken as the design proposed, not confirmed by anyone**: legacy-full read rule on, another device's live session hard-blocks a cert-lane invoke, "Sleep host" naming plus the tray rename, in-session quit-menu variant deferred, no plugin-lane read access. Any of these is cheap to flip before merge.
- **On-glass is owed**: no real sleep/reboot has been run against `.21` or a Windows box. The P0 exit criterion in the design is a curl-level invoke on both.
- **Verify the sysext/Bazzite channel ships `49-punktfunk-power.rules`** — the Linux leg is authorized by it and the design flags this as unchecked.
- The in-session "Disconnect and sleep host" quit-menu variant is not built.
design/host-actions.md P0 (host core) + P1 (web console). A reusable action
registry — GET /api/v1/actions (per-caller permission + honest availability) and
an id-only POST /api/v1/actions/{id} on the mgmt cert lane — with three power
built-ins, so the Wake-on-LAN round trip finally closes from the couch.
Core: GRANT_POWER (1<<6) joins the mask; GRANT_ALL 0x3F -> 0x7F; the legacy-full
read rule (an explicitly stored pre-power 0x3F reads as the current GRANT_ALL,
so old Full records neither render Custom nor silently lack Power); typed close
RejectReason::HostPower (0x6B) so ended sessions say why.
Host: power executors (logind via zbus behind the already-shipped polkit rule,
deliberately without -ignore-inhibit; Windows SeShutdownPrivilege +
InitiateSystemShutdownExW / SetSuspendState; macOS answers 501); ordering
reply-202 -> typed close -> 1 s grace -> act; single-flight; another device's
live session blocks a cert-lane invoke (409); action.invoked on the event bus;
denials logged once per (fingerprint, action) per boot. The plugin token gets
neither route.
Console: Host-power card (password-confirmed, the update-apply recipe) + a BFF
route that keeps the invoke off the ungated catch-all; Host power toggle in the
Access sheet. Mirrors: web/Kotlin/Swift consts + legacy rule, pf-client-core
preset_label now masks (an old client no longer renders a new host's Full as
Custom). Tray's service-restart row renamed Restart Punktfunk before the
clients ship a machine-level Restart host. Docs: host-power page, access-levels
row, WoL cross-link. openapi/header/SDK regenerated.
design/host-actions.md P2. The host has offered sleep/restart/shutdown since
the previous commit; this is where a person can reach them. Every client's host
menu grows the rows right where Wake host appears when the machine is asleep —
finish on the TV, sleep the host from the same menu that woke it.
One shared core: pf_client_core::host_actions holds the discovery read, the
id-only invoke, and a process-wide TTL cache the console, the GTK page and the
Windows tile all read, so three shells cannot end up disagreeing about what a
host offers. Every shell warms it on a refresh tick it already had.
Discovery is PRE-fetched, never fetched when a menu opens: the console screen
holds a cloned row, and rows that appear under a cursor already moving are a
hazard when two of them shut a machine down. For the same reason the console's
armed state became WHICH row is armed rather than a bare flag — a flag let an
arming press on Forget fire Shut down host. Restart and shut down confirm;
sleep is reversible from the same menu, so it goes on one press. An action the
host says it cannot run right now stays listed, disabled, and says why.
Surfaces: console-UI rows + ConsoleCmd::HostAction with session and Android
dispatch; GTK card menu (plus a card-level Toast) with an AlertDialog confirm;
Windows tile menu, its label built and matched through one function so a menu
whose rows outlived their handlers cannot run a different verb; Android touch
home and Skia console; Apple host card and gamepad options. Unknown ids render
from the host's own title on all of them — a later host action needs no client
release to appear.
Typed close: the Swift hostPower case and the Kotlin host-power token. Without
them, sleeping your own host from the couch reads as a crash on those two
clients.
The docs-drift ratchet matches PUNKTFUNK_* by token spelling, not by env
read, so the cbindgen #defines this branch adds — GRANT_POWER,
GRANT_ALL_PRE_POWER and HOST_POWER_CLOSE_CODE — read as new undocumented
knobs. They are ABI constants, not knobs: every sibling grant bit and
close code is already in this file for the same reason.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Implements
design/host-actions.md— phases P0 (host core), P1 (web console) and P2 (every client). P3 (plugin-provided actions) is deliberately left for when the first real plugin action exists; §8 of the design is its contract.Wake-on-LAN has always let a client wake a sleeping host. Nothing could put it back. This closes that loop: Sleep host, Restart host and Shut down host, from the web console or from a paired client, gated per device by a new grant.
The shape
A small host-action registry rather than three power endpoints, so future verbs slot in without new plumbing:
GET /api/v1/actions— what this host offers as seen by the caller: platform availability with an honest reason when something can't run, and whether this caller may invoke it.POST /api/v1/actions/{id}— id-only, empty body. No request field ever reaches the privileged path (thehost-updateinvariant).Clients render the list generically: known ids get local wording, unknown ids get the host's own title. A later host action needs no client release to appear.
Permissions
GRANT_POWER(bit 6) joins the access mask;GRANT_ALLgoes 0x3F → 0x7F.Full control includes it, on purpose. A device holding KEYBOARD+POINTER can already power the host off through the streamed desktop's own menu, so "full control except power" would be a lock painted on an open door. The bit's real job is keeping power away from limited devices — the controller-only guest, the view-only spectator — which it does.
A legacy-full read rule keeps that honest for existing records: a stored mask that is exactly the old
GRANT_ALLwas an explicit "Full control" from before Power existed, so it reads as the current one. Without it those devices would render as "Custom" everywhere while silently lacking Power. Applied in the host store,moonlight_effective, and every client mirror.Execution
Ordering on accept, all platforms: reply 202 → end every session with a typed close → ~1 s grace → act. The client sees "the host is going to sleep or shutting down", not a dropped connection.
49-punktfunk-power.rules). Deliberately still without-ignore-inhibit/-multiple-sessions: a foreign inhibitor or a second local user makes logind refuse, and that refusal is surfaced as an honest409rather than steamrolled.SeShutdownPrivilege+InitiateSystemShutdownExW/SetSuspendState.501, listed unavailable.Refusals are specific: another device streaming is a
409with the reason, an ungranted device a403, an unknown id a404. One action in flight host-wide; denials logged once per (device, action) per boot.Clients
The rows appear in each host's own menu, exactly where "Wake host" sits when the machine is asleep — the gamepad console, the GTK and Windows host cards, the Android touch home and Skia console, the Apple card and gamepad options.
Two decisions worth flagging for review:
pf_client_core::host_actionsserves the console, GTK and Windows, so three shells can't disagree about what a host offers.An action the host says it cannot run right now stays listed, disabled, and explains itself rather than vanishing.
Also in here
pf-client-core::preset_labelcompared againstGRANT_ALLunmasked, so an old client would have rendered a new host's Full session as "Custom". Masked.Verification
punktfunk-core(quic)punktfunk-hostclippy--all-targets -D warnings(Linux)punktfunk-hostmgmt / events / native_pairing suites--all-targets -D warningspf-client-core,pf-console-ui, session, GTK)swift build, full 32-file recompile):app:compileDebugKotlin, kit unit testsscripts/xcheck.sh windows clippypunktfunk-host,punktfunk-client-windows,punktfunk-trayclippy--all-targets -D warningsRegenerated and committed:
api/openapi.json, the docs-site copy,include/punktfunk_core.h, the SDK client.The Windows run matters more than usual here:
punktfunk-hosthas had no pre-merge Windows gate since #408 (thepackagejob carrying it only runs on push to main), andclients/windowsis outsidexcheck's coverage entirely. Both are verified on the real box for this branch. Two would-be Windows breaks were caught and fixed on the way —IsPwrSuspendAllowedandSetSuspendStatereturn a plainboolin windows-rs 0.62, not aBOOLwith.as_bool().Open
.21or a Windows box. The P0 exit criterion in the design is a curl-level invoke on both.49-punktfunk-power.rules— the Linux leg is authorized by it and the design flags this as unchecked.design/host-actions.md P0 (host core) + P1 (web console). A reusable action registry — GET /api/v1/actions (per-caller permission + honest availability) and an id-only POST /api/v1/actions/{id} on the mgmt cert lane — with three power built-ins, so the Wake-on-LAN round trip finally closes from the couch. Core: GRANT_POWER (1<<6) joins the mask; GRANT_ALL 0x3F -> 0x7F; the legacy-full read rule (an explicitly stored pre-power 0x3F reads as the current GRANT_ALL, so old Full records neither render Custom nor silently lack Power); typed close RejectReason::HostPower (0x6B) so ended sessions say why. Host: power executors (logind via zbus behind the already-shipped polkit rule, deliberately without -ignore-inhibit; Windows SeShutdownPrivilege + InitiateSystemShutdownExW / SetSuspendState; macOS answers 501); ordering reply-202 -> typed close -> 1 s grace -> act; single-flight; another device's live session blocks a cert-lane invoke (409); action.invoked on the event bus; denials logged once per (fingerprint, action) per boot. The plugin token gets neither route. Console: Host-power card (password-confirmed, the update-apply recipe) + a BFF route that keeps the invoke off the ungated catch-all; Host power toggle in the Access sheet. Mirrors: web/Kotlin/Swift consts + legacy rule, pf-client-core preset_label now masks (an old client no longer renders a new host's Full as Custom). Tray's service-restart row renamed Restart Punktfunk before the clients ship a machine-level Restart host. Docs: host-power page, access-levels row, WoL cross-link. openapi/header/SDK regenerated.