The host can be put back to sleep from the couch that woke it (host actions) #436
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -31,6 +31,7 @@
|
||||
"client-settings",
|
||||
"profiles-and-links",
|
||||
"wake-on-lan",
|
||||
"host-power",
|
||||
"access-levels",
|
||||
"automation",
|
||||
"running-as-a-service",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user