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
72 changed files with 3458 additions and 71 deletions
+209
View File
@@ -13,6 +13,114 @@
"version": "0.32.0"
},
"paths": {
"/api/v1/actions": {
"get": {
"tags": [
"actions"
],
"summary": "List host actions",
"description": "The actions this host offers, as seen by the caller: platform availability (with the honest\nreason when something can't run) and whether THIS caller is permitted to invoke each one.\nAdmin lane: everything permitted. Paired-cert lane: permission follows the device's live\naccess mask (the Host power grant). Clients render rows generically — unknown ids still\nwork with the server-supplied title.",
"operationId": "listActions",
"responses": {
"200": {
"description": "The actions, per-caller",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ActionList"
}
}
}
},
"401": {
"description": "Missing or invalid credentials",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/actions/{id}": {
"post": {
"tags": [
"actions"
],
"summary": "Invoke a host action",
"description": "Runs one action by id — empty body, no parameters: the id selects a fixed host-side\nbehavior, and nothing in the request reaches the privileged path. On `202` the host first\nends every streaming session cleanly (clients see a typed \"the host is going to sleep /\nshutting down\" close), waits ~1 s so this response flushes, then acts.\n\nPaired-cert callers need the **Host power** grant, and are refused (`409`) while another\ndevice's session is live — a granted guest cannot yank the host out from under the owner\nmid-stream. The admin console is never blocked (it warns instead). One action runs at a\ntime host-wide.",
"operationId": "invokeAction",
"parameters": [
{
"name": "id",
"in": "path",
"description": "Action id (`power.sleep`, `power.reboot`, `power.shutdown`)",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"202": {
"description": "Accepted — sessions are being ended and the action follows in about a second"
},
"401": {
"description": "Missing or invalid credentials",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"403": {
"description": "This caller's access does not include this action (no Host power grant)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "Unknown action id",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"409": {
"description": "Refused: an action is already in flight, another device's session is live (cert lane), or the platform said no (a foreign sleep inhibitor, a second local user, …)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"501": {
"description": "This host platform has no executor for it (macOS host)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/client-logs": {
"get": {
"tags": [
@@ -4331,6 +4439,67 @@
},
"components": {
"schemas": {
"ActionInfo": {
"type": "object",
"description": "One action as the caller sees it (`GET /actions`).",
"required": [
"id",
"title",
"group",
"danger",
"available",
"permitted"
],
"properties": {
"available": {
"type": "boolean",
"description": "Whether this host can run it right now (platform probe — a VM that can't S3 lists\nsleep as unavailable rather than offering a dead switch)."
},
"danger": {
"type": "boolean",
"description": "Whether a client UI should double-confirm (the action loses state — reboot/shutdown)."
},
"group": {
"type": "string",
"description": "Action group (`power` for the built-ins)."
},
"id": {
"type": "string",
"description": "Stable action id (`power.sleep`, …) — the invoke path parameter.",
"example": "power.sleep"
},
"permitted": {
"type": "boolean",
"description": "Whether THIS caller may invoke it (admin lane: always; cert lane: the `GRANT_POWER`\nbit of the device's live access mask)."
},
"title": {
"type": "string",
"description": "Display title. Clients localize known ids and fall back to this for unknown ones."
},
"unavailable_reason": {
"type": [
"string",
"null"
],
"description": "Why it is unavailable, when it is."
}
}
},
"ActionList": {
"type": "object",
"description": "`GET /actions` response.",
"required": [
"actions"
],
"properties": {
"actions": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ActionInfo"
}
}
}
},
"ActiveGame": {
"type": "object",
"description": "One launched game, for the console's running-game card.",
@@ -6141,6 +6310,42 @@
}
}
},
{
"type": "object",
"description": "A host action was invoked (`design/host-actions.md` §3.3) — v1: the `power.*` verbs.\nEmitted on ACCEPT (`outcome: \"accepted\"`), and again if the executor later fails\n(`outcome: \"failed: …\"`) — a succeeded power action ends this process, so \"accepted with\nno failure after it\" is the success signal a hook can act on (\"the host is going down\").",
"required": [
"id",
"outcome",
"kind"
],
"properties": {
"device": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/DeviceRef",
"description": "The invoking paired device, when the cert lane invoked it; absent for the\noperator's console (admin lane)."
}
]
},
"id": {
"type": "string",
"description": "The invoked action id (`power.sleep`, `power.reboot`, `power.shutdown`)."
},
"kind": {
"type": "string",
"enum": [
"action.invoked"
]
},
"outcome": {
"type": "string",
"description": "`accepted`, or `failed: <the executor's error>`."
}
}
},
{
"type": "object",
"required": [
@@ -9142,6 +9347,10 @@
{
"name": "update",
"description": "Host update check: install kind + channel, the last verified release manifest, and whether a newer host exists (admin lane only)"
},
{
"name": "actions",
"description": "Host actions: discover what this host offers (per-caller availability + permission) and invoke one by id — v1: sleep, restart, shut down the machine, gated per device by the Host power grant"
}
]
}
@@ -309,3 +309,33 @@ internal fun EditHostDialog(
},
)
}
/**
* "Restart host?" / "Shut down host?" — the confirmation a destructive host action takes before
* it runs (`design/host-actions.md` §7). Sleep is reversible from the same menu ("Wake host"),
* so it never reaches here; restart and shut down lose whatever is on that machine, so they do.
*/
@Composable
internal fun HostActionConfirmDialog(
hostName: String,
action: HostActions.Action,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("${action.label}?") },
text = {
Text(
"This ends every stream from $hostName and anything running on it. " +
"You'll need to wake or start it again.",
)
},
confirmButton = {
TextButton(onClick = onConfirm) { Text(action.label) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
@@ -58,6 +58,11 @@ object ConnectErrors {
"launch-not-permitted" ->
"This device's access doesn't include launching games — connect to the desktop, " +
"or ask the host's owner."
// A host power action (design/host-actions.md) ended the session deliberately.
// Without this arm it falls through to the generic failure, and sleeping your own
// host from the couch reads as a crash.
"host-power" ->
"The host is going to sleep or shutting down — wake it when you want to play again."
else -> null
}
@@ -82,6 +82,10 @@ internal fun ConnectGrid(
onSpeedTest: (KnownHost) -> Unit,
/** Upload this device's recent log to the host — see the menu row's gate below. */
onSendLogs: (KnownHost) -> Unit,
/** What each paired host last said this device may do TO it, by fingerprint
* (`design/host-actions.md` §7). Absent = no rows. */
hostActions: Map<String, List<HostActions.Action>>,
onHostAction: (KnownHost, HostActions.Action) -> Unit,
onCopyLink: (KnownHost, StreamProfile?) -> Unit,
onTogglePin: (KnownHost, StreamProfile) -> Unit,
/** The experimental game-library toggle — off hides "Browse library…" everywhere. */
@@ -118,6 +122,17 @@ internal fun ConnectGrid(
if (pin == null && kh.paired && kh.isOnline(discovered, reachable)) {
add(HostMenuItem("Send logs to host") { onSendLogs(kh) })
}
// The host's own actions — sleep, restart, shut it down (`design/host-actions.md` §7),
// the other half of the Wake-on-LAN round trip. Nothing is decided here: the list is
// empty unless the host answered AND this device's access carries the grant, so no row
// appears that the host would refuse. A pinned card is a shortcut to one profile, not a
// second host, so it offers none — same rule as "Send logs" above.
if (pin == null) {
hostActions[kh.fpHex].orEmpty().forEach { a ->
val label = if (a.available) a.label else "${a.label} (unavailable)"
add(HostMenuItem(label) { onHostAction(kh, a) })
}
}
add(HostMenuItem("Copy link") { onCopyLink(kh, pin) })
if (profiles.isEmpty()) return@buildList
if (pin != null) {
@@ -53,6 +53,14 @@ import kotlinx.coroutines.withContext
*/
private const val REQUEST_ACCESS_TIMEOUT_MS = 185_000
/**
* How long a host's advertised actions stay fresh before this screen asks again — the desktop's
* `pf_client_core::host_actions::TTL`. Long on purpose: what it governs (whether this device
* holds the Host-power grant, whether the box can suspend) changes when an operator edits
* access, not minute to minute, and every refresh is a TLS handshake against an idle host.
*/
private const val HOST_ACTIONS_TTL_MS = 300_000L
/**
* A no-PIN "request access" connect in flight — the host being requested (drives the cancelable
* "Waiting for approval…" dialog) and a per-attempt flag the Cancel button trips. The connect is a
@@ -292,6 +300,38 @@ fun ConnectScreen(
// A saved host being edited (name / address / port / MAC).
var editTarget by remember { mutableStateOf<KnownHost?>(null) }
// What each paired host says this device may do TO it — sleep, restart, shut it down
// (`design/host-actions.md` §7) — by fingerprint, with the moment we last asked.
//
// Learned on a slow TTL rather than when a menu opens: the row list has to be settled BEFORE
// the menu draws, or rows would appear under a finger already on its way down, and two of
// these rows end whatever is running on that machine. Empty for an older host (no such
// route), an unreachable one, and any device without the grant — the menu simply has no
// power rows then.
var hostActions by remember { mutableStateOf<Map<String, List<HostActions.Action>>>(emptyMap()) }
var hostActionsAt by remember { mutableStateOf<Map<String, Long>>(emptyMap()) }
val reachableNow by rememberUpdatedState(reachable)
LaunchedEffect(savedHosts, identity) {
val id = identity ?: return@LaunchedEffect
while (true) {
val now = android.os.SystemClock.elapsedRealtime()
for (kh in savedHosts) {
if (!kh.paired || kh.fpHex.isEmpty()) continue
if (!kh.isOnline(discoveredNow, reachableNow)) continue
if (now - (hostActionsAt[kh.fpHex] ?: 0L) < HOST_ACTIONS_TTL_MS) continue
// Stamp BEFORE the request, so a slow host cannot make every lap ask again.
hostActionsAt = hostActionsAt + (kh.fpHex to now)
val found = withContext(Dispatchers.IO) {
HostActions.list(id, kh.address, kh.effectiveMgmtPort, kh.fpHex)
}
hostActions = hostActions + (kh.fpHex to found)
}
delay(30_000)
}
}
// A destructive host action awaiting its confirmation (restart / shut down).
var confirmAction by remember { mutableStateOf<Pair<KnownHost, HostActions.Action>?>(null) }
// Discovered hosts not already saved — a saved host (paired or TOFU) belongs in "Saved hosts",
// not also in "Discovered", so we hide the overlap (matched by fingerprint when both carry it, so
// it survives a DHCP address change; else by address:port). Mirrors the Apple client.
@@ -635,6 +675,40 @@ fun ConnectScreen(
if (copied) notice = message else status = message
}
// Host actions (`design/host-actions.md` §7) — sleep, restart or shut the host down. The
// menu rows come from what the HOST said it lets this device do, so a device without the
// Host-power grant is offered none; a destructive one still asks first, because losing what
// is running on that machine is not something a mis-tap should be able to do.
fun runHostAction(kh: KnownHost, a: HostActions.Action) {
val id = identity ?: run {
status = "Identity not ready yet — try again in a moment"
return
}
val name = kh.name.ifBlank { kh.address }
notice = "${a.label} — asking $name"
status = null
// Whatever the host said about itself is about to be wrong: ask again next sweep.
hostActionsAt = hostActionsAt - kh.fpHex
scope.launch {
notice = withContext(Dispatchers.IO) {
HostActions.invoke(
id, kh.address, kh.effectiveMgmtPort, kh.fpHex, name, a.id, a.label,
)
}
}
}
fun hostAction(kh: KnownHost, a: HostActions.Action) {
when {
// The host already said it cannot do this right now — say why, rather than send a
// request we know it will refuse.
!a.available ->
notice = a.unavailableReason.ifEmpty { "${a.label} isn't available right now" }
a.danger -> confirmAction = kh to a
else -> runHostAction(kh, a)
}
}
// "Send logs to host" — [SendLogs], the same upload the console's host menu runs. The outcome
// is a notice either way (success and failure both name the host), because the row's whole job
// is to tell a reporter whether the bundle actually landed.
@@ -793,6 +867,8 @@ fun ConnectScreen(
onWake = { kh -> wakeHost(kh) },
onSpeedTest = { kh -> startSpeedTest(HostCardEntry(kh, null)) },
onSendLogs = { kh -> sendLogs(kh) },
hostActions = hostActions,
onHostAction = { kh, a -> hostAction(kh, a) },
onCopyLink = { kh, pin -> copyLink(kh, pin) },
onTogglePin = { kh, p -> togglePin(kh, p) },
libraryEnabled = settings.libraryEnabled,
@@ -828,6 +904,18 @@ fun ConnectScreen(
val editSuggestedMacs =
editTarget?.let { kh -> discovered.firstOrNull { kh.matches(it) }?.mac } ?: emptyList()
// A destructive host action's confirmation. Kept here rather than in ConnectPrompts because
// it is a one-question dialog owned by the row that raised it — the same place the row's
// handler lives.
confirmAction?.let { (kh, a) ->
HostActionConfirmDialog(
hostName = kh.name.ifBlank { kh.address },
action = a,
onConfirm = { confirmAction = null; runHostAction(kh, a) },
onDismiss = { confirmAction = null },
)
}
// Everything that floats above whichever home was drawn, in one place and in one order — see
// ConnectPrompts.kt. It decides nothing: each action below lands right back in the engine above.
ConnectPrompts(
@@ -0,0 +1,121 @@
package io.unom.punktfunk
import io.unom.punktfunk.kit.security.ClientIdentity
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
/**
* Host actions — sleep, restart or shut down a paired host from this device
* (`design/host-actions.md` §7), over the same mTLS identity the library fetch and the log
* upload use.
*
* The HOST is the only enforcer: [Action.permitted] is what it says about *this* device's
* access, so a device without the Host-power grant is offered nothing rather than shown a row
* that will be refused. Discovery is best-effort by contract — an older host (no such route),
* an unreachable one, or a shape we don't recognise yields an empty list, because a missing
* menu row costs a menu row and a thrown exception costs the screen.
*
* ONE implementation for both Android shells, like [SendLogs]: the Skia console's host menu and
* the touch home's card menu. Wording is the desktop's verbatim (`pf_client_core::host_actions`)
* so a quoted message means the same thing on every client.
*
* Blocking — call it off the main thread.
*/
object HostActions {
/** One action as the host reports it to THIS device. */
data class Action(
/** Stable id, the invoke argument (`power.sleep`). */
val id: String,
/** This client's wording for a known id, else the host's own title. */
val label: String,
/** Confirm twice — the action loses whatever is running on that machine. */
val danger: Boolean,
/** The host can run it right now. */
val available: Boolean,
/** Why not, when it can't. Empty otherwise. */
val unavailableReason: String,
)
/** Local wording for the ids we know; anything else keeps the host's own title, which is
* what lets a later host add an action with no client release. */
private fun label(id: String, title: String): String = when (id) {
"power.sleep" -> "Sleep host"
"power.reboot" -> "Restart host"
"power.shutdown" -> "Shut down host"
else -> title
}
/**
* What this host lets this device do to it (`GET /api/v1/actions`). Only the PERMITTED rows
* come back: what a device may not invoke is not its business to render.
*/
fun list(identity: ClientIdentity, addr: String, mgmtPort: Int, fpHex: String): List<Action> =
runCatching {
val client = io.unom.punktfunk.kit.library.mtlsHttpClient(
identity.certPem, identity.privateKeyPem, addr, fpHex,
)
val req = Request.Builder().url("https://$addr:$mgmtPort/api/v1/actions").get().build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) return@runCatching emptyList()
val arr = JSONObject(resp.body?.string().orEmpty()).optJSONArray("actions")
?: return@runCatching emptyList()
(0 until arr.length()).mapNotNull { i ->
val o = arr.optJSONObject(i) ?: return@mapNotNull null
if (!o.optBoolean("permitted")) return@mapNotNull null
val id = o.optString("id")
Action(
id = id,
label = label(id, o.optString("title")),
danger = o.optBoolean("danger"),
available = o.optBoolean("available"),
unavailableReason = o.optString("unavailable_reason"),
)
}
}
}.getOrDefault(emptyList())
/**
* Invoke one action by id (`POST /api/v1/actions/{id}`, empty body) and return the
* user-facing outcome.
*
* A 202 is the last word: the host ends every session and acts about a second later, so
* there is nothing to poll and nothing to undo. A refusal carries the host's own reason
* ("another device is streaming from this host right now"), which tells a person what to do
* where a bare status code would not.
*/
fun invoke(
identity: ClientIdentity,
addr: String,
mgmtPort: Int,
fpHex: String,
hostName: String,
actionId: String,
label: String,
): String {
val err = runCatching {
val client = io.unom.punktfunk.kit.library.mtlsHttpClient(
identity.certPem, identity.privateKeyPem, addr, fpHex,
)
val req = Request.Builder()
.url("https://$addr:$mgmtPort/api/v1/actions/$actionId")
// Empty body by design: the id is the whole request, and no request field ever
// reaches the host's privileged path.
.post(ByteArray(0).toRequestBody(null, 0, 0))
.build()
client.newCall(req).execute().use { resp ->
if (resp.isSuccessful) {
""
} else {
// The `ApiError` envelope carries the host's sentence; fall back to the code
// only when there isn't one.
runCatching {
JSONObject(resp.body?.string().orEmpty()).optString("error")
}.getOrNull()?.takeIf { it.isNotEmpty() } ?: "the host answered HTTP ${resp.code}"
}
}
}.getOrElse { it.message ?: "the host could not be reached" }
return if (err.isEmpty()) "$hostName: $label — on its way" else "$label failed — $err"
}
}
@@ -1,6 +1,7 @@
package io.unom.punktfunk.console
import android.view.InputDevice
import io.unom.punktfunk.HostActions
import io.unom.punktfunk.MouseMode
import io.unom.punktfunk.Settings
import io.unom.punktfunk.StatsVerbosity
@@ -33,6 +34,22 @@ internal object ConsoleJson {
.put("name", p.name)
.put("accent", p.accent ?: JSONObject.NULL)
/** A host's advertised actions in the console model's shape (`HostRow.actions`). */
private fun actionRows(actions: List<HostActions.Action>?): JSONArray {
val arr = JSONArray()
for (a in actions.orEmpty()) {
arr.put(
JSONObject()
.put("id", a.id)
.put("label", a.label)
.put("danger", a.danger)
.put("available", a.available)
.put("unavailable_reason", a.unavailableReason),
)
}
return arr
}
/**
* The home carousel: saved hosts (name order — Android records carry no last-used time),
* each followed by its pinned profile cards, then discovered-but-unsaved hosts. Mirrors
@@ -44,6 +61,10 @@ internal object ConsoleJson {
discovered: List<DiscoveredHost>,
reachable: Set<String>,
profiles: List<StreamProfile>,
/** What each paired host last said this device may do TO it, by fingerprint
* (`design/host-actions.md` §7). Absent = no rows, which is also what an older host
* and an ungranted device produce. */
hostActions: Map<String, List<HostActions.Action>> = emptyMap(),
): String {
val out = JSONArray()
fun advertFor(h: KnownHost): DiscoveredHost? = discovered.firstOrNull { d ->
@@ -68,6 +89,7 @@ internal object ConsoleJson {
.put("clipboard_sync", h.clipboardSync)
.put("last_used", JSONObject.NULL)
.put("os", advert?.os?.takeIf { it.isNotEmpty() } ?: h.os)
.put("actions", actionRows(hostActions[h.fpHex]))
.put("pin", JSONObject.NULL)
.put(
"bound_profile",
@@ -13,6 +13,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import io.unom.punktfunk.CONNECT_TIMEOUT_MS
import io.unom.punktfunk.ConnectErrors
import io.unom.punktfunk.HostActions
import io.unom.punktfunk.ProfileStore
import io.unom.punktfunk.Settings
import io.unom.punktfunk.SettingsStore
@@ -99,6 +100,11 @@ object SkiaConsole {
private var reachable: Set<String> = emptySet()
private var settings: Settings = Settings()
/** What each paired host last said this device may do TO it, by fingerprint, and when we
* last asked — the Android half of the desktop's shared actions cache. Main-thread only. */
private val hostActions = mutableMapOf<String, List<HostActions.Action>>()
private val hostActionsAt = mutableMapOf<String, Long>()
// What the composable hands us while it is on screen.
private var onConnected: ((ActiveSession) -> Unit)? = null
private var onSettingsChange: ((Settings) -> Unit)? = null
@@ -417,12 +423,47 @@ object SkiaConsole {
private fun pushHosts() {
if (handle == 0L) return
refreshHostActions()
NativeBridge.nativeConsoleSetHosts(
handle,
ConsoleJson.hostRows(knownHostStore.all(), discovered, reachable, profileStore.all()),
ConsoleJson.hostRows(
knownHostStore.all(), discovered, reachable, profileStore.all(), hostActions,
),
)
}
/**
* Keep each paired, reachable host's advertised actions fresh (`design/host-actions.md` §7),
* mirroring the desktop's `pf_client_core::host_actions::refresh`.
*
* On a slow TTL and never when a menu opens: the row list has to be SETTLED before the menu
* draws, or rows would appear under a cursor already moving toward something else — and two
* of those rows shut a machine down.
*/
private fun refreshHostActions() {
val id = identity ?: return
val now = android.os.SystemClock.elapsedRealtime()
for (h in knownHostStore.all()) {
if (!h.paired || h.fpHex.isEmpty()) continue
val online = discovered.any {
it.fingerprint.equals(h.fpHex, ignoreCase = true) ||
(it.host == h.address && it.port == h.port)
} || "${h.address}:${h.port}" in reachable
if (!online) continue
// Stamp BEFORE the request, so a slow host cannot make every push spawn another.
if (now - (hostActionsAt[h.fpHex] ?: 0L) < HOST_ACTIONS_TTL_MS) continue
hostActionsAt[h.fpHex] = now
val (addr, mgmt, fp) = Triple(h.address, h.effectiveMgmtPort, h.fpHex)
ioPool.execute {
val found = HostActions.list(id, addr, mgmt, fp)
main.post {
hostActions[fp] = found
pushHosts()
}
}
}
}
private fun pushKnownHosts() {
if (handle == 0L) return
NativeBridge.nativeConsoleSetKnownHosts(handle, ConsoleJson.knownHosts(knownHostStore.all()))
@@ -573,6 +614,7 @@ object SkiaConsole {
c.optJSONObject("RefreshRunning")?.let { fetchLibrary(it, refreshOnly = true) }
c.optJSONObject("Pair")?.let(::pair)
c.optJSONObject("SendLogs")?.let(::sendLogs)
c.optJSONObject("HostAction")?.let(::hostAction)
c.optJSONObject("SaveHost")?.let(::saveHost)
c.optJSONObject("UpdateHost")?.let(::updateHost)
c.optJSONObject("ForgetHost")?.let(::forgetHost)
@@ -664,6 +706,26 @@ object SkiaConsole {
}
}
/**
* Sleep / restart / shut the host down (`design/host-actions.md` §7) — the console already
* confirmed a destructive one twice before raising this, and the host re-checks this
* device's Host-power grant on arrival, so nothing is decided here.
*/
private fun hostAction(c: JSONObject) {
val addr = c.optString("addr"); val mgmt = c.optInt("mgmt"); val fp = c.optString("fp_hex")
val hostName = c.optString("host_name").ifEmpty { addr }
val actionId = c.optString("action_id"); val label = c.optString("label")
val id = identity
if (id == null) {
notice("Identity not ready yet — try again in a moment")
return
}
ioPool.execute {
val message = HostActions.invoke(id, addr, mgmt, fp, hostName, actionId, label)
main.post { notice(message) }
}
}
private fun pair(c: JSONObject) {
val addr = c.optString("addr"); val port = c.optInt("port")
val pin = c.optString("pin"); val name = c.optString("device_name")
@@ -825,4 +887,9 @@ object SkiaConsole {
/** The no-PIN request-access park (≥ the host's approval window) — ConnectScreen's figure. */
private const val REQUEST_ACCESS_TIMEOUT_MS = 185_000
/** How long a host's advertised actions stay fresh before we ask again — the desktop's
* `pf_client_core::host_actions::TTL`. Long on purpose: what it governs changes when an
* operator edits access, not minute to minute, and each refresh is a TLS handshake. */
private const val HOST_ACTIONS_TTL_MS = 300_000L
}
@@ -29,14 +29,27 @@ object SessionAccess {
/** Library launch (`Hello.launch`). */
const val LAUNCH = 1 shl 5
/** Host power — the `power.*` host actions (`design/host-actions.md`); route-gated, never input. */
const val POWER = 1 shl 6
/** Every defined grant — full control, and what an old host's Welcome decodes to. */
const val ALL = GAMEPAD or POINTER or KEYBOARD or CLIPBOARD or MIC or LAUNCH
const val ALL = GAMEPAD or POINTER or KEYBOARD or CLIPBOARD or MIC or LAUNCH or POWER
/** `ALL` before POWER existed (hosts ≤ 0.32.x) — see [normalizeLegacyFull]. */
private const val ALL_PRE_POWER = GAMEPAD or POINTER or KEYBOARD or CLIPBOARD or MIC or LAUNCH
/**
* The legacy-full read rule (host-actions §4.3): exactly the pre-power full mask (an old
* host's "Full control") reads as the current [ALL], so it labels "Full control", not
* "Custom". Any other mask passes through.
*/
fun normalizeLegacyFull(grants: Int): Int = if (grants == ALL_PRE_POWER) ALL else grants
/**
* The preset name a mask displays as — §3.2's rule: three levels people actually reason
* about, "Custom" for any other combination, never a raw bit list.
*/
fun label(grants: Int): String = when (grants and ALL) {
fun label(grants: Int): String = when (normalizeLegacyFull(grants) and ALL) {
ALL -> "Full control"
GAMEPAD -> "Controller only"
0 -> "View only"
@@ -20,7 +20,8 @@ class SessionAccessTest {
assertEquals(8, SessionAccess.CLIPBOARD)
assertEquals(16, SessionAccess.MIC)
assertEquals(32, SessionAccess.LAUNCH)
assertEquals(0x3F, SessionAccess.ALL)
assertEquals(64, SessionAccess.POWER)
assertEquals(0x7F, SessionAccess.ALL)
}
@Test
@@ -35,6 +36,9 @@ class SessionAccessTest {
SessionAccess.label(SessionAccess.GAMEPAD or SessionAccess.CLIPBOARD),
)
assertEquals("Custom", SessionAccess.label(SessionAccess.ALL and SessionAccess.LAUNCH.inv()))
// The legacy-full read rule (host-actions §4.3): an old host's pre-power "Full control"
// (exactly 0x3F) still labels Full, never Custom.
assertEquals("Full control", SessionAccess.label(0x3F))
}
@Test
@@ -107,6 +107,9 @@ struct GamepadHomeView: View {
/// The profile catalog pinned host+profile combos render as their own tiles here, which is
/// how a controller picks a profile: one focus-and-press instead of a menu (design §5.4).
@ObservedObject private var profiles = ProfileStore.shared
/// What each paired host says this device may do TO it (`design/host-actions.md` §7)
/// shared with the touch grid, so the two menus cannot disagree about what a host offers.
@ObservedObject private var hostPower = HostPowerStore.shared
/// Same gate the touch grid's "Browse Library" context-menu item uses (default ON; the
/// Settings "Game library" toggle opts out).
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
@@ -655,6 +658,10 @@ struct GamepadHomeView: View {
store.setPinned(host.id, profileID: profile.id, pinned: false)
},
onSendLogs: host.pinnedSHA256 != nil ? { await SendLogs.toHost(host) } : nil,
// A pinned card is a shortcut to one profile, not a second host, so it carries no
// host actions the same rule the touch grid and the console menu apply.
hostActions: target.profile == nil ? hostPower.actions(for: host) : [],
onHostAction: { action in await hostPower.invoke(action, on: host) },
close: { if !transitioning { hostOptionsTarget = nil } },
controllerActive: active)
}
@@ -67,6 +67,12 @@ struct GamepadHostOptionsView: View {
/// Upload this device's recent log to the host; answers with what to tell the user. nil on an
/// unpaired host the upload rides the pairing, so there is nothing to offer before it.
var onSendLogs: (() async -> (ok: Bool, message: String))?
/// What the HOST says this device may do to it sleep, restart, shut it down
/// (`design/host-actions.md` §7). Empty unless it answered and this device's access carries
/// the grant, so no row here can be refused for permission.
var hostActions: [HostAction] = []
/// Run one; answers with what to tell the user, like `onSendLogs`.
var onHostAction: ((HostAction) async -> (ok: Bool, message: String))?
var close: (() -> Void)?
var controllerActive = true
@@ -82,8 +88,15 @@ struct GamepadHostOptionsView: View {
/// fires on the second. The touch grid removes behind a system confirmation dialog; a console
/// is driven by a thumbstick from across a room, which is a good reason to be at least as
/// strict as it is, and none at all to be looser.
/// Which row is armed, when the armed one is not Remove: the host's own destructive actions
/// (restart, shut down) take the same two-press treatment. Held as an id, not a flag, so an
/// arming press on one destructive row cannot fire a different one the cursor then reached.
@State private var armedRowID: String?
@State private var armed = false
@State private var copied = false
/// A host action's outcome, reported in place this surface has no toast, exactly like
/// `sendLogs` above.
@State private var hostActionState: HostActionState = .idle
/// The send-logs row's own state: its label and the detail band report the outcome in place,
/// the same way Copy link says "Copied" this surface has no toast.
@State private var sendLogs: SendLogsState = .idle
@@ -93,8 +106,15 @@ struct GamepadHostOptionsView: View {
case idle, sending, done(ok: Bool, message: String)
}
private enum HostActionState: Equatable {
case idle, sending(String), done(ok: Bool, message: String)
}
private enum Action: String {
case wake
/// One of the host's own actions; WHICH one rides on the row (`Row.hostAction`),
/// because this enum's raw values are fixed and the host's list is not.
case hostAction
case copyLink
case edit
case forgetPairing
@@ -108,7 +128,7 @@ struct GamepadHostOptionsView: View {
GamepadMenuList(
items: rows,
focusID: $focusID,
onActivate: { run($0.action) },
onActivate: { run($0) },
onBack: { performClose() },
isActive: controllerActive
) { row, focused in
@@ -163,6 +183,8 @@ struct GamepadHostOptionsView: View {
// two-press rule exists to catch.
.onChange(of: focusID) { _, id in
if id != Action.remove.rawValue { armed = false }
// Same rule for the host's own destructive rows: leaving one disarms it.
if id != armedRowID { armedRowID = nil }
}
#if !os(tvOS)
.background {
@@ -187,7 +209,11 @@ struct GamepadHostOptionsView: View {
let label: String
var icon: String
var isDestructive = false
var id: String { action.rawValue }
/// The host action this row runs, for `action == .hostAction`.
var hostAction: HostAction?
/// Ids must stay unique across the list the host's rows share one `Action` case, so
/// they key on the action id the host sent.
var id: String { hostAction.map { "hostAction:\($0.id)" } ?? action.rawValue }
}
private var rows: [Row] {
@@ -204,6 +230,18 @@ struct GamepadHostOptionsView: View {
if canWake, !isOnline {
list.append(Row(action: .wake, label: "Wake host", icon: "power"))
}
// and the other half of that round trip, immediately below it. A destructive one wears
// the same "press again" the Remove row does; an unavailable one stays listed and says
// why when pressed, rather than vanishing.
for a in hostActions {
let rowID = "hostAction:\(a.id)"
var label = a.available ? a.label : "\(a.label) (unavailable)"
if armedRowID == rowID { label = "\(a.label) \u{2014} press again" }
if case .sending(let id) = hostActionState, id == a.id { label = "\(a.label)\u{2026}" }
list.append(Row(
action: .hostAction, label: label, icon: "power",
isDestructive: a.danger, hostAction: a))
}
list.append(Row(action: .copyLink, label: copied ? "Copied" : "Copy link", icon: "link"))
list.append(Row(action: .edit, label: "Edit\u{2026}", icon: "pencil"))
if onSendLogs != nil {
@@ -231,7 +269,27 @@ struct GamepadHostOptionsView: View {
/// The explainer under the list the same band the settings screen uses, and the only place a
/// destructive action can say what it will actually do before it is pressed.
private var detail: String {
switch rows.first(where: { $0.id == focusID })?.action {
let focused = rows.first(where: { $0.id == focusID })
switch focused?.action {
case .hostAction:
guard let a = focused?.hostAction else { return "" }
if case .done(_, let message) = hostActionState { return message }
if !a.available {
return a.unavailableReason ?? "This host can't do that right now."
}
if armedRowID == focused?.id {
return "Press again — this ends every stream and anything running on the host."
}
switch a.id {
case "power.sleep":
return "Put the host to sleep. Wake it again from this menu."
case "power.reboot":
return "Restart the host. Every stream ends and anything running on it stops."
case "power.shutdown":
return "Shut the host down. Wake-on-LAN can start it again if it is armed."
default:
return a.title
}
case .wake:
return "Send a Wake-on-LAN packet and wait for this host to answer."
case .copyLink:
@@ -260,7 +318,7 @@ struct GamepadHostOptionsView: View {
.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select",
action: { if let id = focusID, let row = rows.first(where: { $0.id == id }) {
run(row.action)
run(row)
} }),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
@@ -270,8 +328,37 @@ struct GamepadHostOptionsView: View {
// MARK: - Actions
private func run(_ action: Action) {
switch action {
private func run(_ row: Row) {
// Moving off an armed row disarms it an arming press is about THAT row, and must not
// leave a live trigger on the destructive row beside it.
if armedRowID != row.id { armedRowID = nil }
switch row.action {
case .hostAction:
guard let a = row.hostAction, let onHostAction else { return }
// The host already said it cannot do this right now: say why rather than send a
// request we know it will refuse.
guard a.available else {
withAnimation(.smooth(duration: 0.2)) {
hostActionState = .done(
ok: false,
message: a.unavailableReason ?? "\(a.label) isn't available right now.")
}
return
}
// Restart and shut down lose whatever is on that machine, so they arm first. Sleep
// is reversible from this very menu ("Wake host"), so it goes on one press.
if a.danger, armedRowID != row.id {
withAnimation(.smooth(duration: 0.2)) { armedRowID = row.id }
return
}
armedRowID = nil
withAnimation(.smooth(duration: 0.2)) { hostActionState = .sending(a.id) }
Task {
let outcome = await onHostAction(a)
withAnimation(.smooth(duration: 0.2)) {
hostActionState = .done(ok: outcome.ok, message: outcome.message)
}
}
case .wake:
onWake()
performClose()
@@ -47,6 +47,11 @@ struct HomeView: View {
@State private var editTarget: StoredHost?
/// The outcome of the last "Send Logs to Host" drives its alert.
@State private var sendLogsResult: (ok: Bool, message: String)?
/// What each paired host says this device may do to it (`design/host-actions.md` §7).
@StateObject private var hostPower = HostPowerStore.shared
/// A destructive host action awaiting its confirmation.
@State private var confirmHostAction: PendingHostAction?
@State private var hostActionResult: (ok: Bool, message: String)?
// How this device shows its own list. `.added` is the default because it is what the grid
// did before it could sort at all an update should not rearrange anyone's hosts.
@AppStorage(DefaultsKey.hostSort) private var sortRaw = HostSort.added.rawValue
@@ -139,6 +144,13 @@ struct HomeView: View {
.task {
while !Task.isCancelled {
await store.refreshReachability(discovery: discovery)
// Keep each reachable paired host's advertised actions warm on the same
// beat, so a card's menu is BUILT from a settled answer rather than one
// arriving while the menu is open. TTL-gated inside, so this costs nothing
// on an ordinary lap.
for host in store.hosts where host.pinnedSHA256 != nil && isOnline(host) {
hostPower.refresh(host)
}
try? await Task.sleep(for: .seconds(10))
}
}
@@ -206,6 +218,39 @@ struct HomeView: View {
} message: {
Text(sendLogsResult?.message ?? "")
}
// A destructive host action asks first: restart and shut down lose whatever is running
// on that machine, and a mis-tap on a phone must not be able to do that. Sleep is
// reversible from the same menu ("Wake Host"), so it never reaches here.
.alert(
confirmHostAction.map { "\($0.action.label)?" } ?? "",
isPresented: Binding(
get: { confirmHostAction != nil },
set: { if !$0 { confirmHostAction = nil } })
) {
Button("Cancel", role: .cancel) { confirmHostAction = nil }
if let pending = confirmHostAction {
Button(pending.action.label, role: .destructive) {
confirmHostAction = nil
runHostAction(pending.action, on: pending.host)
}
}
} message: {
Text(
confirmHostAction.map {
"This ends every stream from \($0.host.displayName) and anything running "
+ "on it. You'll need to wake or start it again."
} ?? "")
}
.alert(
hostActionResult?.ok == true ? "On Its Way" : "Couldn't Do That",
isPresented: Binding(
get: { hostActionResult != nil },
set: { if !$0 { hostActionResult = nil } })
) {
Button("OK", role: .cancel) {}
} message: {
Text(hostActionResult?.message ?? "")
}
#if os(macOS)
.frame(minWidth: 480, minHeight: 360)
#endif
@@ -306,10 +351,34 @@ struct HomeView: View {
onEdit: { editTarget = host },
onSendLogs: host.pinnedSHA256 != nil
? { Task { sendLogsResult = await SendLogs.toHost(host) } } : nil,
// A pinned card is a shortcut to one profile, not a second host, so it carries no
// host actions the same rule the console's menu applies.
hostActions: pinned == nil ? hostPower.actions(for: host) : [],
onHostAction: { action in hostAction(action, on: host) },
profileMenu: profileMenu(for: host),
pinnedProfile: pinned)
}
/// A host action picked from a card's menu: explain an unavailable one, confirm a
/// destructive one, run the rest.
private func hostAction(_ action: HostAction, on host: StoredHost) {
guard action.available else {
hostActionResult = (
false,
action.unavailableReason ?? "\(action.label) isn't available right now.")
return
}
if action.danger {
confirmHostAction = PendingHostAction(host: host, action: action)
} else {
runHostAction(action, on: host)
}
}
private func runHostAction(_ action: HostAction, on host: StoredHost) {
Task { hostActionResult = await hostPower.invoke(action, on: host) }
}
/// The profile affordances every host card carries (§5.2/§5.2a).
private func profileMenu(for host: StoredHost) -> HostProfileMenu {
HostProfileMenu(
@@ -140,6 +140,13 @@ struct HostCardView: View {
/// Upload this device's recent log to the host (`SendLogs`). `nil` when the host is unpaired
/// the upload is authenticated by the pairing, so there is nothing to offer before it.
var onSendLogs: (() -> Void)? = nil
/// What this host says this device may do TO it sleep, restart, shut it down
/// (`design/host-actions.md` §7). Empty on every surface that doesn't offer them, and on
/// every host that hasn't answered or hasn't granted them.
var hostActions: [HostAction] = []
/// Run one of the above. `nil` alongside a non-empty `hostActions` would be a bug, so the
/// rows render disabled in that case rather than silently doing nothing.
var onHostAction: ((HostAction) -> Void)? = nil
/// This card's profile affordances nil on surfaces that don't offer them.
var profileMenu: HostProfileMenu? = nil
/// Set on a PINNED card: the profile this card connects with. nil = the host's primary card,
@@ -261,6 +268,17 @@ struct HostCardView: View {
if !isOnline, !host.wakeMacs.isEmpty, PunktfunkConnection.wakeOnLANAvailable, let onWake {
Button("Wake Host", systemImage: "power", action: onWake)
}
// and the other half of that round trip: what the HOST says this device may do to
// it. Empty unless it answered and this device's access carries the grant, so no row
// here can be refused for permission. A destructive one confirms in the caller.
ForEach(hostActions) { action in
Button(
action.available ? action.label : "\(action.label) (Unavailable)",
systemImage: "power",
role: action.danger ? .destructive : nil
) { onHostAction?(action) }
.disabled(onHostAction == nil)
}
if host.pinnedSHA256 != nil {
// Dropping the pin does NOT downgrade to TOFU: the next connect must re-pair via
// PIN (unless the host advertises pair=optional). Wording reflects that.
@@ -0,0 +1,98 @@
// Host actions sleep, restart or shut down a paired host from this device
// (`design/host-actions.md` §7), the other half of the Wake-on-LAN round trip. The Apple port of
// the Gaming Mode console's `ConsoleCmd::HostAction` (clients/session/src/console.rs), same
// wording on success.
//
// The HOST is the only enforcer: `HostAction.permitted` is what it says about THIS device's
// access, so a device without the Host-power grant is offered nothing rather than shown a row
// that will be refused. Discovery is kept on a slow TTL so a menu's rows are settled before it
// draws rows that appear under a finger already on its way down would be a hazard when two of
// them end whatever is running on that machine.
import Foundation
import PunktfunkKit
private let log = ClientLog(category: "power")
/// A destructive host action waiting on its confirmation which host, and which verb.
struct PendingHostAction: Identifiable {
let host: StoredHost
let action: HostAction
var id: String { "\(host.id.uuidString):\(action.id)" }
}
/// The per-host answer cache, shared by the touch card menu and the gamepad options view.
///
/// `@MainActor` and observable: the menus read `actions(for:)` while drawing, and a refresh that
/// lands republishes them. One cache for both surfaces, so the two menus cannot disagree about
/// what a host offers.
@MainActor
final class HostPowerStore: ObservableObject {
static let shared = HostPowerStore()
/// How long an answer stays fresh. Long on purpose: what it governs (whether this device
/// holds the grant, whether the box can suspend) changes when an operator edits access, not
/// minute to minute, and every refresh is a TLS handshake against an otherwise idle host.
private static let ttl: TimeInterval = 300
@Published private var byHost: [String: [HostAction]] = [:]
private var askedAt: [String: Date] = [:]
/// What `host` last said this device may do to it. Empty until a refresh answers, and empty
/// for an older host, an unreachable one, or a device without the grant.
func actions(for host: StoredHost) -> [HostAction] { byHost[host.id.uuidString] ?? [] }
/// Ask again unless the cached answer is still fresh. Cheap and idempotent call it from
/// whatever the surface already does on appear or on a refresh tick.
func refresh(_ host: StoredHost) {
let key = host.id.uuidString
// Stamp BEFORE the request, so a slow or hanging host cannot make every pass ask again.
if let at = askedAt[key], Date().timeIntervalSince(at) < Self.ttl { return }
guard let pin = host.pinnedSHA256,
let identity = (try? ClientIdentityStore.shared.load())?.identity
else { return }
askedAt[key] = Date()
Task { @MainActor in
let found = await LibraryClient.actions(
address: host.address, port: host.effectiveMgmtPort,
certPEM: identity.certPEM, keyPEM: identity.keyPEM, hostFingerprint: pin)
byHost[key] = found
}
}
/// Forget what this host said call it right after invoking an action, because whatever it
/// said is about to be wrong. Without this, a menu goes on offering "Sleep Host" for a
/// machine that is already asleep until the TTL lapses.
func invalidate(_ host: StoredHost) {
let key = host.id.uuidString
byHost[key] = []
askedAt[key] = nil
}
/// Run one action against `host`. Never throws: the caller shows `message` either way.
///
/// Success means the host ACCEPTED it it now ends every session and acts about a second
/// later, so this is the last word this device will get on the subject.
func invoke(_ action: HostAction, on host: StoredHost) async -> (ok: Bool, message: String) {
guard let identity = (try? ClientIdentityStore.shared.load())?.identity else {
return (false, "Connect to this host once first — host actions use the identity "
+ "created on the first connect.")
}
guard let pin = host.pinnedSHA256 else {
return (false, "Pair with \(host.displayName) first — host actions only go to a "
+ "paired host.")
}
invalidate(host)
do {
try await LibraryClient.invokeAction(
id: action.id, address: host.address, port: host.effectiveMgmtPort,
certPEM: identity.certPEM, keyPEM: identity.keyPEM, hostFingerprint: pin)
log.info("host action \(action.id, privacy: .public) accepted by \(host.displayName, privacy: .public)")
return (true, "\(host.displayName): \(action.label) — on its way.")
} catch {
let why = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
log.warning("host action \(action.id, privacy: .public) refused by \(host.displayName, privacy: .public): \(why, privacy: .public)")
return (false, "\(action.label) failed — \(why)")
}
}
}
@@ -168,6 +168,45 @@ public struct RunningGame: Codable, Hashable, Sendable {
public var isUp: Bool { state != "exited" }
}
/// One action a host offers THIS device, from `/api/v1/actions` (`design/host-actions.md` §3.2)
/// v1: sleep, restart, shut down.
///
/// Unknown ids are expected and fine: the client renders ``title`` verbatim for anything it has
/// no local wording for, which is what lets a later host add actions with no client release.
public struct HostAction: Codable, Hashable, Sendable, Identifiable {
/// Stable id: `power.sleep`, `power.reboot`, `power.shutdown` today.
public var id: String
/// The host's own display title the fallback label for an id this client doesn't know.
public var title: String
/// Action group (`power` for the built-ins).
public var group: String
/// Confirm before running: the action loses whatever is on that machine (restart, shut down).
public var danger: Bool
/// Whether the host can run it right now (a machine that cannot suspend, a foreign inhibitor).
public var available: Bool
/// Why not, when it can't shown rather than hidden, so "greyed out" always has a reason.
public var unavailableReason: String?
/// Whether THIS device's access covers it (the host's Host-power grant). The client only ever
/// keeps the permitted ones.
public var permitted: Bool
private enum CodingKeys: String, CodingKey {
case id, title, group, danger, available, permitted
case unavailableReason = "unavailable_reason"
}
/// This client's wording for a known id, else the host's own title so a familiar action is
/// worded the way the rest of this app words it, without hiding an unfamiliar one.
public var label: String {
switch id {
case "power.sleep": return "Sleep Host"
case "power.reboot": return "Restart Host"
case "power.shutdown": return "Shut Down Host"
default: return title
}
}
}
/// Stateless fetcher for a host's library.
public enum LibraryClient {
/// `GET https://<address>:<port>/api/v1/library`, authenticated by **mTLS**: the client
@@ -269,6 +308,69 @@ public enum LibraryClient {
}
}
/// What this host lets THIS device do to it sleep, restart, shut it down
/// (`design/host-actions.md` §7) from `GET /api/v1/actions`.
///
/// Only the PERMITTED rows come back: the host is the only judge of whether this device's
/// access carries the Host-power grant, and a row it would refuse is not this client's to
/// render. Best-effort by contract, like ``running(address:port:certPEM:keyPEM:hostFingerprint:)``
/// an older host (no such route), an unreachable one, or a shape we don't recognise yields
/// an empty list. A missing menu row costs a menu row; a thrown error would cost the screen.
public static func actions(
address: String,
port: UInt16 = punktfunkDefaultMgmtPort,
certPEM: String,
keyPEM: String,
hostFingerprint: Data?
) async -> [HostAction] {
guard let identity = try? clientIdentity(certPEM: certPEM, keyPEM: keyPEM),
let response = try? await send(
path: "/api/v1/actions", address: address, port: port,
identity: identity, hostFingerprint: hostFingerprint),
response.status == 200,
let list = try? JSONDecoder().decode(HostActionList.self, from: response.body)
else { return [] }
return list.actions.filter(\.permitted)
}
/// Invoke one host action by id (`POST /api/v1/actions/{id}`, empty body).
///
/// Returning normally means the host ACCEPTED it (202) it now ends every session and acts
/// about a second later, so this is the last word the client will get. A refusal throws
/// with the host's own sentence ("another device is streaming from this host right now"),
/// which tells a person what to do where a bare status code would not.
///
/// The body stays empty by design: the id is the whole request, and no request field ever
/// reaches the host's privileged path.
public static func invokeAction(
id: String,
address: String,
port: UInt16 = punktfunkDefaultMgmtPort,
certPEM: String,
keyPEM: String,
hostFingerprint: Data
) async throws {
let identity = try clientIdentity(certPEM: certPEM, keyPEM: keyPEM)
let escaped = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
let response = try await send(
path: "/api/v1/actions/\(escaped)", address: address, port: port,
identity: identity, hostFingerprint: hostFingerprint,
body: (Data(), "application/json"))
switch response.status {
case 200, 202:
return
case 401, 403:
throw LibraryError.unauthorized
default:
// The `ApiError` envelope carries the host's reason; prefer it over the code.
let json = try? JSONSerialization.jsonObject(with: response.body) as? [String: Any]
if let why = json?["error"] as? String, !why.isEmpty {
throw LibraryError.unreachable(why)
}
throw LibraryError.http(response.status)
}
}
/// Just the slice of `/status` this client reads. Everything else on that payload is the
/// operator console's business, and decoding only what we use keeps an unrelated schema change
/// on the host from breaking the library screen.
@@ -276,6 +378,10 @@ public enum LibraryClient {
var games: [RunningGame]?
}
private struct HostActionList: Decodable {
var actions: [HostAction]
}
/// `https://addr:port`, IPv6 literals bracketed the mirror of the Rust client's `base_url`.
static func baseURL(address: String, port: UInt16) -> String {
let bare = address.hasPrefix("[") && address.hasSuffix("]")
@@ -105,6 +105,10 @@ public enum HostRejection: Sendable {
/// The Hello asked to launch a title but this device's grants exclude `LAUNCH` refused
/// at the handshake so the user gets a sentence, not a bare desktop they didn't ask for.
case launchNotPermitted
/// A host power action (`design/host-actions.md`) is ending every session: the host is
/// going to sleep or shutting down, deliberately. Without this case the close reads as a
/// transport failure, and sleeping your own host from the couch looks like a crash.
case hostPower
init?(status: Int32) {
switch status {
@@ -119,6 +123,7 @@ public enum HostRejection: Sendable {
case PUNKTFUNK_STATUS_REJECTED_BUSY.rawValue: self = .busy
case PUNKTFUNK_STATUS_REJECTED_ACCESS_EXPIRED.rawValue: self = .accessExpired
case PUNKTFUNK_STATUS_REJECTED_LAUNCH_NOT_PERMITTED.rawValue: self = .launchNotPermitted
case PUNKTFUNK_STATUS_REJECTED_HOST_POWER.rawValue: self = .hostPower
default: return nil
}
}
@@ -154,6 +159,9 @@ public enum HostRejection: Sendable {
case .launchNotPermitted:
return "This device isn't permitted to launch games on the host — connect "
+ "to the desktop instead, or ask the owner to allow launching."
case .hostPower:
return "The host is going to sleep or shutting down — wake it when you want "
+ "to play again."
}
}
}
@@ -556,9 +564,21 @@ public final class PunktfunkConnection {
public static let grantClipboard: UInt32 = 1 << 3
public static let grantMic: UInt32 = 1 << 4
public static let grantLaunch: UInt32 = 1 << 5
/// Host power the `power.*` host actions (`design/host-actions.md`); route-gated on the
/// mgmt cert lane, never carried by any input event.
public static let grantPower: UInt32 = 1 << 6
/// Every defined grant full control, today's behavior and what an old host's Welcome
/// decodes to.
public static let grantAll: UInt32 = 0x3F
public static let grantAll: UInt32 = 0x7F
/// `grantAll` before Power existed (hosts 0.32.x) see ``normalizedGrants(_:)``.
public static let grantAllPrePower: UInt32 = 0x3F
/// The legacy-full read rule (host-actions §4.3): exactly the pre-power full mask an old
/// host's "Full control" reads as the current ``grantAll``, so a Full session against an
/// old host neither wears a chip nor labels "Custom". Any other mask passes through.
public static func normalizedGrants(_ grants: UInt32) -> UInt32 {
grants == grantAllPrePower ? grantAll : grants
}
/// The three user-facing access presets plus "Custom", DERIVED from the mask (never
/// stored design §3.2, no drift). The label vocabulary is the cross-client one the web
@@ -570,7 +590,7 @@ public final class PunktfunkConnection {
case custom
public init(grants: UInt32) {
switch grants & PunktfunkConnection.grantAll {
switch PunktfunkConnection.normalizedGrants(grants) & PunktfunkConnection.grantAll {
case PunktfunkConnection.grantAll: self = .fullControl
case PunktfunkConnection.grantGamepad: self = .controllerOnly
case 0: self = .viewOnly
@@ -631,9 +651,11 @@ public final class PunktfunkConnection {
/// The session's grants allow mic injection hide the mic UI without it.
public var canUseMic: Bool { accessGrants & Self.grantMic != 0 }
/// Anything about this session's access differs from the everyday full-and-permanent
/// the chip's visibility gate: full + permanent must look exactly like today.
/// the chip's visibility gate: full + permanent must look exactly like today. Compared
/// through ``normalizedGrants(_:)`` so an old host's pre-power full mask stays chipless.
public var accessIsLimited: Bool {
accessGrants & Self.grantAll != Self.grantAll || accessExpiresInSeconds != 0
Self.normalizedGrants(accessGrants) & Self.grantAll != Self.grantAll
|| accessExpiresInSeconds != 0
}
/// The grant bit one wire input kind needs the Swift mirror of core's exhaustive
+92
View File
@@ -159,6 +159,15 @@ pub enum AppMsg {
/// outcome lands as a Toast either way. The mgmt port rides along, resolved like
/// OpenLibrary's.
SendLogs(ConnectRequest, Option<u16>),
/// Run one of the host's own actions — sleep / restart / shut down it
/// (`design/host-actions.md` §7). `danger` asks first; the outcome is a toast.
HostAction {
req: ConnectRequest,
mgmt: Option<u16>,
action_id: String,
label: String,
danger: bool,
},
/// The speed-test dialog resolved (either way) — release `busy`.
SpeedTestDone,
ShowPreferences,
@@ -271,6 +280,19 @@ impl SimpleComponent for AppModel {
HostsOutput::SpeedTest(req) => AppMsg::SpeedTest(req),
HostsOutput::Library(req, mgmt) => AppMsg::OpenLibrary(req, mgmt),
HostsOutput::SendLogs(req, mgmt) => AppMsg::SendLogs(req, mgmt),
HostsOutput::HostAction {
req,
mgmt,
action_id,
label,
danger,
} => AppMsg::HostAction {
req,
mgmt,
action_id,
label,
danger,
},
HostsOutput::Toast(msg) => AppMsg::Toast(msg),
});
@@ -466,6 +488,76 @@ impl SimpleComponent for AppModel {
})
.ok();
}
AppMsg::HostAction {
req,
mgmt,
action_id,
label,
danger,
} => {
let mgmt = mgmt.unwrap_or(pf_client_core::library::DEFAULT_MGMT_PORT);
// Restart and shut down lose whatever is running on that machine, so they ask
// first — the same treatment Forget gets. Sleep is reversible from the same
// menu ("Wake host"), so it goes straight through.
if danger {
let dialog = adw::AlertDialog::new(
Some(&format!("{label}?")),
Some(&format!(
"This ends every stream from {} and anything running on it. \
You'll need to wake or start it again.",
req.name
)),
);
dialog.add_responses(&[("cancel", "Cancel"), ("go", &label)]);
dialog.set_response_appearance("go", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let out = sender.input_sender().clone();
let (req, action_id, label) = (req.clone(), action_id.clone(), label.clone());
dialog.connect_response(Some("go"), move |_, _| {
out.send(AppMsg::HostAction {
req: req.clone(),
mgmt: Some(mgmt),
action_id: action_id.clone(),
label: label.clone(),
// Asked and answered.
danger: false,
})
.ok();
});
dialog.present(Some(&self.window));
return;
}
// Blocking network on a worker, outcome as a toast — the SendLogs recipe. A
// 202 is the last word: the host ends every session and acts a second later,
// so there is nothing to poll and nothing to undo.
let identity = self.identity.clone();
let pin = req.fp_hex.as_deref().and_then(trust::parse_hex32);
if let Some(fp) = req.fp_hex.as_deref() {
// Whatever the host said about itself is about to be wrong.
pf_client_core::host_actions::invalidate(fp);
}
self.toast(&format!("{label} — asking {}", req.name));
let out = sender.input_sender().clone();
std::thread::Builder::new()
.name("punktfunk-hostaction".into())
.spawn(move || {
let msg = match pf_client_core::host_actions::invoke(
&req.addr, mgmt, &identity, pin, &action_id,
) {
Ok(()) => {
tracing::info!(host = %req.name, action = %action_id, "host action accepted");
format!("{}: {label} — on its way", req.name)
}
Err(e) => {
tracing::warn!(host = %req.name, action = %action_id, error = %e, "host action refused");
format!("{label} failed — {e}")
}
};
let _ = out.send(AppMsg::Toast(msg));
})
.ok();
}
AppMsg::SpeedTestDone => self.busy = false,
AppMsg::OpenLibrary(req, mgmt_port) => {
crate::ui_library::open(self, &sender, req, mgmt_port);
+99 -1
View File
@@ -96,6 +96,16 @@ pub enum CardOutput {
Library(ConnectRequest),
/// Upload this device's recent log ring to the host (`logring::send_to_host`).
SendLogs(ConnectRequest),
/// Run one of the host's OWN actions — sleep / restart / shut down it
/// (`design/host-actions.md` §7). `label` is what the menu called it, so the confirmation
/// and the toast say the same words the row did.
HostAction {
req: ConnectRequest,
action_id: String,
label: String,
/// Ask before running: the action loses whatever is on that machine.
danger: bool,
},
/// Open the host edit sheet (name, profile binding, pinned cards, clipboard).
Edit {
fp_hex: String,
@@ -111,6 +121,9 @@ pub enum CardOutput {
},
/// Put this card's `punktfunk://` URL on the clipboard.
CopyLink(String),
/// A one-line message for the window's toast overlay — a card that has something to say
/// and nothing to do (a host action the host has already told us it cannot run).
Toast(String),
/// Write a desktop entry that launches this card's URL.
CreateShortcut {
label: String,
@@ -372,6 +385,38 @@ impl relm4::factory::FactoryComponent for HostCard {
}),
);
}
// The host's own actions, one registered action per offered row. Read from
// the shared cache the hosts page keeps warm, so the menu's rows and these
// handlers are built from the SAME answer — a menu whose rows outlived their
// handlers would run the wrong verb, and two of these verbs are irreversible.
let host_actions = pf_client_core::host_actions::cached(&k.fp_hex);
for (i, a) in host_actions.iter().enumerate() {
let (req, id, label, danger) =
(req.clone(), a.id.clone(), a.label().to_string(), a.danger);
let available = a.available;
let reason = a.unavailable_reason.clone().unwrap_or_default();
add(
&format!("action{i}"),
Box::new(move || {
if available {
CardOutput::HostAction {
req: req.clone(),
action_id: id.clone(),
label: label.clone(),
danger,
}
} else {
// The host already said it cannot do this right now; say why
// rather than send a request we know it will refuse.
CardOutput::Toast(if reason.is_empty() {
format!("{label} isn't available right now")
} else {
reason.clone()
})
}
}),
);
}
// "Copy link" / "Create shortcut…": the self-emitted URL for this card, which
// is what an external tool (a Playnite entry, a Stream Deck macro) is
// configured with. It carries the stable id AND host+fp, so it still resolves
@@ -540,6 +585,20 @@ impl relm4::factory::FactoryComponent for HostCard {
if !online && !k.mac.is_empty() {
look.append(Some("Wake host"), Some("card.wake"));
}
// …and the other half of that round trip: whatever this host last said it
// lets this device do to it (sleep, restart, shut down). Nothing is decided
// here — the list is empty unless the host answered and this device's
// access carries the grant, so no row ever appears that the host would
// refuse. Indexed actions rather than fixed labels: a later host can add
// one and this menu renders it with no client release.
for (i, a) in host_actions.iter().enumerate() {
let label = if a.available {
a.label().to_string()
} else {
format!("{} (unavailable)", a.label())
};
look.append(Some(&label), Some(&format!("card.action{i}")));
}
menu.append_section(None, &look);
let links = gio::Menu::new();
@@ -747,6 +806,15 @@ pub enum HostsOutput {
Library(ConnectRequest, Option<u16>),
/// With the mgmt port resolved the same way as [`HostsOutput::Library`]'s.
SendLogs(ConnectRequest, Option<u16>),
/// Run one of the host's own actions (`design/host-actions.md` §7) — same mgmt-port
/// resolution as the two above.
HostAction {
req: ConnectRequest,
mgmt: Option<u16>,
action_id: String,
label: String,
danger: bool,
},
}
impl SimpleComponent for HostsPage {
@@ -1042,6 +1110,24 @@ impl SimpleComponent for HostsPage {
let mgmt = self.mgmt_port_for(&req);
let _ = sender.output(HostsOutput::SendLogs(req, mgmt));
}
CardOutput::HostAction {
req,
action_id,
label,
danger,
} => {
let mgmt = self.mgmt_port_for(&req);
let _ = sender.output(HostsOutput::HostAction {
req,
mgmt,
action_id,
label,
danger,
});
}
CardOutput::Toast(msg) => {
let _ = sender.output(HostsOutput::Toast(msg));
}
CardOutput::Edit { fp_hex, name } => self.edit_host_dialog(&sender, &fp_hex, &name),
CardOutput::Forget { fp_hex, name } => self.forget_dialog(&sender, &fp_hex, &name),
CardOutput::Wake { mac, addr } => crate::wol::wake(&mac, addr.parse().ok()),
@@ -1123,7 +1209,8 @@ impl HostsPage {
// — the last one not cosmetic, since a host that moved off 47990 loses its
// library the moment mDNS is unavailable and the advert is the only place the
// real port ever lived.
if let Some(a) = self.adverts.values().find(|a| matches(k, a)) {
let advert = self.adverts.values().find(|a| matches(k, a));
if let Some(a) = advert {
crate::trust::learn_from_advert(
&k.fp_hex,
&k.addr,
@@ -1133,6 +1220,17 @@ impl HostsPage {
a.mgmt_port,
);
}
// Keep this host's advertised actions warm, so the card's menu is built from a
// settled answer rather than one that arrives while the menu is open. Gated on
// the TTL inside, so an ordinary refresh costs nothing. Same three rungs for
// the port as everything else here: live advert, then the stored one, then the
// default.
if k.paired && online {
let mgmt = advert
.and_then(|a| a.mgmt_port)
.unwrap_or_else(|| k.effective_mgmt_port());
pf_client_core::host_actions::refresh(&k.addr, mgmt, &k.fp_hex);
}
saved.push_back(HostCard {
connecting: self.connecting.as_deref() == Some(k.fp_hex.as_str()),
kind: CardKind::Saved {
+67
View File
@@ -85,6 +85,9 @@ pub fn run(target: Option<&str>) -> u8 {
clipboard_sync: k.is_some_and(|h| h.clipboard_sync),
last_used: k.and_then(|h| h.last_used),
os: k.map(|h| h.os.clone()).unwrap_or_default(),
// A seed row is a host nobody has reached yet; the refresh tick fills this in
// once it is paired and answering.
actions: Vec::new(),
pin: None,
bound_profile: None,
};
@@ -340,6 +343,7 @@ fn fake_host_row() -> HostRow {
clipboard_sync: false,
last_used: None,
os: "linux/arch/steamos".into(),
actions: Vec::new(),
pin: None,
bound_profile: None,
}
@@ -437,6 +441,7 @@ impl ServiceState {
if self.last_probe.elapsed() >= Duration::from_secs(10) {
self.last_probe = Instant::now();
self.sweep();
self.refresh_actions();
}
self.console.set_hosts(self.rows());
@@ -524,6 +529,41 @@ impl ServiceState {
})
.ok();
}
ConsoleCmd::HostAction {
addr,
mgmt,
fp_hex,
host_name,
action_id,
label,
} => {
// Same lane and budgets as SendLogs above, and the same worker-thread reason.
// A 202 is the last word: the host ends every session and acts a second later,
// so there is nothing to poll and nothing to undo — say it plainly and let the
// tile go dark on its own.
let identity = self.identity.clone();
let pin = trust::parse_hex32(&fp_hex);
let console = self.console.clone();
// Whatever the host said about itself is about to be wrong.
pf_client_core::host_actions::invalidate(&fp_hex);
std::thread::Builder::new()
.name("punktfunk-hostaction".into())
.spawn(move || {
match pf_client_core::host_actions::invoke(
&addr, mgmt, &identity, pin, &action_id,
) {
Ok(()) => {
tracing::info!(host = %host_name, action = %action_id, "host action accepted");
console.set_notice(format!("{host_name}: {label} — on its way"));
}
Err(e) => {
tracing::warn!(host = %host_name, action = %action_id, error = %e, "host action refused");
console.set_notice(format!("{label} failed — {e}"));
}
}
})
.ok();
}
ConsoleCmd::Pair {
addr,
port,
@@ -765,6 +805,17 @@ impl ServiceState {
.ok();
}
/// Keep every paired, reachable host's advertised actions fresh (the shared TTL'd cache in
/// `pf_client_core::host_actions`). Idempotent and cheap — it only reaches the network when
/// an entry has actually lapsed.
fn refresh_actions(&self) {
for r in self.rows() {
if r.paired && r.online && r.pin.is_none() {
pf_client_core::host_actions::refresh(&r.addr, r.mgmt_port, &r.fp_hex);
}
}
}
fn advertised(&self, row: &HostRow) -> bool {
self.discovered.values().any(|d| {
(!row.fp_hex.is_empty() && d.fp_hex == row.fp_hex)
@@ -839,6 +890,20 @@ impl ServiceState {
.filter(|d| !d.os.is_empty())
.map(|d| d.os.clone())
.unwrap_or_else(|| h.os.clone()),
// Whatever this host last told us it lets this device do to it. Empty
// until the first refresh answers, and empty forever for a host that has
// no such route or a device without the grant — the menu simply has no
// power rows then.
actions: pf_client_core::host_actions::cached(&h.fp_hex)
.into_iter()
.map(|a| pf_console_ui::HostAction {
label: a.label().to_string(),
id: a.id,
danger: a.danger,
available: a.available,
unavailable_reason: a.unavailable_reason.unwrap_or_default(),
})
.collect(),
pin: None,
bound_profile: h
.profile_id
@@ -895,6 +960,8 @@ impl ServiceState {
clipboard_sync: false,
last_used: None,
os: d.os.clone(),
// Discovered but unsaved: not paired, so there is nothing it would let us do.
actions: Vec::new(),
pin: None,
bound_profile: None,
})
+98
View File
@@ -21,6 +21,24 @@ const MENU_SPEED: &str = "Test network speed\u{2026}";
/// and an offline host could only ever report an error.
const MENU_SEND_LOGS: &str = "Send logs to host";
const MENU_WAKE: &str = "Wake host";
/// The host's OWN actions — sleep / restart / shut down it (`design/host-actions.md` §7) —
/// each prefixed so the shared click callback can tell them from the fixed entries and recover
/// which one was picked. The rows come from what the HOST said it lets this device do, so a
/// device without the Host-power grant sees none, and a later host can add one without a
/// client release. Same shape as [`MENU_PIN`]'s dynamic family, for the same reason.
const MENU_HOST_ACTION: &str = "\u{23fb} ";
/// One host action's menu label. Used to BUILD the row and to recognise it again in the click
/// callback — one function, so the two can never disagree, and the match stays exact rather
/// than a prefix test that two similarly-named actions could both satisfy.
#[cfg(windows)]
fn host_action_label(a: &pf_client_core::host_actions::ActionInfo) -> String {
format!(
"{MENU_HOST_ACTION}{}{}",
a.label(),
if a.available { "" } else { " (unavailable)" }
)
}
/// One entry for every per-host property (name, address, MAC, clipboard sharing) — the
/// Apple client's add/edit sheet. A menu item per field read as clutter and buried the ones
/// that matter.
@@ -724,8 +742,23 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
);
}
let can_wake = !online && !k.mac.is_empty();
// What this host last said it lets this device do to it. Kept warm here — on the
// list's own refresh, gated by the cache's TTL — so the menu is BUILT from a
// settled answer: rows that appeared while a menu was open would land under a
// cursor already moving, and two of these rows shut a machine down.
if k.paired && online {
pf_client_core::host_actions::refresh(
&k.addr,
target
.mgmt_port
.unwrap_or(pf_client_core::library::DEFAULT_MGMT_PORT),
&k.fp_hex,
);
}
let host_actions = pf_client_core::host_actions::cached(&k.fp_hex);
let menu = {
let (svc, target) = (props.svc.clone(), target.clone());
let click_actions = host_actions.clone();
let (sf, sr) = (set_forget.clone(), set_rename.clone());
let (fp, name) = (k.fp_hex.clone(), k.name.clone());
let menu_profiles = profiles.clone();
@@ -771,6 +804,13 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
if can_wake {
items.push(menu_item(MENU_WAKE));
}
// …and the other half of that round trip, from the shared cache the
// host list keeps warm. Empty unless the host answered AND this
// device's access carries the grant, so no row here can be refused
// for permission.
for a in &host_actions {
items.push(menu_item(host_action_label(a)));
}
items.push(menu_separator());
items.push(menu_item(MENU_COPY_LINK));
@@ -799,6 +839,64 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
items
})
.on_item_clicked(move |item: String| match item.as_str() {
// The host's own actions are dynamic too, and matched by prefix ahead
// of the fixed entries. The label is recovered back to an id through
// the SAME list the rows were built from, so a menu whose rows outlived
// their handlers can never run a different verb than the one clicked —
// which matters here more than anywhere else in this menu.
_ if item.starts_with(MENU_HOST_ACTION) => {
let Some(a) =
click_actions.iter().find(|a| host_action_label(a) == item)
else {
return;
};
let set_status = svc.set_status.clone();
let (action_id, label) = (a.id.clone(), a.label().to_string());
if !a.available {
// The host already said it cannot do this right now.
set_status.call(
a.unavailable_reason
.clone()
.unwrap_or_else(|| format!("{label} isn't available")),
);
return;
}
let identity = svc.ctx.identity.clone();
let target = target.clone();
if let Some(fp) = target.fp_hex.as_deref() {
// Whatever the host said about itself is about to be wrong.
pf_client_core::host_actions::invalidate(fp);
}
set_status.call(format!("{label} — asking {}", target.name));
let _ = std::thread::Builder::new()
.name("punktfunk-hostaction".into())
.spawn(move || {
let pin = target
.fp_hex
.as_deref()
.and_then(crate::trust::parse_hex32);
let mgmt = target
.mgmt_port
.unwrap_or(pf_client_core::library::DEFAULT_MGMT_PORT);
let msg = match pf_client_core::host_actions::invoke(
&target.addr,
mgmt,
&identity,
pin,
&action_id,
) {
Ok(()) => {
tracing::info!(host = %target.name, action = %action_id, "host action accepted");
format!("{}: {label} — on its way", target.name)
}
Err(e) => {
tracing::warn!(host = %target.name, action = %action_id, error = %e, "host action refused");
format!("{label} failed — {e}")
}
};
set_status.call(msg);
});
}
// The profile items are dynamic, so they are matched by prefix before
// the fixed ones.
_ if item.starts_with(MENU_PIN) || item.starts_with(MENU_UNPIN) => {
+28 -5
View File
@@ -8,9 +8,20 @@
//! The Apple/Android clients mirror these rules rather than link them — the labels, the
//! chip/notice wording and the derive-not-store rule below are the contract they copy.
use punktfunk_core::quic::{GRANT_ALL, GRANT_PRESET_CONTROLLER_ONLY, GRANT_PRESET_VIEW_ONLY};
use punktfunk_core::quic::{
normalize_legacy_full, GRANT_ALL, GRANT_PRESET_CONTROLLER_ONLY, GRANT_PRESET_VIEW_ONLY,
};
use std::time::{Duration, Instant};
/// The mask as THIS build's vocabulary reads it: an old host's explicit pre-power "Full
/// control" normalizes to the current `GRANT_ALL` (the legacy-full rule, host-actions §4.3),
/// and bits newer than this build are dropped — so a new host's Full session never renders as
/// "Custom" on an older client (the #408-family rider fix: `preset_label` used to compare the
/// raw wire mask against `GRANT_ALL` unmasked).
fn effective_mask(grants: u32) -> u32 {
normalize_legacy_full(grants) & GRANT_ALL
}
/// What this session may do and for how long — the client-side snapshot of the host's
/// [`Welcome`](punktfunk_core::quic::Welcome) advert, revised by every mid-session
/// [`AccessUpdate`](punktfunk_core::quic::AccessUpdate) (latest wins). Carried on
@@ -57,9 +68,11 @@ impl SessionAccess {
}
/// Full control, permanent — today's default look, which must stay unchanged: no chip,
/// no gating, no toasts (design §7; old-host degrade).
/// no gating, no toasts (design §7; old-host degrade). Compared through
/// [`effective_mask`], so neither an old host's pre-power full mask nor a future host's
/// wider one puts a chip on a session that is simply Full.
pub fn is_default(&self) -> bool {
self.grants == GRANT_ALL && self.deadline.is_none()
effective_mask(self.grants) == GRANT_ALL && self.deadline.is_none()
}
/// Time left before this access expires — `None` = permanent, zero = already due
@@ -83,9 +96,11 @@ impl SessionAccess {
}
/// The user-facing preset name DERIVED from the mask (design §3.2 — never stored, no
/// drift): the three presets, and "Custom" for any other combination.
/// drift): the three presets, and "Custom" for any other combination. Matches on
/// [`effective_mask`] so a host with a different grant vocabulary (older: pre-power full;
/// newer: bits this build doesn't know) still labels a Full session "Full control".
pub fn preset_label(grants: u32) -> &'static str {
match grants {
match effective_mask(grants) {
GRANT_ALL => "Full control",
GRANT_PRESET_CONTROLLER_ONLY => "Controller only",
GRANT_PRESET_VIEW_ONLY => "View only",
@@ -135,6 +150,14 @@ mod tests {
// the media-remote example, and a full mask missing one bit.
assert_eq!(preset_label(GRANT_GAMEPAD | GRANT_CLIPBOARD), "Custom");
assert_eq!(preset_label(GRANT_ALL & !GRANT_KEYBOARD), "Custom");
// The two vocabulary-drift cases (host-actions §4.3): an old host's pre-power full
// mask, and a future host's full mask with a bit this build doesn't know — both are
// simply Full, never "Custom".
assert_eq!(
preset_label(punktfunk_core::quic::GRANT_ALL_PRE_POWER),
"Full control"
);
assert_eq!(preset_label(GRANT_ALL | (1 << 20)), "Full control");
}
#[test]
+272
View File
@@ -0,0 +1,272 @@
//! Host actions from a client (`design/host-actions.md` §7): discovering what the paired host
//! offers — v1, sleep / restart / shut down — and invoking one by id.
//!
//! Same lane, same trust, same agent as the library browse ([`crate::library`]): TLS client auth
//! with the device identity, host pinned by fingerprint, over `mgmt_port`. Nothing new is asked
//! of the transport, and the HOST is the only enforcer — [`ActionInfo::permitted`] is what the
//! host says about *this* device's grants, so the client renders honestly instead of offering a
//! row that will 403.
//!
//! Both calls work OUT of session, which is the point: "sleep the host" belongs on a host tile
//! at the end of an evening, not only mid-stream.
use serde::Deserialize;
/// One action as the host reports it to THIS caller (`GET /api/v1/actions`).
///
/// Unknown ids are expected and fine — a client renders [`Self::title`] verbatim for anything it
/// has no local string for, which is what lets a later host add actions with no client release.
#[derive(Clone, Debug, Deserialize)]
pub struct ActionInfo {
/// Stable id: `power.sleep`, `power.reboot`, `power.shutdown` today.
pub id: String,
/// The host's own display title — the fallback label for an id this client doesn't know.
#[serde(default)]
pub title: String,
/// Action group (`power` for the built-ins).
#[serde(default)]
pub group: String,
/// Confirm twice before running it: the action loses state (restart, shut down).
#[serde(default)]
pub danger: bool,
/// Whether the host can run it at all right now (a machine that cannot suspend, a foreign
/// inhibitor, a missing group membership).
#[serde(default)]
pub available: bool,
/// Why not, when `available` is false — shown rather than hidden, so "greyed out" always has
/// a reason attached.
#[serde(default)]
pub unavailable_reason: Option<String>,
/// Whether THIS device's access covers it (the host's Host-power grant).
#[serde(default)]
pub permitted: bool,
}
impl ActionInfo {
/// Offer this row at all? Actions the device may not invoke are hidden (that is the access
/// level talking, and a permanently dead row is noise); actions it may invoke but the host
/// cannot run right now are SHOWN, disabled, with [`Self::unavailable_reason`].
pub fn offerable(&self) -> bool {
self.permitted
}
/// The client's own label for a known id, else the host's title. Keeps a familiar action
/// worded the way the rest of this client words it, without hiding an unfamiliar one.
pub fn label(&self) -> &str {
match self.id.as_str() {
"power.sleep" => "Sleep host",
"power.reboot" => "Restart host",
"power.shutdown" => "Shut down host",
_ => &self.title,
}
}
}
#[cfg(any(target_os = "linux", windows))]
#[derive(Deserialize)]
struct ActionList {
#[serde(default)]
actions: Vec<ActionInfo>,
}
/// What this host offers this device, from `GET /api/v1/actions`.
///
/// **Best-effort by contract**, exactly like [`crate::library::fetch_running`]: an older host
/// (which has no such route), an unreachable one, or a shape we don't recognize yields an empty
/// list rather than an error. A missing row costs a menu entry; failing a host card over it
/// would cost the screen.
#[cfg(any(target_os = "linux", windows))]
pub fn fetch_actions(
addr: &str,
mgmt_port: u16,
identity: &(String, String),
pin: Option<[u8; 32]>,
) -> Vec<ActionInfo> {
let Ok(agent) = crate::library::agent(identity, pin) else {
return Vec::new();
};
let url = format!(
"{}/api/v1/actions",
crate::library::base_url(addr, mgmt_port)
);
let Ok(mut resp) = agent.get(&url).call() else {
return Vec::new();
};
let Ok(body) = resp.body_mut().read_to_string() else {
return Vec::new();
};
serde_json::from_str::<ActionList>(&body)
.map(|l| l.actions)
.unwrap_or_default()
}
/// Invoke one action by id (`POST /api/v1/actions/{id}`, empty body).
///
/// `Ok(())` means the host ACCEPTED it (202) — it now ends every session and acts about a second
/// later, so this is the last word the client will get on the subject. The error is already
/// user-facing: the host's own refusal sentence (another device is streaming, a foreign sleep
/// inhibitor, the platform said no), or the library lane's classified transport error.
#[cfg(any(target_os = "linux", windows))]
pub fn invoke(
addr: &str,
mgmt_port: u16,
identity: &(String, String),
pin: Option<[u8; 32]>,
action_id: &str,
) -> Result<(), crate::library::LibraryError> {
use crate::library::LibraryError;
let agent = crate::library::agent(identity, pin)?;
// The id is the whole request — the body stays empty (the host's rule: no request field ever
// reaches the privileged path). Percent-encoding is unnecessary and would be wrong: ids are
// `[a-z.]` by grammar, and anything else is an id this host will 404 anyway.
let url = format!(
"{}/api/v1/actions/{action_id}",
crate::library::base_url(addr, mgmt_port)
);
match agent.post(&url).send_empty() {
Ok(_) => Ok(()),
// A refusal carries the host's reason in the `ApiError` envelope; surface THAT, because
// "409" tells a person nothing and "another device is streaming from this host right
// now" tells them exactly what to do.
Err(ureq::Error::StatusCode(code)) if (400..500).contains(&code) => Err(
LibraryError::Unreachable(format!("the host refused ({code})")),
),
Err(e) => Err(crate::library::classify(e)),
}
}
/// How long a host's answer stays fresh before [`refresh`] will ask again. Long on purpose:
/// what it governs — whether this device holds the Host-power grant, whether the box can
/// suspend — changes when an operator edits access, not minute to minute, and every refresh is
/// a TLS handshake against a host that is otherwise idle.
#[cfg(any(target_os = "linux", windows))]
pub const TTL: std::time::Duration = std::time::Duration::from_secs(300);
/// The process-wide answer cache, by host fingerprint.
///
/// Every desktop shell needs the same thing — a list settled BEFORE a menu draws (rows that
/// appear under a cursor already moving toward something else are a hazard when two of them
/// shut a machine down), refreshed rarely, shared across the screens that show it. One cache
/// with one TTL rule beats the same 40 lines in the console, the GTK page and the Windows
/// tile — which is how three shells end up disagreeing about what a host offers.
#[cfg(any(target_os = "linux", windows))]
type Cache =
std::sync::Mutex<std::collections::HashMap<String, (std::time::Instant, Vec<ActionInfo>)>>;
#[cfg(any(target_os = "linux", windows))]
fn cache() -> &'static Cache {
static C: std::sync::OnceLock<Cache> = std::sync::OnceLock::new();
C.get_or_init(Default::default)
}
/// What this host last said it lets this device do — the OFFERABLE rows only, so a caller
/// renders what it gets. Empty until a [`refresh`] has answered, and empty for a host with no
/// such route or a device without the grant.
#[cfg(any(target_os = "linux", windows))]
pub fn cached(fp_hex: &str) -> Vec<ActionInfo> {
cache()
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(fp_hex)
.map(|(_, a)| a.clone())
.unwrap_or_default()
}
/// Ask this host again, off the caller's thread, unless the cached answer is still inside
/// [`TTL`]. Cheap and idempotent: call it on whatever refresh tick a shell already has.
///
/// The freshness stamp is taken BEFORE the request, so a slow or hanging host cannot make
/// every tick spawn another worker for it. The device identity is loaded on the worker rather
/// than taken as an argument — it is the same one file every shell already reads, and asking
/// for it here would mean threading it through three menus that have no other use for it.
#[cfg(any(target_os = "linux", windows))]
pub fn refresh(addr: &str, mgmt_port: u16, fp_hex: &str) {
if fp_hex.is_empty() {
return; // no pinned identity ⇒ nothing to authenticate as, and nothing to key on
}
{
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
match c.get_mut(fp_hex) {
Some(entry) if entry.0.elapsed() < TTL => return,
Some(entry) => entry.0 = std::time::Instant::now(),
None => {
c.insert(fp_hex.to_string(), (std::time::Instant::now(), Vec::new()));
}
}
}
let (addr, fp_hex) = (addr.to_string(), fp_hex.to_string());
std::thread::Builder::new()
.name("punktfunk-hostactions".into())
.spawn(move || {
let Ok(identity) = crate::trust::load_or_create_identity() else {
return; // no device identity ⇒ nothing to authenticate as
};
let pin = crate::trust::parse_hex32(&fp_hex);
let found: Vec<ActionInfo> = fetch_actions(&addr, mgmt_port, &identity, pin)
.into_iter()
// The host decides who may see a row; a device without the grant is told
// nothing about the action beyond that it exists, and shows nothing.
.filter(ActionInfo::offerable)
.collect();
cache()
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(fp_hex, (std::time::Instant::now(), found));
})
.ok();
}
/// Forget what this host said — call it right after invoking an action, because whatever it
/// said is about to be wrong. Without this, a menu goes on offering "Sleep host" on a machine
/// that is already asleep until the TTL lapses.
#[cfg(any(target_os = "linux", windows))]
pub fn invalidate(fp_hex: &str) {
cache()
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(fp_hex);
}
#[cfg(test)]
mod tests {
use super::*;
/// Known ids wear this client's wording; an id from a future host wears the host's own
/// title, which is the whole no-client-release contract.
#[test]
fn labels_prefer_local_wording_and_fall_back_to_the_host() {
let mk = |id: &str, title: &str| ActionInfo {
id: id.into(),
title: title.into(),
group: "power".into(),
danger: false,
available: true,
unavailable_reason: None,
permitted: true,
};
assert_eq!(mk("power.sleep", "Sleep host").label(), "Sleep host");
assert_eq!(mk("power.reboot", "whatever").label(), "Restart host");
assert_eq!(
mk("plugin:vpn:toggle", "Toggle the VPN").label(),
"Toggle the VPN"
);
}
/// Not-permitted hides the row (that is the device's access level, and it will not change
/// while the menu is open); unavailable KEEPS it, so the reason can be shown.
#[test]
fn permission_hides_but_unavailability_only_disables() {
let mut a = ActionInfo {
id: "power.sleep".into(),
title: "Sleep host".into(),
group: "power".into(),
danger: false,
available: false,
unavailable_reason: Some("this machine does not support sleep".into()),
permitted: true,
};
assert!(a.offerable(), "unavailable actions are shown with a reason");
a.permitted = false;
assert!(!a.offerable(), "an ungranted action is not offered at all");
}
}
+6
View File
@@ -63,6 +63,12 @@ pub mod library;
// Per-host catalog cache, so a library screen has titles to show while a sleeping host boots.
#[cfg(any(target_os = "linux", windows))]
pub mod library_cache;
// Host actions — sleep/restart/shut down the host (design/host-actions.md §7). Android-enabled
// for the MODEL half (the row type + labelling rules the console screens read); the ureq calls
// inside stay desktop-gated, exactly like `library`, since Android dials the same routes through
// its own mTLS OkHttp client.
#[cfg(any(target_os = "linux", windows, target_os = "android"))]
pub mod host_actions;
// Android-enabled for the RING half (note/render — std only): the client's "Send logs to
// host" needs the ring on every platform. The `send_to_host` uploader inside stays
// desktop-gated with the rest of the ureq fetches; Android posts the rendered bundle
+4
View File
@@ -876,6 +876,10 @@ pub fn connect_reject_message(reason: punktfunk_core::reject::RejectReason) -> S
a game, or ask the host's owner to allow launching."
.into()
}
R::HostPower => {
"The host is going to sleep or shutting down — wake it when you want to play again."
.into()
}
}
}
+1 -1
View File
@@ -64,7 +64,7 @@ pub use input::Key;
pub use library::{LibraryGame, LibraryPhase, LibraryShared, Stale};
#[cfg(any(target_os = "linux", windows, target_os = "android"))]
pub use model::{
ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, ProfileChip, WakeStatus,
ConsoleBus, ConsoleCmd, ConsoleShared, HostAction, HostRow, PairPhase, ProfileChip, WakeStatus,
};
#[cfg(any(target_os = "linux", windows, target_os = "android"))]
pub use platform::{Platform, PlatformScreen};
+49
View File
@@ -52,6 +52,13 @@ pub struct HostRow {
/// future tile OS glyph. Empty = unknown (older host). Plumbed now; drawing is a
/// follow-up — the Skia glyph set doesn't exist yet.
pub os: String,
/// What this host lets THIS device do to it beyond streaming — sleep, restart, shut down
/// (`design/host-actions.md` §7), as the host itself reported them. Empty for an
/// unreachable host, an older one with no such route, and any device whose access does not
/// carry the Host-power grant: the host is the only judge of that, and a row it would
/// refuse is not offered. `serde(default)` so a producer predating the field still parses.
#[serde(default)]
pub actions: Vec<HostAction>,
/// `Some` = this row is a pinned profile card (§5.2a): a shortcut tile rendered right
/// after its host's primary tile, sharing its live state, that connects with THIS
/// profile. `None` = the host's primary tile.
@@ -62,6 +69,30 @@ pub struct HostRow {
pub bound_profile: Option<ProfileChip>,
}
/// One action a host offers this device, resolved by the service thread from the host's own
/// `GET /api/v1/actions` (`design/host-actions.md` §3.2) — v1: sleep, restart, shut down.
///
/// Presentational on purpose: the label is already the one this client would use for a known
/// id and the host's own title for an id this client has never heard of, so a later host can
/// add an action and this console renders it with no release. The shell never decides WHETHER
/// an action may run — the host answered that before the row existed, and answers it again on
/// invoke.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct HostAction {
/// Stable action id, the invoke argument (`power.sleep`).
pub id: String,
/// Row label, already resolved (local wording for a known id, else the host's title).
pub label: String,
/// Confirm twice — the action loses state (restart, shut down).
pub danger: bool,
/// The host can run it right now. `false` still shows the row, disabled, because
/// "unavailable, because X" is more use than a row that quietly vanished.
pub available: bool,
/// Why it can't run, when it can't. Empty otherwise.
#[serde(default)]
pub unavailable_reason: String,
}
/// The pairing ceremony's observable state (one at a time — the ceremony is modal).
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
pub enum PairPhase {
@@ -255,6 +286,23 @@ pub enum ConsoleCmd {
/// of them is the same shape — do the platform thing, report back as a notice — and a
/// command per grant would make adding the next pad a change in three crates.
PadAction { action: String, pad_key: String },
/// Invoke one of the host's own actions — sleep / restart / shut down it
/// (`design/host-actions.md`). Same lane and trust as [`ConsoleCmd::SendLogs`]; the host
/// re-checks this device's Host-power grant on arrival, so the row having existed grants
/// nothing. Parameterised by `action_id` for the same reason [`ConsoleCmd::PadAction`] is:
/// the host is free to grow the list, and a command per verb would make that a change in
/// three crates. The outcome arrives as a notice toast.
HostAction {
addr: String,
mgmt: u16,
fp_hex: String,
host_name: String,
/// The action's stable id (`power.sleep`).
action_id: String,
/// Its resolved label, for the toast — so the service thread need not re-derive
/// wording the screen has already settled.
label: String,
},
}
/// The overlay→binary command queue. A plain deque under the same locking discipline as
@@ -297,6 +345,7 @@ mod tests {
clipboard_sync: false,
last_used: None,
os: String::new(),
actions: Vec::new(),
pin: None,
bound_profile: None,
};
@@ -224,6 +224,7 @@ mod tests {
clipboard_sync: false,
last_used: None,
os: String::new(),
actions: Vec::new(),
pin: None,
bound_profile: bound.map(|id| ProfileChip {
id: id.into(),
@@ -827,6 +827,7 @@ mod tests {
clipboard_sync: false,
last_used: None,
os: String::new(),
actions: Vec::new(),
pin: None,
bound_profile: None,
}
+1
View File
@@ -832,6 +832,7 @@ mod tests {
clipboard_sync: false,
last_used: None,
os: String::new(),
actions: Vec::new(),
pin: None,
bound_profile: None,
}
@@ -2167,6 +2167,7 @@ mod tests {
clipboard_sync: false,
last_used: None,
os: String::new(),
actions: Vec::new(),
pin: None,
bound_profile: None,
}
+215 -15
View File
@@ -33,6 +33,12 @@ use skia_safe::{Canvas, Rect};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Action {
Wake,
/// One of the host's OWN actions — sleep / restart / shut down it
/// (`design/host-actions.md` §7), indexed into [`HostRow::actions`], which the service
/// thread filled from the host's discovery. Indexed rather than a variant per verb
/// because the console must render an action this build has never heard of: the host
/// already sent a label and said whether this device may run it.
Host(usize),
SendLogs,
/// Open this host's game library — the same shelf the home carousel's Y opens, offered
/// here because Y is a face button and a TV remote has none. Saved-and-paired only,
@@ -76,11 +82,15 @@ pub(crate) struct OptionsScreen {
/// be able to do that.
subject: Subject,
list: MenuList,
/// Forget is the one action here with no undo, so the row arms on the first press and
/// only fires on the second. The other clients forget outright; a console is driven by
/// a thumbstick from across a room, which is a good reason to be stricter than they
/// The row currently armed, if any: an action with no undo arms on the first press and
/// only fires on the second. Forget was the first; the host's own destructive actions
/// (restart, shut down) join it. The other clients forget outright; a console is driven
/// by a thumbstick from across a room, which is a good reason to be stricter than they
/// are, and none at all to be looser.
armed: bool,
///
/// Holding WHICH action is armed, rather than a bare flag, is what stops an arming press
/// on one destructive row from firing a different one the cursor then landed on.
armed: Option<Action>,
}
impl OptionsScreen {
@@ -92,7 +102,7 @@ impl OptionsScreen {
OptionsScreen {
subject,
list: MenuList::new(),
armed: false,
armed: None,
}
}
@@ -148,6 +158,11 @@ impl OptionsScreen {
if host.can_wake && !host.online {
a.push(Action::Wake);
}
// …and the other half of that round trip, immediately below it: the host's own
// actions, as IT reported them for this device. Nothing is decided here — the list is
// empty unless the host is reachable and this device's access carries the grant, so
// "Sleep host" appears exactly where "Wake host" was the evening before.
a.extend((0..host.actions.len()).map(Action::Host));
// "Send logs" needs a paired identity (the upload authenticates with the streaming
// cert) and a reachable host — on anything else the row would only ever toast an
// error. This is the log-escape hatch for platforms whose own filesystem the user
@@ -180,6 +195,13 @@ impl OptionsScreen {
fn label(&self, a: Action) -> String {
match a {
Action::Wake => "Wake host".into(),
Action::Host(i) => match self.host().actions.get(i) {
Some(act) if self.armed == Some(a) => {
format!("{} \u{2014} press again", act.label)
}
Some(act) => act.label.clone(),
None => String::new(),
},
Action::SendLogs => "Send logs to host".into(),
Action::Library => "Library".into(),
Action::CopyLink => "Copy link".into(),
@@ -193,13 +215,27 @@ impl OptionsScreen {
"Off"
}
),
Action::Forget if self.armed => "Forget \u{2014} press again".into(),
Action::Forget if self.armed == Some(Action::Forget) => {
"Forget \u{2014} press again".into()
}
Action::Forget => "Forget".into(),
Action::Unpin => "Unpin card".into(),
Action::Cancel => "Cancel".into(),
}
}
/// Whether a row reads as live. Only a host action can be dead: the host said it cannot
/// run that verb right now (no suspend support, a foreign inhibitor, a second local user).
/// The row stays — activating it explains why — because a row that quietly vanished would
/// leave the person wondering whether they had imagined it (the host's own honesty rule:
/// "unavailable, because X", never a dead switch and never a silence).
fn enabled(&self, a: Action) -> bool {
match a {
Action::Host(i) => self.host().actions.get(i).is_none_or(|act| act.available),
_ => true,
}
}
pub(crate) fn menu(
&mut self,
ev: MenuEvent,
@@ -236,10 +272,11 @@ impl OptionsScreen {
let Some(action) = actions.get(self.list.cursor).copied() else {
return pulse;
};
// Moving off the armed Forget row disarms it: an arming press is about THAT row,
// and leaving it must not leave a live trigger behind for the next visit.
if !matches!(msg, ListMsg::Activate) && action != Action::Forget {
self.armed = false;
// Moving off an armed row disarms it: an arming press is about THAT row, and leaving
// it must not leave a live trigger behind — neither for the next visit, nor for the
// destructive row the cursor happened to land on next.
if !matches!(msg, ListMsg::Activate) && self.armed != Some(action) {
self.armed = None;
}
match msg {
ListMsg::Adjust(_) => Some(MenuPulse::Boundary),
@@ -340,12 +377,52 @@ impl OptionsScreen {
fx.cmds.push(ConsoleCmd::SetClipboard { key, on });
fx.pop();
}
Action::Forget if !self.armed => self.armed = true,
Action::Forget if self.armed != Some(Action::Forget) => {
self.armed = Some(Action::Forget)
}
Action::Forget => {
fx.cmds.push(ConsoleCmd::ForgetHost { key });
fx.toast = Some(format!("Forgot {}", self.host().name));
fx.pop();
}
Action::Host(i) => {
let host = self.host();
let Some(act) = host.actions.get(i) else {
return; // the row list changed under the cursor — do nothing, silently
};
// A host the host itself says it cannot do right now: say why rather than
// send a request we know it will refuse.
if !act.available {
let why = act.unavailable_reason.clone();
fx.toast = Some(if why.is_empty() {
format!("{} isn't available right now", act.label)
} else {
why
});
fx.pop();
return;
}
// Restart and shut down lose whatever is running on that machine, so they take
// the Forget treatment: arm, then fire. Sleep is reversible from the same menu
// (Wake host), so it goes on one press.
if act.danger && self.armed != Some(action) {
self.armed = Some(action);
return;
}
fx.cmds.push(ConsoleCmd::HostAction {
addr: host.addr.clone(),
mgmt: host.mgmt_port,
fp_hex: host.fp_hex.clone(),
host_name: host.name.clone(),
action_id: act.id.clone(),
label: act.label.clone(),
});
fx.toast = Some(format!(
"{} \u{2014} asking {}\u{2026}",
act.label, host.name
));
fx.pop();
}
Action::Unpin => {
if let Some(p) = &self.host().pin {
fx.cmds.push(ConsoleCmd::SetPin {
@@ -412,7 +489,7 @@ impl OptionsScreen {
let rows: Vec<RowSpec> = self
.actions(ctx.platform)
.into_iter()
.map(|a| RowSpec::action(self.label(a), true))
.map(|a| RowSpec::action(self.label(a), self.enabled(a)))
.collect();
self.list
.render(canvas, list_rect, &rows, fonts, k, dt, true);
@@ -473,11 +550,35 @@ mod tests {
clipboard_sync: false,
last_used: None,
os: String::new(),
actions: Vec::new(),
pin: None,
bound_profile: None,
}
}
/// A host that reported the three power actions for this device, sleep available.
fn powered() -> HostRow {
let act = |id: &str, label: &str, danger: bool, available: bool| crate::model::HostAction {
id: id.into(),
label: label.into(),
danger,
available,
unavailable_reason: if available {
String::new()
} else {
"this machine does not support sleep".into()
},
};
HostRow {
actions: vec![
act("power.sleep", "Sleep host", false, true),
act("power.reboot", "Restart host", true, true),
act("power.shutdown", "Shut down host", true, true),
],
..host()
}
}
fn pinned() -> HostRow {
HostRow {
key: "aa\u{0}prof-1".into(),
@@ -550,6 +651,102 @@ mod tests {
.contains(&Action::SendLogs));
}
/// The host's own actions sit right under Wake — the two halves of one round trip — and
/// only exist because the HOST offered them: an empty list (older host, unreachable host,
/// or a device without the grant) leaves the menu exactly as it was.
#[test]
fn host_actions_appear_only_when_the_host_offered_them() {
let none = OptionsScreen::for_host(&host());
assert!(!none
.actions(crate::platform::Platform::Desktop)
.iter()
.any(|a| matches!(a, Action::Host(_))));
let s = OptionsScreen::for_host(&powered());
let rows = s.actions(crate::platform::Platform::Desktop);
assert_eq!(
rows.iter().filter(|a| matches!(a, Action::Host(_))).count(),
3
);
assert_eq!(s.label(Action::Host(0)), "Sleep host");
// An id this build has never heard of still renders — the host sent the label.
let future = OptionsScreen::for_host(&HostRow {
actions: vec![crate::model::HostAction {
id: "plugin:vpn:toggle".into(),
label: "Toggle the VPN".into(),
danger: false,
available: true,
unavailable_reason: String::new(),
}],
..host()
});
assert_eq!(future.label(Action::Host(0)), "Toggle the VPN");
}
/// Sleep is reversible from this very menu, so it goes on one press. Restart and shut down
/// are not, so they take Forget's arm-then-fire — and arming one must never leave the
/// OTHER one live, which is exactly the bug a bare `armed` flag would have shipped.
#[test]
fn destructive_host_actions_arm_before_they_fire() {
let mut s = OptionsScreen::for_host(&powered());
let mut fx = Outbox::default();
run_action(&mut s, Action::Host(0), &mut fx); // sleep — one press
assert!(matches!(
fx.cmds.first(),
Some(ConsoleCmd::HostAction { action_id, .. }) if action_id == "power.sleep"
));
let mut s = OptionsScreen::for_host(&powered());
let mut fx = Outbox::default();
run_action(&mut s, Action::Host(2), &mut fx); // shut down — arms
assert!(fx.cmds.is_empty(), "the first press only arms");
assert_eq!(
s.label(Action::Host(2)),
"Shut down host \u{2014} press again"
);
// The armed row is that one row: moving to Restart and pressing must not shut down.
assert_eq!(s.label(Action::Host(1)), "Restart host");
let mut fx = Outbox::default();
run_action(&mut s, Action::Host(1), &mut fx);
assert!(
fx.cmds.is_empty(),
"arming shut down must not leave restart armed"
);
// Pressing the armed row again fires it.
let mut s = OptionsScreen::for_host(&powered());
let mut fx = Outbox::default();
run_action(&mut s, Action::Host(2), &mut fx);
run_action(&mut s, Action::Host(2), &mut fx);
assert!(matches!(
fx.cmds.first(),
Some(ConsoleCmd::HostAction { action_id, .. }) if action_id == "power.shutdown"
));
}
/// An action the host says it cannot run right now stays on the menu, disabled, and
/// explains itself — never a silent row and never a request we know will be refused.
#[test]
fn an_unavailable_action_explains_itself_instead_of_firing() {
let mut s = OptionsScreen::for_host(&HostRow {
actions: vec![crate::model::HostAction {
id: "power.sleep".into(),
label: "Sleep host".into(),
danger: false,
available: false,
unavailable_reason: "this machine does not support sleep".into(),
}],
..host()
});
assert!(!s.enabled(Action::Host(0)));
let mut fx = Outbox::default();
run_action(&mut s, Action::Host(0), &mut fx);
assert!(fx.cmds.is_empty(), "no request the host would refuse");
assert_eq!(
fx.toast.as_deref(),
Some("this machine does not support sleep")
);
}
#[test]
fn a_pinned_card_cannot_forget_or_edit_the_host() {
let s = OptionsScreen::for_host(&pinned());
@@ -654,7 +851,7 @@ mod tests {
run_action(&mut s, Action::Forget, &mut fx);
assert!(fx.cmds.is_empty(), "the first press only arms");
assert!(s.armed);
assert_eq!(s.armed, Some(Action::Forget));
assert!(s.label(Action::Forget).contains("press again"));
run_action(&mut s, Action::Forget, &mut fx);
@@ -669,7 +866,7 @@ mod tests {
fn leaving_the_forget_row_disarms_it() {
let mut s = OptionsScreen::for_host(&host());
let actions = s.actions(crate::platform::Platform::Desktop);
s.armed = true;
s.armed = Some(Action::Forget);
s.list.cursor = actions.iter().position(|a| *a == Action::Cancel).unwrap();
let mut ctx_settings = pf_client_core::trust::Settings::default();
let mut ctx = Ctx {
@@ -686,7 +883,10 @@ mod tests {
};
let mut fx = Outbox::default();
s.dispatch(ListMsg::None, None, &actions, &mut ctx, &mut fx);
assert!(!s.armed, "a cursor move off the row cancels the arming");
assert_eq!(
s.armed, None,
"a cursor move off the row cancels the arming"
);
}
#[test]
+1
View File
@@ -479,6 +479,7 @@ mod tests {
clipboard_sync: false,
last_used: None,
os: String::new(),
actions: Vec::new(),
pin: None,
bound_profile: None,
}
@@ -210,6 +210,7 @@ mod tests {
clipboard_sync: false,
last_used: None,
os: String::new(),
actions: Vec::new(),
pin: pin.map(|id| ProfileChip {
id: id.into(),
name: "Work".into(),
@@ -2523,6 +2523,7 @@ pub(super) mod tests {
clipboard_sync: false,
last_used: None,
os: String::new(),
actions: Vec::new(),
pin: Some(crate::model::ProfileChip {
id: "p1".into(),
name: "Work".into(),
+1
View File
@@ -92,6 +92,7 @@ fn hosts() -> Vec<HostRow> {
clipboard_sync: false,
last_used: None,
os: String::new(),
actions: Vec::new(),
pin: None,
bound_profile: None,
};
+3
View File
@@ -168,12 +168,14 @@ include = ["PunktfunkEndReason"]
"FLAG_PROBE" = "PUNKTFUNK_FLAG_PROBE"
"FLAG_SOF" = "PUNKTFUNK_FLAG_SOF"
"GRANT_ALL" = "PUNKTFUNK_GRANT_ALL"
"GRANT_ALL_PRE_POWER" = "PUNKTFUNK_GRANT_ALL_PRE_POWER"
"GRANT_CLIPBOARD" = "PUNKTFUNK_GRANT_CLIPBOARD"
"GRANT_GAMEPAD" = "PUNKTFUNK_GRANT_GAMEPAD"
"GRANT_KEYBOARD" = "PUNKTFUNK_GRANT_KEYBOARD"
"GRANT_LAUNCH" = "PUNKTFUNK_GRANT_LAUNCH"
"GRANT_MIC" = "PUNKTFUNK_GRANT_MIC"
"GRANT_POINTER" = "PUNKTFUNK_GRANT_POINTER"
"GRANT_POWER" = "PUNKTFUNK_GRANT_POWER"
"GRANT_PRESET_CONTROLLER_ONLY" = "PUNKTFUNK_GRANT_PRESET_CONTROLLER_ONLY"
"GRANT_PRESET_FULL" = "PUNKTFUNK_GRANT_PRESET_FULL"
"GRANT_PRESET_VIEW_ONLY" = "PUNKTFUNK_GRANT_PRESET_VIEW_ONLY"
@@ -197,6 +199,7 @@ include = ["PunktfunkEndReason"]
"INBOUND_REQ_FLAG" = "PUNKTFUNK_INBOUND_REQ_FLAG"
"INPUT_MAGIC" = "PUNKTFUNK_INPUT_MAGIC"
"INPUT_WIRE_LEN" = "PUNKTFUNK_INPUT_WIRE_LEN"
"HOST_POWER_CLOSE_CODE" = "PUNKTFUNK_HOST_POWER_CLOSE_CODE"
"LAUNCH_NOT_PERMITTED_CLOSE_CODE" = "PUNKTFUNK_LAUNCH_NOT_PERMITTED_CLOSE_CODE"
"LEGACY_STALE_MS" = "PUNKTFUNK_LEGACY_STALE_MS"
"MAX_DATAGRAM_BYTES" = "PUNKTFUNK_MAX_DATAGRAM_BYTES"
+2
View File
@@ -64,6 +64,7 @@ pub enum PunktfunkStatus {
RejectedSetupFailed = -29,
RejectedAccessExpired = -30,
RejectedLaunchNotPermitted = -31,
RejectedHostPower = -32,
Panic = -99,
}
@@ -95,6 +96,7 @@ impl PunktfunkError {
R::SetupFailed => PunktfunkStatus::RejectedSetupFailed,
R::AccessExpired => PunktfunkStatus::RejectedAccessExpired,
R::LaunchNotPermitted => PunktfunkStatus::RejectedLaunchNotPermitted,
R::HostPower => PunktfunkStatus::RejectedHostPower,
}
}
}
+53 -2
View File
@@ -32,13 +32,43 @@ pub const GRANT_CLIPBOARD: u32 = 1 << 3;
pub const GRANT_MIC: u32 = 1 << 4;
/// Library launch: `Hello.launch` resolution (and any future in-session launch/end verbs).
pub const GRANT_LAUNCH: u32 = 1 << 5;
/// Host power: invoking the `power.*` host actions (sleep/reboot/shutdown) over the mgmt cert
/// lane (`design/host-actions.md` §4). Route-gated like `CLIPBOARD`/`MIC`/`LAUNCH` — no
/// datagram ever carries it, so [`classify`] is untouched. Machine power ONLY: future
/// plugin/custom actions get their own class, never this bit.
pub const GRANT_POWER: u32 = 1 << 6;
/// Every defined grant. Also the value an *absent* mask means — a record from before grants
/// existed (or an old host's Welcome that omits the field) is full control, so existing
/// pairings keep today's behavior.
pub const GRANT_ALL: u32 =
pub const GRANT_ALL: u32 = GRANT_GAMEPAD
| GRANT_POINTER
| GRANT_KEYBOARD
| GRANT_CLIPBOARD
| GRANT_MIC
| GRANT_LAUNCH
| GRANT_POWER;
/// [`GRANT_ALL`] as it was before [`GRANT_POWER`] existed (hosts ≤ 0.32.x) — the mask an
/// explicitly saved "Full control" wrote back then. See [`normalize_legacy_full`].
pub const GRANT_ALL_PRE_POWER: u32 =
GRANT_GAMEPAD | GRANT_POINTER | GRANT_KEYBOARD | GRANT_CLIPBOARD | GRANT_MIC | GRANT_LAUNCH;
/// The legacy-full read rule (`design/host-actions.md` §4.3): a mask that is EXACTLY the
/// pre-power [`GRANT_ALL_PRE_POWER`] was written by "Full control" before the Power bit existed
/// — read it as the current [`GRANT_ALL`], so an old explicit-Full record neither renders as
/// "Custom" nor silently lacks Power while looking Full. Not an escalation: that mask holds
/// `KEYBOARD`+`POINTER`, which already reach the streamed desktop's power menu (§4.2). The
/// deliberate consequence: "everything except Power" is not an expressible stored mask — by
/// design, because it would be a lock painted on an open door. Any other mask passes through.
pub fn normalize_legacy_full(mask: u32) -> u32 {
if mask == GRANT_ALL_PRE_POWER {
GRANT_ALL
} else {
mask
}
}
/// The reserved-must-be-zero region: a mask with any of these bits set is invalid today and is
/// rejected at the management API (never silently cleared — the caller meant *something* this
/// host doesn't understand, and clearing would grant less than they asked for without saying so).
@@ -64,6 +94,8 @@ pub enum GrantClass {
Clipboard,
Mic,
Launch,
/// The `power.*` host actions (route-gated on the mgmt cert lane; never an input event).
Power,
}
impl GrantClass {
@@ -77,6 +109,7 @@ impl GrantClass {
Self::Clipboard => GRANT_CLIPBOARD,
Self::Mic => GRANT_MIC,
Self::Launch => GRANT_LAUNCH,
Self::Power => GRANT_POWER,
}
}
}
@@ -124,6 +157,7 @@ mod tests {
GRANT_CLIPBOARD,
GRANT_MIC,
GRANT_LAUNCH,
GRANT_POWER,
];
let mut acc = 0u32;
for b in bits {
@@ -138,13 +172,29 @@ mod tests {
#[test]
fn presets_match_the_design() {
// Full = everything; Controller-only = pad bit ONLY (no LAUNCH — §11 D2); View = nothing.
// Full = everything (Power included — host-actions §4.2); Controller-only = pad bit
// ONLY (no LAUNCH — §11 D2, and certainly no POWER); View = nothing.
assert_eq!(GRANT_PRESET_FULL, GRANT_ALL);
assert_eq!(GRANT_PRESET_FULL & GRANT_POWER, GRANT_POWER);
assert_eq!(GRANT_PRESET_CONTROLLER_ONLY, GRANT_GAMEPAD);
assert_eq!(GRANT_PRESET_CONTROLLER_ONLY & GRANT_LAUNCH, 0);
assert_eq!(GRANT_PRESET_VIEW_ONLY, 0);
}
#[test]
fn legacy_full_reads_as_the_current_full() {
// Exactly the pre-power full mask (an explicitly saved "Full control" from ≤ 0.32.x)
// normalizes to today's GRANT_ALL — anything else, limited or already-current, passes
// through untouched (host-actions §4.3).
assert_eq!(GRANT_ALL_PRE_POWER, 0x3F);
assert_eq!(normalize_legacy_full(GRANT_ALL_PRE_POWER), GRANT_ALL);
assert_eq!(normalize_legacy_full(GRANT_ALL), GRANT_ALL);
assert_eq!(normalize_legacy_full(GRANT_GAMEPAD), GRANT_GAMEPAD);
assert_eq!(normalize_legacy_full(0), 0);
let limited = GRANT_ALL_PRE_POWER & !GRANT_KEYBOARD;
assert_eq!(normalize_legacy_full(limited), limited);
}
#[test]
fn every_input_kind_classifies_per_the_design_table() {
use GrantClass::*;
@@ -181,5 +231,6 @@ mod tests {
assert_eq!(GrantClass::Clipboard.bit(), GRANT_CLIPBOARD);
assert_eq!(GrantClass::Mic.bit(), GRANT_MIC);
assert_eq!(GrantClass::Launch.bit(), GRANT_LAUNCH);
assert_eq!(GrantClass::Power.bit(), GRANT_POWER);
}
}
+16 -4
View File
@@ -48,6 +48,10 @@ pub const ACCESS_EXPIRED_CLOSE_CODE: u32 = 0x69;
/// Refused AT the handshake — a crisp typed reason beats silently dropping the user onto a
/// bare desktop they didn't ask for. Connecting *without* a launch request still works.
pub const LAUNCH_NOT_PERMITTED_CLOSE_CODE: u32 = 0x6A;
/// A host power action (`power.sleep`/`reboot`/`shutdown`, `design/host-actions.md`) is ending
/// every session: the host is going to sleep or shutting down, deliberately — not a crash, not
/// the network. Old clients render the generic close; acceptable degrade.
pub const HOST_POWER_CLOSE_CODE: u32 = 0x6B;
/// Why a host turned a connection away, decoded from the QUIC application close code — the
/// client-side view of [`PAIR_NOT_ARMED_CLOSE_CODE`]..[`WIRE_VERSION_CLOSE_CODE`] plus
@@ -81,6 +85,8 @@ pub enum RejectReason {
AccessExpired,
/// This device's grants don't include launching games (the `LAUNCH` bit is clear).
LaunchNotPermitted,
/// The host is going to sleep or shutting down (a host power action ended the session).
HostPower,
}
impl RejectReason {
@@ -100,6 +106,7 @@ impl RejectReason {
SETUP_FAILED_CLOSE_CODE => Self::SetupFailed,
ACCESS_EXPIRED_CLOSE_CODE => Self::AccessExpired,
LAUNCH_NOT_PERMITTED_CLOSE_CODE => Self::LaunchNotPermitted,
HOST_POWER_CLOSE_CODE => Self::HostPower,
_ => return None,
})
}
@@ -119,6 +126,7 @@ impl RejectReason {
Self::SetupFailed => SETUP_FAILED_CLOSE_CODE,
Self::AccessExpired => ACCESS_EXPIRED_CLOSE_CODE,
Self::LaunchNotPermitted => LAUNCH_NOT_PERMITTED_CLOSE_CODE,
Self::HostPower => HOST_POWER_CLOSE_CODE,
}
}
@@ -138,6 +146,7 @@ impl RejectReason {
Self::SetupFailed => "setup-failed",
Self::AccessExpired => "access-expired",
Self::LaunchNotPermitted => "launch-not-permitted",
Self::HostPower => "host-power",
}
}
}
@@ -159,6 +168,7 @@ impl std::fmt::Display for RejectReason {
Self::SetupFailed => "the host could not start the stream session",
Self::AccessExpired => "your access to this host has expired",
Self::LaunchNotPermitted => "this device is not permitted to launch games on the host",
Self::HostPower => "the host is going to sleep or shutting down",
})
}
}
@@ -167,7 +177,7 @@ impl std::fmt::Display for RejectReason {
mod tests {
use super::*;
const ALL: [RejectReason; 12] = [
const ALL: [RejectReason; 13] = [
RejectReason::PairingNotArmed,
RejectReason::PairingBoundToOtherDevice,
RejectReason::PairingRateLimited,
@@ -180,6 +190,7 @@ mod tests {
RejectReason::SetupFailed,
RejectReason::AccessExpired,
RejectReason::LaunchNotPermitted,
RejectReason::HostPower,
];
#[test]
@@ -200,9 +211,10 @@ mod tests {
#[test]
fn foreign_codes_stay_untyped() {
// Bare closes, the client's own pair-done codes, and the deliberate-end codes must
// never read as a host rejection. (0x69/0x6A left this list when they became the
// access-expired / launch-not-permitted codes; 0x6B is the block's next free id.)
for code in [0u32, 1, 0x41, 0x51, 0x52, 0x5f, 0x6B, 0x70, u32::MAX] {
// never read as a host rejection. (0x69/0x6A/0x6B left this list when they became the
// access-expired / launch-not-permitted / host-power codes; 0x6C is the block's next
// free id.)
for code in [0u32, 1, 0x41, 0x51, 0x52, 0x5f, 0x6C, 0x70, u32::MAX] {
assert_eq!(RejectReason::from_close_code(code), None);
}
}
+5
View File
@@ -307,6 +307,11 @@ windows = { version = "0.62", features = [
# CreateToolhelp32Snapshot/Process32*W — the conflicting-streaming-host process scan
# (src/detect/windows.rs): is Sunshine/Apollo/... running alongside us?
"Win32_System_Diagnostics_ToolHelp",
# The host power actions (src/power.rs, design/host-actions.md): SetSuspendState /
# IsPwrSuspendAllowed (Power) and InitiateSystemShutdownExW + the SeShutdownPrivilege
# constants (Shutdown).
"Win32_System_Power",
"Win32_System_Shutdown",
] }
# The SCM plumbing for the `service` subcommand (define_windows_service! / dispatcher / control
# handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses
+72
View File
@@ -258,6 +258,21 @@ pub enum EventKind {
/// `GET /api/v1/store/catalog` / `…/installed`. Deliberately payload-free: the store's answer
/// is a join over several sources of truth, so "go look again" is the only honest signal.
StoreChanged,
/// A host action was invoked (`design/host-actions.md` §3.3) — v1: the `power.*` verbs.
/// Emitted on ACCEPT (`outcome: "accepted"`), and again if the executor later fails
/// (`outcome: "failed: …"`) — a succeeded power action ends this process, so "accepted with
/// no failure after it" is the success signal a hook can act on ("the host is going down").
#[serde(rename = "action.invoked")]
ActionInvoked {
/// The invoked action id (`power.sleep`, `power.reboot`, `power.shutdown`).
id: String,
/// The invoking paired device, when the cert lane invoked it; absent for the
/// operator's console (admin lane).
#[serde(skip_serializing_if = "Option::is_none")]
device: Option<DeviceRef>,
/// `accepted`, or `failed: <the executor's error>`.
outcome: String,
},
#[serde(rename = "host.started")]
HostStarted {
version: String,
@@ -293,6 +308,7 @@ impl EventKind {
EventKind::UpdateApplied { .. } => "update.applied",
EventKind::PluginsChanged { .. } => "plugins.changed",
EventKind::StoreChanged => "store.changed",
EventKind::ActionInvoked { .. } => "action.invoked",
EventKind::HostStarted { .. } => "host.started",
EventKind::HostStopping => "host.stopping",
}
@@ -322,6 +338,7 @@ impl EventKind {
| EventKind::AccessGranted { device, .. }
| EventKind::AccessChanged { device, .. }
| EventKind::AccessExpired { device } => Some(&device.name),
EventKind::ActionInvoked { device, .. } => device.as_ref().map(|d| d.name.as_str()),
_ => None,
}
}
@@ -337,6 +354,9 @@ impl EventKind {
| EventKind::AccessGranted { device, .. }
| EventKind::AccessChanged { device, .. }
| EventKind::AccessExpired { device } => Some(&device.fingerprint),
EventKind::ActionInvoked { device, .. } => {
device.as_ref().map(|d| d.fingerprint.as_str())
}
_ => None,
}
}
@@ -723,6 +743,58 @@ mod tests {
assert_eq!(expired.plane(), Some(Plane::Native));
}
/// The `action.invoked` wire shape (host actions design §3.3): a cert-lane invoke carries the
/// device; the console's (admin) invoke omits the field entirely — the optional convention.
#[test]
fn action_invoked_wire_shapes_and_filters() {
let ev = HostEvent {
seq: 10,
ts_ms: 1_700_000_000_000,
schema: 1,
kind: EventKind::ActionInvoked {
id: "power.sleep".into(),
device: Some(DeviceRef {
name: "Living Room TV".into(),
fingerprint: "ab12".into(),
plane: Plane::Native,
}),
outcome: "accepted".into(),
},
};
assert_eq!(
serde_json::to_string(&ev).unwrap(),
r#"{"seq":10,"ts_ms":1700000000000,"schema":1,"kind":"action.invoked","id":"power.sleep","device":{"name":"Living Room TV","fingerprint":"ab12","plane":"native"},"outcome":"accepted"}"#
);
let admin = EventKind::ActionInvoked {
id: "power.shutdown".into(),
device: None,
outcome: "accepted".into(),
};
assert_eq!(
serde_json::to_string(&admin).unwrap(),
r#"{"kind":"action.invoked","id":"power.shutdown","outcome":"accepted"}"#
);
assert_eq!(admin.name(), "action.invoked");
assert!(kind_matches("action.*", admin.name()));
assert_eq!(admin.client_name(), None);
let cert = HostEvent {
seq: 11,
ts_ms: 0,
schema: 1,
kind: EventKind::ActionInvoked {
id: "power.sleep".into(),
device: Some(DeviceRef {
name: "Guest Deck".into(),
fingerprint: "ab12".into(),
plane: Plane::Native,
}),
outcome: "accepted".into(),
},
};
assert_eq!(cert.kind.client_name(), Some("Guest Deck"));
assert_eq!(cert.kind.fingerprint(), Some("ab12"));
}
/// The `game.*` events must be reachable by the same hook/SSE filters as every other kind — a
/// filterable event nobody can select is not a feature.
#[test]
@@ -158,15 +158,17 @@ impl SessionAccess {
/// whole session (per-event logging is the DoS), totals surfaced once at session end. Plain
/// integers, not atomics: the control thread is the only writer and reader.
struct GrantDrops {
counts: [u64; 6],
warned: [bool; 6],
// One slot per grant BIT (7 with `Power`), indexed by bit position — Power never produces
// input drops, but `idx` must stay in bounds for every `GrantClass`.
counts: [u64; 7],
warned: [bool; 7],
}
impl GrantDrops {
fn new() -> GrantDrops {
GrantDrops {
counts: [0; 6],
warned: [false; 6],
counts: [0; 7],
warned: [false; 7],
}
}
@@ -1424,7 +1426,7 @@ mod tests {
assert_eq!(drops.counts[super::GrantDrops::idx(GrantClass::Gamepad)], 0);
// Session end logs totals once and resets for the next session.
drops.end_of_session();
assert_eq!(drops.counts, [0u64; 6]);
assert_eq!(drops.counts, [0u64; 7]);
}
/// The session's live access state (WP13): a fingerprint with NO grants record is
+1
View File
@@ -101,6 +101,7 @@ mod native_pairing;
mod osinfo;
mod pipeline;
mod plugins;
mod power;
// Finding a launched game's processes from its store's detect signals — the read side of the
// session⇄game lifetime binding (design/session-game-lifetime.md §4). Per-OS matchers inside; on a
// platform with neither (macOS, which has no launch path either) the module is an empty shell.
+5 -1
View File
@@ -29,6 +29,7 @@ use utoipa::{Modify, OpenApi};
use utoipa_axum::{router::OpenApiRouter, routes};
use utoipa_scalar::{Scalar, Servable};
mod actions;
mod auth;
mod client_logs;
mod clients;
@@ -411,7 +412,9 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(store::get_runtime, store::set_runtime))
.routes(routes!(update::get_update_status))
.routes(routes!(update::force_update_check))
.routes(routes!(update::apply_update));
.routes(routes!(update::apply_update))
.routes(routes!(actions::list_actions))
.routes(routes!(actions::invoke_action));
OpenApiRouter::with_openapi(ApiDoc::openapi())
.nest("/api/v1", api_v1)
.split_for_parts()
@@ -454,6 +457,7 @@ pub fn openapi_json() -> String {
(name = "plugins", description = "Plugin directory: running `punktfunk-plugin-*` processes register a lease and, optionally, a loopback UI the web console proxies and adds to its nav"),
(name = "store", description = "Plugin store: browse signed catalogs (verified first-party entries, attributed third-party sources), install/uninstall as tracked jobs, and switch the plugin runner on"),
(name = "update", description = "Host update check: install kind + channel, the last verified release manifest, and whether a newer host exists (admin lane only)"),
(name = "actions", description = "Host actions: discover what this host offers (per-caller availability + permission) and invoke one by id — v1: sleep, restart, shut down the machine, gated per device by the Host power grant"),
)
)]
struct ApiDoc;
+328
View File
@@ -0,0 +1,328 @@
//! `/api/v1/actions` — the host-action registry (`design/host-actions.md`): discovery of the
//! actions this host offers *as seen by the caller*, and the id-only invoke. v1 ships the three
//! `power.*` built-ins; future host- or plugin-provided actions reuse these two routes, so
//! clients that render the discovery generically need no release to pick them up.
//!
//! Lane split (see `auth`): the **admin bearer** reaches both routes with everything permitted
//! (the console is the owner surface); a **paired streaming cert** reaches both too — the
//! lane's third write route after `POST /client-logs` and the whole point of the design
//! ("Sleep host" from the couch, out of session) — but invoke demands the `GRANT_POWER` bit,
//! re-read via `effective(fp, now)` PER REQUEST so console edits, expiry and unpair apply to
//! the very next call. The **plugin token** gets neither route: a plugin that wants to
//! power-manage the host is an operator-hook story, not a shared-token capability.
//!
//! The invoke invariant (the `host-update` recipe): the request is a trigger — the id selects a
//! fixed host-side behavior, the body is empty, and **no request field ever reaches the
//! privileged path**. Ordering on accept: reply `202` → end every session (typed
//! `RejectReason::HostPower` close, which also drops our own sleep-inhibit hold) → ~1 s grace
//! so the reply flushes before the NIC goes away → act.
use super::auth::AuthLane;
use super::shared::*;
use crate::gamestream::tls::PeerCertFingerprint;
use crate::power::PowerVerb;
use axum::Extension;
use std::sync::atomic::{AtomicBool, Ordering};
/// One built-in action: a stable id (`<group>.<verb>` — `plugin:<id>:<verb>` is reserved for
/// plugin-provided actions later) bound to a fixed executor. The registry is code on purpose:
/// no persistence, nothing a request can add to.
struct Builtin {
id: &'static str,
/// English display title — clients map KNOWN ids to their own localized strings and fall
/// back to this for ids they don't know yet.
title: &'static str,
/// Two-press confirm hint for client UIs (reboot/shutdown lose state; sleep is reversible).
danger: bool,
verb: PowerVerb,
}
/// v1: the three machine-power verbs, all under the `power` group / `GRANT_POWER` bit.
const BUILTINS: [Builtin; 3] = [
Builtin {
id: "power.sleep",
title: "Sleep host",
danger: false,
verb: PowerVerb::Sleep,
},
Builtin {
id: "power.reboot",
title: "Restart host",
danger: true,
verb: PowerVerb::Reboot,
},
Builtin {
id: "power.shutdown",
title: "Shut down host",
danger: true,
verb: PowerVerb::Shutdown,
},
];
/// One action as the caller sees it (`GET /actions`).
#[derive(Serialize, Deserialize, ToSchema)]
pub(crate) struct ActionInfo {
/// Stable action id (`power.sleep`, …) — the invoke path parameter.
#[schema(example = "power.sleep")]
pub id: String,
/// Display title. Clients localize known ids and fall back to this for unknown ones.
pub title: String,
/// Action group (`power` for the built-ins).
pub group: String,
/// Whether a client UI should double-confirm (the action loses state — reboot/shutdown).
pub danger: bool,
/// Whether this host can run it right now (platform probe — a VM that can't S3 lists
/// sleep as unavailable rather than offering a dead switch).
pub available: bool,
/// Why it is unavailable, when it is.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unavailable_reason: Option<String>,
/// Whether THIS caller may invoke it (admin lane: always; cert lane: the `GRANT_POWER`
/// bit of the device's live access mask).
pub permitted: bool,
}
/// `GET /actions` response.
#[derive(Serialize, Deserialize, ToSchema)]
pub(crate) struct ActionList {
pub actions: Vec<ActionInfo>,
}
/// Whether this caller may invoke the `power.*` actions: the admin bearer always; a paired
/// cert exactly when its live mask (re-read NOW — expiry- and edit-aware) carries
/// [`punktfunk_core::quic::GRANT_POWER`]. Any other lane: no.
fn power_permitted(st: &MgmtState, lane: AuthLane, fp: Option<&str>) -> bool {
match lane {
AuthLane::Admin => true,
AuthLane::Cert => fp.is_some_and(|fp| {
st.native
.as_ref()
.and_then(|n| n.effective(fp, unix_now()))
.is_some_and(|mask| mask & punktfunk_core::quic::GRANT_POWER != 0)
}),
AuthLane::Plugin | AuthLane::Public => false,
}
}
/// Host wall clock, unix seconds (the clock the stored access deadlines are expressed in).
fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// List host actions
///
/// The actions this host offers, as seen by the caller: platform availability (with the honest
/// reason when something can't run) and whether THIS caller is permitted to invoke each one.
/// Admin lane: everything permitted. Paired-cert lane: permission follows the device's live
/// access mask (the Host power grant). Clients render rows generically — unknown ids still
/// work with the server-supplied title.
#[utoipa::path(
get,
path = "/actions",
tag = "actions",
operation_id = "listActions",
responses(
(status = OK, description = "The actions, per-caller", body = ActionList),
(status = UNAUTHORIZED, description = "Missing or invalid credentials", body = ApiError),
)
)]
pub(crate) async fn list_actions(
State(st): State<Arc<MgmtState>>,
Extension(lane): Extension<AuthLane>,
fp: Option<Extension<PeerCertFingerprint>>,
) -> Json<ActionList> {
let fp = fp.as_ref().and_then(|e| e.0 .0.as_deref());
let permitted = power_permitted(&st, lane, fp);
// The probes are D-Bus round trips on Linux — off the async worker, all three in one hop.
let probed = tokio::task::spawn_blocking(|| BUILTINS.map(|b| crate::power::probe(b.verb)))
.await
.expect("power probe task panicked");
let actions = BUILTINS
.iter()
.zip(probed)
.map(|(b, avail)| ActionInfo {
id: b.id.into(),
title: b.title.into(),
group: "power".into(),
danger: b.danger,
available: crate::power::supported() && avail.available,
unavailable_reason: if crate::power::supported() {
avail.reason
} else {
Some("not supported on this host platform".into())
},
permitted,
})
.collect();
Json(ActionList { actions })
}
/// One action is in flight host-wide (`409 busy` otherwise) — the actions themselves end the
/// conversation, so this is all the rate limiting v1 needs.
static IN_FLIGHT: AtomicBool = AtomicBool::new(false);
/// Denials are logged once per (fingerprint, action) per boot — a retrying client must not turn
/// the host log into the DoS (the `GrantDrops` discipline).
fn log_denial_once(fp: &str, action: &str, device: &str) {
use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
static LOGGED: OnceLock<Mutex<HashSet<(String, String)>>> = OnceLock::new();
let mut set = LOGGED
.get_or_init(|| Mutex::new(HashSet::new()))
.lock()
.unwrap_or_else(|e| e.into_inner());
if set.insert((fp.to_string(), action.to_string())) {
tracing::info!(
device,
fingerprint = fp,
action,
"denied a host action — this device's access lacks the Host power grant \
(further denials of this pair are silent this boot)"
);
}
}
/// Invoke a host action
///
/// Runs one action by id — empty body, no parameters: the id selects a fixed host-side
/// behavior, and nothing in the request reaches the privileged path. On `202` the host first
/// ends every streaming session cleanly (clients see a typed "the host is going to sleep /
/// shutting down" close), waits ~1 s so this response flushes, then acts.
///
/// Paired-cert callers need the **Host power** grant, and are refused (`409`) while another
/// device's session is live — a granted guest cannot yank the host out from under the owner
/// mid-stream. The admin console is never blocked (it warns instead). One action runs at a
/// time host-wide.
#[utoipa::path(
post,
path = "/actions/{id}",
tag = "actions",
operation_id = "invokeAction",
params(("id" = String, Path, description = "Action id (`power.sleep`, `power.reboot`, `power.shutdown`)")),
responses(
(status = ACCEPTED, description = "Accepted — sessions are being ended and the action follows in about a second"),
(status = FORBIDDEN, description = "This caller's access does not include this action (no Host power grant)", body = ApiError),
(status = NOT_FOUND, description = "Unknown action id", body = ApiError),
(status = CONFLICT, description = "Refused: an action is already in flight, another device's session is live (cert lane), or the platform said no (a foreign sleep inhibitor, a second local user, …)", body = ApiError),
(status = NOT_IMPLEMENTED, description = "This host platform has no executor for it (macOS host)", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid credentials", body = ApiError),
)
)]
pub(crate) async fn invoke_action(
State(st): State<Arc<MgmtState>>,
Extension(lane): Extension<AuthLane>,
fp: Option<Extension<PeerCertFingerprint>>,
Path(id): Path<String>,
) -> Response {
let Some(builtin) = BUILTINS.iter().find(|b| b.id == id) else {
return api_error(StatusCode::NOT_FOUND, "unknown action id");
};
let fp = fp.as_ref().and_then(|e| e.0 .0.as_deref());
// The invoking device's roster identity (cert lane) — for the audit line and the event.
let device = fp.and_then(|fp| {
st.native.as_ref().and_then(|n| {
n.list()
.into_iter()
.find(|c| c.fingerprint.eq_ignore_ascii_case(fp))
.map(|c| crate::events::DeviceRef {
name: c.name,
fingerprint: c.fingerprint,
plane: crate::events::Plane::Native,
})
})
});
if !power_permitted(&st, lane, fp) {
let device_name = device.as_ref().map(|d| d.name.as_str()).unwrap_or("");
log_denial_once(fp.unwrap_or(""), builtin.id, device_name);
return api_error(
StatusCode::FORBIDDEN,
"this device's access does not include host power — ask the host's operator to \
enable the Host power grant",
);
}
if !crate::power::supported() {
return api_error(
StatusCode::NOT_IMPLEMENTED,
"host power actions are not supported on this host platform",
);
}
// Busy policy (design §5.5): another device's LIVE session blocks a cert-lane invoke —
// your own session doesn't (you know what you asked for), and the admin console is never
// blocked (it is the owner surface; the console warns before sending). A GameStream stream
// is always another device on this policy: its cert identity is never the native one.
if lane == AuthLane::Cert {
let others_native = crate::session_status::other_client_live(fp.unwrap_or(""));
let gamestream = st.app.streaming.load(Ordering::SeqCst);
if others_native || gamestream {
return api_error(
StatusCode::CONFLICT,
"blocked: another device is streaming from this host right now",
);
}
}
let verb = builtin.verb;
let avail = tokio::task::spawn_blocking(move || crate::power::probe(verb))
.await
.expect("power probe task panicked");
if !avail.available {
return api_error(
StatusCode::CONFLICT,
&format!(
"blocked: {}",
avail.reason.as_deref().unwrap_or("the platform said no")
),
);
}
if IN_FLIGHT.swap(true, Ordering::SeqCst) {
return api_error(
StatusCode::CONFLICT,
"a host action is already in flight — the host is on its way down",
);
}
let invoker = device
.as_ref()
.map(|d| d.name.clone())
.unwrap_or_else(|| "the host console".into());
tracing::info!(action = builtin.id, invoked_by = %invoker, "host action accepted");
crate::events::emit(crate::events::EventKind::ActionInvoked {
id: builtin.id.into(),
device: device.clone(),
outcome: "accepted".into(),
});
// Reply first, act after: the 202 must flush before the NIC goes away. The typed close
// fires now so every paired session ends as "the host is going to sleep", the quit-flavored
// stop catches the rest (anonymous sessions, belt for the rest), and the compat plane's
// teardown runs its own path.
let id_owned: String = builtin.id.into();
let app = st.app.clone();
tokio::spawn(async move {
crate::power::set_closing(true);
crate::session_status::stop_all_quit();
let _ = app.quit_session("host power action");
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
// Our own suspend veto must be gone before logind is asked (we never hold
// -ignore-inhibit rights): synchronous belt over the session-teardown braces.
crate::sleep_inhibit::release_now();
let outcome = tokio::task::spawn_blocking(move || crate::power::act(verb))
.await
.unwrap_or_else(|e| Err(format!("executor task panicked: {e}")));
match outcome {
// A reboot/shutdown ends this process shortly; a sleep resumes here on wake.
Ok(()) => tracing::info!(action = %id_owned, "host power action handed to the OS"),
Err(e) => {
tracing::warn!(action = %id_owned, error = %e, "host power action FAILED");
crate::events::emit(crate::events::EventKind::ActionInvoked {
id: id_owned,
device,
outcome: format!("failed: {e}"),
});
}
}
crate::power::set_closing(false);
IN_FLIGHT.store(false, Ordering::SeqCst);
});
StatusCode::ACCEPTED.into_response()
}
+10 -1
View File
@@ -313,19 +313,28 @@ fn path_matches(pattern: &str, path: &str) -> bool {
/// a streaming client can't administer the host (unpair others, arm/read the PIN, stop sessions,
/// edit the library). `/health` is handled separately (always open).
pub(crate) fn cert_may_access(method: &Method, path: &str) -> bool {
// The ONE write on this lane: a paired device uploading its own log bundle for the operator
// The FIRST write on this lane: a paired device uploading its own log bundle for the operator
// ("send logs to host" — the only way logs escape a Deck in Gaming Mode or a tvOS box).
// Deliberately write-only: the device gets an id back and can read NOTHING — not the bundle
// list, not even its own upload. Size- and quota-capped in the handler/store.
if method == Method::POST && path == "/api/v1/client-logs" {
return true;
}
// The lane's SECOND write: invoking a host action (`design/host-actions.md` §5.2) — power
// is most useful OUT of session ("sleep the host" from the host tile), which is exactly
// this lane. Id-only, empty body, and the handler re-reads `effective(fp, now)` and demands
// the `GRANT_POWER` bit per request; the route being reachable grants nothing by itself.
// Discovery (`GET /actions`) rides the read list below, per-caller-filtered in the handler.
if method == Method::POST && path_matches("/api/v1/actions/{}", path) {
return true;
}
method == Method::GET
&& (matches!(
path,
"/api/v1/host"
| "/api/v1/compositors"
| "/api/v1/status"
| "/api/v1/actions"
// The paired-client ROSTERS (`/clients`, `/native/clients`) are deliberately NOT on
// this lane — they expose every OTHER paired device's name + fingerprint, which one
// paired streaming client must not be able to enumerate. Only the bearer/loopback
+91
View File
@@ -159,6 +159,91 @@ async fn send_cert(app: &Router, mut req: axum::http::Request<Body>, fp: &str) -
app.clone().oneshot(req).await.expect("infallible").status()
}
/// The host-actions surface (design/host-actions.md): discovery reports `permitted` per caller
/// from the device's LIVE mask; invoke 403s without the Power grant and 404s an unknown id.
/// An explicitly stored pre-power "Full control" (`0x3F`) carries Power via the legacy-full
/// read rule (§4.3). Deliberately NO test ever reaches a 202 accept — on a real box that
/// would genuinely suspend it.
#[tokio::test]
async fn host_actions_follow_the_power_grant() {
use punktfunk_core::quic::{GRANT_ALL_PRE_POWER, GRANT_GAMEPAD};
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(
Some(std::env::temp_dir().join(format!("pf-mgmt-actions-{}.json", std::process::id()))),
None,
false,
)
.unwrap(),
);
let guest_fp = "aaaa00000001";
let owner_fp = "bbbb00000002";
let legacy_fp = "cccc00000003";
np.add_with_access(
"guest",
guest_fp,
Some(crate::native_pairing::Access {
grants: GRANT_GAMEPAD,
expires_unix: None,
}),
)
.unwrap();
np.add("owner", owner_fp).unwrap(); // absent grants = full control, Power included
np.add_with_access(
"legacy",
legacy_fp,
Some(crate::native_pairing::Access {
grants: GRANT_ALL_PRE_POWER, // an explicit pre-power "Full control"
expires_unix: None,
}),
)
.unwrap();
let app = test_app_native(test_state(), np);
let discover = |fp: &str| {
let mut req = get_req("/api/v1/actions");
req.extensions_mut()
.insert(PeerCertFingerprint(Some(fp.to_string())));
req
};
let (status, body) = send(&app, discover(guest_fp)).await;
assert_eq!(status, StatusCode::OK);
let rows = body["actions"].as_array().unwrap();
assert_eq!(rows.len(), 3, "{body}");
assert!(
rows.iter().all(|a| a["permitted"] == false),
"a controller-only guest must not be offered power: {body}"
);
for fp in [owner_fp, legacy_fp] {
let (_, body) = send(&app, discover(fp)).await;
assert!(
body["actions"]
.as_array()
.unwrap()
.iter()
.all(|a| a["permitted"] == true),
"full control (current or legacy-stored) carries Power: {body}"
);
}
// The admin bearer (no cert) sees everything permitted — the console is the owner surface.
let (_, body) = send(&app, get_req("/api/v1/actions")).await;
assert!(body["actions"]
.as_array()
.unwrap()
.iter()
.all(|a| a["permitted"] == true));
let post = |path: &str| axum::http::Request::post(path).body(Body::empty()).unwrap();
// Invoke without the grant: the typed 403, distinct from unpaired (which never gets here).
assert_eq!(
send_cert(&app, post("/api/v1/actions/power.sleep"), guest_fp).await,
StatusCode::FORBIDDEN,
"no Power bit ⇒ 403"
);
// Unknown id: 404, before any permission or platform question.
let (status, _) = send(&app, post("/api/v1/actions/no.such")).await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
/// A paired *streaming* cert (mTLS, no bearer) authorizes only the read-only allowlist; every
/// state-changing or PIN-exposing route still requires the operator's bearer token (audit #4).
#[tokio::test]
@@ -1650,6 +1735,12 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
("GET", "/api/v1/update/status", false, false),
("POST", "/api/v1/update/check", false, false),
("POST", "/api/v1/update/apply", false, false),
// ---- host actions (design/host-actions.md): the cert lane's surface — discovery is
// per-caller-filtered, invoke demands the GRANT_POWER bit in the handler. The plugin
// token gets NEITHER route: a plugin that wants to power-manage the host is an
// operator-hook/automation story, not a shared-token capability (§3.4).
("GET", "/api/v1/actions", false, true),
("POST", "/api/v1/actions/{id}", false, true),
];
/// A path template's concrete form: every `{param}` segment becomes a literal, so the gates
+14 -2
View File
@@ -706,8 +706,10 @@ fn close_rejected(conn: &quinn::Connection, reason: punktfunk_core::reject::Reje
/// malicious client must not turn the log into the DoS — with the totals surfaced once in the
/// datagram loop's end-of-stream line.
struct GrantDrops {
counts: [AtomicU64; 6],
warned: [AtomicBool; 6],
// One slot per grant BIT (7 with `Power`), indexed by bit position — Power never produces
// input drops, but `idx` must stay in bounds for every `GrantClass`.
counts: [AtomicU64; 7],
warned: [AtomicBool; 7],
}
impl GrantDrops {
@@ -817,6 +819,10 @@ async fn access_lifecycle(
device: crate::events::DeviceRef,
) {
let mut warned = spent_warnings(deadline, wall_unix_now());
// The host-power signal (`design/host-actions.md` §5.8): a `power.*` action ending every
// session closes THIS connection with the typed code, so the client says "the host is
// going to sleep" instead of a bare transport error.
let mut power_rx = crate::power::closing_rx();
loop {
let now = wall_unix_now();
if let Some(d) = deadline {
@@ -885,6 +891,12 @@ async fn access_lifecycle(
});
}
}
changed = power_rx.changed() => {
if changed.is_ok() && *power_rx.borrow_and_update() {
close_rejected(&conn, punktfunk_core::reject::RejectReason::HostPower);
return;
}
}
_ = conn.closed() => return, // session over — nothing left to guard
}
}
+4 -1
View File
@@ -223,7 +223,10 @@ impl NativePairing {
if c.expires_unix.is_some_and(|t| now_unix >= t) {
None
} else {
Some(c.grants.unwrap_or(GRANT_ALL) & GRANT_ALL)
Some(
punktfunk_core::quic::normalize_legacy_full(c.grants.unwrap_or(GRANT_ALL))
& GRANT_ALL,
)
}
}
}
@@ -126,6 +126,8 @@ impl TrustStore {
/// expired, `Some(mask)` otherwise (absent grants = [`GRANT_ALL`], the pre-grants record).
/// The mask is ANDed with [`GRANT_ALL`] on the way out: a store written by a future host
/// version (or hand-edited) can't smuggle reserved bits into this version's enforcement.
/// An explicitly stored pre-power "Full control" (exactly the old `GRANT_ALL`) reads as the
/// current one — the legacy-full rule, `normalize_legacy_full` (host-actions §4.3).
/// `now_unix` is the caller's wall clock — passed in, not sampled here, so the expiry
/// evaluation and whatever decision it feeds share one instant.
pub(super) fn effective(&self, fp_hex: &str, now_unix: i64) -> Option<u32> {
@@ -138,7 +140,7 @@ impl TrustStore {
if c.expires_unix.is_some_and(|t| now_unix >= t) {
return None;
}
Some(c.grants.unwrap_or(GRANT_ALL) & GRANT_ALL)
Some(punktfunk_core::quic::normalize_legacy_full(c.grants.unwrap_or(GRANT_ALL)) & GRANT_ALL)
}
/// The stored record for a fingerprint (for the facade's watch-state snapshot and the
+259
View File
@@ -0,0 +1,259 @@
//! Machine power executors for the `power.*` host actions (`design/host-actions.md` §6):
//! sleep / reboot / shutdown, plus the per-verb availability probe the discovery route reports.
//!
//! Linux drives logind over zbus — the SAME privileged path the already-shipped polkit rule
//! (`packaging/linux/49-punktfunk-power.rules`) authorizes for members of group `punktfunk`,
//! and deliberately WITHOUT `-ignore-inhibit`/`-multiple-sessions`: a foreign block inhibitor
//! or a second local user makes logind refuse, and that refusal is surfaced honestly as a
//! `409 blocked` instead of being steamrolled. Windows uses the interactive user token's own
//! `SeShutdownPrivilege` (`InitiateSystemShutdownExW` / `SetSuspendState`). macOS has no
//! executor yet (the mgmt route answers `501`).
//!
//! Both [`probe`] and [`act`] BLOCK (a D-Bus round trip / a Win32 call) — call them via
//! `spawn_blocking` from async contexts. The zbus threading dance mirrors
//! [`crate::sleep_inhibit::acquire`]: zbus's blocking API cannot run on a tokio worker.
/// The three built-in machine-power verbs.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PowerVerb {
Sleep,
Reboot,
Shutdown,
}
/// One verb's platform answer: can this host do it right now, and if not, why (the honest
/// `unavailable_reason` the discovery route reports — the `SessionSettingsState::enforced`
/// pattern: say "unavailable, because X" instead of offering a dead switch).
pub struct Availability {
pub available: bool,
pub reason: Option<String>,
}
impl Availability {
fn yes() -> Availability {
Availability {
available: true,
reason: None,
}
}
fn no(reason: impl Into<String>) -> Availability {
Availability {
available: false,
reason: Some(reason.into()),
}
}
}
/// Whether this platform has power executors at all — `false` answers the invoke route with
/// `501 unsupported` (macOS host, until that leg exists).
pub fn supported() -> bool {
cfg!(any(target_os = "linux", target_os = "windows"))
}
// ---------------------------------------------------------------------------- Linux (logind)
/// One logind `Manager` call on a dedicated plain thread (see the module header for why), the
/// reply deserialized as `T`. Errors come back as the D-Bus error text — which IS the honest
/// reason ("Interactive authentication required", "Operation inhibited by …").
#[cfg(target_os = "linux")]
fn logind_call<T, A>(method: &'static str, args: A) -> Result<T, String>
where
T: for<'de> serde::Deserialize<'de> + ashpd::zbus::zvariant::Type + Send + 'static,
A: serde::Serialize + ashpd::zbus::zvariant::DynamicType + Send + Sync + 'static,
{
std::thread::spawn(move || -> Result<T, String> {
use ashpd::zbus;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| e.to_string())?;
rt.block_on(async {
let conn = zbus::Connection::system()
.await
.map_err(|e| e.to_string())?;
let reply = conn
.call_method(
Some("org.freedesktop.login1"),
"/org/freedesktop/login1",
Some("org.freedesktop.login1.Manager"),
method,
&args,
)
.await
.map_err(|e| e.to_string())?;
reply.body().deserialize().map_err(|e| e.to_string())
})
})
.join()
.map_err(|_| "logind call thread panicked".to_string())?
}
/// Ask logind whether the verb can run: `CanSuspend`/`CanReboot`/`CanPowerOff` answer `"yes"`,
/// `"no"`, `"na"` (hardware can't) or `"challenge"` (polkit would need interactive auth —
/// typically the host user is not in group `punktfunk`, or a second local user is logged in).
/// Only `"yes"` is available; everything else carries its reason.
#[cfg(target_os = "linux")]
pub fn probe(verb: PowerVerb) -> Availability {
let method = match verb {
PowerVerb::Sleep => "CanSuspend",
PowerVerb::Reboot => "CanReboot",
PowerVerb::Shutdown => "CanPowerOff",
};
match logind_call::<String, ()>(method, ()) {
Ok(ans) if ans == "yes" => Availability::yes(),
Ok(ans) if ans == "challenge" => Availability::no(
"the host would need interactive authorization — is the host user in group \
'punktfunk' (and no second local user logged in)?",
),
Ok(ans) if ans == "na" => Availability::no("this machine does not support it"),
Ok(ans) => Availability::no(format!("logind answered {ans:?}")),
Err(e) => Availability::no(format!("no logind: {e}")),
}
}
/// Run the verb: `Suspend`/`Reboot`/`PowerOff` with `interactive = false` — a polkit challenge
/// fails instead of prompting (there is nobody at a dialog on a streaming host). The caller has
/// already ended every session and released our own sleep inhibitor
/// ([`crate::sleep_inhibit::release_now`]) — a still-standing foreign inhibitor makes logind
/// refuse, and the error text says whose it is.
#[cfg(target_os = "linux")]
pub fn act(verb: PowerVerb) -> Result<(), String> {
let method = match verb {
PowerVerb::Sleep => "Suspend",
PowerVerb::Reboot => "Reboot",
PowerVerb::Shutdown => "PowerOff",
};
logind_call::<(), (bool,)>(method, (false,))
}
// ------------------------------------------------------------------------------------ Windows
/// Windows: reboot/shutdown are available whenever the interactive user token holds
/// `SeShutdownPrivilege` (it does by default — [`act`] enables and uses it); sleep asks the
/// power manager whether suspend is supported at all.
#[cfg(target_os = "windows")]
pub fn probe(verb: PowerVerb) -> Availability {
match verb {
PowerVerb::Sleep => {
// SAFETY: no arguments, no aliasing — a pure capability query.
if unsafe { windows::Win32::System::Power::IsPwrSuspendAllowed() } {
Availability::yes()
} else {
Availability::no("this machine does not support sleep")
}
}
PowerVerb::Reboot | PowerVerb::Shutdown => Availability::yes(),
}
}
/// Enable this process's `SeShutdownPrivilege` (present-but-disabled by default on an
/// interactive user token), then run the verb. The reason string lands in the system event log.
#[cfg(target_os = "windows")]
pub fn act(verb: PowerVerb) -> Result<(), String> {
use windows::Win32::Foundation::{CloseHandle, HANDLE, LUID};
use windows::Win32::Security::{
AdjustTokenPrivileges, LookupPrivilegeValueW, LUID_AND_ATTRIBUTES, SE_PRIVILEGE_ENABLED,
SE_SHUTDOWN_NAME, TOKEN_ADJUST_PRIVILEGES, TOKEN_PRIVILEGES, TOKEN_QUERY,
};
use windows::Win32::System::Power::SetSuspendState;
use windows::Win32::System::Shutdown::{
InitiateSystemShutdownExW, SHTDN_REASON_FLAG_PLANNED, SHTDN_REASON_MAJOR_OTHER,
SHTDN_REASON_MINOR_OTHER,
};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
// SAFETY: standard privilege-enable sequence on our own process token; the token handle is
// closed on every path.
unsafe {
let mut token = HANDLE::default();
OpenProcessToken(
GetCurrentProcess(),
TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,
&mut token,
)
.map_err(|e| format!("OpenProcessToken: {e}"))?;
let mut luid = LUID::default();
let looked_up = LookupPrivilegeValueW(None, SE_SHUTDOWN_NAME, &mut luid);
let adjusted = looked_up.and_then(|()| {
let privs = TOKEN_PRIVILEGES {
PrivilegeCount: 1,
Privileges: [LUID_AND_ATTRIBUTES {
Luid: luid,
Attributes: SE_PRIVILEGE_ENABLED,
}],
};
AdjustTokenPrivileges(token, false, Some(&raw const privs), 0, None, None)
});
let _ = CloseHandle(token);
adjusted.map_err(|e| format!("enabling SeShutdownPrivilege: {e}"))?;
}
match verb {
PowerVerb::Sleep => {
// SAFETY: plain suspend request — no hibernate, honor other apps' wake locks.
if unsafe { SetSuspendState(false, false, false) } {
Ok(())
} else {
Err(format!(
"SetSuspendState failed: {}",
windows::core::Error::from_thread()
))
}
}
PowerVerb::Reboot | PowerVerb::Shutdown => {
let reason = windows::core::HSTRING::from(
"Requested from a Punktfunk client (host power action)",
);
// SAFETY: local machine (None), owned wide strings live across the call.
unsafe {
InitiateSystemShutdownExW(
None,
&reason,
0, // no countdown dialog — sessions were already ended cleanly
true, // force apps closed; nobody is at the console to answer prompts
verb == PowerVerb::Reboot,
SHTDN_REASON_MAJOR_OTHER | SHTDN_REASON_MINOR_OTHER | SHTDN_REASON_FLAG_PLANNED,
)
}
.map_err(|e| format!("InitiateSystemShutdownExW: {e}"))
}
}
}
// -------------------------------------------------------------------------- other platforms
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
pub fn probe(_verb: PowerVerb) -> Availability {
Availability::no("not supported on this host platform")
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
pub fn act(_verb: PowerVerb) -> Result<(), String> {
Err("not supported on this host platform".into())
}
// ------------------------------------------------------------------- the session close signal
/// The host-wide "a power action is ending every session" signal. Each paired session's
/// access-lifecycle task subscribes ([`closing_rx`]) and closes its connection with the typed
/// `RejectReason::HostPower` code when it fires — so the client renders "the host is going to
/// sleep" instead of a bare transport error. One-way: nothing un-fires it (sleep resumes with
/// no live sessions either way), but the flag resets after a completed sleep so the woken host
/// types future closes correctly.
static POWER_CLOSING: std::sync::OnceLock<tokio::sync::watch::Sender<bool>> =
std::sync::OnceLock::new();
fn power_closing() -> &'static tokio::sync::watch::Sender<bool> {
POWER_CLOSING.get_or_init(|| tokio::sync::watch::channel(false).0)
}
/// Subscribe to the power-close signal (one receiver per session lifecycle task).
pub fn closing_rx() -> tokio::sync::watch::Receiver<bool> {
power_closing().subscribe()
}
/// Fire (or reset) the power-close signal.
pub fn set_closing(closing: bool) {
let _ = power_closing().send(closing);
}
@@ -356,6 +356,19 @@ pub fn stop_by_fingerprint(fp_hex: &str) -> usize {
n
}
/// Whether any live native session belongs to a client OTHER than `fp_hex` — the host-power
/// busy policy (`design/host-actions.md` §5.5): a granted guest must not pull the host out from
/// under someone else's live stream. Label matching as in [`stop_by_fingerprint`] (the
/// 12-hex-char fingerprint prefix); an anonymous, IP-labelled session always counts as another
/// client — it is certainly not the invoking paired device.
pub fn other_client_live(fp_hex: &str) -> bool {
registry()
.lock()
.unwrap()
.iter()
.any(|s| !(s.client.len() == 12 && fp_hex.starts_with(s.client.as_str())))
}
pub fn stop_all_quit() {
for s in registry().lock().unwrap().iter() {
s.quit.store(true, Ordering::SeqCst);
@@ -147,6 +147,15 @@ fn release(why: &str) {
#[cfg(not(target_os = "linux"))]
fn release(_why: &str) {}
/// Drop any standing veto RIGHT NOW — the host-power path (`design/host-actions.md` §6): an
/// explicit `power.sleep` must not be refused by our own block inhibitor (we deliberately never
/// hold `-ignore-inhibit` rights). The session teardown that precedes it drops the holds too,
/// but via the video loops' next stop-flag check — this is the synchronous belt so the
/// `Suspend()` call can never race a veto that is already on its way out.
pub fn release_now() {
release("a host power action is suspending/stopping this machine");
}
#[cfg(target_os = "linux")]
fn release_locked(st: &mut State, why: &str) {
if st.fd.take().is_some() {
+4 -1
View File
@@ -155,7 +155,10 @@ impl ksni::Tray for HostTray {
}
.into(),
StandardItem {
label: "Restart host".into(),
// "Restart Punktfunk", not "Restart host": this restarts the SERVICE, and the
// clients' host-power menus use "Restart host" for the MACHINE
// (design/host-actions.md §7) — one phrase must not mean two verbs.
label: "Restart Punktfunk".into(),
visible: running || matches!(self.status, TrayStatus::Error(_)),
activate: Box::new(|t: &mut Self| t.systemctl("restart")),
..Default::default()
+5 -2
View File
@@ -477,16 +477,19 @@ fn show_menu(hwnd: HWND) {
}
if running {
add(IDM_STOP, "Stop host", false, Some(win_theme::GLYPH_SHIELD));
// "Restart Punktfunk", not "Restart host": this restarts the SERVICE, and the
// clients' host-power menus use "Restart host" for the MACHINE
// (design/host-actions.md §7) — one phrase must not mean two verbs.
add(
IDM_RESTART,
"Restart host",
"Restart Punktfunk",
false,
Some(win_theme::GLYPH_SHIELD),
);
} else if matches!(status, TrayStatus::Error(_)) {
add(
IDM_RESTART,
"Restart host",
"Restart Punktfunk",
false,
Some(win_theme::GLYPH_SHIELD),
);
+2 -1
View File
@@ -30,7 +30,7 @@ The preset label is derived from the underlying toggles, so a hand-tuned combina
## The advanced toggles
Each preset is a bundle of six independent grants, under **Advanced** in the edit sheet:
Each preset is a bundle of independent grants, under **Advanced** in the edit sheet:
| Toggle | Covers |
|---|---|
@@ -40,6 +40,7 @@ Each preset is a bundle of six independent grants, under **Advanced** in the edi
| **Clipboard** | The [shared clipboard](/docs/clipboard). Both switches still apply: the host operator's clipboard policy *and* this grant have to allow it — the grant can only narrow, never widen, what the operator permits. An ungranted device gets a clean "not permitted" instead of a toggle that silently does nothing. |
| **Microphone** | Sending the client's microphone to the host. Without it, the session never attaches to the host's mic service at all. |
| **Launch** | Starting a game from the host's [library](/docs/game-library) when connecting. Without it, a connect that asks to launch is refused with a clear error rather than dropped onto the bare desktop. The library stays *visible* — this governs launching, not browsing. |
| **Host power** | Sleeping, restarting or shutting down the host machine from the client (see [Host power](/docs/host-power)). Included in Full control on purpose: a device with Keyboard and Pointer can already reach the desktop's own power menu, so withholding only the polite path 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 and the view-only spectator cannot touch it. |
**Controller only deliberately does not include Launch**: in co-play the owner drives what runs.
Want a guest picking games? Turn on that one Advanced toggle.
+47
View File
@@ -0,0 +1,47 @@
---
title: Host power
description: Sleep, restart or shut down the host from the web console or a paired client — who may do it, what happens to running streams, and why an action can be refused.
---
[Wake-on-LAN](/docs/wake-on-lan) lets every client wake a sleeping host. Host power closes that
loop: **Sleep host**, **Restart host** and **Shut down host**, from the web console's Host page
(password-confirmed) or — with the right access — from a paired client. Finish playing on the TV,
sleep the host from the couch, wake it again tomorrow.
On a client the rows sit in the host's own menu, right where **Wake host** appears when it is
asleep: the gamepad console's host options, the Linux and Windows host card menus, and the Apple
and Android host cards. They appear only when the host offered them, so a device without the
grant simply has no power rows. Restart and shut down confirm before they run.
## Who may do it
- The **web console** always can — it is the operator's own surface, behind the console login
plus a per-action password confirmation.
- A **paired device** needs the **Host power** grant ([access levels](/docs/access-levels)).
Full-control devices have it — a device with keyboard access could already reach the desktop's
power menu, so Full control saying otherwise would be a fake distinction. Controller-only and
view-only devices do not, and cannot get it without the operator editing their access.
## What happens
On accept the host first ends every streaming session cleanly — clients show *"the host is going
to sleep or shutting down"* rather than a connection error — waits a moment so the reply reaches
the invoker, then asks the operating system to act. The host tile flips to asleep/offline, and for
sleep the **Wake host** action brings it back.
## Why an action can be refused
The host says no, with the reason, instead of pretending:
- **Another device is streaming** — a granted guest cannot pull the host out from under someone
else's live session. Your own session never blocks you. The console is never blocked; it warns
instead.
- **The platform said no** — a Linux host honors other programs' suspend inhibitors and refuses
while a second local user is logged in; a machine that cannot suspend lists Sleep as
unavailable with the reason. Punktfunk deliberately does not force past these.
- On Linux the host user must be in group `punktfunk` — the same polkit rule the packages
install for unattended power operations. The console lists the action as unavailable with a
hint when the group is missing.
Moonlight/GameStream clients have no vocabulary for this — host power is a native-protocol (and
console) feature.
+1
View File
@@ -31,6 +31,7 @@
"client-settings",
"profiles-and-links",
"wake-on-lan",
"host-power",
"access-levels",
"automation",
"running-as-a-service",
+4 -2
View File
@@ -248,7 +248,9 @@ to wake the machine — if the adapter is not in it, nothing on the network can
round that everywhere except the Linux app.
- **Magic packets are broadcasts.** They do not cross subnets, a VPN or a mesh network. Client and
host have to share a LAN segment.
- **Punktfunk never puts a host to sleep, and never wakes one on a schedule.** A packet goes out
because a connect needs it, or because you asked for one.
- **Punktfunk never sleeps or wakes a host on its own.** A wake packet goes out because a connect
needs it, or because you asked for one — and putting the host *back* to sleep is likewise an
explicit, permission-gated action: see [Host power](/docs/host-power) for the other half of the
round trip.
- **There is no host-side switch.** The host publishes its MAC address and warns when its card is
not armed. Whether to wake, when, and how long to wait is decided on the client.
+209
View File
@@ -13,6 +13,114 @@
"version": "0.32.0"
},
"paths": {
"/api/v1/actions": {
"get": {
"tags": [
"actions"
],
"summary": "List host actions",
"description": "The actions this host offers, as seen by the caller: platform availability (with the honest\nreason when something can't run) and whether THIS caller is permitted to invoke each one.\nAdmin lane: everything permitted. Paired-cert lane: permission follows the device's live\naccess mask (the Host power grant). Clients render rows generically — unknown ids still\nwork with the server-supplied title.",
"operationId": "listActions",
"responses": {
"200": {
"description": "The actions, per-caller",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ActionList"
}
}
}
},
"401": {
"description": "Missing or invalid credentials",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/actions/{id}": {
"post": {
"tags": [
"actions"
],
"summary": "Invoke a host action",
"description": "Runs one action by id — empty body, no parameters: the id selects a fixed host-side\nbehavior, and nothing in the request reaches the privileged path. On `202` the host first\nends every streaming session cleanly (clients see a typed \"the host is going to sleep /\nshutting down\" close), waits ~1 s so this response flushes, then acts.\n\nPaired-cert callers need the **Host power** grant, and are refused (`409`) while another\ndevice's session is live — a granted guest cannot yank the host out from under the owner\nmid-stream. The admin console is never blocked (it warns instead). One action runs at a\ntime host-wide.",
"operationId": "invokeAction",
"parameters": [
{
"name": "id",
"in": "path",
"description": "Action id (`power.sleep`, `power.reboot`, `power.shutdown`)",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"202": {
"description": "Accepted — sessions are being ended and the action follows in about a second"
},
"401": {
"description": "Missing or invalid credentials",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"403": {
"description": "This caller's access does not include this action (no Host power grant)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "Unknown action id",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"409": {
"description": "Refused: an action is already in flight, another device's session is live (cert lane), or the platform said no (a foreign sleep inhibitor, a second local user, …)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"501": {
"description": "This host platform has no executor for it (macOS host)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/client-logs": {
"get": {
"tags": [
@@ -4331,6 +4439,67 @@
},
"components": {
"schemas": {
"ActionInfo": {
"type": "object",
"description": "One action as the caller sees it (`GET /actions`).",
"required": [
"id",
"title",
"group",
"danger",
"available",
"permitted"
],
"properties": {
"available": {
"type": "boolean",
"description": "Whether this host can run it right now (platform probe — a VM that can't S3 lists\nsleep as unavailable rather than offering a dead switch)."
},
"danger": {
"type": "boolean",
"description": "Whether a client UI should double-confirm (the action loses state — reboot/shutdown)."
},
"group": {
"type": "string",
"description": "Action group (`power` for the built-ins)."
},
"id": {
"type": "string",
"description": "Stable action id (`power.sleep`, …) — the invoke path parameter.",
"example": "power.sleep"
},
"permitted": {
"type": "boolean",
"description": "Whether THIS caller may invoke it (admin lane: always; cert lane: the `GRANT_POWER`\nbit of the device's live access mask)."
},
"title": {
"type": "string",
"description": "Display title. Clients localize known ids and fall back to this for unknown ones."
},
"unavailable_reason": {
"type": [
"string",
"null"
],
"description": "Why it is unavailable, when it is."
}
}
},
"ActionList": {
"type": "object",
"description": "`GET /actions` response.",
"required": [
"actions"
],
"properties": {
"actions": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ActionInfo"
}
}
}
},
"ActiveGame": {
"type": "object",
"description": "One launched game, for the console's running-game card.",
@@ -6141,6 +6310,42 @@
}
}
},
{
"type": "object",
"description": "A host action was invoked (`design/host-actions.md` §3.3) — v1: the `power.*` verbs.\nEmitted on ACCEPT (`outcome: \"accepted\"`), and again if the executor later fails\n(`outcome: \"failed: …\"`) — a succeeded power action ends this process, so \"accepted with\nno failure after it\" is the success signal a hook can act on (\"the host is going down\").",
"required": [
"id",
"outcome",
"kind"
],
"properties": {
"device": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/DeviceRef",
"description": "The invoking paired device, when the cert lane invoked it; absent for the\noperator's console (admin lane)."
}
]
},
"id": {
"type": "string",
"description": "The invoked action id (`power.sleep`, `power.reboot`, `power.shutdown`)."
},
"kind": {
"type": "string",
"enum": [
"action.invoked"
]
},
"outcome": {
"type": "string",
"description": "`accepted`, or `failed: <the executor's error>`."
}
}
},
{
"type": "object",
"required": [
@@ -9142,6 +9347,10 @@
{
"name": "update",
"description": "Host update check: install kind + channel, the last verified release manifest, and whether a newer host exists (admin lane only)"
},
{
"name": "actions",
"description": "Host actions: discover what this host offers (per-caller availability + permission) and invoke one by id — v1: sleep, restart, shut down the machine, gated per device by the Host power grant"
}
]
}
+21 -1
View File
@@ -823,11 +823,25 @@
#define PUNKTFUNK_GRANT_LAUNCH (1 << 5)
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Host power: invoking the `power.*` host actions (sleep/reboot/shutdown) over the mgmt cert
// lane (`design/host-actions.md` §4). Route-gated like `CLIPBOARD`/`MIC`/`LAUNCH` — no
// datagram ever carries it, so [`classify`] is untouched. Machine power ONLY: future
// plugin/custom actions get their own class, never this bit.
#define PUNKTFUNK_GRANT_POWER (1 << 6)
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Every defined grant. Also the value an *absent* mask means — a record from before grants
// existed (or an old host's Welcome that omits the field) is full control, so existing
// pairings keep today's behavior.
#define PUNKTFUNK_GRANT_ALL (((((PUNKTFUNK_GRANT_GAMEPAD | PUNKTFUNK_GRANT_POINTER) | PUNKTFUNK_GRANT_KEYBOARD) | PUNKTFUNK_GRANT_CLIPBOARD) | PUNKTFUNK_GRANT_MIC) | PUNKTFUNK_GRANT_LAUNCH)
#define PUNKTFUNK_GRANT_ALL ((((((PUNKTFUNK_GRANT_GAMEPAD | PUNKTFUNK_GRANT_POINTER) | PUNKTFUNK_GRANT_KEYBOARD) | PUNKTFUNK_GRANT_CLIPBOARD) | PUNKTFUNK_GRANT_MIC) | PUNKTFUNK_GRANT_LAUNCH) | PUNKTFUNK_GRANT_POWER)
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`GRANT_ALL`] as it was before [`GRANT_POWER`] existed (hosts ≤ 0.32.x) — the mask an
// explicitly saved "Full control" wrote back then. See [`normalize_legacy_full`].
#define PUNKTFUNK_GRANT_ALL_PRE_POWER (((((PUNKTFUNK_GRANT_GAMEPAD | PUNKTFUNK_GRANT_POINTER) | PUNKTFUNK_GRANT_KEYBOARD) | PUNKTFUNK_GRANT_CLIPBOARD) | PUNKTFUNK_GRANT_MIC) | PUNKTFUNK_GRANT_LAUNCH)
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -1945,6 +1959,11 @@
// bare desktop they didn't ask for. Connecting *without* a launch request still works.
#define PUNKTFUNK_LAUNCH_NOT_PERMITTED_CLOSE_CODE 106
// A host power action (`power.sleep`/`reboot`/`shutdown`, `design/host-actions.md`) is ending
// every session: the host is going to sleep or shutting down, deliberately — not a crash, not
// the network. Old clients render the generic close; acceptable degrade.
#define PUNKTFUNK_HOST_POWER_CLOSE_CODE 107
// Minimum supported multiplier (renders under native, upscaled on present).
#define PUNKTFUNK_MIN_SCALE 0.5
@@ -1981,6 +2000,7 @@ enum PunktfunkStatus
PUNKTFUNK_STATUS_REJECTED_SETUP_FAILED = -29,
PUNKTFUNK_STATUS_REJECTED_ACCESS_EXPIRED = -30,
PUNKTFUNK_STATUS_REJECTED_LAUNCH_NOT_PERMITTED = -31,
PUNKTFUNK_STATUS_REJECTED_HOST_POWER = -32,
PUNKTFUNK_STATUS_PANIC = -99,
};
#ifndef __cplusplus
@@ -116,12 +116,14 @@ PUNKTFUNK_GAMESCOPE_WSI
PUNKTFUNK_GAMESCOPE_WSI_DISABLE
PUNKTFUNK_GATE_DEPTH
PUNKTFUNK_GRANT_ALL
PUNKTFUNK_GRANT_ALL_PRE_POWER
PUNKTFUNK_GRANT_CLIPBOARD
PUNKTFUNK_GRANT_GAMEPAD
PUNKTFUNK_GRANT_KEYBOARD
PUNKTFUNK_GRANT_LAUNCH
PUNKTFUNK_GRANT_MIC
PUNKTFUNK_GRANT_POINTER
PUNKTFUNK_GRANT_POWER
PUNKTFUNK_GRANT_PRESET_CONTROLLER_ONLY
PUNKTFUNK_GRANT_PRESET_FULL
PUNKTFUNK_GRANT_PRESET_VIEW_ONLY
@@ -145,6 +147,7 @@ PUNKTFUNK_HOST_CAP_GAMEPAD_STATE
PUNKTFUNK_HOST_CAP2_REPEAT_MARK
PUNKTFUNK_HOST_CAP_PEN
PUNKTFUNK_HOST_CAP_TEXT_INPUT
PUNKTFUNK_HOST_POWER_CLOSE_CODE
PUNKTFUNK_HOST_TIMING_MAGIC
PUNKTFUNK_HW_FAULT
PUNKTFUNK_INBOUND_REQ_FLAG
File diff suppressed because one or more lines are too long
+10
View File
@@ -134,6 +134,14 @@
"host_codecs": "Codecs",
"host_ports": "Ports",
"host_uniqueid": "Eindeutige ID",
"host_power_title": "Host-Energie",
"host_power_sleep": "Host schlafen legen",
"host_power_reboot": "Host neu starten",
"host_power_shutdown": "Host herunterfahren",
"host_power_confirm_title": "{action}?",
"host_power_confirm_body": "Alle aktiven Streams werden zuerst beendet. Mit dem Konsolen-Passwort bestätigen.",
"host_power_sent": "{action} — gesendet. Der Host fährt herunter.",
"host_power_working": "Wird gesendet…",
"host_compositors": "Compositoren",
"host_compositors_help": "Backends, auf denen der Host eine virtuelle Ausgabe erzeugen kann. Übergib eine ID an das --compositor-Flag eines Clients; der Host nutzt sie, falls verfügbar, sonst per Auto-Erkennung.",
"compositor_available": "Verfügbar",
@@ -303,6 +311,8 @@
"access_grant_clipboard": "Zwischenablage",
"access_grant_mic": "Mikrofon",
"access_grant_launch": "Spiele starten",
"access_grant_power": "Host-Energie",
"access_grant_power_caption": "Host schlafen legen, neu starten oder herunterfahren. Mit Tastaturzugriff geht das ohnehin über den Desktop.",
"access_expires_label": "Zugriff läuft ab",
"access_expires_keep": "Aktuellen Ablauf beibehalten",
"access_expires_forever": "Nie",
+10
View File
@@ -134,6 +134,14 @@
"host_codecs": "Codecs",
"host_ports": "Ports",
"host_uniqueid": "Unique ID",
"host_power_title": "Host power",
"host_power_sleep": "Sleep host",
"host_power_reboot": "Restart host",
"host_power_shutdown": "Shut down host",
"host_power_confirm_title": "{action}?",
"host_power_confirm_body": "Every active stream ends first. Confirm with your console password.",
"host_power_sent": "{action} — sent. The host is on its way down.",
"host_power_working": "Sending…",
"host_compositors": "Compositors",
"host_compositors_help": "Backends the host can drive a virtual output on. Pass an id to a client's --compositor flag; the host honors it if available, else auto-detects.",
"compositor_available": "Available",
@@ -303,6 +311,8 @@
"access_grant_clipboard": "Clipboard",
"access_grant_mic": "Microphone",
"access_grant_launch": "Launch games",
"access_grant_power": "Host power",
"access_grant_power_caption": "Sleep, restart or shut down the host. Keyboard access can already reach this via the desktop.",
"access_expires_label": "Access expires",
"access_expires_keep": "Keep current expiry",
"access_expires_forever": "Never",
@@ -0,0 +1,22 @@
// POST /api/v1/actions/{id} — invoking a host action (sleep/reboot/shutdown,
// design/host-actions.md) is password-gated like update/apply: a 7-day session cookie alone
// must not be able to power the machine off. The password is verified here (only the BFF knows
// it), stripped, and never forwarded — the upstream request body is EMPTY by design (the id is
// the whole request; no field reaches the host's privileged path).
//
// This specific file wins over the `[...]` catch-all (h3 route specificity), which is what
// keeps the catch-all from proxying the invoke UNgated.
import { defineEventHandler, getRouterParam, readBody } from "h3";
import { confirmPassword } from "../../../../util/confirm";
import { forwardJson } from "../../../../util/forward";
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id") ?? "";
const body = await readBody<{ password?: string }>(event);
confirmPassword(event, body?.password);
return forwardJson(
event,
`/api/v1/actions/${encodeURIComponent(id)}`,
"POST",
);
});
+4
View File
@@ -11,6 +11,10 @@
// - PUT /api/v1/hooks — a hook is a shell command the host runs on its events
// - the library writes that carry `prep`/`launch.kind == "command"` — same primitive, gated
// conditionally in util/libraryConfirm.ts
// - POST /api/v1/actions/{id} — the host power actions (sleep/reboot/shutdown,
// design/host-actions.md §7): ending the machine from
// a 7-day cookie alone is exactly what this gate exists
// to prevent
// - the PAIRING routes — arming a window, approving a knock, submitting a GameStream PIN. A
// paired device injects keyboard and mouse on the host desktop, so admitting one IS code
// execution, and it was the shortest path past this gate (security-review 2026-08-25).
+182
View File
@@ -0,0 +1,182 @@
import { type FC, useState } from "react";
import { useListActions } from "@/api/gen/actions/actions";
import type { ActionInfo } from "@/api/gen/model";
import { QueryState } from "@/components/query-state";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { m } from "@/paraglide/messages";
/** Localized titles for the KNOWN action ids; unknown ids fall back to the server's title
* the contract that lets future host actions appear with no console release. */
const actionTitle = (a: ActionInfo): string => {
switch (a.id) {
case "power.sleep":
return m.host_power_sleep();
case "power.reboot":
return m.host_power_reboot();
case "power.shutdown":
return m.host_power_shutdown();
default:
return a.title;
}
};
/**
* Host power (design/host-actions.md §7, the admin lane's free win also the "no restart
* route" gap the update design named): the discovered host actions as password-confirmed
* buttons. Unavailable actions render disabled with the host's honest reason instead of
* being hidden.
*/
export const PowerSection: FC = () => {
const actions = useListActions();
const [confirming, setConfirming] = useState<ActionInfo | null>(null);
const [sent, setSent] = useState<string | null>(null);
const list = actions.data?.actions ?? [];
return (
<Card>
<CardHeader>
<CardTitle>{m.host_power_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<QueryState
isLoading={actions.isLoading}
error={actions.error}
refetch={actions.refetch}
>
<div className="flex flex-wrap items-center gap-3">
{list.map((a) => (
<Button
key={a.id}
variant={a.danger ? "destructive" : "outline"}
size="sm"
disabled={!a.available}
title={a.unavailable_reason ?? undefined}
onClick={() => {
setSent(null);
setConfirming(a);
}}
>
{actionTitle(a)}
</Button>
))}
</div>
{list
.filter((a) => !a.available && a.unavailable_reason)
.map((a) => (
<p key={a.id} className="text-xs text-muted-foreground">
{actionTitle(a)}: {a.unavailable_reason}
</p>
))}
{sent && <p className="text-sm">{sent}</p>}
</QueryState>
{confirming && (
<ConfirmDialog
action={confirming}
onClose={() => setConfirming(null)}
onAccepted={(a) => {
setConfirming(null);
setSent(m.host_power_sent({ action: actionTitle(a) }));
}}
/>
)}
</CardContent>
</Card>
);
};
/** The password-confirm dialog the update-apply recipe: plain fetch (a 401 here is a wrong
* password, not an expired session), password verified and stripped in the BFF. */
const ConfirmDialog: FC<{
action: ActionInfo;
onClose: () => void;
onAccepted: (action: ActionInfo) => void;
}> = ({ action, onClose, onAccepted }) => {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const submit = async () => {
setBusy(true);
setError(null);
try {
const res = await fetch(
`/api/v1/actions/${encodeURIComponent(action.id)}`,
{
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ password }),
},
);
if (res.status === 202) {
onAccepted(action);
return;
}
const body = (await res.json().catch(() => null)) as {
error?: string;
} | null;
if (res.status === 401) setError(m.update_apply_wrong_password());
else if (res.status === 429) setError(m.update_apply_throttled());
else setError(body?.error ?? `HTTP ${res.status}`);
} catch {
setError(m.common_error());
} finally {
setBusy(false);
}
};
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{m.host_power_confirm_title({ action: actionTitle(action) })}
</DialogTitle>
<DialogDescription>{m.host_power_confirm_body()}</DialogDescription>
</DialogHeader>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
void submit();
}}
>
<div className="space-y-1.5">
<Label htmlFor="host-power-password">
{m.update_apply_password_label()}
</Label>
<Input
id="host-power-password"
type="password"
autoFocus
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button
type="submit"
variant={action.danger ? "destructive" : "default"}
disabled={busy || password.length === 0}
>
{busy ? m.host_power_working() : actionTitle(action)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
+2
View File
@@ -3,6 +3,7 @@ import { useGetHostInfo, useListCompositors } from "@/api/gen/host/host";
import { useLocale } from "@/lib/i18n";
import { ConflictsCard } from "./ConflictsCard";
import { GpuSection } from "./GpuCard";
import { PowerSection } from "./PowerCard";
import { UpdateSection } from "./UpdateCard";
import { HostView } from "./view";
@@ -18,6 +19,7 @@ export const SectionHost: FC = () => {
conflicts={<ConflictsCard />}
gpu={<GpuSection />}
update={<UpdateSection />}
power={<PowerSection />}
/>
);
};
+5 -1
View File
@@ -17,10 +17,12 @@ export const HostView: FC<{
gpu?: ReactNode;
/** The update-check card (a self-contained container — see `UpdateCard.tsx`). */
update?: ReactNode;
/** The host-power actions card (a self-contained container — see `PowerCard.tsx`). */
power?: ReactNode;
/** Warning about other Moonlight-compatible servers on this machine renders nothing when
* there are none (see `ConflictsCard.tsx`). Sits at the top: it explains "nothing can connect". */
conflicts?: ReactNode;
}> = ({ host, compositors, gpu, update, conflicts }) => {
}> = ({ host, compositors, gpu, update, power, conflicts }) => {
const h = host.data;
return (
<Section maxWidth={false}>
@@ -99,6 +101,8 @@ export const HostView: FC<{
{update}
{power}
{gpu}
<Card>
+40 -8
View File
@@ -27,7 +27,19 @@ export const GRANT_KEYBOARD = 0x04;
export const GRANT_CLIPBOARD = 0x08;
export const GRANT_MIC = 0x10;
export const GRANT_LAUNCH = 0x20;
export const GRANT_ALL = 0x3f;
export const GRANT_POWER = 0x40;
export const GRANT_ALL = 0x7f;
/** `GRANT_ALL` before the Power bit existed (hosts ≤ 0.32.x). */
const GRANT_ALL_PRE_POWER = 0x3f;
/**
* The legacy-full read rule (host-actions §4.3): a stored mask that is EXACTLY the pre-power
* full mask was an explicit "Full control" from before Power existed read it as today's
* `GRANT_ALL`, so the chip stays "Full" and the edit sheet's toggles agree with it.
*/
export const normalizeLegacyFull = (mask: number): number =>
mask === GRANT_ALL_PRE_POWER ? GRANT_ALL : mask;
/** The guest preset (D2): controller only, WITHOUT launch — the owner drives what runs. */
export const PRESET_CONTROLLER = GRANT_GAMEPAD;
@@ -39,7 +51,7 @@ export type AccessLevel = "full" | "controller" | "view" | "custom";
/** Preset name for a mask — derived, never stored (design §3.2). */
export const levelOfMask = (mask: number): AccessLevel => {
switch (mask & GRANT_ALL) {
switch (normalizeLegacyFull(mask) & GRANT_ALL) {
case GRANT_ALL:
return "full";
case PRESET_CONTROLLER:
@@ -104,8 +116,9 @@ export const draftFromStored = (
expiresUnix: number | null | undefined,
grantedUnix: number | null | undefined,
): AccessDraft => {
// null grants = a pre-grants record = full control (the API contract).
const mask = grants ?? GRANT_ALL;
// null grants = a pre-grants record = full control (the API contract); an explicit
// pre-power full mask normalizes so the toggles agree with the "Full" chip.
const mask = normalizeLegacyFull(grants ?? GRANT_ALL);
if (expiresUnix == null || grantedUnix == null || expiresUnix <= grantedUnix)
return { grants: mask, expiry: "forever", customHours: 4 };
const secs = expiresUnix - grantedUnix;
@@ -168,14 +181,24 @@ export const AccessChip: FC<{
);
};
/** The six toggles behind Advanced, in bit order. Labels name what the bit covers. */
const GRANT_TOGGLES: { bit: number; label: () => string }[] = [
/** The toggles behind Advanced, in bit order. Labels name what the bit covers. */
const GRANT_TOGGLES: {
bit: number;
label: () => string;
/** One line under the label, for a bit whose reach isn't obvious from its name alone. */
caption?: () => string;
}[] = [
{ bit: GRANT_GAMEPAD, label: () => m.access_grant_gamepad() },
{ bit: GRANT_POINTER, label: () => m.access_grant_pointer() },
{ bit: GRANT_KEYBOARD, label: () => m.access_grant_keyboard() },
{ bit: GRANT_CLIPBOARD, label: () => m.access_grant_clipboard() },
{ bit: GRANT_MIC, label: () => m.access_grant_mic() },
{ bit: GRANT_LAUNCH, label: () => m.access_grant_launch() },
{
bit: GRANT_POWER,
label: () => m.access_grant_power(),
caption: () => m.access_grant_power_caption(),
},
];
/**
@@ -243,7 +266,7 @@ export const AccessControls: FC<{
</button>
{advanced && (
<div className="grid grid-cols-2 gap-x-4 gap-y-2 rounded-md border p-3">
{GRANT_TOGGLES.map(({ bit, label }) => (
{GRANT_TOGGLES.map(({ bit, label, caption }) => (
<Label
key={bit}
className="flex items-center gap-2 text-sm font-normal"
@@ -260,7 +283,16 @@ export const AccessControls: FC<{
})
}
/>
{label()}
{caption ? (
<span className="flex flex-col">
{label()}
<span className="text-xs text-muted-foreground">
{caption()}
</span>
</span>
) : (
label()
)}
</Label>
))}
</div>