The host can be put back to sleep from the couch that woke it (host actions) #436

Merged
enricobuehler merged 3 commits from worktree-host-actions into main 2026-08-28 21:59:23 +00:00
Owner

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.
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.
enricobuehler added 2 commits 2026-08-28 21:41:41 +00:00
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.
The couch gets the other half of the round trip: host power on every client
ci / bun-nix (pull_request) Successful in 28s
ci / docs-site (pull_request) Successful in 1m3s
ci / docs-drift (pull_request) Failing after 1m9s
ci / web (pull_request) Successful in 1m33s
apple / swift (pull_request) Successful in 2m9s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 3m0s
android / android (pull_request) Successful in 7m19s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 6m48s
ci / rust (pull_request) Failing after 8m11s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 2m15s
62728317cf
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.
enricobuehler added 1 commit 2026-08-28 21:51:04 +00:00
chore(ci): the three new ABI constants join the undocumented-env baseline
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m4s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 7m35s
ci / bun-nix (pull_request) Successful in 49s
ci / docs-site (pull_request) Successful in 1m2s
ci / web (pull_request) Successful in 1m4s
apple / swift (pull_request) Successful in 2m12s
ci / docs-drift (pull_request) Successful in 1m4s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m26s
android / android (pull_request) Successful in 8m37s
ci / rust (pull_request) Successful in 16m32s
40fc741ad5
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.
enricobuehler merged commit 8fcd9354b5 into main 2026-08-28 21:59:23 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#436